Diffinery: git forge, pull requests and Actions-compatible CI on Cloudflare (#1)
Diffinery: git forge, pull requests and Actions-compatible CI on Cloudflare
Canberk Demirci authored · GitHub committed signed2 parents 3555f7d9593c9dcommit 5387f7994df5be8987796dfc384550e6ce7d167aBrowse files
151 files changed+31672−0
Files (151)
- .github/workflows/ci.yml+52−0
- .github/workflows/deploy.yml+66−0
- .gitignore+9−0
- README.md+173−0
- apps/cli/package.json+17−0
- apps/cli/src/api.ts+95−0
- apps/cli/src/bin.ts+16−0
- apps/cli/src/cli.ts+260−0
- apps/cli/src/commands/admin.ts+302−0
- apps/cli/src/commands/auth.ts+139−0
- apps/cli/src/commands/issue.ts+131−0
- apps/cli/src/commands/migrate.ts+296−0
- apps/cli/src/commands/pr.ts+313−0
- apps/cli/src/commands/repo.ts+114−0
- apps/cli/src/commands/run.ts+213−0
- apps/cli/src/commands/runner.ts+169−0
- apps/cli/src/commands/workflow.ts+140−0
- apps/cli/src/config.ts+89−0
- apps/cli/src/git.ts+103−0
- apps/cli/src/github.ts+127−0
- apps/cli/src/io.ts+89−0
- apps/cli/src/main.ts+30−0
- apps/cli/src/output.ts+67−0
- apps/cli/src/version.ts+1−0
- apps/cli/test/config.test.ts+109−0
- apps/cli/test/credential.test.ts+54−0
- apps/cli/test/helpers.ts+203−0
- apps/cli/test/migrate.test.ts+134−0
- apps/cli/test/misc.test.ts+138−0
- apps/cli/test/pr.test.ts+119−0
- apps/cli/test/runner.test.ts+94−0
- apps/cli/test/workflow.test.ts+98−0
- apps/cli/tsconfig.json+1−0
- apps/diffinery/migrations/0001_init.sql+119−0
- apps/diffinery/migrations/0002_passkeys.sql+46−0
- apps/diffinery/package.json+31−0
- apps/diffinery/src/app.ts+206−0
- apps/diffinery/src/auth/principal.ts+113−0
- apps/diffinery/src/auth/sessions.ts+209−0
- apps/diffinery/src/env.ts+31−0
- apps/diffinery/src/git/protocol.ts+228−0
- apps/diffinery/src/git/secret-scan.ts+30−0
- apps/diffinery/src/http/api.ts+717−0
- apps/diffinery/src/http/common.ts+35−0
- apps/diffinery/src/http/git.ts+92−0
- apps/diffinery/src/http/runner.ts+134−0
- apps/diffinery/src/index.ts+17−0
- apps/diffinery/src/lib/crypto.ts+34−0
- apps/diffinery/src/lib/directory.ts+403−0
- apps/diffinery/src/lib/jwt.ts+50−0
- apps/diffinery/src/lib/util.ts+80−0
- apps/diffinery/src/mcp/server.ts+457−0
- apps/diffinery/src/repo/RepoDO.ts+732−0
- apps/diffinery/src/repo/actions.ts+1048−0
- apps/diffinery/src/repo/collab.ts+665−0
- apps/diffinery/src/repo/objects.ts+97−0
- apps/diffinery/src/repo/schema.ts+66−0
- apps/diffinery/src/repo/types.ts+51−0
- apps/diffinery/src/runner/containers.ts+70−0
- apps/diffinery/src/runner/workflow.ts+48−0
- apps/diffinery/src/web/account.tsx+1760−0
- apps/diffinery/src/web/actions.tsx+579−0
- apps/diffinery/src/web/assets.ts+391−0
- apps/diffinery/src/web/auth.tsx+424−0
- apps/diffinery/src/web/code.tsx+1107−0
- apps/diffinery/src/web/collab.tsx+1198−0
- apps/diffinery/src/web/csrf.ts+74−0
- apps/diffinery/src/web/markdown.ts+544−0
- apps/diffinery/src/web/repo-settings.tsx+744−0
- apps/diffinery/src/web/routes.tsx+37−0
- apps/diffinery/src/web/ui.tsx+761−0
- apps/diffinery/test/e2e/e2e.test.mjs+342−0
- apps/diffinery/test/e2e/entry.ts+10−0
- apps/diffinery/test/e2e/passkeys.test.mjs+236−0
- apps/diffinery/tsconfig.json+11−0
- apps/diffinery/wrangler.jsonc+78−0
- containers/runner/.dockerignore+2−0
- containers/runner/Dockerfile+51−0
- containers/runner/package.json+13−0
- containers/runner/src/agent.ts+65−0
- containers/runner/src/api.ts+222−0
- containers/runner/src/builtins/artifacts.ts+96−0
- containers/runner/src/builtins/cache.ts+130−0
- containers/runner/src/builtins/checkout.ts+207−0
- containers/runner/src/builtins/index.ts+114−0
- containers/runner/src/builtins/setup-go.ts+73−0
- containers/runner/src/builtins/setup-node.ts+125−0
- containers/runner/src/builtins/setup-python.ts+101−0
- containers/runner/src/builtins/tools.ts+148−0
- containers/runner/src/commands.ts+89−0
- containers/runner/src/job.ts+678−0
- containers/runner/src/log.ts+135−0
- containers/runner/src/main.ts+106−0
- containers/runner/src/mask.ts+49−0
- containers/runner/src/sandbox.ts+217−0
- containers/runner/src/steps/actions.ts+333−0
- containers/runner/src/steps/run.ts+114−0
- containers/runner/src/util.ts+440−0
- containers/runner/src/yaml.ts+393−0
- containers/runner/test/helpers.ts+157−0
- containers/runner/test/job.test.ts+610−0
- containers/runner/test/sandbox.test.ts+91−0
- containers/runner/test/unit.test.ts+230−0
- containers/runner/tsconfig.json+1−0
- docs/PREFERENCES.md+24−0
- package.json+22−0
- packages/git-core/package.json+10−0
- packages/git-core/src/bytes.ts+79−0
- packages/git-core/src/delta.ts+47−0
- packages/git-core/src/diff.ts+227−0
- packages/git-core/src/index.ts+10−0
- packages/git-core/src/memory-store.ts+18−0
- packages/git-core/src/objects.ts+165−0
- packages/git-core/src/pack.ts+156−0
- packages/git-core/src/pktline.ts+55−0
- packages/git-core/src/sha1.ts+15−0
- packages/git-core/src/store.ts+373−0
- packages/git-core/src/zlib.ts+33−0
- packages/git-core/test/pack.test.ts+139−0
- packages/git-core/tsconfig.json+1−0
- packages/shared/package.json+9−0
- packages/shared/src/events.ts+19−0
- packages/shared/src/index.ts+2−0
- packages/shared/src/runner.ts+109−0
- packages/shared/tsconfig.json+1−0
- packages/workflow-yaml/package.json+10−0
- packages/workflow-yaml/src/cron.ts+171−0
- packages/workflow-yaml/src/expr/coerce.ts+143−0
- packages/workflow-yaml/src/expr/evaluate.ts+292−0
- packages/workflow-yaml/src/expr/lexer.ts+143−0
- packages/workflow-yaml/src/expr/parser.ts+216−0
- packages/workflow-yaml/src/expr/template.ts+68−0
- packages/workflow-yaml/src/filters.ts+194−0
- packages/workflow-yaml/src/graph.ts+73−0
- packages/workflow-yaml/src/index.ts+50−0
- packages/workflow-yaml/src/matrix.ts+120−0
- packages/workflow-yaml/src/model.ts+174−0
- packages/workflow-yaml/src/parse.ts+736−0
- packages/workflow-yaml/test/cron.test.ts+82−0
- packages/workflow-yaml/test/expr.test.ts+356−0
- packages/workflow-yaml/test/filters.test.ts+230−0
- packages/workflow-yaml/test/matrix.test.ts+149−0
- packages/workflow-yaml/test/parse.test.ts+600−0
- packages/workflow-yaml/tsconfig.json+1−0
- pnpm-lock.yaml+3067−0
- pnpm-workspace.yaml+4−0
- scripts/bootstrap.sh+126−0
- scripts/ensure-secrets.sh+29−0
- scripts/render-config.mjs+43−0
- tsconfig.base.json+17−0
- turbo.json+8−0
added.github/workflows/ci.yml+52−0View file
| @@ -1,0 +1,52 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + | |
| 8 | +permissions: | |
| 9 | + contents: read | |
| 10 | + | |
| 11 | +concurrency: | |
| 12 | + group: ci-${{ github.ref }} | |
| 13 | + cancel-in-progress: true | |
| 14 | + | |
| 15 | +jobs: | |
| 16 | + check: | |
| 17 | + name: Typecheck and test | |
| 18 | + runs-on: ubuntu-latest | |
| 19 | + timeout-minutes: 20 | |
| 20 | + steps: | |
| 21 | + - uses: actions/checkout@v4 | |
| 22 | + - uses: pnpm/action-setup@v4 | |
| 23 | + - uses: actions/setup-node@v4 | |
| 24 | + with: | |
| 25 | + node-version: 22 | |
| 26 | + cache: pnpm | |
| 27 | + - run: pnpm install --frozen-lockfile | |
| 28 | + - name: Build | |
| 29 | + run: pnpm -r --if-present build | |
| 30 | + - name: Typecheck | |
| 31 | + run: pnpm -r --if-present typecheck | |
| 32 | + - name: Unit tests | |
| 33 | + run: pnpm -r --if-present test | |
| 34 | + - name: Bundle the Worker (dry run) | |
| 35 | + working-directory: apps/diffinery | |
| 36 | + run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist | |
| 37 | + | |
| 38 | + e2e: | |
| 39 | + name: End-to-end (git, PRs, Actions, passkeys) | |
| 40 | + runs-on: ubuntu-latest | |
| 41 | + timeout-minutes: 20 | |
| 42 | + steps: | |
| 43 | + - uses: actions/checkout@v4 | |
| 44 | + - uses: pnpm/action-setup@v4 | |
| 45 | + - uses: actions/setup-node@v4 | |
| 46 | + with: | |
| 47 | + node-version: 22 | |
| 48 | + cache: pnpm | |
| 49 | + - run: pnpm install --frozen-lockfile | |
| 50 | + - run: pnpm -r --if-present build | |
| 51 | + - run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium | |
| 52 | + - run: pnpm --filter @diffinery/app e2e |
added.github/workflows/deploy.yml+66−0View file
| @@ -1,0 +1,66 @@ | ||
| 1 | +name: Deploy | |
| 2 | + | |
| 3 | +# Deploys diffinery.com from main. Uses the repository secrets | |
| 4 | +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID; optional repository variable | |
| 5 | +# ACCESS_TEAM_DOMAIN (e.g. codician.cloudflareaccess.com). | |
| 6 | +on: | |
| 7 | + push: | |
| 8 | + branches: [main] | |
| 9 | + workflow_dispatch: | |
| 10 | + | |
| 11 | +permissions: | |
| 12 | + contents: read | |
| 13 | + | |
| 14 | +concurrency: | |
| 15 | + group: deploy-production | |
| 16 | + cancel-in-progress: false | |
| 17 | + | |
| 18 | +jobs: | |
| 19 | + deploy: | |
| 20 | + name: Deploy to Cloudflare | |
| 21 | + runs-on: ubuntu-latest | |
| 22 | + timeout-minutes: 45 | |
| 23 | + environment: | |
| 24 | + name: production | |
| 25 | + url: https://diffinery.com | |
| 26 | + env: | |
| 27 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 28 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 29 | + ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} | |
| 30 | + steps: | |
| 31 | + - uses: actions/checkout@v4 | |
| 32 | + - uses: pnpm/action-setup@v4 | |
| 33 | + - uses: actions/setup-node@v4 | |
| 34 | + with: | |
| 35 | + node-version: 22 | |
| 36 | + cache: pnpm | |
| 37 | + - run: pnpm install --frozen-lockfile | |
| 38 | + - name: Build runner agent | |
| 39 | + run: pnpm --filter @diffinery/runner build | |
| 40 | + - name: Typecheck and test | |
| 41 | + run: pnpm -r --if-present typecheck && pnpm -r --if-present test | |
| 42 | + | |
| 43 | + - name: Create or update Cloudflare resources | |
| 44 | + id: bootstrap | |
| 45 | + run: scripts/bootstrap.sh | |
| 46 | + | |
| 47 | + - name: Deploy Worker and runner image | |
| 48 | + working-directory: apps/diffinery | |
| 49 | + run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc | |
| 50 | + | |
| 51 | + - name: Generate Worker secrets (first deploy only) | |
| 52 | + run: scripts/ensure-secrets.sh | |
| 53 | + | |
| 54 | + - name: Finish Access setup on first deploy | |
| 55 | + if: steps.bootstrap.outputs.access_team == '' | |
| 56 | + run: | | |
| 57 | + scripts/bootstrap.sh | |
| 58 | + cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none | |
| 59 | + | |
| 60 | + - name: Smoke test | |
| 61 | + run: | | |
| 62 | + for i in 1 2 3 4 5 6; do | |
| 63 | + code=$(curl -s -o /dev/null -w '%{http_code}' https://diffinery.com/healthz) && [ "$code" = 200 ] && exit 0 | |
| 64 | + sleep 10 | |
| 65 | + done | |
| 66 | + echo "::error::https://diffinery.com/healthz did not return 200 (last: $code)"; exit 1 |
added.gitignore+9−0View file
| @@ -1,0 +1,9 @@ | ||
| 1 | +node_modules/ | |
| 2 | +dist/ | |
| 3 | +.wrangler/ | |
| 4 | +.turbo/ | |
| 5 | +.dev.vars | |
| 6 | +*.log | |
| 7 | +coverage/ | |
| 8 | +.DS_Store | |
| 9 | +wrangler.deploy.jsonc |
addedREADME.md+173−0View file
| @@ -1,0 +1,173 @@ | ||
| 1 | +# Diffinery | |
| 2 | + | |
| 3 | +A git forge for teams of people and AI agents, running entirely on Cloudflare. | |
| 4 | +Git hosting, pull requests and reviews, issues, GitHub-Actions-compatible CI, | |
| 5 | +an MCP server for agents, and sandboxes, with no servers to run. | |
| 6 | + | |
| 7 | +- **Drop-in for GitHub workflows.** `.github/workflows/*.yml` runs as-is: same | |
| 8 | + triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets, | |
| 9 | + OIDC, `actions/checkout`, `setup-node/python/go`, and remote JavaScript and | |
| 10 | + composite actions. | |
| 11 | +- **Built for agents.** Agents are first-class accounts owned by a person or | |
| 12 | + team, with their own tokens and limits. They can't push or merge to protected | |
| 13 | + branches, and their pull requests always need a human approval. Every commit | |
| 14 | + they make is stamped with who they act for. They work through MCP | |
| 15 | + (`/mcp`) or the REST API, and can start sandboxes to build and test. | |
| 16 | +- **Secure by default.** People sign in with passkeys, with no passwords to | |
| 17 | + leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for | |
| 18 | + leaked credentials. Secrets use envelope encryption, and every sensitive | |
| 19 | + action goes to an append-only audit log. | |
| 20 | + | |
| 21 | +## How it fits on Cloudflare | |
| 22 | + | |
| 23 | +| Piece | Cloudflare product | | |
| 24 | +|---|---| | |
| 25 | +| HTTP, git smart HTTP, REST API, MCP, web UI | Workers (Hono) | | |
| 26 | +| One repository: refs, objects, PRs, issues, reviews, checks, runs | Durable Object with SQLite (`RepoDO`), one per repository | | |
| 27 | +| Large objects, Git LFS, logs, caches, artifacts, event archive | R2 | | |
| 28 | +| Accounts, orgs, tokens, invites, notifications, audit log | D1 | | |
| 29 | +| Webhooks, notifications, audit fan-out | Queues (with a dead-letter queue) | | |
| 30 | +| Workflow runs (durable, survive restarts, 6 h job timeout) | Workflows | | |
| 31 | +| Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in `containers/runner`) | | |
| 32 | +| Scheduled workflows (`on: schedule`) | Cron Triggers | | |
| 33 | +| Live run logs and PR updates | Durable Object WebSockets (hibernation) | | |
| 34 | +| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only `/auth/access`, the email recovery path | | |
| 35 | + | |
| 36 | +``` | |
| 37 | +apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test | |
| 38 | +apps/cli the `diffinery` command-line client | |
| 39 | +containers/runner runner agent + sandbox server, and the container image | |
| 40 | +packages/git-core git objects, packfiles, deltas, diff/merge (pure TypeScript) | |
| 41 | +packages/workflow-yaml GitHub Actions workflow parser, expressions, matrix, cron | |
| 42 | +packages/shared types shared by the Worker and the runner | |
| 43 | +scripts bootstrap.sh, ensure-secrets.sh, render-config.mjs | |
| 44 | +``` | |
| 45 | + | |
| 46 | +## Deploying | |
| 47 | + | |
| 48 | +Deploys run from GitHub Actions on every push to `main` (`.github/workflows/deploy.yml`). | |
| 49 | +The workflow needs two repository secrets: | |
| 50 | + | |
| 51 | +- `CLOUDFLARE_API_TOKEN`: account permissions Workers Scripts, D1, R2, | |
| 52 | + Queues, Workflows, Containers and Access: Apps and Policies (all Edit), and | |
| 53 | + zone permission Workers Routes Edit on `diffinery.com`. | |
| 54 | +- `CLOUDFLARE_ACCOUNT_ID` | |
| 55 | + | |
| 56 | +On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1 | |
| 57 | +database, R2 bucket, queues and one Access application that covers only | |
| 58 | +`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy` | |
| 59 | +builds and pushes the runner image. On the first deploy, `ensure-secrets.sh` | |
| 60 | +generates `MASTER_KEY` and the OIDC signing key inside the job, so neither | |
| 61 | +value is ever printed. | |
| 62 | + | |
| 63 | +If the token can't read your Zero Trust organization, set a repository | |
| 64 | +**variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`). | |
| 65 | + | |
| 66 | +Email recovery uses Access's email one-time PIN, which works without any setup. | |
| 67 | +You can also add GitHub as a login method under Zero Trust → Settings → Authentication. | |
| 68 | + | |
| 69 | +## Signing in and inviting people | |
| 70 | + | |
| 71 | +Everyone signs in with a passkey. There are no passwords. | |
| 72 | + | |
| 73 | +1. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in | |
| 74 | + `apps/diffinery/wrangler.jsonc`) become site admins. Open | |
| 75 | + `https://diffinery.com/auth/access`, confirm your email with the one-time | |
| 76 | + code, and add a passkey on the page that follows. | |
| 77 | +2. **Inviting someone.** An admin or org owner creates an invite from | |
| 78 | + *Settings → Site administration*, an organization's *People* tab, or | |
| 79 | + `diffinery invite friend@example.com --org codician`. The person opens the | |
| 80 | + link, picks a username and creates a passkey, and they're in. | |
| 81 | +3. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in | |
| 82 | + *Settings → Site administration* and sends them the one-time link, which | |
| 83 | + works for 24 hours. People can also recover through `/auth/access` by email. | |
| 84 | +4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or | |
| 85 | + keep one in a password manager that syncs. The same page signs out other | |
| 86 | + browsers. | |
| 87 | +5. Repository and org roles (read, triage, write, maintain, admin) decide what | |
| 88 | + each person can do. | |
| 89 | + | |
| 90 | +Nobody needs a Cloudflare account. Git and the CLI use a personal token from | |
| 91 | +*Settings → Tokens*. | |
| 92 | + | |
| 93 | +```sh | |
| 94 | +git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token | |
| 95 | +diffinery login && diffinery auth setup-git # or let the CLI handle credentials | |
| 96 | +``` | |
| 97 | + | |
| 98 | +## Agents | |
| 99 | + | |
| 100 | +Create an agent under *Settings → Agents* or with `diffinery agent create reviewer-bot --org codician`. | |
| 101 | +Then point any MCP client at it: | |
| 102 | + | |
| 103 | +```json | |
| 104 | +{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_…" } } } } | |
| 105 | +``` | |
| 106 | + | |
| 107 | +Tools include `get_file`, `list_tree`, `commit_files` (no git client needed), | |
| 108 | +`create_branch`, `create_pull_request` (with an `intent` for reviewers), | |
| 109 | +`review_pull_request`, `merge_pull_request` (policy-guarded), issues, | |
| 110 | +`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, and | |
| 111 | +`sandbox_create`/`sandbox_exec` for running builds and tests. | |
| 112 | + | |
| 113 | +Agent guardrails apply everywhere: git, REST, MCP and the web UI. | |
| 114 | + | |
| 115 | +- An agent's role is capped at write, and never exceeds its owner's role. | |
| 116 | +- Agents can't push to the default branch or protected branches, and can't | |
| 117 | + merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`. | |
| 118 | +- A pull request opened by an agent needs at least one human approval, and | |
| 119 | + approvals from agents don't count toward it. | |
| 120 | +- Agents can't change branch protection or read or set secrets. | |
| 121 | + | |
| 122 | +## Workflows | |
| 123 | + | |
| 124 | +Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`). | |
| 125 | +Supported triggers are `push`, `pull_request`, `workflow_dispatch` (with | |
| 126 | +inputs) and `schedule`, with branch, tag and path filters. Supported workflow | |
| 127 | +features: | |
| 128 | + | |
| 129 | +- `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and | |
| 130 | + `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and | |
| 131 | + `max-parallel`, `concurrency` with `cancel-in-progress`, and `timeout-minutes`. | |
| 132 | +- `continue-on-error`, `defaults.run`, `env` at every level, `$GITHUB_OUTPUT`, | |
| 133 | + `$GITHUB_ENV`, `$GITHUB_PATH`, `$GITHUB_STEP_SUMMARY`, and masking of | |
| 134 | + secrets and `::add-mask::` values. | |
| 135 | +- `secrets.*`, `vars.*`, `GITHUB_TOKEN` (a per-job token), and | |
| 136 | + `permissions: id-token: write` for OIDC (issuer `https://diffinery.com`). | |
| 137 | +- `runs-on: ubuntu-latest` (and any `ubuntu-*` or `linux` label) runs in a | |
| 138 | + Cloudflare Container. `runs-on: [self-hosted, …]` jobs wait for a runner you | |
| 139 | + start with `diffinery runner -R owner/repo --labels gpu,macos`. Use that for | |
| 140 | + macOS, GPUs or private networks. | |
| 141 | + | |
| 142 | +**Not supported yet:** reusable workflows (`jobs.<id>.uses`), job | |
| 143 | +`container:` and `services:`, Docker container actions, `actions/github-script`, | |
| 144 | +Windows and macOS hosted runners, `pull_request_target`, `merge_group`, and | |
| 145 | +`deepen-not` / partial clone filters. Pull request runs use the head commit | |
| 146 | +rather than a test merge commit. A job that uses an unsupported feature fails | |
| 147 | +with a clear message instead of misbehaving. | |
| 148 | + | |
| 149 | +## Developing | |
| 150 | + | |
| 151 | +```sh | |
| 152 | +pnpm install | |
| 153 | +pnpm -r build && pnpm -r typecheck && pnpm -r test | |
| 154 | +pnpm --filter @diffinery/app db:migrate:local | |
| 155 | +pnpm dev # http://localhost:8787, signed in as dev@diffinery.local | |
| 156 | +pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium | |
| 157 | +``` | |
| 158 | + | |
| 159 | +The end-to-end test does the following: | |
| 160 | + | |
| 161 | +1. Pushes a repository with real `git`. | |
| 162 | +2. Runs its CI through the actual runner agent. | |
| 163 | +3. Checks secret scanning and branch protection. | |
| 164 | +4. Has an agent open a pull request that needs a human approval and a green | |
| 165 | + check, then merges it. | |
| 166 | +5. Clones shallow, then deepens the clone. | |
| 167 | +6. Drives the MCP server. | |
| 168 | +7. In Chromium with a virtual authenticator, joins from an invite with a new | |
| 169 | + passkey, adds and removes passkeys, signs out and in, and uses an admin | |
| 170 | + sign-in link. | |
| 171 | + | |
| 172 | +Team preferences for product and design decisions are in | |
| 173 | +[docs/PREFERENCES.md](docs/PREFERENCES.md). |
addedapps/cli/package.json+17−0View file
| @@ -1,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@diffinery/cli", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "description": "Command-line client for Diffinery", | |
| 6 | + "type": "module", | |
| 7 | + "bin": { "diffinery": "dist/diffinery.mjs" }, | |
| 8 | + "files": ["dist"], | |
| 9 | + "engines": { "node": ">=22" }, | |
| 10 | + "scripts": { | |
| 11 | + "build": "esbuild src/bin.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 12 | + "typecheck": "tsc --noEmit", | |
| 13 | + "test": "vitest run" | |
| 14 | + }, | |
| 15 | + "dependencies": { "@diffinery/workflow-yaml": "workspace:*" }, | |
| 16 | + "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" } | |
| 17 | +} |
addedapps/cli/src/api.ts+95−0View file
| @@ -1,0 +1,95 @@ | ||
| 1 | +import { CliError } from "./io.js"; | |
| 2 | +import { VERSION } from "./version.js"; | |
| 3 | + | |
| 4 | +export class ApiError extends CliError { | |
| 5 | + constructor( | |
| 6 | + readonly status: number, | |
| 7 | + message: string, | |
| 8 | + readonly body: unknown, | |
| 9 | + ) { | |
| 10 | + super(message); | |
| 11 | + } | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface RequestOptions { | |
| 15 | + body?: unknown; | |
| 16 | + query?: Record<string, string | number | boolean | undefined | null>; | |
| 17 | + /** Return the response text instead of parsing JSON. */ | |
| 18 | + text?: boolean; | |
| 19 | + /** Treat these statuses as success (e.g. 404 when probing existence). */ | |
| 20 | + allow?: number[]; | |
| 21 | + headers?: Record<string, string>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface ApiResponse<T> { | |
| 25 | + status: number; | |
| 26 | + data: T; | |
| 27 | + headers: Headers; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */ | |
| 31 | +export class Api { | |
| 32 | + constructor( | |
| 33 | + readonly baseUrl: string, | |
| 34 | + readonly token: string | null, | |
| 35 | + ) {} | |
| 36 | + | |
| 37 | + url(path: string, query?: RequestOptions["query"]): string { | |
| 38 | + const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; | |
| 39 | + const u = new URL(p); | |
| 40 | + for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v)); | |
| 41 | + return u.toString(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> { | |
| 45 | + const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers }; | |
| 46 | + if (this.token) headers.authorization = `Bearer ${this.token}`; | |
| 47 | + let body: string | undefined; | |
| 48 | + if (o.body !== undefined) { | |
| 49 | + body = typeof o.body === "string" ? o.body : JSON.stringify(o.body); | |
| 50 | + headers["content-type"] ??= "application/json"; | |
| 51 | + } | |
| 52 | + let res: Response; | |
| 53 | + const url = this.url(path, o.query); | |
| 54 | + try { | |
| 55 | + res = await fetch(url, { method, headers, body }); | |
| 56 | + } catch (e) { | |
| 57 | + const cause = (e as { cause?: { code?: string; message?: string } }).cause; | |
| 58 | + throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`); | |
| 59 | + } | |
| 60 | + const text = await res.text(); | |
| 61 | + let data: unknown = text; | |
| 62 | + if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) { | |
| 63 | + try { | |
| 64 | + data = JSON.parse(text); | |
| 65 | + } catch { | |
| 66 | + /* leave as text */ | |
| 67 | + } | |
| 68 | + } else if (!o.text && !text) data = null; | |
| 69 | + if (!res.ok && !o.allow?.includes(res.status)) { | |
| 70 | + const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText; | |
| 71 | + let hint = ""; | |
| 72 | + if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)"; | |
| 73 | + throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data); | |
| 74 | + } | |
| 75 | + return { status: res.status, data: data as T, headers: res.headers }; | |
| 76 | + } | |
| 77 | + | |
| 78 | + async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> { | |
| 79 | + return (await this.raw<T>("GET", path, { query })).data; | |
| 80 | + } | |
| 81 | + async post<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 82 | + return (await this.raw<T>("POST", path, { body: body ?? {} })).data; | |
| 83 | + } | |
| 84 | + async put<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 85 | + return (await this.raw<T>("PUT", path, { body: body ?? {} })).data; | |
| 86 | + } | |
| 87 | + async patch<T = unknown>(path: string, body: unknown): Promise<T> { | |
| 88 | + return (await this.raw<T>("PATCH", path, { body })).data; | |
| 89 | + } | |
| 90 | + async delete<T = unknown>(path: string): Promise<T> { | |
| 91 | + return (await this.raw<T>("DELETE", path)).data; | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +export const enc = encodeURIComponent; |
addedapps/cli/src/bin.ts+16−0View file
| @@ -1,0 +1,16 @@ | ||
| 1 | +import { main } from "./main.js"; | |
| 2 | + | |
| 3 | +// EPIPE when piping into `head` and similar should end quietly. | |
| 4 | +process.stdout.on("error", (e: NodeJS.ErrnoException) => { | |
| 5 | + if (e.code === "EPIPE") process.exit(0); | |
| 6 | +}); | |
| 7 | + | |
| 8 | +main(process.argv.slice(2)).then( | |
| 9 | + (code) => { | |
| 10 | + process.exitCode = code; | |
| 11 | + }, | |
| 12 | + (e: unknown) => { | |
| 13 | + process.stderr.write(`error: ${String(e)}\n`); | |
| 14 | + process.exitCode = 1; | |
| 15 | + }, | |
| 16 | +); |
addedapps/cli/src/cli.ts+260−0View file
| @@ -1,0 +1,260 @@ | ||
| 1 | +import { parseArgs } from "node:util"; | |
| 2 | +import { Api } from "./api.js"; | |
| 3 | +import { resolveConfig, type ResolvedConfig } from "./config.js"; | |
| 4 | +import { gitCapture, parseRemote } from "./git.js"; | |
| 5 | +import { CliError, ExitCode, type Io } from "./io.js"; | |
| 6 | +import { colors, type Colors } from "./output.js"; | |
| 7 | +import { VERSION } from "./version.js"; | |
| 8 | + | |
| 9 | +export interface OptSpec { | |
| 10 | + type: "string" | "boolean"; | |
| 11 | + short?: string; | |
| 12 | + multiple?: boolean; | |
| 13 | + desc: string; | |
| 14 | + /** Placeholder shown in help, e.g. "NAME". */ | |
| 15 | + arg?: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export type Values = Record<string, string | boolean | string[] | undefined>; | |
| 19 | + | |
| 20 | +export interface Command { | |
| 21 | + /** Full command path, e.g. "pr create". */ | |
| 22 | + name: string; | |
| 23 | + summary: string; | |
| 24 | + /** Positional part of the usage line, e.g. "<number>". */ | |
| 25 | + args?: string; | |
| 26 | + description?: string; | |
| 27 | + options?: Record<string, OptSpec>; | |
| 28 | + /** Accepts -R/--repo OWNER/NAME (defaults to the git origin). */ | |
| 29 | + repo?: boolean; | |
| 30 | + /** Accepts --json. */ | |
| 31 | + json?: boolean; | |
| 32 | + minArgs?: number; | |
| 33 | + maxArgs?: number; | |
| 34 | + run(ctx: Ctx, o: Values, args: string[]): Promise<number | void>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface RepoRef { | |
| 38 | + owner: string; | |
| 39 | + name: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export class Ctx { | |
| 43 | + readonly c: Colors; | |
| 44 | + private cfg?: Promise<ResolvedConfig>; | |
| 45 | + constructor( | |
| 46 | + readonly io: Io, | |
| 47 | + readonly opts: Values, | |
| 48 | + ) { | |
| 49 | + this.c = colors(io); | |
| 50 | + } | |
| 51 | + | |
| 52 | + get json(): boolean { | |
| 53 | + return this.opts.json === true; | |
| 54 | + } | |
| 55 | + | |
| 56 | + config(): Promise<ResolvedConfig> { | |
| 57 | + return (this.cfg ??= resolveConfig(this.io)); | |
| 58 | + } | |
| 59 | + | |
| 60 | + async api(): Promise<Api> { | |
| 61 | + const cfg = await this.config(); | |
| 62 | + return new Api(cfg.url, cfg.token); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** Requires a token (for commands that make no sense anonymously). */ | |
| 66 | + async authedApi(): Promise<Api> { | |
| 67 | + const cfg = await this.config(); | |
| 68 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 69 | + return new Api(cfg.url, cfg.token); | |
| 70 | + } | |
| 71 | + | |
| 72 | + out(s: string): void { | |
| 73 | + this.io.stdout.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 74 | + } | |
| 75 | + | |
| 76 | + err(s: string): void { | |
| 77 | + this.io.stderr.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** -R owner/name, else the current git repository's origin when it points at the configured host. */ | |
| 81 | + async repo(flag?: string): Promise<RepoRef> { | |
| 82 | + const v = flag ?? (typeof this.opts.repo === "string" ? this.opts.repo : undefined); | |
| 83 | + if (v) return parseRepoArg(v); | |
| 84 | + const cfg = await this.config(); | |
| 85 | + const host = new URL(cfg.url).host; | |
| 86 | + const r = await gitCapture(this.io, ["remote", "get-url", "origin"]); | |
| 87 | + if (r.code === 0) { | |
| 88 | + const parsed = parseRemote(r.stdout, host); | |
| 89 | + if (parsed) return parsed; | |
| 90 | + throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`); | |
| 91 | + } | |
| 92 | + throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +export function parseRepoArg(v: string): RepoRef { | |
| 97 | + const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, ""); | |
| 98 | + const [owner, name, extra] = s.split("/"); | |
| 99 | + if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2); | |
| 100 | + return { owner, name }; | |
| 101 | +} | |
| 102 | + | |
| 103 | +export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`; | |
| 104 | + | |
| 105 | +// ---------------------------------------------------------------- help | |
| 106 | + | |
| 107 | +const COMMON: Record<string, OptSpec> = { | |
| 108 | + repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" }, | |
| 109 | + json: { type: "boolean", desc: "Print raw JSON" }, | |
| 110 | + help: { type: "boolean", short: "h", desc: "Show help" }, | |
| 111 | +}; | |
| 112 | + | |
| 113 | +export function optionsFor(cmd: Command): Record<string, OptSpec> { | |
| 114 | + return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! }; | |
| 115 | +} | |
| 116 | + | |
| 117 | +export function commandHelp(cmd: Command): string { | |
| 118 | + const opts = optionsFor(cmd); | |
| 119 | + const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`]; | |
| 120 | + if (cmd.description) lines.push("", cmd.description.trim()); | |
| 121 | + const rows = Object.entries(opts).map(([k, o]) => { | |
| 122 | + const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`; | |
| 123 | + return [flag, o.desc + (o.multiple ? " (repeatable)" : "")]; | |
| 124 | + }); | |
| 125 | + const w = Math.max(...rows.map((r) => r[0]!.length)); | |
| 126 | + lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`)); | |
| 127 | + return lines.join("\n") + "\n"; | |
| 128 | +} | |
| 129 | + | |
| 130 | +export function groupHelp(group: string, summary: string, cmds: Command[]): string { | |
| 131 | + const w = Math.max(...cmds.map((c) => c.name.length)); | |
| 132 | + return [summary, "", `Usage: diffinery ${group} <command> [options]`, "", "Commands:", ...cmds.map((c) => ` ${c.name.padEnd(w)} ${c.summary}`), "", `Run 'diffinery ${group} <command> --help' for details.`].join("\n") + "\n"; | |
| 133 | +} | |
| 134 | + | |
| 135 | +// ---------------------------------------------------------------- dispatch | |
| 136 | + | |
| 137 | +export interface Group { | |
| 138 | + name: string; | |
| 139 | + summary: string; | |
| 140 | + commands: Command[]; | |
| 141 | +} | |
| 142 | + | |
| 143 | +export interface Registry { | |
| 144 | + groups: Group[]; | |
| 145 | + commands: Command[]; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export function topHelp(reg: Registry): string { | |
| 149 | + const rows: Array<[string, string]> = [...reg.groups.map((g) => [g.name, g.summary] as [string, string]), ...reg.commands.map((c) => [c.name, c.summary] as [string, string])]; | |
| 150 | + rows.sort((a, b) => a[0].localeCompare(b[0])); | |
| 151 | + const w = Math.max(...rows.map((r) => r[0].length)); | |
| 152 | + return [ | |
| 153 | + `diffinery ${VERSION}: command-line client for Diffinery`, | |
| 154 | + "", | |
| 155 | + "Usage: diffinery <command> [subcommand] [options]", | |
| 156 | + "", | |
| 157 | + "Commands:", | |
| 158 | + ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`), | |
| 159 | + "", | |
| 160 | + "Global:", | |
| 161 | + " --help, -h Show help for any command", | |
| 162 | + " --version Print the version", | |
| 163 | + "", | |
| 164 | + "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);", | |
| 165 | + "DIFFINERY_URL and DIFFINERY_TOKEN override it.", | |
| 166 | + ].join("\n") + "\n"; | |
| 167 | +} | |
| 168 | + | |
| 169 | +export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> { | |
| 170 | + const [first, ...rest] = argv; | |
| 171 | + if (!first || first === "--help" || first === "-h" || first === "help") { | |
| 172 | + if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io); | |
| 173 | + io.stdout.write(topHelp(reg)); | |
| 174 | + return first ? 0 : argv.length ? 0 : 2; | |
| 175 | + } | |
| 176 | + if (first === "--version" || first === "-v" || first === "version") { | |
| 177 | + io.stdout.write(`diffinery ${VERSION}\n`); | |
| 178 | + return 0; | |
| 179 | + } | |
| 180 | + const group = reg.groups.find((g) => g.name === first); | |
| 181 | + if (group) { | |
| 182 | + const [sub, ...args] = rest; | |
| 183 | + if (!sub || sub === "--help" || sub === "-h") { | |
| 184 | + io.stdout.write(groupHelp(group.name, group.summary, group.commands)); | |
| 185 | + return sub ? 0 : 2; | |
| 186 | + } | |
| 187 | + const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`); | |
| 188 | + if (!cmd) { | |
| 189 | + io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`); | |
| 190 | + return 2; | |
| 191 | + } | |
| 192 | + return runCommand(cmd, args, io); | |
| 193 | + } | |
| 194 | + const cmd = reg.commands.find((c) => c.name === first); | |
| 195 | + if (!cmd) { | |
| 196 | + io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`); | |
| 197 | + return 2; | |
| 198 | + } | |
| 199 | + return runCommand(cmd, rest, io); | |
| 200 | +} | |
| 201 | + | |
| 202 | +export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> { | |
| 203 | + const specs = optionsFor(cmd); | |
| 204 | + let values: Values; | |
| 205 | + let positionals: string[]; | |
| 206 | + try { | |
| 207 | + const parsed = parseArgs({ | |
| 208 | + args: argv, | |
| 209 | + options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])), | |
| 210 | + allowPositionals: true, | |
| 211 | + strict: true, | |
| 212 | + }); | |
| 213 | + values = parsed.values as Values; | |
| 214 | + positionals = parsed.positionals; | |
| 215 | + } catch (e) { | |
| 216 | + io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`); | |
| 217 | + return 2; | |
| 218 | + } | |
| 219 | + if (values.help) { | |
| 220 | + io.stdout.write(commandHelp(cmd)); | |
| 221 | + return 0; | |
| 222 | + } | |
| 223 | + if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) { | |
| 224 | + io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`); | |
| 225 | + return 2; | |
| 226 | + } | |
| 227 | + const ctx = new Ctx(io, values); | |
| 228 | + try { | |
| 229 | + const code = await cmd.run(ctx, values, positionals); | |
| 230 | + return typeof code === "number" ? code : 0; | |
| 231 | + } catch (e) { | |
| 232 | + if (e instanceof ExitCode) return e.code; | |
| 233 | + if (e instanceof CliError) { | |
| 234 | + io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`); | |
| 235 | + return e.code; | |
| 236 | + } | |
| 237 | + throw e; | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +// ---------------------------------------------------------------- small helpers for commands | |
| 242 | + | |
| 243 | +export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined); | |
| 244 | +export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []); | |
| 245 | + | |
| 246 | +export function positiveInt(v: string | undefined, what: string): number { | |
| 247 | + const n = Number(String(v ?? "").replace(/^#/, "")); | |
| 248 | + if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2); | |
| 249 | + return n; | |
| 250 | +} | |
| 251 | + | |
| 252 | +export function exactlyOne(o: Values, keys: string[], fallback?: string): string { | |
| 253 | + const set = keys.filter((k) => o[k] === true); | |
| 254 | + if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2); | |
| 255 | + if (set.length === 0) { | |
| 256 | + if (fallback) return fallback; | |
| 257 | + throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2); | |
| 258 | + } | |
| 259 | + return set[0]!; | |
| 260 | +} |
addedapps/cli/src/commands/admin.ts+302−0View file
| @@ -1,0 +1,302 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { enc } from "../api.js"; | |
| 4 | +import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 5 | +import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 6 | +import { ago, json, table } from "../output.js"; | |
| 7 | + | |
| 8 | +const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 9 | +function checkName(name: string, what: string): string { | |
| 10 | + if (!NAME_RE.test(name)) throw new CliError(`invalid ${what} name "${name}": use letters, digits and _, not starting with a digit`, 2); | |
| 11 | + return name; | |
| 12 | +} | |
| 13 | + | |
| 14 | +// ---------------------------------------------------------------- secrets | |
| 15 | + | |
| 16 | +const secretSet: Command = { | |
| 17 | + name: "secret set", | |
| 18 | + summary: "Create or update an Actions secret", | |
| 19 | + description: "The value comes from --body, from stdin when piped, or a hidden prompt.\nRepository level by default; --org sets an organization secret.", | |
| 20 | + args: "<NAME>", | |
| 21 | + options: { | |
| 22 | + body: { type: "string", short: "b", desc: "Secret value", arg: "VALUE" }, | |
| 23 | + org: { type: "string", short: "o", desc: "Set an organization secret instead", arg: "ORG" }, | |
| 24 | + }, | |
| 25 | + repo: true, | |
| 26 | + minArgs: 1, | |
| 27 | + maxArgs: 1, | |
| 28 | + async run(ctx, o, [name]) { | |
| 29 | + checkName(name!, "secret"); | |
| 30 | + const org = str(o, "org"); | |
| 31 | + const target = org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`; | |
| 32 | + const value = await valueFromFlagOrStdin(ctx.io, str(o, "body"), `Value for ${name}: `); | |
| 33 | + if (!value) throw new CliError("secret value is empty"); | |
| 34 | + await (await ctx.authedApi()).put(target, { value }); | |
| 35 | + ctx.out(`${ctx.c.green("✓")} Set secret ${name}${org ? ` for ${org}` : ""}`); | |
| 36 | + }, | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const secretList: Command = { | |
| 40 | + name: "secret list", | |
| 41 | + summary: "List Actions secret names", | |
| 42 | + options: { org: { type: "string", short: "o", desc: "List organization secrets instead", arg: "ORG" } }, | |
| 43 | + repo: true, | |
| 44 | + json: true, | |
| 45 | + maxArgs: 0, | |
| 46 | + async run(ctx, o) { | |
| 47 | + const org = str(o, "org"); | |
| 48 | + const path = org ? `/orgs/${enc(org)}/actions/secrets` : `${repoPath(await ctx.repo())}/actions/secrets`; | |
| 49 | + const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(path); | |
| 50 | + if (ctx.json) return json(ctx.io, res.secrets); | |
| 51 | + if (!res.secrets.length) return ctx.err("No secrets."); | |
| 52 | + ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)]))); | |
| 53 | + }, | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const secretDelete: Command = { | |
| 57 | + name: "secret delete", | |
| 58 | + summary: "Delete an Actions secret", | |
| 59 | + args: "<NAME>", | |
| 60 | + options: { org: { type: "string", short: "o", desc: "Delete an organization secret instead", arg: "ORG" } }, | |
| 61 | + repo: true, | |
| 62 | + minArgs: 1, | |
| 63 | + maxArgs: 1, | |
| 64 | + async run(ctx, o, [name]) { | |
| 65 | + const org = str(o, "org"); | |
| 66 | + await (await ctx.authedApi()).delete(org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`); | |
| 67 | + ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`); | |
| 68 | + }, | |
| 69 | +}; | |
| 70 | + | |
| 71 | +const variableSet: Command = { | |
| 72 | + name: "variable set", | |
| 73 | + summary: "Create or update an Actions variable", | |
| 74 | + description: "The value comes from --body, or from stdin when piped.", | |
| 75 | + args: "<NAME>", | |
| 76 | + options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } }, | |
| 77 | + repo: true, | |
| 78 | + minArgs: 1, | |
| 79 | + maxArgs: 1, | |
| 80 | + async run(ctx, o, [name]) { | |
| 81 | + checkName(name!, "variable"); | |
| 82 | + let value = str(o, "body"); | |
| 83 | + if (value === undefined) { | |
| 84 | + if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2); | |
| 85 | + value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, ""); | |
| 86 | + } | |
| 87 | + await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value }); | |
| 88 | + ctx.out(`${ctx.c.green("✓")} Set variable ${name}`); | |
| 89 | + }, | |
| 90 | +}; | |
| 91 | + | |
| 92 | +const variableList: Command = { | |
| 93 | + name: "variable list", | |
| 94 | + summary: "List Actions variables", | |
| 95 | + repo: true, | |
| 96 | + json: true, | |
| 97 | + maxArgs: 0, | |
| 98 | + async run(ctx) { | |
| 99 | + const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`); | |
| 100 | + if (ctx.json) return json(ctx.io, res.variables); | |
| 101 | + if (!res.variables.length) return ctx.err("No variables."); | |
| 102 | + ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value]))); | |
| 103 | + }, | |
| 104 | +}; | |
| 105 | + | |
| 106 | +// ---------------------------------------------------------------- tokens | |
| 107 | + | |
| 108 | +interface TokenRow { | |
| 109 | + id: string; | |
| 110 | + name: string; | |
| 111 | + prefix: string; | |
| 112 | + scopes: string[]; | |
| 113 | + repoIds: string[] | null; | |
| 114 | + expiresAt: string; | |
| 115 | + lastUsedAt: string | null; | |
| 116 | + createdAt: string; | |
| 117 | +} | |
| 118 | + | |
| 119 | +const tokenCreate: Command = { | |
| 120 | + name: "token create", | |
| 121 | + summary: "Create a personal access token (printed once)", | |
| 122 | + description: "Needs an admin-scoped token (or create tokens in the web UI).", | |
| 123 | + options: { | |
| 124 | + name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" }, | |
| 125 | + scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" }, | |
| 126 | + "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" }, | |
| 127 | + repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" }, | |
| 128 | + }, | |
| 129 | + json: true, | |
| 130 | + maxArgs: 0, | |
| 131 | + async run(ctx, o) { | |
| 132 | + const name = str(o, "name"); | |
| 133 | + if (!name) throw new CliError("--name is required", 2); | |
| 134 | + const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean); | |
| 135 | + for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2); | |
| 136 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 137 | + const repos = list(o, "repo"); | |
| 138 | + const t = await (await ctx.authedApi()).post<{ token: string; id: string; expiresAt: string }>("/diffinery/tokens", { name, scopes: scopes.length ? scopes : undefined, expires_in_days: days, repositories: repos.length ? repos : undefined }); | |
| 139 | + if (ctx.json) return json(ctx.io, t); | |
| 140 | + ctx.out(t.token); | |
| 141 | + ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`)); | |
| 142 | + }, | |
| 143 | +}; | |
| 144 | + | |
| 145 | +const tokenList: Command = { | |
| 146 | + name: "token list", | |
| 147 | + summary: "List your tokens", | |
| 148 | + json: true, | |
| 149 | + maxArgs: 0, | |
| 150 | + async run(ctx) { | |
| 151 | + const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens"); | |
| 152 | + if (ctx.json) return json(ctx.io, rows); | |
| 153 | + if (!rows.length) return ctx.err("No tokens."); | |
| 154 | + const c = ctx.c; | |
| 155 | + ctx.io.stdout.write( | |
| 156 | + table([ | |
| 157 | + ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)), | |
| 158 | + ...rows.map((t) => [t.id, t.name, `${t.prefix}…`, t.scopes.join(",") + (t.repoIds ? ` (${t.repoIds.length} repo${t.repoIds.length === 1 ? "" : "s"})` : ""), t.expiresAt.slice(0, 10), t.lastUsedAt ? ago(t.lastUsedAt) : c.dim("never")]), | |
| 159 | + ]), | |
| 160 | + ); | |
| 161 | + }, | |
| 162 | +}; | |
| 163 | + | |
| 164 | +const tokenRevoke: Command = { | |
| 165 | + name: "token revoke", | |
| 166 | + summary: "Revoke a token", | |
| 167 | + args: "<id>", | |
| 168 | + minArgs: 1, | |
| 169 | + maxArgs: 1, | |
| 170 | + async run(ctx, _o, [id]) { | |
| 171 | + await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`); | |
| 172 | + ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`); | |
| 173 | + }, | |
| 174 | +}; | |
| 175 | + | |
| 176 | +// ---------------------------------------------------------------- agents & invites | |
| 177 | + | |
| 178 | +const agentCreate: Command = { | |
| 179 | + name: "agent create", | |
| 180 | + summary: "Create an agent account and print its token once", | |
| 181 | + args: "<login>", | |
| 182 | + options: { | |
| 183 | + org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" }, | |
| 184 | + name: { type: "string", short: "n", desc: "Display name", arg: "NAME" }, | |
| 185 | + scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" }, | |
| 186 | + "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" }, | |
| 187 | + }, | |
| 188 | + json: true, | |
| 189 | + minArgs: 1, | |
| 190 | + maxArgs: 1, | |
| 191 | + async run(ctx, o, [login]) { | |
| 192 | + const scopes = list(o, "scope"); | |
| 193 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 194 | + const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", { | |
| 195 | + login, | |
| 196 | + name: str(o, "name"), | |
| 197 | + org: str(o, "org"), | |
| 198 | + token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined, | |
| 199 | + }); | |
| 200 | + if (ctx.json) return json(ctx.io, r); | |
| 201 | + ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`); | |
| 202 | + ctx.out(r.token.token); | |
| 203 | + ctx.err(ctx.c.dim(`This token (expires ${r.token.expiresAt.slice(0, 10)}) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.`)); | |
| 204 | + }, | |
| 205 | +}; | |
| 206 | + | |
| 207 | +const agentList: Command = { | |
| 208 | + name: "agent list", | |
| 209 | + summary: "List agents you or your organizations own", | |
| 210 | + json: true, | |
| 211 | + maxArgs: 0, | |
| 212 | + async run(ctx) { | |
| 213 | + const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents"); | |
| 214 | + if (ctx.json) return json(ctx.io, rows); | |
| 215 | + if (!rows.length) return ctx.err("No agents."); | |
| 216 | + ctx.io.stdout.write(table(rows.map((a) => [ctx.c.bold(a.login), a.name && a.name !== a.login ? a.name : "", ctx.c.dim(typeof a.policy?.maxRole === "string" ? `max role ${a.policy.maxRole}` : "")]))); | |
| 217 | + }, | |
| 218 | +}; | |
| 219 | + | |
| 220 | +const agentDisable: Command = { | |
| 221 | + name: "agent disable", | |
| 222 | + summary: "Disable an agent (its tokens stop working)", | |
| 223 | + args: "<login>", | |
| 224 | + minArgs: 1, | |
| 225 | + maxArgs: 1, | |
| 226 | + async run(ctx, _o, [login]) { | |
| 227 | + await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`); | |
| 228 | + ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`); | |
| 229 | + }, | |
| 230 | +}; | |
| 231 | + | |
| 232 | +const invite: Command = { | |
| 233 | + name: "invite", | |
| 234 | + summary: "Invite someone by email (to an organization, or to the site as an admin)", | |
| 235 | + args: "<email>", | |
| 236 | + options: { | |
| 237 | + org: { type: "string", short: "o", desc: "Organization to join (omit for a site invite; site admins only)", arg: "ORG" }, | |
| 238 | + role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" }, | |
| 239 | + }, | |
| 240 | + json: true, | |
| 241 | + minArgs: 1, | |
| 242 | + maxArgs: 1, | |
| 243 | + async run(ctx, o, [email]) { | |
| 244 | + const org = str(o, "org"); | |
| 245 | + const role = str(o, "role"); | |
| 246 | + if (!org && role) throw new CliError("--role needs --org", 2); | |
| 247 | + if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2); | |
| 248 | + const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(org ? `/orgs/${enc(org)}/invitations` : "/invitations", org ? { email, role } : { email }); | |
| 249 | + if (ctx.json) return json(ctx.io, r); | |
| 250 | + ctx.out(`${ctx.c.green("✓")} Invited ${email}${org ? ` to ${org}` : ""}${role ? ` as ${role}` : ""}`); | |
| 251 | + ctx.out(r.url); | |
| 252 | + ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days.")); | |
| 253 | + }, | |
| 254 | +}; | |
| 255 | + | |
| 256 | +// ---------------------------------------------------------------- raw API | |
| 257 | + | |
| 258 | +const apiCmd: Command = { | |
| 259 | + name: "api", | |
| 260 | + summary: "Make an authenticated API request and print the response", | |
| 261 | + args: "<METHOD> <PATH>", | |
| 262 | + description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/. | |
| 263 | +--input sends a JSON body from a file, or from stdin with '-'.`, | |
| 264 | + options: { | |
| 265 | + input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" }, | |
| 266 | + field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" }, | |
| 267 | + }, | |
| 268 | + minArgs: 2, | |
| 269 | + maxArgs: 2, | |
| 270 | + async run(ctx, o, [method, p]) { | |
| 271 | + const m = method!.toUpperCase(); | |
| 272 | + if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2); | |
| 273 | + let body: string | undefined; | |
| 274 | + const input = str(o, "input"); | |
| 275 | + if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8"); | |
| 276 | + const fields: Record<string, string> = {}; | |
| 277 | + for (const f of list(o, "field")) { | |
| 278 | + const i = f.indexOf("="); | |
| 279 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2); | |
| 280 | + fields[f.slice(0, i)] = f.slice(i + 1); | |
| 281 | + } | |
| 282 | + const hasFields = Object.keys(fields).length > 0; | |
| 283 | + if (hasFields && m !== "GET" && m !== "HEAD") { | |
| 284 | + if (body) throw new CliError("use either --input or -f, not both", 2); | |
| 285 | + body = JSON.stringify(fields); | |
| 286 | + } | |
| 287 | + const api = await ctx.api(); | |
| 288 | + const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) }); | |
| 289 | + const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2); | |
| 290 | + if (out) ctx.out(out); | |
| 291 | + if (res.status >= 400) { | |
| 292 | + ctx.err(ctx.c.red(`HTTP ${res.status}`)); | |
| 293 | + throw new ExitCode(1); | |
| 294 | + } | |
| 295 | + }, | |
| 296 | +}; | |
| 297 | + | |
| 298 | +export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] }; | |
| 299 | +export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] }; | |
| 300 | +export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] }; | |
| 301 | +export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] }; | |
| 302 | +export const adminCommands: Command[] = [invite, apiCmd]; |
addedapps/cli/src/commands/auth.ts+139−0View file
| @@ -1,0 +1,139 @@ | ||
| 1 | +import { Api } from "../api.js"; | |
| 2 | +import { str, type Command, type Group } from "../cli.js"; | |
| 3 | +import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; | |
| 4 | +import { gitCapture } from "../git.js"; | |
| 5 | +import { CliError, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 6 | +import { json } from "../output.js"; | |
| 7 | + | |
| 8 | +interface User { | |
| 9 | + login: string; | |
| 10 | + name?: string | null; | |
| 11 | + type?: string; | |
| 12 | + email?: string | null; | |
| 13 | + html_url?: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const login: Command = { | |
| 17 | + name: "login", | |
| 18 | + summary: "Log in with a personal or agent token", | |
| 19 | + description: `Stores the server URL and token in the config file (mode 0600). | |
| 20 | +The token is read from --token, from stdin when piped, or prompted for. | |
| 21 | +Create a token in the web UI (Settings > Tokens) or with 'diffinery token create'.`, | |
| 22 | + options: { | |
| 23 | + url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" }, | |
| 24 | + token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" }, | |
| 25 | + }, | |
| 26 | + maxArgs: 0, | |
| 27 | + async run(ctx, o) { | |
| 28 | + const stored = await readConfig(ctx.io.env); | |
| 29 | + const cfg = await resolveConfig(ctx.io); | |
| 30 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 31 | + const token = (await valueFromFlagOrStdin(ctx.io, str(o, "token"), `Paste a token for ${url}: `)).trim(); | |
| 32 | + if (!token) throw new CliError("no token given"); | |
| 33 | + const me = await new Api(url, token).get<User>("/user"); | |
| 34 | + const file = await writeConfig(ctx.io.env, { ...stored, url, token }); | |
| 35 | + ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 36 | + ctx.err(ctx.c.dim(`Saved to ${file}`)); | |
| 37 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token")); | |
| 38 | + }, | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const logout: Command = { | |
| 42 | + name: "logout", | |
| 43 | + summary: "Remove the saved token", | |
| 44 | + maxArgs: 0, | |
| 45 | + async run(ctx) { | |
| 46 | + const stored = await readConfig(ctx.io.env); | |
| 47 | + if (!stored.token) ctx.out("Not logged in."); | |
| 48 | + else { | |
| 49 | + await writeConfig(ctx.io.env, { url: stored.url }); | |
| 50 | + ctx.out(`Logged out of ${stored.url ?? "Diffinery"}.`); | |
| 51 | + } | |
| 52 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment")); | |
| 53 | + }, | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const whoami: Command = { | |
| 57 | + name: "whoami", | |
| 58 | + summary: "Show the account the current token acts as", | |
| 59 | + json: true, | |
| 60 | + maxArgs: 0, | |
| 61 | + async run(ctx) { | |
| 62 | + const cfg = await ctx.config(); | |
| 63 | + const me = await (await ctx.authedApi()).get<User>("/user"); | |
| 64 | + if (ctx.json) return json(ctx.io, me); | |
| 65 | + ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`); | |
| 66 | + ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`)); | |
| 67 | + }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const setupGit: Command = { | |
| 71 | + name: "auth setup-git", | |
| 72 | + summary: "Make git use your Diffinery token for HTTPS remotes", | |
| 73 | + description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential" | |
| 74 | +(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host | |
| 75 | +authenticate with the token from the config file or DIFFINERY_TOKEN.`, | |
| 76 | + maxArgs: 0, | |
| 77 | + async run(ctx) { | |
| 78 | + const cfg = await ctx.config(); | |
| 79 | + const key = `credential.${cfg.url}.helper`; | |
| 80 | + await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]); | |
| 81 | + // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first. | |
| 82 | + for (const value of ["", "!diffinery auth git-credential"]) { | |
| 83 | + const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]); | |
| 84 | + if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`); | |
| 85 | + } | |
| 86 | + ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`); | |
| 87 | + }, | |
| 88 | +}; | |
| 89 | + | |
| 90 | +/** Parses the git credential helper protocol's key=value lines. */ | |
| 91 | +export function parseCredentialInput(text: string): Record<string, string> { | |
| 92 | + const out: Record<string, string> = {}; | |
| 93 | + for (const line of text.split(/\r?\n/)) { | |
| 94 | + if (!line) break; | |
| 95 | + const i = line.indexOf("="); | |
| 96 | + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); | |
| 97 | + } | |
| 98 | + return out; | |
| 99 | +} | |
| 100 | + | |
| 101 | +const gitCredential: Command = { | |
| 102 | + name: "auth git-credential", | |
| 103 | + summary: "Git credential helper (used by 'auth setup-git')", | |
| 104 | + args: "<get|store|erase>", | |
| 105 | + minArgs: 1, | |
| 106 | + maxArgs: 1, | |
| 107 | + async run(ctx, _o, [op]) { | |
| 108 | + const input = parseCredentialInput(await readAll(ctx.io.stdin)); | |
| 109 | + if (op !== "get") return; // store/erase: the token lives in the config file | |
| 110 | + const cfg = await ctx.config(); | |
| 111 | + if (!cfg.token) return; | |
| 112 | + const want = new URL(cfg.url); | |
| 113 | + const proto = (input.protocol ?? "").toLowerCase(); | |
| 114 | + const host = (input.host ?? "").toLowerCase(); | |
| 115 | + if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return; | |
| 116 | + ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`); | |
| 117 | + }, | |
| 118 | +}; | |
| 119 | + | |
| 120 | +const mcpConfig: Command = { | |
| 121 | + name: "mcp-config", | |
| 122 | + summary: "Print an MCP server config snippet for Claude Code and other MCP clients", | |
| 123 | + description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as | |
| 124 | +Claude Code from the environment). Pass --show-token to embed the saved token instead.`, | |
| 125 | + options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } }, | |
| 126 | + maxArgs: 0, | |
| 127 | + async run(ctx, o) { | |
| 128 | + const cfg = await ctx.config(); | |
| 129 | + let token = "${DIFFINERY_TOKEN}"; | |
| 130 | + if (o["show-token"]) { | |
| 131 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 132 | + token = cfg.token; | |
| 133 | + } | |
| 134 | + json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } }); | |
| 135 | + }, | |
| 136 | +}; | |
| 137 | + | |
| 138 | +export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; | |
| 139 | +export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] }; |
addedapps/cli/src/commands/issue.ts+131−0View file
| @@ -1,0 +1,131 @@ | ||
| 1 | +import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 2 | +import { CliError } from "../io.js"; | |
| 3 | +import { ago, json, table, truncate } from "../output.js"; | |
| 4 | +import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js"; | |
| 5 | + | |
| 6 | +const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed")); | |
| 7 | + | |
| 8 | +const create: Command = { | |
| 9 | + name: "issue create", | |
| 10 | + summary: "Open an issue", | |
| 11 | + options: { | |
| 12 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 13 | + ...bodyOpts, | |
| 14 | + label: { type: "string", short: "l", multiple: true, desc: "Add a label", arg: "NAME" }, | |
| 15 | + assignee: { type: "string", short: "a", multiple: true, desc: "Assign a login", arg: "LOGIN" }, | |
| 16 | + }, | |
| 17 | + repo: true, | |
| 18 | + json: true, | |
| 19 | + maxArgs: 0, | |
| 20 | + async run(ctx, o) { | |
| 21 | + const title = str(o, "title"); | |
| 22 | + if (!title) throw new CliError("--title is required", 2); | |
| 23 | + const repo = await ctx.repo(); | |
| 24 | + const labels = list(o, "label"); | |
| 25 | + const assignees = list(o, "assignee"); | |
| 26 | + const i = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/issues`, { title, body: await bodyFrom(ctx, o), labels: labels.length ? labels : undefined, assignees: assignees.length ? assignees : undefined }); | |
| 27 | + if (ctx.json) return json(ctx.io, i); | |
| 28 | + ctx.out(`${ctx.c.green("✓")} Opened issue #${i.number} ${i.title}`); | |
| 29 | + ctx.out(i.html_url); | |
| 30 | + }, | |
| 31 | +}; | |
| 32 | + | |
| 33 | +const listCmd: Command = { | |
| 34 | + name: "issue list", | |
| 35 | + summary: "List issues", | |
| 36 | + options: { | |
| 37 | + state: { type: "string", short: "s", desc: "open, closed or all (default: open)", arg: "STATE" }, | |
| 38 | + label: { type: "string", short: "l", desc: "Only issues with this label", arg: "NAME" }, | |
| 39 | + author: { type: "string", short: "A", desc: "Only issues opened by this login", arg: "LOGIN" }, | |
| 40 | + search: { type: "string", short: "S", desc: "Search text", arg: "QUERY" }, | |
| 41 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 42 | + }, | |
| 43 | + repo: true, | |
| 44 | + json: true, | |
| 45 | + maxArgs: 0, | |
| 46 | + async run(ctx, o) { | |
| 47 | + const repo = await ctx.repo(); | |
| 48 | + const state = str(o, "state") ?? "open"; | |
| 49 | + if (!["open", "closed", "all"].includes(state)) throw new CliError("--state must be open, closed or all", 2); | |
| 50 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 51 | + const items = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/issues`, { state, labels: str(o, "label"), creator: str(o, "author"), q: str(o, "search"), per_page: Math.min(100, limit + 20) }); | |
| 52 | + // The issues endpoint can include pull requests (as on GitHub); show only issues. | |
| 53 | + const issues = items.filter((i) => !i.pull_request).slice(0, limit); | |
| 54 | + if (ctx.json) return json(ctx.io, issues); | |
| 55 | + if (!issues.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}issues in ${repo.owner}/${repo.name}.`); | |
| 56 | + const c = ctx.c; | |
| 57 | + ctx.io.stdout.write(table(issues.map((i) => [c.bold(`#${i.number}`), truncate(i.title, 60), issueState(c, i), c.cyan(i.labels.map((l) => l.name).join(", ")), c.dim(i.user.login), c.dim(ago(i.updated_at))]))); | |
| 58 | + }, | |
| 59 | +}; | |
| 60 | + | |
| 61 | +const view: Command = { | |
| 62 | + name: "issue view", | |
| 63 | + summary: "Show an issue and its comments", | |
| 64 | + args: "<number>", | |
| 65 | + repo: true, | |
| 66 | + json: true, | |
| 67 | + minArgs: 1, | |
| 68 | + maxArgs: 1, | |
| 69 | + async run(ctx, _o, [num]) { | |
| 70 | + const n = positiveInt(num, "issue number"); | |
| 71 | + const repo = await ctx.repo(); | |
| 72 | + const api = await ctx.api(); | |
| 73 | + const [i, comments] = await Promise.all([api.get<IssueJson>(`${repoPath(repo)}/issues/${n}`), api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`)]); | |
| 74 | + if (ctx.json) return json(ctx.io, { ...i, comment_list: comments }); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.out(`${c.bold(i.title)} ${c.dim(`#${i.number}`)}`); | |
| 77 | + ctx.out(`${issueState(c, i)} · opened by ${i.user.login} ${ago(i.created_at)}${i.labels.length ? ` · ${c.cyan(i.labels.map((l) => l.name).join(", "))}` : ""}${i.assignees.length ? ` · assigned to ${i.assignees.map((a) => a.login).join(", ")}` : ""}`); | |
| 78 | + if (i.body?.trim()) ctx.out(`\n${i.body.trim()}`); | |
| 79 | + for (const cm of comments) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 80 | + ctx.out(`\n${c.dim(i.html_url)}`); | |
| 81 | + }, | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const comment: Command = { | |
| 85 | + name: "issue comment", | |
| 86 | + summary: "Comment on an issue", | |
| 87 | + args: "<number>", | |
| 88 | + options: bodyOpts, | |
| 89 | + repo: true, | |
| 90 | + json: true, | |
| 91 | + minArgs: 1, | |
| 92 | + maxArgs: 1, | |
| 93 | + async run(ctx, o, [num]) { | |
| 94 | + const n = positiveInt(num, "issue number"); | |
| 95 | + const body = await bodyFrom(ctx, o, true); | |
| 96 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 97 | + const repo = await ctx.repo(); | |
| 98 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 99 | + if (ctx.json) return json(ctx.io, r); | |
| 100 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +function setState(state: "open" | "closed"): Command { | |
| 105 | + return { | |
| 106 | + name: state === "closed" ? "issue close" : "issue reopen", | |
| 107 | + summary: state === "closed" ? "Close an issue" : "Reopen an issue", | |
| 108 | + args: "<number>", | |
| 109 | + options: { | |
| 110 | + ...(state === "closed" ? { reason: { type: "string" as const, short: "r", desc: "completed (default) or not_planned", arg: "REASON" } } : {}), | |
| 111 | + comment: { type: "string", short: "c", desc: "Leave a comment too", arg: "TEXT" }, | |
| 112 | + }, | |
| 113 | + repo: true, | |
| 114 | + json: true, | |
| 115 | + minArgs: 1, | |
| 116 | + maxArgs: 1, | |
| 117 | + async run(ctx, o, [num]) { | |
| 118 | + const n = positiveInt(num, "issue number"); | |
| 119 | + const reason = str(o, "reason")?.replace(/[\s-]+/g, "_"); | |
| 120 | + if (reason && !["completed", "not_planned"].includes(reason)) throw new CliError("--reason must be completed or not_planned", 2); | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.authedApi(); | |
| 123 | + if (str(o, "comment")) await api.post(`${repoPath(repo)}/issues/${n}/comments`, { body: str(o, "comment") }); | |
| 124 | + const i = await api.patch<IssueJson>(`${repoPath(repo)}/issues/${n}`, { state, state_reason: state === "closed" ? (reason ?? "completed") : undefined }); | |
| 125 | + if (ctx.json) return json(ctx.io, i); | |
| 126 | + ctx.out(`${ctx.c.green("✓")} ${state === "closed" ? "Closed" : "Reopened"} #${n} ${i.title}`); | |
| 127 | + }, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] }; |
addedapps/cli/src/commands/migrate.ts+296−0View file
| @@ -1,0 +1,296 @@ | ||
| 1 | +import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { ApiError, type Api } from "../api.js"; | |
| 5 | +import { parseRepoArg, repoPath, str, type Command, type Ctx, type Group, type RepoRef } from "../cli.js"; | |
| 6 | +import { basicAuthHeader, git, gitCapture, withGitConfig } from "../git.js"; | |
| 7 | +import { GitHub, GitHubError } from "../github.js"; | |
| 8 | +import { CliError } from "../io.js"; | |
| 9 | +import type { IssueJson } from "./pr.js"; | |
| 10 | +import type { RepoJson } from "./repo.js"; | |
| 11 | + | |
| 12 | +interface GhRepo { | |
| 13 | + full_name: string; | |
| 14 | + name: string; | |
| 15 | + private: boolean; | |
| 16 | + visibility?: "public" | "private" | "internal"; | |
| 17 | + description: string | null; | |
| 18 | + default_branch: string; | |
| 19 | + clone_url: string; | |
| 20 | + html_url: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +interface GhUser { | |
| 24 | + login: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +interface GhIssue { | |
| 28 | + number: number; | |
| 29 | + title: string; | |
| 30 | + body: string | null; | |
| 31 | + state: "open" | "closed"; | |
| 32 | + state_reason?: string | null; | |
| 33 | + user: GhUser | null; | |
| 34 | + labels: Array<{ name: string } | string>; | |
| 35 | + comments: number; | |
| 36 | + created_at: string; | |
| 37 | + pull_request?: unknown; | |
| 38 | +} | |
| 39 | + | |
| 40 | +interface GhPull { | |
| 41 | + number: number; | |
| 42 | + title: string; | |
| 43 | + body: string | null; | |
| 44 | + draft?: boolean; | |
| 45 | + user: GhUser | null; | |
| 46 | + created_at: string; | |
| 47 | + head: { ref: string; repo: { full_name: string } | null }; | |
| 48 | + base: { ref: string }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +interface GhComment { | |
| 52 | + body: string | null; | |
| 53 | + user: GhUser | null; | |
| 54 | + created_at: string; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const day = (iso: string) => iso.slice(0, 10); | |
| 58 | +const who = (u: GhUser | null) => (u ? `@${u.login}` : "a deleted user"); | |
| 59 | + | |
| 60 | +export function attribution(kind: "issue" | "pull request" | "comment", n: number, user: GhUser | null, createdAt: string, htmlBase: string): string { | |
| 61 | + const link = `${htmlBase}/${kind === "pull request" ? "pull" : "issues"}/${n}`; | |
| 62 | + if (kind === "comment") return `> Imported from GitHub: comment by ${who(user)} on ${day(createdAt)}`; | |
| 63 | + return `> Imported from GitHub ${kind} [#${n}](${link}) by ${who(user)} on ${day(createdAt)}`; | |
| 64 | +} | |
| 65 | + | |
| 66 | +const withAttribution = (line: string, body: string | null) => `${line}\n\n${body ?? ""}`.trimEnd(); | |
| 67 | + | |
| 68 | +interface Summary { | |
| 69 | + repo: string; | |
| 70 | + created: boolean; | |
| 71 | + branches: number; | |
| 72 | + tags: number; | |
| 73 | + pushFailures: string[]; | |
| 74 | + defaultBranch: string | null; | |
| 75 | + labels: number; | |
| 76 | + issues: Array<{ from: number; to: number }>; | |
| 77 | + pulls: Array<{ from: number; to: number }>; | |
| 78 | + skippedPulls: Array<{ number: number; reason: string }>; | |
| 79 | + comments: number; | |
| 80 | + variables: string[]; | |
| 81 | + secrets: string[]; | |
| 82 | + warnings: string[]; | |
| 83 | +} | |
| 84 | + | |
| 85 | +async function optional<T>(ctx: Ctx, s: Summary, what: string, f: () => Promise<T>): Promise<T | null> { | |
| 86 | + try { | |
| 87 | + return await f(); | |
| 88 | + } catch (e) { | |
| 89 | + if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) { | |
| 90 | + s.warnings.push(`could not read ${what} from GitHub (${e.status}; the token may lack access)`); | |
| 91 | + ctx.err(`${ctx.c.yellow("warning:")} could not read ${what} from GitHub (${e.status})`); | |
| 92 | + return null; | |
| 93 | + } | |
| 94 | + throw e; | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +async function ensureRepo(ctx: Ctx, api: Api, to: RepoRef, me: string, gh: GhRepo, forcePrivate: boolean): Promise<{ repo: RepoJson; created: boolean }> { | |
| 99 | + const existing = await api.raw<RepoJson>("GET", repoPath(to), { allow: [404] }); | |
| 100 | + if (existing.status !== 404) { | |
| 101 | + ctx.out(`Using existing repository ${to.owner}/${to.name}`); | |
| 102 | + return { repo: existing.data, created: false }; | |
| 103 | + } | |
| 104 | + const visibility = forcePrivate ? "private" : (gh.visibility ?? (gh.private ? "private" : "public")); | |
| 105 | + const body = { name: to.name, description: gh.description ?? undefined, visibility }; | |
| 106 | + const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body); | |
| 107 | + ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`); | |
| 108 | + return { repo, created: true }; | |
| 109 | +} | |
| 110 | + | |
| 111 | +async function migrateGit(ctx: Ctx, gh: GhRepo, ghToken: string | null, target: string, token: string | null, diffineryUrl: string, s: Summary): Promise<Set<string>> { | |
| 112 | + const dir = await mkdtemp(path.join(tmpdir(), "diffinery-migrate-")); | |
| 113 | + const mirror = path.join(dir, "repo.git"); | |
| 114 | + try { | |
| 115 | + ctx.out(`Cloning ${gh.html_url} …`); | |
| 116 | + const cloneEnv = withGitConfig(ctx.io.env, ghToken && /^https?:\/\//.test(gh.clone_url) ? [basicAuthHeader(new URL(gh.clone_url).origin, "x-access-token", ghToken)] : []); | |
| 117 | + const cl = await gitCapture(ctx.io, ["clone", "--mirror", "--quiet", gh.clone_url, mirror], { env: { ...cloneEnv, GIT_TERMINAL_PROMPT: "0" } }); | |
| 118 | + if (cl.code !== 0) throw new CliError(`git clone --mirror failed: ${cl.stderr.trim()}`); | |
| 119 | + const refs = (await git(ctx.io, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: mirror })).split("\n").filter(Boolean); | |
| 120 | + const branches = new Set(refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length))); | |
| 121 | + s.branches = branches.size; | |
| 122 | + s.tags = refs.length - branches.size; | |
| 123 | + if (!refs.length) { | |
| 124 | + ctx.out("The GitHub repository is empty; nothing to push."); | |
| 125 | + return branches; | |
| 126 | + } | |
| 127 | + ctx.out(`Pushing ${s.branches} branch${s.branches === 1 ? "" : "es"} and ${s.tags} tag${s.tags === 1 ? "" : "s"} to ${target} …`); | |
| 128 | + const pushEnv = { ...withGitConfig(ctx.io.env, token ? [basicAuthHeader(diffineryUrl, "x-token", token)] : []), GIT_TERMINAL_PROMPT: "0" }; | |
| 129 | + const remote = `${diffineryUrl}/${target}.git`; | |
| 130 | + const all = await gitCapture(ctx.io, ["push", "--quiet", remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { cwd: mirror, env: pushEnv }); | |
| 131 | + if (all.code !== 0) { | |
| 132 | + // One big push can hit request size limits or a rejected ref; fall back to ref by ref. | |
| 133 | + ctx.err(`${ctx.c.yellow("warning:")} bulk push failed (${all.stderr.trim().split("\n").pop()}); pushing refs one at a time`); | |
| 134 | + for (const ref of refs) { | |
| 135 | + const r = await gitCapture(ctx.io, ["push", "--quiet", remote, `${ref}:${ref}`], { cwd: mirror, env: pushEnv }); | |
| 136 | + if (r.code !== 0) { | |
| 137 | + s.pushFailures.push(`${ref}: ${r.stderr.trim().split("\n").pop()}`); | |
| 138 | + ctx.err(`${ctx.c.red("X")} ${ref}: ${r.stderr.trim().split("\n").pop()}`); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + } | |
| 142 | + return branches; | |
| 143 | + } finally { | |
| 144 | + await rm(dir, { recursive: true, force: true }); | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +async function migrateIssues(ctx: Ctx, gh: GitHub, api: Api, src: string, gr: GhRepo, to: RepoRef, branches: Set<string>, s: Summary): Promise<void> { | |
| 149 | + const base = repoPath(to); | |
| 150 | + const ghPath = `/repos/${src}`; | |
| 151 | + // Labels | |
| 152 | + const labels = (await optional(ctx, s, "labels", () => gh.paginate<{ name: string; color: string; description: string | null }>(`${ghPath}/labels`))) ?? []; | |
| 153 | + for (const l of labels) { | |
| 154 | + try { | |
| 155 | + await api.post(`${base}/labels`, { name: l.name, color: l.color, description: l.description ?? "" }); | |
| 156 | + s.labels++; | |
| 157 | + } catch (e) { | |
| 158 | + s.warnings.push(`label ${l.name}: ${(e as Error).message}`); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + if (labels.length) ctx.out(`${ctx.c.green("✓")} Copied ${s.labels} label${s.labels === 1 ? "" : "s"}`); | |
| 162 | + | |
| 163 | + const openPulls = new Map<number, GhPull>(); | |
| 164 | + for (const p of (await optional(ctx, s, "pull requests", () => gh.paginate<GhPull>(`${ghPath}/pulls`, { state: "open" }))) ?? []) openPulls.set(p.number, p); | |
| 165 | + const items = ((await optional(ctx, s, "issues", () => gh.paginate<GhIssue>(`${ghPath}/issues`, { state: "all", sort: "created", direction: "asc" }))) ?? []).sort((a, b) => a.number - b.number); | |
| 166 | + ctx.out(`Importing ${items.length} issues and pull requests …`); | |
| 167 | + | |
| 168 | + for (const it of items) { | |
| 169 | + let created: IssueJson | null = null; | |
| 170 | + if (it.pull_request) { | |
| 171 | + const p = openPulls.get(it.number); | |
| 172 | + if (it.state !== "open" || !p) { | |
| 173 | + s.skippedPulls.push({ number: it.number, reason: it.state === "open" ? "not found" : "closed or merged" }); | |
| 174 | + continue; | |
| 175 | + } | |
| 176 | + if (p.head.repo?.full_name?.toLowerCase() !== gr.full_name.toLowerCase()) { | |
| 177 | + s.skippedPulls.push({ number: it.number, reason: "head branch is in a fork" }); | |
| 178 | + continue; | |
| 179 | + } | |
| 180 | + if (!branches.has(p.head.ref) || !branches.has(p.base.ref)) { | |
| 181 | + s.skippedPulls.push({ number: it.number, reason: `branch ${branches.has(p.head.ref) ? p.base.ref : p.head.ref} missing` }); | |
| 182 | + continue; | |
| 183 | + } | |
| 184 | + try { | |
| 185 | + created = await api.post<IssueJson>(`${base}/pulls`, { title: p.title, body: withAttribution(attribution("pull request", p.number, p.user, p.created_at, gr.html_url), p.body), head: p.head.ref, base: p.base.ref, draft: p.draft || undefined }); | |
| 186 | + s.pulls.push({ from: it.number, to: created.number }); | |
| 187 | + } catch (e) { | |
| 188 | + s.skippedPulls.push({ number: it.number, reason: (e as Error).message }); | |
| 189 | + continue; | |
| 190 | + } | |
| 191 | + } else { | |
| 192 | + const labelNames = it.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean); | |
| 193 | + const body = withAttribution(attribution("issue", it.number, it.user, it.created_at, gr.html_url), it.body); | |
| 194 | + try { | |
| 195 | + created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body, labels: labelNames.length ? labelNames : undefined }); | |
| 196 | + } catch (e) { | |
| 197 | + if (!(e instanceof ApiError) || !labelNames.length) throw e; | |
| 198 | + created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body }); | |
| 199 | + s.warnings.push(`issue #${it.number}: labels not applied (${e.message})`); | |
| 200 | + } | |
| 201 | + s.issues.push({ from: it.number, to: created.number }); | |
| 202 | + } | |
| 203 | + if (it.comments > 0) { | |
| 204 | + const comments = (await optional(ctx, s, `comments of #${it.number}`, () => gh.paginate<GhComment>(`${ghPath}/issues/${it.number}/comments`))) ?? []; | |
| 205 | + for (const cm of comments) { | |
| 206 | + await api.post(`${base}/issues/${created.number}/comments`, { body: withAttribution(attribution("comment", it.number, cm.user, cm.created_at, gr.html_url), cm.body) }); | |
| 207 | + s.comments++; | |
| 208 | + } | |
| 209 | + } | |
| 210 | + if (it.state === "closed" && !it.pull_request) { | |
| 211 | + await api.patch(`${base}/issues/${created.number}`, { state: "closed", state_reason: it.state_reason === "not_planned" ? "not_planned" : "completed" }); | |
| 212 | + } | |
| 213 | + ctx.out(ctx.c.dim(` GitHub #${it.number} → #${created.number} ${it.title.slice(0, 60)}`)); | |
| 214 | + } | |
| 215 | +} | |
| 216 | + | |
| 217 | +const github: Command = { | |
| 218 | + name: "migrate github", | |
| 219 | + summary: "Import a GitHub repository (git data, and optionally issues)", | |
| 220 | + args: "<GH-OWNER/GH-REPO>", | |
| 221 | + description: `Creates the Diffinery repository if needed, mirrors all branches and tags (not refs/pull), | |
| 222 | +sets the default branch, copies Actions variables and lists Actions secret names to set. | |
| 223 | +With --issues, also copies labels, issues and comments (with attribution lines), and recreates | |
| 224 | +open pull requests whose branches were imported. | |
| 225 | + | |
| 226 | +GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL | |
| 227 | +selects a GitHub Enterprise API).`, | |
| 228 | + options: { | |
| 229 | + to: { type: "string", short: "t", desc: "Diffinery repository to create or push into (default: <you>/<gh-repo>)", arg: "OWNER/NAME" }, | |
| 230 | + issues: { type: "boolean", short: "i", desc: "Also import labels, issues, comments and open pull requests" }, | |
| 231 | + private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" }, | |
| 232 | + }, | |
| 233 | + json: true, | |
| 234 | + minArgs: 1, | |
| 235 | + maxArgs: 1, | |
| 236 | + async run(ctx, o, [srcArg]) { | |
| 237 | + const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, "")); | |
| 238 | + const srcName = `${src.owner}/${src.name}`; | |
| 239 | + const api = await ctx.authedApi(); | |
| 240 | + const cfg = await ctx.config(); | |
| 241 | + const ghToken = ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null; | |
| 242 | + const gh = new GitHub({ baseUrl: ctx.io.env.GITHUB_API_URL, token: ghToken, sleep: (ms) => ctx.io.sleep(ms), log: (m) => ctx.err(ctx.c.yellow(m)) }); | |
| 243 | + if (!ghToken) ctx.err(ctx.c.yellow("note: GH_TOKEN/GITHUB_TOKEN is not set; only public data can be read and GitHub rate limits are low")); | |
| 244 | + | |
| 245 | + const me = await api.get<{ login: string }>("/user"); | |
| 246 | + const gr = await gh.get<GhRepo>(`/repos/${srcName}`); | |
| 247 | + const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name }; | |
| 248 | + const target = `${to.owner}/${to.name}`; | |
| 249 | + const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] }; | |
| 250 | + | |
| 251 | + const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true); | |
| 252 | + s.created = created; | |
| 253 | + const branches = await migrateGit(ctx, gr, ghToken, target, cfg.token, cfg.url, s); | |
| 254 | + if (branches.has(gr.default_branch)) { | |
| 255 | + await api.patch(repoPath(to), { default_branch: gr.default_branch }); | |
| 256 | + s.defaultBranch = gr.default_branch; | |
| 257 | + } | |
| 258 | + | |
| 259 | + const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? []; | |
| 260 | + for (const v of vars) { | |
| 261 | + await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value }); | |
| 262 | + s.variables.push(v.name); | |
| 263 | + } | |
| 264 | + s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name); | |
| 265 | + | |
| 266 | + if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s); | |
| 267 | + | |
| 268 | + if (ctx.json) { | |
| 269 | + ctx.out(JSON.stringify(s, null, 2)); | |
| 270 | + return s.pushFailures.length ? 1 : 0; | |
| 271 | + } | |
| 272 | + const c = ctx.c; | |
| 273 | + ctx.out(""); | |
| 274 | + ctx.out(c.bold(`Imported ${srcName} → ${target}`)); | |
| 275 | + ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`); | |
| 276 | + ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`); | |
| 277 | + ctx.out(` variables: ${s.variables.length}`); | |
| 278 | + if (o.issues) { | |
| 279 | + ctx.out(` labels: ${s.labels}`); | |
| 280 | + ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`); | |
| 281 | + ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`); | |
| 282 | + for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`)); | |
| 283 | + } | |
| 284 | + if (s.secrets.length) { | |
| 285 | + ctx.out(""); | |
| 286 | + ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`); | |
| 287 | + for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`); | |
| 288 | + } | |
| 289 | + for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`); | |
| 290 | + ctx.out(""); | |
| 291 | + ctx.out(`${cfg.url}/${target}`); | |
| 292 | + return s.pushFailures.length ? 1 : 0; | |
| 293 | + }, | |
| 294 | +}; | |
| 295 | + | |
| 296 | +export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github] }; |
addedapps/cli/src/commands/pr.ts+313−0View file
| @@ -1,0 +1,313 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js"; | |
| 4 | +import { currentBranch } from "../git.js"; | |
| 5 | +import { CliError, ExitCode, readAll } from "../io.js"; | |
| 6 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 7 | + | |
| 8 | +export interface IssueJson { | |
| 9 | + number: number; | |
| 10 | + title: string; | |
| 11 | + body: string; | |
| 12 | + state: "open" | "closed"; | |
| 13 | + state_reason: string | null; | |
| 14 | + user: { login: string; type: string }; | |
| 15 | + labels: Array<{ name: string }>; | |
| 16 | + assignees: Array<{ login: string }>; | |
| 17 | + comments: number; | |
| 18 | + created_at: string; | |
| 19 | + updated_at: string; | |
| 20 | + closed_at: string | null; | |
| 21 | + html_url: string; | |
| 22 | + pull_request?: { url: string }; | |
| 23 | + head?: { ref: string; sha: string }; | |
| 24 | + base?: { ref: string }; | |
| 25 | + draft?: boolean; | |
| 26 | + merged?: boolean; | |
| 27 | + merged_at?: string | null; | |
| 28 | + merged_by?: { login: string } | null; | |
| 29 | + intent?: string | null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +interface PullDetail extends IssueJson { | |
| 33 | + mergeable: boolean; | |
| 34 | + mergeable_state: string; | |
| 35 | + merge_blockers: string[]; | |
| 36 | + approvals: number; | |
| 37 | + required_approvals: number; | |
| 38 | + checks: Array<{ name: string; state: string }>; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export interface CommentJson { | |
| 42 | + id: string; | |
| 43 | + body: string; | |
| 44 | + user: { login: string }; | |
| 45 | + created_at: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */ | |
| 49 | +export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> { | |
| 50 | + const b = str(o, "body"); | |
| 51 | + if (b !== undefined) return b; | |
| 52 | + const f = str(o, "body-file"); | |
| 53 | + if (f !== undefined) { | |
| 54 | + if (f === "-") return readAll(ctx.io.stdin); | |
| 55 | + return readFile(path.resolve(ctx.io.cwd, f), "utf8"); | |
| 56 | + } | |
| 57 | + if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin); | |
| 58 | + return undefined; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export const bodyOpts = { | |
| 62 | + body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" }, | |
| 63 | + "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" }, | |
| 64 | +}; | |
| 65 | + | |
| 66 | +export function prState(c: Ctx["c"], p: IssueJson): string { | |
| 67 | + if (p.merged) return c.magenta("merged"); | |
| 68 | + if (p.state === "closed") return c.red("closed"); | |
| 69 | + if (p.draft) return c.dim("draft"); | |
| 70 | + return c.green("open"); | |
| 71 | +} | |
| 72 | + | |
| 73 | +const create: Command = { | |
| 74 | + name: "pr create", | |
| 75 | + summary: "Open a pull request", | |
| 76 | + options: { | |
| 77 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 78 | + ...bodyOpts, | |
| 79 | + head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" }, | |
| 80 | + base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" }, | |
| 81 | + draft: { type: "boolean", short: "d", desc: "Open as a draft" }, | |
| 82 | + intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" }, | |
| 83 | + reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" }, | |
| 84 | + }, | |
| 85 | + repo: true, | |
| 86 | + json: true, | |
| 87 | + maxArgs: 0, | |
| 88 | + async run(ctx, o) { | |
| 89 | + const title = str(o, "title"); | |
| 90 | + if (!title) throw new CliError("--title is required", 2); | |
| 91 | + const repo = await ctx.repo(); | |
| 92 | + const head = str(o, "head") ?? (await currentBranch(ctx.io)); | |
| 93 | + const reviewers = list(o, "reviewer"); | |
| 94 | + const payload = { | |
| 95 | + title, | |
| 96 | + body: await bodyFrom(ctx, o), | |
| 97 | + head, | |
| 98 | + base: str(o, "base"), | |
| 99 | + draft: o.draft === true ? true : undefined, | |
| 100 | + intent: str(o, "intent"), | |
| 101 | + reviewers: reviewers.length ? reviewers : undefined, | |
| 102 | + }; | |
| 103 | + const pr = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/pulls`, payload); | |
| 104 | + if (ctx.json) return json(ctx.io, pr); | |
| 105 | + ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`); | |
| 106 | + ctx.out(pr.html_url); | |
| 107 | + }, | |
| 108 | +}; | |
| 109 | + | |
| 110 | +const listCmd: Command = { | |
| 111 | + name: "pr list", | |
| 112 | + summary: "List pull requests", | |
| 113 | + options: { | |
| 114 | + state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" }, | |
| 115 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 116 | + author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" }, | |
| 117 | + }, | |
| 118 | + repo: true, | |
| 119 | + json: true, | |
| 120 | + maxArgs: 0, | |
| 121 | + async run(ctx, o) { | |
| 122 | + const repo = await ctx.repo(); | |
| 123 | + const state = str(o, "state") ?? "open"; | |
| 124 | + if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2); | |
| 125 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 126 | + const api = await ctx.api(); | |
| 127 | + let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { state: state === "merged" ? "closed" : state, per_page: Math.min(100, state === "merged" ? 100 : limit), creator: str(o, "author") }); | |
| 128 | + if (state === "merged") prs = prs.filter((p) => p.merged); | |
| 129 | + prs = prs.slice(0, limit); | |
| 130 | + if (ctx.json) return json(ctx.io, prs); | |
| 131 | + if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`); | |
| 132 | + const c = ctx.c; | |
| 133 | + ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))]))); | |
| 134 | + }, | |
| 135 | +}; | |
| 136 | + | |
| 137 | +async function getPull(ctx: Ctx, n: number) { | |
| 138 | + const repo = await ctx.repo(); | |
| 139 | + const api = await ctx.api(); | |
| 140 | + return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) }; | |
| 141 | +} | |
| 142 | + | |
| 143 | +const view: Command = { | |
| 144 | + name: "pr view", | |
| 145 | + summary: "Show a pull request", | |
| 146 | + args: "<number>", | |
| 147 | + options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } }, | |
| 148 | + repo: true, | |
| 149 | + json: true, | |
| 150 | + minArgs: 1, | |
| 151 | + maxArgs: 1, | |
| 152 | + async run(ctx, o, [num]) { | |
| 153 | + const n = positiveInt(num, "pull request number"); | |
| 154 | + const { pr, api, repo } = await getPull(ctx, n); | |
| 155 | + const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined; | |
| 156 | + if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr); | |
| 157 | + const c = ctx.c; | |
| 158 | + ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`); | |
| 159 | + ctx.out(`${prState(c, pr)} · ${pr.user.login} wants to merge ${c.cyan(pr.head?.ref ?? "?")} into ${c.cyan(pr.base?.ref ?? "?")} · ${ago(pr.created_at)}`); | |
| 160 | + if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`); | |
| 161 | + if (pr.state === "open") { | |
| 162 | + ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`); | |
| 163 | + for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`); | |
| 164 | + } | |
| 165 | + if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`); | |
| 166 | + if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`); | |
| 167 | + for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 168 | + ctx.out(c.dim(pr.html_url)); | |
| 169 | + }, | |
| 170 | +}; | |
| 171 | + | |
| 172 | +function checkSymbol(ctx: Ctx, state: string): string { | |
| 173 | + const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"]; | |
| 174 | + return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state); | |
| 175 | +} | |
| 176 | + | |
| 177 | +const diff: Command = { | |
| 178 | + name: "pr diff", | |
| 179 | + summary: "Show a pull request's changes as a unified diff", | |
| 180 | + args: "<number>", | |
| 181 | + options: { "name-only": { type: "boolean", desc: "Only list changed file names" } }, | |
| 182 | + repo: true, | |
| 183 | + minArgs: 1, | |
| 184 | + maxArgs: 1, | |
| 185 | + async run(ctx, o, [num]) { | |
| 186 | + const repo = await ctx.repo(); | |
| 187 | + const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`); | |
| 188 | + if (o["name-only"]) { | |
| 189 | + ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + const c = ctx.c; | |
| 193 | + for (const f of files) { | |
| 194 | + const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`; | |
| 195 | + if (!c.enabled) { | |
| 196 | + ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`); | |
| 197 | + continue; | |
| 198 | + } | |
| 199 | + for (const line of patch.replace(/\n$/, "").split("\n")) { | |
| 200 | + if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line)); | |
| 201 | + else if (line.startsWith("@@")) ctx.out(c.cyan(line)); | |
| 202 | + else if (line.startsWith("+")) ctx.out(c.green(line)); | |
| 203 | + else if (line.startsWith("-")) ctx.out(c.red(line)); | |
| 204 | + else ctx.out(line); | |
| 205 | + } | |
| 206 | + } | |
| 207 | + }, | |
| 208 | +}; | |
| 209 | + | |
| 210 | +const checks: Command = { | |
| 211 | + name: "pr checks", | |
| 212 | + summary: "Show check status for a pull request", | |
| 213 | + description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.", | |
| 214 | + args: "<number>", | |
| 215 | + repo: true, | |
| 216 | + json: true, | |
| 217 | + minArgs: 1, | |
| 218 | + maxArgs: 1, | |
| 219 | + async run(ctx, _o, [num]) { | |
| 220 | + const { pr } = await getPull(ctx, positiveInt(num, "pull request number")); | |
| 221 | + const list = pr.checks ?? []; | |
| 222 | + if (ctx.json) json(ctx.io, list); | |
| 223 | + else if (!list.length) ctx.err("No checks reported for this pull request."); | |
| 224 | + else ctx.io.stdout.write(table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"].includes(k.state) ? "completed" : k.state, k.state)]))); | |
| 225 | + const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state)); | |
| 226 | + const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state)); | |
| 227 | + throw new ExitCode(failed ? 1 : pending ? 8 : 0); | |
| 228 | + }, | |
| 229 | +}; | |
| 230 | + | |
| 231 | +const review: Command = { | |
| 232 | + name: "pr review", | |
| 233 | + summary: "Approve, request changes or comment on a pull request", | |
| 234 | + args: "<number>", | |
| 235 | + options: { | |
| 236 | + approve: { type: "boolean", short: "a", desc: "Approve" }, | |
| 237 | + "request-changes": { type: "boolean", short: "r", desc: "Request changes" }, | |
| 238 | + comment: { type: "boolean", short: "c", desc: "Comment without approving" }, | |
| 239 | + ...bodyOpts, | |
| 240 | + }, | |
| 241 | + repo: true, | |
| 242 | + json: true, | |
| 243 | + minArgs: 1, | |
| 244 | + maxArgs: 1, | |
| 245 | + async run(ctx, o, [num]) { | |
| 246 | + const n = positiveInt(num, "pull request number"); | |
| 247 | + const which = exactlyOne(o, ["approve", "request-changes", "comment"]); | |
| 248 | + const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT"; | |
| 249 | + const body = await bodyFrom(ctx, o); | |
| 250 | + if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2); | |
| 251 | + const repo = await ctx.repo(); | |
| 252 | + const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body }); | |
| 253 | + if (ctx.json) return json(ctx.io, r); | |
| 254 | + const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed"; | |
| 255 | + ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`); | |
| 256 | + }, | |
| 257 | +}; | |
| 258 | + | |
| 259 | +const merge: Command = { | |
| 260 | + name: "pr merge", | |
| 261 | + summary: "Merge a pull request", | |
| 262 | + args: "<number>", | |
| 263 | + options: { | |
| 264 | + merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" }, | |
| 265 | + squash: { type: "boolean", short: "s", desc: "Squash into one commit" }, | |
| 266 | + rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" }, | |
| 267 | + "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" }, | |
| 268 | + subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" }, | |
| 269 | + ...bodyOpts, | |
| 270 | + "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" }, | |
| 271 | + }, | |
| 272 | + repo: true, | |
| 273 | + json: true, | |
| 274 | + minArgs: 1, | |
| 275 | + maxArgs: 1, | |
| 276 | + async run(ctx, o, [num]) { | |
| 277 | + const n = positiveInt(num, "pull request number"); | |
| 278 | + const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); | |
| 279 | + const repo = await ctx.repo(); | |
| 280 | + const payload = { | |
| 281 | + merge_method: method, | |
| 282 | + delete_branch: o["delete-branch"] === true ? true : undefined, | |
| 283 | + commit_title: str(o, "subject"), | |
| 284 | + commit_message: await bodyFrom(ctx, o), | |
| 285 | + sha: str(o, "match-head-commit"), | |
| 286 | + }; | |
| 287 | + const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload); | |
| 288 | + if (ctx.json) return json(ctx.io, r); | |
| 289 | + ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`); | |
| 290 | + }, | |
| 291 | +}; | |
| 292 | + | |
| 293 | +const comment: Command = { | |
| 294 | + name: "pr comment", | |
| 295 | + summary: "Comment on a pull request", | |
| 296 | + args: "<number>", | |
| 297 | + options: bodyOpts, | |
| 298 | + repo: true, | |
| 299 | + json: true, | |
| 300 | + minArgs: 1, | |
| 301 | + maxArgs: 1, | |
| 302 | + async run(ctx, o, [num]) { | |
| 303 | + const n = positiveInt(num, "pull request number"); | |
| 304 | + const body = await bodyFrom(ctx, o, true); | |
| 305 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 306 | + const repo = await ctx.repo(); | |
| 307 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 308 | + if (ctx.json) return json(ctx.io, r); | |
| 309 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 310 | + }, | |
| 311 | +}; | |
| 312 | + | |
| 313 | +export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, comment] }; |
addedapps/cli/src/commands/repo.ts+114−0View file
| @@ -1,0 +1,114 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { enc } from "../api.js"; | |
| 3 | +import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 4 | +import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js"; | |
| 5 | +import { CliError } from "../io.js"; | |
| 6 | +import { ago, json, table, truncate } from "../output.js"; | |
| 7 | + | |
| 8 | +export interface RepoJson { | |
| 9 | + id: string; | |
| 10 | + name: string; | |
| 11 | + full_name: string; | |
| 12 | + owner: { login: string; type: string }; | |
| 13 | + private: boolean; | |
| 14 | + visibility: "public" | "internal" | "private"; | |
| 15 | + description: string | null; | |
| 16 | + default_branch: string; | |
| 17 | + archived: boolean; | |
| 18 | + html_url: string; | |
| 19 | + clone_url: string; | |
| 20 | + pushed_at: string | null; | |
| 21 | + updated_at: string; | |
| 22 | + permissions?: { role: string }; | |
| 23 | + stats?: Record<string, unknown>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const create: Command = { | |
| 27 | + name: "repo create", | |
| 28 | + summary: "Create a repository", | |
| 29 | + args: "<name>", | |
| 30 | + options: { | |
| 31 | + org: { type: "string", desc: "Create it in this organization", arg: "ORG" }, | |
| 32 | + private: { type: "boolean", desc: "Only collaborators can see it (default)" }, | |
| 33 | + internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" }, | |
| 34 | + public: { type: "boolean", desc: "Anyone can see it" }, | |
| 35 | + description: { type: "string", short: "d", desc: "Description", arg: "TEXT" }, | |
| 36 | + "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" }, | |
| 37 | + }, | |
| 38 | + json: true, | |
| 39 | + minArgs: 1, | |
| 40 | + maxArgs: 1, | |
| 41 | + async run(ctx, o, [name]) { | |
| 42 | + const visibility = exactlyOne(o, ["private", "internal", "public"], "private"); | |
| 43 | + const api = await ctx.authedApi(); | |
| 44 | + const org = str(o, "org"); | |
| 45 | + const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") }); | |
| 46 | + if (ctx.json) return json(ctx.io, r); | |
| 47 | + ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`); | |
| 48 | + ctx.out(r.html_url); | |
| 49 | + ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`)); | |
| 50 | + }, | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const listCmd: Command = { | |
| 54 | + name: "repo list", | |
| 55 | + summary: "List repositories you can see", | |
| 56 | + options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } }, | |
| 57 | + json: true, | |
| 58 | + maxArgs: 0, | |
| 59 | + async run(ctx, o) { | |
| 60 | + const api = await ctx.api(); | |
| 61 | + const owner = str(o, "owner"); | |
| 62 | + // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login. | |
| 63 | + const repos = await api.get<RepoJson[]>(owner ? `/users/${enc(owner)}/repos` : "/user/repos"); | |
| 64 | + if (ctx.json) return json(ctx.io, repos); | |
| 65 | + if (!repos.length) return ctx.err("No repositories."); | |
| 66 | + ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))]))); | |
| 67 | + }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const view: Command = { | |
| 71 | + name: "repo view", | |
| 72 | + summary: "Show a repository", | |
| 73 | + args: "[OWNER/NAME]", | |
| 74 | + repo: true, | |
| 75 | + json: true, | |
| 76 | + maxArgs: 1, | |
| 77 | + async run(ctx, _o, [arg]) { | |
| 78 | + const ref = arg ? parseRepoArg(arg) : await ctx.repo(); | |
| 79 | + const r = await (await ctx.api()).get<RepoJson>(repoPath(ref)); | |
| 80 | + if (ctx.json) return json(ctx.io, r); | |
| 81 | + const c = ctx.c; | |
| 82 | + ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`); | |
| 83 | + if (r.description) ctx.out(r.description); | |
| 84 | + const rows: string[][] = [ | |
| 85 | + ["default branch", r.default_branch], | |
| 86 | + ["web", r.html_url], | |
| 87 | + ["clone", r.clone_url], | |
| 88 | + ]; | |
| 89 | + if (r.permissions?.role) rows.push(["your role", r.permissions.role]); | |
| 90 | + if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]); | |
| 91 | + for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]); | |
| 92 | + ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!]))); | |
| 93 | + }, | |
| 94 | +}; | |
| 95 | + | |
| 96 | +const clone: Command = { | |
| 97 | + name: "repo clone", | |
| 98 | + summary: "Clone a repository", | |
| 99 | + description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.", | |
| 100 | + args: "<OWNER/NAME> [directory]", | |
| 101 | + minArgs: 1, | |
| 102 | + maxArgs: 2, | |
| 103 | + async run(ctx, _o, [arg, dir]) { | |
| 104 | + const ref = parseRepoArg(arg!); | |
| 105 | + const cfg = await ctx.config(); | |
| 106 | + const url = `${cfg.url}/${ref.owner}/${ref.name}.git`; | |
| 107 | + const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]); | |
| 108 | + const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env }); | |
| 109 | + if (code !== 0) throw new CliError(`git clone exited with ${code}`, code); | |
| 110 | + ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`)); | |
| 111 | + }, | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] }; |
addedapps/cli/src/commands/run.ts+213−0View file
| @@ -1,0 +1,213 @@ | ||
| 1 | +import { enc } from "../api.js"; | |
| 2 | +import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 3 | +import { CliError, ExitCode } from "../io.js"; | |
| 4 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 5 | + | |
| 6 | +export interface RunJson { | |
| 7 | + id: string; | |
| 8 | + run_number: number; | |
| 9 | + run_attempt: number; | |
| 10 | + name: string; | |
| 11 | + display_title: string; | |
| 12 | + path: string; | |
| 13 | + event: string; | |
| 14 | + head_branch: string; | |
| 15 | + head_sha: string; | |
| 16 | + status: string; | |
| 17 | + conclusion: string | null; | |
| 18 | + actor: { login: string }; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + html_url: string; | |
| 22 | + pull_request_number: number | null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface JobJson { | |
| 26 | + id: string; | |
| 27 | + runId: string; | |
| 28 | + key: string; | |
| 29 | + name: string; | |
| 30 | + status: string; | |
| 31 | + conclusion: string | null; | |
| 32 | + startedAt: string | null; | |
| 33 | + completedAt: string | null; | |
| 34 | + steps: Array<{ index: number; name?: string; status?: string; conclusion?: string | null }>; | |
| 35 | + failure: { message?: string } | null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +type RunDetail = RunJson & { jobs: JobJson[] }; | |
| 39 | + | |
| 40 | +function duration(a: string | null, b: string | null): string { | |
| 41 | + if (!a) return ""; | |
| 42 | + const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a); | |
| 43 | + if (!Number.isFinite(ms) || ms < 0) return ""; | |
| 44 | + const s = Math.round(ms / 1000); | |
| 45 | + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const listCmd: Command = { | |
| 49 | + name: "run list", | |
| 50 | + summary: "List workflow runs", | |
| 51 | + options: { | |
| 52 | + branch: { type: "string", short: "b", desc: "Only runs for this branch", arg: "BRANCH" }, | |
| 53 | + workflow: { type: "string", short: "w", desc: "Only runs of this workflow (file name or path)", arg: "FILE" }, | |
| 54 | + status: { type: "string", short: "s", desc: "Only runs with this status (queued, in_progress, completed, ...)", arg: "STATUS" }, | |
| 55 | + event: { type: "string", short: "e", desc: "Only runs triggered by this event", arg: "EVENT" }, | |
| 56 | + commit: { type: "string", short: "c", desc: "Only runs for this commit SHA", arg: "SHA" }, | |
| 57 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 20)", arg: "N" }, | |
| 58 | + }, | |
| 59 | + repo: true, | |
| 60 | + json: true, | |
| 61 | + maxArgs: 0, | |
| 62 | + async run(ctx, o) { | |
| 63 | + const repo = await ctx.repo(); | |
| 64 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 20; | |
| 65 | + const res = await (await ctx.api()).get<{ total_count: number; workflow_runs: RunJson[] }>(`${repoPath(repo)}/actions/runs`, { | |
| 66 | + branch: str(o, "branch"), | |
| 67 | + workflow: str(o, "workflow"), | |
| 68 | + status: str(o, "status"), | |
| 69 | + event: str(o, "event"), | |
| 70 | + head_sha: str(o, "commit"), | |
| 71 | + per_page: Math.min(limit, 100), | |
| 72 | + }); | |
| 73 | + if (ctx.json) return json(ctx.io, res.workflow_runs); | |
| 74 | + if (!res.workflow_runs.length) return ctx.err("No runs found."); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.io.stdout.write( | |
| 77 | + table([ | |
| 78 | + ["", "TITLE", "WORKFLOW", "BRANCH", "EVENT", "ID", "AGE"].map((h) => c.dim(h)), | |
| 79 | + ...res.workflow_runs.map((r) => [symbol(c, r.status, r.conclusion), truncate(r.display_title, 50), r.name, c.cyan(r.head_branch), r.event, r.id, c.dim(ago(r.created_at))]), | |
| 80 | + ]), | |
| 81 | + ); | |
| 82 | + }, | |
| 83 | +}; | |
| 84 | + | |
| 85 | +function printRun(ctx: Ctx, r: RunDetail): void { | |
| 86 | + const c = ctx.c; | |
| 87 | + ctx.out(`${symbol(c, r.status, r.conclusion)} ${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number}${r.run_attempt > 1 ? ` (attempt ${r.run_attempt})` : ""}`)}`); | |
| 88 | + ctx.out(`${statusText(c, r.status, r.conclusion)} · ${r.event} on ${c.cyan(r.head_branch)} ${c.dim(r.head_sha.slice(0, 7))} · ${r.actor.login} · ${ago(r.created_at)}`); | |
| 89 | + ctx.out(""); | |
| 90 | + ctx.io.stdout.write(table(r.jobs.map((j) => [symbol(c, j.status, j.conclusion), j.name, statusText(c, j.status, j.conclusion), c.dim(duration(j.startedAt, j.completedAt)), c.dim(`job ${j.id}`)]))); | |
| 91 | + for (const j of r.jobs) if (j.failure?.message) ctx.out(`${c.red("X")} ${j.name}: ${j.failure.message}`); | |
| 92 | + ctx.out(c.dim(r.html_url)); | |
| 93 | +} | |
| 94 | + | |
| 95 | +const view: Command = { | |
| 96 | + name: "run view", | |
| 97 | + summary: "Show a run and its jobs", | |
| 98 | + args: "<run-id>", | |
| 99 | + repo: true, | |
| 100 | + json: true, | |
| 101 | + minArgs: 1, | |
| 102 | + maxArgs: 1, | |
| 103 | + async run(ctx, _o, [id]) { | |
| 104 | + const repo = await ctx.repo(); | |
| 105 | + const r = await (await ctx.api()).get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 106 | + if (ctx.json) return json(ctx.io, r); | |
| 107 | + printRun(ctx, r); | |
| 108 | + }, | |
| 109 | +}; | |
| 110 | + | |
| 111 | +const watch: Command = { | |
| 112 | + name: "run watch", | |
| 113 | + summary: "Follow a run until it completes", | |
| 114 | + description: "Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds.", | |
| 115 | + args: "<run-id>", | |
| 116 | + options: { interval: { type: "string", short: "i", desc: "Seconds between polls (default 3)", arg: "SECONDS" } }, | |
| 117 | + repo: true, | |
| 118 | + minArgs: 1, | |
| 119 | + maxArgs: 1, | |
| 120 | + async run(ctx, o, [id]) { | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.api(); | |
| 123 | + const interval = Math.max(0, Number(str(o, "interval") ?? 3)) * 1000; | |
| 124 | + if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); | |
| 125 | + const c = ctx.c; | |
| 126 | + const seen = new Map<string, string>(); | |
| 127 | + let first = true; | |
| 128 | + let failures = 0; | |
| 129 | + for (;;) { | |
| 130 | + let r: RunDetail; | |
| 131 | + try { | |
| 132 | + r = await api.get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 133 | + failures = 0; | |
| 134 | + } catch (e) { | |
| 135 | + // Ride out transient network/server errors; give up on client errors. | |
| 136 | + const status = (e as { status?: number }).status; | |
| 137 | + if ((status && status < 500 && status !== 429) || ++failures > 5) throw e; | |
| 138 | + await ctx.io.sleep(interval); | |
| 139 | + continue; | |
| 140 | + } | |
| 141 | + if (first) { | |
| 142 | + ctx.out(`${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number} · ${r.html_url}`)}`); | |
| 143 | + first = false; | |
| 144 | + } | |
| 145 | + for (const j of r.jobs) { | |
| 146 | + const key = `${j.status}/${j.conclusion ?? ""}`; | |
| 147 | + if (seen.get(j.id) === key) continue; | |
| 148 | + seen.set(j.id, key); | |
| 149 | + const extra = j.status === "completed" ? ` ${c.dim(duration(j.startedAt, j.completedAt))}` : ""; | |
| 150 | + ctx.out(`${symbol(c, j.status, j.conclusion)} ${j.name}: ${statusText(c, j.status, j.conclusion)}${extra}${j.failure?.message && j.status === "completed" ? ` ${c.dim(`(${j.failure.message})`)}` : ""}`); | |
| 151 | + } | |
| 152 | + if (r.status === "completed") { | |
| 153 | + ctx.out(`${symbol(c, r.status, r.conclusion)} Run ${r.id} completed: ${statusText(c, r.status, r.conclusion)}`); | |
| 154 | + if (r.conclusion !== "success") { | |
| 155 | + const failed = r.jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); | |
| 156 | + if (failed) ctx.err(c.dim(`See the log: diffinery run logs ${failed.id} -R ${repo.owner}/${repo.name}`)); | |
| 157 | + } | |
| 158 | + throw new ExitCode(r.conclusion === "success" ? 0 : 1); | |
| 159 | + } | |
| 160 | + await ctx.io.sleep(interval); | |
| 161 | + } | |
| 162 | + }, | |
| 163 | +}; | |
| 164 | + | |
| 165 | +const logs: Command = { | |
| 166 | + name: "run logs", | |
| 167 | + summary: "Print a job's log", | |
| 168 | + args: "<job-id>", | |
| 169 | + repo: true, | |
| 170 | + json: true, | |
| 171 | + minArgs: 1, | |
| 172 | + maxArgs: 1, | |
| 173 | + async run(ctx, _o, [id]) { | |
| 174 | + const repo = await ctx.repo(); | |
| 175 | + const api = await ctx.api(); | |
| 176 | + if (ctx.json) return json(ctx.io, await api.get(`${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { format: "json" })); | |
| 177 | + const res = await api.raw<string>("GET", `${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { text: true }); | |
| 178 | + ctx.io.stdout.write(res.data.endsWith("\n") || !res.data ? res.data : `${res.data}\n`); | |
| 179 | + }, | |
| 180 | +}; | |
| 181 | + | |
| 182 | +const rerun: Command = { | |
| 183 | + name: "run rerun", | |
| 184 | + summary: "Re-run a workflow run", | |
| 185 | + args: "<run-id>", | |
| 186 | + options: { failed: { type: "boolean", desc: "Only re-run failed jobs (where supported)" } }, | |
| 187 | + repo: true, | |
| 188 | + json: true, | |
| 189 | + minArgs: 1, | |
| 190 | + maxArgs: 1, | |
| 191 | + async run(ctx, o, [id]) { | |
| 192 | + const repo = await ctx.repo(); | |
| 193 | + const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`); | |
| 194 | + if (ctx.json) return json(ctx.io, r); | |
| 195 | + ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`); | |
| 196 | + }, | |
| 197 | +}; | |
| 198 | + | |
| 199 | +const cancel: Command = { | |
| 200 | + name: "run cancel", | |
| 201 | + summary: "Cancel a queued or running run", | |
| 202 | + args: "<run-id>", | |
| 203 | + repo: true, | |
| 204 | + minArgs: 1, | |
| 205 | + maxArgs: 1, | |
| 206 | + async run(ctx, _o, [id]) { | |
| 207 | + const repo = await ctx.repo(); | |
| 208 | + await (await ctx.authedApi()).post(`${repoPath(repo)}/actions/runs/${enc(id!)}/cancel`); | |
| 209 | + ctx.out(`${ctx.c.green("✓")} Cancellation requested for run ${id}`); | |
| 210 | + }, | |
| 211 | +}; | |
| 212 | + | |
| 213 | +export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] }; |
addedapps/cli/src/commands/runner.ts+169−0View file
| @@ -1,0 +1,169 @@ | ||
| 1 | +import { spawn, type ChildProcess } from "node:child_process"; | |
| 2 | +import { existsSync } from "node:fs"; | |
| 3 | +import { mkdir } from "node:fs/promises"; | |
| 4 | +import { homedir, hostname } from "node:os"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import { createInterface } from "node:readline"; | |
| 7 | +import { ApiError, type Api } from "../api.js"; | |
| 8 | +import { list, parseRepoArg, repoPath, str, type Command, type Ctx, type RepoRef } from "../cli.js"; | |
| 9 | +import { CliError, type Io } from "../io.js"; | |
| 10 | + | |
| 11 | +export interface ClaimedJob { | |
| 12 | + job_id: string; | |
| 13 | + run_id: string; | |
| 14 | + name: string; | |
| 15 | + token: string; | |
| 16 | + api_url: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +const AGENT_HELP = `The runner needs the Diffinery runner agent (a single Node.js file). Build it from the | |
| 20 | +Diffinery repository with: | |
| 21 | + | |
| 22 | + pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs | |
| 23 | + | |
| 24 | +then pass --agent /path/to/agent.mjs or set DIFFINERY_RUNNER_AGENT.`; | |
| 25 | + | |
| 26 | +export function resolveAgentPath(io: Io, flag: string | undefined): string { | |
| 27 | + const p = flag ?? io.env.DIFFINERY_RUNNER_AGENT; | |
| 28 | + if (!p) throw new CliError(`no runner agent given.\n\n${AGENT_HELP}`); | |
| 29 | + const abs = path.resolve(io.cwd, p); | |
| 30 | + if (!existsSync(abs)) throw new CliError(`runner agent not found at ${abs}.\n\n${AGENT_HELP}`); | |
| 31 | + return abs; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** POST .../actions/runners/claim: a job, or null when nothing is waiting (204). */ | |
| 35 | +export async function claim(api: Api, repo: RepoRef, name: string, labels: string[]): Promise<ClaimedJob | null> { | |
| 36 | + const res = await api.raw<ClaimedJob | null>("POST", `${repoPath(repo)}/actions/runners/claim`, { body: { name, labels } }); | |
| 37 | + if (res.status === 204 || !res.data || typeof res.data !== "object") return null; | |
| 38 | + return res.data; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Environment for the agent: the job's credentials, minus the CLI's own token. */ | |
| 42 | +export function agentEnv(base: NodeJS.ProcessEnv, job: ClaimedJob, workRoot: string, echo: boolean): NodeJS.ProcessEnv { | |
| 43 | + const env: NodeJS.ProcessEnv = { ...base }; | |
| 44 | + for (const k of ["DIFFINERY_TOKEN", "DIFFINERY_URL", "DIFFINERY_RUNNER_AGENT", "GH_TOKEN", "GITHUB_TOKEN"]) delete env[k]; | |
| 45 | + env.DIFFINERY_API_URL = job.api_url; | |
| 46 | + env.DIFFINERY_JOB_ID = job.job_id; | |
| 47 | + env.DIFFINERY_JOB_TOKEN = job.token; | |
| 48 | + env.DIFFINERY_WORK_ROOT ??= workRoot; | |
| 49 | + if (echo) env.DIFFINERY_ECHO_LOGS = "1"; | |
| 50 | + return env; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function pipeLines(stream: NodeJS.ReadableStream, prefix: string, write: (s: string) => void): Promise<void> { | |
| 54 | + return new Promise((resolve) => { | |
| 55 | + const rl = createInterface({ input: stream, crlfDelay: Infinity }); | |
| 56 | + rl.on("line", (l) => write(`${prefix}${l}\n`)); | |
| 57 | + rl.on("close", () => resolve()); | |
| 58 | + }); | |
| 59 | +} | |
| 60 | + | |
| 61 | +async function runJob(ctx: Ctx, agent: string, job: ClaimedJob, repo: RepoRef, workRoot: string, echo: boolean, onChild: (c: ChildProcess | null) => void): Promise<number> { | |
| 62 | + const c = ctx.c; | |
| 63 | + const prefix = c.dim(`[${job.name}] `); | |
| 64 | + ctx.out(`${c.cyan("→")} ${repo.owner}/${repo.name}: running job ${c.bold(job.name)} ${c.dim(`(run ${job.run_id}, job ${job.job_id})`)}`); | |
| 65 | + await mkdir(workRoot, { recursive: true }); | |
| 66 | + const child = spawn(process.execPath, [agent], { cwd: workRoot, env: agentEnv(ctx.io.env, job, workRoot, echo), stdio: ["ignore", "pipe", "pipe"] }); | |
| 67 | + onChild(child); | |
| 68 | + const done = Promise.all([pipeLines(child.stdout!, prefix, (s) => ctx.io.stdout.write(s)), pipeLines(child.stderr!, prefix, (s) => ctx.io.stderr.write(s))]); | |
| 69 | + const code = await new Promise<number>((resolve) => { | |
| 70 | + child.on("error", (e) => { | |
| 71 | + ctx.err(`${c.red("error:")} could not start the agent: ${e.message}`); | |
| 72 | + resolve(1); | |
| 73 | + }); | |
| 74 | + child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 + (signal === "SIGKILL" ? 9 : 15) : 1))); | |
| 75 | + }); | |
| 76 | + await done; | |
| 77 | + onChild(null); | |
| 78 | + ctx.out(`${code === 0 ? c.green("✓") : c.red("X")} job ${job.name} finished${code === 0 ? "" : ` with exit code ${code}`}`); | |
| 79 | + return code; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export const runnerCommand: Command = { | |
| 83 | + name: "runner", | |
| 84 | + summary: "Run self-hosted workflow jobs on this machine", | |
| 85 | + description: `Polls the given repositories for jobs with 'runs-on: [self-hosted, ...]' whose labels this | |
| 86 | +runner offers, and runs each with the Diffinery runner agent. Requires admin access to the | |
| 87 | +repositories. Jobs run directly on this machine with your user's permissions. | |
| 88 | + | |
| 89 | +${AGENT_HELP}`, | |
| 90 | + options: { | |
| 91 | + repo: { type: "string", short: "R", multiple: true, desc: "Repository to take jobs from (default: the git origin)", arg: "OWNER/NAME" }, | |
| 92 | + labels: { type: "string", short: "l", desc: "Extra labels this runner offers, comma separated ('self-hosted' is implied)", arg: "a,b" }, | |
| 93 | + name: { type: "string", short: "n", desc: "Runner name (default: host name)", arg: "NAME" }, | |
| 94 | + once: { type: "boolean", desc: "Run at most one job, then exit with its exit code" }, | |
| 95 | + agent: { type: "string", short: "a", desc: "Path to the runner agent (agent.mjs); default DIFFINERY_RUNNER_AGENT", arg: "PATH" }, | |
| 96 | + "work-dir": { type: "string", short: "w", desc: "Working directory for jobs (default ~/.cache/diffinery/runner/<name>)", arg: "DIR" }, | |
| 97 | + interval: { type: "string", desc: "Seconds between polls when idle (default 5)", arg: "SECONDS" }, | |
| 98 | + quiet: { type: "boolean", short: "q", desc: "Do not echo step output locally (logs still go to the server)" }, | |
| 99 | + }, | |
| 100 | + maxArgs: 0, | |
| 101 | + async run(ctx, o) { | |
| 102 | + const agent = resolveAgentPath(ctx.io, str(o, "agent")); | |
| 103 | + const repos = list(o, "repo").length ? list(o, "repo").map(parseRepoArg) : [await ctx.repo()]; | |
| 104 | + const labels = (str(o, "labels") ?? "").split(",").map((s) => s.trim()).filter(Boolean); | |
| 105 | + const name = str(o, "name") ?? hostname(); | |
| 106 | + const intervalMs = Math.max(0, Number(str(o, "interval") ?? 5)) * 1000; | |
| 107 | + if (!Number.isFinite(intervalMs)) throw new CliError("--interval must be a number", 2); | |
| 108 | + const cacheHome = ctx.io.env.XDG_CACHE_HOME && path.isAbsolute(ctx.io.env.XDG_CACHE_HOME) ? ctx.io.env.XDG_CACHE_HOME : path.join(ctx.io.env.HOME || homedir(), ".cache"); | |
| 109 | + const workRoot = path.resolve(ctx.io.cwd, str(o, "work-dir") ?? path.join(cacheHome, "diffinery", "runner", name.replace(/[^A-Za-z0-9._-]/g, "_"))); | |
| 110 | + const api = await ctx.authedApi(); | |
| 111 | + const c = ctx.c; | |
| 112 | + | |
| 113 | + let stopping = false; | |
| 114 | + let child: ChildProcess | null = null; | |
| 115 | + const abort = new AbortController(); | |
| 116 | + let signals = 0; | |
| 117 | + const onSignal = () => { | |
| 118 | + signals++; | |
| 119 | + stopping = true; | |
| 120 | + abort.abort(); | |
| 121 | + if (child && child.exitCode === null) { | |
| 122 | + if (signals === 1) { | |
| 123 | + ctx.err(c.yellow("Stopping: asking the running job to cancel (press Ctrl-C again to kill it)…")); | |
| 124 | + child.kill("SIGTERM"); | |
| 125 | + } else child.kill("SIGKILL"); | |
| 126 | + } | |
| 127 | + }; | |
| 128 | + process.on("SIGINT", onSignal); | |
| 129 | + process.on("SIGTERM", onSignal); | |
| 130 | + | |
| 131 | + try { | |
| 132 | + ctx.out(`Runner ${c.bold(name)} offering [${["self-hosted", ...labels].join(", ")}] for ${repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`); | |
| 133 | + if (!o.once) ctx.out(c.dim("Waiting for jobs. Press Ctrl-C to stop.")); | |
| 134 | + let lastError = ""; | |
| 135 | + while (!stopping) { | |
| 136 | + let ran = false; | |
| 137 | + for (const repo of repos) { | |
| 138 | + if (stopping) break; | |
| 139 | + let job: ClaimedJob | null; | |
| 140 | + try { | |
| 141 | + job = await claim(api, repo, name, labels); | |
| 142 | + lastError = ""; | |
| 143 | + } catch (e) { | |
| 144 | + if (o.once || (e instanceof ApiError && [401, 403, 404].includes(e.status))) throw e; | |
| 145 | + const msg = (e as Error).message; | |
| 146 | + if (msg !== lastError) ctx.err(`${c.yellow("warning:")} ${msg} (will retry)`); | |
| 147 | + lastError = msg; | |
| 148 | + continue; | |
| 149 | + } | |
| 150 | + if (!job) continue; | |
| 151 | + ran = true; | |
| 152 | + const code = await runJob(ctx, agent, job, repo, workRoot, !o.quiet, (ch) => (child = ch)); | |
| 153 | + if (o.once) return code; | |
| 154 | + if (stopping) break; | |
| 155 | + } | |
| 156 | + if (o.once) { | |
| 157 | + ctx.out("No jobs waiting."); | |
| 158 | + return 0; | |
| 159 | + } | |
| 160 | + if (!ran && !stopping) await ctx.io.sleep(intervalMs, abort.signal); | |
| 161 | + } | |
| 162 | + ctx.out("Runner stopped."); | |
| 163 | + return 0; | |
| 164 | + } finally { | |
| 165 | + process.off("SIGINT", onSignal); | |
| 166 | + process.off("SIGTERM", onSignal); | |
| 167 | + } | |
| 168 | + }, | |
| 169 | +}; |
addedapps/cli/src/commands/workflow.ts+140−0View file
| @@ -1,0 +1,140 @@ | ||
| 1 | +import { readdir, readFile, stat } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml"; | |
| 4 | +import { enc } from "../api.js"; | |
| 5 | +import { list, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 6 | +import { gitCapture } from "../git.js"; | |
| 7 | +import { CliError } from "../io.js"; | |
| 8 | +import { json, table } from "../output.js"; | |
| 9 | +import type { RepoJson } from "./repo.js"; | |
| 10 | + | |
| 11 | +export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; | |
| 12 | +const isYaml = (f: string) => /\.ya?ml$/i.test(f); | |
| 13 | + | |
| 14 | +/** Parses -f key=value pairs into workflow_dispatch inputs. */ | |
| 15 | +export function parseInputs(pairs: string[]): Record<string, string> { | |
| 16 | + const out: Record<string, string> = {}; | |
| 17 | + for (const p of pairs) { | |
| 18 | + const i = p.indexOf("="); | |
| 19 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${p}"`, 2); | |
| 20 | + out[p.slice(0, i)] = p.slice(i + 1); | |
| 21 | + } | |
| 22 | + return out; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const run: Command = { | |
| 26 | + name: "workflow run", | |
| 27 | + summary: "Start a workflow_dispatch run", | |
| 28 | + args: "<workflow-file>", | |
| 29 | + description: "The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml).", | |
| 30 | + options: { | |
| 31 | + ref: { type: "string", short: "r", desc: "Branch or tag to run on (default: the default branch)", arg: "REF" }, | |
| 32 | + field: { type: "string", short: "f", multiple: true, desc: "Input value", arg: "KEY=VALUE" }, | |
| 33 | + }, | |
| 34 | + repo: true, | |
| 35 | + json: true, | |
| 36 | + minArgs: 1, | |
| 37 | + maxArgs: 1, | |
| 38 | + async run(ctx, o, [file]) { | |
| 39 | + const inputs = parseInputs(list(o, "field")); | |
| 40 | + const repo = await ctx.repo(); | |
| 41 | + const api = await ctx.authedApi(); | |
| 42 | + const ref = str(o, "ref") ?? (await api.get<RepoJson>(repoPath(repo))).default_branch; | |
| 43 | + const wf = file!.replace(/^\.\//, ""); | |
| 44 | + const r = await api.post<{ run_id: string; html_url: string }>(`${repoPath(repo)}/actions/workflows/${enc(wf)}/dispatches`, { ref, inputs }); | |
| 45 | + if (ctx.json) return json(ctx.io, r); | |
| 46 | + ctx.out(`${ctx.c.green("✓")} Started run ${r.run_id} of ${wf} on ${ref}`); | |
| 47 | + ctx.out(r.html_url); | |
| 48 | + ctx.err(ctx.c.dim(`Follow it: diffinery run watch ${r.run_id} -R ${repo.owner}/${repo.name}`)); | |
| 49 | + }, | |
| 50 | +}; | |
| 51 | + | |
| 52 | +const listCmd: Command = { | |
| 53 | + name: "workflow list", | |
| 54 | + summary: "List the repository's workflows", | |
| 55 | + repo: true, | |
| 56 | + json: true, | |
| 57 | + maxArgs: 0, | |
| 58 | + async run(ctx) { | |
| 59 | + const repo = await ctx.repo(); | |
| 60 | + const res = await (await ctx.api()).get<{ workflows: Array<{ name: string; path: string; state: string; errors: string[]; triggers?: string[] }> }>(`${repoPath(repo)}/actions/workflows`); | |
| 61 | + if (ctx.json) return json(ctx.io, res.workflows); | |
| 62 | + if (!res.workflows.length) return ctx.err("No workflows."); | |
| 63 | + const c = ctx.c; | |
| 64 | + ctx.io.stdout.write(table(res.workflows.map((w) => [w.name, c.dim(w.path), w.state === "active" ? c.green(w.state) : c.red(w.state), c.dim((w.triggers ?? []).join(", "))]))); | |
| 65 | + }, | |
| 66 | +}; | |
| 67 | + | |
| 68 | +async function collectFiles(cwd: string, root: string, args: string[]): Promise<string[]> { | |
| 69 | + const out: string[] = []; | |
| 70 | + const addDir = async (dir: string, explicit: boolean) => { | |
| 71 | + let entries: string[]; | |
| 72 | + try { | |
| 73 | + entries = await readdir(dir); | |
| 74 | + } catch (e) { | |
| 75 | + if (!explicit && (e as NodeJS.ErrnoException).code === "ENOENT") return; | |
| 76 | + throw new CliError(`cannot read ${dir}: ${(e as Error).message}`); | |
| 77 | + } | |
| 78 | + for (const f of entries.sort()) if (isYaml(f)) out.push(path.join(dir, f)); | |
| 79 | + }; | |
| 80 | + if (!args.length) { | |
| 81 | + for (const d of WORKFLOW_DIRS) await addDir(path.join(root, d), false); | |
| 82 | + return out; | |
| 83 | + } | |
| 84 | + for (const a of args) { | |
| 85 | + const p = path.resolve(cwd, a); | |
| 86 | + let s; | |
| 87 | + try { | |
| 88 | + s = await stat(p); | |
| 89 | + } catch { | |
| 90 | + throw new CliError(`no such file: ${a}`); | |
| 91 | + } | |
| 92 | + if (s.isDirectory()) await addDir(p, true); | |
| 93 | + else out.push(p); | |
| 94 | + } | |
| 95 | + return out; | |
| 96 | +} | |
| 97 | + | |
| 98 | +const validate: Command = { | |
| 99 | + name: "workflow validate", | |
| 100 | + summary: "Check workflow files for errors", | |
| 101 | + args: "[files or directories...]", | |
| 102 | + description: `Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the | |
| 103 | +current repository. Uses the same parser as the server. Exits 1 when any file has errors.`, | |
| 104 | + json: true, | |
| 105 | + async run(ctx, _o, args) { | |
| 106 | + const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"]); | |
| 107 | + const root = top.code === 0 && top.stdout.trim() ? top.stdout.trim() : ctx.io.cwd; | |
| 108 | + const files = await collectFiles(ctx.io.cwd, root, args); | |
| 109 | + if (!files.length) { | |
| 110 | + if (ctx.json) return json(ctx.io, []); | |
| 111 | + ctx.err(`No workflow files found in ${WORKFLOW_DIRS.join(" or ")}.`); | |
| 112 | + return 0; | |
| 113 | + } | |
| 114 | + const c = ctx.c; | |
| 115 | + const results = []; | |
| 116 | + let errors = 0; | |
| 117 | + let warnings = 0; | |
| 118 | + for (const f of files) { | |
| 119 | + const rel = path.relative(ctx.io.cwd, f) || f; | |
| 120 | + const relRoot = path.relative(root, f).split(path.sep).join("/"); | |
| 121 | + const source = await readFile(f, "utf8"); | |
| 122 | + const res = parseWorkflow(source, relRoot.startsWith("..") ? rel : relRoot); | |
| 123 | + const items = res.errors.map((e) => ({ ...e, severity: isWarning(e) ? "warning" : "error", message: isWarning(e) ? e.message.replace(/^warning:\s*/, "") : e.message })); | |
| 124 | + results.push({ file: rel, valid: !items.some((i) => i.severity === "error"), problems: items }); | |
| 125 | + for (const i of items) { | |
| 126 | + if (i.severity === "error") errors++; | |
| 127 | + else warnings++; | |
| 128 | + if (ctx.json) continue; | |
| 129 | + const loc = `${rel}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ""}` : ""}`; | |
| 130 | + ctx.out(`${c.bold(loc)}: ${i.severity === "error" ? c.red("error") : c.yellow("warning")}: ${i.message}${i.path ? c.dim(` (${i.path})`) : ""}`); | |
| 131 | + } | |
| 132 | + if (!ctx.json && !items.length) ctx.out(`${c.green("✓")} ${rel}`); | |
| 133 | + } | |
| 134 | + if (ctx.json) json(ctx.io, results); | |
| 135 | + else ctx.err(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors ? c.red(`${errors} error${errors === 1 ? "" : "s"}`) : "no errors"}, ${warnings} warning${warnings === 1 ? "" : "s"}`); | |
| 136 | + return errors ? 1 : 0; | |
| 137 | + }, | |
| 138 | +}; | |
| 139 | + | |
| 140 | +export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] }; |
addedapps/cli/src/config.ts+89−0View file
| @@ -1,0 +1,89 @@ | ||
| 1 | +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 2 | +import { homedir } from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { CliError, type Io } from "./io.js"; | |
| 5 | + | |
| 6 | +export const DEFAULT_URL = "https://diffinery.com"; | |
| 7 | + | |
| 8 | +export interface StoredConfig { | |
| 9 | + url?: string; | |
| 10 | + token?: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ResolvedConfig { | |
| 14 | + url: string; | |
| 15 | + token: string | null; | |
| 16 | + /** Where the token came from, for messages. */ | |
| 17 | + tokenSource: "env" | "config" | null; | |
| 18 | + urlSource: "env" | "config" | "default"; | |
| 19 | + file: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function configDir(env: NodeJS.ProcessEnv): string { | |
| 23 | + const xdg = env.XDG_CONFIG_HOME; | |
| 24 | + const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || homedir(), ".config"); | |
| 25 | + return path.join(base, "diffinery"); | |
| 26 | +} | |
| 27 | + | |
| 28 | +export const configPath = (env: NodeJS.ProcessEnv) => path.join(configDir(env), "config.json"); | |
| 29 | + | |
| 30 | +export async function readConfig(env: NodeJS.ProcessEnv): Promise<StoredConfig> { | |
| 31 | + const file = configPath(env); | |
| 32 | + let text: string; | |
| 33 | + try { | |
| 34 | + text = await readFile(file, "utf8"); | |
| 35 | + } catch (e) { | |
| 36 | + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; | |
| 37 | + throw e; | |
| 38 | + } | |
| 39 | + try { | |
| 40 | + const v = JSON.parse(text) as unknown; | |
| 41 | + if (!v || typeof v !== "object") return {}; | |
| 42 | + const o = v as Record<string, unknown>; | |
| 43 | + return { url: typeof o.url === "string" ? o.url : undefined, token: typeof o.token === "string" ? o.token : undefined }; | |
| 44 | + } catch { | |
| 45 | + throw new CliError(`${file} is not valid JSON; fix or delete it`); | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function writeConfig(env: NodeJS.ProcessEnv, cfg: StoredConfig): Promise<string> { | |
| 50 | + const dir = configDir(env); | |
| 51 | + await mkdir(dir, { recursive: true, mode: 0o700 }); | |
| 52 | + const file = configPath(env); | |
| 53 | + const clean: StoredConfig = {}; | |
| 54 | + if (cfg.url) clean.url = cfg.url; | |
| 55 | + if (cfg.token) clean.token = cfg.token; | |
| 56 | + await writeFile(file, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600 }); | |
| 57 | + await chmod(file, 0o600); // writeFile's mode only applies on create | |
| 58 | + return file; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */ | |
| 62 | +export function normalizeUrl(u: string): string { | |
| 63 | + let s = u.trim(); | |
| 64 | + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`; | |
| 65 | + let parsed: URL; | |
| 66 | + try { | |
| 67 | + parsed = new URL(s); | |
| 68 | + } catch { | |
| 69 | + throw new CliError(`invalid URL: ${u}`); | |
| 70 | + } | |
| 71 | + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`); | |
| 72 | + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Env vars override the config file; the URL falls back to the public instance. */ | |
| 76 | +export async function resolveConfig(io: Io): Promise<ResolvedConfig> { | |
| 77 | + const stored = await readConfig(io.env); | |
| 78 | + const envUrl = io.env.DIFFINERY_URL?.trim(); | |
| 79 | + const envToken = io.env.DIFFINERY_TOKEN?.trim(); | |
| 80 | + const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL; | |
| 81 | + const token = envToken || stored.token || null; | |
| 82 | + return { | |
| 83 | + url, | |
| 84 | + token, | |
| 85 | + tokenSource: envToken ? "env" : stored.token ? "config" : null, | |
| 86 | + urlSource: envUrl ? "env" : stored.url ? "config" : "default", | |
| 87 | + file: configPath(io.env), | |
| 88 | + }; | |
| 89 | +} |
addedapps/cli/src/git.ts+103−0View file
| @@ -1,0 +1,103 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { CliError, type Io } from "./io.js"; | |
| 3 | + | |
| 4 | +export interface GitResult { | |
| 5 | + code: number; | |
| 6 | + stdout: string; | |
| 7 | + stderr: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Runs git and captures its output. */ | |
| 11 | +export function gitCapture(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> { | |
| 12 | + return new Promise((resolve) => { | |
| 13 | + let child; | |
| 14 | + try { | |
| 15 | + child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); | |
| 16 | + } catch (e) { | |
| 17 | + return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) }); | |
| 18 | + } | |
| 19 | + let stdout = ""; | |
| 20 | + let stderr = ""; | |
| 21 | + child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 22 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 23 | + child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message })); | |
| 24 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 25 | + if (child.stdin) { | |
| 26 | + child.stdin.on("error", () => {}); // git may exit before reading its input | |
| 27 | + child.stdin.end(o.input); | |
| 28 | + } | |
| 29 | + }); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Runs git with output going to the user's terminal (clone/push progress). */ | |
| 33 | +export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> { | |
| 34 | + return new Promise((resolve, reject) => { | |
| 35 | + const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] }); | |
| 36 | + child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString())); | |
| 37 | + child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString())); | |
| 38 | + child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`))); | |
| 39 | + child.on("close", (code) => resolve(code ?? 1)); | |
| 40 | + }); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> { | |
| 44 | + const r = await gitCapture(io, args, o); | |
| 45 | + if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`); | |
| 46 | + return r.stdout.trim(); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * Environment that adds one-off git config for a single git invocation | |
| 51 | + * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up | |
| 52 | + * in the process list, and like `-c` they are never written to .git/config. | |
| 53 | + */ | |
| 54 | +export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv { | |
| 55 | + const out = { ...env }; | |
| 56 | + let n = Number(out.GIT_CONFIG_COUNT ?? 0); | |
| 57 | + if (!Number.isInteger(n) || n < 0) n = 0; | |
| 58 | + for (const [k, v] of entries) { | |
| 59 | + out[`GIT_CONFIG_KEY_${n}`] = k; | |
| 60 | + out[`GIT_CONFIG_VALUE_${n}`] = v; | |
| 61 | + n++; | |
| 62 | + } | |
| 63 | + out.GIT_CONFIG_COUNT = String(n); | |
| 64 | + return out; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */ | |
| 68 | +export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] { | |
| 69 | + return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`]; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */ | |
| 73 | +export function parseRemote(remote: string, host: string): { owner: string; name: string } | null { | |
| 74 | + const r = remote.trim(); | |
| 75 | + let h: string; | |
| 76 | + let p: string; | |
| 77 | + const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r); | |
| 78 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) { | |
| 79 | + try { | |
| 80 | + const u = new URL(r); | |
| 81 | + h = u.host; | |
| 82 | + p = decodeURIComponent(u.pathname); | |
| 83 | + if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port | |
| 84 | + } catch { | |
| 85 | + return null; | |
| 86 | + } | |
| 87 | + } else if (scp) { | |
| 88 | + h = scp[1]!; | |
| 89 | + p = scp[2]!; | |
| 90 | + } else return null; | |
| 91 | + const want = host.toLowerCase(); | |
| 92 | + if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null; | |
| 93 | + const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/"); | |
| 94 | + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; | |
| 95 | + const name = parts[1].replace(/\.git$/, ""); | |
| 96 | + return name ? { owner: parts[0], name } : null; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function currentBranch(io: Io): Promise<string> { | |
| 100 | + const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | |
| 101 | + if (r.code !== 0 || !r.stdout.trim()) throw new CliError("could not determine the current branch (detached HEAD or not a git repository); pass --head"); | |
| 102 | + return r.stdout.trim(); | |
| 103 | +} |
addedapps/cli/src/github.ts+127−0View file
| @@ -1,0 +1,127 @@ | ||
| 1 | +import { CliError } from "./io.js"; | |
| 2 | +import { VERSION } from "./version.js"; | |
| 3 | + | |
| 4 | +export class GitHubError extends CliError { | |
| 5 | + constructor( | |
| 6 | + readonly status: number, | |
| 7 | + message: string, | |
| 8 | + ) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface GitHubOptions { | |
| 14 | + baseUrl?: string; | |
| 15 | + token?: string | null; | |
| 16 | + sleep: (ms: number) => Promise<void>; | |
| 17 | + log?: (msg: string) => void; | |
| 18 | + /** Longest single wait for a rate-limit reset before giving up (default 1h). */ | |
| 19 | + maxWaitMs?: number; | |
| 20 | + now?: () => number; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Next-page URL from a GitHub `Link` header. */ | |
| 24 | +export function nextLink(link: string | null): string | null { | |
| 25 | + if (!link) return null; | |
| 26 | + for (const part of link.split(",")) { | |
| 27 | + const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part); | |
| 28 | + if (m) return m[1]!; | |
| 29 | + } | |
| 30 | + return null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Minimal GitHub REST client with pagination and rate-limit handling. */ | |
| 34 | +export class GitHub { | |
| 35 | + readonly baseUrl: string; | |
| 36 | + constructor(private o: GitHubOptions) { | |
| 37 | + this.baseUrl = (o.baseUrl ?? "https://api.github.com").replace(/\/+$/, ""); | |
| 38 | + } | |
| 39 | + | |
| 40 | + private async fetchRetrying(url: string): Promise<Response> { | |
| 41 | + const now = this.o.now ?? Date.now; | |
| 42 | + const maxWait = this.o.maxWaitMs ?? 3600_000; | |
| 43 | + for (let attempt = 0; ; attempt++) { | |
| 44 | + const headers: Record<string, string> = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": `diffinery-cli/${VERSION}` }; | |
| 45 | + if (this.o.token) headers.authorization = `Bearer ${this.o.token}`; | |
| 46 | + let res: Response; | |
| 47 | + try { | |
| 48 | + res = await fetch(url, { headers }); | |
| 49 | + } catch (e) { | |
| 50 | + if (attempt < 3) { | |
| 51 | + await this.o.sleep(1000 * 2 ** attempt); | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + throw new CliError(`could not reach GitHub (${new URL(url).host}): ${(e as Error).message}`); | |
| 55 | + } | |
| 56 | + if ((res.status === 403 || res.status === 429) && attempt < 8) { | |
| 57 | + const retryAfter = res.headers.get("retry-after"); | |
| 58 | + const remaining = res.headers.get("x-ratelimit-remaining"); | |
| 59 | + const reset = res.headers.get("x-ratelimit-reset"); | |
| 60 | + let wait: number | null = null; | |
| 61 | + if (retryAfter && Number.isFinite(Number(retryAfter))) wait = Number(retryAfter) * 1000; | |
| 62 | + else if (remaining === "0" && reset && Number.isFinite(Number(reset))) wait = Math.max(0, Number(reset) * 1000 - now()) + 1000; | |
| 63 | + else if (res.status === 429) wait = 60_000; | |
| 64 | + else { | |
| 65 | + const text = await res.clone().text(); | |
| 66 | + if (/secondary rate limit|abuse/i.test(text)) wait = 60_000; | |
| 67 | + } | |
| 68 | + if (wait !== null) { | |
| 69 | + if (wait > maxWait) throw new GitHubError(res.status, `GitHub rate limit: reset is ${Math.ceil(wait / 60000)} minutes away; try again later${this.o.token ? "" : " or set GH_TOKEN"}`); | |
| 70 | + this.o.log?.(`GitHub rate limit reached; waiting ${Math.ceil(wait / 1000)}s…`); | |
| 71 | + await res.body?.cancel(); | |
| 72 | + await this.o.sleep(wait); | |
| 73 | + continue; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + if (res.status >= 500 && attempt < 3) { | |
| 77 | + await res.body?.cancel(); | |
| 78 | + await this.o.sleep(1000 * 2 ** attempt); | |
| 79 | + continue; | |
| 80 | + } | |
| 81 | + return res; | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + private url(path: string, query?: Record<string, string | number>): string { | |
| 86 | + const u = new URL(/^https?:\/\//.test(path) ? path : `${this.baseUrl}${path}`); | |
| 87 | + for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, String(v)); | |
| 88 | + return u.toString(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + private async check(res: Response, url: string): Promise<void> { | |
| 92 | + if (res.ok) return; | |
| 93 | + let msg = res.statusText; | |
| 94 | + try { | |
| 95 | + const b = (await res.json()) as { message?: string }; | |
| 96 | + if (b.message) msg = b.message; | |
| 97 | + } catch { | |
| 98 | + /* not JSON */ | |
| 99 | + } | |
| 100 | + throw new GitHubError(res.status, `GitHub ${new URL(url).pathname}: ${res.status} ${msg}`); | |
| 101 | + } | |
| 102 | + | |
| 103 | + async get<T>(path: string, query?: Record<string, string | number>): Promise<T> { | |
| 104 | + const url = this.url(path, query); | |
| 105 | + const res = await this.fetchRetrying(url); | |
| 106 | + await this.check(res, url); | |
| 107 | + return (await res.json()) as T; | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * All pages of a list endpoint. `key` picks the array out of wrapped responses | |
| 112 | + * such as `{ total_count, variables: [...] }`. | |
| 113 | + */ | |
| 114 | + async paginate<T>(path: string, query: Record<string, string | number> = {}, key?: string): Promise<T[]> { | |
| 115 | + const out: T[] = []; | |
| 116 | + let url: string | null = this.url(path, { per_page: 100, ...query }); | |
| 117 | + while (url) { | |
| 118 | + const res = await this.fetchRetrying(url); | |
| 119 | + await this.check(res, url); | |
| 120 | + const body = (await res.json()) as unknown; | |
| 121 | + const items = key ? ((body as Record<string, unknown>)[key] as T[] | undefined) : (body as T[]); | |
| 122 | + if (Array.isArray(items)) out.push(...items); | |
| 123 | + url = nextLink(res.headers.get("link")); | |
| 124 | + } | |
| 125 | + return out; | |
| 126 | + } | |
| 127 | +} |
addedapps/cli/src/io.ts+89−0View file
| @@ -1,0 +1,89 @@ | ||
| 1 | +import type { Readable } from "node:stream"; | |
| 2 | + | |
| 3 | +/** Everything a command touches in the outside world, so tests can run the CLI in-process. */ | |
| 4 | +export interface Io { | |
| 5 | + env: NodeJS.ProcessEnv; | |
| 6 | + cwd: string; | |
| 7 | + stdin: Readable & { isTTY?: boolean }; | |
| 8 | + stdout: { write(s: string): unknown; isTTY?: boolean }; | |
| 9 | + stderr: { write(s: string): unknown; isTTY?: boolean }; | |
| 10 | + /** Overridable for tests (polling loops). */ | |
| 11 | + sleep(ms: number, signal?: AbortSignal): Promise<void>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function sleep(ms: number, signal?: AbortSignal): Promise<void> { | |
| 15 | + return new Promise((resolve) => { | |
| 16 | + if (signal?.aborted) return resolve(); | |
| 17 | + const t = setTimeout(done, ms); | |
| 18 | + function done() { | |
| 19 | + clearTimeout(t); | |
| 20 | + signal?.removeEventListener("abort", done); | |
| 21 | + resolve(); | |
| 22 | + } | |
| 23 | + signal?.addEventListener("abort", done, { once: true }); | |
| 24 | + }); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function processIo(): Io { | |
| 28 | + return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** A user-facing failure: printed as `error: message`, exits with `code`. */ | |
| 32 | +export class CliError extends Error { | |
| 33 | + constructor( | |
| 34 | + message: string, | |
| 35 | + readonly code = 1, | |
| 36 | + ) { | |
| 37 | + super(message); | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Exit silently with a code (the command already printed what it wanted). */ | |
| 42 | +export class ExitCode extends Error { | |
| 43 | + constructor(readonly code: number) { | |
| 44 | + super(`exit ${code}`); | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +export async function readAll(stream: Readable): Promise<string> { | |
| 49 | + const chunks: Buffer[] = []; | |
| 50 | + for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer)); | |
| 51 | + return Buffer.concat(chunks).toString("utf8"); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Reads one line from a TTY without echoing it (for tokens and secret values). */ | |
| 55 | +export async function promptHidden(io: Io, question: string): Promise<string> { | |
| 56 | + const stdin = io.stdin as NodeJS.ReadStream; | |
| 57 | + io.stderr.write(question); | |
| 58 | + return new Promise((resolve, reject) => { | |
| 59 | + let value = ""; | |
| 60 | + const raw = typeof stdin.setRawMode === "function"; | |
| 61 | + if (raw) stdin.setRawMode(true); | |
| 62 | + stdin.resume(); | |
| 63 | + stdin.setEncoding("utf8"); | |
| 64 | + const finish = (err?: Error) => { | |
| 65 | + stdin.off("data", onData); | |
| 66 | + if (raw) stdin.setRawMode(false); | |
| 67 | + stdin.pause(); | |
| 68 | + io.stderr.write("\n"); | |
| 69 | + if (err) reject(err); | |
| 70 | + else resolve(value); | |
| 71 | + }; | |
| 72 | + const onData = (chunk: string) => { | |
| 73 | + for (const ch of chunk) { | |
| 74 | + if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish(); | |
| 75 | + if (ch === "\u0003") return finish(new CliError("cancelled", 130)); | |
| 76 | + if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1); | |
| 77 | + else value += ch; | |
| 78 | + } | |
| 79 | + }; | |
| 80 | + stdin.on("data", onData); | |
| 81 | + }); | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Value from a flag, else stdin when piped, else a hidden prompt. */ | |
| 85 | +export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> { | |
| 86 | + if (flag !== undefined) return flag; | |
| 87 | + if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, ""); | |
| 88 | + return promptHidden(io, prompt); | |
| 89 | +} |
addedapps/cli/src/main.ts+30−0View file
| @@ -1,0 +1,30 @@ | ||
| 1 | +import { dispatch, type Registry } from "./cli.js"; | |
| 2 | +import { adminCommands, agentGroup, secretGroup, tokenGroup, variableGroup } from "./commands/admin.js"; | |
| 3 | +import { authCommands, authGroup } from "./commands/auth.js"; | |
| 4 | +import { issueGroup } from "./commands/issue.js"; | |
| 5 | +import { migrateGroup } from "./commands/migrate.js"; | |
| 6 | +import { prGroup } from "./commands/pr.js"; | |
| 7 | +import { repoGroup } from "./commands/repo.js"; | |
| 8 | +import { runGroup } from "./commands/run.js"; | |
| 9 | +import { runnerCommand } from "./commands/runner.js"; | |
| 10 | +import { workflowGroup } from "./commands/workflow.js"; | |
| 11 | +import { CliError, processIo, type Io } from "./io.js"; | |
| 12 | + | |
| 13 | +export const registry: Registry = { | |
| 14 | + groups: [authGroup, repoGroup, prGroup, issueGroup, runGroup, workflowGroup, secretGroup, variableGroup, tokenGroup, agentGroup, migrateGroup], | |
| 15 | + commands: [...authCommands, ...adminCommands, runnerCommand], | |
| 16 | +}; | |
| 17 | + | |
| 18 | +/** Runs the CLI and returns its exit code (never calls process.exit). */ | |
| 19 | +export async function main(argv: string[], io: Io = processIo()): Promise<number> { | |
| 20 | + try { | |
| 21 | + return await dispatch(registry, argv, io); | |
| 22 | + } catch (e) { | |
| 23 | + if (e instanceof CliError) { | |
| 24 | + io.stderr.write(`error: ${e.message}\n`); | |
| 25 | + return e.code; | |
| 26 | + } | |
| 27 | + io.stderr.write(`error: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`); | |
| 28 | + return 1; | |
| 29 | + } | |
| 30 | +} |
addedapps/cli/src/output.ts+67−0View file
| @@ -1,0 +1,67 @@ | ||
| 1 | +import type { Io } from "./io.js"; | |
| 2 | + | |
| 3 | +export interface Colors { | |
| 4 | + enabled: boolean; | |
| 5 | + bold(s: string): string; | |
| 6 | + dim(s: string): string; | |
| 7 | + red(s: string): string; | |
| 8 | + green(s: string): string; | |
| 9 | + yellow(s: string): string; | |
| 10 | + cyan(s: string): string; | |
| 11 | + magenta(s: string): string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function colors(io: Io): Colors { | |
| 15 | + const on = !!io.stdout.isTTY && io.env.NO_COLOR === undefined && io.env.TERM !== "dumb"; | |
| 16 | + const wrap = (open: number, close: number) => (s: string) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s); | |
| 17 | + return { enabled: on, bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) }; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const ANSI = /\x1b\[[0-9;]*m/g; | |
| 21 | +const width = (s: string) => [...s.replace(ANSI, "")].length; | |
| 22 | + | |
| 23 | +/** Left-aligned columns separated by two spaces; the last column is not padded. */ | |
| 24 | +export function table(rows: string[][]): string { | |
| 25 | + if (!rows.length) return ""; | |
| 26 | + const cols = Math.max(...rows.map((r) => r.length)); | |
| 27 | + const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? "")))); | |
| 28 | + return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + " ".repeat(widths[i]! - width(cell)))).join(" ").trimEnd()).join("\n") + "\n"; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function json(io: Io, v: unknown): void { | |
| 32 | + io.stdout.write(JSON.stringify(v, null, 2) + "\n"); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function truncate(s: string | null | undefined, n: number): string { | |
| 36 | + const t = (s ?? "").replace(/\s+/g, " ").trim(); | |
| 37 | + return t.length > n ? `${t.slice(0, n - 1)}…` : t; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function ago(iso: string | null | undefined, now = Date.now()): string { | |
| 41 | + if (!iso) return ""; | |
| 42 | + const t = Date.parse(iso); | |
| 43 | + if (Number.isNaN(t)) return iso; | |
| 44 | + const s = Math.max(0, Math.round((now - t) / 1000)); | |
| 45 | + if (s < 60) return `${s}s ago`; | |
| 46 | + if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 47 | + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 48 | + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; | |
| 49 | + return iso.slice(0, 10); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Colors a status/conclusion word. */ | |
| 53 | +export function statusText(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 54 | + if (status && status !== "completed") return c.yellow(status.replace(/_/g, " ")); | |
| 55 | + const v = conclusion ?? status ?? "unknown"; | |
| 56 | + if (v === "success" || v === "merged" || v === "open" || v === "clean") return c.green(v); | |
| 57 | + if (v === "failure" || v === "timed_out" || v === "cancelled" || v === "action_required") return c.red(v.replace(/_/g, " ")); | |
| 58 | + if (v === "closed" || v === "skipped" || v === "neutral") return c.dim(v); | |
| 59 | + return c.yellow(v.replace(/_/g, " ")); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function symbol(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 63 | + if (status && status !== "completed") return c.yellow("*"); | |
| 64 | + if (conclusion === "success") return c.green("✓"); | |
| 65 | + if (conclusion === "skipped" || conclusion === "neutral") return c.dim("-"); | |
| 66 | + return c.red("X"); | |
| 67 | +} |
addedapps/cli/src/version.ts+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +export const VERSION = "0.1.0"; |
addedapps/cli/test/config.test.ts+109−0View file
| @@ -1,0 +1,109 @@ | ||
| 1 | +import { mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js"; | |
| 5 | +import type { Io } from "../src/io.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io; | |
| 9 | + | |
| 10 | +describe("config", () => { | |
| 11 | + it("defaults to https://diffinery.com with no token", async () => { | |
| 12 | + const cfg = await resolveConfig(ioWith(isolatedEnv())); | |
| 13 | + expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null }); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => { | |
| 17 | + const env = isolatedEnv(); | |
| 18 | + const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json"); | |
| 19 | + expect(configPath(env)).toBe(file); | |
| 20 | + mkdirSync(path.dirname(file), { recursive: true }); | |
| 21 | + writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" })); | |
| 22 | + | |
| 23 | + expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" }); | |
| 24 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" }); | |
| 25 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" }); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { | |
| 29 | + const env = isolatedEnv(); | |
| 30 | + delete env.XDG_CONFIG_HOME; | |
| 31 | + expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json")); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("writes the config with mode 0600", async () => { | |
| 35 | + const env = isolatedEnv(); | |
| 36 | + const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" }); | |
| 37 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 38 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("normalizes URLs", () => { | |
| 42 | + expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com"); | |
| 43 | + expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787"); | |
| 44 | + expect(() => normalizeUrl("ftp://x")).toThrow(); | |
| 45 | + }); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +describe("login / whoami / logout", () => { | |
| 49 | + let srv: FakeServer; | |
| 50 | + beforeEach(async () => { | |
| 51 | + srv = await fakeServer(); | |
| 52 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 53 | + }); | |
| 54 | + afterEach(() => srv.close()); | |
| 55 | + | |
| 56 | + it("verifies the token, saves it 0600, and whoami uses it", async () => { | |
| 57 | + const env = isolatedEnv(); | |
| 58 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env }); | |
| 59 | + expect(r.code).toBe(0); | |
| 60 | + expect(r.stdout).toContain("Logged in to"); | |
| 61 | + expect(r.stdout).toContain("alice"); | |
| 62 | + expect(r.stdout).not.toContain("dfy_good"); | |
| 63 | + const file = configPath(env); | |
| 64 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); | |
| 65 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 66 | + | |
| 67 | + const who = await run(["whoami"], { env }); | |
| 68 | + expect(who.code).toBe(0); | |
| 69 | + expect(who.stdout).toContain("alice"); | |
| 70 | + expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good"); | |
| 71 | + | |
| 72 | + const out = await run(["logout"], { env }); | |
| 73 | + expect(out.code).toBe(0); | |
| 74 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url }); | |
| 75 | + expect((await run(["whoami"], { env })).code).toBe(1); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("reads the token from stdin when it is not a TTY", async () => { | |
| 79 | + const env = isolatedEnv(); | |
| 80 | + const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" }); | |
| 81 | + expect(r.code).toBe(0); | |
| 82 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("refuses a token the server rejects and saves nothing", async () => { | |
| 86 | + const env = isolatedEnv(); | |
| 87 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env }); | |
| 88 | + expect(r.code).toBe(1); | |
| 89 | + expect(r.stderr).toContain("bad credentials"); | |
| 90 | + expect(() => readFileSync(configPath(env))).toThrow(); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => { | |
| 94 | + const env = isolatedEnv(); | |
| 95 | + await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" }); | |
| 96 | + const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } }); | |
| 97 | + expect(r.code).toBe(0); | |
| 98 | + expect(JSON.parse(r.stdout).login).toBe("alice"); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("mcp-config prints a snippet without the token unless asked", async () => { | |
| 102 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" }); | |
| 103 | + const r = await run(["mcp-config"], { env }); | |
| 104 | + const cfg = JSON.parse(r.stdout); | |
| 105 | + expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } }); | |
| 106 | + const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout); | |
| 107 | + expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good"); | |
| 108 | + }); | |
| 109 | +}); |
addedapps/cli/test/credential.test.ts+54−0View file
| @@ -1,0 +1,54 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { parseCredentialInput } from "../src/commands/auth.js"; | |
| 4 | +import { isolatedEnv, run } from "./helpers.js"; | |
| 5 | + | |
| 6 | +describe("git credential helper", () => { | |
| 7 | + const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" }); | |
| 8 | + | |
| 9 | + it("parses key=value lines up to the blank line", () => { | |
| 10 | + expect(parseCredentialInput("protocol=https\nhost=a.b\npath=x/y.git\n\nignored=1\n")).toEqual({ protocol: "https", host: "a.b", path: "x/y.git" }); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("answers get for the configured host", async () => { | |
| 14 | + const r = await run(["auth", "git-credential", "get"], { env, stdin: "protocol=https\nhost=forge.example.com\n\n" }); | |
| 15 | + expect(r.code).toBe(0); | |
| 16 | + expect(r.stdout).toBe("protocol=https\nhost=forge.example.com\nusername=x-token\npassword=dfy_secret\n"); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("stays silent for other hosts, other protocols and other operations", async () => { | |
| 20 | + for (const stdin of ["protocol=https\nhost=github.com\n\n", "protocol=http\nhost=forge.example.com\n\n"]) { | |
| 21 | + const r = await run(["auth", "git-credential", "get"], { env, stdin }); | |
| 22 | + expect(r.code).toBe(0); | |
| 23 | + expect(r.stdout).toBe(""); | |
| 24 | + } | |
| 25 | + for (const op of ["store", "erase"]) { | |
| 26 | + const r = await run(["auth", "git-credential", op], { env, stdin: "protocol=https\nhost=forge.example.com\nusername=x\npassword=y\n\n" }); | |
| 27 | + expect(r.code).toBe(0); | |
| 28 | + expect(r.stdout).toBe(""); | |
| 29 | + } | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("matches hosts with ports", async () => { | |
| 33 | + const e = { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" }; | |
| 34 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=http\nhost=127.0.0.1:8787\n" }); | |
| 35 | + expect(r.stdout).toContain("password=dfy_secret"); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("prints nothing without a token", async () => { | |
| 39 | + const e = { ...env }; | |
| 40 | + delete e.DIFFINERY_TOKEN; | |
| 41 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=https\nhost=forge.example.com\n" }); | |
| 42 | + expect(r.stdout).toBe(""); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("auth setup-git configures a URL-scoped helper in the global git config", async () => { | |
| 46 | + const r = await run(["auth", "setup-git"], { env }); | |
| 47 | + expect(r.code).toBe(0); | |
| 48 | + const helpers = execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n"); | |
| 49 | + expect(helpers.slice(0, 2)).toEqual(["", "!diffinery auth git-credential"]); | |
| 50 | + // Idempotent. | |
| 51 | + await run(["auth", "setup-git"], { env }); | |
| 52 | + expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]); | |
| 53 | + }); | |
| 54 | +}); |
addedapps/cli/test/helpers.ts+203−0View file
| @@ -1,0 +1,203 @@ | ||
| 1 | +import { execFileSync, spawn } from "node:child_process"; | |
| 2 | +import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 4 | +import type { AddressInfo } from "node:net"; | |
| 5 | +import { tmpdir } from "node:os"; | |
| 6 | +import path from "node:path"; | |
| 7 | +import { Readable } from "node:stream"; | |
| 8 | +import type { Io } from "../src/io.js"; | |
| 9 | +import { main } from "../src/main.js"; | |
| 10 | + | |
| 11 | +export interface Recorded { | |
| 12 | + method: string; | |
| 13 | + path: string; | |
| 14 | + query: Record<string, string>; | |
| 15 | + headers: IncomingMessage["headers"]; | |
| 16 | + body: unknown; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface Reply { | |
| 20 | + status?: number; | |
| 21 | + body?: unknown; | |
| 22 | + headers?: Record<string, string>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +type Handler = (req: Recorded) => Reply | Promise<Reply>; | |
| 26 | + | |
| 27 | +export interface FakeServer { | |
| 28 | + url: string; | |
| 29 | + host: string; | |
| 30 | + requests: Recorded[]; | |
| 31 | + on(method: string, path: string | RegExp, h: Handler | Reply): void; | |
| 32 | + find(method: string, path: string | RegExp): Recorded[]; | |
| 33 | + close(): Promise<void>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * A tiny programmable HTTP server: unmatched routes return 404 { message }. | |
| 38 | + * With `gitRoot`, paths containing `.git/` are served by `git http-backend` from that directory. | |
| 39 | + */ | |
| 40 | +export async function fakeServer(o: { gitRoot?: string; gitAuth?: string } = {}): Promise<FakeServer> { | |
| 41 | + const routes: Array<{ method: string; path: string | RegExp; h: Handler }> = []; | |
| 42 | + const requests: Recorded[] = []; | |
| 43 | + const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? p === path : p.test(path)); | |
| 44 | + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { | |
| 45 | + if (o.gitRoot && /\.git\//.test(req.url ?? "")) return gitBackend(o.gitRoot, o.gitAuth, req, res, requests); | |
| 46 | + const chunks: Buffer[] = []; | |
| 47 | + for await (const c of req) chunks.push(c as Buffer); | |
| 48 | + const text = Buffer.concat(chunks).toString("utf8"); | |
| 49 | + let body: unknown = text || undefined; | |
| 50 | + try { | |
| 51 | + if (text) body = JSON.parse(text); | |
| 52 | + } catch { | |
| 53 | + /* keep text */ | |
| 54 | + } | |
| 55 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 56 | + const rec: Recorded = { method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body }; | |
| 57 | + requests.push(rec); | |
| 58 | + const route = [...routes].reverse().find((r) => r.method === rec.method && matches(r.path, rec.path)); | |
| 59 | + const reply: Reply = route ? await route.h(rec) : { status: 404, body: { message: "not found" } }; | |
| 60 | + const status = reply.status ?? 200; | |
| 61 | + const headers: Record<string, string> = { ...reply.headers }; | |
| 62 | + let out = ""; | |
| 63 | + if (reply.body !== undefined && status !== 204) { | |
| 64 | + if (typeof reply.body === "string") { | |
| 65 | + out = reply.body; | |
| 66 | + headers["content-type"] ??= "text/plain"; | |
| 67 | + } else { | |
| 68 | + out = JSON.stringify(reply.body); | |
| 69 | + headers["content-type"] ??= "application/json"; | |
| 70 | + } | |
| 71 | + } | |
| 72 | + res.writeHead(status, headers); | |
| 73 | + res.end(out); | |
| 74 | + }); | |
| 75 | + await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); | |
| 76 | + const port = (server.address() as AddressInfo).port; | |
| 77 | + return { | |
| 78 | + url: `http://127.0.0.1:${port}`, | |
| 79 | + host: `127.0.0.1:${port}`, | |
| 80 | + requests, | |
| 81 | + on(method, path, h) { | |
| 82 | + routes.push({ method, path, h: typeof h === "function" ? h : () => h }); | |
| 83 | + }, | |
| 84 | + find(method, path) { | |
| 85 | + return requests.filter((r) => r.method === method && matches(path, r.path)); | |
| 86 | + }, | |
| 87 | + close: () => | |
| 88 | + new Promise<void>((r) => { | |
| 89 | + server.closeAllConnections(); | |
| 90 | + server.close(() => r()); | |
| 91 | + }), | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function tempDir(prefix = "diffinery-cli-"): string { | |
| 96 | + return mkdtempSync(path.join(tmpdir(), prefix)); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export interface RunResult { | |
| 100 | + code: number; | |
| 101 | + stdout: string; | |
| 102 | + stderr: string; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export interface RunOptions { | |
| 106 | + env?: NodeJS.ProcessEnv; | |
| 107 | + cwd?: string; | |
| 108 | + stdin?: string; | |
| 109 | + stdinTTY?: boolean; | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */ | |
| 113 | +export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { | |
| 114 | + const home = tempDir("diffinery-home-"); | |
| 115 | + const gitconfig = path.join(home, ".gitconfig"); | |
| 116 | + writeFileSync(gitconfig, ""); | |
| 117 | + return { | |
| 118 | + PATH: process.env.PATH, | |
| 119 | + HOME: home, | |
| 120 | + XDG_CONFIG_HOME: path.join(home, ".config"), | |
| 121 | + GIT_CONFIG_GLOBAL: gitconfig, | |
| 122 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 123 | + GIT_AUTHOR_NAME: "Test", | |
| 124 | + GIT_AUTHOR_EMAIL: "test@example.com", | |
| 125 | + GIT_COMMITTER_NAME: "Test", | |
| 126 | + GIT_COMMITTER_EMAIL: "test@example.com", | |
| 127 | + ...extra, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> { | |
| 132 | + let stdout = ""; | |
| 133 | + let stderr = ""; | |
| 134 | + const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"]; | |
| 135 | + stdin.isTTY = o.stdinTTY ?? false; | |
| 136 | + const io: Io = { | |
| 137 | + env: o.env ?? isolatedEnv(), | |
| 138 | + cwd: o.cwd ?? tempDir(), | |
| 139 | + stdin, | |
| 140 | + stdout: { write: (s: string) => ((stdout += s), true) }, | |
| 141 | + stderr: { write: (s: string) => ((stderr += s), true) }, | |
| 142 | + sleep: () => new Promise((r) => setTimeout(r, 1)), | |
| 143 | + }; | |
| 144 | + const code = await main(argv, io); | |
| 145 | + return { code, stdout, stderr }; | |
| 146 | +} | |
| 147 | + | |
| 148 | +/** Creates a git repository whose origin points at `remote`, on branch `branch`. */ | |
| 149 | +export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string { | |
| 150 | + const dir = tempDir("diffinery-repo-"); | |
| 151 | + const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" }); | |
| 152 | + g("init", "-q", "-b", "main"); | |
| 153 | + g("remote", "add", "origin", remote); | |
| 154 | + g("commit", "-q", "--allow-empty", "-m", "init"); | |
| 155 | + g("checkout", "-q", "-b", branch); | |
| 156 | + return dir; | |
| 157 | +} | |
| 158 | + | |
| 159 | +/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */ | |
| 160 | +async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> { | |
| 161 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 162 | + requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined }); | |
| 163 | + if (auth && req.headers.authorization !== auth) { | |
| 164 | + res.writeHead(401, { "www-authenticate": 'Basic realm="test"' }); | |
| 165 | + return void res.end(); | |
| 166 | + } | |
| 167 | + const child = spawn("git", ["http-backend"], { | |
| 168 | + env: { | |
| 169 | + PATH: process.env.PATH, | |
| 170 | + GIT_PROJECT_ROOT: root, | |
| 171 | + GIT_HTTP_EXPORT_ALL: "1", | |
| 172 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 173 | + GIT_CONFIG_GLOBAL: "/dev/null", | |
| 174 | + REQUEST_METHOD: req.method ?? "GET", | |
| 175 | + PATH_INFO: decodeURIComponent(u.pathname), | |
| 176 | + QUERY_STRING: u.search.slice(1), | |
| 177 | + CONTENT_TYPE: req.headers["content-type"] ?? "", | |
| 178 | + HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""), | |
| 179 | + GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""), | |
| 180 | + REMOTE_USER: "tester", | |
| 181 | + REMOTE_ADDR: "127.0.0.1", | |
| 182 | + }, | |
| 183 | + stdio: ["pipe", "pipe", "inherit"], | |
| 184 | + }); | |
| 185 | + req.pipe(child.stdin); | |
| 186 | + const chunks: Buffer[] = []; | |
| 187 | + for await (const c of child.stdout) chunks.push(c as Buffer); | |
| 188 | + const out = Buffer.concat(chunks); | |
| 189 | + const sep = out.indexOf("\r\n\r\n"); | |
| 190 | + const head = out.subarray(0, sep).toString(); | |
| 191 | + const headers: Record<string, string> = {}; | |
| 192 | + let status = 200; | |
| 193 | + for (const line of head.split("\r\n")) { | |
| 194 | + const i = line.indexOf(":"); | |
| 195 | + if (i < 0) continue; | |
| 196 | + const k = line.slice(0, i).trim(); | |
| 197 | + const v = line.slice(i + 1).trim(); | |
| 198 | + if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]); | |
| 199 | + else headers[k] = v; | |
| 200 | + } | |
| 201 | + res.writeHead(status, headers); | |
| 202 | + res.end(out.subarray(sep + 4)); | |
| 203 | +} |
addedapps/cli/test/migrate.test.ts+134−0View file
| @@ -1,0 +1,134 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { mkdirSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | + | |
| 7 | +describe("migrate github", () => { | |
| 8 | + let srv: FakeServer; | |
| 9 | + let env: NodeJS.ProcessEnv; | |
| 10 | + let gitRoot: string; | |
| 11 | + let srcBare: string; | |
| 12 | + | |
| 13 | + beforeEach(async () => { | |
| 14 | + env = isolatedEnv(); | |
| 15 | + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim(); | |
| 16 | + // Source repository standing in for GitHub: main, develop, a tag and a pull ref. | |
| 17 | + const work = tempDir("diffinery-src-"); | |
| 18 | + g(work, "init", "-q", "-b", "main"); | |
| 19 | + g(work, "commit", "-q", "--allow-empty", "-m", "one"); | |
| 20 | + g(work, "tag", "v1"); | |
| 21 | + g(work, "checkout", "-q", "-b", "develop"); | |
| 22 | + g(work, "commit", "-q", "--allow-empty", "-m", "two"); | |
| 23 | + srcBare = path.join(tempDir("diffinery-gh-"), "widgets.git"); | |
| 24 | + g(work, "clone", "-q", "--bare", work, srcBare); | |
| 25 | + g(srcBare, "update-ref", "refs/pull/2/head", g(work, "rev-parse", "HEAD")); | |
| 26 | + // Target: an empty bare repository served over smart HTTP. | |
| 27 | + gitRoot = tempDir("diffinery-git-"); | |
| 28 | + mkdirSync(path.join(gitRoot, "alice")); | |
| 29 | + g(path.join(gitRoot, "alice"), "init", "-q", "--bare", "widgets.git"); | |
| 30 | + | |
| 31 | + srv = await fakeServer({ gitRoot, gitAuth: `Basic ${Buffer.from("x-token:dfy_t").toString("base64")}` }); | |
| 32 | + const now = "2024-05-01T10:00:00Z"; | |
| 33 | + const ghRepo = { full_name: "gh-org/widgets", name: "widgets", private: false, visibility: "public", description: "Widgets!", default_branch: "develop", clone_url: srcBare, html_url: "https://github.com/gh-org/widgets" }; | |
| 34 | + srv.on("GET", "/gh/repos/gh-org/widgets", { body: ghRepo }); | |
| 35 | + srv.on("GET", "/gh/repos/gh-org/widgets/actions/variables", { body: { total_count: 1, variables: [{ name: "REGION", value: "eu-west" }] } }); | |
| 36 | + srv.on("GET", "/gh/repos/gh-org/widgets/actions/secrets", { body: { total_count: 1, secrets: [{ name: "NPM_TOKEN" }] } }); | |
| 37 | + srv.on("GET", "/gh/repos/gh-org/widgets/labels", { body: [{ name: "bug", color: "d73a4a", description: "Broken" }] }); | |
| 38 | + srv.on("GET", "/gh/repos/gh-org/widgets/pulls", { | |
| 39 | + body: [ | |
| 40 | + { number: 2, title: "Develop work", body: "PR body", draft: false, user: { login: "carol" }, created_at: now, head: { ref: "develop", repo: { full_name: "gh-org/widgets" } }, base: { ref: "main" } }, | |
| 41 | + { number: 4, title: "From a fork", body: null, user: { login: "mallory" }, created_at: now, head: { ref: "patch-1", repo: { full_name: "mallory/widgets" } }, base: { ref: "main" } }, | |
| 42 | + ], | |
| 43 | + }); | |
| 44 | + srv.on("GET", "/gh/repos/gh-org/widgets/issues", { | |
| 45 | + body: [ | |
| 46 | + { number: 5, title: "Open issue", body: "five", state: "open", user: { login: "dave" }, labels: [], comments: 0, created_at: now }, | |
| 47 | + { number: 1, title: "Old bug", body: "it breaks", state: "closed", state_reason: "not_planned", user: { login: "bob" }, labels: [{ name: "bug" }], comments: 1, created_at: now }, | |
| 48 | + { number: 2, title: "Develop work", body: "PR body", state: "open", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 49 | + { number: 3, title: "Merged PR", body: "", state: "closed", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 50 | + { number: 4, title: "From a fork", body: null, state: "open", user: { login: "mallory" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 51 | + ], | |
| 52 | + }); | |
| 53 | + srv.on("GET", "/gh/repos/gh-org/widgets/issues/1/comments", { body: [{ body: "me too", user: { login: "erin" }, created_at: "2024-05-02T00:00:00Z" }] }); | |
| 54 | + | |
| 55 | + let n = 0; | |
| 56 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 57 | + srv.on("POST", "/api/v3/user/repos", (r) => ({ status: 201, body: { full_name: "alice/widgets", visibility: (r.body as { visibility: string }).visibility } })); | |
| 58 | + srv.on("PATCH", "/api/v3/repos/alice/widgets", { body: {} }); | |
| 59 | + srv.on("PUT", /\/actions\/variables\//, { status: 204 }); | |
| 60 | + srv.on("POST", "/api/v3/repos/alice/widgets/labels", (r) => ({ status: 201, body: r.body })); | |
| 61 | + srv.on("POST", "/api/v3/repos/alice/widgets/issues", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } })); | |
| 62 | + srv.on("POST", "/api/v3/repos/alice/widgets/pulls", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } })); | |
| 63 | + srv.on("POST", /\/issues\/\d+\/comments$/, { status: 201, body: {} }); | |
| 64 | + srv.on("PATCH", /\/issues\/\d+$/, { body: {} }); | |
| 65 | + Object.assign(env, { DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t", GH_TOKEN: "ght", GITHUB_API_URL: `${srv.url}/gh` }); | |
| 66 | + }); | |
| 67 | + afterEach(() => srv.close()); | |
| 68 | + | |
| 69 | + it("creates the repo, pushes branches and tags, and copies issues, PRs, variables and secret names", async () => { | |
| 70 | + const r = await run(["migrate", "github", "gh-org/widgets", "--issues"], { env }); | |
| 71 | + expect(r.stderr).not.toContain("error"); | |
| 72 | + expect(r.code).toBe(0); | |
| 73 | + | |
| 74 | + // Repository created with GitHub's visibility and description. | |
| 75 | + expect(srv.find("POST", "/api/v3/user/repos")[0]!.body).toEqual({ name: "widgets", description: "Widgets!", visibility: "public" }); | |
| 76 | + | |
| 77 | + // Git data: heads and tags only. | |
| 78 | + const refs = execFileSync("git", ["for-each-ref", "--format=%(refname)"], { cwd: path.join(gitRoot, "alice", "widgets.git"), env }).toString().trim().split("\n").sort(); | |
| 79 | + expect(refs).toEqual(["refs/heads/develop", "refs/heads/main", "refs/tags/v1"]); | |
| 80 | + expect(srv.find("PATCH", "/api/v3/repos/alice/widgets")[0]!.body).toEqual({ default_branch: "develop" }); | |
| 81 | + | |
| 82 | + // Variables copied, secrets listed. | |
| 83 | + expect(srv.find("PUT", /\/actions\/variables\//).map((q) => [q.path, q.body])).toEqual([["/api/v3/repos/alice/widgets/actions/variables/REGION", { value: "eu-west" }]]); | |
| 84 | + expect(r.stdout).toContain("diffinery secret set NPM_TOKEN -R alice/widgets"); | |
| 85 | + | |
| 86 | + // Labels, then issues and PRs in GitHub number order. | |
| 87 | + expect(srv.find("POST", "/api/v3/repos/alice/widgets/labels")[0]!.body).toEqual({ name: "bug", color: "d73a4a", description: "Broken" }); | |
| 88 | + const issues = srv.find("POST", "/api/v3/repos/alice/widgets/issues").map((q) => q.body as { title: string; body: string; labels?: string[] }); | |
| 89 | + expect(issues.map((i) => i.title)).toEqual(["Old bug", "Open issue"]); | |
| 90 | + expect(issues[0]!.body).toBe("> Imported from GitHub issue [#1](https://github.com/gh-org/widgets/issues/1) by @bob on 2024-05-01\n\nit breaks"); | |
| 91 | + expect(issues[0]!.labels).toEqual(["bug"]); | |
| 92 | + const comment = srv.find("POST", /\/issues\/1\/comments$/)[0]!.body as { body: string }; | |
| 93 | + expect(comment.body).toBe("> Imported from GitHub: comment by @erin on 2024-05-02\n\nme too"); | |
| 94 | + expect(srv.find("PATCH", /\/issues\/1$/)[0]!.body).toEqual({ state: "closed", state_reason: "not_planned" }); | |
| 95 | + | |
| 96 | + const pulls = srv.find("POST", "/api/v3/repos/alice/widgets/pulls").map((q) => q.body); | |
| 97 | + expect(pulls).toEqual([{ title: "Develop work", body: "> Imported from GitHub pull request [#2](https://github.com/gh-org/widgets/pull/2) by @carol on 2024-05-01\n\nPR body", head: "develop", base: "main" }]); | |
| 98 | + expect(r.stdout).toContain("#4: head branch is in a fork"); | |
| 99 | + expect(r.stdout).toContain("issues: 2 (1 comments)"); | |
| 100 | + | |
| 101 | + // The GitHub token went to GitHub only, the Diffinery token to Diffinery only. | |
| 102 | + for (const q of srv.requests) { | |
| 103 | + if (q.path.startsWith("/gh/")) expect(q.headers.authorization).toBe("Bearer ght"); | |
| 104 | + else if (q.path.startsWith("/api/")) expect(q.headers.authorization).toBe("Bearer dfy_t"); | |
| 105 | + } | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("repo clone authenticates for that command only", async () => { | |
| 109 | + await run(["migrate", "github", "gh-org/widgets"], { env }); | |
| 110 | + const cwd = tempDir("diffinery-clone-"); | |
| 111 | + const r = await run(["repo", "clone", "alice/widgets", "w"], { env, cwd }); | |
| 112 | + expect(r.code).toBe(0); | |
| 113 | + const dir = path.join(cwd, "w"); | |
| 114 | + expect(execFileSync("git", ["branch", "-r"], { cwd: dir, env }).toString()).toContain("origin/develop"); | |
| 115 | + const config = execFileSync("git", ["config", "--list", "--local"], { cwd: dir, env }).toString(); | |
| 116 | + expect(config).toContain(`remote.origin.url=${srv.url}/alice/widgets.git`); | |
| 117 | + expect(config).not.toMatch(/extraheader|dfy_t|Basic/i); | |
| 118 | + // Without the header, the server refuses. | |
| 119 | + const anon = await run(["repo", "clone", "alice/widgets", "w2"], { env: { ...env, DIFFINERY_TOKEN: "", GIT_TERMINAL_PROMPT: "0" }, cwd }); | |
| 120 | + expect(anon.code).not.toBe(0); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("--private and --to override the target", async () => { | |
| 124 | + srv.on("GET", "/api/v3/repos/team/w2", { status: 404, body: { message: "repository not found" } }); | |
| 125 | + srv.on("POST", "/api/v3/orgs/team/repos", { status: 201, body: { full_name: "team/w2", visibility: "private" } }); | |
| 126 | + srv.on("PATCH", "/api/v3/repos/team/w2", { body: {} }); | |
| 127 | + mkdirSync(path.join(gitRoot, "team")); | |
| 128 | + execFileSync("git", ["init", "-q", "--bare", "w2.git"], { cwd: path.join(gitRoot, "team"), env }); | |
| 129 | + const r = await run(["migrate", "github", "https://github.com/gh-org/widgets", "--to", "team/w2", "--private"], { env }); | |
| 130 | + expect(r.code).toBe(0); | |
| 131 | + expect(srv.find("POST", "/api/v3/orgs/team/repos")[0]!.body).toMatchObject({ name: "w2", visibility: "private" }); | |
| 132 | + expect(srv.find("POST", /\/issues$/)).toHaveLength(0); | |
| 133 | + }); | |
| 134 | +}); |
addedapps/cli/test/misc.test.ts+138−0View file
| @@ -1,0 +1,138 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { GitHub, nextLink } from "../src/github.js"; | |
| 3 | +import { withGitConfig } from "../src/git.js"; | |
| 4 | +import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js"; | |
| 5 | + | |
| 6 | +describe("run watch", () => { | |
| 7 | + let srv: FakeServer; | |
| 8 | + beforeEach(async () => { | |
| 9 | + srv = await fakeServer(); | |
| 10 | + }); | |
| 11 | + afterEach(() => srv.close()); | |
| 12 | + | |
| 13 | + const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({ | |
| 14 | + id: "run1", | |
| 15 | + run_number: 4, | |
| 16 | + run_attempt: 1, | |
| 17 | + name: "CI", | |
| 18 | + display_title: "Fix bug", | |
| 19 | + path: ".github/workflows/ci.yml", | |
| 20 | + event: "push", | |
| 21 | + head_branch: "main", | |
| 22 | + head_sha: "abcdef1234", | |
| 23 | + status, | |
| 24 | + conclusion, | |
| 25 | + actor: { login: "alice" }, | |
| 26 | + created_at: new Date().toISOString(), | |
| 27 | + updated_at: new Date().toISOString(), | |
| 28 | + html_url: "http://x/runs/run1", | |
| 29 | + jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })), | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("prints job transitions and exits 1 when the run fails", async () => { | |
| 33 | + const states = [ | |
| 34 | + runBody("queued", null, [["a", "queued", null]]), | |
| 35 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 36 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 37 | + runBody("completed", "failure", [["a", "completed", "failure"]]), | |
| 38 | + ]; | |
| 39 | + let i = 0; | |
| 40 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] })); | |
| 41 | + const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 42 | + expect(r.code).toBe(1); | |
| 43 | + const lines = r.stdout.split("\n").filter((l) => l.includes("job-a")); | |
| 44 | + expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state) | |
| 45 | + expect(r.stdout).toContain("completed: failure"); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("exits 0 on success", async () => { | |
| 49 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) }); | |
| 50 | + const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 51 | + expect(r.code).toBe(0); | |
| 52 | + }); | |
| 53 | +}); | |
| 54 | + | |
| 55 | +describe("GitHub client", () => { | |
| 56 | + let srv: FakeServer; | |
| 57 | + beforeEach(async () => { | |
| 58 | + srv = await fakeServer(); | |
| 59 | + }); | |
| 60 | + afterEach(() => srv.close()); | |
| 61 | + | |
| 62 | + it("follows Link pagination and waits out rate limits", async () => { | |
| 63 | + let hits = 0; | |
| 64 | + srv.on("GET", "/repos/o/r/issues", (req): Reply => { | |
| 65 | + hits++; | |
| 66 | + if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } }; | |
| 67 | + if (req.query.page === "2") return { body: [{ number: 3 }] }; | |
| 68 | + return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } }; | |
| 69 | + }); | |
| 70 | + srv.on("GET", "/repos/o/r/actions/variables", (req) => | |
| 71 | + req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } }, | |
| 72 | + ); | |
| 73 | + srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } })); | |
| 74 | + const waits: number[] = []; | |
| 75 | + const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 }); | |
| 76 | + expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]); | |
| 77 | + expect(waits).toEqual([2000]); | |
| 78 | + expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght"); | |
| 79 | + expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" }); | |
| 80 | + expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([ | |
| 81 | + { name: "A", value: "1" }, | |
| 82 | + { name: "B", value: "2" }, | |
| 83 | + ]); | |
| 84 | + hits = 5; | |
| 85 | + waits.length = 0; | |
| 86 | + expect(await gh.get("/rate")).toEqual({ ok: true }); | |
| 87 | + expect(waits.length).toBeGreaterThan(0); | |
| 88 | + expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("parses Link headers", () => { | |
| 92 | + expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2"); | |
| 93 | + expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull(); | |
| 94 | + expect(nextLink(null)).toBeNull(); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe("misc", () => { | |
| 99 | + it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => { | |
| 100 | + const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]); | |
| 101 | + expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" }); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("--help works for every command and --version prints the version", async () => { | |
| 105 | + const { registry } = await import("../src/main.js"); | |
| 106 | + const env = isolatedEnv(); | |
| 107 | + for (const g of registry.groups) { | |
| 108 | + expect((await run([g.name, "--help"], { env })).code).toBe(0); | |
| 109 | + for (const c of g.commands) { | |
| 110 | + const r = await run([...c.name.split(" "), "--help"], { env }); | |
| 111 | + expect(r.code, c.name).toBe(0); | |
| 112 | + expect(r.stdout).toContain(`Usage: diffinery ${c.name}`); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0); | |
| 116 | + expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/); | |
| 117 | + expect((await run(["nope"], { env })).code).toBe(2); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it("api prints JSON and exits 1 on HTTP errors", async () => { | |
| 121 | + const srv = await fakeServer(); | |
| 122 | + try { | |
| 123 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 124 | + srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body })); | |
| 125 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); | |
| 126 | + const r = await run(["api", "get", "/user"], { env }); | |
| 127 | + expect(r.code).toBe(0); | |
| 128 | + expect(JSON.parse(r.stdout)).toEqual({ login: "alice" }); | |
| 129 | + const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' }); | |
| 130 | + expect(JSON.parse(p.stdout)).toEqual({ name: "bug" }); | |
| 131 | + const missing = await run(["api", "GET", "/nope"], { env }); | |
| 132 | + expect(missing.code).toBe(1); | |
| 133 | + expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" }); | |
| 134 | + } finally { | |
| 135 | + await srv.close(); | |
| 136 | + } | |
| 137 | + }); | |
| 138 | +}); |
addedapps/cli/test/pr.test.ts+119−0View file
| @@ -1,0 +1,119 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { parseRemote } from "../src/git.js"; | |
| 3 | +import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 4 | + | |
| 5 | +const pull = (n: number, extra: Record<string, unknown> = {}) => ({ | |
| 6 | + number: n, | |
| 7 | + title: "Add thing", | |
| 8 | + body: "", | |
| 9 | + state: "open", | |
| 10 | + user: { login: "alice", type: "User" }, | |
| 11 | + labels: [], | |
| 12 | + assignees: [], | |
| 13 | + comments: 0, | |
| 14 | + created_at: new Date().toISOString(), | |
| 15 | + updated_at: new Date().toISOString(), | |
| 16 | + html_url: `http://x/acme/widgets/pull/${n}`, | |
| 17 | + head: { ref: "feature/x", sha: "abc" }, | |
| 18 | + base: { ref: "main" }, | |
| 19 | + ...extra, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe("remote parsing", () => { | |
| 23 | + it("recognizes https, ssh and scp-style remotes for the configured host", () => { | |
| 24 | + expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 25 | + expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" }); | |
| 26 | + expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 27 | + expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull(); | |
| 28 | + }); | |
| 29 | +}); | |
| 30 | + | |
| 31 | +describe("pull requests", () => { | |
| 32 | + let srv: FakeServer; | |
| 33 | + let env: NodeJS.ProcessEnv; | |
| 34 | + beforeEach(async () => { | |
| 35 | + srv = await fakeServer(); | |
| 36 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); | |
| 37 | + }); | |
| 38 | + afterEach(() => srv.close()); | |
| 39 | + | |
| 40 | + it("pr create infers the repo from origin and the head from the current branch", async () => { | |
| 41 | + srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) }); | |
| 42 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); | |
| 43 | + const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd }); | |
| 44 | + expect(r.stderr).toBe(""); | |
| 45 | + expect(r.code).toBe(0); | |
| 46 | + expect(r.stdout).toContain("#7"); | |
| 47 | + const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!; | |
| 48 | + expect(req.headers.authorization).toBe("Bearer dfy_t"); | |
| 49 | + expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it("pr create with -R and --head omits unset fields", async () => { | |
| 53 | + srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) }); | |
| 54 | + const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env }); | |
| 55 | + expect(r.code).toBe(0); | |
| 56 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" }); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("pr create requires a title", async () => { | |
| 60 | + const r = await run(["pr", "create", "-R", "o/r"], { env }); | |
| 61 | + expect(r.code).toBe(2); | |
| 62 | + expect(r.stderr).toContain("--title is required"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("pr merge sends the method and delete_branch", async () => { | |
| 66 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } }); | |
| 67 | + const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env }); | |
| 68 | + expect(r.code).toBe(0); | |
| 69 | + expect(r.stdout).toContain("Merged #12"); | |
| 70 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true }); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("pr merge defaults to a merge commit and rejects conflicting flags", async () => { | |
| 74 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } }); | |
| 75 | + expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0); | |
| 76 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" }); | |
| 77 | + const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env }); | |
| 78 | + expect(bad.code).toBe(2); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("surfaces the server's { message } on errors", async () => { | |
| 82 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); | |
| 83 | + const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); | |
| 84 | + expect(r.code).toBe(1); | |
| 85 | + expect(r.stderr).toContain("1 approval required"); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("pr review maps flags to review events", async () => { | |
| 89 | + srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); | |
| 90 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0); | |
| 91 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0); | |
| 92 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2); | |
| 93 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + it("pr list and --json", async () => { | |
| 97 | + srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] }); | |
| 98 | + const r = await run(["pr", "list", "-R", "o/r"], { env }); | |
| 99 | + expect(r.code).toBe(0); | |
| 100 | + expect(r.stdout).toMatch(/#1\s+Add thing/); | |
| 101 | + expect(r.stdout).toMatch(/#2\s+WIP.*draft/); | |
| 102 | + const j = await run(["pr", "list", "-R", "o/r", "--json"], { env }); | |
| 103 | + expect(JSON.parse(j.stdout)).toHaveLength(2); | |
| 104 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open"); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it("pr checks exits 1 on failure", async () => { | |
| 108 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } }); | |
| 109 | + const r = await run(["pr", "checks", "9", "-R", "o/r"], { env }); | |
| 110 | + expect(r.code).toBe(1); | |
| 111 | + expect(r.stdout).toContain("ci / test"); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("pr comment posts to the issue comments endpoint", async () => { | |
| 115 | + srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); | |
| 116 | + expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); | |
| 117 | + expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" }); | |
| 118 | + }); | |
| 119 | +}); |
addedapps/cli/test/runner.test.ts+94−0View file
| @@ -1,0 +1,94 @@ | ||
| 1 | +import { existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 5 | + | |
| 6 | +/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */ | |
| 7 | +function fakeAgent(exitCode = 0): { agent: string; record: string } { | |
| 8 | + const dir = tempDir("diffinery-agent-"); | |
| 9 | + const record = path.join(dir, "record.json"); | |
| 10 | + const agent = path.join(dir, "agent.mjs"); | |
| 11 | + writeFileSync( | |
| 12 | + agent, | |
| 13 | + `import { writeFileSync } from "node:fs"; | |
| 14 | +writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env })); | |
| 15 | +console.log("hello from the agent"); | |
| 16 | +console.error("agent stderr line"); | |
| 17 | +process.exit(${exitCode}); | |
| 18 | +`, | |
| 19 | + ); | |
| 20 | + return { agent, record }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +describe("runner", () => { | |
| 24 | + let srv: FakeServer; | |
| 25 | + let env: NodeJS.ProcessEnv; | |
| 26 | + beforeEach(async () => { | |
| 27 | + srv = await fakeServer(); | |
| 28 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" }); | |
| 29 | + }); | |
| 30 | + afterEach(() => srv.close()); | |
| 31 | + | |
| 32 | + it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => { | |
| 33 | + let claims = 0; | |
| 34 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => | |
| 35 | + ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 }, | |
| 36 | + ); | |
| 37 | + const { agent, record } = fakeAgent(3); | |
| 38 | + const work = tempDir("diffinery-work-"); | |
| 39 | + const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env }); | |
| 40 | + | |
| 41 | + expect(r.code).toBe(3); | |
| 42 | + const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); | |
| 43 | + expect(claimReq).toHaveLength(1); | |
| 44 | + expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] }); | |
| 45 | + expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token"); | |
| 46 | + | |
| 47 | + const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> }; | |
| 48 | + expect(rec.argv).toEqual([]); | |
| 49 | + expect(rec.env.DIFFINERY_API_URL).toBe(srv.url); | |
| 50 | + expect(rec.env.DIFFINERY_JOB_ID).toBe("job42"); | |
| 51 | + expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret"); | |
| 52 | + expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work); | |
| 53 | + expect(rec.env.DIFFINERY_TOKEN).toBeUndefined(); | |
| 54 | + expect(rec.env.DIFFINERY_URL).toBeUndefined(); | |
| 55 | + | |
| 56 | + expect(r.stdout).toContain("[build (linux)] hello from the agent"); | |
| 57 | + expect(r.stderr).toContain("[build (linux)] agent stderr line"); | |
| 58 | + expect(r.stdout).not.toContain("djt_repo_job42_secret"); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("--once with no waiting job exits 0 without running the agent", async () => { | |
| 62 | + srv.on("POST", /\/runners\/claim$/, { status: 204 }); | |
| 63 | + const { agent, record } = fakeAgent(0); | |
| 64 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env }); | |
| 65 | + expect(r.code).toBe(0); | |
| 66 | + expect(r.stdout).toContain("No jobs waiting"); | |
| 67 | + expect(existsSync(record)).toBe(false); | |
| 68 | + expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => { | |
| 72 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); | |
| 73 | + srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } }); | |
| 74 | + const { agent, record } = fakeAgent(0); | |
| 75 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } }); | |
| 76 | + expect(r.code).toBe(0); | |
| 77 | + expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1"); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("explains how to build the agent when none is configured", async () => { | |
| 81 | + const r = await run(["runner", "-R", "acme/app", "--once"], { env }); | |
| 82 | + expect(r.code).toBe(1); | |
| 83 | + expect(r.stderr).toContain("pnpm --filter @diffinery/runner build"); | |
| 84 | + expect(r.stderr).toContain("containers/runner/dist/agent.mjs"); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("fails fast on permission errors", async () => { | |
| 88 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } }); | |
| 89 | + const { agent } = fakeAgent(0); | |
| 90 | + const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env }); | |
| 91 | + expect(r.code).toBe(1); | |
| 92 | + expect(r.stderr).toContain("admin access required"); | |
| 93 | + }); | |
| 94 | +}); |
addedapps/cli/test/workflow.test.ts+98−0View file
| @@ -1,0 +1,98 @@ | ||
| 1 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { parseInputs } from "../src/commands/workflow.js"; | |
| 5 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | + | |
| 7 | +const GOOD = `name: CI | |
| 8 | +on: [push] | |
| 9 | +jobs: | |
| 10 | + test: | |
| 11 | + runs-on: ubuntu-latest | |
| 12 | + steps: | |
| 13 | + - run: echo hi | |
| 14 | +`; | |
| 15 | + | |
| 16 | +const BAD = `name: Broken | |
| 17 | +on: push | |
| 18 | +jobs: | |
| 19 | + build: | |
| 20 | + runs-on: ubuntu-latest | |
| 21 | + steps: | |
| 22 | + - run: echo hi | |
| 23 | + bogus: true | |
| 24 | + deploy: | |
| 25 | + needs: missing-job | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - run: echo deploy | |
| 29 | +`; | |
| 30 | + | |
| 31 | +describe("workflow validate", () => { | |
| 32 | + function project(files: Record<string, string>): string { | |
| 33 | + const dir = tempDir("diffinery-wf-"); | |
| 34 | + for (const [p, content] of Object.entries(files)) { | |
| 35 | + mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); | |
| 36 | + writeFileSync(path.join(dir, p), content); | |
| 37 | + } | |
| 38 | + return dir; | |
| 39 | + } | |
| 40 | + | |
| 41 | + it("passes valid files in both workflow directories", async () => { | |
| 42 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".diffinery/workflows/other.yaml": GOOD, ".github/workflows/README.md": "not yaml" }); | |
| 43 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 44 | + expect(r.code).toBe(0); | |
| 45 | + expect(r.stdout).toContain(".github/workflows/ci.yml"); | |
| 46 | + expect(r.stdout).toContain(".diffinery/workflows/other.yaml"); | |
| 47 | + expect(r.stderr).toContain("2 files checked: no errors"); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("reports errors with file:line and exits 1", async () => { | |
| 51 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".github/workflows/bad.yml": BAD }); | |
| 52 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 53 | + expect(r.code).toBe(1); | |
| 54 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:10(:\d+)?: error: .*missing-job/); | |
| 55 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:8(:\d+)?: (error|warning): .*bogus/); | |
| 56 | + expect(r.stderr).toMatch(/2 files checked: \d+ errors?/); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("validates explicit files and reports YAML syntax errors", async () => { | |
| 60 | + const cwd = project({ "x.yml": "jobs: [unclosed\n" }); | |
| 61 | + const r = await run(["workflow", "validate", "x.yml", "--json"], { cwd, env: isolatedEnv() }); | |
| 62 | + expect(r.code).toBe(1); | |
| 63 | + const res = JSON.parse(r.stdout); | |
| 64 | + expect(res[0].file).toBe("x.yml"); | |
| 65 | + expect(res[0].valid).toBe(false); | |
| 66 | + expect(res[0].problems[0].message).toMatch(/YAML/); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("is fine with no workflow files", async () => { | |
| 70 | + const r = await run(["workflow", "validate"], { cwd: project({}), env: isolatedEnv() }); | |
| 71 | + expect(r.code).toBe(0); | |
| 72 | + expect(r.stderr).toContain("No workflow files"); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe("workflow run", () => { | |
| 77 | + let srv: FakeServer; | |
| 78 | + beforeEach(async () => { | |
| 79 | + srv = await fakeServer(); | |
| 80 | + }); | |
| 81 | + afterEach(() => srv.close()); | |
| 82 | + | |
| 83 | + it("dispatches with inputs on the default branch", async () => { | |
| 84 | + srv.on("GET", "/api/v3/repos/o/r", { body: { default_branch: "trunk" } }); | |
| 85 | + srv.on("POST", /\/actions\/workflows\/.+\/dispatches$/, { status: 201, body: { run_id: "run1", html_url: "http://x" } }); | |
| 86 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy" }); | |
| 87 | + const r = await run(["workflow", "run", "deploy.yml", "-R", "o/r", "-f", "env=prod", "-f", "dry=true"], { env }); | |
| 88 | + expect(r.code).toBe(0); | |
| 89 | + const req = srv.find("POST", /dispatches$/)[0]!; | |
| 90 | + expect(req.path).toBe("/api/v3/repos/o/r/actions/workflows/deploy.yml/dispatches"); | |
| 91 | + expect(req.body).toEqual({ ref: "trunk", inputs: { env: "prod", dry: "true" } }); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("parses -f pairs", () => { | |
| 95 | + expect(parseInputs(["a=1", "b=x=y", "c="])).toEqual({ a: "1", b: "x=y", c: "" }); | |
| 96 | + expect(() => parseInputs(["nope"])).toThrow(); | |
| 97 | + }); | |
| 98 | +}); |
addedapps/cli/tsconfig.json+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } |
addedapps/diffinery/migrations/0001_init.sql+119−0View file
| @@ -1,0 +1,119 @@ | ||
| 1 | +-- Global directory: accounts (users, orgs, agents), repos, access, tokens, invites. | |
| 2 | +CREATE TABLE accounts ( | |
| 3 | + id TEXT PRIMARY KEY, | |
| 4 | + login TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 5 | + kind TEXT NOT NULL CHECK (kind IN ('user', 'org', 'agent')), | |
| 6 | + name TEXT, | |
| 7 | + email TEXT UNIQUE COLLATE NOCASE, | |
| 8 | + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending', 'disabled')), | |
| 9 | + site_admin INTEGER NOT NULL DEFAULT 0, | |
| 10 | + owner_id TEXT REFERENCES accounts(id), | |
| 11 | + agent_policy TEXT, | |
| 12 | + created_at TEXT NOT NULL | |
| 13 | +); | |
| 14 | +CREATE INDEX accounts_owner ON accounts(owner_id); | |
| 15 | + | |
| 16 | +CREATE TABLE org_members ( | |
| 17 | + org_id TEXT NOT NULL REFERENCES accounts(id), | |
| 18 | + user_id TEXT NOT NULL REFERENCES accounts(id), | |
| 19 | + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), | |
| 20 | + created_at TEXT NOT NULL, | |
| 21 | + PRIMARY KEY (org_id, user_id) | |
| 22 | +); | |
| 23 | +CREATE INDEX org_members_user ON org_members(user_id); | |
| 24 | + | |
| 25 | +CREATE TABLE repos ( | |
| 26 | + id TEXT PRIMARY KEY, | |
| 27 | + owner_id TEXT NOT NULL REFERENCES accounts(id), | |
| 28 | + name TEXT NOT NULL COLLATE NOCASE, | |
| 29 | + description TEXT NOT NULL DEFAULT '', | |
| 30 | + visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'internal', 'public')), | |
| 31 | + default_branch TEXT NOT NULL DEFAULT 'main', | |
| 32 | + archived INTEGER NOT NULL DEFAULT 0, | |
| 33 | + created_at TEXT NOT NULL, | |
| 34 | + updated_at TEXT NOT NULL, | |
| 35 | + pushed_at TEXT, | |
| 36 | + UNIQUE (owner_id, name) | |
| 37 | +); | |
| 38 | + | |
| 39 | +CREATE TABLE repo_access ( | |
| 40 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 41 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 42 | + role TEXT NOT NULL CHECK (role IN ('read', 'triage', 'write', 'maintain', 'admin')), | |
| 43 | + PRIMARY KEY (repo_id, account_id) | |
| 44 | +); | |
| 45 | +CREATE INDEX repo_access_account ON repo_access(account_id); | |
| 46 | + | |
| 47 | +CREATE TABLE tokens ( | |
| 48 | + id TEXT PRIMARY KEY, | |
| 49 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 50 | + name TEXT NOT NULL, | |
| 51 | + token_hash TEXT NOT NULL UNIQUE, | |
| 52 | + prefix TEXT NOT NULL, | |
| 53 | + scopes TEXT NOT NULL, | |
| 54 | + repo_ids TEXT, | |
| 55 | + expires_at TEXT NOT NULL, | |
| 56 | + last_used_at TEXT, | |
| 57 | + created_at TEXT NOT NULL, | |
| 58 | + revoked_at TEXT | |
| 59 | +); | |
| 60 | +CREATE INDEX tokens_account ON tokens(account_id); | |
| 61 | + | |
| 62 | +CREATE TABLE invites ( | |
| 63 | + id TEXT PRIMARY KEY, | |
| 64 | + org_id TEXT REFERENCES accounts(id), | |
| 65 | + repo_id TEXT REFERENCES repos(id), | |
| 66 | + email TEXT COLLATE NOCASE, | |
| 67 | + role TEXT NOT NULL, | |
| 68 | + token_hash TEXT NOT NULL UNIQUE, | |
| 69 | + invited_by TEXT NOT NULL REFERENCES accounts(id), | |
| 70 | + created_at TEXT NOT NULL, | |
| 71 | + expires_at TEXT NOT NULL, | |
| 72 | + accepted_at TEXT, | |
| 73 | + accepted_by TEXT | |
| 74 | +); | |
| 75 | + | |
| 76 | +CREATE TABLE notifications ( | |
| 77 | + id TEXT PRIMARY KEY, | |
| 78 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 79 | + repo_id TEXT, | |
| 80 | + kind TEXT NOT NULL, | |
| 81 | + title TEXT NOT NULL, | |
| 82 | + url TEXT NOT NULL, | |
| 83 | + created_at TEXT NOT NULL, | |
| 84 | + read_at TEXT | |
| 85 | +); | |
| 86 | +CREATE INDEX notifications_account ON notifications(account_id, read_at); | |
| 87 | + | |
| 88 | +CREATE TABLE audit_log ( | |
| 89 | + id TEXT PRIMARY KEY, | |
| 90 | + at TEXT NOT NULL, | |
| 91 | + actor_id TEXT, | |
| 92 | + actor_login TEXT, | |
| 93 | + on_behalf_of TEXT, | |
| 94 | + action TEXT NOT NULL, | |
| 95 | + target TEXT, | |
| 96 | + detail TEXT | |
| 97 | +); | |
| 98 | +CREATE INDEX audit_at ON audit_log(at); | |
| 99 | + | |
| 100 | +CREATE TABLE schedules ( | |
| 101 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 102 | + workflow_path TEXT NOT NULL, | |
| 103 | + cron TEXT NOT NULL, | |
| 104 | + PRIMARY KEY (repo_id, workflow_path, cron) | |
| 105 | +); | |
| 106 | + | |
| 107 | +CREATE TABLE org_secrets ( | |
| 108 | + org_id TEXT NOT NULL REFERENCES accounts(id), | |
| 109 | + name TEXT NOT NULL, | |
| 110 | + ciphertext TEXT NOT NULL, | |
| 111 | + updated_at TEXT NOT NULL, | |
| 112 | + PRIMARY KEY (org_id, name) | |
| 113 | +); | |
| 114 | + | |
| 115 | +CREATE TABLE access_requests ( | |
| 116 | + account_id TEXT PRIMARY KEY REFERENCES accounts(id), | |
| 117 | + note TEXT, | |
| 118 | + created_at TEXT NOT NULL | |
| 119 | +); |
addedapps/diffinery/migrations/0002_passkeys.sql+46−0View file
| @@ -1,0 +1,46 @@ | ||
| 1 | +-- Passkey (WebAuthn) sign-in, browser sessions and one-time sign-in links. | |
| 2 | + | |
| 3 | +CREATE TABLE passkeys ( | |
| 4 | + id TEXT PRIMARY KEY, -- credential id, base64url | |
| 5 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 6 | + public_key TEXT NOT NULL, -- COSE public key, base64url | |
| 7 | + counter INTEGER NOT NULL DEFAULT 0, | |
| 8 | + transports TEXT, -- JSON array | |
| 9 | + device_type TEXT NOT NULL, -- singleDevice | multiDevice | |
| 10 | + backed_up INTEGER NOT NULL DEFAULT 0, | |
| 11 | + name TEXT NOT NULL, | |
| 12 | + created_at TEXT NOT NULL, | |
| 13 | + last_used_at TEXT | |
| 14 | +); | |
| 15 | +CREATE INDEX passkeys_account ON passkeys(account_id); | |
| 16 | + | |
| 17 | +CREATE TABLE sessions ( | |
| 18 | + id TEXT PRIMARY KEY, -- sha256 of the cookie value | |
| 19 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 20 | + method TEXT NOT NULL, -- passkey | access | link | |
| 21 | + passkey_id TEXT, | |
| 22 | + user_agent TEXT, | |
| 23 | + created_at TEXT NOT NULL, | |
| 24 | + last_seen_at TEXT NOT NULL, | |
| 25 | + expires_at TEXT NOT NULL | |
| 26 | +); | |
| 27 | +CREATE INDEX sessions_account ON sessions(account_id); | |
| 28 | + | |
| 29 | +CREATE TABLE webauthn_challenges ( | |
| 30 | + id TEXT PRIMARY KEY, -- sha256 of the browser cookie value | |
| 31 | + purpose TEXT NOT NULL CHECK (purpose IN ('login', 'register')), | |
| 32 | + challenge TEXT NOT NULL, | |
| 33 | + subject TEXT NOT NULL, -- JSON: who the passkey is for | |
| 34 | + expires_at TEXT NOT NULL | |
| 35 | +); | |
| 36 | + | |
| 37 | +-- Admin-issued, single-use links that let someone add a passkey to an existing account. | |
| 38 | +CREATE TABLE signin_links ( | |
| 39 | + id TEXT PRIMARY KEY, | |
| 40 | + token_hash TEXT NOT NULL UNIQUE, | |
| 41 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 42 | + created_by TEXT NOT NULL REFERENCES accounts(id), | |
| 43 | + created_at TEXT NOT NULL, | |
| 44 | + expires_at TEXT NOT NULL, | |
| 45 | + used_at TEXT | |
| 46 | +); |
addedapps/diffinery/package.json+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@diffinery/app", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "wrangler dev -e dev", | |
| 8 | + "deploy": "wrangler deploy -c wrangler.deploy.jsonc", | |
| 9 | + "typecheck": "tsc --noEmit", | |
| 10 | + "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'", | |
| 11 | + "cf-typegen": "wrangler types", | |
| 12 | + "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs", | |
| 13 | + "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev" | |
| 14 | + }, | |
| 15 | + "dependencies": { | |
| 16 | + "@cloudflare/containers": "^0.3.7", | |
| 17 | + "@diffinery/git-core": "workspace:*", | |
| 18 | + "@diffinery/shared": "workspace:*", | |
| 19 | + "@diffinery/workflow-yaml": "workspace:*", | |
| 20 | + "@simplewebauthn/server": "14.0.3", | |
| 21 | + "hono": "^4.13.0" | |
| 22 | + }, | |
| 23 | + "devDependencies": { | |
| 24 | + "@cloudflare/vitest-pool-workers": "^0.23.0", | |
| 25 | + "@cloudflare/workers-types": "^5.20261012.1", | |
| 26 | + "playwright": "1.56.1", | |
| 27 | + "typescript": "^5.9.3", | |
| 28 | + "vitest": "^4.1.0", | |
| 29 | + "wrangler": "^4.149.0" | |
| 30 | + } | |
| 31 | +} |
addedapps/diffinery/src/app.ts+206−0View file
| @@ -1,0 +1,206 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 3 | +import { cronMatches } from "@diffinery/workflow-yaml"; | |
| 4 | +import type { Env } from "./env.js"; | |
| 5 | +import { credentialFrom, resolvePrincipal, type AppVars } from "./auth/principal.js"; | |
| 6 | +import { Directory } from "./lib/directory.js"; | |
| 7 | +import { hmacSha256Hex, newId } from "./lib/util.js"; | |
| 8 | +import { errorResponse, loadRepo, repoStub } from "./http/common.js"; | |
| 9 | +import { git } from "./http/git.js"; | |
| 10 | +import { runner } from "./http/runner.js"; | |
| 11 | +import { api, wellKnown } from "./http/api.js"; | |
| 12 | +import { mcp } from "./mcp/server.js"; | |
| 13 | + | |
| 14 | +export const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 15 | + | |
| 16 | +app.onError((e) => errorResponse(e)); | |
| 17 | + | |
| 18 | +// Security headers on every response, then identity: tokens, Access JWT, or anonymous. | |
| 19 | +app.use("*", async (c, next) => { | |
| 20 | + // Browser sessions ride on cookies, so refuse state-changing requests that come from another site. | |
| 21 | + // Token-authenticated calls (git, CLI, MCP, runners) carry no ambient credentials and are exempt. | |
| 22 | + if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !credentialFrom(c.req.raw)) { | |
| 23 | + const site = c.req.header("sec-fetch-site"); | |
| 24 | + const origin = c.req.header("origin"); | |
| 25 | + const ours = new URL(c.env.PUBLIC_URL).origin; | |
| 26 | + if ((site && site !== "same-origin" && site !== "none") || (!site && origin && origin !== ours)) { | |
| 27 | + return c.json({ message: "cross-site request refused" }, 403); | |
| 28 | + } | |
| 29 | + } | |
| 30 | + const dir = new Directory(c.env); | |
| 31 | + c.set("dir", dir); | |
| 32 | + c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 33 | + await next(); | |
| 34 | + if (c.res.status === 101) return; | |
| 35 | + const h = c.res.headers; | |
| 36 | + h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload"); | |
| 37 | + h.set("x-content-type-options", "nosniff"); | |
| 38 | + h.set("referrer-policy", "strict-origin-when-cross-origin"); | |
| 39 | + if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY"); | |
| 40 | + h.set("permissions-policy", "camera=(), microphone=(), geolocation=()"); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +app.get("/healthz", (c) => c.json({ ok: true })); | |
| 44 | + | |
| 45 | +app.route("/", wellKnown); | |
| 46 | +app.route("/", git); | |
| 47 | +app.route("/", runner); | |
| 48 | +app.route("/", api); | |
| 49 | +app.route("/", mcp); | |
| 50 | + | |
| 51 | +// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. | |
| 52 | +app.get("/:owner/:repo/live", async (c) => { | |
| 53 | + if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426); | |
| 54 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 55 | + return stub.fetch(c.req.raw); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => { | |
| 59 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 60 | + const name = c.req.param("name"); | |
| 61 | + if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404); | |
| 62 | + const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name)); | |
| 63 | + if (!obj) return c.json({ message: "artifact expired" }, 410); | |
| 64 | + return new Response(obj.body, { | |
| 65 | + headers: { | |
| 66 | + "content-type": "application/gzip", | |
| 67 | + "content-length": String(obj.size), | |
| 68 | + "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`, | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | +}); | |
| 72 | + | |
| 73 | +// ---------------------------------------------------------------- events queue | |
| 74 | + | |
| 75 | +const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = { | |
| 76 | + push: "push", | |
| 77 | + pull_request: "pull_request", | |
| 78 | + issues: "issues", | |
| 79 | + issue_comment: "issue_comment", | |
| 80 | + pull_request_review: "pull_request_review", | |
| 81 | + workflow_run: "workflow_run", | |
| 82 | + workflow_dispatch: "workflow_dispatch", | |
| 83 | + audit: "audit", | |
| 84 | +}; | |
| 85 | + | |
| 86 | +function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) { | |
| 87 | + const repository = { id: ev.repoId, name: ev.repo, full_name: `${ev.owner}/${ev.repo}`, owner: { login: ev.owner }, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}`, clone_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}.git` }; | |
| 88 | + const sender = { id: ev.actor.id, login: ev.actor.login, type: ev.actor.kind === "agent" ? "Bot" : ev.actor.kind === "system" ? "Bot" : "User", on_behalf_of: ev.actor.onBehalfOf }; | |
| 89 | + const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev; | |
| 90 | + const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined; | |
| 91 | + const extra: Record<string, unknown> = {}; | |
| 92 | + if (ev.type === "pull_request") { | |
| 93 | + extra.pull_request = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/pull/${ev.number}`, head: { ref: ev.headRef, sha: ev.headSha }, base: { ref: ev.baseRef }, merged: ev.action === "merged" }; | |
| 94 | + } | |
| 95 | + if (ev.type === "issues" || ev.type === "issue_comment") extra.issue = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/issues/${ev.number}` }; | |
| 96 | + return { ...rest, action, repository, sender, ...extra }; | |
| 97 | +} | |
| 98 | + | |
| 99 | +async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> { | |
| 100 | + const stub = repoStub(env, ev.repoId); | |
| 101 | + const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]))); | |
| 102 | + if (!hooks.length) return; | |
| 103 | + const body = JSON.stringify(webhookPayload(env, ev)); | |
| 104 | + await Promise.all( | |
| 105 | + hooks.map(async (h) => { | |
| 106 | + const delivery = newId(); | |
| 107 | + let status = 0; | |
| 108 | + try { | |
| 109 | + const res = await fetch(h.url, { | |
| 110 | + method: "POST", | |
| 111 | + headers: { | |
| 112 | + "content-type": "application/json", | |
| 113 | + "user-agent": "Diffinery-Hookshot/1", | |
| 114 | + "x-github-event": GITHUB_EVENT[ev.type], | |
| 115 | + "x-github-delivery": delivery, | |
| 116 | + "x-diffinery-event": ev.type, | |
| 117 | + "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`, | |
| 118 | + }, | |
| 119 | + body, | |
| 120 | + redirect: "manual", | |
| 121 | + signal: AbortSignal.timeout(10_000), | |
| 122 | + }); | |
| 123 | + status = res.status; | |
| 124 | + } catch { | |
| 125 | + status = 0; | |
| 126 | + } | |
| 127 | + await stub.recordDelivery(h.id, status); | |
| 128 | + }), | |
| 129 | + ); | |
| 130 | +} | |
| 131 | + | |
| 132 | +/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */ | |
| 133 | +async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 134 | + if (ev.type === "workflow_run") { | |
| 135 | + if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return; | |
| 136 | + await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` }); | |
| 137 | + return; | |
| 138 | + } | |
| 139 | + if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return; | |
| 140 | + if (ev.type === "pull_request" && ev.action === "synchronize") return; | |
| 141 | + const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number); | |
| 142 | + const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]); | |
| 143 | + for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) { | |
| 144 | + const a = await dir.accountByLogin(login); | |
| 145 | + if (a) ids.add(a.id); | |
| 146 | + } | |
| 147 | + ids.delete(ev.actor.id); | |
| 148 | + if (!ids.size) return; | |
| 149 | + const kind = issue.kind === "pull" ? "pull" : "issues"; | |
| 150 | + const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action; | |
| 151 | + await dir.notify([...ids].slice(0, 100), { | |
| 152 | + repoId: ev.repoId, | |
| 153 | + kind: ev.type, | |
| 154 | + title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`, | |
| 155 | + url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`, | |
| 156 | + }); | |
| 157 | +} | |
| 158 | + | |
| 159 | +async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 160 | + if (ev.type === "audit") { | |
| 161 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at }); | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + if (ev.type === "push") { | |
| 165 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at }); | |
| 166 | + } | |
| 167 | + if (ev.type === "pull_request" && ev.action === "merged") { | |
| 168 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at }); | |
| 169 | + } | |
| 170 | + await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]); | |
| 171 | +} | |
| 172 | + | |
| 173 | +export async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> { | |
| 174 | + const dir = new Directory(env); | |
| 175 | + // Keep an append-only copy of every event in R2 for long-term audit retention. | |
| 176 | + const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/"); | |
| 177 | + await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, batch.messages.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e)); | |
| 178 | + for (const msg of batch.messages) { | |
| 179 | + try { | |
| 180 | + await handleEvent(env, dir, msg.body); | |
| 181 | + msg.ack(); | |
| 182 | + } catch (e) { | |
| 183 | + console.error("event handling failed", msg.body.type, e); | |
| 184 | + msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) }); | |
| 185 | + } | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +// ---------------------------------------------------------------- cron: workflow schedules | |
| 190 | + | |
| 191 | +export async function scheduled(controller: ScheduledController, env: Env): Promise<void> { | |
| 192 | + // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window. | |
| 193 | + const end = new Date(controller.scheduledTime); | |
| 194 | + end.setUTCSeconds(0, 0); | |
| 195 | + const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000)); | |
| 196 | + const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>(); | |
| 197 | + await Promise.all( | |
| 198 | + results.map(async (s) => { | |
| 199 | + try { | |
| 200 | + if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron); | |
| 201 | + } catch (e) { | |
| 202 | + console.error("schedule failed", s.repo_id, s.workflow_path, e); | |
| 203 | + } | |
| 204 | + }), | |
| 205 | + ); | |
| 206 | +} |
addedapps/diffinery/src/auth/principal.ts+113−0View file
| @@ -1,0 +1,113 @@ | ||
| 1 | +import type { Context } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 4 | +import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js"; | |
| 5 | +import { HttpError, fromB64 } from "../lib/util.js"; | |
| 6 | +import type { Caller, Role } from "../repo/types.js"; | |
| 7 | +import { ROLE_LEVEL } from "../repo/types.js"; | |
| 8 | + | |
| 9 | +export type Principal = | |
| 10 | + | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string } | |
| 11 | + | { type: "repo-token"; repoId: string; token: string } | |
| 12 | + | { type: "anonymous" }; | |
| 13 | + | |
| 14 | +export interface AppVars { | |
| 15 | + principal: Principal; | |
| 16 | + dir: Directory; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>; | |
| 20 | + | |
| 21 | +/** Extract a bearer/basic credential, as git, curl, the CLI and MCP clients send it. */ | |
| 22 | +export function credentialFrom(req: Request): string | null { | |
| 23 | + const h = req.headers.get("authorization"); | |
| 24 | + if (!h) return null; | |
| 25 | + const [scheme, value] = h.split(" ", 2); | |
| 26 | + if (!value) return null; | |
| 27 | + if (/^bearer$/i.test(scheme!) || /^token$/i.test(scheme!)) return value.trim(); | |
| 28 | + if (/^basic$/i.test(scheme!)) { | |
| 29 | + const decoded = new TextDecoder().decode(fromB64(value.trim())); | |
| 30 | + const idx = decoded.indexOf(":"); | |
| 31 | + const pass = idx >= 0 ? decoded.slice(idx + 1) : decoded; | |
| 32 | + const user = idx >= 0 ? decoded.slice(0, idx) : ""; | |
| 33 | + return pass || user || null; | |
| 34 | + } | |
| 35 | + return null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** The Cloudflare Access assertion, present only on the Access-protected recovery path. */ | |
| 39 | +export function accessToken(req: Request): string | null { | |
| 40 | + const h = req.headers.get("cf-access-jwt-assertion"); | |
| 41 | + if (h) return h; | |
| 42 | + const cookie = req.headers.get("cookie") ?? ""; | |
| 43 | + const m = /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie); | |
| 44 | + return m ? m[1]! : null; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export async function resolvePrincipal(req: Request, env: Env, dir: Directory): Promise<Principal> { | |
| 48 | + const cred = credentialFrom(req); | |
| 49 | + if (cred) { | |
| 50 | + if (cred.startsWith("dfy_")) { | |
| 51 | + const v = await dir.verifyToken(cred); | |
| 52 | + if (!v) throw new HttpError(401, "invalid, expired or revoked token"); | |
| 53 | + return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds }; | |
| 54 | + } | |
| 55 | + if (cred.startsWith("djt_") || cred.startsWith("dst_")) { | |
| 56 | + const repoId = cred.split("_")[1]; | |
| 57 | + if (!repoId) throw new HttpError(401, "malformed token"); | |
| 58 | + return { type: "repo-token", repoId, token: cred }; | |
| 59 | + } | |
| 60 | + throw new HttpError(401, "unrecognised token; create one under Settings → Tokens"); | |
| 61 | + } | |
| 62 | + // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie. | |
| 63 | + const session = cookieFrom(req, SESSION_COOKIE); | |
| 64 | + if (session) { | |
| 65 | + const s = await new Auth(env).session(session); | |
| 66 | + if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id }; | |
| 67 | + } | |
| 68 | + if (env.DEV_AUTH && !env.ACCESS_AUD) { | |
| 69 | + const account = await dir.userForEmail(env.DEV_AUTH); | |
| 70 | + return { type: "account", account, via: "dev", scopes: ["admin"], repoIds: null }; | |
| 71 | + } | |
| 72 | + return { type: "anonymous" }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +export function requireAccount(c: AppContext): Account { | |
| 76 | + const p = c.get("principal"); | |
| 77 | + if (p.type !== "account") throw new HttpError(401, "sign in required"); | |
| 78 | + if (p.account.status === "pending") throw new HttpError(403, "your account is waiting for an invite"); | |
| 79 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 80 | + return p.account; | |
| 81 | +} | |
| 82 | + | |
| 83 | +const SCOPE_CAP: Record<TokenScope, Role> = { read: "read", write: "maintain", admin: "admin" }; | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * Resolve the caller's effective role on a repository, combining account | |
| 87 | + * permissions with token scopes and repo restrictions. Returns null for no access. | |
| 88 | + */ | |
| 89 | +export async function callerFor(c: AppContext, repo: Repo): Promise<Caller | null> { | |
| 90 | + const p = c.get("principal"); | |
| 91 | + const dir = c.get("dir"); | |
| 92 | + if (p.type === "repo-token") { | |
| 93 | + if (p.repoId !== repo.id) return null; | |
| 94 | + const stub = c.env.REPO.get(c.env.REPO.idFromName(repo.id)); | |
| 95 | + const auth = await stub.runnerAuth(p.token); | |
| 96 | + if (!auth) return null; | |
| 97 | + if (auth.kind === "job") return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: "system", role: "read" }; | |
| 98 | + const cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role; | |
| 99 | + return { ...auth.caller, role: cap }; | |
| 100 | + } | |
| 101 | + if (p.type !== "account") { | |
| 102 | + return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; | |
| 103 | + } | |
| 104 | + if (p.repoIds && !p.repoIds.includes(repo.id)) return null; | |
| 105 | + let role = await dir.roleFor(p.account, repo); | |
| 106 | + if (!role) return null; | |
| 107 | + const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read"); | |
| 108 | + if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap; | |
| 109 | + const a = p.account; | |
| 110 | + let onBehalfOf: string | undefined; | |
| 111 | + if (a.kind === "agent" && a.owner_id) onBehalfOf = (await dir.account(a.owner_id))?.login; | |
| 112 | + return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role }; | |
| 113 | +} |
addedapps/diffinery/src/auth/sessions.ts+209−0View file
| @@ -1,0 +1,209 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Account } from "../lib/directory.js"; | |
| 3 | +import { newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 4 | + | |
| 5 | +/** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */ | |
| 6 | +export const SESSION_COOKIE = "__Host-dfy_session"; | |
| 7 | +/** Binds an in-flight WebAuthn ceremony to the browser that started it. */ | |
| 8 | +export const CHALLENGE_COOKIE = "__Host-dfy_webauthn"; | |
| 9 | +export const SESSION_DAYS = 30; | |
| 10 | +const CHALLENGE_MINUTES = 5; | |
| 11 | +const LINK_HOURS = 24; | |
| 12 | + | |
| 13 | +export type PasskeyRow = { | |
| 14 | + id: string; | |
| 15 | + account_id: string; | |
| 16 | + public_key: string; | |
| 17 | + counter: number; | |
| 18 | + transports: string | null; | |
| 19 | + device_type: string; | |
| 20 | + backed_up: number; | |
| 21 | + name: string; | |
| 22 | + created_at: string; | |
| 23 | + last_used_at: string | null; | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export type SessionRow = { | |
| 27 | + id: string; | |
| 28 | + account_id: string; | |
| 29 | + method: string; | |
| 30 | + passkey_id: string | null; | |
| 31 | + user_agent: string | null; | |
| 32 | + created_at: string; | |
| 33 | + last_seen_at: string; | |
| 34 | + expires_at: string; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export type InviteRow = { id: string; email: string | null; org_id: string | null; repo_id: string | null; role: string }; | |
| 38 | + | |
| 39 | +/** Who a passkey being registered will belong to. */ | |
| 40 | +export type Subject = | |
| 41 | + | { kind: "account"; accountId: string } | |
| 42 | + | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string } | |
| 43 | + | { kind: "link"; linkHash: string; accountId: string } | |
| 44 | + | { kind: "login" }; | |
| 45 | + | |
| 46 | +const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString(); | |
| 47 | + | |
| 48 | +/** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */ | |
| 49 | +export class Auth { | |
| 50 | + constructor(private env: Env) {} | |
| 51 | + | |
| 52 | + private get db() { | |
| 53 | + return this.env.DB; | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** Relying party: passkeys are bound to the hostname of PUBLIC_URL. */ | |
| 57 | + rp(): { id: string; origin: string; name: string } { | |
| 58 | + const u = new URL(this.env.PUBLIC_URL); | |
| 59 | + return { id: u.hostname, origin: u.origin, name: "Diffinery" }; | |
| 60 | + } | |
| 61 | + | |
| 62 | + // ---------------------------------------------------------------- sessions | |
| 63 | + | |
| 64 | + async createSession(accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null, userAgent: string | null): Promise<string> { | |
| 65 | + const token = randomToken(32); | |
| 66 | + const at = now(); | |
| 67 | + await this.db | |
| 68 | + .prepare("INSERT INTO sessions (id, account_id, method, passkey_id, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 69 | + .bind(await sha256(token), accountId, method, passkeyId, userAgent?.slice(0, 300) ?? null, at, at, days(SESSION_DAYS)) | |
| 70 | + .run(); | |
| 71 | + return token; | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days. */ | |
| 75 | + async session(token: string): Promise<{ session: SessionRow; account: Account } | null> { | |
| 76 | + if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; | |
| 77 | + const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>(); | |
| 78 | + if (!row || row.expires_at < now()) return null; | |
| 79 | + const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); | |
| 80 | + if (!account || account.kind !== "user") return null; | |
| 81 | + if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { | |
| 82 | + await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run(); | |
| 83 | + } | |
| 84 | + return { session: row, account }; | |
| 85 | + } | |
| 86 | + | |
| 87 | + async endSession(token: string): Promise<void> { | |
| 88 | + await this.db.prepare("DELETE FROM sessions WHERE id = ?").bind(await sha256(token)).run(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + async sessions(accountId: string): Promise<SessionRow[]> { | |
| 92 | + const r = await this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 100").bind(accountId, now()).all<SessionRow>(); | |
| 93 | + return r.results; | |
| 94 | + } | |
| 95 | + | |
| 96 | + async endOtherSessions(accountId: string, keepId: string | null): Promise<void> { | |
| 97 | + await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + async endAllSessions(accountId: string): Promise<void> { | |
| 101 | + await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(accountId).run(); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // ---------------------------------------------------------------- passkeys | |
| 105 | + | |
| 106 | + async passkeys(accountId: string): Promise<PasskeyRow[]> { | |
| 107 | + const r = await this.db.prepare("SELECT * FROM passkeys WHERE account_id = ? ORDER BY created_at").bind(accountId).all<PasskeyRow>(); | |
| 108 | + return r.results; | |
| 109 | + } | |
| 110 | + | |
| 111 | + async passkey(id: string): Promise<PasskeyRow | null> { | |
| 112 | + return this.db.prepare("SELECT * FROM passkeys WHERE id = ?").bind(id).first<PasskeyRow>(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + async addPasskey(p: Omit<PasskeyRow, "created_at" | "last_used_at">): Promise<void> { | |
| 116 | + await this.db | |
| 117 | + .prepare("INSERT INTO passkeys (id, account_id, public_key, counter, transports, device_type, backed_up, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 118 | + .bind(p.id, p.account_id, p.public_key, p.counter, p.transports, p.device_type, p.backed_up, p.name.slice(0, 100), now()) | |
| 119 | + .run(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + async passkeyUsed(id: string, counter: number, backedUp: boolean): Promise<void> { | |
| 123 | + await this.db.prepare("UPDATE passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?").bind(counter, backedUp ? 1 : 0, now(), id).run(); | |
| 124 | + } | |
| 125 | + | |
| 126 | + async renamePasskey(accountId: string, id: string, name: string): Promise<void> { | |
| 127 | + await this.db.prepare("UPDATE passkeys SET name = ? WHERE id = ? AND account_id = ?").bind(name.slice(0, 100), id, accountId).run(); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /** Remove a passkey and every other session it signed in (a lost device's browsers). */ | |
| 131 | + async deletePasskey(accountId: string, id: string, keepSessionId: string | null): Promise<void> { | |
| 132 | + await this.db.batch([ | |
| 133 | + this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND passkey_id = ? AND id != ?").bind(accountId, id, keepSessionId ?? ""), | |
| 134 | + this.db.prepare("DELETE FROM passkeys WHERE id = ? AND account_id = ?").bind(id, accountId), | |
| 135 | + ]); | |
| 136 | + } | |
| 137 | + | |
| 138 | + // ---------------------------------------------------------------- WebAuthn challenges | |
| 139 | + | |
| 140 | + /** Store a challenge; returns the value for the browser's challenge cookie. */ | |
| 141 | + async saveChallenge(purpose: "login" | "register", challenge: string, subject: Subject): Promise<string> { | |
| 142 | + const token = randomToken(24); | |
| 143 | + const expires = new Date(Date.now() + CHALLENGE_MINUTES * 60_000).toISOString(); | |
| 144 | + await this.db.batch([ | |
| 145 | + this.db.prepare("DELETE FROM webauthn_challenges WHERE expires_at < ?").bind(now()), | |
| 146 | + this.db.prepare("INSERT INTO webauthn_challenges (id, purpose, challenge, subject, expires_at) VALUES (?, ?, ?, ?, ?)").bind(await sha256(token), purpose, challenge, JSON.stringify(subject), expires), | |
| 147 | + ]); | |
| 148 | + return token; | |
| 149 | + } | |
| 150 | + | |
| 151 | + /** Consume a challenge exactly once. */ | |
| 152 | + async takeChallenge(token: string | undefined, purpose: "login" | "register"): Promise<{ challenge: string; subject: Subject } | null> { | |
| 153 | + if (!token) return null; | |
| 154 | + const row = await this.db | |
| 155 | + .prepare("DELETE FROM webauthn_challenges WHERE id = ? RETURNING purpose, challenge, subject, expires_at") | |
| 156 | + .bind(await sha256(token)) | |
| 157 | + .first<{ purpose: string; challenge: string; subject: string; expires_at: string }>(); | |
| 158 | + if (!row || row.purpose !== purpose || row.expires_at < now()) return null; | |
| 159 | + return { challenge: row.challenge, subject: JSON.parse(row.subject) as Subject }; | |
| 160 | + } | |
| 161 | + | |
| 162 | + // ---------------------------------------------------------------- invites and sign-in links | |
| 163 | + | |
| 164 | + /** An open invite by its secret token (null when used, expired or unknown). */ | |
| 165 | + async invite(token: string): Promise<(InviteRow & { hash: string }) | null> { | |
| 166 | + const hash = await sha256(token); | |
| 167 | + const row = await this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); | |
| 168 | + return row ? { ...row, hash } : null; | |
| 169 | + } | |
| 170 | + | |
| 171 | + async inviteByHash(hash: string): Promise<InviteRow | null> { | |
| 172 | + return this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** A single-use link that lets the holder add a passkey to `account` (account recovery, new devices). */ | |
| 176 | + async createSigninLink(by: Account, account: Account): Promise<{ url: string; expiresAt: string }> { | |
| 177 | + const token = randomToken(32); | |
| 178 | + const expiresAt = new Date(Date.now() + LINK_HOURS * 3600_000).toISOString(); | |
| 179 | + await this.db | |
| 180 | + .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") | |
| 181 | + .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt) | |
| 182 | + .run(); | |
| 183 | + return { url: `${this.env.PUBLIC_URL}/signin/${token}`, expiresAt }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + async signinLink(token: string): Promise<{ hash: string; account: Account } | null> { | |
| 187 | + const hash = await sha256(token); | |
| 188 | + const row = await this.db.prepare("SELECT account_id FROM signin_links WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ account_id: string }>(); | |
| 189 | + if (!row) return null; | |
| 190 | + const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); | |
| 191 | + return account && account.kind === "user" ? { hash, account } : null; | |
| 192 | + } | |
| 193 | + | |
| 194 | + /** Mark a link used; false when someone else used it first. */ | |
| 195 | + async useSigninLink(hash: string): Promise<boolean> { | |
| 196 | + const r = await this.db.prepare("UPDATE signin_links SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run(); | |
| 197 | + return (r.meta.changes ?? 0) > 0; | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +/** Read one cookie from a raw request. */ | |
| 202 | +export function cookieFrom(req: Request, name: string): string | undefined { | |
| 203 | + const cookie = req.headers.get("cookie") ?? ""; | |
| 204 | + for (const part of cookie.split(";")) { | |
| 205 | + const i = part.indexOf("="); | |
| 206 | + if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim(); | |
| 207 | + } | |
| 208 | + return undefined; | |
| 209 | +} |
addedapps/diffinery/src/env.ts+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 2 | +import type { RepoDO } from "./repo/RepoDO.js"; | |
| 3 | +import type { RunnerContainer, SandboxContainer } from "./runner/containers.js"; | |
| 4 | + | |
| 5 | +export interface Env { | |
| 6 | + DB: D1Database; | |
| 7 | + BUCKET: R2Bucket; | |
| 8 | + EVENTS: Queue<DiffineryEvent>; | |
| 9 | + REPO: DurableObjectNamespace<RepoDO>; | |
| 10 | + RUNNER: DurableObjectNamespace<RunnerContainer>; | |
| 11 | + SANDBOX: DurableObjectNamespace<SandboxContainer>; | |
| 12 | + RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>; | |
| 13 | + | |
| 14 | + /** Canonical origin, e.g. https://diffinery.com */ | |
| 15 | + PUBLIC_URL: string; | |
| 16 | + /** Comma-separated emails or logins that become site admins on first sign-in. */ | |
| 17 | + BOOTSTRAP_ADMINS: string; | |
| 18 | + /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */ | |
| 19 | + ACCESS_TEAM_DOMAIN: string; | |
| 20 | + /** Access application audience tag. */ | |
| 21 | + ACCESS_AUD: string; | |
| 22 | + /** Local development only: act as this email when Access is not configured. */ | |
| 23 | + DEV_AUTH?: string; | |
| 24 | + | |
| 25 | + /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */ | |
| 26 | + MASTER_KEY?: string; | |
| 27 | + /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */ | |
| 28 | + OIDC_PRIVATE_KEY?: string; | |
| 29 | + /** Secret: optional GitHub token used by the importer. */ | |
| 30 | + GITHUB_IMPORT_TOKEN?: string; | |
| 31 | +} |
addedapps/diffinery/src/git/protocol.ts+228−0View file
| @@ -1,0 +1,228 @@ | ||
| 1 | +import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, planFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core"; | |
| 2 | +import type { RawObject } from "../repo/objects.js"; | |
| 3 | + | |
| 4 | +export const AGENT = "diffinery/0.1"; | |
| 5 | + | |
| 6 | +export type RefEntry = { | |
| 7 | + name: string; | |
| 8 | + oid: string; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export interface UploadContext { | |
| 12 | + store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean }; | |
| 13 | + refs: RefEntry[]; | |
| 14 | + /** e.g. refs/heads/main — HEAD points here even when the branch does not exist yet. */ | |
| 15 | + headTarget: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +// ------------------------------------------------------------------ advertisements | |
| 19 | + | |
| 20 | +export function advertiseUploadPackV2(): Uint8Array { | |
| 21 | + const w = new ByteWriter(); | |
| 22 | + w.write(pkt("version 2\n")); | |
| 23 | + w.write(pkt(`agent=${AGENT}\n`)); | |
| 24 | + w.write(pkt("ls-refs=unborn\n")); | |
| 25 | + w.write(pkt("fetch=shallow wait-for-done\n")); | |
| 26 | + w.write(pkt("server-option\n")); | |
| 27 | + w.write(pkt("object-format=sha1\n")); | |
| 28 | + w.write(FLUSH); | |
| 29 | + return w.bytes(); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function advertiseReceivePack(refs: RefEntry[]): Uint8Array { | |
| 33 | + const w = new ByteWriter(); | |
| 34 | + w.write(pkt("# service=git-receive-pack\n")); | |
| 35 | + w.write(FLUSH); | |
| 36 | + const caps = `report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent=${AGENT}`; | |
| 37 | + if (refs.length === 0) { | |
| 38 | + w.write(pkt(`${ZERO_OID} capabilities^{}\0${caps}\n`)); | |
| 39 | + } else { | |
| 40 | + refs.forEach((r, i) => w.write(pkt(i === 0 ? `${r.oid} ${r.name}\0${caps}\n` : `${r.oid} ${r.name}\n`))); | |
| 41 | + } | |
| 42 | + w.write(FLUSH); | |
| 43 | + return w.bytes(); | |
| 44 | +} | |
| 45 | + | |
| 46 | +// ------------------------------------------------------------------ protocol v2: upload-pack | |
| 47 | + | |
| 48 | +interface V2Request { | |
| 49 | + command: string; | |
| 50 | + capabilities: string[]; | |
| 51 | + args: string[]; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function parseV2(body: Uint8Array): V2Request { | |
| 55 | + const { pkts } = readPkts(body); | |
| 56 | + const req: V2Request = { command: "", capabilities: [], args: [] }; | |
| 57 | + let section: "caps" | "args" = "caps"; | |
| 58 | + for (const p of pkts) { | |
| 59 | + if (p.type === "delim") { | |
| 60 | + section = "args"; | |
| 61 | + continue; | |
| 62 | + } | |
| 63 | + if (p.type === "flush") break; | |
| 64 | + const line = pktText(p); | |
| 65 | + if (section === "caps") { | |
| 66 | + if (line.startsWith("command=")) req.command = line.slice(8); | |
| 67 | + else req.capabilities.push(line); | |
| 68 | + } else req.args.push(line); | |
| 69 | + } | |
| 70 | + return req; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export async function handleUploadPackV2(body: Uint8Array, ctx: UploadContext): Promise<Uint8Array> { | |
| 74 | + const req = parseV2(body); | |
| 75 | + if (req.command === "ls-refs") return lsRefs(req, ctx); | |
| 76 | + if (req.command === "fetch") return fetchCommand(req, ctx); | |
| 77 | + throw new Error(`unsupported command: ${req.command || "(none)"}`); | |
| 78 | +} | |
| 79 | + | |
| 80 | +async function lsRefs(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { | |
| 81 | + const prefixes = req.args.filter((a) => a.startsWith("ref-prefix ")).map((a) => a.slice(11)); | |
| 82 | + const symrefs = req.args.includes("symrefs"); | |
| 83 | + const peel = req.args.includes("peel"); | |
| 84 | + const unborn = req.args.includes("unborn"); | |
| 85 | + const match = (name: string) => prefixes.length === 0 || prefixes.some((p) => name.startsWith(p)); | |
| 86 | + const w = new ByteWriter(); | |
| 87 | + const head = ctx.refs.find((r) => r.name === ctx.headTarget); | |
| 88 | + if (match("HEAD")) { | |
| 89 | + if (head) w.write(pkt(`${head.oid} HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); | |
| 90 | + else if (unborn) w.write(pkt(`unborn HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); | |
| 91 | + } | |
| 92 | + for (const r of ctx.refs) { | |
| 93 | + if (!match(r.name)) continue; | |
| 94 | + let line = `${r.oid} ${r.name}`; | |
| 95 | + if (peel && r.name.startsWith("refs/tags/")) { | |
| 96 | + const o = await ctx.store.read(r.oid); | |
| 97 | + if (o?.type === "tag") line += ` peeled:${parseTag(o.content).object}`; | |
| 98 | + } | |
| 99 | + w.write(pkt(`${line}\n`)); | |
| 100 | + } | |
| 101 | + w.write(FLUSH); | |
| 102 | + return w.bytes(); | |
| 103 | +} | |
| 104 | + | |
| 105 | +async function fetchCommand(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { | |
| 106 | + const wants: string[] = []; | |
| 107 | + const haves: string[] = []; | |
| 108 | + let done = false; | |
| 109 | + let noProgress = false; | |
| 110 | + let depth: number | undefined; | |
| 111 | + let since: number | undefined; | |
| 112 | + const clientShallow = new Set<string>(); | |
| 113 | + for (const a of req.args) { | |
| 114 | + if (a.startsWith("want ")) wants.push(a.slice(5)); | |
| 115 | + else if (a.startsWith("want-ref ")) { | |
| 116 | + const ref = ctx.refs.find((r) => r.name === a.slice(9)); | |
| 117 | + if (ref) wants.push(ref.oid); | |
| 118 | + } else if (a.startsWith("have ")) haves.push(a.slice(5)); | |
| 119 | + else if (a === "done") done = true; | |
| 120 | + else if (a === "no-progress") noProgress = true; | |
| 121 | + else if (a.startsWith("shallow ")) clientShallow.add(a.slice(8)); | |
| 122 | + else if (a.startsWith("deepen ")) depth = Math.max(1, Number.parseInt(a.slice(7), 10) || 1); | |
| 123 | + else if (a.startsWith("deepen-since ")) since = Number.parseInt(a.slice(13), 10); | |
| 124 | + else if (a === "deepen-relative") { | |
| 125 | + // Approximation: treat relative deepening as absolute depth from the wants. | |
| 126 | + } else if (a.startsWith("deepen-not ")) throw new Error("--shallow-exclude is not supported; use --depth or --shallow-since"); | |
| 127 | + else if (a.startsWith("filter ")) throw new Error("partial clones (--filter) are not supported yet"); | |
| 128 | + } | |
| 129 | + for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`); | |
| 130 | + const common = haves.filter((h) => ctx.store.has(h)); | |
| 131 | + | |
| 132 | + const out = new ByteWriter(); | |
| 133 | + if (!done) { | |
| 134 | + out.write(pkt("acknowledgments\n")); | |
| 135 | + if (common.length === 0) out.write(pkt("NAK\n")); | |
| 136 | + for (const c of common) out.write(pkt(`ACK ${c}\n`)); | |
| 137 | + out.write(pkt("ready\n")); | |
| 138 | + out.write(DELIM); | |
| 139 | + } | |
| 140 | + const plan = await planFetch(ctx.store, wants, common, { depth, since, clientShallow }); | |
| 141 | + if (depth !== undefined || since !== undefined || clientShallow.size) { | |
| 142 | + out.write(pkt("shallow-info\n")); | |
| 143 | + for (const oid of plan.shallow) out.write(pkt(`shallow ${oid}\n`)); | |
| 144 | + for (const oid of plan.unshallow) out.write(pkt(`unshallow ${oid}\n`)); | |
| 145 | + out.write(DELIM); | |
| 146 | + } | |
| 147 | + out.write(pkt("packfile\n")); | |
| 148 | + const oids = plan.oids; | |
| 149 | + if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out); | |
| 150 | + const entries = []; | |
| 151 | + for (const oid of oids) { | |
| 152 | + const raw = await ctx.store.readRaw(oid); | |
| 153 | + if (!raw) throw new Error(`missing object ${oid}`); | |
| 154 | + entries.push({ type: raw.type, size: raw.size, deflated: raw.deflated }); | |
| 155 | + } | |
| 156 | + sideband(1, await writePack(entries), out); | |
| 157 | + if (!noProgress) sideband(2, utf8(`Total ${oids.length} (delta 0), reused ${oids.length} (delta 0)\n`), out); | |
| 158 | + out.write(FLUSH); | |
| 159 | + return out.bytes(); | |
| 160 | +} | |
| 161 | + | |
| 162 | +// ------------------------------------------------------------------ receive-pack | |
| 163 | + | |
| 164 | +export interface RefCommand { | |
| 165 | + oldOid: string; | |
| 166 | + newOid: string; | |
| 167 | + ref: string; | |
| 168 | +} | |
| 169 | + | |
| 170 | +export interface ReceiveRequest { | |
| 171 | + commands: RefCommand[]; | |
| 172 | + capabilities: Set<string>; | |
| 173 | + pushOptions: string[]; | |
| 174 | + pack: Uint8Array | null; | |
| 175 | +} | |
| 176 | + | |
| 177 | +export function parseReceivePack(body: Uint8Array): ReceiveRequest { | |
| 178 | + const { pkts, offset } = readPkts(body, 0, true); | |
| 179 | + const commands: RefCommand[] = []; | |
| 180 | + let capabilities = new Set<string>(); | |
| 181 | + for (const p of pkts) { | |
| 182 | + if (p.type !== "data") continue; | |
| 183 | + let line = fromUtf8(p.data).replace(/\n$/, ""); | |
| 184 | + const nul = line.indexOf("\0"); | |
| 185 | + if (nul >= 0) { | |
| 186 | + capabilities = new Set(line.slice(nul + 1).trim().split(" ").filter(Boolean)); | |
| 187 | + line = line.slice(0, nul); | |
| 188 | + } | |
| 189 | + if (line.startsWith("shallow ")) continue; | |
| 190 | + const [oldOid, newOid, ref] = line.split(" "); | |
| 191 | + if (!oldOid || !newOid || !ref) throw new Error(`bad command: ${line}`); | |
| 192 | + commands.push({ oldOid, newOid, ref }); | |
| 193 | + } | |
| 194 | + let rest = offset; | |
| 195 | + const pushOptions: string[] = []; | |
| 196 | + if (capabilities.has("push-options")) { | |
| 197 | + const opts = readPkts(body, rest, true); | |
| 198 | + for (const p of opts.pkts) if (p.type === "data") pushOptions.push(pktText(p)); | |
| 199 | + rest = opts.offset; | |
| 200 | + } | |
| 201 | + const pack = rest < body.length ? body.subarray(rest) : null; | |
| 202 | + return { commands, capabilities, pushOptions, pack }; | |
| 203 | +} | |
| 204 | + | |
| 205 | +export interface RefResult { | |
| 206 | + ref: string; | |
| 207 | + ok: boolean; | |
| 208 | + reason?: string; | |
| 209 | +} | |
| 210 | + | |
| 211 | +export function reportStatus(req: ReceiveRequest, unpackError: string | null, results: RefResult[], messages: string[] = []): Uint8Array { | |
| 212 | + const report = new ByteWriter(); | |
| 213 | + report.write(pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n")); | |
| 214 | + for (const r of results) report.write(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.reason ?? "rejected"}\n`)); | |
| 215 | + report.write(FLUSH); | |
| 216 | + if (!req.capabilities.has("side-band-64k")) return report.bytes(); | |
| 217 | + const out = new ByteWriter(); | |
| 218 | + for (const m of messages) sideband(2, utf8(`${m}\n`), out); | |
| 219 | + sideband(1, report.bytes(), out); | |
| 220 | + out.write(FLUSH); | |
| 221 | + return out.bytes(); | |
| 222 | +} | |
| 223 | + | |
| 224 | +export function errorPacket(message: string): Uint8Array { | |
| 225 | + const w = new ByteWriter(); | |
| 226 | + w.write(pkt(`ERR ${message}\n`)); | |
| 227 | + return w.bytes(); | |
| 228 | +} |
addedapps/diffinery/src/git/secret-scan.ts+30−0View file
| @@ -1,0 +1,30 @@ | ||
| 1 | +import { fromUtf8 } from "@diffinery/git-core"; | |
| 2 | + | |
| 3 | +/** High-confidence credential patterns. Push protection blocks pushes that add these. */ | |
| 4 | +const PATTERNS: Array<{ kind: string; re: RegExp }> = [ | |
| 5 | + { kind: "AWS access key", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ }, | |
| 6 | + { kind: "GitHub token", re: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/ }, | |
| 7 | + { kind: "Diffinery token", re: /\bdfy_[A-Za-z0-9_-]{32,}\b/ }, | |
| 8 | + { kind: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ }, | |
| 9 | + { kind: "Stripe live key", re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/ }, | |
| 10 | + { kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ }, | |
| 11 | + { kind: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, | |
| 12 | + { kind: "OpenAI API key", re: /\bsk-(proj-)?[A-Za-z0-9_-]{40,}\b/ }, | |
| 13 | + { kind: "Private key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ }, | |
| 14 | +]; | |
| 15 | + | |
| 16 | +export interface SecretHit { | |
| 17 | + oid: string; | |
| 18 | + kind: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function scanForSecrets(blobs: Array<{ oid: string; content: Uint8Array }>): SecretHit[] { | |
| 22 | + const hits: SecretHit[] = []; | |
| 23 | + for (const b of blobs) { | |
| 24 | + if (b.content.includes(0)) continue; // binary | |
| 25 | + const text = fromUtf8(b.content); | |
| 26 | + if (/diffinery:allow-secret/.test(text)) continue; // explicit, auditable opt-out for test fixtures | |
| 27 | + for (const p of PATTERNS) if (p.re.test(text)) hits.push({ oid: b.oid, kind: p.kind }); | |
| 28 | + } | |
| 29 | + return hits; | |
| 30 | +} |
addedapps/diffinery/src/http/api.ts+717−0View file
| @@ -1,0 +1,717 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import { fromUtf8 } from "@diffinery/git-core"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 5 | +import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 6 | +import { encryptSecret } from "../lib/crypto.js"; | |
| 7 | +import { publicJwk } from "../lib/jwt.js"; | |
| 8 | +import { HttpError, b64, fromB64 } from "../lib/util.js"; | |
| 9 | +import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 10 | +import type { RunInfo } from "../repo/actions.js"; | |
| 11 | +import { loadRepo, repoStub } from "./common.js"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * REST API. Paths and payloads follow GitHub's REST API v3 where practical so | |
| 15 | + * existing scripts and tools work; Diffinery-only endpoints live under /api/v3/diffinery. | |
| 16 | + */ | |
| 17 | +export const api = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); | |
| 18 | + | |
| 19 | +const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ | |
| 20 | + id: a.id, | |
| 21 | + login: a.login, | |
| 22 | + name: a.name, | |
| 23 | + type: a.kind === "org" ? "Organization" : a.kind === "agent" ? "Bot" : "User", | |
| 24 | + html_url: `${c.env.PUBLIC_URL}/${a.login}`, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const repoJson = (c: AppContext, r: Repo) => ({ | |
| 28 | + id: r.id, | |
| 29 | + name: r.name, | |
| 30 | + full_name: `${r.owner_login}/${r.name}`, | |
| 31 | + owner: { login: r.owner_login, type: r.owner_kind === "org" ? "Organization" : "User" }, | |
| 32 | + private: r.visibility !== "public", | |
| 33 | + visibility: r.visibility, | |
| 34 | + description: r.description, | |
| 35 | + default_branch: r.default_branch, | |
| 36 | + archived: !!r.archived, | |
| 37 | + html_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}`, | |
| 38 | + clone_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}.git`, | |
| 39 | + created_at: r.created_at, | |
| 40 | + updated_at: r.updated_at, | |
| 41 | + pushed_at: r.pushed_at, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +async function body<T>(c: AppContext): Promise<T> { | |
| 45 | + try { | |
| 46 | + return (await c.req.json()) as T; | |
| 47 | + } catch { | |
| 48 | + throw new HttpError(400, "request body must be JSON"); | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! }); | |
| 53 | +const num = (c: AppContext, name = "number") => { | |
| 54 | + const n = Number(c.req.param(name)); | |
| 55 | + if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found"); | |
| 56 | + return n; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +// ---------------------------------------------------------------- meta & users | |
| 60 | + | |
| 61 | +api.get("/", (c) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp` })); | |
| 62 | +api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY })); | |
| 63 | +api.get("/user", (c) => { | |
| 64 | + const a = requireAccount(c); | |
| 65 | + return c.json({ ...userJson(c, a), email: a.email, site_admin: !!a.site_admin }); | |
| 66 | +}); | |
| 67 | +api.get("/users/:login", async (c) => { | |
| 68 | + requireAccount(c); | |
| 69 | + const a = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 70 | + if (!a) throw new HttpError(404, "not found"); | |
| 71 | + return c.json(userJson(c, a)); | |
| 72 | +}); | |
| 73 | +api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role })))); | |
| 74 | +api.post("/user/orgs", async (c) => { | |
| 75 | + const a = requireAccount(c); | |
| 76 | + const b = await body<{ login: string; name?: string }>(c); | |
| 77 | + return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201); | |
| 78 | +}); | |
| 79 | +api.get("/orgs/:org/members", async (c) => { | |
| 80 | + const a = requireAccount(c); | |
| 81 | + const dir = c.get("dir"); | |
| 82 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 83 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 84 | + if (!(await dir.orgRole(org.id, a.id)) && !a.site_admin) throw new HttpError(404, "not found"); | |
| 85 | + return c.json((await dir.orgMembers(org.id)).map((m) => ({ ...userJson(c, m), role: m.role }))); | |
| 86 | +}); | |
| 87 | +api.post("/orgs/:org/invitations", async (c) => { | |
| 88 | + const a = requireAccount(c); | |
| 89 | + const dir = c.get("dir"); | |
| 90 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 91 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 92 | + const role = await dir.orgRole(org.id, a.id); | |
| 93 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can invite"); | |
| 94 | + const b = await body<{ email: string; role?: "member" | "admin" | "owner" }>(c); | |
| 95 | + if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required"); | |
| 96 | + const inv = await dir.createInvite(a, { email: b.email, orgId: org.id, role: b.role ?? "member" }); | |
| 97 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.invite", target: org.login, detail: { email: b.email, role: b.role ?? "member" } })); | |
| 98 | + return c.json({ id: inv.id, url: inv.url }, 201); | |
| 99 | +}); | |
| 100 | + | |
| 101 | +// Site-wide invitation (no organization): site admins only. | |
| 102 | +api.post("/invitations", async (c) => { | |
| 103 | + const a = requireAccount(c); | |
| 104 | + if (!a.site_admin) throw new HttpError(403, "only site admins can invite people without an organization"); | |
| 105 | + const b = await body<{ email: string }>(c); | |
| 106 | + if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required"); | |
| 107 | + const dir = c.get("dir"); | |
| 108 | + const inv = await dir.createInvite(a, { email: b.email, role: "member" }); | |
| 109 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "site.invite", detail: { email: b.email } })); | |
| 110 | + return c.json({ id: inv.id, url: inv.url }, 201); | |
| 111 | +}); | |
| 112 | + | |
| 113 | +// ---------------------------------------------------------------- repositories | |
| 114 | + | |
| 115 | +api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))); | |
| 116 | +api.post("/user/repos", async (c) => { | |
| 117 | + const a = requireAccount(c); | |
| 118 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 119 | + const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a; | |
| 120 | + const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 121 | + return c.json(repoJson(c, r), 201); | |
| 122 | +}); | |
| 123 | +api.get("/users/:login/repos", async (c) => { | |
| 124 | + const a = requireAccount(c); | |
| 125 | + const owner = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 126 | + if (!owner) throw new HttpError(404, "not found"); | |
| 127 | + return c.json((await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r))); | |
| 128 | +}); | |
| 129 | +api.get("/orgs/:org/repos", async (c) => { | |
| 130 | + const a = requireAccount(c); | |
| 131 | + const org = await c.get("dir").accountByLogin(c.req.param("org")); | |
| 132 | + if (!org) throw new HttpError(404, "not found"); | |
| 133 | + return c.json((await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r))); | |
| 134 | +}); | |
| 135 | +api.post("/orgs/:org/repos", async (c) => { | |
| 136 | + const a = requireAccount(c); | |
| 137 | + const org = await c.get("dir").accountByLogin(c.req.param("org")); | |
| 138 | + if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); | |
| 139 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 140 | + const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 141 | + return c.json(repoJson(c, r), 201); | |
| 142 | +}); | |
| 143 | +api.get("/repos/:owner/:repo", async (c) => { | |
| 144 | + const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 145 | + return c.json({ ...repoJson(c, repo), permissions: { role: caller.role }, stats: await stub.stats() }); | |
| 146 | +}); | |
| 147 | +api.patch("/repos/:owner/:repo", async (c) => { | |
| 148 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 149 | + const b = await body<{ name?: string; description?: string; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c); | |
| 150 | + return c.json(repoJson(c, await c.get("dir").updateRepo(repo, { name: b.name, description: b.description, visibility: b.visibility, defaultBranch: b.default_branch, archived: b.archived }))); | |
| 151 | +}); | |
| 152 | +api.delete("/repos/:owner/:repo", async (c) => { | |
| 153 | + const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 154 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories"); | |
| 155 | + await c.get("dir").deleteRepo(repo); | |
| 156 | + return c.body(null, 204); | |
| 157 | +}); | |
| 158 | +api.get("/repos/:owner/:repo/collaborators", async (c) => { | |
| 159 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 160 | + return c.json((await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), role_name: x.role }))); | |
| 161 | +}); | |
| 162 | +api.put("/repos/:owner/:repo/collaborators/:login", async (c) => { | |
| 163 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 164 | + const who = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 165 | + if (!who) throw new HttpError(404, "user not found"); | |
| 166 | + const b = await body<{ permission?: Role | "pull" | "push" }>(c).catch(() => ({ permission: "write" as Role })); | |
| 167 | + const map: Record<string, Role> = { pull: "read", push: "write" }; | |
| 168 | + await c.get("dir").setCollaborator(repo.id, who.id, map[b.permission ?? ""] ?? (b.permission as Role) ?? "write"); | |
| 169 | + return c.body(null, 204); | |
| 170 | +}); | |
| 171 | +api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => { | |
| 172 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 173 | + const who = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 174 | + if (who) await c.get("dir").setCollaborator(repo.id, who.id, null); | |
| 175 | + return c.body(null, 204); | |
| 176 | +}); | |
| 177 | + | |
| 178 | +// ---------------------------------------------------------------- git data | |
| 179 | + | |
| 180 | +api.get("/repos/:owner/:repo/branches", async (c) => { | |
| 181 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 182 | + return c.json((await stub.branches()).map((b) => ({ name: b.name, commit: { sha: b.sha }, protected: b.protected, default: b.isDefault }))); | |
| 183 | +}); | |
| 184 | +api.get("/repos/:owner/:repo/branches/:branch", async (c) => { | |
| 185 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 186 | + const sha = await stub.resolve(c.req.param("branch")); | |
| 187 | + if (!sha) throw new HttpError(404, "branch not found"); | |
| 188 | + return c.json({ name: c.req.param("branch"), commit: await stub.commitInfo(sha) }); | |
| 189 | +}); | |
| 190 | +api.get("/repos/:owner/:repo/tags", async (c) => { | |
| 191 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 192 | + return c.json((await stub.tags()).map((t) => ({ name: t.name, commit: { sha: t.target } }))); | |
| 193 | +}); | |
| 194 | +api.get("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 195 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 196 | + const prefix = `refs/${c.req.path.split("/git/refs/")[1] ?? ""}`; | |
| 197 | + return c.json(stub.refs ? (await stub.refs(prefix)).map((r) => ({ ref: r.name, object: { sha: r.oid } })) : []); | |
| 198 | +}); | |
| 199 | +api.post("/repos/:owner/:repo/git/refs", async (c) => { | |
| 200 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 201 | + const b = await body<{ ref: string; sha: string }>(c); | |
| 202 | + await stub.createRef(b.ref, b.sha, caller); | |
| 203 | + return c.json({ ref: b.ref, object: { sha: b.sha } }, 201); | |
| 204 | +}); | |
| 205 | +api.patch("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 206 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 207 | + const ref = `refs/${c.req.path.split("/git/refs/")[1]}`; | |
| 208 | + const b = await body<{ sha: string; force?: boolean }>(c); | |
| 209 | + await stub.updateRef(ref, b.sha, !!b.force, caller); | |
| 210 | + return c.json({ ref, object: { sha: b.sha } }); | |
| 211 | +}); | |
| 212 | +api.delete("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 213 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 214 | + await stub.deleteRef(`refs/${c.req.path.split("/git/refs/")[1]}`, caller); | |
| 215 | + return c.body(null, 204); | |
| 216 | +}); | |
| 217 | +api.get("/repos/:owner/:repo/commits", async (c) => { | |
| 218 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 219 | + const perPage = Number(c.req.query("per_page") ?? 30); | |
| 220 | + const page = Number(c.req.query("page") ?? 1); | |
| 221 | + return c.json(await stub.log(c.req.query("sha") ?? "HEAD", { path: c.req.query("path"), limit: perPage, skip: (page - 1) * perPage })); | |
| 222 | +}); | |
| 223 | +api.get("/repos/:owner/:repo/commits/:ref", async (c) => { | |
| 224 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 225 | + return c.json(await stub.commitWithDiff(c.req.param("ref"))); | |
| 226 | +}); | |
| 227 | +api.get("/repos/:owner/:repo/compare/:spec", async (c) => { | |
| 228 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 229 | + const [base, head] = c.req.param("spec").split("..."); | |
| 230 | + if (!base || !head) throw new HttpError(422, "use base...head"); | |
| 231 | + return c.json(await stub.compare(base, head)); | |
| 232 | +}); | |
| 233 | +api.get("/repos/:owner/:repo/contents/*", async (c) => { | |
| 234 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 235 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 236 | + const ref = c.req.query("ref") ?? "HEAD"; | |
| 237 | + const tree = await stub.tree(ref, path); | |
| 238 | + if (tree) return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : i.type === "commit" ? "submodule" : "file" }))); | |
| 239 | + const blob = await stub.blob(ref, path); | |
| 240 | + if (!blob) throw new HttpError(404, "not found"); | |
| 241 | + return c.json({ type: "file", name: path.split("/").pop(), path, sha: blob.oid, size: blob.size, encoding: "base64", content: b64(blob.content), commit: blob.sha }); | |
| 242 | +}); | |
| 243 | +api.get("/repos/:owner/:repo/contents", async (c) => { | |
| 244 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 245 | + const tree = await stub.tree(c.req.query("ref") ?? "HEAD", ""); | |
| 246 | + if (!tree) return c.json([]); | |
| 247 | + return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : "file" }))); | |
| 248 | +}); | |
| 249 | +api.put("/repos/:owner/:repo/contents/*", async (c) => { | |
| 250 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 251 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 252 | + const b = await body<{ message: string; content: string; branch?: string; sha?: string }>(c); | |
| 253 | + const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, content: fromB64(b.content) }] }, caller); | |
| 254 | + return c.json({ commit: { sha: res.sha }, content: { path } }, 201); | |
| 255 | +}); | |
| 256 | +api.delete("/repos/:owner/:repo/contents/*", async (c) => { | |
| 257 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 258 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 259 | + const b = await body<{ message: string; branch?: string }>(c); | |
| 260 | + const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, delete: true }] }, caller); | |
| 261 | + return c.json({ commit: { sha: res.sha } }); | |
| 262 | +}); | |
| 263 | + | |
| 264 | +/** Agent-friendly multi-file commit without a Git client. */ | |
| 265 | +api.post("/repos/:owner/:repo/diffinery/commits", async (c) => { | |
| 266 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 267 | + const b = await body<{ branch: string; message: string; base?: string; expected_head_sha?: string; files: Array<{ path: string; content?: string; encoding?: "utf-8" | "base64"; delete?: boolean; mode?: "100644" | "100755" }> }>(c); | |
| 268 | + if (!Array.isArray(b.files) || b.files.length === 0) throw new HttpError(422, "files is required"); | |
| 269 | + const changes = b.files.map((f) => (f.delete ? { path: f.path, delete: true } : { path: f.path, mode: f.mode, content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? "") })); | |
| 270 | + return c.json(await stub.commitFiles({ branch: b.branch, message: b.message, changes, createBranchFrom: b.base, expectedHeadSha: b.expected_head_sha }, caller), 201); | |
| 271 | +}); | |
| 272 | + | |
| 273 | +api.get("/repos/:owner/:repo/raw/:ref/*", async (c) => { | |
| 274 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 275 | + const path = decodeURIComponent(c.req.path.split(`/raw/${c.req.param("ref")}/`)[1] ?? ""); | |
| 276 | + const blob = await stub.blob(c.req.param("ref"), path); | |
| 277 | + if (!blob) throw new HttpError(404, "not found"); | |
| 278 | + return new Response(blob.content, { headers: { "content-type": blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } }); | |
| 279 | +}); | |
| 280 | + | |
| 281 | +// ---------------------------------------------------------------- branch protection | |
| 282 | + | |
| 283 | +api.get("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 284 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 285 | + const rule = (await stub.listProtections()).find((p) => p.pattern === c.req.param("branch")); | |
| 286 | + if (!rule) throw new HttpError(404, "branch not protected"); | |
| 287 | + return c.json(rule); | |
| 288 | +}); | |
| 289 | +api.put("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 290 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 291 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection"); | |
| 292 | + const rules = await body<ProtectionRules>(c); | |
| 293 | + await stub.setProtection(c.req.param("branch"), rules); | |
| 294 | + return c.json({ pattern: c.req.param("branch"), rules }); | |
| 295 | +}); | |
| 296 | +api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 297 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 298 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection"); | |
| 299 | + await stub.setProtection(c.req.param("branch"), null); | |
| 300 | + return c.body(null, 204); | |
| 301 | +}); | |
| 302 | + | |
| 303 | +// ---------------------------------------------------------------- issues & pulls | |
| 304 | + | |
| 305 | +const issueJson = (c: AppContext, owner: string, repo: string, i: Awaited<ReturnType<ReturnType<typeof repoStub>["issue"]>>["issue"]) => ({ | |
| 306 | + number: i.number, | |
| 307 | + title: i.title, | |
| 308 | + body: i.body, | |
| 309 | + state: i.pull?.merged ? "closed" : i.state, | |
| 310 | + state_reason: i.stateReason, | |
| 311 | + user: { login: i.author.login, type: i.author.kind === "agent" ? "Bot" : "User" }, | |
| 312 | + labels: i.labels.map((name) => ({ name })), | |
| 313 | + assignees: i.assignees.map((login) => ({ login })), | |
| 314 | + comments: i.comments, | |
| 315 | + created_at: i.createdAt, | |
| 316 | + updated_at: i.updatedAt, | |
| 317 | + closed_at: i.closedAt, | |
| 318 | + html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`, | |
| 319 | + ...(i.pull | |
| 320 | + ? { | |
| 321 | + pull_request: { url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${repo}/pulls/${i.number}` }, | |
| 322 | + head: { ref: i.pull.head, sha: i.pull.headSha }, | |
| 323 | + base: { ref: i.pull.base }, | |
| 324 | + draft: i.pull.draft, | |
| 325 | + merged: i.pull.merged, | |
| 326 | + merged_at: i.pull.mergedAt, | |
| 327 | + merged_by: i.pull.mergedBy ? { login: i.pull.mergedBy } : null, | |
| 328 | + merge_commit_sha: i.pull.mergeCommit, | |
| 329 | + requested_reviewers: i.pull.requestedReviewers.map((login) => ({ login })), | |
| 330 | + intent: i.pull.intent, | |
| 331 | + } | |
| 332 | + : {}), | |
| 333 | +}); | |
| 334 | + | |
| 335 | +for (const kind of ["issues", "pulls"] as const) { | |
| 336 | + api.get(`/repos/:owner/:repo/${kind}`, async (c) => { | |
| 337 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 338 | + const perPage = Math.min(Number(c.req.query("per_page") ?? 30), 100); | |
| 339 | + const page = Number(c.req.query("page") ?? 1); | |
| 340 | + const res = await stub.issues({ kind: kind === "pulls" ? "pull" : undefined, state: (c.req.query("state") as "open" | "closed" | "all") ?? "open", label: c.req.query("labels"), author: c.req.query("creator"), q: c.req.query("q"), limit: perPage, offset: (page - 1) * perPage }); | |
| 341 | + c.header("x-total-count", String(res.total)); | |
| 342 | + return c.json(res.items.map((i) => issueJson(c, P(c).owner, P(c).repo, i))); | |
| 343 | + }); | |
| 344 | +} | |
| 345 | +api.post("/repos/:owner/:repo/issues", async (c) => { | |
| 346 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 347 | + const b = await body<{ title: string; body?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 348 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createIssue(b, caller)), 201); | |
| 349 | +}); | |
| 350 | +api.get("/repos/:owner/:repo/issues/:number", async (c) => { | |
| 351 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 352 | + return c.json(issueJson(c, P(c).owner, P(c).repo, (await stub.issue(num(c))).issue)); | |
| 353 | +}); | |
| 354 | +api.patch("/repos/:owner/:repo/issues/:number", async (c) => { | |
| 355 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 356 | + const b = await body<{ title?: string; body?: string; state?: "open" | "closed"; state_reason?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 357 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), { ...b, stateReason: b.state_reason }, caller))); | |
| 358 | +}); | |
| 359 | +api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => { | |
| 360 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 361 | + return c.json((await stub.issue(num(c))).comments.filter((x) => !x.reviewId).map((x) => ({ id: x.id, body: x.body, user: { login: x.author.login, type: x.author.kind === "agent" ? "Bot" : "User" }, created_at: x.createdAt, updated_at: x.updatedAt }))); | |
| 362 | +}); | |
| 363 | +api.post("/repos/:owner/:repo/issues/:number/comments", async (c) => { | |
| 364 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 365 | + const b = await body<{ body: string }>(c); | |
| 366 | + const x = await stub.addComment(num(c), b.body, caller); | |
| 367 | + return c.json({ id: x.id, body: x.body, user: { login: x.author.login }, created_at: x.createdAt }, 201); | |
| 368 | +}); | |
| 369 | +api.patch("/repos/:owner/:repo/issues/comments/:id", async (c) => { | |
| 370 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 371 | + await stub.editComment(c.req.param("id"), (await body<{ body: string }>(c)).body, caller); | |
| 372 | + return c.json({ id: c.req.param("id") }); | |
| 373 | +}); | |
| 374 | +api.get("/repos/:owner/:repo/labels", async (c) => { | |
| 375 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 376 | + return c.json(await stub.labels()); | |
| 377 | +}); | |
| 378 | +api.post("/repos/:owner/:repo/labels", async (c) => { | |
| 379 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage"); | |
| 380 | + const b = await body<{ name: string; color?: string; description?: string }>(c); | |
| 381 | + await stub.setLabel(b.name, b.color ?? "888888", b.description ?? ""); | |
| 382 | + return c.json(b, 201); | |
| 383 | +}); | |
| 384 | + | |
| 385 | +api.post("/repos/:owner/:repo/pulls", async (c) => { | |
| 386 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 387 | + const b = await body<{ title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }>(c); | |
| 388 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createPull({ ...b, head: b.head.includes(":") ? b.head.split(":")[1]! : b.head }, caller)), 201); | |
| 389 | +}); | |
| 390 | +api.get("/repos/:owner/:repo/pulls/:number", async (c) => { | |
| 391 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 392 | + const n = num(c); | |
| 393 | + const { issue } = await stub.issue(n); | |
| 394 | + if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); | |
| 395 | + const m = await stub.mergeability(n, caller); | |
| 396 | + return c.json({ ...issueJson(c, P(c).owner, P(c).repo, issue), mergeable: m.mergeable, mergeable_state: m.state, merge_blockers: m.blockers, approvals: m.approvals, required_approvals: m.requiredApprovals, checks: m.checks }); | |
| 397 | +}); | |
| 398 | +api.patch("/repos/:owner/:repo/pulls/:number", async (c) => { | |
| 399 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 400 | + const b = await body<{ title?: string; body?: string; state?: "open" | "closed" }>(c); | |
| 401 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), b, caller))); | |
| 402 | +}); | |
| 403 | +api.get("/repos/:owner/:repo/pulls/:number/files", async (c) => { | |
| 404 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 405 | + const res = await stub.pullFiles(num(c)); | |
| 406 | + return c.json(res.files.map((f) => ({ filename: f.path, status: f.status === "deleted" ? "removed" : f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, patch: f.patch, binary: f.binary }))); | |
| 407 | +}); | |
| 408 | +api.get("/repos/:owner/:repo/pulls/:number/commits", async (c) => { | |
| 409 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 410 | + return c.json(await stub.pullCommits(num(c))); | |
| 411 | +}); | |
| 412 | +api.get("/repos/:owner/:repo/pulls/:number/merge", async (c) => { | |
| 413 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 414 | + return c.json(await stub.mergeability(num(c), caller)); | |
| 415 | +}); | |
| 416 | +api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => { | |
| 417 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 418 | + const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>); | |
| 419 | + return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch }, caller)); | |
| 420 | +}); | |
| 421 | +api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { | |
| 422 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 423 | + return c.json((await stub.issue(num(c))).reviews.map((r) => ({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt, user: { login: r.author.login, type: r.author.kind === "agent" ? "Bot" : "User" } }))); | |
| 424 | +}); | |
| 425 | +api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { | |
| 426 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 427 | + const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c); | |
| 428 | + const r = await stub.submitReview(num(c), { event: b.event ?? "COMMENT", body: b.body, comments: b.comments }, caller); | |
| 429 | + return c.json({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt }, 201); | |
| 430 | +}); | |
| 431 | +api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => { | |
| 432 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 433 | + return c.json((await stub.issue(num(c))).comments.filter((x) => x.reviewId).map((x) => ({ id: x.id, body: x.body, path: x.path, line: x.line, side: x.side, commit_id: x.commitId, in_reply_to_id: x.inReplyTo, user: { login: x.author.login }, created_at: x.createdAt, resolved: x.resolved }))); | |
| 434 | +}); | |
| 435 | +api.post("/repos/:owner/:repo/pulls/:number/requested_reviewers", async (c) => { | |
| 436 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 437 | + const b = await body<{ reviewers: string[] }>(c); | |
| 438 | + await stub.requestReviewers(num(c), b.reviewers ?? [], caller); | |
| 439 | + return c.json({ requested_reviewers: b.reviewers }, 201); | |
| 440 | +}); | |
| 441 | + | |
| 442 | +// ---------------------------------------------------------------- checks (for plugins and external CI) | |
| 443 | + | |
| 444 | +api.get("/repos/:owner/:repo/commits/:ref/check-runs", async (c) => { | |
| 445 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 446 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 447 | + if (!sha) throw new HttpError(404, "ref not found"); | |
| 448 | + const runs = await stub.checks(sha); | |
| 449 | + return c.json({ total_count: runs.length, check_runs: runs.map((r) => ({ id: r.id, name: r.name, head_sha: r.sha, status: r.status, conclusion: r.conclusion, details_url: r.detailsUrl, output: r.output, app: { slug: r.app }, started_at: r.startedAt, completed_at: r.completedAt })) }); | |
| 450 | +}); | |
| 451 | +api.post("/repos/:owner/:repo/check-runs", async (c) => { | |
| 452 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 453 | + const b = await body<{ name: string; head_sha: string; status?: "queued" | "in_progress" | "completed"; conclusion?: string; details_url?: string; output?: { title?: string; summary?: string } }>(c); | |
| 454 | + const check = await stub.upsertCheck({ sha: b.head_sha, name: b.name, status: b.status ?? "queued", conclusion: b.conclusion, detailsUrl: b.details_url, output: b.output, app: caller.login }); | |
| 455 | + return c.json(check, 201); | |
| 456 | +}); | |
| 457 | + | |
| 458 | +// ---------------------------------------------------------------- actions | |
| 459 | + | |
| 460 | +const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ({ | |
| 461 | + id: r.id, | |
| 462 | + run_number: r.number, | |
| 463 | + run_attempt: r.attempt, | |
| 464 | + name: r.workflowName, | |
| 465 | + display_title: r.title, | |
| 466 | + path: r.workflowPath, | |
| 467 | + event: r.event, | |
| 468 | + head_branch: r.ref.replace(/^refs\/heads\//, ""), | |
| 469 | + head_sha: r.sha, | |
| 470 | + status: r.status, | |
| 471 | + conclusion: r.conclusion, | |
| 472 | + actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" }, | |
| 473 | + created_at: r.createdAt, | |
| 474 | + updated_at: r.updatedAt, | |
| 475 | + html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/actions/runs/${r.id}`, | |
| 476 | + pull_request_number: r.prNumber, | |
| 477 | +}); | |
| 478 | + | |
| 479 | +api.get("/repos/:owner/:repo/actions/workflows", async (c) => { | |
| 480 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 481 | + const wfs = await stub.workflows(); | |
| 482 | + return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) }); | |
| 483 | +}); | |
| 484 | +api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => { | |
| 485 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 486 | + const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c); | |
| 487 | + const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller); | |
| 488 | + return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201); | |
| 489 | +}); | |
| 490 | +// Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`). | |
| 491 | +api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => { | |
| 492 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 493 | + const b = await body<{ name?: string; labels?: string[] }>(c); | |
| 494 | + const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller); | |
| 495 | + if (!job) return c.body(null, 204); | |
| 496 | + return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin }); | |
| 497 | +}); | |
| 498 | +api.get("/repos/:owner/:repo/actions/runs", async (c) => { | |
| 499 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 500 | + const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100); | |
| 501 | + const page = Number(c.req.query("page") ?? 1); | |
| 502 | + const res = await stub.runs({ workflow: c.req.query("workflow"), branch: c.req.query("branch"), status: c.req.query("status"), event: c.req.query("event"), sha: c.req.query("head_sha"), limit: perPage, offset: (page - 1) * perPage }); | |
| 503 | + return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => runJson(c, P(c).owner, P(c).repo, r)) }); | |
| 504 | +}); | |
| 505 | +api.get("/repos/:owner/:repo/actions/runs/:id", async (c) => { | |
| 506 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 507 | + const r = await stub.run(c.req.param("id")); | |
| 508 | + return c.json({ ...runJson(c, P(c).owner, P(c).repo, r), jobs: r.jobs }); | |
| 509 | +}); | |
| 510 | +api.get("/repos/:owner/:repo/actions/runs/:id/jobs", async (c) => { | |
| 511 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 512 | + const jobs = await stub.runJobs(c.req.param("id")); | |
| 513 | + return c.json({ total_count: jobs.length, jobs }); | |
| 514 | +}); | |
| 515 | +api.post("/repos/:owner/:repo/actions/runs/:id/cancel", async (c) => { | |
| 516 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 517 | + await stub.cancelRun(c.req.param("id"), caller); | |
| 518 | + return c.json({}, 202); | |
| 519 | +}); | |
| 520 | +for (const path of ["rerun", "rerun-failed-jobs"]) { | |
| 521 | + api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => { | |
| 522 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 523 | + const r = await stub.rerun(c.req.param("id"), caller); | |
| 524 | + return c.json({ run_id: r.id }, 201); | |
| 525 | + }); | |
| 526 | +} | |
| 527 | +api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => { | |
| 528 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 529 | + return c.json(await stub.job(c.req.param("id"))); | |
| 530 | +}); | |
| 531 | +api.get("/repos/:owner/:repo/actions/jobs/:id/logs", async (c) => { | |
| 532 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 533 | + const { lines } = await stub.jobLogs(c.req.param("id"), Number(c.req.query("after") ?? -1)); | |
| 534 | + if (c.req.query("format") === "json") return c.json(lines); | |
| 535 | + return c.text(lines.map((l) => `${l.t} ${l.text}`).join("\n")); | |
| 536 | +}); | |
| 537 | +api.get("/repos/:owner/:repo/actions/runs/:id/artifacts", async (c) => { | |
| 538 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 539 | + const list = await stub.artifacts(c.req.param("id")); | |
| 540 | + return c.json({ total_count: list.length, artifacts: list.map((a) => ({ name: a.name, size_in_bytes: a.size, created_at: a.createdAt, archive_download_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${c.req.param("id")}/artifacts/${encodeURIComponent(a.name)}` })) }); | |
| 541 | +}); | |
| 542 | +api.get("/repos/:owner/:repo/actions/secrets", async (c) => { | |
| 543 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 544 | + const s = await stub.secretNames(); | |
| 545 | + return c.json({ total_count: s.length, secrets: s.map((x) => ({ name: x.name, updated_at: x.updatedAt })) }); | |
| 546 | +}); | |
| 547 | +api.put("/repos/:owner/:repo/actions/secrets/:name", async (c) => { | |
| 548 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 549 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 550 | + const b = await body<{ value: string }>(c); | |
| 551 | + if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); | |
| 552 | + await stub.setSecret(c.req.param("name"), b.value); | |
| 553 | + return c.body(null, 204); | |
| 554 | +}); | |
| 555 | +api.delete("/repos/:owner/:repo/actions/secrets/:name", async (c) => { | |
| 556 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 557 | + await stub.deleteSecret(c.req.param("name")); | |
| 558 | + return c.body(null, 204); | |
| 559 | +}); | |
| 560 | +api.get("/repos/:owner/:repo/actions/variables", async (c) => { | |
| 561 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 562 | + return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) }); | |
| 563 | +}); | |
| 564 | +api.delete("/repos/:owner/:repo/actions/variables/:name", async (c) => { | |
| 565 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 566 | + await stub.setVariable(c.req.param("name"), null); | |
| 567 | + return c.body(null, 204); | |
| 568 | +}); | |
| 569 | +api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => { | |
| 570 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 571 | + await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value); | |
| 572 | + return c.body(null, 204); | |
| 573 | +}); | |
| 574 | +async function orgAdmin(c: AppContext) { | |
| 575 | + const a = requireAccount(c); | |
| 576 | + if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 577 | + const dir = c.get("dir"); | |
| 578 | + const org = await dir.accountByLogin(c.req.param("org")!); | |
| 579 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 580 | + const role = await dir.orgRole(org.id, a.id); | |
| 581 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "organization admins only"); | |
| 582 | + return { a, org }; | |
| 583 | +} | |
| 584 | +api.get("/orgs/:org/actions/secrets", async (c) => { | |
| 585 | + const { org } = await orgAdmin(c); | |
| 586 | + const { results } = await c.env.DB.prepare("SELECT name, updated_at FROM org_secrets WHERE org_id = ? ORDER BY name").bind(org.id).all<{ name: string; updated_at: string }>(); | |
| 587 | + return c.json({ total_count: results.length, secrets: results }); | |
| 588 | +}); | |
| 589 | +api.delete("/orgs/:org/actions/secrets/:name", async (c) => { | |
| 590 | + const { org } = await orgAdmin(c); | |
| 591 | + await c.env.DB.prepare("DELETE FROM org_secrets WHERE org_id = ? AND name = ?").bind(org.id, c.req.param("name").toUpperCase()).run(); | |
| 592 | + return c.body(null, 204); | |
| 593 | +}); | |
| 594 | +api.put("/orgs/:org/actions/secrets/:name", async (c) => { | |
| 595 | + const { org } = await orgAdmin(c); | |
| 596 | + const name = c.req.param("name").toUpperCase(); | |
| 597 | + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name"); | |
| 598 | + const { value } = await body<{ value: string }>(c); | |
| 599 | + await c.env.DB.prepare("INSERT OR REPLACE INTO org_secrets (org_id, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)").bind(org.id, name, await encryptSecret(c.env.MASTER_KEY, `org:${org.id}`, value), new Date().toISOString()).run(); | |
| 600 | + return c.body(null, 204); | |
| 601 | +}); | |
| 602 | + | |
| 603 | +// ---------------------------------------------------------------- webhooks | |
| 604 | + | |
| 605 | +api.get("/repos/:owner/:repo/hooks", async (c) => { | |
| 606 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 607 | + return c.json((await stub.webhooks()).map(({ secret: _s, ...h }) => h)); | |
| 608 | +}); | |
| 609 | +api.post("/repos/:owner/:repo/hooks", async (c) => { | |
| 610 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 611 | + const b = await body<{ config: { url: string; secret?: string }; events?: string[] }>(c); | |
| 612 | + return c.json(await stub.createWebhook(b.config?.url, b.events ?? ["*"], b.config?.secret), 201); | |
| 613 | +}); | |
| 614 | +api.delete("/repos/:owner/:repo/hooks/:id", async (c) => { | |
| 615 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 616 | + await stub.deleteWebhook(c.req.param("id")); | |
| 617 | + return c.body(null, 204); | |
| 618 | +}); | |
| 619 | + | |
| 620 | +// ---------------------------------------------------------------- Diffinery: tokens, agents, sandboxes, notifications | |
| 621 | + | |
| 622 | +api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id))); | |
| 623 | +api.post("/diffinery/tokens", async (c) => { | |
| 624 | + const a = requireAccount(c); | |
| 625 | + const p = c.get("principal"); | |
| 626 | + if (p.type === "account" && p.via === "token" && !p.scopes.includes("admin")) throw new HttpError(403, "creating tokens requires an admin-scoped token or a browser session"); | |
| 627 | + const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c); | |
| 628 | + let repoIds: string[] | null = null; | |
| 629 | + if (b.repositories?.length) { | |
| 630 | + repoIds = []; | |
| 631 | + for (const full of b.repositories) { | |
| 632 | + const [o, n] = full.split("/"); | |
| 633 | + const r = o && n ? await c.get("dir").repo(o, n) : null; | |
| 634 | + if (!r) throw new HttpError(422, `unknown repository ${full}`); | |
| 635 | + repoIds.push(r.id); | |
| 636 | + } | |
| 637 | + } | |
| 638 | + const t = await c.get("dir").createToken(a, { name: b.name, scopes: b.scopes, repoIds, expiresInDays: b.expires_in_days }); | |
| 639 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "token.create", detail: { name: b.name, scopes: b.scopes } })); | |
| 640 | + return c.json(t, 201); | |
| 641 | +}); | |
| 642 | +api.delete("/diffinery/tokens/:id", async (c) => { | |
| 643 | + await c.get("dir").revokeToken(requireAccount(c).id, c.req.param("id")); | |
| 644 | + return c.body(null, 204); | |
| 645 | +}); | |
| 646 | +api.get("/diffinery/agents", async (c) => { | |
| 647 | + const a = requireAccount(c); | |
| 648 | + const orgs = (await c.get("dir").orgsFor(a.id)).filter((o) => o.role !== "member").map((o) => o.id); | |
| 649 | + return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} }))); | |
| 650 | +}); | |
| 651 | +api.post("/diffinery/agents", async (c) => { | |
| 652 | + const a = requireAccount(c); | |
| 653 | + if (a.kind !== "user") throw new HttpError(403, "only people can create agents"); | |
| 654 | + const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c); | |
| 655 | + const dir = c.get("dir"); | |
| 656 | + const org = b.org ? await dir.accountByLogin(b.org) : null; | |
| 657 | + const agent = await dir.createAgent(a, { login: b.login, name: b.name, orgId: org?.id, policy: b.policy }); | |
| 658 | + const token = await dir.createToken(agent, { name: "default", scopes: b.token?.scopes ?? ["write"], expiresInDays: b.token?.expires_in_days ?? 90 }); | |
| 659 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.create", target: agent.login })); | |
| 660 | + return c.json({ agent: userJson(c, agent), token }, 201); | |
| 661 | +}); | |
| 662 | +api.post("/diffinery/agents/:login/tokens", async (c) => { | |
| 663 | + const a = requireAccount(c); | |
| 664 | + const dir = c.get("dir"); | |
| 665 | + const agent = await dir.accountByLogin(c.req.param("login")); | |
| 666 | + if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found"); | |
| 667 | + const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null; | |
| 668 | + if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin") throw new HttpError(403, "not your agent"); | |
| 669 | + const b = await body<{ name?: string; scopes?: TokenScope[]; expires_in_days?: number }>(c).catch(() => ({}) as Record<string, undefined>); | |
| 670 | + return c.json(await dir.createToken(agent, { name: b.name ?? "token", scopes: b.scopes, expiresInDays: b.expires_in_days }), 201); | |
| 671 | +}); | |
| 672 | +api.delete("/diffinery/agents/:login", async (c) => { | |
| 673 | + const a = requireAccount(c); | |
| 674 | + const dir = c.get("dir"); | |
| 675 | + const agent = await dir.accountByLogin(c.req.param("login")); | |
| 676 | + if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found"); | |
| 677 | + const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null; | |
| 678 | + if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin" && !a.site_admin) throw new HttpError(403, "not your agent"); | |
| 679 | + await dir.setStatus(agent.id, "disabled"); | |
| 680 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.disable", target: agent.login })); | |
| 681 | + return c.body(null, 204); | |
| 682 | +}); | |
| 683 | + | |
| 684 | +api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => { | |
| 685 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 686 | + const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string }); | |
| 687 | + return c.json(await stub.createSandbox(caller, b.ref), 201); | |
| 688 | +}); | |
| 689 | +api.post("/repos/:owner/:repo/diffinery/sandboxes/:id/:action", async (c) => { | |
| 690 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 691 | + const id = c.req.param("id"); | |
| 692 | + const action = c.req.param("action"); | |
| 693 | + await stub.sandbox(id, caller); | |
| 694 | + if (action === "close") { | |
| 695 | + await stub.closeSandbox(id, caller); | |
| 696 | + return c.body(null, 204); | |
| 697 | + } | |
| 698 | + const map: Record<string, string> = { exec: "/exec", read: "/files/read", write: "/files/write" }; | |
| 699 | + if (!map[action]) throw new HttpError(404, "unknown sandbox action"); | |
| 700 | + const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(map[action]!, JSON.stringify(await c.req.json().catch(() => ({})))); | |
| 701 | + return new Response(res.body, { status: res.status, headers: { "content-type": res.json ? "application/json" : "text/plain; charset=utf-8" } }); | |
| 702 | +}); | |
| 703 | + | |
| 704 | +api.get("/notifications", async (c) => c.json(await c.get("dir").notifications(requireAccount(c).id, c.req.query("all") !== "true"))); | |
| 705 | +api.put("/notifications", async (c) => { | |
| 706 | + await c.get("dir").markRead(requireAccount(c).id); | |
| 707 | + return c.body(null, 205); | |
| 708 | +}); | |
| 709 | + | |
| 710 | +// OIDC discovery for workflow id tokens. | |
| 711 | +export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 712 | +wellKnown.get("/.well-known/openid-configuration", (c) => | |
| 713 | + c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }), | |
| 714 | +); | |
| 715 | +wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] })); | |
| 716 | + | |
| 717 | +export { fromUtf8 }; |
addedapps/diffinery/src/http/common.ts+35−0View file
| @@ -1,0 +1,35 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Repo } from "../lib/directory.js"; | |
| 3 | +import { HttpError } from "../lib/util.js"; | |
| 4 | +import { callerFor, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { atLeast, type Caller, type Role } from "../repo/types.js"; | |
| 6 | + | |
| 7 | +export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId)); | |
| 8 | + | |
| 9 | +/** Load a repository and the caller's role, enforcing a minimum role. 404 hides private repos. */ | |
| 10 | +export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read"): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub> }> { | |
| 11 | + const repo = await c.get("dir").repo(owner, name.replace(/\.git$/, "")); | |
| 12 | + if (!repo) throw new HttpError(404, "repository not found"); | |
| 13 | + const caller = await callerFor(c, repo); | |
| 14 | + if (!caller) { | |
| 15 | + if (c.get("principal").type === "anonymous") throw new HttpError(401, "authentication required"); | |
| 16 | + throw new HttpError(404, "repository not found"); | |
| 17 | + } | |
| 18 | + if (!atLeast(caller.role, min)) throw new HttpError(403, `${min} access required`); | |
| 19 | + if (repo.archived && min !== "read" && min !== "admin") throw new HttpError(403, "repository is archived (read-only)"); | |
| 20 | + return { repo, caller, stub: repoStub(c.env, repo.id) }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function errorResponse(e: unknown): Response { | |
| 24 | + if (e instanceof HttpError) { | |
| 25 | + const headers: Record<string, string> = { "content-type": "application/json" }; | |
| 26 | + if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery", charset="UTF-8"'; | |
| 27 | + return new Response(JSON.stringify({ message: e.detail, ...e.extra }), { status: e.status, headers }); | |
| 28 | + } | |
| 29 | + // Errors thrown inside Durable Objects arrive as plain Errors; recover HttpError-like status when present. | |
| 30 | + const msg = (e as Error)?.message ?? String(e); | |
| 31 | + const remote = /^(\d{3}):/.exec(msg); | |
| 32 | + if (remote) return new Response(JSON.stringify({ message: msg.slice(5) }), { status: Number(remote[1]), headers: { "content-type": "application/json" } }); | |
| 33 | + console.error(e); | |
| 34 | + return new Response(JSON.stringify({ message: "internal error" }), { status: 500, headers: { "content-type": "application/json" } }); | |
| 35 | +} |
addedapps/diffinery/src/http/git.ts+92−0View file
| @@ -1,0 +1,92 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import { loadRepo } from "./common.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS | |
| 9 | + * batch API, at https://diffinery.com/<owner>/<repo>.git | |
| 10 | + */ | |
| 11 | +export const git = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +const noCache = { "cache-control": "no-cache, max-age=0, must-revalidate", pragma: "no-cache" }; | |
| 14 | + | |
| 15 | +git.get("/:owner/:repo/info/refs", async (c) => { | |
| 16 | + const service = c.req.query("service"); | |
| 17 | + if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git"); | |
| 18 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read"); | |
| 19 | + if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) { | |
| 20 | + throw new HttpError(400, "Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)"); | |
| 21 | + } | |
| 22 | + const body = await stub.gitInfoRefs(service); | |
| 23 | + return new Response(body, { headers: { "content-type": `application/x-${service}-advertisement`, ...noCache } }); | |
| 24 | +}); | |
| 25 | + | |
| 26 | +git.post("/:owner/:repo/git-upload-pack", async (c) => { | |
| 27 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read"); | |
| 28 | + const body = await readBody(c.req.raw); | |
| 29 | + const out = await stub.gitUploadPack(body); | |
| 30 | + return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } }); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +git.post("/:owner/:repo/git-receive-pack", async (c) => { | |
| 34 | + const { stub, caller, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write"); | |
| 35 | + const body = await readBody(c.req.raw); | |
| 36 | + const out = await stub.gitReceivePack(body, caller); | |
| 37 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); | |
| 38 | + return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } }); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +async function readBody(req: Request): Promise<Uint8Array> { | |
| 42 | + let stream = req.body; | |
| 43 | + if (!stream) return new Uint8Array(0); | |
| 44 | + if (req.headers.get("content-encoding") === "gzip") stream = stream.pipeThrough(new DecompressionStream("gzip")); | |
| 45 | + return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| 46 | +} | |
| 47 | + | |
| 48 | +// ---------------------------------------------------------------- Git LFS | |
| 49 | + | |
| 50 | +const lfsKey = (repoId: string, oid: string) => `repos/${repoId}/lfs/${oid.slice(0, 2)}/${oid.slice(2, 4)}/${oid}`; | |
| 51 | +const LFS_TYPE = "application/vnd.git-lfs+json"; | |
| 52 | + | |
| 53 | +git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => { | |
| 54 | + const req = (await c.req.json()) as { operation: "upload" | "download"; objects: Array<{ oid: string; size: number }> }; | |
| 55 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read"); | |
| 56 | + const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`; | |
| 57 | + const auth = c.req.header("authorization"); | |
| 58 | + const objects = []; | |
| 59 | + for (const o of req.objects.slice(0, 1000)) { | |
| 60 | + if (!/^[0-9a-f]{64}$/.test(o.oid)) { | |
| 61 | + objects.push({ oid: o.oid, size: o.size, error: { code: 422, message: "invalid oid" } }); | |
| 62 | + continue; | |
| 63 | + } | |
| 64 | + const head = await c.env.BUCKET.head(lfsKey(repo.id, o.oid)); | |
| 65 | + if (req.operation === "download") { | |
| 66 | + if (!head) objects.push({ oid: o.oid, size: o.size, error: { code: 404, message: "object not found" } }); | |
| 67 | + else objects.push({ oid: o.oid, size: head.size, authenticated: true, actions: { download: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } }); | |
| 68 | + } else { | |
| 69 | + objects.push(head ? { oid: o.oid, size: o.size, authenticated: true } : { oid: o.oid, size: o.size, authenticated: true, actions: { upload: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } }); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return c.body(JSON.stringify({ transfer: "basic", objects, hash_algo: "sha256" }), 200, { "content-type": LFS_TYPE }); | |
| 73 | +}); | |
| 74 | + | |
| 75 | +git.get("/:owner/:repo/info/lfs/objects/:oid", async (c) => { | |
| 76 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read"); | |
| 77 | + const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid"))); | |
| 78 | + if (!obj) throw new HttpError(404, "object not found"); | |
| 79 | + return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } }); | |
| 80 | +}); | |
| 81 | + | |
| 82 | +git.put("/:owner/:repo/info/lfs/objects/:oid", async (c) => { | |
| 83 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write"); | |
| 84 | + const oid = c.req.param("oid"); | |
| 85 | + if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid"); | |
| 86 | + // Verify the content hash while storing: R2 checks the SHA-256 we pass. | |
| 87 | + const body = await c.req.arrayBuffer(); | |
| 88 | + const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", body))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 89 | + if (digest !== oid) throw new HttpError(422, "content does not match oid"); | |
| 90 | + await c.env.BUCKET.put(lfsKey(repo.id, oid), body); | |
| 91 | + return c.body(null, 200); | |
| 92 | +}); |
addedapps/diffinery/src/http/runner.ts+134−0View file
| @@ -1,0 +1,134 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { JobCompletion, LogLine, StepReport } from "@diffinery/shared"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 5 | +import { credentialFrom } from "../auth/principal.js"; | |
| 6 | +import { signEs256 } from "../lib/jwt.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { repoStub } from "./common.js"; | |
| 9 | + | |
| 10 | +/** Endpoints the runner agent inside a job container calls. Auth: the job token. */ | |
| 11 | +export const runner = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +function jobAuth(req: Request, jobId: string): { token: string; repoId: string } { | |
| 14 | + const token = credentialFrom(req); | |
| 15 | + if (!token?.startsWith("djt_")) throw new HttpError(401, "job token required"); | |
| 16 | + const [, repoId, tokJob] = token.split("_"); | |
| 17 | + if (!repoId || tokJob !== jobId) throw new HttpError(401, "token does not match job"); | |
| 18 | + return { token, repoId }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +runner.get("/runner/jobs/:jobId", async (c) => { | |
| 22 | + const jobId = c.req.param("jobId"); | |
| 23 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 24 | + return c.json(await repoStub(c.env, repoId).runnerSpec(jobId, token)); | |
| 25 | +}); | |
| 26 | + | |
| 27 | +runner.post("/runner/jobs/:jobId/logs", async (c) => { | |
| 28 | + const jobId = c.req.param("jobId"); | |
| 29 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 30 | + const { lines } = (await c.req.json()) as { lines: LogLine[] }; | |
| 31 | + await repoStub(c.env, repoId).runnerLogs(jobId, token, lines ?? []); | |
| 32 | + return c.json({ ok: true }); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +runner.post("/runner/jobs/:jobId/steps/:index", async (c) => { | |
| 36 | + const jobId = c.req.param("jobId"); | |
| 37 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 38 | + await repoStub(c.env, repoId).runnerStep(jobId, token, Number(c.req.param("index")), (await c.req.json()) as StepReport); | |
| 39 | + return c.json({ ok: true }); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +runner.post("/runner/jobs/:jobId/complete", async (c) => { | |
| 43 | + const jobId = c.req.param("jobId"); | |
| 44 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 45 | + await repoStub(c.env, repoId).runnerComplete(jobId, token, (await c.req.json()) as JobCompletion); | |
| 46 | + return c.json({ ok: true }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +// ---- cache (actions/cache compatible semantics, backed by R2) | |
| 50 | + | |
| 51 | +runner.get("/runner/jobs/:jobId/cache", async (c) => { | |
| 52 | + const jobId = c.req.param("jobId"); | |
| 53 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 54 | + const key = c.req.query("key") ?? ""; | |
| 55 | + const restore = (c.req.query("restore") ?? "").split(",").map((s) => s.trim()).filter(Boolean); | |
| 56 | + const hit = await repoStub(c.env, repoId).cacheLookup(jobId, token, key, restore); | |
| 57 | + if (!hit) return c.json({ message: "cache miss" }, 404); | |
| 58 | + return c.json({ key: hit.key, url: `${new URL(c.req.url).origin}/runner/jobs/${jobId}/cache/blob/${encodeURIComponent(hit.key)}` }); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +runner.get("/runner/jobs/:jobId/cache/blob/:key", async (c) => { | |
| 62 | + const jobId = c.req.param("jobId"); | |
| 63 | + const { repoId } = jobAuth(c.req.raw, jobId); | |
| 64 | + const r2Key = await repoStub(c.env, repoId).cacheKeyFor(c.req.param("key")); | |
| 65 | + const obj = await c.env.BUCKET.get(r2Key); | |
| 66 | + if (!obj) throw new HttpError(404, "cache entry missing"); | |
| 67 | + return new Response(obj.body, { headers: { "content-type": "application/gzip", "content-length": String(obj.size) } }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +runner.put("/runner/jobs/:jobId/cache/:key", async (c) => { | |
| 71 | + const jobId = c.req.param("jobId"); | |
| 72 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 73 | + const key = c.req.param("key"); | |
| 74 | + if (key.length > 512) throw new HttpError(422, "cache key too long"); | |
| 75 | + const stub = repoStub(c.env, repoId); | |
| 76 | + const r2Key = await stub.cacheKeyFor(key); | |
| 77 | + if (await c.env.BUCKET.head(r2Key)) return c.json({ message: "cache entry already exists" }, 409); | |
| 78 | + const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } }); | |
| 79 | + const evicted = await stub.cacheSaved(jobId, token, key, put?.size ?? 0); | |
| 80 | + if (evicted.length) await c.env.BUCKET.delete(evicted); | |
| 81 | + return c.json({ ok: true }); | |
| 82 | +}); | |
| 83 | + | |
| 84 | +// ---- artifacts | |
| 85 | + | |
| 86 | +runner.put("/runner/jobs/:jobId/artifacts/:name", async (c) => { | |
| 87 | + const jobId = c.req.param("jobId"); | |
| 88 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 89 | + const stub = repoStub(c.env, repoId); | |
| 90 | + const r2Key = await stub.artifactKeyFor(jobId, c.req.param("name")); | |
| 91 | + const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } }); | |
| 92 | + await stub.artifactSaved(jobId, token, c.req.param("name"), put?.size ?? 0); | |
| 93 | + return c.json({ ok: true }); | |
| 94 | +}); | |
| 95 | + | |
| 96 | +runner.get("/runner/jobs/:jobId/artifacts/:name", async (c) => { | |
| 97 | + const jobId = c.req.param("jobId"); | |
| 98 | + const { repoId } = jobAuth(c.req.raw, jobId); | |
| 99 | + const obj = await c.env.BUCKET.get(await repoStub(c.env, repoId).artifactKeyFor(jobId, c.req.param("name"))); | |
| 100 | + if (!obj) throw new HttpError(404, "artifact not found"); | |
| 101 | + return new Response(obj.body, { headers: { "content-type": "application/gzip" } }); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +// ---- OIDC id tokens (actions/core getIDToken compatible: GET ?audience= → { value }) | |
| 105 | + | |
| 106 | +const oidc = async (c: AppContext) => { | |
| 107 | + const jobId = c.req.param("jobId")!; | |
| 108 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 109 | + if (!c.env.OIDC_PRIVATE_KEY) throw new HttpError(501, "OIDC is not configured on this Diffinery instance"); | |
| 110 | + const claims = await repoStub(c.env, repoId).oidcClaims(jobId, token); | |
| 111 | + if (!claims.allowed) throw new HttpError(403, "this job lacks permissions: id-token: write"); | |
| 112 | + const g = claims.github; | |
| 113 | + const nowS = Math.floor(Date.now() / 1000); | |
| 114 | + const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), { | |
| 115 | + iss: c.env.PUBLIC_URL, | |
| 116 | + aud: c.req.query("audience") ?? c.env.PUBLIC_URL, | |
| 117 | + sub: `repo:${g.repository}:ref:${g.ref}`, | |
| 118 | + iat: nowS, | |
| 119 | + nbf: nowS, | |
| 120 | + exp: nowS + 600, | |
| 121 | + repository: g.repository, | |
| 122 | + repository_owner: g.repository_owner, | |
| 123 | + ref: g.ref, | |
| 124 | + sha: g.sha, | |
| 125 | + workflow: g.workflow, | |
| 126 | + event_name: g.event_name, | |
| 127 | + run_id: g.run_id, | |
| 128 | + actor: g.actor, | |
| 129 | + job_workflow_ref: g.workflow_ref, | |
| 130 | + }); | |
| 131 | + return c.json({ value }); | |
| 132 | +}; | |
| 133 | +runner.get("/runner/jobs/:jobId/oidc", oidc); | |
| 134 | +runner.post("/runner/jobs/:jobId/oidc", oidc); |
addedapps/diffinery/src/index.ts+17−0View file
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 2 | +import type { Env } from "./env.js"; | |
| 3 | +import { app, queue, scheduled } from "./app.js"; | |
| 4 | +import { web } from "./web/routes.js"; | |
| 5 | + | |
| 6 | +export { RepoDO } from "./repo/RepoDO.js"; | |
| 7 | +export { RunnerContainer, SandboxContainer } from "./runner/containers.js"; | |
| 8 | +export { RunWorkflow } from "./runner/workflow.js"; | |
| 9 | + | |
| 10 | +// The web UI is mounted last so API, git and runner routes take precedence. | |
| 11 | +app.route("/", web); | |
| 12 | + | |
| 13 | +export default { | |
| 14 | + fetch: app.fetch, | |
| 15 | + queue, | |
| 16 | + scheduled, | |
| 17 | +} satisfies ExportedHandler<Env, DiffineryEvent>; |
addedapps/diffinery/src/lib/crypto.ts+34−0View file
| @@ -1,0 +1,34 @@ | ||
| 1 | +import { b64, fromB64 } from "./util.js"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Envelope encryption for secrets. Each scope (repo or org) gets its own AES-GCM | |
| 5 | + * key derived from MASTER_KEY with HKDF, so a leaked ciphertext from one repo | |
| 6 | + * cannot be replayed into another. | |
| 7 | + */ | |
| 8 | +async function scopeKey(masterKeyB64: string, scope: string): Promise<CryptoKey> { | |
| 9 | + const raw = await crypto.subtle.importKey("raw", fromB64(masterKeyB64) as BufferSource, "HKDF", false, ["deriveKey"]); | |
| 10 | + return crypto.subtle.deriveKey( | |
| 11 | + { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("diffinery-secrets-v1"), info: new TextEncoder().encode(scope) }, | |
| 12 | + raw, | |
| 13 | + { name: "AES-GCM", length: 256 }, | |
| 14 | + false, | |
| 15 | + ["encrypt", "decrypt"], | |
| 16 | + ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export async function encryptSecret(masterKey: string | undefined, scope: string, plaintext: string): Promise<string> { | |
| 20 | + if (!masterKey) throw new Error("MASTER_KEY is not configured"); | |
| 21 | + const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| 22 | + const key = await scopeKey(masterKey, scope); | |
| 23 | + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(scope) }, key, new TextEncoder().encode(plaintext))); | |
| 24 | + return `v1.${b64(iv)}.${b64(ct)}`; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function decryptSecret(masterKey: string | undefined, scope: string, envelope: string): Promise<string> { | |
| 28 | + if (!masterKey) throw new Error("MASTER_KEY is not configured"); | |
| 29 | + const [v, ivB, ctB] = envelope.split("."); | |
| 30 | + if (v !== "v1" || !ivB || !ctB) throw new Error("bad secret envelope"); | |
| 31 | + const key = await scopeKey(masterKey, scope); | |
| 32 | + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(ivB) as BufferSource, additionalData: new TextEncoder().encode(scope) }, key, fromB64(ctB) as BufferSource); | |
| 33 | + return new TextDecoder().decode(pt); | |
| 34 | +} |
addedapps/diffinery/src/lib/directory.ts+403−0View file
| @@ -1,0 +1,403 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { RepoMeta, Role } from "../repo/types.js"; | |
| 3 | +import { ROLE_LEVEL } from "../repo/types.js"; | |
| 4 | +import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js"; | |
| 5 | + | |
| 6 | +export interface Account { | |
| 7 | + id: string; | |
| 8 | + login: string; | |
| 9 | + kind: "user" | "org" | "agent"; | |
| 10 | + name: string | null; | |
| 11 | + email: string | null; | |
| 12 | + status: "active" | "pending" | "disabled"; | |
| 13 | + site_admin: number; | |
| 14 | + owner_id: string | null; | |
| 15 | + agent_policy: string | null; | |
| 16 | + created_at: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface Repo { | |
| 20 | + id: string; | |
| 21 | + owner_id: string; | |
| 22 | + owner_login: string; | |
| 23 | + owner_kind: "user" | "org" | "agent"; | |
| 24 | + name: string; | |
| 25 | + description: string; | |
| 26 | + visibility: "private" | "internal" | "public"; | |
| 27 | + default_branch: string; | |
| 28 | + archived: number; | |
| 29 | + created_at: string; | |
| 30 | + updated_at: string; | |
| 31 | + pushed_at: string | null; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export type TokenScope = "read" | "write" | "admin"; | |
| 35 | + | |
| 36 | +/** Data access for the global directory in D1. */ | |
| 37 | +export class Directory { | |
| 38 | + constructor(private env: Env) {} | |
| 39 | + | |
| 40 | + private get db() { | |
| 41 | + return this.env.DB; | |
| 42 | + } | |
| 43 | + | |
| 44 | + // ---------------------------------------------------------------- accounts | |
| 45 | + | |
| 46 | + async account(id: string): Promise<Account | null> { | |
| 47 | + return this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first<Account>(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + async accountByLogin(login: string): Promise<Account | null> { | |
| 51 | + return this.db.prepare("SELECT * FROM accounts WHERE login = ?").bind(login).first<Account>(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + async accountByEmail(email: string): Promise<Account | null> { | |
| 55 | + return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + private isBootstrapAdmin(email: string, login?: string): boolean { | |
| 59 | + const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); | |
| 60 | + return list.includes(email.toLowerCase()) || (!!login && list.includes(login.toLowerCase())); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private async uniqueLogin(base: string): Promise<string> { | |
| 64 | + let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user"; | |
| 65 | + for (let i = 0; i < 50; i++) { | |
| 66 | + const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`; | |
| 67 | + if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.accountByLogin(tryLogin))) return tryLogin; | |
| 68 | + } | |
| 69 | + candidate = `${candidate}-${randomToken(3)}`; | |
| 70 | + return candidate; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */ | |
| 74 | + async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> { | |
| 75 | + const existing = await this.accountByEmail(email); | |
| 76 | + if (existing) { | |
| 77 | + if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) { | |
| 78 | + await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run(); | |
| 79 | + return { ...existing, status: "active", site_admin: 1 }; | |
| 80 | + } | |
| 81 | + return existing; | |
| 82 | + } | |
| 83 | + const admin = this.isBootstrapAdmin(email, hints.login); | |
| 84 | + const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!); | |
| 85 | + const id = hints.id ?? newId(); | |
| 86 | + const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first(); | |
| 87 | + await this.db | |
| 88 | + .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)") | |
| 89 | + .bind(id, login, hints.name ?? null, email, admin || hasInvite ? "active" : "pending", admin ? 1 : 0, now()) | |
| 90 | + .run(); | |
| 91 | + if (hasInvite) await this.acceptInvitesForEmail(id, email); | |
| 92 | + return (await this.account(id))!; | |
| 93 | + } | |
| 94 | + | |
| 95 | + async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> { | |
| 96 | + if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login"); | |
| 97 | + if (patch.login) { | |
| 98 | + const clash = await this.accountByLogin(patch.login); | |
| 99 | + if (clash && clash.id !== id) throw new HttpError(422, "login already taken"); | |
| 100 | + } | |
| 101 | + await this.db.prepare("UPDATE accounts SET login = COALESCE(?, login), name = COALESCE(?, name) WHERE id = ?").bind(patch.login ?? null, patch.name ?? null, id).run(); | |
| 102 | + if (patch.login) { | |
| 103 | + // Repos keep their ids; their Durable Objects cache the owner login, so refresh them. | |
| 104 | + const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>(); | |
| 105 | + for (const r of repos.results) await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login }); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + async createOrg(login: string, name: string, creator: Account): Promise<Account> { | |
| 110 | + if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name"); | |
| 111 | + if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); | |
| 112 | + const id = newId(); | |
| 113 | + const t = now(); | |
| 114 | + await this.db.batch([ | |
| 115 | + this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES (?, ?, 'org', ?, 'active', ?)").bind(id, login, name || login, t), | |
| 116 | + this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t), | |
| 117 | + ]); | |
| 118 | + return (await this.account(id))!; | |
| 119 | + } | |
| 120 | + | |
| 121 | + async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> { | |
| 122 | + const r = await this.db.prepare("SELECT role FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).first<{ role: "owner" | "admin" | "member" }>(); | |
| 123 | + return r?.role ?? null; | |
| 124 | + } | |
| 125 | + | |
| 126 | + async orgsFor(userId: string): Promise<Array<Account & { role: string }>> { | |
| 127 | + const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.login").bind(userId).all<Account & { role: string }>(); | |
| 128 | + return r.results; | |
| 129 | + } | |
| 130 | + | |
| 131 | + async orgMembers(orgId: string): Promise<Array<Account & { role: string }>> { | |
| 132 | + const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.login").bind(orgId).all<Account & { role: string }>(); | |
| 133 | + return r.results; | |
| 134 | + } | |
| 135 | + | |
| 136 | + async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { | |
| 137 | + if (role === null) { | |
| 138 | + const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>(); | |
| 139 | + if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner"); | |
| 140 | + await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run(); | |
| 141 | + // Removing someone revokes their tokens' reach immediately: roles are computed per request. | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run(); | |
| 145 | + } | |
| 146 | + | |
| 147 | + async users(): Promise<Account[]> { | |
| 148 | + return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' ORDER BY login").all<Account>()).results; | |
| 149 | + } | |
| 150 | + | |
| 151 | + async setStatus(id: string, status: Account["status"]): Promise<void> { | |
| 152 | + await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run(); | |
| 153 | + if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run(); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // ---------------------------------------------------------------- agents | |
| 157 | + | |
| 158 | + async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> { | |
| 159 | + if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name"); | |
| 160 | + const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`; | |
| 161 | + if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); | |
| 162 | + const id = newId(); | |
| 163 | + const ownerId = input.orgId ?? owner.id; | |
| 164 | + if (input.orgId) { | |
| 165 | + const role = await this.orgRole(input.orgId, owner.id); | |
| 166 | + if (role !== "owner" && role !== "admin") throw new HttpError(403, "only organization admins can create organization agents"); | |
| 167 | + } | |
| 168 | + const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy }; | |
| 169 | + await this.db | |
| 170 | + .prepare("INSERT INTO accounts (id, login, kind, name, status, owner_id, agent_policy, created_at) VALUES (?, ?, 'agent', ?, 'active', ?, ?, ?)") | |
| 171 | + .bind(id, login, input.name ?? login, ownerId, JSON.stringify(policy), now()) | |
| 172 | + .run(); | |
| 173 | + return (await this.account(id))!; | |
| 174 | + } | |
| 175 | + | |
| 176 | + async agentsOwnedBy(ownerIds: string[]): Promise<Account[]> { | |
| 177 | + if (!ownerIds.length) return []; | |
| 178 | + const q = `SELECT * FROM accounts WHERE kind = 'agent' AND owner_id IN (${ownerIds.map(() => "?").join(",")}) ORDER BY login`; | |
| 179 | + return (await this.db.prepare(q).bind(...ownerIds).all<Account>()).results; | |
| 180 | + } | |
| 181 | + | |
| 182 | + // ---------------------------------------------------------------- tokens | |
| 183 | + | |
| 184 | + async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number }): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 185 | + const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366); | |
| 186 | + const token = `dfy_${randomToken(30)}`; | |
| 187 | + const id = newId(); | |
| 188 | + const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); | |
| 189 | + await this.db | |
| 190 | + .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 191 | + .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now()) | |
| 192 | + .run(); | |
| 193 | + return { token, id, expiresAt }; | |
| 194 | + } | |
| 195 | + | |
| 196 | + async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> { | |
| 197 | + const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); | |
| 198 | + return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! })); | |
| 199 | + } | |
| 200 | + | |
| 201 | + async revokeToken(accountId: string, id: string): Promise<void> { | |
| 202 | + await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run(); | |
| 203 | + } | |
| 204 | + | |
| 205 | + async verifyToken(token: string): Promise<{ account: Account; scopes: TokenScope[]; repoIds: string[] | null } | null> { | |
| 206 | + const row = await this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); | |
| 207 | + if (!row || row.revoked_at || row.expires_at! < now()) return null; | |
| 208 | + const account = await this.account(row.account_id!); | |
| 209 | + if (!account || account.status !== "active") return null; | |
| 210 | + if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) { | |
| 211 | + await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run(); | |
| 212 | + } | |
| 213 | + return { account, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null }; | |
| 214 | + } | |
| 215 | + | |
| 216 | + // ---------------------------------------------------------------- invites | |
| 217 | + | |
| 218 | + async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> { | |
| 219 | + const token = randomToken(24); | |
| 220 | + const id = newId(); | |
| 221 | + await this.db | |
| 222 | + .prepare("INSERT INTO invites (id, org_id, repo_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 223 | + .bind(id, input.orgId ?? null, input.repoId ?? null, input.email, input.role, await sha256(token), inviter.id, now(), new Date(Date.now() + 14 * 86400_000).toISOString()) | |
| 224 | + .run(); | |
| 225 | + // If the person already exists, activate them now so the invite also works from the dashboard. | |
| 226 | + const existing = await this.accountByEmail(input.email); | |
| 227 | + if (existing?.status === "pending") await this.setStatus(existing.id, "active"); | |
| 228 | + return { id, url: `${this.env.PUBLIC_URL}/invite/${token}`, token }; | |
| 229 | + } | |
| 230 | + | |
| 231 | + async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> { | |
| 232 | + const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>(); | |
| 233 | + return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null })); | |
| 234 | + } | |
| 235 | + | |
| 236 | + async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { | |
| 237 | + const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); | |
| 238 | + if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired"); | |
| 239 | + if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`); | |
| 240 | + await this.applyInvite(inv, user); | |
| 241 | + return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null }; | |
| 242 | + } | |
| 243 | + | |
| 244 | + private async acceptInvitesForEmail(userId: string, email: string): Promise<void> { | |
| 245 | + const user = (await this.account(userId))!; | |
| 246 | + const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>(); | |
| 247 | + for (const inv of r.results) await this.applyInvite(inv, user); | |
| 248 | + } | |
| 249 | + | |
| 250 | + private async applyInvite(inv: Record<string, string | null>, user: Account): Promise<void> { | |
| 251 | + const stmts = [this.db.prepare("UPDATE invites SET accepted_at = ?, accepted_by = ? WHERE id = ?").bind(now(), user.id, inv.id!), this.db.prepare("UPDATE accounts SET status = 'active' WHERE id = ? AND status = 'pending'").bind(user.id)]; | |
| 252 | + if (inv.org_id) stmts.push(this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO NOTHING").bind(inv.org_id, user.id, inv.role === "admin" || inv.role === "owner" ? inv.role : "member", now())); | |
| 253 | + if (inv.repo_id) stmts.push(this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(inv.repo_id, user.id, inv.role)); | |
| 254 | + await this.db.batch(stmts); | |
| 255 | + } | |
| 256 | + | |
| 257 | + async requestAccess(accountId: string, note: string): Promise<void> { | |
| 258 | + await this.db.prepare("INSERT OR REPLACE INTO access_requests (account_id, note, created_at) VALUES (?, ?, ?)").bind(accountId, note.slice(0, 500), now()).run(); | |
| 259 | + } | |
| 260 | + | |
| 261 | + async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> { | |
| 262 | + const r = await this.db.prepare("SELECT a.*, r.note AS req_note, r.created_at AS req_at FROM access_requests r JOIN accounts a ON a.id = r.account_id WHERE a.status = 'pending' ORDER BY r.created_at").all<Account & { req_note: string; req_at: string }>(); | |
| 263 | + return r.results.map((x) => ({ account: x, note: x.req_note, createdAt: x.req_at })); | |
| 264 | + } | |
| 265 | + | |
| 266 | + // ---------------------------------------------------------------- repositories | |
| 267 | + | |
| 268 | + async repo(owner: string, name: string): Promise<Repo | null> { | |
| 269 | + return this.db | |
| 270 | + .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?") | |
| 271 | + .bind(owner, name.replace(/\.git$/, "")) | |
| 272 | + .first<Repo>(); | |
| 273 | + } | |
| 274 | + | |
| 275 | + async repoById(id: string): Promise<Repo | null> { | |
| 276 | + return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>(); | |
| 277 | + } | |
| 278 | + | |
| 279 | + /** Repositories an account can see. */ | |
| 280 | + async reposVisibleTo(account: Account, ownerId?: string): Promise<Repo[]> { | |
| 281 | + const params: unknown[] = []; | |
| 282 | + let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id"; | |
| 283 | + const subjectId = account.kind === "agent" ? account.owner_id! : account.id; | |
| 284 | + if (!account.site_admin) { | |
| 285 | + q += ` WHERE (r.visibility IN ('internal', 'public') OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 286 | + params.push(subjectId, subjectId, account.id, subjectId); | |
| 287 | + } else q += " WHERE 1 = 1"; | |
| 288 | + if (ownerId) { | |
| 289 | + q += " AND r.owner_id = ?"; | |
| 290 | + params.push(ownerId); | |
| 291 | + } | |
| 292 | + q += " ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 500"; | |
| 293 | + return (await this.db.prepare(q).bind(...params).all<Repo>()).results; | |
| 294 | + } | |
| 295 | + | |
| 296 | + async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string }): Promise<Repo> { | |
| 297 | + if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'"); | |
| 298 | + if (owner.kind === "org") { | |
| 299 | + const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id); | |
| 300 | + if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization"); | |
| 301 | + } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations"); | |
| 302 | + if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists"); | |
| 303 | + const id = newId(); | |
| 304 | + const t = now(); | |
| 305 | + const branch = input.defaultBranch || "main"; | |
| 306 | + await this.db | |
| 307 | + .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 308 | + .bind(id, owner.id, input.name, input.description ?? "", input.visibility ?? "private", branch, t, t) | |
| 309 | + .run(); | |
| 310 | + const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" }; | |
| 311 | + await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta); | |
| 312 | + return (await this.repoById(id))!; | |
| 313 | + } | |
| 314 | + | |
| 315 | + async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string; archived?: boolean }): Promise<Repo> { | |
| 316 | + if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name"); | |
| 317 | + await this.db | |
| 318 | + .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?") | |
| 319 | + .bind(patch.name ?? null, patch.description ?? null, patch.visibility ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id) | |
| 320 | + .run(); | |
| 321 | + if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch }); | |
| 322 | + return (await this.repoById(repo.id))!; | |
| 323 | + } | |
| 324 | + | |
| 325 | + async deleteRepo(repo: Repo): Promise<void> { | |
| 326 | + await this.db.batch([this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id)]); | |
| 327 | + // R2 objects under repos/<id>/ are removed by the cleanup job; the DO's storage is wiped here. | |
| 328 | + await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy(); | |
| 329 | + } | |
| 330 | + | |
| 331 | + async touchPushed(repoId: string): Promise<void> { | |
| 332 | + await this.db.prepare("UPDATE repos SET pushed_at = ?, updated_at = ? WHERE id = ?").bind(now(), now(), repoId).run(); | |
| 333 | + } | |
| 334 | + | |
| 335 | + async collaborators(repoId: string): Promise<Array<{ account: Account; role: Role }>> { | |
| 336 | + const r = await this.db.prepare("SELECT a.*, x.role AS access_role FROM repo_access x JOIN accounts a ON a.id = x.account_id WHERE x.repo_id = ? ORDER BY a.login").bind(repoId).all<Account & { access_role: Role }>(); | |
| 337 | + return r.results.map((x) => ({ account: x, role: x.access_role })); | |
| 338 | + } | |
| 339 | + | |
| 340 | + async setCollaborator(repoId: string, accountId: string, role: Role | null): Promise<void> { | |
| 341 | + if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run(); | |
| 342 | + else await this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).run(); | |
| 343 | + } | |
| 344 | + | |
| 345 | + /** Effective role of an account on a repository (null = no access). */ | |
| 346 | + async roleFor(account: Account, repo: Repo): Promise<Role | null> { | |
| 347 | + if (account.status !== "active") return null; | |
| 348 | + if (account.kind === "agent") { | |
| 349 | + if (!account.owner_id) return null; | |
| 350 | + const owner = await this.account(account.owner_id); | |
| 351 | + if (!owner || owner.status !== "active") return null; | |
| 352 | + const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo); | |
| 353 | + if (!ownerRole) return null; | |
| 354 | + const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(); | |
| 355 | + const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {}; | |
| 356 | + const cap: Role = explicit?.role ?? policy.maxRole ?? "write"; | |
| 357 | + return ROLE_LEVEL[ownerRole] < ROLE_LEVEL[cap] ? ownerRole : cap; | |
| 358 | + } | |
| 359 | + if (account.site_admin) return "admin"; | |
| 360 | + let best: Role | null = null; | |
| 361 | + const bump = (r: Role | null) => { | |
| 362 | + if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r; | |
| 363 | + }; | |
| 364 | + if (repo.owner_id === account.id) bump("admin"); | |
| 365 | + if (repo.owner_kind === "org") { | |
| 366 | + const orgRole = await this.orgRole(repo.owner_id, account.id); | |
| 367 | + if (orgRole === "owner" || orgRole === "admin") bump("admin"); | |
| 368 | + else if (orgRole === "member") bump("write"); | |
| 369 | + } | |
| 370 | + const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(); | |
| 371 | + bump(explicit?.role ?? null); | |
| 372 | + if (repo.visibility !== "private") bump("read"); | |
| 373 | + return best; | |
| 374 | + } | |
| 375 | + | |
| 376 | + // ---------------------------------------------------------------- notifications and audit | |
| 377 | + | |
| 378 | + async notify(accountIds: string[], n: { repoId?: string; kind: string; title: string; url: string }): Promise<void> { | |
| 379 | + const unique = [...new Set(accountIds)]; | |
| 380 | + if (!unique.length) return; | |
| 381 | + await this.db.batch(unique.map((id) => this.db.prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(newId(), id, n.repoId ?? null, n.kind, n.title, n.url, now()))); | |
| 382 | + } | |
| 383 | + | |
| 384 | + async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> { | |
| 385 | + const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>(); | |
| 386 | + return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at ?? null })); | |
| 387 | + } | |
| 388 | + | |
| 389 | + async markRead(accountId: string, id?: string): Promise<void> { | |
| 390 | + await this.db.prepare(`UPDATE notifications SET read_at = ? WHERE account_id = ? ${id ? "AND id = ?" : ""} AND read_at IS NULL`).bind(...(id ? [now(), accountId, id] : [now(), accountId])).run(); | |
| 391 | + } | |
| 392 | + | |
| 393 | + async audit(entry: { actorId?: string; actorLogin?: string; onBehalfOf?: string; action: string; target?: string; detail?: unknown; at?: string }): Promise<void> { | |
| 394 | + await this.db | |
| 395 | + .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 396 | + .bind(newId(), entry.at ?? now(), entry.actorId ?? null, entry.actorLogin ?? null, entry.onBehalfOf ?? null, entry.action, entry.target ?? null, entry.detail ? JSON.stringify(entry.detail) : null) | |
| 397 | + .run(); | |
| 398 | + } | |
| 399 | + | |
| 400 | + async auditLog(limit = 200): Promise<Array<Record<string, string | null>>> { | |
| 401 | + return (await this.db.prepare("SELECT * FROM audit_log ORDER BY at DESC LIMIT ?").bind(limit).all<Record<string, string | null>>()).results; | |
| 402 | + } | |
| 403 | +} |
addedapps/diffinery/src/lib/jwt.ts+50−0View file
| @@ -1,0 +1,50 @@ | ||
| 1 | +import { b64url, fromB64url } from "./util.js"; | |
| 2 | + | |
| 3 | +interface Jwk extends JsonWebKey { | |
| 4 | + kid?: string; | |
| 5 | +} | |
| 6 | + | |
| 7 | +const certCache = new Map<string, { keys: Jwk[]; at: number }>(); | |
| 8 | + | |
| 9 | +/** Verify a Cloudflare Access JWT (RS256) and return its claims. */ | |
| 10 | +export async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise<Record<string, unknown>> { | |
| 11 | + const [h, p, s] = token.split("."); | |
| 12 | + if (!h || !p || !s) throw new Error("malformed JWT"); | |
| 13 | + const header = JSON.parse(new TextDecoder().decode(fromB64url(h))) as { kid?: string; alg?: string }; | |
| 14 | + if (header.alg !== "RS256") throw new Error("unexpected alg"); | |
| 15 | + const certsUrl = `https://${teamDomain}/cdn-cgi/access/certs`; | |
| 16 | + let cached = certCache.get(certsUrl); | |
| 17 | + if (!cached || Date.now() - cached.at > 10 * 60_000 || !cached.keys.some((k) => k.kid === header.kid)) { | |
| 18 | + const res = await fetch(certsUrl); | |
| 19 | + if (!res.ok) throw new Error(`failed to fetch Access certs: ${res.status}`); | |
| 20 | + cached = { keys: ((await res.json()) as { keys: Jwk[] }).keys, at: Date.now() }; | |
| 21 | + certCache.set(certsUrl, cached); | |
| 22 | + } | |
| 23 | + const jwk = cached.keys.find((k) => k.kid === header.kid); | |
| 24 | + if (!jwk) throw new Error("unknown signing key"); | |
| 25 | + const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); | |
| 26 | + const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(s) as BufferSource, new TextEncoder().encode(`${h}.${p}`)); | |
| 27 | + if (!ok) throw new Error("bad signature"); | |
| 28 | + const claims = JSON.parse(new TextDecoder().decode(fromB64url(p))) as Record<string, unknown>; | |
| 29 | + const now = Math.floor(Date.now() / 1000); | |
| 30 | + if (typeof claims.exp === "number" && claims.exp < now) throw new Error("token expired"); | |
| 31 | + const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; | |
| 32 | + if (!auds.includes(aud)) throw new Error("audience mismatch"); | |
| 33 | + if (claims.iss !== `https://${teamDomain}`) throw new Error("issuer mismatch"); | |
| 34 | + return claims; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Sign an ES256 JWT with a private JWK (used for job OIDC tokens). */ | |
| 38 | +export async function signEs256(privateJwk: Jwk, claims: Record<string, unknown>): Promise<string> { | |
| 39 | + const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); | |
| 40 | + const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o))); | |
| 41 | + const head = enc({ alg: "ES256", typ: "JWT", kid: privateJwk.kid ?? "diffinery-1" }); | |
| 42 | + const body = enc(claims); | |
| 43 | + const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${head}.${body}`))); | |
| 44 | + return `${head}.${body}.${b64url(sig)}`; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function publicJwk(privateJwk: Jwk): Jwk { | |
| 48 | + const { d: _d, key_ops: _ops, ...pub } = privateJwk; | |
| 49 | + return { ...pub, use: "sig", alg: "ES256", kid: privateJwk.kid ?? "diffinery-1" }; | |
| 50 | +} |
addedapps/diffinery/src/lib/util.ts+80−0View file
| @@ -1,0 +1,80 @@ | ||
| 1 | +export const now = (): string => new Date().toISOString(); | |
| 2 | + | |
| 3 | +export function newId(): string { | |
| 4 | + return crypto.randomUUID().replace(/-/g, ""); | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function randomToken(bytes = 24): string { | |
| 8 | + const b = crypto.getRandomValues(new Uint8Array(bytes)); | |
| 9 | + return b64url(b); | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function b64url(b: Uint8Array): string { | |
| 13 | + let s = ""; | |
| 14 | + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); | |
| 15 | + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function fromB64url(s: string): Uint8Array { | |
| 19 | + const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : ""; | |
| 20 | + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); | |
| 21 | + const out = new Uint8Array(bin.length); | |
| 22 | + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 23 | + return out; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function b64(b: Uint8Array): string { | |
| 27 | + let s = ""; | |
| 28 | + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); | |
| 29 | + return btoa(s); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function fromB64(s: string): Uint8Array { | |
| 33 | + const bin = atob(s); | |
| 34 | + const out = new Uint8Array(bin.length); | |
| 35 | + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 36 | + return out; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export async function sha256(s: string): Promise<string> { | |
| 40 | + const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); | |
| 41 | + return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join(""); | |
| 42 | +} | |
| 43 | + | |
| 44 | +export async function hmacSha256Hex(secret: string, body: string | Uint8Array): Promise<string> { | |
| 45 | + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 46 | + const data = typeof body === "string" ? new TextEncoder().encode(body) : body; | |
| 47 | + const sig = await crypto.subtle.sign("HMAC", key, data as BufferSource); | |
| 48 | + return [...new Uint8Array(sig)].map((x) => x.toString(16).padStart(2, "0")).join(""); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Constant-time string comparison. */ | |
| 52 | +export function safeEqual(a: string, b: string): boolean { | |
| 53 | + if (a.length !== b.length) return false; | |
| 54 | + let r = 0; | |
| 55 | + for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 56 | + return r === 0; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export class HttpError extends Error { | |
| 60 | + /** The message without the status prefix. */ | |
| 61 | + readonly detail: string; | |
| 62 | + constructor(public status: number, message: string, public extra?: Record<string, unknown>) { | |
| 63 | + // The status is part of the message so it survives Durable Object RPC boundaries. | |
| 64 | + super(`${status}: ${message}`); | |
| 65 | + this.detail = message; | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Top-level paths the app serves; no account may use them as its login. */ | |
| 70 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "-"]); | |
| 71 | + | |
| 72 | +export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase()); | |
| 73 | + | |
| 74 | +export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== ".."; | |
| 75 | + | |
| 76 | +export function isValidRefName(ref: string): boolean { | |
| 77 | + if (!ref.startsWith("refs/")) return false; | |
| 78 | + if (/(^|\/)\.|\.\.|[\x00-\x20~^:?*[\\\x7f]|@\{|\/\/|\/$|\.lock(\/|$)|\.$/.test(ref)) return false; | |
| 79 | + return true; | |
| 80 | +} |
addedapps/diffinery/src/mcp/server.ts+457−0View file
| @@ -1,0 +1,457 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError, b64, fromB64 } from "../lib/util.js"; | |
| 5 | +import type { Role } from "../repo/types.js"; | |
| 6 | +import { loadRepo } from "../http/common.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Model Context Protocol server (Streamable HTTP transport, JSON responses). | |
| 10 | + * Agents connect with a Diffinery token: `Authorization: Bearer dfy_…`. | |
| 11 | + * Every tool runs through the same permission checks, branch protection and | |
| 12 | + * agent guardrails as the REST API and git, so an agent can never do more | |
| 13 | + * than its token, its owner and the repository's rules allow. | |
| 14 | + */ | |
| 15 | +export const mcp = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 16 | + | |
| 17 | +const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 18 | + | |
| 19 | +type Args = Record<string, unknown>; | |
| 20 | +interface Tool { | |
| 21 | + description: string; | |
| 22 | + input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>; | |
| 23 | + required?: string[]; | |
| 24 | + readOnly?: boolean; | |
| 25 | + run: (c: AppContext, a: Args) => Promise<unknown>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const str = (a: Args, k: string, def?: string): string => { | |
| 29 | + const v = a[k]; | |
| 30 | + if (v === undefined || v === null || v === "") { | |
| 31 | + if (def !== undefined) return def; | |
| 32 | + throw new HttpError(422, `${k} is required`); | |
| 33 | + } | |
| 34 | + if (typeof v !== "string") throw new HttpError(422, `${k} must be a string`); | |
| 35 | + return v; | |
| 36 | +}; | |
| 37 | +const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined); | |
| 38 | +const int = (a: Args, k: string) => { | |
| 39 | + const n = Number(a[k]); | |
| 40 | + if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`); | |
| 41 | + return n; | |
| 42 | +}; | |
| 43 | +const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min); | |
| 44 | +const OWNER_REPO = { | |
| 45 | + owner: { type: "string", description: "Repository owner (user or org login)" }, | |
| 46 | + repo: { type: "string", description: "Repository name" }, | |
| 47 | +}; | |
| 48 | +const decoder = new TextDecoder(); | |
| 49 | + | |
| 50 | +const TOOLS: Record<string, Tool> = { | |
| 51 | + whoami: { | |
| 52 | + description: "Show the account this token acts as, including the human an agent works on behalf of.", | |
| 53 | + input: {}, | |
| 54 | + readOnly: true, | |
| 55 | + run: async (c) => { | |
| 56 | + const a = requireAccount(c); | |
| 57 | + const owner = a.owner_id ? await c.get("dir").account(a.owner_id) : null; | |
| 58 | + return { login: a.login, kind: a.kind, on_behalf_of: owner?.login ?? null }; | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + list_repos: { | |
| 62 | + description: "List repositories visible to you, optionally filtered by owner.", | |
| 63 | + input: { owner: { type: "string" } }, | |
| 64 | + readOnly: true, | |
| 65 | + run: async (c, a) => { | |
| 66 | + const me = requireAccount(c); | |
| 67 | + const dir = c.get("dir"); | |
| 68 | + const owner = optStr(a, "owner") ? await dir.accountByLogin(optStr(a, "owner")!) : null; | |
| 69 | + const repos = await dir.reposVisibleTo(me, owner?.id); | |
| 70 | + return repos.map((r) => ({ full_name: `${r.owner_login}/${r.name}`, description: r.description, default_branch: r.default_branch, visibility: r.visibility, pushed_at: r.pushed_at })); | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | + list_tree: { | |
| 74 | + description: "List files and directories at a path in a repository.", | |
| 75 | + input: { ...OWNER_REPO, ref: { type: "string", description: "Branch, tag or commit (default: default branch)" }, path: { type: "string" } }, | |
| 76 | + required: ["owner", "repo"], | |
| 77 | + readOnly: true, | |
| 78 | + run: async (c, a) => { | |
| 79 | + const { stub } = await repoOf(c, a); | |
| 80 | + const t = await stub.tree(optStr(a, "ref") ?? "HEAD", optStr(a, "path") ?? ""); | |
| 81 | + if (!t) throw new HttpError(404, "path not found"); | |
| 82 | + return { sha: t.sha, items: t.items.map((i) => ({ path: i.path, type: i.type })) }; | |
| 83 | + }, | |
| 84 | + }, | |
| 85 | + get_file: { | |
| 86 | + description: "Read a file's contents.", | |
| 87 | + input: { ...OWNER_REPO, path: { type: "string" }, ref: { type: "string" } }, | |
| 88 | + required: ["owner", "repo", "path"], | |
| 89 | + readOnly: true, | |
| 90 | + run: async (c, a) => { | |
| 91 | + const { stub } = await repoOf(c, a); | |
| 92 | + const b = await stub.blob(optStr(a, "ref") ?? "HEAD", str(a, "path")); | |
| 93 | + if (!b) throw new HttpError(404, "file not found"); | |
| 94 | + if (b.binary) return { path: str(a, "path"), commit: b.sha, size: b.size, binary: true }; | |
| 95 | + return { path: str(a, "path"), commit: b.sha, size: b.size, content: decoder.decode(b.content) }; | |
| 96 | + }, | |
| 97 | + }, | |
| 98 | + list_branches: { | |
| 99 | + description: "List branches, marking the default and protected ones.", | |
| 100 | + input: { ...OWNER_REPO }, | |
| 101 | + required: ["owner", "repo"], | |
| 102 | + readOnly: true, | |
| 103 | + run: async (c, a) => (await repoOf(c, a)).stub.branches(), | |
| 104 | + }, | |
| 105 | + create_branch: { | |
| 106 | + description: "Create a branch from another branch, tag or commit.", | |
| 107 | + input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } }, | |
| 108 | + required: ["owner", "repo", "branch"], | |
| 109 | + run: async (c, a) => { | |
| 110 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 111 | + const sha = await stub.resolve(optStr(a, "from") ?? "HEAD"); | |
| 112 | + if (!sha) throw new HttpError(404, "source ref not found"); | |
| 113 | + await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller); | |
| 114 | + return { branch: str(a, "branch"), sha }; | |
| 115 | + }, | |
| 116 | + }, | |
| 117 | + commit_files: { | |
| 118 | + description: | |
| 119 | + "Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch.", | |
| 120 | + input: { | |
| 121 | + ...OWNER_REPO, | |
| 122 | + branch: { type: "string" }, | |
| 123 | + message: { type: "string" }, | |
| 124 | + files: { type: "array", description: "[{ path, content, encoding?: 'utf-8'|'base64', executable?: boolean }]", items: { type: "object" } }, | |
| 125 | + deletes: { type: "array", items: { type: "string" } }, | |
| 126 | + expected_head_sha: { type: "string" }, | |
| 127 | + create_branch_from: { type: "string" }, | |
| 128 | + }, | |
| 129 | + required: ["owner", "repo", "branch", "message"], | |
| 130 | + run: async (c, a) => { | |
| 131 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 132 | + const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : []; | |
| 133 | + const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : []; | |
| 134 | + if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit"); | |
| 135 | + const changes = [ | |
| 136 | + ...files.map((f) => ({ | |
| 137 | + path: String(f.path), | |
| 138 | + mode: f.executable ? ("100755" as const) : ("100644" as const), | |
| 139 | + content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? ""), | |
| 140 | + })), | |
| 141 | + ...deletes.map((p) => ({ path: String(p), delete: true })), | |
| 142 | + ]; | |
| 143 | + return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller); | |
| 144 | + }, | |
| 145 | + }, | |
| 146 | + compare: { | |
| 147 | + description: "Compare two refs: commits ahead/behind and a unified diff.", | |
| 148 | + input: { ...OWNER_REPO, base: { type: "string" }, head: { type: "string" } }, | |
| 149 | + required: ["owner", "repo", "base", "head"], | |
| 150 | + readOnly: true, | |
| 151 | + run: async (c, a) => (await repoOf(c, a)).stub.compare(str(a, "base"), str(a, "head")), | |
| 152 | + }, | |
| 153 | + list_pull_requests: { | |
| 154 | + description: "List pull requests.", | |
| 155 | + input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] } }, | |
| 156 | + required: ["owner", "repo"], | |
| 157 | + readOnly: true, | |
| 158 | + run: async (c, a) => { | |
| 159 | + const { stub } = await repoOf(c, a); | |
| 160 | + const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 }); | |
| 161 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) }; | |
| 162 | + }, | |
| 163 | + }, | |
| 164 | + get_pull_request: { | |
| 165 | + description: "Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff.", | |
| 166 | + input: { ...OWNER_REPO, number: { type: "integer" }, include_diff: { type: "boolean" } }, | |
| 167 | + required: ["owner", "repo", "number"], | |
| 168 | + readOnly: true, | |
| 169 | + run: async (c, a) => { | |
| 170 | + const { stub, caller } = await repoOf(c, a); | |
| 171 | + const n = int(a, "number"); | |
| 172 | + const [detail, mergeability] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null)]); | |
| 173 | + const files = a.include_diff ? await stub.pullFiles(n) : undefined; | |
| 174 | + return { ...detail, mergeability, files }; | |
| 175 | + }, | |
| 176 | + }, | |
| 177 | + create_pull_request: { | |
| 178 | + description: "Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it).", | |
| 179 | + input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } } }, | |
| 180 | + required: ["owner", "repo", "title", "head"], | |
| 181 | + run: async (c, a) => { | |
| 182 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 183 | + return stub.createPull( | |
| 184 | + { title: str(a, "title"), body: optStr(a, "body"), head: str(a, "head"), base: optStr(a, "base"), draft: !!a.draft, intent: optStr(a, "intent"), reviewers: Array.isArray(a.reviewers) ? (a.reviewers as string[]) : undefined }, | |
| 185 | + caller, | |
| 186 | + ); | |
| 187 | + }, | |
| 188 | + }, | |
| 189 | + comment: { | |
| 190 | + description: "Comment on an issue or pull request.", | |
| 191 | + input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } }, | |
| 192 | + required: ["owner", "repo", "number", "body"], | |
| 193 | + run: async (c, a) => { | |
| 194 | + const { stub, caller } = await repoOf(c, a, "triage"); | |
| 195 | + return stub.addComment(int(a, "number"), str(a, "body"), caller); | |
| 196 | + }, | |
| 197 | + }, | |
| 198 | + review_pull_request: { | |
| 199 | + description: "Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs.", | |
| 200 | + input: { ...OWNER_REPO, number: { type: "integer" }, event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }, body: { type: "string" }, comments: { type: "array", items: { type: "object" } } }, | |
| 201 | + required: ["owner", "repo", "number", "event"], | |
| 202 | + run: async (c, a) => { | |
| 203 | + const { stub, caller } = await repoOf(c, a, "read"); | |
| 204 | + return stub.submitReview(int(a, "number"), { event: str(a, "event") as "COMMENT", body: optStr(a, "body"), comments: Array.isArray(a.comments) ? (a.comments as []) : undefined }, caller); | |
| 205 | + }, | |
| 206 | + }, | |
| 207 | + merge_pull_request: { | |
| 208 | + description: "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced).", | |
| 209 | + input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } }, | |
| 210 | + required: ["owner", "repo", "number"], | |
| 211 | + run: async (c, a) => { | |
| 212 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 213 | + return stub.mergePull(int(a, "number"), { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller); | |
| 214 | + }, | |
| 215 | + }, | |
| 216 | + list_issues: { | |
| 217 | + description: "List or search issues.", | |
| 218 | + input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } }, | |
| 219 | + required: ["owner", "repo"], | |
| 220 | + readOnly: true, | |
| 221 | + run: async (c, a) => { | |
| 222 | + const { stub } = await repoOf(c, a); | |
| 223 | + const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100 }); | |
| 224 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) }; | |
| 225 | + }, | |
| 226 | + }, | |
| 227 | + get_issue: { | |
| 228 | + description: "Get an issue with its comments.", | |
| 229 | + input: { ...OWNER_REPO, number: { type: "integer" } }, | |
| 230 | + required: ["owner", "repo", "number"], | |
| 231 | + readOnly: true, | |
| 232 | + run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")), | |
| 233 | + }, | |
| 234 | + create_issue: { | |
| 235 | + description: "Open an issue.", | |
| 236 | + input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } }, | |
| 237 | + required: ["owner", "repo", "title"], | |
| 238 | + run: async (c, a) => { | |
| 239 | + const { stub, caller } = await repoOf(c, a, "read"); | |
| 240 | + return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); | |
| 241 | + }, | |
| 242 | + }, | |
| 243 | + update_issue: { | |
| 244 | + description: "Update an issue or pull request: title, body, state (open/closed), labels.", | |
| 245 | + input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } }, | |
| 246 | + required: ["owner", "repo", "number"], | |
| 247 | + run: async (c, a) => { | |
| 248 | + const { stub, caller } = await repoOf(c, a, "triage"); | |
| 249 | + return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); | |
| 250 | + }, | |
| 251 | + }, | |
| 252 | + list_workflow_runs: { | |
| 253 | + description: "List workflow runs, newest first. Filter by branch, commit sha, PR number or status.", | |
| 254 | + input: { ...OWNER_REPO, branch: { type: "string" }, sha: { type: "string" }, pr_number: { type: "integer" }, status: { type: "string" } }, | |
| 255 | + required: ["owner", "repo"], | |
| 256 | + readOnly: true, | |
| 257 | + run: async (c, a) => { | |
| 258 | + const { stub } = await repoOf(c, a); | |
| 259 | + return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 }); | |
| 260 | + }, | |
| 261 | + }, | |
| 262 | + get_workflow_run: { | |
| 263 | + description: "Get a run with its jobs, their status, conclusion and failure reason.", | |
| 264 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 265 | + required: ["owner", "repo", "run_id"], | |
| 266 | + readOnly: true, | |
| 267 | + run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")), | |
| 268 | + }, | |
| 269 | + get_job_logs: { | |
| 270 | + description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).", | |
| 271 | + input: { ...OWNER_REPO, job_id: { type: "string" }, tail: { type: "integer" } }, | |
| 272 | + required: ["owner", "repo", "job_id"], | |
| 273 | + readOnly: true, | |
| 274 | + run: async (c, a) => { | |
| 275 | + const { stub } = await repoOf(c, a); | |
| 276 | + const { lines, complete } = await stub.jobLogs(str(a, "job_id")); | |
| 277 | + const tail = a.tail ? int(a, "tail") : 400; | |
| 278 | + return { complete, truncated: lines.length > tail, log: lines.slice(-tail).map((l) => l.text).join("\n") }; | |
| 279 | + }, | |
| 280 | + }, | |
| 281 | + rerun_workflow: { | |
| 282 | + description: "Re-run a workflow run.", | |
| 283 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 284 | + required: ["owner", "repo", "run_id"], | |
| 285 | + run: async (c, a) => { | |
| 286 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 287 | + return stub.rerun(str(a, "run_id"), caller); | |
| 288 | + }, | |
| 289 | + }, | |
| 290 | + cancel_workflow: { | |
| 291 | + description: "Cancel a queued or running workflow run.", | |
| 292 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 293 | + required: ["owner", "repo", "run_id"], | |
| 294 | + run: async (c, a) => { | |
| 295 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 296 | + await stub.cancelRun(str(a, "run_id"), caller); | |
| 297 | + return { cancelled: true }; | |
| 298 | + }, | |
| 299 | + }, | |
| 300 | + dispatch_workflow: { | |
| 301 | + description: "Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs.", | |
| 302 | + input: { ...OWNER_REPO, workflow: { type: "string" }, ref: { type: "string" }, inputs: { type: "object" } }, | |
| 303 | + required: ["owner", "repo", "workflow"], | |
| 304 | + run: async (c, a) => { | |
| 305 | + const { stub, caller, repo } = await repoOf(c, a, "write"); | |
| 306 | + return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller); | |
| 307 | + }, | |
| 308 | + }, | |
| 309 | + sandbox_create: { | |
| 310 | + description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.", | |
| 311 | + input: { ...OWNER_REPO, ref: { type: "string" } }, | |
| 312 | + required: ["owner", "repo"], | |
| 313 | + run: async (c, a) => { | |
| 314 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 315 | + return stub.createSandbox(caller, optStr(a, "ref")); | |
| 316 | + }, | |
| 317 | + }, | |
| 318 | + sandbox_exec: { | |
| 319 | + description: "Run a shell command in a sandbox. Returns exit code, stdout and stderr.", | |
| 320 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, command: { type: "string" }, cwd: { type: "string" }, timeout_seconds: { type: "integer" } }, | |
| 321 | + required: ["owner", "repo", "sandbox_id", "command"], | |
| 322 | + run: (c, a) => sandboxCall(c, a, "/exec", { cmd: str(a, "command"), cwd: optStr(a, "cwd"), timeoutMs: a.timeout_seconds ? int(a, "timeout_seconds") * 1000 : undefined }), | |
| 323 | + }, | |
| 324 | + sandbox_read_file: { | |
| 325 | + description: "Read a file inside a sandbox.", | |
| 326 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" } }, | |
| 327 | + required: ["owner", "repo", "sandbox_id", "path"], | |
| 328 | + run: async (c, a) => { | |
| 329 | + const r = (await sandboxCall(c, a, "/files/read", { path: str(a, "path") })) as { path: string; size: number; content: string }; | |
| 330 | + const bytes = fromB64(r.content); | |
| 331 | + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); | |
| 332 | + try { | |
| 333 | + return { path: r.path, size: r.size, content: text.decode(bytes) }; | |
| 334 | + } catch { | |
| 335 | + return { path: r.path, size: r.size, encoding: "base64", content: r.content }; | |
| 336 | + } | |
| 337 | + }, | |
| 338 | + }, | |
| 339 | + sandbox_write_file: { | |
| 340 | + description: "Write a file inside a sandbox.", | |
| 341 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" }, content: { type: "string" } }, | |
| 342 | + required: ["owner", "repo", "sandbox_id", "path", "content"], | |
| 343 | + run: (c, a) => sandboxCall(c, a, "/files/write", { path: str(a, "path"), content: b64(new TextEncoder().encode(str(a, "content", ""))) }), | |
| 344 | + }, | |
| 345 | + sandbox_close: { | |
| 346 | + description: "Shut down a sandbox.", | |
| 347 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" } }, | |
| 348 | + required: ["owner", "repo", "sandbox_id"], | |
| 349 | + run: async (c, a) => { | |
| 350 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 351 | + await stub.closeSandbox(str(a, "sandbox_id"), caller); | |
| 352 | + return { closed: true }; | |
| 353 | + }, | |
| 354 | + }, | |
| 355 | +}; | |
| 356 | + | |
| 357 | +async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) { | |
| 358 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 359 | + const id = str(a, "sandbox_id"); | |
| 360 | + await stub.sandbox(id, caller); | |
| 361 | + const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(path, JSON.stringify(payload)); | |
| 362 | + let out: unknown = res.body; | |
| 363 | + try { | |
| 364 | + out = JSON.parse(res.body); | |
| 365 | + } catch { | |
| 366 | + /* plain text */ | |
| 367 | + } | |
| 368 | + if (res.status >= 400) throw new HttpError(res.status, out && typeof out === "object" && "error" in out ? String((out as { error: unknown }).error) : res.body || "sandbox error"); | |
| 369 | + return out; | |
| 370 | +} | |
| 371 | + | |
| 372 | +interface RpcRequest { | |
| 373 | + jsonrpc: "2.0"; | |
| 374 | + id?: string | number | null; | |
| 375 | + method: string; | |
| 376 | + params?: Record<string, unknown>; | |
| 377 | +} | |
| 378 | + | |
| 379 | +const rpcError = (id: RpcRequest["id"], code: number, message: string) => ({ jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } }); | |
| 380 | + | |
| 381 | +async function handle(c: AppContext, req: RpcRequest): Promise<unknown | null> { | |
| 382 | + const isNotification = req.id === undefined; | |
| 383 | + switch (req.method) { | |
| 384 | + case "initialize": { | |
| 385 | + const asked = String(req.params?.protocolVersion ?? ""); | |
| 386 | + return { | |
| 387 | + jsonrpc: "2.0", | |
| 388 | + id: req.id, | |
| 389 | + result: { | |
| 390 | + protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0], | |
| 391 | + capabilities: { tools: { listChanged: false } }, | |
| 392 | + serverInfo: { name: "diffinery", title: "Diffinery", version: "0.1.0" }, | |
| 393 | + instructions: | |
| 394 | + "Diffinery is a git forge built for agents. Read code with get_file/list_tree, change it with commit_files on a branch, open a pull request with an `intent`, watch CI with list_workflow_runs/get_job_logs, and use sandboxes to build and test. Protected branches and required human reviews are enforced server-side.", | |
| 395 | + }, | |
| 396 | + }; | |
| 397 | + } | |
| 398 | + case "notifications/initialized": | |
| 399 | + case "notifications/cancelled": | |
| 400 | + return null; | |
| 401 | + case "ping": | |
| 402 | + return { jsonrpc: "2.0", id: req.id, result: {} }; | |
| 403 | + case "tools/list": | |
| 404 | + return { | |
| 405 | + jsonrpc: "2.0", | |
| 406 | + id: req.id, | |
| 407 | + result: { | |
| 408 | + tools: Object.entries(TOOLS).map(([name, t]) => ({ | |
| 409 | + name, | |
| 410 | + description: t.description, | |
| 411 | + inputSchema: { type: "object", properties: t.input, required: t.required ?? [] }, | |
| 412 | + annotations: { readOnlyHint: !!t.readOnly }, | |
| 413 | + })), | |
| 414 | + }, | |
| 415 | + }; | |
| 416 | + case "tools/call": { | |
| 417 | + const name = String(req.params?.name ?? ""); | |
| 418 | + const tool = TOOLS[name]; | |
| 419 | + if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`); | |
| 420 | + try { | |
| 421 | + const out = await tool.run(c, (req.params?.arguments as Args) ?? {}); | |
| 422 | + return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } }; | |
| 423 | + } catch (e) { | |
| 424 | + const msg = e instanceof HttpError ? e.detail : String((e as Error)?.message ?? e).replace(/^\d{3}: /, ""); | |
| 425 | + return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: msg }], isError: true } }; | |
| 426 | + } | |
| 427 | + } | |
| 428 | + default: | |
| 429 | + return isNotification ? null : rpcError(req.id, -32601, `method not found: ${req.method}`); | |
| 430 | + } | |
| 431 | +} | |
| 432 | + | |
| 433 | +mcp.get("/mcp", (c) => c.json({ message: "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>" }, 405, { allow: "POST" })); | |
| 434 | +mcp.delete("/mcp", (c) => c.body(null, 405)); | |
| 435 | +mcp.post("/mcp", async (c) => { | |
| 436 | + // Every MCP call needs an active account (a token for an agent or a person). | |
| 437 | + requireAccount(c); | |
| 438 | + let payload: unknown; | |
| 439 | + try { | |
| 440 | + payload = await c.req.json(); | |
| 441 | + } catch { | |
| 442 | + return c.json(rpcError(null, -32700, "parse error"), 400); | |
| 443 | + } | |
| 444 | + const batch = Array.isArray(payload); | |
| 445 | + const reqs = (batch ? payload : [payload]) as RpcRequest[]; | |
| 446 | + const out: unknown[] = []; | |
| 447 | + for (const r of reqs) { | |
| 448 | + if (!r || typeof r !== "object" || typeof r.method !== "string") { | |
| 449 | + out.push(rpcError(null, -32600, "invalid request")); | |
| 450 | + continue; | |
| 451 | + } | |
| 452 | + const res = await handle(c, r); | |
| 453 | + if (res) out.push(res); | |
| 454 | + } | |
| 455 | + if (!out.length) return c.body(null, 202); | |
| 456 | + return c.json(batch ? out : out[0]); | |
| 457 | +}); |
addedapps/diffinery/src/repo/RepoDO.ts+732−0View file
| @@ -1,0 +1,732 @@ | ||
| 1 | +import { DurableObject } from "cloudflare:workers"; | |
| 2 | +import { | |
| 3 | + EMPTY_TREE_OID, | |
| 4 | + ZERO_OID, | |
| 5 | + applyChanges, | |
| 6 | + diffTrees, | |
| 7 | + fileDiff, | |
| 8 | + formatUnified, | |
| 9 | + fromUtf8, | |
| 10 | + isAncestor, | |
| 11 | + isBinary, | |
| 12 | + isOid, | |
| 13 | + isTreeMode, | |
| 14 | + mergeBase, | |
| 15 | + parsePack, | |
| 16 | + peelToCommit, | |
| 17 | + readCommit, | |
| 18 | + readTree, | |
| 19 | + resolvePath, | |
| 20 | + serializeCommit, | |
| 21 | + walkCommits, | |
| 22 | + type Commit, | |
| 23 | + type FileChange, | |
| 24 | + type Signature, | |
| 25 | +} from "@diffinery/git-core"; | |
| 26 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 27 | +import type { Env } from "../env.js"; | |
| 28 | +import { | |
| 29 | + advertiseReceivePack, | |
| 30 | + advertiseUploadPackV2, | |
| 31 | + errorPacket, | |
| 32 | + handleUploadPackV2, | |
| 33 | + parseReceivePack, | |
| 34 | + reportStatus, | |
| 35 | + type RefEntry, | |
| 36 | + type RefResult, | |
| 37 | +} from "../git/protocol.js"; | |
| 38 | +import { scanForSecrets } from "../git/secret-scan.js"; | |
| 39 | +import { HttpError, isValidRefName, now } from "../lib/util.js"; | |
| 40 | +import { Actions } from "./actions.js"; | |
| 41 | +import { Collab } from "./collab.js"; | |
| 42 | +import { RepoObjectStore } from "./objects.js"; | |
| 43 | +import { REPO_SCHEMA } from "./schema.js"; | |
| 44 | +import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; | |
| 45 | + | |
| 46 | +const MAX_PUSH_BYTES = 100 * 1024 * 1024; | |
| 47 | + | |
| 48 | +export type TreeItem = { | |
| 49 | + name: string; | |
| 50 | + path: string; | |
| 51 | + type: "tree" | "blob" | "commit"; | |
| 52 | + mode: string; | |
| 53 | + oid: string; | |
| 54 | + size?: number; | |
| 55 | +}; | |
| 56 | + | |
| 57 | +export type CommitInfo = { | |
| 58 | + sha: string; | |
| 59 | + tree: string; | |
| 60 | + parents: string[]; | |
| 61 | + author: Signature; | |
| 62 | + committer: Signature; | |
| 63 | + message: string; | |
| 64 | + signed: boolean; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * One Durable Object per repository. It is the single writer for the repo's | |
| 69 | + * refs, Git objects, issues, pull requests, reviews, checks and workflow runs, | |
| 70 | + * which gives strongly consistent ref updates and merges without locks. | |
| 71 | + */ | |
| 72 | +export class RepoDO extends DurableObject<Env> { | |
| 73 | + readonly sql: SqlStorage; | |
| 74 | + store!: RepoObjectStore; | |
| 75 | + readonly collab: Collab; | |
| 76 | + readonly actions: Actions; | |
| 77 | + private metaCache: RepoMeta | null = null; | |
| 78 | + | |
| 79 | + constructor(ctx: DurableObjectState, env: Env) { | |
| 80 | + super(ctx, env); | |
| 81 | + this.sql = ctx.storage.sql; | |
| 82 | + this.sql.exec(REPO_SCHEMA); | |
| 83 | + const meta = this.readMeta(); | |
| 84 | + if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId); | |
| 85 | + this.collab = new Collab(this); | |
| 86 | + this.actions = new Actions(this, env); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // ---------------------------------------------------------------- meta | |
| 90 | + | |
| 91 | + private readMeta(): RepoMeta | null { | |
| 92 | + const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0]; | |
| 93 | + this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null; | |
| 94 | + return this.metaCache; | |
| 95 | + } | |
| 96 | + | |
| 97 | + get meta(): RepoMeta { | |
| 98 | + const m = this.metaCache ?? this.readMeta(); | |
| 99 | + if (!m) throw new HttpError(404, "repository not initialised"); | |
| 100 | + return m; | |
| 101 | + } | |
| 102 | + | |
| 103 | + async init(meta: RepoMeta): Promise<void> { | |
| 104 | + this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta)); | |
| 105 | + this.metaCache = meta; | |
| 106 | + this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId); | |
| 107 | + } | |
| 108 | + | |
| 109 | + async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> { | |
| 110 | + const next = { ...this.meta, ...patch }; | |
| 111 | + await this.init(next); | |
| 112 | + return next; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** Wipe all storage (repository deletion). */ | |
| 116 | + async destroy(): Promise<void> { | |
| 117 | + await this.ctx.storage.deleteAll(); | |
| 118 | + this.metaCache = null; | |
| 119 | + } | |
| 120 | + | |
| 121 | + async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> { | |
| 122 | + const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n; | |
| 123 | + return { | |
| 124 | + objects: this.store.count(), | |
| 125 | + bytes: this.store.sizeBytes(), | |
| 126 | + refs: q("SELECT count(*) AS n FROM refs"), | |
| 127 | + openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"), | |
| 128 | + openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"), | |
| 129 | + }; | |
| 130 | + } | |
| 131 | + | |
| 132 | + emit(event: DiffineryEvent): void { | |
| 133 | + this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e))); | |
| 134 | + this.broadcast("repo", { kind: "event", event }); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ---------------------------------------------------------------- refs | |
| 138 | + | |
| 139 | + refs(prefix = "refs/"): RefEntry[] { | |
| 140 | + return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}`).toArray(); | |
| 141 | + } | |
| 142 | + | |
| 143 | + getRef(name: string): string | null { | |
| 144 | + return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null; | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */ | |
| 148 | + casRef(name: string, expected: string | null, next: string | null): boolean { | |
| 149 | + const current = this.getRef(name); | |
| 150 | + if (expected !== null && (current ?? ZERO_OID) !== expected) return false; | |
| 151 | + if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name); | |
| 152 | + else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next); | |
| 153 | + return true; | |
| 154 | + } | |
| 155 | + | |
| 156 | + get headRef(): string { | |
| 157 | + return `refs/heads/${this.meta.defaultBranch}`; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */ | |
| 161 | + async resolve(rev: string | undefined | null): Promise<string | null> { | |
| 162 | + const r = rev && rev.length ? rev : "HEAD"; | |
| 163 | + let oid: string | null = null; | |
| 164 | + if (r === "HEAD") oid = this.getRef(this.headRef); | |
| 165 | + else if (isOid(r)) oid = this.store.has(r) ? r : null; | |
| 166 | + else if (r.startsWith("refs/")) oid = this.getRef(r); | |
| 167 | + else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`); | |
| 168 | + if (!oid && /^[0-9a-f]{7,39}$/.test(r)) { | |
| 169 | + const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray(); | |
| 170 | + if (rows.length === 1) oid = rows[0]!.oid; | |
| 171 | + } | |
| 172 | + if (!oid) return null; | |
| 173 | + return peelToCommit(this.store, oid); | |
| 174 | + } | |
| 175 | + | |
| 176 | + async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> { | |
| 177 | + return this.refs("refs/heads/").map((r) => { | |
| 178 | + const name = r.name.slice(11); | |
| 179 | + return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch }; | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + | |
| 183 | + async tags(): Promise<Array<{ name: string; sha: string; target: string }>> { | |
| 184 | + const out = []; | |
| 185 | + for (const r of this.refs("refs/tags/")) out.push({ name: r.name.slice(10), sha: r.oid, target: await peelToCommit(this.store, r.oid).catch(() => r.oid) }); | |
| 186 | + return out; | |
| 187 | + } | |
| 188 | + | |
| 189 | + // ---------------------------------------------------------------- git smart HTTP | |
| 190 | + | |
| 191 | + async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> { | |
| 192 | + if (service === "git-upload-pack") return advertiseUploadPackV2(); | |
| 193 | + return advertiseReceivePack(this.refs()); | |
| 194 | + } | |
| 195 | + | |
| 196 | + async gitUploadPack(body: Uint8Array): Promise<Uint8Array> { | |
| 197 | + try { | |
| 198 | + return await handleUploadPackV2(body, { store: this.store, refs: this.refs(), headTarget: this.headRef }); | |
| 199 | + } catch (e) { | |
| 200 | + return errorPacket((e as Error).message); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + async gitReceivePack(body: Uint8Array, caller: Caller): Promise<Uint8Array> { | |
| 205 | + if (body.length > MAX_PUSH_BYTES) { | |
| 206 | + const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }; | |
| 207 | + return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []); | |
| 208 | + } | |
| 209 | + const req = parseReceivePack(body); | |
| 210 | + const messages: string[] = []; | |
| 211 | + let unpackError: string | null = null; | |
| 212 | + const newBlobs: Array<{ oid: string; content: Uint8Array }> = []; | |
| 213 | + if (req.pack && req.pack.length > 0) { | |
| 214 | + try { | |
| 215 | + const objects = await parsePack(req.pack, (oid) => this.store.read(oid)); | |
| 216 | + for (const o of objects) { | |
| 217 | + await this.store.writeKnown(o.oid, o.type, o.content); | |
| 218 | + if (o.type === "blob" && o.content.length < 1024 * 1024) newBlobs.push({ oid: o.oid, content: o.content }); | |
| 219 | + } | |
| 220 | + } catch (e) { | |
| 221 | + unpackError = (e as Error).message; | |
| 222 | + } | |
| 223 | + } | |
| 224 | + const results: RefResult[] = []; | |
| 225 | + if (unpackError) { | |
| 226 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); | |
| 227 | + return reportStatus(req, unpackError, results); | |
| 228 | + } | |
| 229 | + | |
| 230 | + const leaks = scanForSecrets(newBlobs); | |
| 231 | + if (leaks.length) { | |
| 232 | + messages.push("Push blocked: possible secrets detected."); | |
| 233 | + for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); | |
| 234 | + messages.push("Remove the secret from your commits, or rotate it and contact an admin."); | |
| 235 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" }); | |
| 236 | + this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: leaks.map((l) => l.kind) }, at: now() }); | |
| 237 | + return reportStatus(req, null, results, messages); | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Validate every command before applying any, so `atomic` is honoured. | |
| 241 | + const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = []; | |
| 242 | + for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller)) }); | |
| 243 | + const atomic = req.capabilities.has("atomic"); | |
| 244 | + const anyFailed = checks.some((c) => !c.ok); | |
| 245 | + for (const c of checks) { | |
| 246 | + if (!c.ok) { | |
| 247 | + results.push({ ref: c.ref, ok: false, reason: c.reason }); | |
| 248 | + continue; | |
| 249 | + } | |
| 250 | + if (atomic && anyFailed) { | |
| 251 | + results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" }); | |
| 252 | + continue; | |
| 253 | + } | |
| 254 | + const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid); | |
| 255 | + results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" }); | |
| 256 | + if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages); | |
| 257 | + } | |
| 258 | + return reportStatus(req, null, results, messages); | |
| 259 | + } | |
| 260 | + | |
| 261 | + protectionFor(branch: string): ProtectionRules | null { | |
| 262 | + for (const row of this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections").toArray()) { | |
| 263 | + if (globToRegExp(row.pattern).test(branch)) return JSON.parse(row.rules) as ProtectionRules; | |
| 264 | + } | |
| 265 | + return null; | |
| 266 | + } | |
| 267 | + | |
| 268 | + /** Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails. */ | |
| 269 | + async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller): Promise<RefResult> { | |
| 270 | + if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" }; | |
| 271 | + if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" }; | |
| 272 | + const deleting = newOid === ZERO_OID; | |
| 273 | + if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" }; | |
| 274 | + if (!ref.startsWith("refs/heads/")) { | |
| 275 | + if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" }; | |
| 276 | + return { ref, ok: true }; | |
| 277 | + } | |
| 278 | + const branch = ref.slice(11); | |
| 279 | + const rules = this.protectionFor(branch); | |
| 280 | + const isDefault = branch === this.meta.defaultBranch; | |
| 281 | + if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) { | |
| 282 | + return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" }; | |
| 283 | + } | |
| 284 | + if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" }; | |
| 285 | + if (!rules) return { ref, ok: true }; | |
| 286 | + const bypass = caller.role === "admin" && !rules.enforceAdmins; | |
| 287 | + if (bypass) return { ref, ok: true }; | |
| 288 | + if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" }; | |
| 289 | + if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" }; | |
| 290 | + if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) { | |
| 291 | + return { ref, ok: false, reason: "protected branch: force push not allowed" }; | |
| 292 | + } | |
| 293 | + if (rules.requireLinearHistory) { | |
| 294 | + const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 }); | |
| 295 | + if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" }; | |
| 296 | + } | |
| 297 | + return { ref, ok: true }; | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** Side effects after a ref moved: events, PR sync, workflow triggers. */ | |
| 301 | + async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> { | |
| 302 | + const at = now(); | |
| 303 | + const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid)); | |
| 304 | + const ev: DiffineryEvent = { | |
| 305 | + type: "push", | |
| 306 | + repoId: this.meta.repoId, | |
| 307 | + owner: this.meta.owner, | |
| 308 | + repo: this.meta.name, | |
| 309 | + ref, | |
| 310 | + before: oldOid, | |
| 311 | + after: newOid, | |
| 312 | + actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, | |
| 313 | + forced, | |
| 314 | + created: oldOid === ZERO_OID, | |
| 315 | + deleted: newOid === ZERO_OID, | |
| 316 | + at, | |
| 317 | + }; | |
| 318 | + this.emit(ev); | |
| 319 | + if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) { | |
| 320 | + const branch = ref.slice(11); | |
| 321 | + const synced = await this.collab.syncPullsForBranch(branch, newOid, caller); | |
| 322 | + for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`); | |
| 323 | + if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) { | |
| 324 | + messages.push(`Create a pull request for '${branch}':`); | |
| 325 | + messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`); | |
| 326 | + } | |
| 327 | + } | |
| 328 | + if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`)); | |
| 329 | + } | |
| 330 | + | |
| 331 | + // ---------------------------------------------------------------- browsing | |
| 332 | + | |
| 333 | + async commitInfo(sha: string): Promise<CommitInfo> { | |
| 334 | + const c = await readCommit(this.store, sha); | |
| 335 | + return toInfo(sha, c); | |
| 336 | + } | |
| 337 | + | |
| 338 | + async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> { | |
| 339 | + const sha = await this.resolve(rev); | |
| 340 | + if (!sha) return null; | |
| 341 | + const c = await readCommit(this.store, sha); | |
| 342 | + const entry = await resolvePath(this.store, c.tree, path); | |
| 343 | + if (!entry || !isTreeMode(entry.mode)) return null; | |
| 344 | + const items: TreeItem[] = []; | |
| 345 | + for (const e of await readTree(this.store, entry.oid)) { | |
| 346 | + const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob"; | |
| 347 | + const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined; | |
| 348 | + items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size }); | |
| 349 | + } | |
| 350 | + items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1)); | |
| 351 | + return { sha, items }; | |
| 352 | + } | |
| 353 | + | |
| 354 | + async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> { | |
| 355 | + const sha = await this.resolve(rev); | |
| 356 | + if (!sha) return null; | |
| 357 | + const c = await readCommit(this.store, sha); | |
| 358 | + const entry = await resolvePath(this.store, c.tree, path); | |
| 359 | + if (!entry || isTreeMode(entry.mode)) return null; | |
| 360 | + const o = await this.store.read(entry.oid); | |
| 361 | + if (!o) return null; | |
| 362 | + return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content }; | |
| 363 | + } | |
| 364 | + | |
| 365 | + async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> { | |
| 366 | + const sha = await this.resolve(rev); | |
| 367 | + if (!sha) return []; | |
| 368 | + const limit = Math.min(opts.limit ?? 30, 100); | |
| 369 | + const skip = opts.skip ?? 0; | |
| 370 | + if (!opts.path) { | |
| 371 | + const commits = await walkCommits(this.store, [sha], { limit: limit + skip }); | |
| 372 | + return commits.slice(skip).map((c) => toInfo(c.oid, c.commit)); | |
| 373 | + } | |
| 374 | + // Path history: commits whose tree entry at `path` differs from their first parent's. | |
| 375 | + const out: CommitInfo[] = []; | |
| 376 | + let skipped = 0; | |
| 377 | + for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) { | |
| 378 | + const mine = await resolvePath(this.store, c.commit.tree, opts.path); | |
| 379 | + const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null; | |
| 380 | + const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null; | |
| 381 | + if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue; | |
| 382 | + if (skipped++ < skip) continue; | |
| 383 | + out.push(toInfo(c.oid, c.commit)); | |
| 384 | + if (out.length >= limit) break; | |
| 385 | + } | |
| 386 | + return out; | |
| 387 | + } | |
| 388 | + | |
| 389 | + /** Diff between two commits (or a commit and its first parent). */ | |
| 390 | + async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) { | |
| 391 | + const head = await readCommit(this.store, headSha); | |
| 392 | + const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null; | |
| 393 | + const changes = await diffTrees(this.store, baseTree, head.tree); | |
| 394 | + const max = opts.maxFiles ?? 300; | |
| 395 | + const files = []; | |
| 396 | + for (const ch of changes.slice(0, max)) { | |
| 397 | + const d = await fileDiff(this.store, ch); | |
| 398 | + files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) }); | |
| 399 | + } | |
| 400 | + return { | |
| 401 | + files, | |
| 402 | + totalFiles: changes.length, | |
| 403 | + additions: files.reduce((s, f) => s + f.additions, 0), | |
| 404 | + deletions: files.reduce((s, f) => s + f.deletions, 0), | |
| 405 | + truncated: changes.length > max, | |
| 406 | + }; | |
| 407 | + } | |
| 408 | + | |
| 409 | + async commitWithDiff(rev: string) { | |
| 410 | + const sha = await this.resolve(rev); | |
| 411 | + if (!sha) throw new HttpError(404, "commit not found"); | |
| 412 | + const info = await this.commitInfo(sha); | |
| 413 | + return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) }; | |
| 414 | + } | |
| 415 | + | |
| 416 | + async compare(base: string, head: string) { | |
| 417 | + const b = await this.resolve(base); | |
| 418 | + const h = await this.resolve(head); | |
| 419 | + if (!b || !h) throw new HttpError(404, "unknown base or head"); | |
| 420 | + const mb = await mergeBase(this.store, b, h); | |
| 421 | + const commits = (await walkCommits(this.store, [h], { stop: new Set(mb ? (await walkCommits(this.store, [mb], { limit: 100000 })).map((c) => c.oid) : []), limit: 250 })).map((c) => toInfo(c.oid, c.commit)); | |
| 422 | + const ahead = commits.length; | |
| 423 | + const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0; | |
| 424 | + return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) }; | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * Create a commit from file changes without a Git client (used by the web | |
| 429 | + * editor, the REST contents API and AI agents through MCP). | |
| 430 | + */ | |
| 431 | + async commitFiles(input: { | |
| 432 | + branch: string; | |
| 433 | + message: string; | |
| 434 | + changes: FileChange[]; | |
| 435 | + expectedHeadSha?: string; | |
| 436 | + createBranchFrom?: string; | |
| 437 | + author?: { name: string; email: string }; | |
| 438 | + }, caller: Caller): Promise<{ sha: string; branch: string }> { | |
| 439 | + const ref = `refs/heads/${input.branch}`; | |
| 440 | + let parent = this.getRef(ref); | |
| 441 | + if (!parent) { | |
| 442 | + if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom); | |
| 443 | + else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`); | |
| 444 | + } | |
| 445 | + if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent }); | |
| 446 | + const baseTree = parent ? (await readCommit(this.store, parent)).tree : null; | |
| 447 | + const tree = await applyChanges(this.store, baseTree, input.changes); | |
| 448 | + const ts = Math.floor(Date.now() / 1000); | |
| 449 | + const committer = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 450 | + const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer; | |
| 451 | + const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; | |
| 452 | + const sha = await this.store.write( | |
| 453 | + "commit", | |
| 454 | + serializeCommit({ tree: tree === EMPTY_TREE_OID ? tree : tree, parents: parent ? [parent] : [], author, committer, message: input.message.endsWith("\n") ? input.message : `${input.message}\n`, extraHeaders: extra }), | |
| 455 | + ); | |
| 456 | + const old = parent && this.getRef(ref) ? parent : ZERO_OID; | |
| 457 | + const check = await this.checkRefUpdate(ref, old, sha, caller); | |
| 458 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 459 | + if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry"); | |
| 460 | + await this.afterRefUpdate(ref, old, sha, caller); | |
| 461 | + return { sha, branch: input.branch }; | |
| 462 | + } | |
| 463 | + | |
| 464 | + async createRef(ref: string, sha: string, caller: Caller): Promise<void> { | |
| 465 | + if (!this.store.has(sha)) throw new HttpError(422, "object does not exist"); | |
| 466 | + const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller); | |
| 467 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 468 | + if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists"); | |
| 469 | + await this.afterRefUpdate(ref, ZERO_OID, sha, caller); | |
| 470 | + } | |
| 471 | + | |
| 472 | + async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> { | |
| 473 | + const old = this.getRef(ref); | |
| 474 | + if (!old) throw new HttpError(404, "reference not found"); | |
| 475 | + if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward"); | |
| 476 | + const check = await this.checkRefUpdate(ref, old, sha, caller); | |
| 477 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 478 | + if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved"); | |
| 479 | + await this.afterRefUpdate(ref, old, sha, caller); | |
| 480 | + } | |
| 481 | + | |
| 482 | + async deleteRef(ref: string, caller: Caller): Promise<void> { | |
| 483 | + const old = this.getRef(ref); | |
| 484 | + if (!old) throw new HttpError(404, "reference not found"); | |
| 485 | + const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller); | |
| 486 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 487 | + this.casRef(ref, old, null); | |
| 488 | + await this.afterRefUpdate(ref, old, ZERO_OID, caller); | |
| 489 | + } | |
| 490 | + | |
| 491 | + // ---------------------------------------------------------------- protections | |
| 492 | + | |
| 493 | + listProtections(): Array<{ pattern: string; rules: ProtectionRules }> { | |
| 494 | + return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) })); | |
| 495 | + } | |
| 496 | + | |
| 497 | + setProtection(pattern: string, rules: ProtectionRules | null): void { | |
| 498 | + if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules)); | |
| 499 | + else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern); | |
| 500 | + } | |
| 501 | + | |
| 502 | + // ---------------------------------------------------------------- RPC passthroughs (collab + actions) | |
| 503 | + // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here. | |
| 504 | + | |
| 505 | + issues(...a: Parameters<Collab["list"]>) { | |
| 506 | + return this.collab.list(...a); | |
| 507 | + } | |
| 508 | + issue(...a: Parameters<Collab["get"]>) { | |
| 509 | + return this.collab.get(...a); | |
| 510 | + } | |
| 511 | + createIssue(...a: Parameters<Collab["createIssue"]>) { | |
| 512 | + return this.collab.createIssue(...a); | |
| 513 | + } | |
| 514 | + updateIssue(...a: Parameters<Collab["update"]>) { | |
| 515 | + return this.collab.update(...a); | |
| 516 | + } | |
| 517 | + addComment(...a: Parameters<Collab["addComment"]>) { | |
| 518 | + return this.collab.addComment(...a); | |
| 519 | + } | |
| 520 | + editComment(...a: Parameters<Collab["editComment"]>) { | |
| 521 | + return this.collab.editComment(...a); | |
| 522 | + } | |
| 523 | + createPull(...a: Parameters<Collab["createPull"]>) { | |
| 524 | + return this.collab.createPull(...a); | |
| 525 | + } | |
| 526 | + pullFiles(...a: Parameters<Collab["pullFiles"]>) { | |
| 527 | + return this.collab.pullFiles(...a); | |
| 528 | + } | |
| 529 | + pullCommits(...a: Parameters<Collab["pullCommits"]>) { | |
| 530 | + return this.collab.pullCommits(...a); | |
| 531 | + } | |
| 532 | + submitReview(...a: Parameters<Collab["submitReview"]>) { | |
| 533 | + return this.collab.submitReview(...a); | |
| 534 | + } | |
| 535 | + resolveThread(...a: Parameters<Collab["resolveThread"]>) { | |
| 536 | + return this.collab.resolveThread(...a); | |
| 537 | + } | |
| 538 | + mergeability(...a: Parameters<Collab["mergeability"]>) { | |
| 539 | + return this.collab.mergeability(...a); | |
| 540 | + } | |
| 541 | + mergePull(...a: Parameters<Collab["merge"]>) { | |
| 542 | + return this.collab.merge(...a); | |
| 543 | + } | |
| 544 | + requestReviewers(...a: Parameters<Collab["requestReviewers"]>) { | |
| 545 | + return this.collab.requestReviewers(...a); | |
| 546 | + } | |
| 547 | + labels(...a: Parameters<Collab["labels"]>) { | |
| 548 | + return this.collab.labels(...a); | |
| 549 | + } | |
| 550 | + setLabel(...a: Parameters<Collab["setLabel"]>) { | |
| 551 | + return this.collab.setLabel(...a); | |
| 552 | + } | |
| 553 | + checks(...a: Parameters<Collab["checks"]>) { | |
| 554 | + return this.collab.checks(...a); | |
| 555 | + } | |
| 556 | + upsertCheck(...a: Parameters<Collab["upsertCheck"]>) { | |
| 557 | + return this.collab.upsertCheck(...a); | |
| 558 | + } | |
| 559 | + webhooks(...a: Parameters<Collab["webhooks"]>) { | |
| 560 | + return this.collab.webhooks(...a); | |
| 561 | + } | |
| 562 | + createWebhook(...a: Parameters<Collab["createWebhook"]>) { | |
| 563 | + return this.collab.createWebhook(...a); | |
| 564 | + } | |
| 565 | + deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) { | |
| 566 | + return this.collab.deleteWebhook(...a); | |
| 567 | + } | |
| 568 | + recordDelivery(...a: Parameters<Collab["recordDelivery"]>) { | |
| 569 | + return this.collab.recordDelivery(...a); | |
| 570 | + } | |
| 571 | + | |
| 572 | + runs(...a: Parameters<Actions["listRuns"]>) { | |
| 573 | + return this.actions.listRuns(...a); | |
| 574 | + } | |
| 575 | + run(...a: Parameters<Actions["getRun"]>) { | |
| 576 | + return this.actions.getRun(...a); | |
| 577 | + } | |
| 578 | + runJobs(...a: Parameters<Actions["jobs"]>) { | |
| 579 | + return this.actions.jobs(...a); | |
| 580 | + } | |
| 581 | + claimJob(...a: Parameters<Actions["claimJob"]>) { | |
| 582 | + return this.actions.claimJob(...a); | |
| 583 | + } | |
| 584 | + jobStatus(jobId: string): string { | |
| 585 | + return this.actions.getJob(jobId).status; | |
| 586 | + } | |
| 587 | + job(...a: Parameters<Actions["getJob"]>) { | |
| 588 | + return this.actions.getJob(...a); | |
| 589 | + } | |
| 590 | + jobLogs(...a: Parameters<Actions["logs"]>) { | |
| 591 | + return this.actions.logs(...a); | |
| 592 | + } | |
| 593 | + cancelRun(...a: Parameters<Actions["cancel"]>) { | |
| 594 | + return this.actions.cancel(...a); | |
| 595 | + } | |
| 596 | + rerun(...a: Parameters<Actions["rerun"]>) { | |
| 597 | + return this.actions.rerun(...a); | |
| 598 | + } | |
| 599 | + dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) { | |
| 600 | + return this.actions.dispatch(...a); | |
| 601 | + } | |
| 602 | + scheduleTick(...a: Parameters<Actions["schedule"]>) { | |
| 603 | + return this.actions.schedule(...a); | |
| 604 | + } | |
| 605 | + onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) { | |
| 606 | + return this.actions.onPullRequest(...a); | |
| 607 | + } | |
| 608 | + workflows(...a: Parameters<Actions["listWorkflows"]>) { | |
| 609 | + return this.actions.listWorkflows(...a); | |
| 610 | + } | |
| 611 | + runPlan(...a: Parameters<Actions["plan"]>) { | |
| 612 | + return this.actions.plan(...a); | |
| 613 | + } | |
| 614 | + startJobKey(...a: Parameters<Actions["startJobKey"]>) { | |
| 615 | + return this.actions.startJobKey(...a); | |
| 616 | + } | |
| 617 | + jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) { | |
| 618 | + return this.actions.jobTimedOut(...a); | |
| 619 | + } | |
| 620 | + finishRun(...a: Parameters<Actions["finishRun"]>) { | |
| 621 | + return this.actions.finishRun(...a); | |
| 622 | + } | |
| 623 | + oidcClaims(...a: Parameters<Actions["oidcClaims"]>) { | |
| 624 | + return this.actions.oidcClaims(...a); | |
| 625 | + } | |
| 626 | + runnerSpec(...a: Parameters<Actions["runnerSpec"]>) { | |
| 627 | + return this.actions.runnerSpec(...a); | |
| 628 | + } | |
| 629 | + runnerLogs(...a: Parameters<Actions["runnerLogs"]>) { | |
| 630 | + return this.actions.runnerLogs(...a); | |
| 631 | + } | |
| 632 | + runnerStep(...a: Parameters<Actions["runnerStep"]>) { | |
| 633 | + return this.actions.runnerStep(...a); | |
| 634 | + } | |
| 635 | + runnerComplete(...a: Parameters<Actions["runnerComplete"]>) { | |
| 636 | + return this.actions.runnerComplete(...a); | |
| 637 | + } | |
| 638 | + runnerAuth(...a: Parameters<Actions["runnerAuth"]>) { | |
| 639 | + return this.actions.runnerAuth(...a); | |
| 640 | + } | |
| 641 | + containerStopped(...a: Parameters<Actions["containerStopped"]>) { | |
| 642 | + return this.actions.containerStopped(...a); | |
| 643 | + } | |
| 644 | + setSecret(...a: Parameters<Actions["setSecret"]>) { | |
| 645 | + return this.actions.setSecret(...a); | |
| 646 | + } | |
| 647 | + deleteSecret(...a: Parameters<Actions["deleteSecret"]>) { | |
| 648 | + return this.actions.deleteSecret(...a); | |
| 649 | + } | |
| 650 | + secretNames(...a: Parameters<Actions["secretNames"]>) { | |
| 651 | + return this.actions.secretNames(...a); | |
| 652 | + } | |
| 653 | + setVariable(...a: Parameters<Actions["setVariable"]>) { | |
| 654 | + return this.actions.setVariable(...a); | |
| 655 | + } | |
| 656 | + variables(...a: Parameters<Actions["variables"]>) { | |
| 657 | + return this.actions.variables(...a); | |
| 658 | + } | |
| 659 | + cacheLookup(...a: Parameters<Actions["cacheLookup"]>) { | |
| 660 | + return this.actions.cacheLookup(...a); | |
| 661 | + } | |
| 662 | + cacheSaved(...a: Parameters<Actions["cacheSaved"]>) { | |
| 663 | + return this.actions.cacheSaved(...a); | |
| 664 | + } | |
| 665 | + artifactSaved(...a: Parameters<Actions["artifactSaved"]>) { | |
| 666 | + return this.actions.artifactSaved(...a); | |
| 667 | + } | |
| 668 | + artifacts(...a: Parameters<Actions["artifacts"]>) { | |
| 669 | + return this.actions.artifacts(...a); | |
| 670 | + } | |
| 671 | + createSandbox(...a: Parameters<Actions["createSandbox"]>) { | |
| 672 | + return this.actions.createSandbox(...a); | |
| 673 | + } | |
| 674 | + sandbox(...a: Parameters<Actions["getSandbox"]>) { | |
| 675 | + return this.actions.getSandbox(...a); | |
| 676 | + } | |
| 677 | + closeSandbox(...a: Parameters<Actions["closeSandbox"]>) { | |
| 678 | + return this.actions.closeSandbox(...a); | |
| 679 | + } | |
| 680 | + | |
| 681 | + plannedRunError(runId: string, message: string) { | |
| 682 | + return this.actions.plannedError(runId, message); | |
| 683 | + } | |
| 684 | + cacheKeyFor(key: string) { | |
| 685 | + return this.actions.cacheR2Key(key); | |
| 686 | + } | |
| 687 | + artifactKeyFor(jobId: string, name: string) { | |
| 688 | + return this.actions.artifactR2Key(this.actions.runIdForJob(jobId), name); | |
| 689 | + } | |
| 690 | + artifactKeyForRun(runId: string, name: string) { | |
| 691 | + return this.actions.artifactR2Key(runId, name); | |
| 692 | + } | |
| 693 | + | |
| 694 | + // ---------------------------------------------------------------- live updates (WebSockets with hibernation) | |
| 695 | + | |
| 696 | + override async fetch(request: Request): Promise<Response> { | |
| 697 | + if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 }); | |
| 698 | + const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10); | |
| 699 | + const pair = new WebSocketPair(); | |
| 700 | + this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]); | |
| 701 | + return new Response(null, { status: 101, webSocket: pair[0] }); | |
| 702 | + } | |
| 703 | + | |
| 704 | + override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { | |
| 705 | + if (message === "ping") ws.send("pong"); | |
| 706 | + } | |
| 707 | + | |
| 708 | + override async webSocketClose(ws: WebSocket, code: number): Promise<void> { | |
| 709 | + try { | |
| 710 | + ws.close(code, "bye"); | |
| 711 | + } catch { | |
| 712 | + /* already closed */ | |
| 713 | + } | |
| 714 | + } | |
| 715 | + | |
| 716 | + broadcast(topic: string, data: unknown): void { | |
| 717 | + const msg = JSON.stringify({ topic, data }); | |
| 718 | + for (const ws of this.ctx.getWebSockets(topic)) { | |
| 719 | + try { | |
| 720 | + ws.send(msg); | |
| 721 | + } catch { | |
| 722 | + /* client went away */ | |
| 723 | + } | |
| 724 | + } | |
| 725 | + } | |
| 726 | +} | |
| 727 | + | |
| 728 | +export function toInfo(sha: string, c: Commit): CommitInfo { | |
| 729 | + return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig }; | |
| 730 | +} | |
| 731 | + | |
| 732 | +export { fromUtf8 }; |
addedapps/diffinery/src/repo/actions.ts+1048−0View file
| @@ -1,0 +1,1048 @@ | ||
| 1 | +import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core"; | |
| 2 | +import type { Actor, Conclusion, Json, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared"; | |
| 3 | +import { | |
| 4 | + evaluateIf, | |
| 5 | + evaluateValue, | |
| 6 | + expandMatrix, | |
| 7 | + interpolate, | |
| 8 | + isWarning, | |
| 9 | + jobOrder, | |
| 10 | + matchesTrigger, | |
| 11 | + parseWorkflow, | |
| 12 | + type ExprContext, | |
| 13 | + type Job, | |
| 14 | + type StatusFns, | |
| 15 | + type TriggerEvent, | |
| 16 | + type Workflow, | |
| 17 | +} from "@diffinery/workflow-yaml"; | |
| 18 | +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; | |
| 19 | +import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 20 | +import type { Env } from "../env.js"; | |
| 21 | +import type { RepoDO } from "./RepoDO.js"; | |
| 22 | +import { atLeast, type Caller } from "./types.js"; | |
| 23 | + | |
| 24 | +const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; | |
| 25 | +const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; | |
| 26 | + | |
| 27 | +export type RunRow = { | |
| 28 | + id: string; | |
| 29 | + run_number: number; | |
| 30 | + workflow_path: string; | |
| 31 | + workflow_name: string; | |
| 32 | + event: string; | |
| 33 | + ref: string; | |
| 34 | + sha: string; | |
| 35 | + actor: string; | |
| 36 | + status: "queued" | "in_progress" | "completed" | "waiting"; | |
| 37 | + conclusion: Conclusion | "timed_out" | null; | |
| 38 | + created_at: string; | |
| 39 | + updated_at: string; | |
| 40 | + pr_number: number | null; | |
| 41 | + attempt: number; | |
| 42 | + event_payload: string; | |
| 43 | + inputs: string; | |
| 44 | + concurrency_group: string | null; | |
| 45 | + display_title: string; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +export type JobRow = { | |
| 49 | + id: string; | |
| 50 | + run_id: string; | |
| 51 | + job_key: string; | |
| 52 | + name: string; | |
| 53 | + status: "queued" | "waiting" | "in_progress" | "completed"; | |
| 54 | + conclusion: Conclusion | "timed_out" | null; | |
| 55 | + matrix: string; | |
| 56 | + strategy: string; | |
| 57 | + started_at: string | null; | |
| 58 | + completed_at: string | null; | |
| 59 | + outputs: string; | |
| 60 | + steps: string; | |
| 61 | + failure: string | null; | |
| 62 | + token_hash: string | null; | |
| 63 | + summary: string | null; | |
| 64 | + container: string | null; | |
| 65 | + timeout_minutes: number; | |
| 66 | + /** runs-on labels for self-hosted jobs (JSON array); null for Diffinery-hosted containers. */ | |
| 67 | + labels: string | null; | |
| 68 | +}; | |
| 69 | + | |
| 70 | +export type RunInfo = { | |
| 71 | + id: string; | |
| 72 | + number: number; | |
| 73 | + attempt: number; | |
| 74 | + workflowPath: string; | |
| 75 | + workflowName: string; | |
| 76 | + title: string; | |
| 77 | + event: string; | |
| 78 | + ref: string; | |
| 79 | + sha: string; | |
| 80 | + actor: Actor; | |
| 81 | + status: RunRow["status"]; | |
| 82 | + conclusion: RunRow["conclusion"]; | |
| 83 | + createdAt: string; | |
| 84 | + updatedAt: string; | |
| 85 | + prNumber: number | null; | |
| 86 | +}; | |
| 87 | + | |
| 88 | +export type JobInfo = { | |
| 89 | + id: string; | |
| 90 | + runId: string; | |
| 91 | + key: string; | |
| 92 | + name: string; | |
| 93 | + status: JobRow["status"]; | |
| 94 | + conclusion: JobRow["conclusion"]; | |
| 95 | + matrix: Record<string, Json>; | |
| 96 | + startedAt: string | null; | |
| 97 | + completedAt: string | null; | |
| 98 | + outputs: Record<string, string>; | |
| 99 | + steps: Array<StepReport & { index: number }>; | |
| 100 | + failure: JobCompletion["failure"] | null; | |
| 101 | + summary: string | null; | |
| 102 | +}; | |
| 103 | + | |
| 104 | +const toRun = (r: RunRow): RunInfo => ({ | |
| 105 | + id: r.id, | |
| 106 | + number: r.run_number, | |
| 107 | + attempt: r.attempt, | |
| 108 | + workflowPath: r.workflow_path, | |
| 109 | + workflowName: r.workflow_name, | |
| 110 | + title: r.display_title, | |
| 111 | + event: r.event, | |
| 112 | + ref: r.ref, | |
| 113 | + sha: r.sha, | |
| 114 | + actor: JSON.parse(r.actor), | |
| 115 | + status: r.status, | |
| 116 | + conclusion: r.conclusion, | |
| 117 | + createdAt: r.created_at, | |
| 118 | + updatedAt: r.updated_at, | |
| 119 | + prNumber: r.pr_number, | |
| 120 | +}); | |
| 121 | + | |
| 122 | +const toJob = (j: JobRow): JobInfo => ({ | |
| 123 | + id: j.id, | |
| 124 | + runId: j.run_id, | |
| 125 | + key: j.job_key, | |
| 126 | + name: j.name, | |
| 127 | + status: j.status, | |
| 128 | + conclusion: j.conclusion, | |
| 129 | + matrix: JSON.parse(j.matrix), | |
| 130 | + startedAt: j.started_at, | |
| 131 | + completedAt: j.completed_at, | |
| 132 | + outputs: JSON.parse(j.outputs), | |
| 133 | + steps: JSON.parse(j.steps), | |
| 134 | + failure: j.failure ? JSON.parse(j.failure) : null, | |
| 135 | + summary: j.summary, | |
| 136 | +}); | |
| 137 | + | |
| 138 | +const statusFor = (needs: NeedsResult[], cancelled: boolean): StatusFns => ({ | |
| 139 | + success: () => !cancelled && needs.every((n) => n.result === "success" || n.result === "skipped"), | |
| 140 | + failure: () => needs.some((n) => n.result === "failure"), | |
| 141 | + cancelled: () => cancelled, | |
| 142 | + always: () => true, | |
| 143 | +}); | |
| 144 | + | |
| 145 | +/** | |
| 146 | + * GitHub Actions compatible run orchestration state. The Cloudflare Workflow | |
| 147 | + * (`RunWorkflow`) drives a run through these methods; the runner agent inside | |
| 148 | + * each job's container reports back through the runner* methods. | |
| 149 | + */ | |
| 150 | +export class Actions { | |
| 151 | + constructor( | |
| 152 | + private repo: RepoDO, | |
| 153 | + private env: Env, | |
| 154 | + ) {} | |
| 155 | + | |
| 156 | + private get sql() { | |
| 157 | + return this.repo.sql; | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ---------------------------------------------------------------- workflow discovery | |
| 161 | + | |
| 162 | + async loadWorkflows(sha: string): Promise<Array<{ path: string; workflow?: Workflow; errors: string[] }>> { | |
| 163 | + const commit = await readCommit(this.repo.store, sha); | |
| 164 | + const out: Array<{ path: string; workflow?: Workflow; errors: string[] }> = []; | |
| 165 | + for (const dir of WORKFLOW_DIRS) { | |
| 166 | + const entry = await resolvePath(this.repo.store, commit.tree, dir); | |
| 167 | + if (!entry || !isTreeMode(entry.mode)) continue; | |
| 168 | + for (const f of await readTree(this.repo.store, entry.oid)) { | |
| 169 | + if (!/\.ya?ml$/.test(f.name) || isTreeMode(f.mode)) continue; | |
| 170 | + const path = `${dir}/${f.name}`; | |
| 171 | + const o = await this.repo.store.read(f.oid); | |
| 172 | + if (!o) continue; | |
| 173 | + const { workflow, errors } = parseWorkflow(fromUtf8(o.content), path); | |
| 174 | + out.push({ path, workflow, errors: errors.filter((e) => !isWarning(e)).map((e) => `${e.path}${e.line ? `:${e.line}` : ""}: ${e.message}`) }); | |
| 175 | + } | |
| 176 | + } | |
| 177 | + return out; | |
| 178 | + } | |
| 179 | + | |
| 180 | + async listWorkflows(): Promise<Array<{ path: string; name: string; errors: string[]; triggers: string[] }>> { | |
| 181 | + const sha = await this.repo.resolve("HEAD"); | |
| 182 | + if (!sha) return []; | |
| 183 | + return (await this.loadWorkflows(sha)).map((w) => ({ path: w.path, name: w.workflow?.name ?? w.path.split("/").pop()!, errors: w.errors, triggers: w.workflow ? Object.keys(w.workflow.on) : [] })); | |
| 184 | + } | |
| 185 | + | |
| 186 | + private repositoryPayload() { | |
| 187 | + const m = this.repo.meta; | |
| 188 | + return { id: m.repoId, name: m.name, full_name: `${m.owner}/${m.name}`, owner: { login: m.owner }, default_branch: m.defaultBranch, private: true, html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}`, clone_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git` }; | |
| 189 | + } | |
| 190 | + | |
| 191 | + // ---------------------------------------------------------------- triggers | |
| 192 | + | |
| 193 | + async onPush(ev: { ref: string; before: string; after: string; forced: boolean; created: boolean; deleted: boolean }, caller: Caller): Promise<void> { | |
| 194 | + if (ev.deleted) return; | |
| 195 | + const workflows = await this.loadWorkflows(ev.after); | |
| 196 | + let changedFiles: string[] | undefined; | |
| 197 | + if (ev.before !== ZERO_OID && this.repo.store.has(ev.before)) { | |
| 198 | + const [a, b] = await Promise.all([readCommit(this.repo.store, ev.before), readCommit(this.repo.store, ev.after)]); | |
| 199 | + changedFiles = (await diffTrees(this.repo.store, a.tree, b.tree)).map((c) => c.path); | |
| 200 | + } | |
| 201 | + const head = await readCommit(this.repo.store, ev.after); | |
| 202 | + const payload = { | |
| 203 | + ref: ev.ref, | |
| 204 | + before: ev.before, | |
| 205 | + after: ev.after, | |
| 206 | + created: ev.created, | |
| 207 | + deleted: ev.deleted, | |
| 208 | + forced: ev.forced, | |
| 209 | + repository: this.repositoryPayload(), | |
| 210 | + pusher: { name: caller.login }, | |
| 211 | + sender: { login: caller.login, type: caller.kind === "agent" ? "Bot" : "User" }, | |
| 212 | + head_commit: { id: ev.after, message: head.message, timestamp: new Date(head.committer.timestamp * 1000).toISOString(), author: { name: head.author.name, email: head.author.email } }, | |
| 213 | + commits: [{ id: ev.after, message: head.message }], | |
| 214 | + }; | |
| 215 | + if (ev.ref === `refs/heads/${this.repo.meta.defaultBranch}`) await this.syncSchedules(workflows); | |
| 216 | + for (const w of workflows) { | |
| 217 | + if (!w.workflow) { | |
| 218 | + if (w.errors.length) await this.createFailedRun(w.path, "push", ev.ref, ev.after, caller, w.errors); | |
| 219 | + continue; | |
| 220 | + } | |
| 221 | + if (!matchesTrigger(w.workflow, { name: "push", ref: ev.ref, changedFiles })) continue; | |
| 222 | + await this.createRun(w.workflow, { event: "push", ref: ev.ref, sha: ev.after, payload, caller, title: head.message.split("\n")[0] ?? "" }); | |
| 223 | + } | |
| 224 | + } | |
| 225 | + | |
| 226 | + async onPullRequest(action: string, number: number, caller: Caller): Promise<void> { | |
| 227 | + const { issue } = this.repo.collab.get(number); | |
| 228 | + const pull = issue.pull!; | |
| 229 | + const files = await this.repo.collab.pullFiles(number); | |
| 230 | + const workflows = await this.loadWorkflows(pull.headSha); | |
| 231 | + const baseSha = this.repo.getRef(`refs/heads/${pull.base}`); | |
| 232 | + const payload = { | |
| 233 | + action, | |
| 234 | + number, | |
| 235 | + pull_request: { | |
| 236 | + number, | |
| 237 | + title: issue.title, | |
| 238 | + body: issue.body, | |
| 239 | + draft: pull.draft, | |
| 240 | + user: { login: issue.author.login }, | |
| 241 | + head: { ref: pull.head, sha: pull.headSha, repo: this.repositoryPayload() }, | |
| 242 | + base: { ref: pull.base, sha: baseSha, repo: this.repositoryPayload() }, | |
| 243 | + html_url: `${this.env.PUBLIC_URL}/${this.repo.meta.owner}/${this.repo.meta.name}/pull/${number}`, | |
| 244 | + }, | |
| 245 | + repository: this.repositoryPayload(), | |
| 246 | + sender: { login: caller.login }, | |
| 247 | + }; | |
| 248 | + for (const w of workflows) { | |
| 249 | + if (!w.workflow) continue; | |
| 250 | + for (const name of ["pull_request", "pull_request_target"] as const) { | |
| 251 | + const ev: TriggerEvent = { name, action, baseRef: pull.base, changedFiles: files.files.map((f) => f.path) }; | |
| 252 | + if (!matchesTrigger(w.workflow, ev)) continue; | |
| 253 | + await this.createRun(w.workflow, { | |
| 254 | + event: name, | |
| 255 | + ref: `refs/pull/${number}/head`, | |
| 256 | + sha: pull.headSha, | |
| 257 | + payload, | |
| 258 | + caller, | |
| 259 | + prNumber: number, | |
| 260 | + headRef: pull.head, | |
| 261 | + baseRef: pull.base, | |
| 262 | + title: issue.title, | |
| 263 | + }); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> { | |
| 269 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows"); | |
| 270 | + const sha = await this.repo.resolve(ref); | |
| 271 | + if (!sha) throw new HttpError(422, `unknown ref ${ref}`); | |
| 272 | + const all = await this.loadWorkflows(sha); | |
| 273 | + const w = all.find((x) => x.path === workflowFile || x.path.endsWith(`/${workflowFile}`)); | |
| 274 | + if (!w?.workflow) throw new HttpError(404, `workflow ${workflowFile} not found at ${ref}${w?.errors.length ? `: ${w.errors.join("; ")}` : ""}`); | |
| 275 | + if (!w.workflow.on.workflow_dispatch) throw new HttpError(422, "workflow does not have a workflow_dispatch trigger"); | |
| 276 | + const defs = w.workflow.on.workflow_dispatch.inputs ?? {}; | |
| 277 | + const resolved: Record<string, unknown> = {}; | |
| 278 | + for (const [k, d] of Object.entries(defs)) { | |
| 279 | + const v = inputs[k] ?? d.default; | |
| 280 | + if (d.required && (v === undefined || v === "")) throw new HttpError(422, `input '${k}' is required`); | |
| 281 | + if (d.type === "choice" && d.options && v !== undefined && !d.options.includes(String(v))) throw new HttpError(422, `input '${k}' must be one of ${d.options.join(", ")}`); | |
| 282 | + resolved[k] = d.type === "boolean" ? v === true || v === "true" : d.type === "number" ? Number(v) : v; | |
| 283 | + } | |
| 284 | + const fullRef = ref.startsWith("refs/") ? ref : this.repo.getRef(`refs/heads/${ref}`) ? `refs/heads/${ref}` : `refs/tags/${ref}`; | |
| 285 | + const payload = { inputs: resolved, ref: fullRef, repository: this.repositoryPayload(), sender: { login: caller.login }, workflow: w.path }; | |
| 286 | + return (await this.createRun(w.workflow, { event: "workflow_dispatch", ref: fullRef, sha, payload, caller, inputs: resolved, title: w.workflow.name ?? w.path }))!; | |
| 287 | + } | |
| 288 | + | |
| 289 | + async schedule(workflowPath: string, cron: string): Promise<void> { | |
| 290 | + const sha = await this.repo.resolve("HEAD"); | |
| 291 | + if (!sha) return; | |
| 292 | + const w = (await this.loadWorkflows(sha)).find((x) => x.path === workflowPath); | |
| 293 | + if (!w?.workflow || !matchesTrigger(w.workflow, { name: "schedule", cron })) return; | |
| 294 | + const system: Caller = { id: "system", login: "diffinery-scheduler", kind: "system", role: "write" }; | |
| 295 | + await this.createRun(w.workflow, { event: "schedule", ref: this.repo.headRef, sha, payload: { schedule: cron, repository: this.repositoryPayload() }, caller: system, title: w.workflow.name ?? w.path }); | |
| 296 | + } | |
| 297 | + | |
| 298 | + private async syncSchedules(workflows: Array<{ path: string; workflow?: Workflow }>): Promise<void> { | |
| 299 | + const repoId = this.repo.meta.repoId; | |
| 300 | + const stmts = [this.env.DB.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repoId)]; | |
| 301 | + for (const w of workflows) for (const s of w.workflow?.on.schedule ?? []) stmts.push(this.env.DB.prepare("INSERT OR IGNORE INTO schedules (repo_id, workflow_path, cron) VALUES (?, ?, ?)").bind(repoId, w.path, s.cron)); | |
| 302 | + await this.env.DB.batch(stmts); | |
| 303 | + } | |
| 304 | + | |
| 305 | + // ---------------------------------------------------------------- runs | |
| 306 | + | |
| 307 | + private nextRunNumber(): number { | |
| 308 | + this.sql.exec("INSERT INTO counters (name, value) VALUES ('run', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); | |
| 309 | + return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'run'").one().value; | |
| 310 | + } | |
| 311 | + | |
| 312 | + private githubContext(run: RunRow, jobKey: string, token: string): Record<string, unknown> { | |
| 313 | + const m = this.repo.meta; | |
| 314 | + const payload = JSON.parse(run.event_payload) as Record<string, unknown>; | |
| 315 | + const actor = JSON.parse(run.actor) as Actor; | |
| 316 | + const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/head$/, "$1/merge"); | |
| 317 | + const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined; | |
| 318 | + return { | |
| 319 | + event_name: run.event, | |
| 320 | + event: payload, | |
| 321 | + sha: run.sha, | |
| 322 | + ref: run.ref, | |
| 323 | + ref_name: refName, | |
| 324 | + ref_type: run.ref.startsWith("refs/tags/") ? "tag" : "branch", | |
| 325 | + ref_protected: false, | |
| 326 | + head_ref: pr?.head?.ref ?? "", | |
| 327 | + base_ref: pr?.base?.ref ?? "", | |
| 328 | + repository: `${m.owner}/${m.name}`, | |
| 329 | + repository_id: m.repoId, | |
| 330 | + repository_owner: m.owner, | |
| 331 | + repositoryUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, | |
| 332 | + actor: actor.login, | |
| 333 | + triggering_actor: actor.login, | |
| 334 | + run_id: run.id, | |
| 335 | + run_number: String(run.run_number), | |
| 336 | + run_attempt: String(run.attempt), | |
| 337 | + workflow: run.workflow_name, | |
| 338 | + workflow_ref: `${m.owner}/${m.name}/${run.workflow_path}@${run.ref}`, | |
| 339 | + job: jobKey, | |
| 340 | + server_url: this.env.PUBLIC_URL, | |
| 341 | + api_url: `${this.env.PUBLIC_URL}/api/v3`, | |
| 342 | + graphql_url: `${this.env.PUBLIC_URL}/api/graphql`, | |
| 343 | + token, | |
| 344 | + retention_days: "90", | |
| 345 | + secret_source: "Actions", | |
| 346 | + }; | |
| 347 | + } | |
| 348 | + | |
| 349 | + private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Caller, errors: string[]): Promise<void> { | |
| 350 | + const id = newId(); | |
| 351 | + const t = now(); | |
| 352 | + this.sql.exec( | |
| 353 | + "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, conclusion, created_at, updated_at, event_payload, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed', 'failure', ?, ?, ?, ?)", | |
| 354 | + id, | |
| 355 | + this.nextRunNumber(), | |
| 356 | + path, | |
| 357 | + path, | |
| 358 | + event, | |
| 359 | + ref, | |
| 360 | + sha, | |
| 361 | + JSON.stringify({ id: caller.id, login: caller.login, kind: caller.kind }), | |
| 362 | + t, | |
| 363 | + t, | |
| 364 | + "{}", | |
| 365 | + `Invalid workflow file: ${errors[0] ?? path}`, | |
| 366 | + ); | |
| 367 | + const jobId = newId(); | |
| 368 | + this.sql.exec( | |
| 369 | + "INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'invalid', 'Invalid workflow file', 'completed', 'failure', ?, ?, ?)", | |
| 370 | + jobId, | |
| 371 | + id, | |
| 372 | + t, | |
| 373 | + t, | |
| 374 | + JSON.stringify({ message: errors.join("\n") }), | |
| 375 | + ); | |
| 376 | + } | |
| 377 | + | |
| 378 | + async createRun( | |
| 379 | + wf: Workflow, | |
| 380 | + o: { event: string; ref: string; sha: string; payload: Record<string, unknown>; caller: Caller | { id: string; login: string; kind: Actor["kind"] }; prNumber?: number; inputs?: Record<string, unknown>; headRef?: string; baseRef?: string; title: string }, | |
| 381 | + ): Promise<RunInfo | null> { | |
| 382 | + const id = newId(); | |
| 383 | + const t = now(); | |
| 384 | + const run: RunRow = { | |
| 385 | + id, | |
| 386 | + run_number: this.nextRunNumber(), | |
| 387 | + workflow_path: wf.path, | |
| 388 | + workflow_name: wf.name ?? wf.path, | |
| 389 | + event: o.event, | |
| 390 | + ref: o.ref, | |
| 391 | + sha: o.sha, | |
| 392 | + actor: JSON.stringify({ id: o.caller.id, login: o.caller.login, kind: o.caller.kind }), | |
| 393 | + status: "queued", | |
| 394 | + conclusion: null, | |
| 395 | + created_at: t, | |
| 396 | + updated_at: t, | |
| 397 | + pr_number: o.prNumber ?? null, | |
| 398 | + attempt: 1, | |
| 399 | + event_payload: JSON.stringify(o.payload), | |
| 400 | + inputs: JSON.stringify(o.inputs ?? {}), | |
| 401 | + concurrency_group: null, | |
| 402 | + display_title: o.title, | |
| 403 | + }; | |
| 404 | + const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() }; | |
| 405 | + try { | |
| 406 | + if (wf.runName) run.display_title = interpolate(wf.runName, ctx); | |
| 407 | + if (wf.concurrency) run.concurrency_group = interpolate(wf.concurrency.group, ctx); | |
| 408 | + } catch (e) { | |
| 409 | + console.warn("run-name/concurrency evaluation failed", e); | |
| 410 | + } | |
| 411 | + this.sql.exec( | |
| 412 | + "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 413 | + run.id, | |
| 414 | + run.run_number, | |
| 415 | + run.workflow_path, | |
| 416 | + run.workflow_name, | |
| 417 | + run.event, | |
| 418 | + run.ref, | |
| 419 | + run.sha, | |
| 420 | + run.actor, | |
| 421 | + run.status, | |
| 422 | + run.created_at, | |
| 423 | + run.updated_at, | |
| 424 | + run.pr_number, | |
| 425 | + run.attempt, | |
| 426 | + run.event_payload, | |
| 427 | + run.inputs, | |
| 428 | + run.concurrency_group, | |
| 429 | + run.display_title, | |
| 430 | + ); | |
| 431 | + if (run.concurrency_group && wf.concurrency) { | |
| 432 | + const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx); | |
| 433 | + const others = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE concurrency_group = ? AND status != 'completed' AND id != ?", run.concurrency_group, id).toArray(); | |
| 434 | + for (const other of others) if (cancelInProgress === true || cancelInProgress === "true") await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group"); | |
| 435 | + } | |
| 436 | + await this.env.RUN_WORKFLOW.create({ id, params: { repoId: this.repo.meta.repoId, runId: id } }); | |
| 437 | + const m = this.repo.meta; | |
| 438 | + this.repo.emit({ type: "workflow_run", action: "requested", repoId: m.repoId, owner: m.owner, repo: m.name, runId: id, actor: JSON.parse(run.actor), at: t }); | |
| 439 | + this.repo.broadcast("runs", { action: "created", runId: id }); | |
| 440 | + return toRun(run); | |
| 441 | + } | |
| 442 | + | |
| 443 | + private runRow(id: string): RunRow { | |
| 444 | + const r = this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0]; | |
| 445 | + if (!r) throw new HttpError(404, "run not found"); | |
| 446 | + return r; | |
| 447 | + } | |
| 448 | + | |
| 449 | + private jobRow(id: string): JobRow { | |
| 450 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0]; | |
| 451 | + if (!j) throw new HttpError(404, "job not found"); | |
| 452 | + return j; | |
| 453 | + } | |
| 454 | + | |
| 455 | + listRuns(opts: { workflow?: string; branch?: string; status?: string; event?: string; sha?: string; prNumber?: number; limit?: number; offset?: number } = {}): { runs: RunInfo[]; total: number } { | |
| 456 | + const where: string[] = []; | |
| 457 | + const args: unknown[] = []; | |
| 458 | + if (opts.workflow) { | |
| 459 | + where.push("(workflow_path = ? OR workflow_path LIKE ?)"); | |
| 460 | + args.push(opts.workflow, `%/${opts.workflow}`); | |
| 461 | + } | |
| 462 | + if (opts.branch) { | |
| 463 | + where.push("ref = ?"); | |
| 464 | + args.push(`refs/heads/${opts.branch}`); | |
| 465 | + } | |
| 466 | + if (opts.status) { | |
| 467 | + where.push(opts.status === "completed" || opts.status === "queued" || opts.status === "in_progress" ? "status = ?" : "conclusion = ?"); | |
| 468 | + args.push(opts.status); | |
| 469 | + } | |
| 470 | + if (opts.event) { | |
| 471 | + where.push("event = ?"); | |
| 472 | + args.push(opts.event); | |
| 473 | + } | |
| 474 | + if (opts.sha) { | |
| 475 | + where.push("sha = ?"); | |
| 476 | + args.push(opts.sha); | |
| 477 | + } | |
| 478 | + if (opts.prNumber) { | |
| 479 | + where.push("pr_number = ?"); | |
| 480 | + args.push(opts.prNumber); | |
| 481 | + } | |
| 482 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 483 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs ${w}`, ...args).one().n; | |
| 484 | + const rows = this.sql.exec<RunRow>(`SELECT * FROM runs ${w} ORDER BY created_at DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 25, 100), opts.offset ?? 0).toArray(); | |
| 485 | + return { runs: rows.map(toRun), total }; | |
| 486 | + } | |
| 487 | + | |
| 488 | + getRun(id: string): RunInfo & { jobs: JobInfo[] } { | |
| 489 | + return { ...toRun(this.runRow(id)), jobs: this.jobs(id) }; | |
| 490 | + } | |
| 491 | + | |
| 492 | + jobs(runId: string): JobInfo[] { | |
| 493 | + return this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", runId).toArray().map(toJob); | |
| 494 | + } | |
| 495 | + | |
| 496 | + getJob(id: string): JobInfo { | |
| 497 | + return toJob(this.jobRow(id)); | |
| 498 | + } | |
| 499 | + | |
| 500 | + async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> { | |
| 501 | + const j = this.jobRow(jobId); | |
| 502 | + const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray(); | |
| 503 | + if (rows.length === 0 && j.status === "completed") { | |
| 504 | + const archived = await this.env.BUCKET.get(this.logKey(jobId)); | |
| 505 | + if (archived && afterSeq < 0) return { lines: (await archived.json()) as Array<LogLine & { seq: number }>, complete: true }; | |
| 506 | + } | |
| 507 | + return { lines: rows, complete: j.status === "completed" }; | |
| 508 | + } | |
| 509 | + | |
| 510 | + private logKey(jobId: string) { | |
| 511 | + return `repos/${this.repo.meta.repoId}/logs/${jobId}.json`; | |
| 512 | + } | |
| 513 | + | |
| 514 | + // ---------------------------------------------------------------- orchestration (called by RunWorkflow) | |
| 515 | + | |
| 516 | + async plan(runId: string): Promise<{ levels: string[][]; error?: string; concurrencyBlocked: boolean }> { | |
| 517 | + const run = this.runRow(runId); | |
| 518 | + if (run.status === "completed") return { levels: [], error: "run already completed", concurrencyBlocked: false }; | |
| 519 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path); | |
| 520 | + if (!wf?.workflow) return { levels: [], error: `workflow file not found or invalid: ${wf?.errors.join("; ") ?? run.workflow_path}`, concurrencyBlocked: false }; | |
| 521 | + const { order, errors } = jobOrder(wf.workflow); | |
| 522 | + if (errors.length) return { levels: [], error: errors.map((e) => e.message).join("; "), concurrencyBlocked: false }; | |
| 523 | + const blocked = | |
| 524 | + !!run.concurrency_group && | |
| 525 | + this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE concurrency_group = ? AND status = 'in_progress' AND id != ?", run.concurrency_group, runId).one().n > 0; | |
| 526 | + if (!blocked) this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'queued'", now(), runId); | |
| 527 | + return { levels: order, concurrencyBlocked: blocked }; | |
| 528 | + } | |
| 529 | + | |
| 530 | + private needsResults(runId: string, keys: string[]): Record<string, NeedsResult> { | |
| 531 | + const out: Record<string, NeedsResult> = {}; | |
| 532 | + for (const key of keys) { | |
| 533 | + const rows = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ?", runId, key).toArray(); | |
| 534 | + let result: Conclusion = "success"; | |
| 535 | + const outputs: Record<string, string> = {}; | |
| 536 | + if (rows.length === 0) result = "skipped"; | |
| 537 | + for (const r of rows) { | |
| 538 | + Object.assign(outputs, JSON.parse(r.outputs)); | |
| 539 | + const c = r.conclusion === "timed_out" ? "failure" : (r.conclusion ?? "failure"); | |
| 540 | + if (c === "failure") result = "failure"; | |
| 541 | + else if (c === "cancelled" && result !== "failure") result = "cancelled"; | |
| 542 | + else if (c === "skipped" && result === "success" && rows.length === 1) result = "skipped"; | |
| 543 | + } | |
| 544 | + out[key] = { result, outputs }; | |
| 545 | + } | |
| 546 | + return out; | |
| 547 | + } | |
| 548 | + | |
| 549 | + /** | |
| 550 | + * Start every matrix instance of a job. Returns the ids the workflow must wait | |
| 551 | + * for (instances beyond max-parallel are queued and started as others finish). | |
| 552 | + */ | |
| 553 | + async startJobKey(runId: string, jobKey: string): Promise<{ jobIds: string[] }> { | |
| 554 | + const run = this.runRow(runId); | |
| 555 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 556 | + const job = wf.jobs[jobKey]!; | |
| 557 | + const cancelled = run.conclusion === "cancelled"; | |
| 558 | + const needs = this.needsResults(runId, job.needs); | |
| 559 | + const status = statusFor(Object.values(needs), cancelled); | |
| 560 | + const baseCtx: ExprContext = { github: this.githubContext(run, jobKey, ""), needs, inputs: JSON.parse(run.inputs), vars: this.variables(), env: wf.env }; | |
| 561 | + const fail = (name: string, message: string, conclusion: Conclusion = "failure") => { | |
| 562 | + const id = newId(); | |
| 563 | + const t = now(); | |
| 564 | + this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, ?)", id, runId, jobKey, name, conclusion, t, t, conclusion === "failure" ? JSON.stringify({ message }) : null); | |
| 565 | + this.repo.broadcast(`run:${runId}`, { action: "job", jobId: id }); | |
| 566 | + return { jobIds: [] as string[] }; | |
| 567 | + }; | |
| 568 | + let shouldRun: boolean; | |
| 569 | + try { | |
| 570 | + shouldRun = evaluateIf(job.if, baseCtx, status); | |
| 571 | + } catch (e) { | |
| 572 | + return fail(job.name ?? jobKey, `Error evaluating 'if': ${(e as Error).message}`); | |
| 573 | + } | |
| 574 | + if (!shouldRun) return fail(job.name ?? jobKey, "", "skipped"); | |
| 575 | + if (job.uses) return fail(job.name ?? jobKey, `Reusable workflows (jobs.${jobKey}.uses) are not supported yet on Diffinery.`); | |
| 576 | + const labels = job.runsOn.map((l) => String(evaluateValue(l, baseCtx))); | |
| 577 | + const unsupported = labels.filter((l) => !SUPPORTED_LABEL.test(l)); | |
| 578 | + const selfHosted = labels.some((l) => l.toLowerCase() === "self-hosted"); | |
| 579 | + if (unsupported.length && !selfHosted) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.`); | |
| 580 | + if (job.container) return fail(job.name ?? jobKey, "Job containers (jobs.<id>.container) are not supported yet; steps run directly on the Ubuntu runner image."); | |
| 581 | + if (job.services && Object.keys(job.services).length) return fail(job.name ?? jobKey, "Service containers (jobs.<id>.services) are not supported yet on Diffinery runners."); | |
| 582 | + | |
| 583 | + let combos: Array<Record<string, unknown>>; | |
| 584 | + try { | |
| 585 | + combos = expandMatrix(job.strategy?.matrix, baseCtx); | |
| 586 | + } catch (e) { | |
| 587 | + return fail(job.name ?? jobKey, `Error expanding matrix: ${(e as Error).message}`); | |
| 588 | + } | |
| 589 | + if (combos.length > 256) return fail(job.name ?? jobKey, "A matrix may produce at most 256 jobs."); | |
| 590 | + const failFast = evaluateValue(job.strategy?.failFast ?? true, baseCtx) !== false; | |
| 591 | + const maxParallel = Number(evaluateValue(job.strategy?.maxParallel ?? combos.length, baseCtx)) || combos.length; | |
| 592 | + const ids: string[] = []; | |
| 593 | + combos.forEach((matrix, i) => { | |
| 594 | + const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } }; | |
| 595 | + let name = job.name ? interpolate(job.name, ctx) : jobKey; | |
| 596 | + if (!job.name && Object.keys(matrix).length) name = `${jobKey} (${Object.values(matrix).map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ")})`; | |
| 597 | + const timeout = Number(evaluateValue(job.timeoutMinutes, ctx)) || 360; | |
| 598 | + const id = newId(); | |
| 599 | + this.sql.exec( | |
| 600 | + "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)", | |
| 601 | + id, | |
| 602 | + runId, | |
| 603 | + jobKey, | |
| 604 | + name, | |
| 605 | + JSON.stringify(matrix), | |
| 606 | + JSON.stringify(ctx.strategy), | |
| 607 | + Math.min(timeout, 360), | |
| 608 | + selfHosted ? JSON.stringify(labels.map((l) => l.toLowerCase())) : null, | |
| 609 | + ); | |
| 610 | + ids.push(id); | |
| 611 | + }); | |
| 612 | + for (const id of ids.slice(0, maxParallel)) await this.launch(id); | |
| 613 | + this.repo.broadcast(`run:${runId}`, { action: "jobs" }); | |
| 614 | + return { jobIds: ids }; | |
| 615 | + } | |
| 616 | + | |
| 617 | + private checkName(run: RunRow, job: JobRow): string { | |
| 618 | + return `${run.workflow_name} / ${job.name}`; | |
| 619 | + } | |
| 620 | + | |
| 621 | + private async launch(jobId: string): Promise<void> { | |
| 622 | + const job = this.jobRow(jobId); | |
| 623 | + const run = this.runRow(job.run_id); | |
| 624 | + const m = this.repo.meta; | |
| 625 | + if (job.labels) { | |
| 626 | + // Self-hosted: park the job until a registered runner with matching labels claims it. | |
| 627 | + this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId); | |
| 628 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 629 | + return; | |
| 630 | + } | |
| 631 | + const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; | |
| 632 | + const container = `job-${jobId}`; | |
| 633 | + this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); | |
| 634 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 635 | + try { | |
| 636 | + const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container)); | |
| 637 | + await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL }); | |
| 638 | + } catch (e) { | |
| 639 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `Could not start a runner: ${(e as Error).message}` } }); | |
| 640 | + } | |
| 641 | + } | |
| 642 | + | |
| 643 | + async jobTimedOut(jobId: string): Promise<void> { | |
| 644 | + const j = this.jobRow(jobId); | |
| 645 | + if (j.status === "completed") return; | |
| 646 | + await this.stopContainer(j); | |
| 647 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out"); | |
| 648 | + } | |
| 649 | + | |
| 650 | + async containerStopped(jobId: string, exitCode: number | null): Promise<void> { | |
| 651 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", jobId).toArray()[0]; | |
| 652 | + if (!j || j.status === "completed") return; | |
| 653 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The runner stopped unexpectedly (exit code ${exitCode ?? "unknown"}).` } }); | |
| 654 | + } | |
| 655 | + | |
| 656 | + /** | |
| 657 | + * A self-hosted runner asks for work. Returns the oldest waiting job whose | |
| 658 | + * runs-on labels are all offered by the runner, with a fresh job token. | |
| 659 | + */ | |
| 660 | + async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { | |
| 661 | + if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs"); | |
| 662 | + const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]); | |
| 663 | + const waiting = this.sql | |
| 664 | + .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid") | |
| 665 | + .toArray(); | |
| 666 | + const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l))); | |
| 667 | + if (!job) return null; | |
| 668 | + const m = this.repo.meta; | |
| 669 | + const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`; | |
| 670 | + const container = `self-hosted:${runnerName.slice(0, 64)}`; | |
| 671 | + this.sql.exec("UPDATE jobs SET token_hash = ?, container = ? WHERE id = ? AND container IS NULL", await sha256(token), container, job.id); | |
| 672 | + this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); | |
| 673 | + return { jobId: job.id, token, runId: job.run_id, name: job.name }; | |
| 674 | + } | |
| 675 | + | |
| 676 | + private async stopContainer(j: JobRow): Promise<void> { | |
| 677 | + if (!j.container || j.container.startsWith("self-hosted:")) return; | |
| 678 | + try { | |
| 679 | + await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob(); | |
| 680 | + } catch (e) { | |
| 681 | + console.warn("stop container failed", e); | |
| 682 | + } | |
| 683 | + } | |
| 684 | + | |
| 685 | + async finishRun(runId: string): Promise<RunInfo> { | |
| 686 | + const run = this.runRow(runId); | |
| 687 | + const jobs = this.jobs(runId); | |
| 688 | + let conclusion: RunRow["conclusion"] = run.conclusion === "cancelled" ? "cancelled" : "success"; | |
| 689 | + if (conclusion !== "cancelled") { | |
| 690 | + if (jobs.some((j) => j.conclusion === "failure" || j.conclusion === "timed_out")) conclusion = "failure"; | |
| 691 | + else if (jobs.some((j) => j.conclusion === "cancelled")) conclusion = "cancelled"; | |
| 692 | + } | |
| 693 | + const t = now(); | |
| 694 | + this.sql.exec("UPDATE runs SET status = 'completed', conclusion = ?, updated_at = ? WHERE id = ?", conclusion, t, runId); | |
| 695 | + const m = this.repo.meta; | |
| 696 | + this.repo.emit({ type: "workflow_run", action: "completed", repoId: m.repoId, owner: m.owner, repo: m.name, runId, conclusion: conclusion ?? undefined, actor: JSON.parse(run.actor), at: t }); | |
| 697 | + this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion }); | |
| 698 | + this.repo.broadcast("runs", { action: "completed", runId }); | |
| 699 | + return toRun(this.runRow(runId)); | |
| 700 | + } | |
| 701 | + | |
| 702 | + async plannedError(runId: string, message: string): Promise<void> { | |
| 703 | + const t = now(); | |
| 704 | + this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'setup', 'Set up workflow', 'completed', 'failure', ?, ?, ?)", newId(), runId, t, t, JSON.stringify({ message })); | |
| 705 | + } | |
| 706 | + | |
| 707 | + // ---------------------------------------------------------------- cancellation and re-runs | |
| 708 | + | |
| 709 | + private async cancelInternal(runId: string, reason: string): Promise<void> { | |
| 710 | + const run = this.runRow(runId); | |
| 711 | + if (run.status === "completed") return; | |
| 712 | + this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), runId); | |
| 713 | + for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", runId).toArray()) { | |
| 714 | + await this.stopContainer(j); | |
| 715 | + await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: reason } }); | |
| 716 | + } | |
| 717 | + } | |
| 718 | + | |
| 719 | + async cancel(runId: string, caller: Caller): Promise<void> { | |
| 720 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 721 | + await this.cancelInternal(runId, `Cancelled by ${caller.login}`); | |
| 722 | + } | |
| 723 | + | |
| 724 | + async rerun(runId: string, caller: Caller): Promise<RunInfo> { | |
| 725 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 726 | + const old = this.runRow(runId); | |
| 727 | + const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow; | |
| 728 | + if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit"); | |
| 729 | + const run = await this.createRun(wf, { | |
| 730 | + event: old.event, | |
| 731 | + ref: old.ref, | |
| 732 | + sha: old.sha, | |
| 733 | + payload: JSON.parse(old.event_payload), | |
| 734 | + caller, | |
| 735 | + prNumber: old.pr_number ?? undefined, | |
| 736 | + inputs: JSON.parse(old.inputs), | |
| 737 | + title: old.display_title, | |
| 738 | + }); | |
| 739 | + this.sql.exec("UPDATE runs SET attempt = ?, run_number = ? WHERE id = ?", old.attempt + 1, old.run_number, run!.id); | |
| 740 | + return toRun(this.runRow(run!.id)); | |
| 741 | + } | |
| 742 | + | |
| 743 | + // ---------------------------------------------------------------- runner protocol | |
| 744 | + | |
| 745 | + /** Validate a job or sandbox token. Returns what it grants. */ | |
| 746 | + async runnerAuth(token: string): Promise<{ kind: "job"; jobId: string; runId: string } | { kind: "sandbox"; sandboxId: string; caller: Caller } | null> { | |
| 747 | + const parts = token.split("_"); | |
| 748 | + if (parts.length < 4) return null; | |
| 749 | + const hash = await sha256(token); | |
| 750 | + if (parts[0] === "djt") { | |
| 751 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0]; | |
| 752 | + if (!j || j.token_hash !== hash || j.status === "completed") return null; | |
| 753 | + return { kind: "job", jobId: j.id, runId: j.run_id }; | |
| 754 | + } | |
| 755 | + if (parts[0] === "dst") { | |
| 756 | + const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0]; | |
| 757 | + if (!s || s.secret !== hash || s.status !== "running") return null; | |
| 758 | + return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller }; | |
| 759 | + } | |
| 760 | + return null; | |
| 761 | + } | |
| 762 | + | |
| 763 | + private async authJob(jobId: string, token: string): Promise<JobRow> { | |
| 764 | + const auth = await this.runnerAuth(token); | |
| 765 | + if (!auth || auth.kind !== "job" || auth.jobId !== jobId) throw new HttpError(401, "invalid job token"); | |
| 766 | + return this.jobRow(jobId); | |
| 767 | + } | |
| 768 | + | |
| 769 | + /** Claims for an OIDC id token; side-effect free (unlike runnerSpec). */ | |
| 770 | + async oidcClaims(jobId: string, token: string): Promise<{ allowed: boolean; github: Record<string, string> }> { | |
| 771 | + const job = await this.authJob(jobId, token); | |
| 772 | + if (job.status !== "in_progress") throw new HttpError(409, "job is not running"); | |
| 773 | + const run = this.runRow(job.run_id); | |
| 774 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 775 | + const def: Job = wf.jobs[job.job_key]!; | |
| 776 | + const allowed = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all"; | |
| 777 | + const ctx = this.githubContext(run, job.job_key, token) as Record<string, Json>; | |
| 778 | + const github: Record<string, string> = {}; | |
| 779 | + for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v); | |
| 780 | + return { allowed, github }; | |
| 781 | + } | |
| 782 | + | |
| 783 | + async runnerSpec(jobId: string, token: string): Promise<JobSpec> { | |
| 784 | + const job = await this.authJob(jobId, token); | |
| 785 | + const run = this.runRow(job.run_id); | |
| 786 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 787 | + const def: Job = wf.jobs[job.job_key]!; | |
| 788 | + const m = this.repo.meta; | |
| 789 | + const t = now(); | |
| 790 | + this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId); | |
| 791 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 792 | + this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); | |
| 793 | + const secrets: Record<string, string> = { ...(await this.orgSecrets()), ...(await this.repoSecrets()), GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; | |
| 794 | + const idToken = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all"; | |
| 795 | + return { | |
| 796 | + runId: run.id, | |
| 797 | + jobId, | |
| 798 | + jobKey: job.job_key, | |
| 799 | + name: job.name, | |
| 800 | + repo: { owner: m.owner, name: m.name, cloneUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, defaultBranch: m.defaultBranch }, | |
| 801 | + sha: run.sha, | |
| 802 | + ref: run.ref, | |
| 803 | + workflowPath: run.workflow_path, | |
| 804 | + github: this.githubContext(run, job.job_key, token), | |
| 805 | + env: { ...wf.env, ...def.env }, | |
| 806 | + defaults: { run: { ...wf.defaults.run, ...def.defaults.run } }, | |
| 807 | + matrix: JSON.parse(job.matrix), | |
| 808 | + strategy: JSON.parse(job.strategy), | |
| 809 | + needs: this.needsResults(run.id, def.needs), | |
| 810 | + inputs: JSON.parse(run.inputs), | |
| 811 | + vars: this.variables(), | |
| 812 | + secrets, | |
| 813 | + outputs: def.outputs, | |
| 814 | + steps: def.steps.map((s) => ({ ...s, "timeout-minutes": s["timeout-minutes"] === undefined ? undefined : Number(s["timeout-minutes"]) })), | |
| 815 | + timeoutMinutes: job.timeout_minutes, | |
| 816 | + continueOnError: def.continueOnError === true || def.continueOnError === "true", | |
| 817 | + idToken: !!idToken, | |
| 818 | + apiUrl: this.env.PUBLIC_URL, | |
| 819 | + token, | |
| 820 | + }; | |
| 821 | + } | |
| 822 | + | |
| 823 | + async runnerLogs(jobId: string, token: string, lines: LogLine[]): Promise<void> { | |
| 824 | + await this.authJob(jobId, token); | |
| 825 | + let seq = this.sql.exec<{ n: number | null }>("SELECT max(seq) AS n FROM logs WHERE job_id = ?", jobId).one().n ?? -1; | |
| 826 | + const out: Array<LogLine & { seq: number }> = []; | |
| 827 | + for (const l of lines.slice(0, 5000)) { | |
| 828 | + seq++; | |
| 829 | + const text = l.text.length > 16384 ? `${l.text.slice(0, 16384)}…` : l.text; | |
| 830 | + this.sql.exec("INSERT INTO logs (job_id, seq, t, step, text) VALUES (?, ?, ?, ?, ?)", jobId, seq, l.t, l.step, text); | |
| 831 | + out.push({ ...l, text, seq }); | |
| 832 | + } | |
| 833 | + this.repo.broadcast(`job:${jobId}`, { action: "logs", lines: out }); | |
| 834 | + } | |
| 835 | + | |
| 836 | + async runnerStep(jobId: string, token: string, index: number, report: StepReport): Promise<void> { | |
| 837 | + const job = await this.authJob(jobId, token); | |
| 838 | + const steps = JSON.parse(job.steps) as Array<StepReport & { index: number }>; | |
| 839 | + const existing = steps.findIndex((s) => s.index === index); | |
| 840 | + const next = { ...(existing >= 0 ? steps[existing] : {}), ...report, index }; | |
| 841 | + if (existing >= 0) steps[existing] = next; | |
| 842 | + else steps.push(next); | |
| 843 | + steps.sort((a, b) => a.index - b.index); | |
| 844 | + this.sql.exec("UPDATE jobs SET steps = ? WHERE id = ?", JSON.stringify(steps), jobId); | |
| 845 | + this.repo.broadcast(`job:${jobId}`, { action: "step", step: next }); | |
| 846 | + this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId }); | |
| 847 | + } | |
| 848 | + | |
| 849 | + async runnerComplete(jobId: string, token: string, completion: JobCompletion): Promise<void> { | |
| 850 | + await this.authJob(jobId, token); | |
| 851 | + await this.completeJob(jobId, completion); | |
| 852 | + } | |
| 853 | + | |
| 854 | + private async completeJob(jobId: string, completion: JobCompletion, override?: "timed_out"): Promise<void> { | |
| 855 | + const job = this.jobRow(jobId); | |
| 856 | + if (job.status === "completed") return; | |
| 857 | + const run = this.runRow(job.run_id); | |
| 858 | + const t = now(); | |
| 859 | + const conclusion = override ?? completion.conclusion; | |
| 860 | + this.sql.exec( | |
| 861 | + "UPDATE jobs SET status = 'completed', conclusion = ?, completed_at = ?, outputs = ?, failure = ?, summary = ?, token_hash = NULL, started_at = COALESCE(started_at, ?) WHERE id = ?", | |
| 862 | + conclusion, | |
| 863 | + t, | |
| 864 | + JSON.stringify(completion.outputs ?? {}), | |
| 865 | + completion.failure ? JSON.stringify(completion.failure) : null, | |
| 866 | + completion.summary ?? null, | |
| 867 | + t, | |
| 868 | + jobId, | |
| 869 | + ); | |
| 870 | + const m = this.repo.meta; | |
| 871 | + this.repo.upsertCheck({ | |
| 872 | + sha: run.sha, | |
| 873 | + name: this.checkName(run, job), | |
| 874 | + status: "completed", | |
| 875 | + conclusion: conclusion === "timed_out" ? "timed_out" : conclusion, | |
| 876 | + runId: run.id, | |
| 877 | + jobId, | |
| 878 | + detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`, | |
| 879 | + output: completion.failure ? { title: "Failed", summary: completion.failure.message } : { title: conclusion }, | |
| 880 | + }); | |
| 881 | + // Archive logs to R2 and free the Durable Object's storage. | |
| 882 | + const lines = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? ORDER BY seq", jobId).toArray(); | |
| 883 | + if (lines.length) { | |
| 884 | + await this.env.BUCKET.put(this.logKey(jobId), JSON.stringify(lines), { httpMetadata: { contentType: "application/json" } }); | |
| 885 | + this.sql.exec("DELETE FROM logs WHERE job_id = ?", jobId); | |
| 886 | + } | |
| 887 | + this.repo.broadcast(`job:${jobId}`, { action: "completed", conclusion }); | |
| 888 | + this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); | |
| 889 | + | |
| 890 | + // fail-fast: cancel unfinished siblings of a failed matrix job. | |
| 891 | + const siblings = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ? AND id != ? AND status != 'completed'", run.id, job.job_key, jobId).toArray(); | |
| 892 | + const strategy = JSON.parse(job.strategy || "{}") as { "fail-fast"?: boolean }; | |
| 893 | + if (conclusion === "failure" && strategy["fail-fast"] !== false && siblings.length) { | |
| 894 | + for (const s of siblings) { | |
| 895 | + await this.stopContainer(s); | |
| 896 | + await this.completeJob(s.id, { conclusion: "cancelled", outputs: {}, failure: { message: `Cancelled because ${job.name} failed (fail-fast).` } }); | |
| 897 | + } | |
| 898 | + } else { | |
| 899 | + const queued = siblings.find((s) => s.status === "queued"); | |
| 900 | + if (queued) await this.launch(queued.id); | |
| 901 | + } | |
| 902 | + try { | |
| 903 | + const instance = await this.env.RUN_WORKFLOW.get(run.id); | |
| 904 | + await instance.sendEvent({ type: `job-${jobId}`, payload: { conclusion } }); | |
| 905 | + } catch (e) { | |
| 906 | + console.error("failed to notify workflow", e); | |
| 907 | + } | |
| 908 | + } | |
| 909 | + | |
| 910 | + // ---------------------------------------------------------------- secrets and variables | |
| 911 | + | |
| 912 | + private async repoSecrets(): Promise<Record<string, string>> { | |
| 913 | + const out: Record<string, string> = {}; | |
| 914 | + for (const r of this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM secrets").toArray()) { | |
| 915 | + out[r.name] = await decryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, r.ciphertext); | |
| 916 | + } | |
| 917 | + return out; | |
| 918 | + } | |
| 919 | + | |
| 920 | + private async orgSecrets(): Promise<Record<string, string>> { | |
| 921 | + const m = this.repo.meta; | |
| 922 | + if (m.ownerKind !== "org") return {}; | |
| 923 | + const rows = await this.env.DB.prepare("SELECT name, ciphertext FROM org_secrets WHERE org_id = ?").bind(m.ownerId).all<{ name: string; ciphertext: string }>(); | |
| 924 | + const out: Record<string, string> = {}; | |
| 925 | + for (const r of rows.results) out[r.name] = await decryptSecret(this.env.MASTER_KEY, `org:${m.ownerId}`, r.ciphertext); | |
| 926 | + return out; | |
| 927 | + } | |
| 928 | + | |
| 929 | + async setSecret(name: string, value: string): Promise<void> { | |
| 930 | + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); | |
| 931 | + this.sql.exec("INSERT OR REPLACE INTO secrets (name, ciphertext, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, value), now()); | |
| 932 | + } | |
| 933 | + | |
| 934 | + deleteSecret(name: string): void { | |
| 935 | + this.sql.exec("DELETE FROM secrets WHERE name = ?", name.toUpperCase()); | |
| 936 | + } | |
| 937 | + | |
| 938 | + secretNames(): Array<{ name: string; updatedAt: string }> { | |
| 939 | + return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM secrets ORDER BY name").toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); | |
| 940 | + } | |
| 941 | + | |
| 942 | + setVariable(name: string, value: string | null): void { | |
| 943 | + if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name); | |
| 944 | + else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now()); | |
| 945 | + } | |
| 946 | + | |
| 947 | + variables(): Record<string, string> { | |
| 948 | + return Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM variables").toArray().map((r) => [r.name, r.value])); | |
| 949 | + } | |
| 950 | + | |
| 951 | + // ---------------------------------------------------------------- cache and artifacts | |
| 952 | + | |
| 953 | + async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; r2Key: string } | null> { | |
| 954 | + await this.authJob(jobId, token); | |
| 955 | + const exact = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key = ?", key).toArray()[0]; | |
| 956 | + let hit = exact?.key; | |
| 957 | + if (!hit) { | |
| 958 | + for (const prefix of restoreKeys) { | |
| 959 | + const r = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key >= ? AND key < ? ORDER BY created_at DESC LIMIT 1", prefix, `${prefix}`).toArray()[0]; | |
| 960 | + if (r) { | |
| 961 | + hit = r.key; | |
| 962 | + break; | |
| 963 | + } | |
| 964 | + } | |
| 965 | + } | |
| 966 | + if (!hit) return null; | |
| 967 | + this.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", now(), hit); | |
| 968 | + return { key: hit, r2Key: this.cacheR2Key(hit) }; | |
| 969 | + } | |
| 970 | + | |
| 971 | + cacheR2Key(key: string): string { | |
| 972 | + return `repos/${this.repo.meta.repoId}/cache/${encodeURIComponent(key)}`; | |
| 973 | + } | |
| 974 | + | |
| 975 | + async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> { | |
| 976 | + await this.authJob(jobId, token); | |
| 977 | + this.sql.exec("INSERT OR REPLACE INTO caches (key, size, created_at, last_used) VALUES (?, ?, ?, ?)", key, size, now(), now()); | |
| 978 | + // Evict least recently used entries above 10 GB per repository. | |
| 979 | + const evicted: string[] = []; | |
| 980 | + let total = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM caches").one().n ?? 0; | |
| 981 | + while (total > 10 * 1024 ** 3) { | |
| 982 | + const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one(); | |
| 983 | + this.sql.exec("DELETE FROM caches WHERE key = ?", old.key); | |
| 984 | + evicted.push(this.cacheR2Key(old.key)); | |
| 985 | + total -= old.size; | |
| 986 | + } | |
| 987 | + return evicted; | |
| 988 | + } | |
| 989 | + | |
| 990 | + async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ r2Key: string }> { | |
| 991 | + const job = await this.authJob(jobId, token); | |
| 992 | + this.sql.exec("INSERT OR REPLACE INTO artifacts (id, run_id, name, size, created_at) VALUES (?, ?, ?, ?, ?)", newId(), job.run_id, name, size, now()); | |
| 993 | + return { r2Key: this.artifactR2Key(job.run_id, name) }; | |
| 994 | + } | |
| 995 | + | |
| 996 | + artifactR2Key(runId: string, name: string): string { | |
| 997 | + return `repos/${this.repo.meta.repoId}/artifacts/${runId}/${encodeURIComponent(name)}`; | |
| 998 | + } | |
| 999 | + | |
| 1000 | + artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Key: string }> { | |
| 1001 | + return this.sql.exec<{ name: string; size: number; created_at: string }>("SELECT name, size, created_at FROM artifacts WHERE run_id = ? ORDER BY name", runId).toArray().map((a) => ({ name: a.name, size: a.size, createdAt: a.created_at, r2Key: this.artifactR2Key(runId, a.name) })); | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + /** Runs of the same commit; lets a job download artifacts uploaded by earlier jobs. */ | |
| 1005 | + runIdForJob(jobId: string): string { | |
| 1006 | + return this.jobRow(jobId).run_id; | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + // ---------------------------------------------------------------- sandboxes for agents | |
| 1010 | + | |
| 1011 | + async createSandbox(caller: Caller, ref?: string): Promise<{ id: string; ref: string }> { | |
| 1012 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox"); | |
| 1013 | + const sha = await this.repo.resolve(ref ?? "HEAD"); | |
| 1014 | + if (!sha) throw new HttpError(422, "unknown ref"); | |
| 1015 | + const id = newId(); | |
| 1016 | + const m = this.repo.meta; | |
| 1017 | + const token = `dst_${m.repoId}_${id}_${randomToken(24)}`; | |
| 1018 | + const secret = randomToken(32); | |
| 1019 | + this.sql.exec("INSERT INTO sandboxes (id, secret, created_by, ref, created_at, status) VALUES (?, ?, ?, ?, ?, 'running')", id, await sha256(token), JSON.stringify(caller), ref ?? m.defaultBranch, now()); | |
| 1020 | + const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)); | |
| 1021 | + await stub.boot({ | |
| 1022 | + DIFFINERY_CLONE_URL: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, | |
| 1023 | + DIFFINERY_REF: ref ?? m.defaultBranch, | |
| 1024 | + DIFFINERY_JOB_TOKEN: token, | |
| 1025 | + DIFFINERY_SANDBOX_SECRET: secret, | |
| 1026 | + DIFFINERY_API_URL: this.env.PUBLIC_URL, | |
| 1027 | + GIT_AUTHOR_NAME: caller.login, | |
| 1028 | + GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`, | |
| 1029 | + GIT_COMMITTER_NAME: caller.login, | |
| 1030 | + GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`, | |
| 1031 | + }, secret); | |
| 1032 | + return { id, ref: ref ?? m.defaultBranch }; | |
| 1033 | + } | |
| 1034 | + | |
| 1035 | + getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } { | |
| 1036 | + const s = this.sql.exec<{ id: string; ref: string; status: string; created_at: string; created_by: string }>("SELECT * FROM sandboxes WHERE id = ?", id).toArray()[0]; | |
| 1037 | + if (!s) throw new HttpError(404, "sandbox not found"); | |
| 1038 | + const owner = JSON.parse(s.created_by) as Caller; | |
| 1039 | + if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox"); | |
| 1040 | + return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at }; | |
| 1041 | + } | |
| 1042 | + | |
| 1043 | + async closeSandbox(id: string, caller: Caller): Promise<void> { | |
| 1044 | + this.getSandbox(id, caller); | |
| 1045 | + this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); | |
| 1046 | + await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); | |
| 1047 | + } | |
| 1048 | +} |
addedapps/diffinery/src/repo/collab.ts+665−0View file
| @@ -1,0 +1,665 @@ | ||
| 1 | +import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core"; | |
| 2 | +import { HttpError, newId, now, randomToken } from "../lib/util.js"; | |
| 3 | +import type { RepoDO } from "./RepoDO.js"; | |
| 4 | +import { toInfo } from "./RepoDO.js"; | |
| 5 | +import { atLeast, type Caller } from "./types.js"; | |
| 6 | + | |
| 7 | +export type IssueRow = { | |
| 8 | + number: number; | |
| 9 | + kind: "issue" | "pull"; | |
| 10 | + title: string; | |
| 11 | + body: string; | |
| 12 | + state: "open" | "closed"; | |
| 13 | + state_reason: string | null; | |
| 14 | + author_id: string; | |
| 15 | + author_login: string; | |
| 16 | + author_kind: string; | |
| 17 | + assignees: string; | |
| 18 | + labels: string; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + closed_at: string | null; | |
| 22 | + locked: number; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export type PullRow = { | |
| 26 | + number: number; | |
| 27 | + head_ref: string; | |
| 28 | + head_sha: string; | |
| 29 | + base_ref: string; | |
| 30 | + draft: number; | |
| 31 | + merged: number; | |
| 32 | + merged_at: string | null; | |
| 33 | + merged_by: string | null; | |
| 34 | + merge_commit: string | null; | |
| 35 | + requested_reviewers: string; | |
| 36 | + intent: string | null; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +export type Issue = { | |
| 40 | + number: number; | |
| 41 | + kind: "issue" | "pull"; | |
| 42 | + title: string; | |
| 43 | + body: string; | |
| 44 | + state: "open" | "closed"; | |
| 45 | + stateReason: string | null; | |
| 46 | + author: { id: string; login: string; kind: string }; | |
| 47 | + assignees: string[]; | |
| 48 | + labels: string[]; | |
| 49 | + createdAt: string; | |
| 50 | + updatedAt: string; | |
| 51 | + closedAt: string | null; | |
| 52 | + comments: number; | |
| 53 | + pull?: { | |
| 54 | + head: string; | |
| 55 | + headSha: string; | |
| 56 | + base: string; | |
| 57 | + draft: boolean; | |
| 58 | + merged: boolean; | |
| 59 | + mergedAt: string | null; | |
| 60 | + mergedBy: string | null; | |
| 61 | + mergeCommit: string | null; | |
| 62 | + requestedReviewers: string[]; | |
| 63 | + intent: string | null; | |
| 64 | + }; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +export type Comment = { | |
| 68 | + id: string; | |
| 69 | + author: { id: string; login: string; kind: string }; | |
| 70 | + body: string; | |
| 71 | + createdAt: string; | |
| 72 | + updatedAt: string; | |
| 73 | + reviewId: string | null; | |
| 74 | + path: string | null; | |
| 75 | + line: number | null; | |
| 76 | + side: string | null; | |
| 77 | + commitId: string | null; | |
| 78 | + inReplyTo: string | null; | |
| 79 | + resolved: boolean; | |
| 80 | +}; | |
| 81 | + | |
| 82 | +export type Review = { | |
| 83 | + id: string; | |
| 84 | + author: { id: string; login: string; kind: string }; | |
| 85 | + state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED"; | |
| 86 | + body: string; | |
| 87 | + commitId: string; | |
| 88 | + submittedAt: string; | |
| 89 | +}; | |
| 90 | + | |
| 91 | +export type Check = { | |
| 92 | + id: string; | |
| 93 | + sha: string; | |
| 94 | + name: string; | |
| 95 | + status: "queued" | "in_progress" | "completed"; | |
| 96 | + conclusion: string | null; | |
| 97 | + runId: string | null; | |
| 98 | + jobId: string | null; | |
| 99 | + detailsUrl: string | null; | |
| 100 | + output: { title?: string; summary?: string } | null; | |
| 101 | + app: string; | |
| 102 | + startedAt: string | null; | |
| 103 | + completedAt: string | null; | |
| 104 | +}; | |
| 105 | + | |
| 106 | +export type Mergeability = { | |
| 107 | + mergeable: boolean; | |
| 108 | + state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown"; | |
| 109 | + conflicts: string[]; | |
| 110 | + approvals: number; | |
| 111 | + requiredApprovals: number; | |
| 112 | + changesRequested: string[]; | |
| 113 | + checks: { name: string; state: string }[]; | |
| 114 | + blockers: string[]; | |
| 115 | + mergeBase: string | null; | |
| 116 | +}; | |
| 117 | + | |
| 118 | +const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf }); | |
| 119 | + | |
| 120 | +export class Collab { | |
| 121 | + constructor(private repo: RepoDO) {} | |
| 122 | + | |
| 123 | + private get sql() { | |
| 124 | + return this.repo.sql; | |
| 125 | + } | |
| 126 | + | |
| 127 | + private nextNumber(): number { | |
| 128 | + this.sql.exec("INSERT INTO counters (name, value) VALUES ('issue', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); | |
| 129 | + return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'issue'").one().value; | |
| 130 | + } | |
| 131 | + | |
| 132 | + private row(number: number): IssueRow { | |
| 133 | + const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0]; | |
| 134 | + if (!r) throw new HttpError(404, `#${number} not found`); | |
| 135 | + return r; | |
| 136 | + } | |
| 137 | + | |
| 138 | + private pullRow(number: number): PullRow { | |
| 139 | + const r = this.sql.exec<PullRow>("SELECT * FROM pulls WHERE number = ?", number).toArray()[0]; | |
| 140 | + if (!r) throw new HttpError(404, `pull request #${number} not found`); | |
| 141 | + return r; | |
| 142 | + } | |
| 143 | + | |
| 144 | + private toIssue(r: IssueRow): Issue { | |
| 145 | + const comments = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM comments WHERE number = ? AND review_id IS NULL", r.number).one().n; | |
| 146 | + const issue: Issue = { | |
| 147 | + number: r.number, | |
| 148 | + kind: r.kind, | |
| 149 | + title: r.title, | |
| 150 | + body: r.body, | |
| 151 | + state: r.state, | |
| 152 | + stateReason: r.state_reason, | |
| 153 | + author: { id: r.author_id, login: r.author_login, kind: r.author_kind }, | |
| 154 | + assignees: JSON.parse(r.assignees), | |
| 155 | + labels: JSON.parse(r.labels), | |
| 156 | + createdAt: r.created_at, | |
| 157 | + updatedAt: r.updated_at, | |
| 158 | + closedAt: r.closed_at, | |
| 159 | + comments, | |
| 160 | + }; | |
| 161 | + if (r.kind === "pull") { | |
| 162 | + const p = this.pullRow(r.number); | |
| 163 | + issue.pull = { | |
| 164 | + head: p.head_ref, | |
| 165 | + headSha: p.head_sha, | |
| 166 | + base: p.base_ref, | |
| 167 | + draft: !!p.draft, | |
| 168 | + merged: !!p.merged, | |
| 169 | + mergedAt: p.merged_at, | |
| 170 | + mergedBy: p.merged_by, | |
| 171 | + mergeCommit: p.merge_commit, | |
| 172 | + requestedReviewers: JSON.parse(p.requested_reviewers), | |
| 173 | + intent: p.intent, | |
| 174 | + }; | |
| 175 | + } | |
| 176 | + return issue; | |
| 177 | + } | |
| 178 | + | |
| 179 | + // ---------------------------------------------------------------- issues | |
| 180 | + | |
| 181 | + list(opts: { kind?: "issue" | "pull"; state?: "open" | "closed" | "all"; label?: string; author?: string; limit?: number; offset?: number; q?: string } = {}): { items: Issue[]; total: number } { | |
| 182 | + const where: string[] = []; | |
| 183 | + const args: unknown[] = []; | |
| 184 | + if (opts.kind) { | |
| 185 | + where.push("kind = ?"); | |
| 186 | + args.push(opts.kind); | |
| 187 | + } | |
| 188 | + if (opts.state && opts.state !== "all") { | |
| 189 | + where.push("state = ?"); | |
| 190 | + args.push(opts.state); | |
| 191 | + } | |
| 192 | + if (opts.author) { | |
| 193 | + where.push("author_login = ? COLLATE NOCASE"); | |
| 194 | + args.push(opts.author); | |
| 195 | + } | |
| 196 | + if (opts.label) { | |
| 197 | + where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)"); | |
| 198 | + args.push(opts.label); | |
| 199 | + } | |
| 200 | + if (opts.q) { | |
| 201 | + where.push("(title LIKE ? OR body LIKE ?)"); | |
| 202 | + args.push(`%${opts.q}%`, `%${opts.q}%`); | |
| 203 | + } | |
| 204 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 205 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM issues ${w}`, ...args).one().n; | |
| 206 | + const rows = this.sql.exec<IssueRow>(`SELECT * FROM issues ${w} ORDER BY number DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 30, 100), opts.offset ?? 0).toArray(); | |
| 207 | + return { items: rows.map((r) => this.toIssue(r)), total }; | |
| 208 | + } | |
| 209 | + | |
| 210 | + get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } { | |
| 211 | + const issue = this.toIssue(this.row(number)); | |
| 212 | + const comments = this.sql | |
| 213 | + .exec<Record<string, SqlStorageValue>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number) | |
| 214 | + .toArray() | |
| 215 | + .map( | |
| 216 | + (c): Comment => ({ | |
| 217 | + id: c.id as string, | |
| 218 | + author: { id: c.author_id as string, login: c.author_login as string, kind: c.author_kind as string }, | |
| 219 | + body: c.body as string, | |
| 220 | + createdAt: c.created_at as string, | |
| 221 | + updatedAt: c.updated_at as string, | |
| 222 | + reviewId: (c.review_id as string) ?? null, | |
| 223 | + path: (c.path as string) ?? null, | |
| 224 | + line: (c.line as number) ?? null, | |
| 225 | + side: (c.side as string) ?? null, | |
| 226 | + commitId: (c.commit_id as string) ?? null, | |
| 227 | + inReplyTo: (c.in_reply_to as string) ?? null, | |
| 228 | + resolved: !!c.resolved, | |
| 229 | + }), | |
| 230 | + ); | |
| 231 | + const reviews = this.reviews(number); | |
| 232 | + return { issue, comments, reviews }; | |
| 233 | + } | |
| 234 | + | |
| 235 | + private reviews(number: number): Review[] { | |
| 236 | + return this.sql | |
| 237 | + .exec<Record<string, string>>("SELECT * FROM reviews WHERE number = ? ORDER BY submitted_at", number) | |
| 238 | + .toArray() | |
| 239 | + .map((r) => ({ | |
| 240 | + id: r.id!, | |
| 241 | + author: { id: r.author_id!, login: r.author_login!, kind: r.author_kind! }, | |
| 242 | + state: r.state as Review["state"], | |
| 243 | + body: r.body!, | |
| 244 | + commitId: r.commit_id!, | |
| 245 | + submittedAt: r.submitted_at!, | |
| 246 | + })); | |
| 247 | + } | |
| 248 | + | |
| 249 | + createIssue(input: { title: string; body?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue { | |
| 250 | + if (!input.title?.trim()) throw new HttpError(422, "title is required"); | |
| 251 | + const n = this.nextNumber(); | |
| 252 | + const t = now(); | |
| 253 | + this.sql.exec( | |
| 254 | + "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, labels, assignees, created_at, updated_at) VALUES (?, 'issue', ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 255 | + n, | |
| 256 | + input.title.trim(), | |
| 257 | + input.body ?? "", | |
| 258 | + caller.id, | |
| 259 | + caller.login, | |
| 260 | + caller.kind, | |
| 261 | + JSON.stringify(input.labels ?? []), | |
| 262 | + JSON.stringify(input.assignees ?? []), | |
| 263 | + t, | |
| 264 | + t, | |
| 265 | + ); | |
| 266 | + const m = this.repo.meta; | |
| 267 | + this.repo.emit({ type: "issues", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, actor: actorOf(caller), at: t }); | |
| 268 | + this.repo.broadcast("issues", { number: n, action: "opened" }); | |
| 269 | + return this.toIssue(this.row(n)); | |
| 270 | + } | |
| 271 | + | |
| 272 | + update(number: number, patch: { title?: string; body?: string; state?: "open" | "closed"; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue { | |
| 273 | + const r = this.row(number); | |
| 274 | + const isAuthor = r.author_id === caller.id; | |
| 275 | + if (!isAuthor && !atLeast(caller.role, "triage")) throw new HttpError(403, "permission denied"); | |
| 276 | + if (r.kind === "pull" && patch.state === "open" && this.pullRow(number).merged) throw new HttpError(422, "a merged pull request cannot be reopened"); | |
| 277 | + const t = now(); | |
| 278 | + const state = patch.state ?? r.state; | |
| 279 | + this.sql.exec( | |
| 280 | + "UPDATE issues SET title = ?, body = ?, state = ?, state_reason = ?, labels = ?, assignees = ?, updated_at = ?, closed_at = ? WHERE number = ?", | |
| 281 | + patch.title ?? r.title, | |
| 282 | + patch.body ?? r.body, | |
| 283 | + state, | |
| 284 | + patch.state === "closed" ? (patch.stateReason ?? "completed") : patch.state === "open" ? null : r.state_reason, | |
| 285 | + JSON.stringify(patch.labels ?? JSON.parse(r.labels)), | |
| 286 | + JSON.stringify(patch.assignees ?? JSON.parse(r.assignees)), | |
| 287 | + t, | |
| 288 | + state === "closed" ? (r.closed_at ?? t) : null, | |
| 289 | + number, | |
| 290 | + ); | |
| 291 | + const m = this.repo.meta; | |
| 292 | + if (patch.state && patch.state !== r.state) { | |
| 293 | + const action = patch.state === "closed" ? "closed" : "reopened"; | |
| 294 | + if (r.kind === "pull") { | |
| 295 | + const p = this.pullRow(number); | |
| 296 | + this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t }); | |
| 297 | + } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t }); | |
| 298 | + } | |
| 299 | + this.repo.broadcast(`pr:${number}`, { action: "updated" }); | |
| 300 | + return this.toIssue(this.row(number)); | |
| 301 | + } | |
| 302 | + | |
| 303 | + addComment(number: number, body: string, caller: Caller): Comment { | |
| 304 | + const r = this.row(number); | |
| 305 | + if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked"); | |
| 306 | + if (!body?.trim()) throw new HttpError(422, "body is required"); | |
| 307 | + const id = newId(); | |
| 308 | + const t = now(); | |
| 309 | + this.sql.exec("INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id, number, caller.id, caller.login, caller.kind, body, t, t); | |
| 310 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number); | |
| 311 | + const m = this.repo.meta; | |
| 312 | + this.repo.emit({ type: "issue_comment", action: "created", repoId: m.repoId, owner: m.owner, repo: m.name, number, commentId: id, actor: actorOf(caller), at: t }); | |
| 313 | + this.repo.broadcast(`pr:${number}`, { action: "comment", id }); | |
| 314 | + return this.get(number).comments.find((c) => c.id === id)!; | |
| 315 | + } | |
| 316 | + | |
| 317 | + editComment(id: string, body: string, caller: Caller): void { | |
| 318 | + const c = this.sql.exec<{ author_id: string }>("SELECT author_id FROM comments WHERE id = ?", id).toArray()[0]; | |
| 319 | + if (!c) throw new HttpError(404, "comment not found"); | |
| 320 | + if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied"); | |
| 321 | + this.sql.exec("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?", body, now(), id); | |
| 322 | + } | |
| 323 | + | |
| 324 | + labels(): Array<{ name: string; color: string; description: string }> { | |
| 325 | + return this.sql.exec<{ name: string; color: string; description: string }>("SELECT * FROM labels ORDER BY name").toArray(); | |
| 326 | + } | |
| 327 | + | |
| 328 | + setLabel(name: string, color: string | null, description = ""): void { | |
| 329 | + if (color === null) this.sql.exec("DELETE FROM labels WHERE name = ?", name); | |
| 330 | + else this.sql.exec("INSERT OR REPLACE INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.replace(/^#/, ""), description); | |
| 331 | + } | |
| 332 | + | |
| 333 | + // ---------------------------------------------------------------- pull requests | |
| 334 | + | |
| 335 | + async createPull(input: { title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }, caller: Caller): Promise<Issue> { | |
| 336 | + const base = input.base ?? this.repo.meta.defaultBranch; | |
| 337 | + if (input.head === base) throw new HttpError(422, "head and base must be different branches"); | |
| 338 | + const headSha = this.repo.getRef(`refs/heads/${input.head}`); | |
| 339 | + const baseSha = this.repo.getRef(`refs/heads/${base}`); | |
| 340 | + if (!headSha) throw new HttpError(422, `head branch '${input.head}' does not exist`); | |
| 341 | + if (!baseSha) throw new HttpError(422, `base branch '${base}' does not exist`); | |
| 342 | + if (await isAncestor(this.repo.store, headSha, baseSha)) throw new HttpError(422, `no commits between ${base} and ${input.head}`); | |
| 343 | + const dup = this.sql.exec<{ number: number }>("SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'", input.head, base).toArray()[0]; | |
| 344 | + if (dup) throw new HttpError(422, `a pull request already exists for ${input.head}: #${dup.number}`, { number: dup.number }); | |
| 345 | + if (!input.title?.trim()) throw new HttpError(422, "title is required"); | |
| 346 | + const n = this.nextNumber(); | |
| 347 | + const t = now(); | |
| 348 | + this.sql.exec( | |
| 349 | + "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, created_at, updated_at) VALUES (?, 'pull', ?, ?, ?, ?, ?, ?, ?)", | |
| 350 | + n, | |
| 351 | + input.title.trim(), | |
| 352 | + input.body ?? "", | |
| 353 | + caller.id, | |
| 354 | + caller.login, | |
| 355 | + caller.kind, | |
| 356 | + t, | |
| 357 | + t, | |
| 358 | + ); | |
| 359 | + this.sql.exec( | |
| 360 | + "INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft, requested_reviewers, intent) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 361 | + n, | |
| 362 | + input.head, | |
| 363 | + headSha, | |
| 364 | + base, | |
| 365 | + input.draft ? 1 : 0, | |
| 366 | + JSON.stringify(input.reviewers ?? []), | |
| 367 | + input.intent ?? null, | |
| 368 | + ); | |
| 369 | + const m = this.repo.meta; | |
| 370 | + this.repo.emit({ type: "pull_request", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, headRef: input.head, headSha, baseRef: base, actor: actorOf(caller), at: t }); | |
| 371 | + await this.repo.actions.onPullRequest("opened", n, caller).catch((e) => console.error("pr workflow trigger", e)); | |
| 372 | + return this.toIssue(this.row(n)); | |
| 373 | + } | |
| 374 | + | |
| 375 | + requestReviewers(number: number, reviewers: string[], caller: Caller): void { | |
| 376 | + if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied"); | |
| 377 | + const p = this.pullRow(number); | |
| 378 | + const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]); | |
| 379 | + this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify([...set]), number); | |
| 380 | + } | |
| 381 | + | |
| 382 | + /** Called after a branch moves: refresh open pull requests whose head is that branch. */ | |
| 383 | + async syncPullsForBranch(branch: string, sha: string, caller: Caller): Promise<number[]> { | |
| 384 | + const rows = this.sql.exec<PullRow>("SELECT p.* FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND i.state = 'open'", branch).toArray(); | |
| 385 | + const m = this.repo.meta; | |
| 386 | + const out: number[] = []; | |
| 387 | + for (const p of rows) { | |
| 388 | + if (p.head_sha === sha) continue; | |
| 389 | + this.sql.exec("UPDATE pulls SET head_sha = ? WHERE number = ?", sha, p.number); | |
| 390 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number); | |
| 391 | + const prefs = this.repo.protectionFor(p.base_ref); | |
| 392 | + if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha); | |
| 393 | + this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() }); | |
| 394 | + await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e)); | |
| 395 | + this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha }); | |
| 396 | + out.push(p.number); | |
| 397 | + } | |
| 398 | + return out; | |
| 399 | + } | |
| 400 | + | |
| 401 | + async pullFiles(number: number) { | |
| 402 | + const p = this.pullRow(number); | |
| 403 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`) ?? p.head_sha; | |
| 404 | + const head = p.merged && p.merge_commit ? p.head_sha : p.head_sha; | |
| 405 | + const mb = await mergeBase(this.repo.store, baseSha, head); | |
| 406 | + return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) }; | |
| 407 | + } | |
| 408 | + | |
| 409 | + async pullCommits(number: number) { | |
| 410 | + const p = this.pullRow(number); | |
| 411 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); | |
| 412 | + const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null; | |
| 413 | + const stop = new Set<string>(); | |
| 414 | + if (mb) for (const c of await walkCommits(this.repo.store, [mb], { limit: 100000 })) stop.add(c.oid); | |
| 415 | + return (await walkCommits(this.repo.store, [p.head_sha], { stop, limit: 250 })).map((c) => toInfo(c.oid, c.commit)).reverse(); | |
| 416 | + } | |
| 417 | + | |
| 418 | + submitReview( | |
| 419 | + number: number, | |
| 420 | + input: { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; inReplyTo?: string }> }, | |
| 421 | + caller: Caller, | |
| 422 | + ): Review { | |
| 423 | + const r = this.row(number); | |
| 424 | + const p = this.pullRow(number); | |
| 425 | + if (r.state !== "open") throw new HttpError(422, "pull request is closed"); | |
| 426 | + if (input.event !== "COMMENT" && r.author_id === caller.id) throw new HttpError(422, "you cannot approve or request changes on your own pull request"); | |
| 427 | + if (input.event !== "COMMENT" && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to approve or request changes"); | |
| 428 | + if (input.event === "COMMENT" && !input.body?.trim() && !input.comments?.length) throw new HttpError(422, "a comment review needs a body or line comments"); | |
| 429 | + const id = newId(); | |
| 430 | + const t = now(); | |
| 431 | + const state = input.event === "APPROVE" ? "APPROVED" : input.event === "REQUEST_CHANGES" ? "CHANGES_REQUESTED" : "COMMENTED"; | |
| 432 | + this.sql.exec( | |
| 433 | + "INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 434 | + id, | |
| 435 | + number, | |
| 436 | + caller.id, | |
| 437 | + caller.login, | |
| 438 | + caller.kind, | |
| 439 | + state, | |
| 440 | + input.body ?? "", | |
| 441 | + p.head_sha, | |
| 442 | + t, | |
| 443 | + ); | |
| 444 | + for (const c of input.comments ?? []) { | |
| 445 | + this.sql.exec( | |
| 446 | + "INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at, review_id, path, line, side, commit_id, in_reply_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 447 | + newId(), | |
| 448 | + number, | |
| 449 | + caller.id, | |
| 450 | + caller.login, | |
| 451 | + caller.kind, | |
| 452 | + c.body, | |
| 453 | + t, | |
| 454 | + t, | |
| 455 | + id, | |
| 456 | + c.path, | |
| 457 | + c.line, | |
| 458 | + c.side ?? "RIGHT", | |
| 459 | + p.head_sha, | |
| 460 | + c.inReplyTo ?? null, | |
| 461 | + ); | |
| 462 | + } | |
| 463 | + const reviewers = (JSON.parse(p.requested_reviewers) as string[]).filter((x) => x.toLowerCase() !== caller.login.toLowerCase()); | |
| 464 | + this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(reviewers), number); | |
| 465 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number); | |
| 466 | + const m = this.repo.meta; | |
| 467 | + this.repo.emit({ type: "pull_request_review", action: "submitted", repoId: m.repoId, owner: m.owner, repo: m.name, number, state, actor: actorOf(caller), at: t }); | |
| 468 | + this.repo.broadcast(`pr:${number}`, { action: "review", state }); | |
| 469 | + return this.reviews(number).find((x) => x.id === id)!; | |
| 470 | + } | |
| 471 | + | |
| 472 | + resolveThread(commentId: string, resolved: boolean, caller: Caller): void { | |
| 473 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 474 | + this.sql.exec("UPDATE comments SET resolved = ? WHERE id = ? OR in_reply_to = ?", resolved ? 1 : 0, commentId, commentId); | |
| 475 | + } | |
| 476 | + | |
| 477 | + // ---------------------------------------------------------------- checks | |
| 478 | + | |
| 479 | + checks(sha: string): Check[] { | |
| 480 | + return this.sql | |
| 481 | + .exec<Record<string, string | null>>("SELECT * FROM checks WHERE sha = ? ORDER BY name", sha) | |
| 482 | + .toArray() | |
| 483 | + .map((c) => ({ | |
| 484 | + id: c.id!, | |
| 485 | + sha: c.sha!, | |
| 486 | + name: c.name!, | |
| 487 | + status: c.status as Check["status"], | |
| 488 | + conclusion: c.conclusion ?? null, | |
| 489 | + runId: c.run_id ?? null, | |
| 490 | + jobId: c.job_id ?? null, | |
| 491 | + detailsUrl: c.details_url ?? null, | |
| 492 | + output: c.output ? JSON.parse(c.output) : null, | |
| 493 | + app: c.app!, | |
| 494 | + startedAt: c.started_at ?? null, | |
| 495 | + completedAt: c.completed_at ?? null, | |
| 496 | + })); | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** Create or update a check run, keyed by (sha, name, app). */ | |
| 500 | + upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string }): Check { | |
| 501 | + const app = input.app ?? "diffinery-actions"; | |
| 502 | + const existing = this.sql.exec<{ id: string; started_at: string | null }>("SELECT id, started_at FROM checks WHERE sha = ? AND name = ? AND app = ?", input.sha, input.name, app).toArray()[0]; | |
| 503 | + const t = now(); | |
| 504 | + const id = existing?.id ?? newId(); | |
| 505 | + this.sql.exec( | |
| 506 | + "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 507 | + id, | |
| 508 | + input.sha, | |
| 509 | + input.name, | |
| 510 | + input.status, | |
| 511 | + input.conclusion ?? null, | |
| 512 | + input.runId ?? null, | |
| 513 | + input.jobId ?? null, | |
| 514 | + input.detailsUrl ?? null, | |
| 515 | + input.output ? JSON.stringify(input.output) : null, | |
| 516 | + app, | |
| 517 | + existing?.started_at ?? (input.status !== "queued" ? t : null), | |
| 518 | + input.status === "completed" ? t : null, | |
| 519 | + ); | |
| 520 | + for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" }); | |
| 521 | + return this.checks(input.sha).find((c) => c.id === id)!; | |
| 522 | + } | |
| 523 | + | |
| 524 | + // ---------------------------------------------------------------- merging | |
| 525 | + | |
| 526 | + async mergeability(number: number, caller?: Caller): Promise<Mergeability> { | |
| 527 | + const r = this.row(number); | |
| 528 | + const p = this.pullRow(number); | |
| 529 | + const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null }; | |
| 530 | + if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] }; | |
| 531 | + if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] }; | |
| 532 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); | |
| 533 | + if (!baseSha) return { ...result, blockers: [`base branch ${p.base_ref} no longer exists`] }; | |
| 534 | + const mb = await mergeBase(this.repo.store, baseSha, p.head_sha); | |
| 535 | + result.mergeBase = mb; | |
| 536 | + const [bt, ht, mt] = await Promise.all([readCommit(this.repo.store, baseSha), readCommit(this.repo.store, p.head_sha), mb ? readCommit(this.repo.store, mb) : null]); | |
| 537 | + const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree); | |
| 538 | + result.conflicts = merged.conflicts; | |
| 539 | + const rules = this.repo.protectionFor(p.base_ref) ?? {}; | |
| 540 | + result.requiredApprovals = rules.requiredApprovals ?? 0; | |
| 541 | + const latest = new Map<string, Review>(); | |
| 542 | + for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED") latest.set(rv.author.id, rv); | |
| 543 | + for (const rv of latest.values()) { | |
| 544 | + // Agent reviews are advisory: only people's approvals count toward requirements. | |
| 545 | + if (rv.state === "APPROVED" && rv.author.kind !== "agent" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) result.approvals++; | |
| 546 | + if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login); | |
| 547 | + } | |
| 548 | + const checks = this.checks(p.head_sha); | |
| 549 | + result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status })); | |
| 550 | + if (r.author_kind === "agent" && result.requiredApprovals < 1) { | |
| 551 | + // Agent-authored changes always need one human approval unless a rule says otherwise. | |
| 552 | + result.requiredApprovals = 1; | |
| 553 | + } | |
| 554 | + if (p.draft) result.blockers.push("pull request is a draft"); | |
| 555 | + if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`); | |
| 556 | + if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)`); | |
| 557 | + if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`); | |
| 558 | + for (const name of rules.requiredChecks ?? []) { | |
| 559 | + const c = checks.find((x) => x.name === name); | |
| 560 | + if (!c) result.blockers.push(`required check '${name}' has not run`); | |
| 561 | + else if (c.status !== "completed") result.blockers.push(`required check '${name}' is ${c.status}`); | |
| 562 | + else if (c.conclusion !== "success" && c.conclusion !== "skipped" && c.conclusion !== "neutral") result.blockers.push(`required check '${name}' ${c.conclusion}`); | |
| 563 | + } | |
| 564 | + if (rules.requireLinearHistory && mb !== baseSha) result.blockers.push("branch is behind its base; update it (linear history required)"); | |
| 565 | + if (caller) { | |
| 566 | + if (!atLeast(caller.role, "write")) result.blockers.push("write access is required to merge"); | |
| 567 | + const isDefault = p.base_ref === this.repo.meta.defaultBranch; | |
| 568 | + if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge"); | |
| 569 | + } | |
| 570 | + result.mergeable = result.blockers.length === 0; | |
| 571 | + result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked"; | |
| 572 | + return result; | |
| 573 | + } | |
| 574 | + | |
| 575 | + async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean }, caller: Caller) { | |
| 576 | + const p = this.pullRow(number); | |
| 577 | + const r = this.row(number); | |
| 578 | + if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again"); | |
| 579 | + const m = await this.mergeability(number, caller); | |
| 580 | + const rules = this.repo.protectionFor(p.base_ref) ?? {}; | |
| 581 | + const adminBypass = caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft; | |
| 582 | + if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}`, { blockers: m.blockers }); | |
| 583 | + const baseRef = `refs/heads/${p.base_ref}`; | |
| 584 | + const baseSha = this.repo.getRef(baseRef)!; | |
| 585 | + const store = this.repo.store; | |
| 586 | + const method = input.method ?? (rules.requireLinearHistory ? "squash" : "merge"); | |
| 587 | + let newSha: string; | |
| 588 | + const ts = Math.floor(Date.now() / 1000); | |
| 589 | + const sig = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 590 | + if (method === "rebase") { | |
| 591 | + if (!(await isAncestor(store, baseSha, p.head_sha))) throw new HttpError(405, "rebase merge requires the branch to be up to date with its base (fast-forward only for now)"); | |
| 592 | + newSha = p.head_sha; | |
| 593 | + } else { | |
| 594 | + const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), m.mergeBase ? readCommit(store, m.mergeBase) : null]); | |
| 595 | + const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree); | |
| 596 | + if (merged.conflicts.length || !merged.tree) throw new HttpError(405, "merge conflicts"); | |
| 597 | + const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`); | |
| 598 | + const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title); | |
| 599 | + const author = method === "squash" ? { ...sig, name: r.author_login, email: `${r.author_login}@users.noreply.diffinery.com` } : sig; | |
| 600 | + newSha = await store.write( | |
| 601 | + "commit", | |
| 602 | + serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n` }), | |
| 603 | + ); | |
| 604 | + } | |
| 605 | + if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again"); | |
| 606 | + const t = now(); | |
| 607 | + this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number); | |
| 608 | + this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number); | |
| 609 | + const meta = this.repo.meta; | |
| 610 | + this.repo.emit({ type: "pull_request", action: "merged", repoId: meta.repoId, owner: meta.owner, repo: meta.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t }); | |
| 611 | + // A merge is a push to the base branch from the server's point of view. | |
| 612 | + const serverCaller: Caller = { ...caller, role: "admin" }; | |
| 613 | + await this.repo.afterRefUpdate(baseRef, baseSha, newSha, serverCaller); | |
| 614 | + // Close keywords: "fixes #12", "closes #3", "resolves #7". | |
| 615 | + for (const match of `${r.title}\n${r.body}`.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)) { | |
| 616 | + const n = Number(match[1]); | |
| 617 | + try { | |
| 618 | + const target = this.row(n); | |
| 619 | + if (target.kind === "issue" && target.state === "open") this.update(n, { state: "closed", stateReason: "completed" }, serverCaller); | |
| 620 | + } catch { | |
| 621 | + /* unknown issue number */ | |
| 622 | + } | |
| 623 | + } | |
| 624 | + if (input.deleteBranch && p.head_ref !== meta.defaultBranch) { | |
| 625 | + const headRef = `refs/heads/${p.head_ref}`; | |
| 626 | + if (this.repo.getRef(headRef) === p.head_sha) this.repo.casRef(headRef, p.head_sha, ZERO_OID); | |
| 627 | + } | |
| 628 | + this.repo.broadcast(`pr:${number}`, { action: "merged", sha: newSha }); | |
| 629 | + return { merged: true, sha: newSha, method }; | |
| 630 | + } | |
| 631 | + | |
| 632 | + // ---------------------------------------------------------------- webhooks | |
| 633 | + | |
| 634 | + webhooks(): Array<{ id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret: string }> { | |
| 635 | + return this.sql | |
| 636 | + .exec<Record<string, string | number | null>>("SELECT * FROM webhooks ORDER BY created_at") | |
| 637 | + .toArray() | |
| 638 | + .map((w) => ({ | |
| 639 | + id: w.id as string, | |
| 640 | + url: w.url as string, | |
| 641 | + events: JSON.parse(w.events as string), | |
| 642 | + active: !!w.active, | |
| 643 | + createdAt: w.created_at as string, | |
| 644 | + lastStatus: (w.last_status as number) ?? null, | |
| 645 | + lastDeliveryAt: (w.last_delivery_at as string) ?? null, | |
| 646 | + secret: w.secret as string, | |
| 647 | + })); | |
| 648 | + } | |
| 649 | + | |
| 650 | + createWebhook(url: string, events: string[], secret?: string): { id: string; secret: string } { | |
| 651 | + if (!/^https:\/\//.test(url)) throw new HttpError(422, "webhook URL must use https"); | |
| 652 | + const id = newId(); | |
| 653 | + const s = secret ?? randomToken(24); | |
| 654 | + this.sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES (?, ?, ?, ?, ?)", id, url, s, JSON.stringify(events.length ? events : ["*"]), now()); | |
| 655 | + return { id, secret: s }; | |
| 656 | + } | |
| 657 | + | |
| 658 | + deleteWebhook(id: string): void { | |
| 659 | + this.sql.exec("DELETE FROM webhooks WHERE id = ?", id); | |
| 660 | + } | |
| 661 | + | |
| 662 | + recordDelivery(id: string, status: number): void { | |
| 663 | + this.sql.exec("UPDATE webhooks SET last_status = ?, last_delivery_at = ? WHERE id = ?", status, now(), id); | |
| 664 | + } | |
| 665 | +} |
addedapps/diffinery/src/repo/objects.ts+97−0View file
| @@ -1,0 +1,97 @@ | ||
| 1 | +import { CODE_TYPES, TYPE_CODES, deflate, hashObject, inflate, type GitObject, type ObjectStore, type ObjectType } from "@diffinery/git-core"; | |
| 2 | + | |
| 3 | +/** Objects larger than this (compressed) go to R2 instead of SQLite (rows are capped at 2 MB). */ | |
| 4 | +const INLINE_LIMIT = 512 * 1024; | |
| 5 | +const CACHE_BYTES = 24 * 1024 * 1024; | |
| 6 | + | |
| 7 | +export interface RawObject { | |
| 8 | + type: ObjectType; | |
| 9 | + size: number; | |
| 10 | + deflated: Uint8Array; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Git object storage for one repository: SQLite inside the Durable Object for | |
| 15 | + * small objects, R2 for large ones, with an in-memory LRU of inflated objects. | |
| 16 | + */ | |
| 17 | +export class RepoObjectStore implements ObjectStore { | |
| 18 | + private cache = new Map<string, GitObject>(); | |
| 19 | + private cacheBytes = 0; | |
| 20 | + | |
| 21 | + constructor(private sql: SqlStorage, private bucket: R2Bucket, private repoId: string) {} | |
| 22 | + | |
| 23 | + private r2Key(oid: string) { | |
| 24 | + return `repos/${this.repoId}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`; | |
| 25 | + } | |
| 26 | + | |
| 27 | + private remember(oid: string, obj: GitObject) { | |
| 28 | + if (obj.content.length > 4 * 1024 * 1024) return; | |
| 29 | + this.cache.set(oid, obj); | |
| 30 | + this.cacheBytes += obj.content.length; | |
| 31 | + while (this.cacheBytes > CACHE_BYTES) { | |
| 32 | + const [k, v] = this.cache.entries().next().value as [string, GitObject]; | |
| 33 | + this.cache.delete(k); | |
| 34 | + this.cacheBytes -= v.content.length; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + has(oid: string): boolean { | |
| 39 | + return this.sql.exec("SELECT 1 FROM objects WHERE oid = ?", oid).toArray().length > 0; | |
| 40 | + } | |
| 41 | + | |
| 42 | + async readRaw(oid: string): Promise<RawObject | null> { | |
| 43 | + const row = this.sql.exec<{ type: number; size: number; data: ArrayBuffer | null; r2: number }>("SELECT type, size, data, r2 FROM objects WHERE oid = ?", oid).toArray()[0]; | |
| 44 | + if (!row) return null; | |
| 45 | + let deflated: Uint8Array; | |
| 46 | + if (row.r2) { | |
| 47 | + const o = await this.bucket.get(this.r2Key(oid)); | |
| 48 | + if (!o) throw new Error(`object ${oid} missing from R2`); | |
| 49 | + deflated = new Uint8Array(await o.arrayBuffer()); | |
| 50 | + } else { | |
| 51 | + deflated = new Uint8Array(row.data!); | |
| 52 | + } | |
| 53 | + return { type: CODE_TYPES[row.type]!, size: row.size, deflated }; | |
| 54 | + } | |
| 55 | + | |
| 56 | + async read(oid: string): Promise<GitObject | null> { | |
| 57 | + const hit = this.cache.get(oid); | |
| 58 | + if (hit) { | |
| 59 | + this.cache.delete(oid); | |
| 60 | + this.cache.set(oid, hit); | |
| 61 | + return hit; | |
| 62 | + } | |
| 63 | + const raw = await this.readRaw(oid); | |
| 64 | + if (!raw) return null; | |
| 65 | + const obj = { type: raw.type, content: inflate(raw.deflated) }; | |
| 66 | + this.remember(oid, obj); | |
| 67 | + return obj; | |
| 68 | + } | |
| 69 | + | |
| 70 | + async write(type: ObjectType, content: Uint8Array): Promise<string> { | |
| 71 | + const oid = await hashObject(type, content); | |
| 72 | + await this.writeKnown(oid, type, content); | |
| 73 | + return oid; | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** Store an object whose oid is already verified. */ | |
| 77 | + async writeKnown(oid: string, type: ObjectType, content: Uint8Array): Promise<void> { | |
| 78 | + if (this.has(oid)) return; | |
| 79 | + const deflated = deflate(content); | |
| 80 | + if (deflated.length > INLINE_LIMIT) { | |
| 81 | + await this.bucket.put(this.r2Key(oid), deflated); | |
| 82 | + this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, NULL, 1)", oid, TYPE_CODES[type], content.length); | |
| 83 | + } else { | |
| 84 | + this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, ?, 0)", oid, TYPE_CODES[type], content.length, deflated); | |
| 85 | + } | |
| 86 | + this.remember(oid, { type, content }); | |
| 87 | + } | |
| 88 | + | |
| 89 | + count(): number { | |
| 90 | + return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM objects").one().n; | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** Approximate stored bytes (compressed). */ | |
| 94 | + sizeBytes(): number { | |
| 95 | + return this.sql.exec<{ n: number | null }>("SELECT sum(length(data)) AS n FROM objects").one().n ?? 0; | |
| 96 | + } | |
| 97 | +} |
addedapps/diffinery/src/repo/schema.ts+66−0View file
| @@ -1,0 +1,66 @@ | ||
| 1 | +/** SQLite schema of a repository Durable Object. Applied idempotently on construction. */ | |
| 2 | +export const REPO_SCHEMA = ` | |
| 3 | +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); | |
| 4 | +CREATE TABLE IF NOT EXISTS refs (name TEXT PRIMARY KEY, oid TEXT NOT NULL); | |
| 5 | +CREATE TABLE IF NOT EXISTS objects ( | |
| 6 | + oid TEXT PRIMARY KEY, type INTEGER NOT NULL, size INTEGER NOT NULL, data BLOB, r2 INTEGER NOT NULL DEFAULT 0 | |
| 7 | +); | |
| 8 | +CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL); | |
| 9 | + | |
| 10 | +CREATE TABLE IF NOT EXISTS issues ( | |
| 11 | + number INTEGER PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', | |
| 12 | + state TEXT NOT NULL DEFAULT 'open', state_reason TEXT, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 13 | + author_kind TEXT NOT NULL DEFAULT 'user', assignees TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', | |
| 14 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, locked INTEGER NOT NULL DEFAULT 0 | |
| 15 | +); | |
| 16 | +CREATE TABLE IF NOT EXISTS pulls ( | |
| 17 | + number INTEGER PRIMARY KEY, head_ref TEXT NOT NULL, head_sha TEXT NOT NULL, base_ref TEXT NOT NULL, | |
| 18 | + draft INTEGER NOT NULL DEFAULT 0, merged INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_by TEXT, | |
| 19 | + merge_commit TEXT, requested_reviewers TEXT NOT NULL DEFAULT '[]', intent TEXT | |
| 20 | +); | |
| 21 | +CREATE INDEX IF NOT EXISTS pulls_head ON pulls(head_ref); | |
| 22 | +CREATE TABLE IF NOT EXISTS comments ( | |
| 23 | + id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 24 | + author_kind TEXT NOT NULL DEFAULT 'user', body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 25 | + review_id TEXT, path TEXT, line INTEGER, side TEXT, commit_id TEXT, in_reply_to TEXT, resolved INTEGER NOT NULL DEFAULT 0 | |
| 26 | +); | |
| 27 | +CREATE INDEX IF NOT EXISTS comments_number ON comments(number); | |
| 28 | +CREATE TABLE IF NOT EXISTS reviews ( | |
| 29 | + id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 30 | + author_kind TEXT NOT NULL DEFAULT 'user', state TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', commit_id TEXT NOT NULL, | |
| 31 | + submitted_at TEXT NOT NULL | |
| 32 | +); | |
| 33 | +CREATE INDEX IF NOT EXISTS reviews_number ON reviews(number); | |
| 34 | +CREATE TABLE IF NOT EXISTS labels (name TEXT PRIMARY KEY, color TEXT NOT NULL, description TEXT NOT NULL DEFAULT ''); | |
| 35 | +CREATE TABLE IF NOT EXISTS protections (pattern TEXT PRIMARY KEY, rules TEXT NOT NULL); | |
| 36 | +CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, ciphertext TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 37 | +CREATE TABLE IF NOT EXISTS variables (name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 38 | +CREATE TABLE IF NOT EXISTS webhooks ( | |
| 39 | + id TEXT PRIMARY KEY, url TEXT NOT NULL, secret TEXT NOT NULL, events TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, | |
| 40 | + created_at TEXT NOT NULL, last_status INTEGER, last_delivery_at TEXT | |
| 41 | +); | |
| 42 | +CREATE TABLE IF NOT EXISTS checks ( | |
| 43 | + id TEXT PRIMARY KEY, sha TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, conclusion TEXT, | |
| 44 | + run_id TEXT, job_id TEXT, details_url TEXT, output TEXT, app TEXT NOT NULL DEFAULT 'diffinery-actions', | |
| 45 | + started_at TEXT, completed_at TEXT | |
| 46 | +); | |
| 47 | +CREATE INDEX IF NOT EXISTS checks_sha ON checks(sha); | |
| 48 | +CREATE TABLE IF NOT EXISTS runs ( | |
| 49 | + id TEXT PRIMARY KEY, run_number INTEGER NOT NULL, workflow_path TEXT NOT NULL, workflow_name TEXT NOT NULL, | |
| 50 | + event TEXT NOT NULL, ref TEXT NOT NULL, sha TEXT NOT NULL, actor TEXT NOT NULL, status TEXT NOT NULL, | |
| 51 | + conclusion TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, pr_number INTEGER, attempt INTEGER NOT NULL DEFAULT 1, | |
| 52 | + event_payload TEXT NOT NULL, inputs TEXT NOT NULL DEFAULT '{}', concurrency_group TEXT, display_title TEXT NOT NULL DEFAULT '' | |
| 53 | +); | |
| 54 | +CREATE INDEX IF NOT EXISTS runs_created ON runs(created_at); | |
| 55 | +CREATE TABLE IF NOT EXISTS jobs ( | |
| 56 | + id TEXT PRIMARY KEY, run_id TEXT NOT NULL, job_key TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, | |
| 57 | + conclusion TEXT, matrix TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT, | |
| 58 | + outputs TEXT NOT NULL DEFAULT '{}', steps TEXT NOT NULL DEFAULT '[]', failure TEXT, token_hash TEXT, summary TEXT, | |
| 59 | + container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360, labels TEXT | |
| 60 | +); | |
| 61 | +CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id); | |
| 62 | +CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq)); | |
| 63 | +CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL); | |
| 64 | +CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, name)); | |
| 65 | +CREATE TABLE IF NOT EXISTS sandboxes (id TEXT PRIMARY KEY, secret TEXT NOT NULL, created_by TEXT NOT NULL, ref TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL); | |
| 66 | +`; |
addedapps/diffinery/src/repo/types.ts+51−0View file
| @@ -1,0 +1,51 @@ | ||
| 1 | +import type { Actor } from "@diffinery/shared"; | |
| 2 | + | |
| 3 | +export type Role = "read" | "triage" | "write" | "maintain" | "admin"; | |
| 4 | +export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 }; | |
| 5 | +export const atLeast = (role: Role | null | undefined, needed: Role): boolean => !!role && ROLE_LEVEL[role] >= ROLE_LEVEL[needed]; | |
| 6 | + | |
| 7 | +/** The authenticated caller, with their effective role on this repository. */ | |
| 8 | +export interface Caller extends Actor { | |
| 9 | + role: Role; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface RepoMeta { | |
| 13 | + repoId: string; | |
| 14 | + ownerId: string; | |
| 15 | + owner: string; | |
| 16 | + name: string; | |
| 17 | + defaultBranch: string; | |
| 18 | + /** Owner kind decides whether org secrets apply. */ | |
| 19 | + ownerKind: "user" | "org"; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface ProtectionRules { | |
| 23 | + requirePullRequest?: boolean; | |
| 24 | + requiredApprovals?: number; | |
| 25 | + dismissStaleReviews?: boolean; | |
| 26 | + requiredChecks?: string[]; | |
| 27 | + allowForcePush?: boolean; | |
| 28 | + allowDeletion?: boolean; | |
| 29 | + requireLinearHistory?: boolean; | |
| 30 | + /** When false (default), admins may bypass the rules. */ | |
| 31 | + enforceAdmins?: boolean; | |
| 32 | + /** Agents may push directly to matching branches. Default false. */ | |
| 33 | + agentsMayPush?: boolean; | |
| 34 | + /** Agents may merge pull requests into matching branches. Default false. */ | |
| 35 | + agentsMayMerge?: boolean; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function globToRegExp(pattern: string): RegExp { | |
| 39 | + let re = ""; | |
| 40 | + for (let i = 0; i < pattern.length; i++) { | |
| 41 | + const c = pattern[i]!; | |
| 42 | + if (c === "*") { | |
| 43 | + if (pattern[i + 1] === "*") { | |
| 44 | + re += ".*"; | |
| 45 | + i++; | |
| 46 | + } else re += "[^/]*"; | |
| 47 | + } else if (c === "?") re += "[^/]"; | |
| 48 | + else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); | |
| 49 | + } | |
| 50 | + return new RegExp(`^${re}$`); | |
| 51 | +} |
addedapps/diffinery/src/runner/containers.ts+70−0View file
| @@ -1,0 +1,70 @@ | ||
| 1 | +import { Container } from "@cloudflare/containers"; | |
| 2 | +import type { StopParams } from "@cloudflare/containers"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One container per workflow job. The runner agent inside fetches its job spec | |
| 7 | + * from the Diffinery API, runs the steps and reports back, then exits. | |
| 8 | + */ | |
| 9 | +export class RunnerContainer extends Container<Env> { | |
| 10 | + // Jobs make no inbound requests, so activity-based sleep must not stop them. | |
| 11 | + override sleepAfter = "6h"; | |
| 12 | + override enableInternet = true; | |
| 13 | + | |
| 14 | + async startJob(input: { repoId: string; jobId: string; token: string; apiUrl: string }): Promise<void> { | |
| 15 | + await this.ctx.storage.put("job", { repoId: input.repoId, jobId: input.jobId }); | |
| 16 | + await this.start({ | |
| 17 | + envVars: { | |
| 18 | + DIFFINERY_API_URL: input.apiUrl, | |
| 19 | + DIFFINERY_JOB_ID: input.jobId, | |
| 20 | + DIFFINERY_JOB_TOKEN: input.token, | |
| 21 | + }, | |
| 22 | + }); | |
| 23 | + } | |
| 24 | + | |
| 25 | + async stopJob(): Promise<void> { | |
| 26 | + if (this.ctx.container?.running) await this.stop("SIGTERM"); | |
| 27 | + } | |
| 28 | + | |
| 29 | + override async onStop(params: StopParams): Promise<void> { | |
| 30 | + const job = await this.ctx.storage.get<{ repoId: string; jobId: string }>("job"); | |
| 31 | + if (!job) return; | |
| 32 | + const repo = this.env.REPO.get(this.env.REPO.idFromName(job.repoId)); | |
| 33 | + await repo.containerStopped(job.jobId, params.exitCode ?? null); | |
| 34 | + await this.ctx.storage.delete("job"); | |
| 35 | + } | |
| 36 | + | |
| 37 | + override onError(error: unknown): void { | |
| 38 | + console.error("runner container error", error); | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Long-lived task sandbox for AI agents: the repo checked out with toolchains, | |
| 44 | + * and an HTTP API (exec, read/write files) on port 8080. | |
| 45 | + */ | |
| 46 | +export class SandboxContainer extends Container<Env> { | |
| 47 | + override defaultPort = 8080; | |
| 48 | + override sleepAfter = "30m"; | |
| 49 | + override enableInternet = true; | |
| 50 | + | |
| 51 | + async boot(envVars: Record<string, string>, secret: string): Promise<void> { | |
| 52 | + await this.ctx.storage.put("secret", secret); | |
| 53 | + await this.startAndWaitForPorts({ ports: 8080, startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] } }); | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */ | |
| 57 | + async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> { | |
| 58 | + const secret = await this.ctx.storage.get<string>("secret"); | |
| 59 | + const res = await this.containerFetch(`http://sandbox${path}`, { | |
| 60 | + method: "POST", | |
| 61 | + headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, | |
| 62 | + body: body || "{}", | |
| 63 | + }); | |
| 64 | + return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + async shutdown(): Promise<void> { | |
| 68 | + if (this.ctx.container?.running) await this.stop(); | |
| 69 | + } | |
| 70 | +} |
addedapps/diffinery/src/runner/workflow.ts+48−0View file
| @@ -1,0 +1,48 @@ | ||
| 1 | +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | + | |
| 4 | +export interface RunParams { | |
| 5 | + repoId: string; | |
| 6 | + runId: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Durable orchestration of one workflow run: resolves the job graph level by | |
| 11 | + * level, starts each job's containers, and waits for the runner to report | |
| 12 | + * completion. Retries, sleeps and waits survive restarts of the platform. | |
| 13 | + */ | |
| 14 | +export class RunWorkflow extends WorkflowEntrypoint<Env, RunParams> { | |
| 15 | + override async run(event: WorkflowEvent<RunParams>, step: WorkflowStep): Promise<unknown> { | |
| 16 | + const { repoId, runId } = event.payload; | |
| 17 | + const repo = () => this.env.REPO.get(this.env.REPO.idFromName(repoId)); | |
| 18 | + | |
| 19 | + let plan = await step.do("plan", () => repo().runPlan(runId)); | |
| 20 | + // Concurrency groups without cancel-in-progress: wait for the running run to finish. | |
| 21 | + for (let i = 0; plan.concurrencyBlocked && i < 360; i++) { | |
| 22 | + await step.sleep(`concurrency-wait-${i}`, "1 minute"); | |
| 23 | + plan = await step.do(`plan-${i}`, () => repo().runPlan(runId)); | |
| 24 | + } | |
| 25 | + if (plan.error) { | |
| 26 | + await step.do("record-error", () => repo().plannedRunError(runId, plan.error!)); | |
| 27 | + return step.do("finish", () => repo().finishRun(runId)); | |
| 28 | + } | |
| 29 | + | |
| 30 | + for (const [levelIndex, level] of plan.levels.entries()) { | |
| 31 | + const started = await Promise.all(level.map((key) => step.do(`start ${key}`, { retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } }, () => repo().startJobKey(runId, key)))); | |
| 32 | + const jobIds = started.flatMap((s) => s.jobIds); | |
| 33 | + await Promise.all( | |
| 34 | + jobIds.map(async (jobId) => { | |
| 35 | + const status = await step.do(`job ${jobId} info`, () => repo().jobStatus(jobId)); | |
| 36 | + if (status === "completed") return; | |
| 37 | + try { | |
| 38 | + await step.waitForEvent(`wait ${jobId}`, { type: `job-${jobId}`, timeout: "6 hours" }); | |
| 39 | + } catch { | |
| 40 | + await step.do(`timeout ${jobId}`, () => repo().jobTimedOut(jobId)); | |
| 41 | + } | |
| 42 | + }), | |
| 43 | + ); | |
| 44 | + void levelIndex; | |
| 45 | + } | |
| 46 | + return step.do("finish", () => repo().finishRun(runId)); | |
| 47 | + } | |
| 48 | +} |
addedapps/diffinery/src/web/account.tsx+1760−0View file
| @@ -1,0 +1,1760 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 6 | +import { loadRepo } from "../http/common.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import type { Role } from "../repo/types.js"; | |
| 9 | +import { ICONS } from "./assets.js"; | |
| 10 | +import { csrfToken } from "./csrf.js"; | |
| 11 | +import { Auth } from "../auth/sessions.js"; | |
| 12 | +import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; | |
| 13 | +import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 14 | + | |
| 15 | +// ---------------------------------------------------------------- shared bits | |
| 16 | + | |
| 17 | +const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) => | |
| 18 | + repos.length ? ( | |
| 19 | + <ul class="list"> | |
| 20 | + {repos.map((r) => ( | |
| 21 | + <li> | |
| 22 | + <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span> | |
| 23 | + <div class="grow"> | |
| 24 | + <a class="title" href={repoUrl(r)}> | |
| 25 | + {showOwner ? `${r.owner_login}/` : ""} | |
| 26 | + {r.name} | |
| 27 | + </a>{" "} | |
| 28 | + <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null} | |
| 29 | + {r.description ? <div class="meta">{r.description}</div> : null} | |
| 30 | + </div> | |
| 31 | + <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span> | |
| 32 | + </li> | |
| 33 | + ))} | |
| 34 | + </ul> | |
| 35 | + ) : ( | |
| 36 | + <div class="empty">{empty ?? "No repositories yet."}</div> | |
| 37 | + ); | |
| 38 | + | |
| 39 | +function parsePolicy(a: Account): { maxRole?: Role } { | |
| 40 | + try { | |
| 41 | + return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {}; | |
| 42 | + } catch { | |
| 43 | + return {}; | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> { | |
| 48 | + return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */ | |
| 52 | +async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> { | |
| 53 | + if (agent.kind !== "agent" || !agent.owner_id) return false; | |
| 54 | + if (me.site_admin || agent.owner_id === me.id) return true; | |
| 55 | + const role = await c.get("dir").orgRole(agent.owner_id, me.id); | |
| 56 | + return role === "owner" || role === "admin"; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const SCOPES: TokenScope[] = ["read", "write", "admin"]; | |
| 60 | + | |
| 61 | +// ---------------------------------------------------------------- landing / dashboard | |
| 62 | + | |
| 63 | +const Landing: FC = () => ( | |
| 64 | + <div> | |
| 65 | + <section class="hero"> | |
| 66 | + <h1>Diffinery</h1> | |
| 67 | + <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p> | |
| 68 | + <p class="row" style="justify-content:center"> | |
| 69 | + <a class="btn primary" href="/login"> | |
| 70 | + Sign in with a passkey | |
| 71 | + </a> | |
| 72 | + </p> | |
| 73 | + <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p> | |
| 74 | + </section> | |
| 75 | + <div class="features"> | |
| 76 | + <div class="box"> | |
| 77 | + <h3>{raw(ICONS.repo)} Git, natively</h3> | |
| 78 | + <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p> | |
| 79 | + </div> | |
| 80 | + <div class="box"> | |
| 81 | + <h3>{raw(ICONS.pr)} Reviews with intent</h3> | |
| 82 | + <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p> | |
| 83 | + </div> | |
| 84 | + <div class="box"> | |
| 85 | + <h3>{raw(ICONS.play)} Actions-compatible CI</h3> | |
| 86 | + <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p> | |
| 87 | + </div> | |
| 88 | + <div class="box"> | |
| 89 | + <h3>{raw(ICONS.bot)} Agents with guardrails</h3> | |
| 90 | + <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p> | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | +); | |
| 95 | + | |
| 96 | +export function registerAccount(web: WebApp): void { | |
| 97 | + web.get("/", async (c) => { | |
| 98 | + const p = c.get("principal"); | |
| 99 | + if (p.type !== "account") return render(c, { title: "" }, <Landing />); | |
| 100 | + const me = p.account; | |
| 101 | + const t = csrfToken(c); | |
| 102 | + if (me.status === "disabled") { | |
| 103 | + return render( | |
| 104 | + c, | |
| 105 | + { title: "Account disabled", status: 403 }, | |
| 106 | + <div class="blank" style="max-width:560px;margin:48px auto"> | |
| 107 | + <h1>Your account is disabled</h1> | |
| 108 | + <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p> | |
| 109 | + </div>, | |
| 110 | + ); | |
| 111 | + } | |
| 112 | + if (me.status === "pending") { | |
| 113 | + return render( | |
| 114 | + c, | |
| 115 | + { title: "Waiting for an invite" }, | |
| 116 | + <div class="box" style="max-width:600px;margin:48px auto"> | |
| 117 | + <div class="body"> | |
| 118 | + <h1>Waiting for an invite</h1> | |
| 119 | + <p class="muted"> | |
| 120 | + You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators. | |
| 121 | + </p> | |
| 122 | + <Form action="/request-access" t={t}> | |
| 123 | + <div class="field"> | |
| 124 | + <label for="note">Note to the administrators</label> | |
| 125 | + <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea> | |
| 126 | + </div> | |
| 127 | + <button class="btn primary">Request access</button> | |
| 128 | + </Form> | |
| 129 | + </div> | |
| 130 | + </div>, | |
| 131 | + ); | |
| 132 | + } | |
| 133 | + const dir = c.get("dir"); | |
| 134 | + const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]); | |
| 135 | + const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id)); | |
| 136 | + const others = repos.filter((r) => !mine.includes(r)); | |
| 137 | + return render( | |
| 138 | + c, | |
| 139 | + { title: "Dashboard" }, | |
| 140 | + <div class="grid left"> | |
| 141 | + <aside class="side"> | |
| 142 | + <section> | |
| 143 | + <div class="row between mb"> | |
| 144 | + <h3 class="m0">Your repositories</h3> | |
| 145 | + <a class="btn sm primary" href="/new"> | |
| 146 | + {raw(ICONS.plus)} New | |
| 147 | + </a> | |
| 148 | + </div> | |
| 149 | + {mine.length ? ( | |
| 150 | + <div class="nav"> | |
| 151 | + {mine.slice(0, 40).map((r) => ( | |
| 152 | + <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis"> | |
| 153 | + <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b> | |
| 154 | + </a> | |
| 155 | + ))} | |
| 156 | + </div> | |
| 157 | + ) : ( | |
| 158 | + <p class="muted small">You have no repositories yet.</p> | |
| 159 | + )} | |
| 160 | + </section> | |
| 161 | + <section> | |
| 162 | + <h3>Organizations</h3> | |
| 163 | + <div class="nav"> | |
| 164 | + {orgs.map((o) => ( | |
| 165 | + <a href={`/${o.login}`}> | |
| 166 | + <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span> | |
| 167 | + </a> | |
| 168 | + ))} | |
| 169 | + <a href="/settings/orgs" class="muted"> | |
| 170 | + {raw(ICONS.plus)} New organization | |
| 171 | + </a> | |
| 172 | + </div> | |
| 173 | + </section> | |
| 174 | + </aside> | |
| 175 | + <div> | |
| 176 | + {repos.length === 0 ? ( | |
| 177 | + <div class="box"> | |
| 178 | + <div class="body"> | |
| 179 | + <h2>Welcome to Diffinery, {me.name ?? me.login}</h2> | |
| 180 | + <ol class="muted" style="padding-left:20px"> | |
| 181 | + <li> | |
| 182 | + <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends). | |
| 183 | + </li> | |
| 184 | + <li> | |
| 185 | + <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS. | |
| 186 | + </li> | |
| 187 | + <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li> | |
| 188 | + <li> | |
| 189 | + Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Access</b>. | |
| 190 | + </li> | |
| 191 | + </ol> | |
| 192 | + </div> | |
| 193 | + </div> | |
| 194 | + ) : null} | |
| 195 | + {notes.length ? ( | |
| 196 | + <div class="box"> | |
| 197 | + <div class="head"> | |
| 198 | + <h2>{raw(ICONS.bell)} Unread notifications</h2> | |
| 199 | + <a class="small" href="/notifications" style="margin-left:auto"> | |
| 200 | + View all | |
| 201 | + </a> | |
| 202 | + </div> | |
| 203 | + <ul class="list"> | |
| 204 | + {notes.slice(0, 8).map((n) => ( | |
| 205 | + <li> | |
| 206 | + <div class="grow"> | |
| 207 | + <a class="title" href={`/notifications/${n.id}`}> | |
| 208 | + {n.title} | |
| 209 | + </a> | |
| 210 | + <div class="meta"> | |
| 211 | + {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} /> | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | + </li> | |
| 215 | + ))} | |
| 216 | + </ul> | |
| 217 | + </div> | |
| 218 | + ) : null} | |
| 219 | + <div class="box"> | |
| 220 | + <div class="head"> | |
| 221 | + <h2>Repositories</h2> | |
| 222 | + <span class="muted small">{repos.length} you can access</span> | |
| 223 | + </div> | |
| 224 | + <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} /> | |
| 225 | + </div> | |
| 226 | + </div> | |
| 227 | + </div>, | |
| 228 | + ); | |
| 229 | + }); | |
| 230 | + | |
| 231 | + post(web, "/request-access", async (c, f) => { | |
| 232 | + const p = c.get("principal"); | |
| 233 | + if (p.type !== "account") throw new HttpError(401, "sign in required"); | |
| 234 | + if (p.account.status !== "pending") return c.redirect("/", 303); | |
| 235 | + return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it."); | |
| 236 | + }); | |
| 237 | + | |
| 238 | + // ---------------------------------------------------------------- new repository | |
| 239 | + | |
| 240 | + const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => ( | |
| 241 | + <div style="max-width:720px;margin:0 auto"> | |
| 242 | + <h1>Create a new repository</h1> | |
| 243 | + <p class="muted">A repository holds your project's files, history, issues, pull requests and workflows.</p> | |
| 244 | + {error ? <div class="flash error">{error}</div> : null} | |
| 245 | + <Form action="/new" t={t} class="box"> | |
| 246 | + <div class="body"> | |
| 247 | + <div class="row" style="align-items:flex-end"> | |
| 248 | + <div class="field" style="min-width:180px"> | |
| 249 | + <label for="owner">Owner</label> | |
| 250 | + <select id="owner" name="owner"> | |
| 251 | + <option value={me.login} selected={values.owner === me.login}> | |
| 252 | + {me.login} | |
| 253 | + </option> | |
| 254 | + {orgs.map((o) => ( | |
| 255 | + <option value={o.login} selected={values.owner === o.login}> | |
| 256 | + {o.login} | |
| 257 | + </option> | |
| 258 | + ))} | |
| 259 | + </select> | |
| 260 | + </div> | |
| 261 | + <div class="field" style="padding-bottom:8px">/</div> | |
| 262 | + <div class="field grow"> | |
| 263 | + <label for="name">Repository name</label> | |
| 264 | + <input id="name" name="name" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" value={values.name} autofocus autocomplete="off" /> | |
| 265 | + </div> | |
| 266 | + </div> | |
| 267 | + <div class="field"> | |
| 268 | + <label for="description"> | |
| 269 | + Description <span class="faint">(optional)</span> | |
| 270 | + </label> | |
| 271 | + <input id="description" name="description" type="text" value={values.description} maxlength={350} /> | |
| 272 | + </div> | |
| 273 | + <div class="field"> | |
| 274 | + <label>Visibility</label> | |
| 275 | + {( | |
| 276 | + [ | |
| 277 | + ["private", "Private", "Only you, collaborators and organization members can see it."], | |
| 278 | + ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."], | |
| 279 | + ["public", "Public", "Anyone on the internet can see and clone it."], | |
| 280 | + ] as const | |
| 281 | + ).map(([v, l, d]) => ( | |
| 282 | + <label class="inline" style="margin:4px 0"> | |
| 283 | + <input type="radio" name="visibility" value={v} checked={(values.visibility || "private") === v} /> <b>{l}</b> <span class="muted small">— {d}</span> | |
| 284 | + </label> | |
| 285 | + ))} | |
| 286 | + </div> | |
| 287 | + <div class="field" style="max-width:280px"> | |
| 288 | + <label for="branch">Default branch</label> | |
| 289 | + <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} /> | |
| 290 | + </div> | |
| 291 | + <button class="btn primary">Create repository</button> | |
| 292 | + </div> | |
| 293 | + </Form> | |
| 294 | + </div> | |
| 295 | + ); | |
| 296 | + | |
| 297 | + web.get("/new", async (c) => { | |
| 298 | + const me = requireAccount(c); | |
| 299 | + const orgs = await c.get("dir").orgsFor(me.id); | |
| 300 | + return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />); | |
| 301 | + }); | |
| 302 | + | |
| 303 | + post(web, "/new", async (c, f) => { | |
| 304 | + const me = requireAccount(c); | |
| 305 | + const dir = c.get("dir"); | |
| 306 | + const values = { owner: f.s("owner"), name: f.s("name").trim(), description: f.s("description").trim(), visibility: f.s("visibility"), default_branch: f.s("default_branch").trim() }; | |
| 307 | + try { | |
| 308 | + const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner); | |
| 309 | + if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner"); | |
| 310 | + const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private"; | |
| 311 | + const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" }); | |
| 312 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` })); | |
| 313 | + return c.redirect(repoUrl(repo), 303); | |
| 314 | + } catch (e) { | |
| 315 | + if (!(e instanceof HttpError) || e.status >= 500) throw e; | |
| 316 | + const orgs = await dir.orgsFor(me.id); | |
| 317 | + return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />); | |
| 318 | + } | |
| 319 | + }); | |
| 320 | + | |
| 321 | + // ---------------------------------------------------------------- notifications | |
| 322 | + | |
| 323 | + web.get("/notifications", async (c) => { | |
| 324 | + const me = requireAccount(c); | |
| 325 | + const t = csrfToken(c); | |
| 326 | + const all = c.req.query("all") === "1"; | |
| 327 | + const notes = await c.get("dir").notifications(me.id, !all); | |
| 328 | + return render( | |
| 329 | + c, | |
| 330 | + { title: "Notifications" }, | |
| 331 | + <div style="max-width:900px;margin:0 auto"> | |
| 332 | + <div class="row between mb"> | |
| 333 | + <h1 class="m0">Notifications</h1> | |
| 334 | + <div class="row"> | |
| 335 | + <div class="subnav"> | |
| 336 | + <a href="/notifications" class={!all ? "on" : ""}> | |
| 337 | + Unread | |
| 338 | + </a> | |
| 339 | + <a href="/notifications?all=1" class={all ? "on" : ""}> | |
| 340 | + All | |
| 341 | + </a> | |
| 342 | + </div> | |
| 343 | + <Form action="/notifications/read" t={t} class="inline"> | |
| 344 | + <button class="btn sm">Mark all as read</button> | |
| 345 | + </Form> | |
| 346 | + </div> | |
| 347 | + </div> | |
| 348 | + <div class="box"> | |
| 349 | + {notes.length ? ( | |
| 350 | + <ul class="list"> | |
| 351 | + {notes.map((n) => ( | |
| 352 | + <li> | |
| 353 | + <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}> | |
| 354 | + {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)} | |
| 355 | + </span> | |
| 356 | + <div class="grow"> | |
| 357 | + <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}> | |
| 358 | + {n.title} | |
| 359 | + </a> | |
| 360 | + <div class="meta"> | |
| 361 | + {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} /> | |
| 362 | + </div> | |
| 363 | + </div> | |
| 364 | + {!n.readAt ? ( | |
| 365 | + <Form action="/notifications/read" t={t} class="inline"> | |
| 366 | + <input type="hidden" name="id" value={n.id} /> | |
| 367 | + <button class="btn sm" title="Mark as read"> | |
| 368 | + {raw(ICONS.check)} | |
| 369 | + </button> | |
| 370 | + </Form> | |
| 371 | + ) : null} | |
| 372 | + </li> | |
| 373 | + ))} | |
| 374 | + </ul> | |
| 375 | + ) : ( | |
| 376 | + <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div> | |
| 377 | + )} | |
| 378 | + </div> | |
| 379 | + </div>, | |
| 380 | + ); | |
| 381 | + }); | |
| 382 | + | |
| 383 | + web.get("/notifications/:id", async (c) => { | |
| 384 | + const me = requireAccount(c); | |
| 385 | + const dir = c.get("dir"); | |
| 386 | + const id = prm(c, "id"); | |
| 387 | + const n = (await dir.notifications(me.id)).find((x) => x.id === id); | |
| 388 | + if (!n) throw new HttpError(404, "notification not found"); | |
| 389 | + await dir.markRead(me.id, id); | |
| 390 | + const base = publicUrl(c); | |
| 391 | + let to = n.url; | |
| 392 | + if (to.startsWith(base)) to = to.slice(base.length) || "/"; | |
| 393 | + if (!to.startsWith("/") || to.startsWith("//")) to = "/notifications"; | |
| 394 | + return c.redirect(to, 302); | |
| 395 | + }); | |
| 396 | + | |
| 397 | + post(web, "/notifications/read", async (c, f) => { | |
| 398 | + const me = requireAccount(c); | |
| 399 | + const id = f.s("id"); | |
| 400 | + return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined)); | |
| 401 | + }); | |
| 402 | + | |
| 403 | + // ---------------------------------------------------------------- invites | |
| 404 | + | |
| 405 | + web.get("/invite/:token", async (c) => { | |
| 406 | + const p = c.get("principal"); | |
| 407 | + if (p.type !== "account") return invitePasskeyPage(c, prm(c, "token")); | |
| 408 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 409 | + return render( | |
| 410 | + c, | |
| 411 | + { title: "Accept invite" }, | |
| 412 | + <div class="box" style="max-width:560px;margin:48px auto"> | |
| 413 | + <div class="body"> | |
| 414 | + <h1>You have been invited</h1> | |
| 415 | + <p class="muted"> | |
| 416 | + Accept this invite as <b>{p.account.login}</b> ({p.account.email ?? "no email"}). Invites only work for the email address they were sent to. | |
| 417 | + </p> | |
| 418 | + <Form action={`/invite/${encodeURIComponent(prm(c, "token"))}`} t={csrfToken(c)}> | |
| 419 | + <button class="btn primary">Accept invite</button> | |
| 420 | + </Form> | |
| 421 | + </div> | |
| 422 | + </div>, | |
| 423 | + ); | |
| 424 | + }); | |
| 425 | + | |
| 426 | + post(web, "/invite/:token", async (c) => { | |
| 427 | + const p = c.get("principal"); | |
| 428 | + if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite"); | |
| 429 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 430 | + const dir = c.get("dir"); | |
| 431 | + const res = await dir.acceptInvite(prm(c, "token"), p.account); | |
| 432 | + let to = "/"; | |
| 433 | + if (res.repoId) { | |
| 434 | + const r = await dir.repoById(res.repoId); | |
| 435 | + if (r) to = repoUrl(r); | |
| 436 | + } else if (res.orgId) { | |
| 437 | + const o = await dir.account(res.orgId); | |
| 438 | + if (o) to = `/${o.login}`; | |
| 439 | + } | |
| 440 | + c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to })); | |
| 441 | + return c.redirect(to, 303); | |
| 442 | + }); | |
| 443 | + | |
| 444 | + // ---------------------------------------------------------------- palette data | |
| 445 | + | |
| 446 | + web.get("/-/palette.json", async (c) => { | |
| 447 | + const me = requireAccount(c); | |
| 448 | + const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`); | |
| 449 | + const param = c.req.query("repo") ?? ""; | |
| 450 | + let files: string[] = []; | |
| 451 | + let ref: string | undefined; | |
| 452 | + const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param); | |
| 453 | + if (m) { | |
| 454 | + try { | |
| 455 | + const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read"); | |
| 456 | + ref = c.req.query("ref") || repo.default_branch; | |
| 457 | + files = await listFiles(stub, ref); | |
| 458 | + } catch { | |
| 459 | + files = []; | |
| 460 | + } | |
| 461 | + } | |
| 462 | + return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" }); | |
| 463 | + }); | |
| 464 | + | |
| 465 | + // ---------------------------------------------------------------- personal settings | |
| 466 | + | |
| 467 | + registerSettings(web); | |
| 468 | + registerProfiles(web); | |
| 469 | +} | |
| 470 | + | |
| 471 | +/** Breadth-first file listing for "go to file" (bounded). */ | |
| 472 | +async function listFiles(stub: Stub, ref: string): Promise<string[]> { | |
| 473 | + const out: string[] = []; | |
| 474 | + let queue = [""]; | |
| 475 | + let reads = 0; | |
| 476 | + while (queue.length && reads < 120 && out.length < 5000) { | |
| 477 | + const batch = queue.slice(0, 12); | |
| 478 | + queue = queue.slice(12); | |
| 479 | + reads += batch.length; | |
| 480 | + const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null))); | |
| 481 | + for (const t of trees) { | |
| 482 | + if (!t) continue; | |
| 483 | + for (const it of t.items) { | |
| 484 | + if (it.type === "tree") { | |
| 485 | + if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path); | |
| 486 | + } else if (it.type === "blob") out.push(it.path); | |
| 487 | + } | |
| 488 | + } | |
| 489 | + } | |
| 490 | + return out.slice(0, 5000); | |
| 491 | +} | |
| 492 | + | |
| 493 | +// ---------------------------------------------------------------- /settings | |
| 494 | + | |
| 495 | +type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit"; | |
| 496 | + | |
| 497 | +const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( | |
| 498 | + <aside class="side"> | |
| 499 | + <section> | |
| 500 | + <div class="row mb"> | |
| 501 | + <Avatar login={me.login} size="lg" /> | |
| 502 | + <div> | |
| 503 | + <b>{me.name ?? me.login}</b> | |
| 504 | + <div class="muted small">{me.login}</div> | |
| 505 | + </div> | |
| 506 | + </div> | |
| 507 | + <div class="nav"> | |
| 508 | + <a href="/settings" class={tab === "profile" ? "on" : ""}> | |
| 509 | + Profile | |
| 510 | + </a> | |
| 511 | + <a href="/settings/security" class={tab === "security" ? "on" : ""}> | |
| 512 | + Passkeys and sessions | |
| 513 | + </a> | |
| 514 | + <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}> | |
| 515 | + Access tokens | |
| 516 | + </a> | |
| 517 | + <a href="/settings/agents" class={tab === "agents" ? "on" : ""}> | |
| 518 | + Agents | |
| 519 | + </a> | |
| 520 | + <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}> | |
| 521 | + Organizations | |
| 522 | + </a> | |
| 523 | + <a href="/notifications">Notifications</a> | |
| 524 | + {me.site_admin ? ( | |
| 525 | + <> | |
| 526 | + <a href="/settings/admin" class={tab === "admin" ? "on" : ""}> | |
| 527 | + Site administration | |
| 528 | + </a> | |
| 529 | + <a href="/settings/audit" class={tab === "audit" ? "on" : ""}> | |
| 530 | + Audit log | |
| 531 | + </a> | |
| 532 | + </> | |
| 533 | + ) : null} | |
| 534 | + </div> | |
| 535 | + </section> | |
| 536 | + </aside> | |
| 537 | +); | |
| 538 | + | |
| 539 | +const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => ( | |
| 540 | + <div class="box" style="border-color:var(--amber)"> | |
| 541 | + <div class="body"> | |
| 542 | + <h3>{title}</h3> | |
| 543 | + <p class="muted">Copy it now — it will not be shown again.</p> | |
| 544 | + <div class="row"> | |
| 545 | + <div class="secret grow">{token}</div> | |
| 546 | + <CopyButton text={token} /> | |
| 547 | + </div> | |
| 548 | + {note} | |
| 549 | + <details class="mt"> | |
| 550 | + <summary class="small" style="cursor:pointer"> | |
| 551 | + How to use it | |
| 552 | + </summary> | |
| 553 | + <pre class="cmd mt"> | |
| 554 | + {`# Git over HTTPS: use your username and this token as the password\ngit clone ${publicUrl(c)}/OWNER/REPO.git\n\n# Remember it (or use your OS keychain helper)\ngit config --global credential.helper store\n\n# HTTP APIs and MCP clients\nAuthorization: Bearer <token>`} | |
| 555 | + </pre> | |
| 556 | + </details> | |
| 557 | + </div> | |
| 558 | + </div> | |
| 559 | +); | |
| 560 | + | |
| 561 | +function registerSettings(web: WebApp): void { | |
| 562 | + const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) => | |
| 563 | + render( | |
| 564 | + c, | |
| 565 | + { title, status }, | |
| 566 | + <div class="grid left"> | |
| 567 | + <SettingsNav me={me} tab={tab} /> | |
| 568 | + <div>{body}</div> | |
| 569 | + </div>, | |
| 570 | + ); | |
| 571 | + | |
| 572 | + // Profile | |
| 573 | + web.get("/settings", async (c) => { | |
| 574 | + const me = requireAccount(c); | |
| 575 | + const t = csrfToken(c); | |
| 576 | + return page( | |
| 577 | + c, | |
| 578 | + me, | |
| 579 | + "profile", | |
| 580 | + "Settings", | |
| 581 | + <> | |
| 582 | + <h1>Profile</h1> | |
| 583 | + <Form action="/settings/profile" t={t} class="box"> | |
| 584 | + <div class="body"> | |
| 585 | + <div class="field"> | |
| 586 | + <label for="login">Username</label> | |
| 587 | + <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 588 | + <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div> | |
| 589 | + </div> | |
| 590 | + <div class="field"> | |
| 591 | + <label for="name">Display name</label> | |
| 592 | + <input id="name" name="name" type="text" value={me.name ?? ""} /> | |
| 593 | + </div> | |
| 594 | + <dl class="kv mb"> | |
| 595 | + <dt>Email</dt> | |
| 596 | + <dd>{me.email ?? "—"}</dd> | |
| 597 | + <dt>Account type</dt> | |
| 598 | + <dd>{me.kind}</dd> | |
| 599 | + <dt>Joined</dt> | |
| 600 | + <dd> | |
| 601 | + <Time at={me.created_at} /> | |
| 602 | + </dd> | |
| 603 | + <dt>Site admin</dt> | |
| 604 | + <dd>{me.site_admin ? "yes" : "no"}</dd> | |
| 605 | + </dl> | |
| 606 | + <button class="btn primary">Save profile</button> | |
| 607 | + </div> | |
| 608 | + </Form> | |
| 609 | + </>, | |
| 610 | + ); | |
| 611 | + }); | |
| 612 | + | |
| 613 | + post(web, "/settings/profile", async (c, f) => { | |
| 614 | + const me = requireAccount(c); | |
| 615 | + return act( | |
| 616 | + c, | |
| 617 | + "/settings", | |
| 618 | + async () => { | |
| 619 | + const login = f.s("login").trim(); | |
| 620 | + await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() }); | |
| 621 | + }, | |
| 622 | + "Profile saved.", | |
| 623 | + ); | |
| 624 | + }); | |
| 625 | + | |
| 626 | + // Passkeys and sessions | |
| 627 | + web.get("/settings/security", async (c) => { | |
| 628 | + const me = requireAccount(c); | |
| 629 | + const t = csrfToken(c); | |
| 630 | + const auth = new Auth(c.env); | |
| 631 | + const p = c.get("principal"); | |
| 632 | + const current = p.type === "account" ? p.sessionId : undefined; | |
| 633 | + const [keys, sessions] = await Promise.all([auth.passkeys(me.id), auth.sessions(me.id)]); | |
| 634 | + const recovery = !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD); | |
| 635 | + return page( | |
| 636 | + c, | |
| 637 | + me, | |
| 638 | + "security", | |
| 639 | + "Passkeys and sessions", | |
| 640 | + <> | |
| 641 | + <h1>Passkeys and sessions</h1> | |
| 642 | + <p class="muted">You sign in to Diffinery with passkeys instead of a password. A passkey lives on your device or in your password manager and is unlocked with your fingerprint, face or screen lock.</p> | |
| 643 | + <div class="box"> | |
| 644 | + <div class="head"> | |
| 645 | + <h2>Passkeys</h2> | |
| 646 | + <span class="badge">{keys.length}</span> | |
| 647 | + </div> | |
| 648 | + {keys.length ? ( | |
| 649 | + <ul class="list"> | |
| 650 | + {keys.map((k) => ( | |
| 651 | + <li> | |
| 652 | + <span class="st faint">{raw(ICONS.key)}</span> | |
| 653 | + <div class="grow"> | |
| 654 | + <b>{k.name}</b> {k.backed_up ? <span class="badge blue" title="Backed up by your password manager or platform, so it syncs to your other devices">synced</span> : <span class="badge" title="Stored only on one device or security key">this device only</span>} | |
| 655 | + <div class="meta"> | |
| 656 | + Added <Time at={k.created_at} /> · {k.last_used_at ? <>last used <Time at={k.last_used_at} /></> : "never used to sign in"} | |
| 657 | + </div> | |
| 658 | + </div> | |
| 659 | + {keys.length > 1 ? ( | |
| 660 | + <Form action={`/settings/security/passkeys/${encodeURIComponent(k.id)}/delete`} t={t} class="inline" confirm={`Remove the passkey "${k.name}"? Sessions it signed in are signed out.`}> | |
| 661 | + <button class="btn sm danger">Remove</button> | |
| 662 | + </Form> | |
| 663 | + ) : ( | |
| 664 | + <span class="small faint" title="Add another passkey before removing this one">Only passkey</span> | |
| 665 | + )} | |
| 666 | + </li> | |
| 667 | + ))} | |
| 668 | + </ul> | |
| 669 | + ) : ( | |
| 670 | + <div class="empty">No passkeys yet. Add one so you can sign in.</div> | |
| 671 | + )} | |
| 672 | + <div class="body" style="border-top:1px solid var(--border)"> | |
| 673 | + <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end"> | |
| 674 | + <div class="field grow m0"> | |
| 675 | + <label for="pklabel">Add a passkey</label> | |
| 676 | + <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 677 | + <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div> | |
| 678 | + </div> | |
| 679 | + <button class="btn primary">Add a passkey</button> | |
| 680 | + </form> | |
| 681 | + <div class="mt"> | |
| 682 | + <div class="flash error" data-passkey-msg role="alert" hidden></div> | |
| 683 | + </div> | |
| 684 | + </div> | |
| 685 | + </div> | |
| 686 | + <div class="box"> | |
| 687 | + <div class="head"> | |
| 688 | + <h2>Signed-in browsers</h2> | |
| 689 | + <span class="badge">{sessions.length}</span> | |
| 690 | + </div> | |
| 691 | + {sessions.length ? ( | |
| 692 | + <ul class="list"> | |
| 693 | + {sessions.map((s) => ( | |
| 694 | + <li> | |
| 695 | + <div class="grow"> | |
| 696 | + <b>{deviceLabel(s.user_agent ?? undefined)}</b> {s.id === current ? <span class="badge green">this browser</span> : null} | |
| 697 | + <div class="meta"> | |
| 698 | + Signed in <Time at={s.created_at} /> with {s.method === "access" ? "email recovery" : s.method === "link" ? "a sign-in link" : "a passkey"} · active <Time at={s.last_seen_at} /> | |
| 699 | + </div> | |
| 700 | + </div> | |
| 701 | + </li> | |
| 702 | + ))} | |
| 703 | + </ul> | |
| 704 | + ) : ( | |
| 705 | + <div class="empty">No browser sessions.</div> | |
| 706 | + )} | |
| 707 | + {sessions.some((s) => s.id !== current) ? ( | |
| 708 | + <div class="body" style="border-top:1px solid var(--border)"> | |
| 709 | + <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?"> | |
| 710 | + <button class="btn">Sign out everywhere else</button> | |
| 711 | + </Form> | |
| 712 | + </div> | |
| 713 | + ) : null} | |
| 714 | + </div> | |
| 715 | + <p class="muted small"> | |
| 716 | + Lost every passkey? {recovery ? <>Use <a href="/auth/access">email recovery</a>, then add a new passkey here.</> : "Ask a site administrator for a one-time sign-in link."} Git, the CLI and agents use <a href="/settings/tokens">access tokens</a> instead of passkeys. | |
| 717 | + </p> | |
| 718 | + </>, | |
| 719 | + ); | |
| 720 | + }); | |
| 721 | + | |
| 722 | + post(web, "/settings/security/passkeys/:id/delete", async (c) => { | |
| 723 | + const me = browserAccount(c); | |
| 724 | + const auth = new Auth(c.env); | |
| 725 | + const id = prm(c, "id"); | |
| 726 | + return act( | |
| 727 | + c, | |
| 728 | + "/settings/security", | |
| 729 | + async () => { | |
| 730 | + const keys = await auth.passkeys(me.id); | |
| 731 | + const key = keys.find((k) => k.id === id); | |
| 732 | + if (!key) throw new HttpError(404, "passkey not found"); | |
| 733 | + if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one."); | |
| 734 | + const p = c.get("principal"); | |
| 735 | + await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null); | |
| 736 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } })); | |
| 737 | + }, | |
| 738 | + "Passkey removed.", | |
| 739 | + ); | |
| 740 | + }); | |
| 741 | + | |
| 742 | + post(web, "/settings/security/sessions/others", async (c) => { | |
| 743 | + const me = browserAccount(c); | |
| 744 | + const p = c.get("principal"); | |
| 745 | + return act(c, "/settings/security", async () => new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null), "Signed out of every other browser."); | |
| 746 | + }); | |
| 747 | + | |
| 748 | + // Tokens | |
| 749 | + const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => { | |
| 750 | + const dir = c.get("dir"); | |
| 751 | + const t = csrfToken(c); | |
| 752 | + const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]); | |
| 753 | + const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`])); | |
| 754 | + return page( | |
| 755 | + c, | |
| 756 | + me, | |
| 757 | + "tokens", | |
| 758 | + "Access tokens", | |
| 759 | + <> | |
| 760 | + <h1>Personal access tokens</h1> | |
| 761 | + <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p> | |
| 762 | + {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null} | |
| 763 | + <div class="box"> | |
| 764 | + <div class="head"> | |
| 765 | + <h2>Active tokens</h2> | |
| 766 | + </div> | |
| 767 | + {tokens.length ? ( | |
| 768 | + <table class="tbl"> | |
| 769 | + <thead> | |
| 770 | + <tr> | |
| 771 | + <th>Name</th> | |
| 772 | + <th>Scopes</th> | |
| 773 | + <th>Repositories</th> | |
| 774 | + <th>Expires</th> | |
| 775 | + <th>Last used</th> | |
| 776 | + <th /> | |
| 777 | + </tr> | |
| 778 | + </thead> | |
| 779 | + <tbody> | |
| 780 | + {tokens.map((k) => ( | |
| 781 | + <tr> | |
| 782 | + <td> | |
| 783 | + <b>{k.name}</b> | |
| 784 | + <div class="faint mono small">{k.prefix}…</div> | |
| 785 | + </td> | |
| 786 | + <td>{k.scopes.join(", ")}</td> | |
| 787 | + <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td> | |
| 788 | + <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td> | |
| 789 | + <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td> | |
| 790 | + <td class="right"> | |
| 791 | + <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}> | |
| 792 | + <button class="btn sm danger">Revoke</button> | |
| 793 | + </Form> | |
| 794 | + </td> | |
| 795 | + </tr> | |
| 796 | + ))} | |
| 797 | + </tbody> | |
| 798 | + </table> | |
| 799 | + ) : ( | |
| 800 | + <div class="empty">No tokens yet.</div> | |
| 801 | + )} | |
| 802 | + </div> | |
| 803 | + <Form action="/settings/tokens" t={t} class="box"> | |
| 804 | + <div class="head"> | |
| 805 | + <h2>Create a token</h2> | |
| 806 | + </div> | |
| 807 | + <div class="body"> | |
| 808 | + <div class="row" style="align-items:flex-start"> | |
| 809 | + <div class="field grow"> | |
| 810 | + <label for="tname">Name</label> | |
| 811 | + <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} /> | |
| 812 | + </div> | |
| 813 | + <div class="field" style="width:140px"> | |
| 814 | + <label for="days">Expires in (days)</label> | |
| 815 | + <input id="days" name="days" type="number" min={1} max={366} value="90" /> | |
| 816 | + </div> | |
| 817 | + </div> | |
| 818 | + <div class="field"> | |
| 819 | + <label>Scope</label> | |
| 820 | + {SCOPES.map((s) => ( | |
| 821 | + <label class="inline" style="margin-right:16px"> | |
| 822 | + <input type="radio" name="scope" value={s} checked={s === "write"} /> {s} | |
| 823 | + </label> | |
| 824 | + ))} | |
| 825 | + <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div> | |
| 826 | + </div> | |
| 827 | + <div class="field"> | |
| 828 | + <label for="repos"> | |
| 829 | + Restrict to repositories <span class="faint">(optional)</span> | |
| 830 | + </label> | |
| 831 | + <select id="repos" name="repos" multiple size={Math.min(6, Math.max(3, repos.length))}> | |
| 832 | + {repos.map((r) => ( | |
| 833 | + <option value={r.id}> | |
| 834 | + {r.owner_login}/{r.name} | |
| 835 | + </option> | |
| 836 | + ))} | |
| 837 | + </select> | |
| 838 | + <div class="hint">Leave empty for access to every repository you can access.</div> | |
| 839 | + </div> | |
| 840 | + <button class="btn primary">Create token</button> | |
| 841 | + </div> | |
| 842 | + </Form> | |
| 843 | + </>, | |
| 844 | + ); | |
| 845 | + }; | |
| 846 | + | |
| 847 | + web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c))); | |
| 848 | + | |
| 849 | + post(web, "/settings/tokens", async (c, f) => { | |
| 850 | + const me = requireAccount(c); | |
| 851 | + const dir = c.get("dir"); | |
| 852 | + const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; | |
| 853 | + const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id)); | |
| 854 | + const repoIds = f.all("repos").filter((id) => visible.has(id)); | |
| 855 | + const name = f.s("name").trim() || "token"; | |
| 856 | + const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 }); | |
| 857 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } })); | |
| 858 | + return tokensPage(c, me, created); | |
| 859 | + }); | |
| 860 | + | |
| 861 | + post(web, "/settings/tokens/:id/revoke", async (c) => { | |
| 862 | + const me = requireAccount(c); | |
| 863 | + return act( | |
| 864 | + c, | |
| 865 | + "/settings/tokens", | |
| 866 | + async () => { | |
| 867 | + await c.get("dir").revokeToken(me.id, prm(c, "id")); | |
| 868 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: prm(c, "id") })); | |
| 869 | + }, | |
| 870 | + "Token revoked.", | |
| 871 | + ); | |
| 872 | + }); | |
| 873 | + | |
| 874 | + // Agents | |
| 875 | + const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => { | |
| 876 | + const dir = c.get("dir"); | |
| 877 | + const t = csrfToken(c); | |
| 878 | + const orgs = await adminOrgs(c, me); | |
| 879 | + const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]); | |
| 880 | + const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]); | |
| 881 | + const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id))); | |
| 882 | + return page( | |
| 883 | + c, | |
| 884 | + me, | |
| 885 | + "agents", | |
| 886 | + "Agents", | |
| 887 | + <> | |
| 888 | + <h1>Agents</h1> | |
| 889 | + <p class="muted"> | |
| 890 | + Agents are accounts for AI tools and automation. They act on behalf of their owner, are capped by a policy, cannot push to protected or default branches, and their pull requests need a human approval. Give each agent its own token. | |
| 891 | + </p> | |
| 892 | + {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null} | |
| 893 | + <div class="box"> | |
| 894 | + <div class="head"> | |
| 895 | + <h2>Your agents</h2> | |
| 896 | + </div> | |
| 897 | + {agents.length ? ( | |
| 898 | + <ul class="list"> | |
| 899 | + {agents.map((a, i) => { | |
| 900 | + const policy = parsePolicy(a); | |
| 901 | + const toks = tokenLists[i] ?? []; | |
| 902 | + return ( | |
| 903 | + <li style="display:block"> | |
| 904 | + <div class="row"> | |
| 905 | + <Actor login={a.login} kind="agent" /> | |
| 906 | + <span class="muted small"> | |
| 907 | + owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status} | |
| 908 | + </span> | |
| 909 | + <span class="grow" /> | |
| 910 | + <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline"> | |
| 911 | + <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} /> | |
| 912 | + <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button> | |
| 913 | + </Form> | |
| 914 | + </div> | |
| 915 | + {toks.length ? ( | |
| 916 | + <div class="small muted mt" style="margin-top:6px"> | |
| 917 | + Tokens:{" "} | |
| 918 | + {toks.map((k) => ( | |
| 919 | + <span class="row" style="display:inline-flex;gap:4px;margin-right:10px"> | |
| 920 | + <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"}) | |
| 921 | + <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?"> | |
| 922 | + <button class="btn sm link bad">revoke</button> | |
| 923 | + </Form> | |
| 924 | + </span> | |
| 925 | + ))} | |
| 926 | + </div> | |
| 927 | + ) : null} | |
| 928 | + <details style="margin-top:6px"> | |
| 929 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 930 | + Create a token for {a.login} | |
| 931 | + </summary> | |
| 932 | + <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end"> | |
| 933 | + <div class="field grow m0"> | |
| 934 | + <label>Name</label> | |
| 935 | + <input name="name" type="text" required value="default" /> | |
| 936 | + </div> | |
| 937 | + <div class="field m0"> | |
| 938 | + <label>Scope</label> | |
| 939 | + <select name="scope"> | |
| 940 | + {SCOPES.map((s) => ( | |
| 941 | + <option value={s} selected={s === "write"}> | |
| 942 | + {s} | |
| 943 | + </option> | |
| 944 | + ))} | |
| 945 | + </select> | |
| 946 | + </div> | |
| 947 | + <div class="field m0" style="width:110px"> | |
| 948 | + <label>Days</label> | |
| 949 | + <input name="days" type="number" min={1} max={366} value="30" /> | |
| 950 | + </div> | |
| 951 | + <button class="btn primary">Create token</button> | |
| 952 | + </Form> | |
| 953 | + </details> | |
| 954 | + </li> | |
| 955 | + ); | |
| 956 | + })} | |
| 957 | + </ul> | |
| 958 | + ) : ( | |
| 959 | + <div class="empty">No agents yet.</div> | |
| 960 | + )} | |
| 961 | + </div> | |
| 962 | + <Form action="/settings/agents" t={t} class="box"> | |
| 963 | + <div class="head"> | |
| 964 | + <h2>Create an agent</h2> | |
| 965 | + </div> | |
| 966 | + <div class="body"> | |
| 967 | + <div class="row" style="align-items:flex-start"> | |
| 968 | + <div class="field grow"> | |
| 969 | + <label for="alogin">Agent username</label> | |
| 970 | + <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" /> | |
| 971 | + </div> | |
| 972 | + <div class="field grow"> | |
| 973 | + <label for="aname">Display name</label> | |
| 974 | + <input id="aname" name="name" type="text" placeholder="Review bot" /> | |
| 975 | + </div> | |
| 976 | + </div> | |
| 977 | + <div class="row" style="align-items:flex-start"> | |
| 978 | + <div class="field grow"> | |
| 979 | + <label for="aowner">Owner</label> | |
| 980 | + <select id="aowner" name="owner"> | |
| 981 | + <option value="">{me.login} (you)</option> | |
| 982 | + {orgs.map((o) => ( | |
| 983 | + <option value={o.id}>{o.login} (organization)</option> | |
| 984 | + ))} | |
| 985 | + </select> | |
| 986 | + <div class="hint">An agent can never do more than its owner.</div> | |
| 987 | + </div> | |
| 988 | + <div class="field grow"> | |
| 989 | + <label for="arole">Maximum role</label> | |
| 990 | + <select id="arole" name="maxRole"> | |
| 991 | + {ROLES.map((r) => ( | |
| 992 | + <option value={r} selected={r === "write"}> | |
| 993 | + {r} | |
| 994 | + </option> | |
| 995 | + ))} | |
| 996 | + </select> | |
| 997 | + </div> | |
| 998 | + </div> | |
| 999 | + <button class="btn primary">Create agent</button> | |
| 1000 | + </div> | |
| 1001 | + </Form> | |
| 1002 | + </>, | |
| 1003 | + ); | |
| 1004 | + }; | |
| 1005 | + | |
| 1006 | + web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c))); | |
| 1007 | + | |
| 1008 | + post(web, "/settings/agents", async (c, f) => { | |
| 1009 | + const me = requireAccount(c); | |
| 1010 | + const dir = c.get("dir"); | |
| 1011 | + return act( | |
| 1012 | + c, | |
| 1013 | + "/settings/agents", | |
| 1014 | + async () => { | |
| 1015 | + const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write"; | |
| 1016 | + const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } }); | |
| 1017 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } })); | |
| 1018 | + }, | |
| 1019 | + "Agent created. Create a token for it below.", | |
| 1020 | + ); | |
| 1021 | + }); | |
| 1022 | + | |
| 1023 | + const loadAgent = async (c: AppContext, me: Account): Promise<Account> => { | |
| 1024 | + const agent = await c.get("dir").account(prm(c, "id")); | |
| 1025 | + if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found"); | |
| 1026 | + return agent; | |
| 1027 | + }; | |
| 1028 | + | |
| 1029 | + post(web, "/settings/agents/:id/token", async (c, f) => { | |
| 1030 | + const me = requireAccount(c); | |
| 1031 | + const agent = await loadAgent(c, me); | |
| 1032 | + const dir = c.get("dir"); | |
| 1033 | + const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; | |
| 1034 | + const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 }); | |
| 1035 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } })); | |
| 1036 | + return agentsPage(c, me, { token: created.token, agent: agent.login }); | |
| 1037 | + }); | |
| 1038 | + | |
| 1039 | + post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => { | |
| 1040 | + const me = requireAccount(c); | |
| 1041 | + const agent = await loadAgent(c, me); | |
| 1042 | + return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, prm(c, "tid")), "Token revoked."); | |
| 1043 | + }); | |
| 1044 | + | |
| 1045 | + post(web, "/settings/agents/:id/status", async (c, f) => { | |
| 1046 | + const me = requireAccount(c); | |
| 1047 | + const agent = await loadAgent(c, me); | |
| 1048 | + const status = f.s("status") === "active" ? "active" : "disabled"; | |
| 1049 | + return act(c, "/settings/agents", async () => { | |
| 1050 | + await c.get("dir").setStatus(agent.id, status); | |
| 1051 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login })); | |
| 1052 | + }); | |
| 1053 | + }); | |
| 1054 | + | |
| 1055 | + // Organizations | |
| 1056 | + web.get("/settings/orgs", async (c) => { | |
| 1057 | + const me = requireAccount(c); | |
| 1058 | + const t = csrfToken(c); | |
| 1059 | + const orgs = await c.get("dir").orgsFor(me.id); | |
| 1060 | + return page( | |
| 1061 | + c, | |
| 1062 | + me, | |
| 1063 | + "orgs", | |
| 1064 | + "Organizations", | |
| 1065 | + <> | |
| 1066 | + <h1>Organizations</h1> | |
| 1067 | + <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p> | |
| 1068 | + <div class="box"> | |
| 1069 | + {orgs.length ? ( | |
| 1070 | + <ul class="list"> | |
| 1071 | + {orgs.map((o) => ( | |
| 1072 | + <li> | |
| 1073 | + <Avatar login={o.login} /> | |
| 1074 | + <div class="grow"> | |
| 1075 | + <a class="title" href={`/${o.login}`}> | |
| 1076 | + {o.login} | |
| 1077 | + </a> | |
| 1078 | + {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null} | |
| 1079 | + </div> | |
| 1080 | + <span class="badge">{o.role}</span> | |
| 1081 | + </li> | |
| 1082 | + ))} | |
| 1083 | + </ul> | |
| 1084 | + ) : ( | |
| 1085 | + <div class="empty">You are not a member of any organization.</div> | |
| 1086 | + )} | |
| 1087 | + </div> | |
| 1088 | + <Form action="/settings/orgs" t={t} class="box"> | |
| 1089 | + <div class="head"> | |
| 1090 | + <h2>New organization</h2> | |
| 1091 | + </div> | |
| 1092 | + <div class="body"> | |
| 1093 | + <div class="row" style="align-items:flex-start"> | |
| 1094 | + <div class="field grow"> | |
| 1095 | + <label for="olog">Organization name</label> | |
| 1096 | + <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" /> | |
| 1097 | + </div> | |
| 1098 | + <div class="field grow"> | |
| 1099 | + <label for="oname">Display name</label> | |
| 1100 | + <input id="oname" name="name" type="text" /> | |
| 1101 | + </div> | |
| 1102 | + </div> | |
| 1103 | + <button class="btn primary">Create organization</button> | |
| 1104 | + </div> | |
| 1105 | + </Form> | |
| 1106 | + </>, | |
| 1107 | + ); | |
| 1108 | + }); | |
| 1109 | + | |
| 1110 | + post(web, "/settings/orgs", async (c, f) => { | |
| 1111 | + const me = requireAccount(c); | |
| 1112 | + const dir = c.get("dir"); | |
| 1113 | + return act(c, "/settings/orgs", async () => { | |
| 1114 | + const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me); | |
| 1115 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login })); | |
| 1116 | + return `/${org.login}?tab=people`; | |
| 1117 | + }); | |
| 1118 | + }); | |
| 1119 | + | |
| 1120 | + // Site administration | |
| 1121 | + const requireSiteAdmin = (c: AppContext): Account => { | |
| 1122 | + const me = requireAccount(c); | |
| 1123 | + if (!me.site_admin) throw new HttpError(403, "site administrators only"); | |
| 1124 | + return me; | |
| 1125 | + }; | |
| 1126 | + | |
| 1127 | + const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => { | |
| 1128 | + const dir = c.get("dir"); | |
| 1129 | + const t = csrfToken(c); | |
| 1130 | + const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]); | |
| 1131 | + return page( | |
| 1132 | + c, | |
| 1133 | + me, | |
| 1134 | + "admin", | |
| 1135 | + "Site administration", | |
| 1136 | + <> | |
| 1137 | + <h1>Site administration</h1> | |
| 1138 | + {invite ? ( | |
| 1139 | + <div class="box" style="border-color:var(--amber)"> | |
| 1140 | + <div class="body"> | |
| 1141 | + <h3>{invite.title}</h3> | |
| 1142 | + <p class="muted">{invite.note}</p> | |
| 1143 | + <div class="row"> | |
| 1144 | + <div class="secret grow">{invite.url}</div> | |
| 1145 | + <CopyButton text={invite.url} /> | |
| 1146 | + </div> | |
| 1147 | + </div> | |
| 1148 | + </div> | |
| 1149 | + ) : null} | |
| 1150 | + <div class="box"> | |
| 1151 | + <div class="head"> | |
| 1152 | + <h2>Pending access requests</h2> | |
| 1153 | + <span class="badge">{requests.length}</span> | |
| 1154 | + </div> | |
| 1155 | + {requests.length ? ( | |
| 1156 | + <ul class="list"> | |
| 1157 | + {requests.map((r) => ( | |
| 1158 | + <li> | |
| 1159 | + <Avatar login={r.account.login} /> | |
| 1160 | + <div class="grow"> | |
| 1161 | + <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span> | |
| 1162 | + {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null} | |
| 1163 | + </div> | |
| 1164 | + <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline"> | |
| 1165 | + <input type="hidden" name="status" value="active" /> | |
| 1166 | + <button class="btn sm green">Approve</button> | |
| 1167 | + </Form> | |
| 1168 | + <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline"> | |
| 1169 | + <input type="hidden" name="status" value="disabled" /> | |
| 1170 | + <button class="btn sm danger">Deny</button> | |
| 1171 | + </Form> | |
| 1172 | + </li> | |
| 1173 | + ))} | |
| 1174 | + </ul> | |
| 1175 | + ) : ( | |
| 1176 | + <div class="empty">No pending requests.</div> | |
| 1177 | + )} | |
| 1178 | + </div> | |
| 1179 | + <Form action="/settings/admin/invite" t={t} class="box"> | |
| 1180 | + <div class="head"> | |
| 1181 | + <h2>Invite someone to this instance</h2> | |
| 1182 | + </div> | |
| 1183 | + <div class="body row" style="align-items:flex-end"> | |
| 1184 | + <div class="field grow m0"> | |
| 1185 | + <label for="iemail">Email address</label> | |
| 1186 | + <input id="iemail" name="email" type="email" required placeholder="friend@example.com" /> | |
| 1187 | + <div class="hint">Opening the link lets them pick a username and create a passkey.</div> | |
| 1188 | + </div> | |
| 1189 | + <button class="btn primary">Create invite</button> | |
| 1190 | + </div> | |
| 1191 | + </Form> | |
| 1192 | + <div class="box"> | |
| 1193 | + <div class="head"> | |
| 1194 | + <h2>Users</h2> | |
| 1195 | + <span class="badge">{users.length}</span> | |
| 1196 | + </div> | |
| 1197 | + <table class="tbl"> | |
| 1198 | + <thead> | |
| 1199 | + <tr> | |
| 1200 | + <th>User</th> | |
| 1201 | + <th>Email</th> | |
| 1202 | + <th>Joined</th> | |
| 1203 | + <th>Status</th> | |
| 1204 | + <th /> | |
| 1205 | + </tr> | |
| 1206 | + </thead> | |
| 1207 | + <tbody> | |
| 1208 | + {users.map((u) => ( | |
| 1209 | + <tr> | |
| 1210 | + <td> | |
| 1211 | + <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null} | |
| 1212 | + </td> | |
| 1213 | + <td class="small">{u.email ?? "—"}</td> | |
| 1214 | + <td class="small">{ago(u.created_at)}</td> | |
| 1215 | + <td> | |
| 1216 | + {u.id === me.id ? ( | |
| 1217 | + <span class="badge green">{u.status}</span> | |
| 1218 | + ) : ( | |
| 1219 | + <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 1220 | + <select name="status" style="width:auto;padding:3px 6px"> | |
| 1221 | + {(["active", "pending", "disabled"] as const).map((s) => ( | |
| 1222 | + <option value={s} selected={u.status === s}> | |
| 1223 | + {s} | |
| 1224 | + </option> | |
| 1225 | + ))} | |
| 1226 | + </select> | |
| 1227 | + <button class="btn sm">Save</button> | |
| 1228 | + </Form> | |
| 1229 | + )} | |
| 1230 | + </td> | |
| 1231 | + <td class="right"> | |
| 1232 | + <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}> | |
| 1233 | + <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button> | |
| 1234 | + </Form> | |
| 1235 | + </td> | |
| 1236 | + </tr> | |
| 1237 | + ))} | |
| 1238 | + </tbody> | |
| 1239 | + </table> | |
| 1240 | + </div> | |
| 1241 | + </>, | |
| 1242 | + ); | |
| 1243 | + }; | |
| 1244 | + | |
| 1245 | + web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c))); | |
| 1246 | + | |
| 1247 | + post(web, "/settings/admin/users/:id/status", async (c, f) => { | |
| 1248 | + const me = requireSiteAdmin(c); | |
| 1249 | + const dir = c.get("dir"); | |
| 1250 | + const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status")); | |
| 1251 | + return act( | |
| 1252 | + c, | |
| 1253 | + "/settings/admin", | |
| 1254 | + async () => { | |
| 1255 | + if (!status) throw new HttpError(422, "invalid status"); | |
| 1256 | + const target = await dir.account(prm(c, "id")); | |
| 1257 | + if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1258 | + if (target.id === me.id) throw new HttpError(422, "you cannot change your own status"); | |
| 1259 | + await dir.setStatus(target.id, status); | |
| 1260 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login })); | |
| 1261 | + }, | |
| 1262 | + "User updated.", | |
| 1263 | + ); | |
| 1264 | + }); | |
| 1265 | + | |
| 1266 | + post(web, "/settings/admin/users/:id/signin-link", async (c) => { | |
| 1267 | + const me = requireSiteAdmin(c); | |
| 1268 | + const dir = c.get("dir"); | |
| 1269 | + const target = await dir.account(prm(c, "id")); | |
| 1270 | + if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1271 | + if (target.status === "disabled") throw new HttpError(422, "enable this account first"); | |
| 1272 | + const link = await new Auth(c.env).createSigninLink(me, target); | |
| 1273 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login })); | |
| 1274 | + return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: "Send this only to them. It works once within 24 hours and adds a passkey to their account." }); | |
| 1275 | + }); | |
| 1276 | + | |
| 1277 | + post(web, "/settings/admin/invite", async (c, f) => { | |
| 1278 | + const me = requireSiteAdmin(c); | |
| 1279 | + const email = f.s("email").trim(); | |
| 1280 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); | |
| 1281 | + const inv = await c.get("dir").createInvite(me, { email, role: "read" }); | |
| 1282 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email })); | |
| 1283 | + return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: "Send them this link. It expires in 14 days and works once: they pick a username and create a passkey." }); | |
| 1284 | + }); | |
| 1285 | + | |
| 1286 | + web.get("/settings/audit", async (c) => { | |
| 1287 | + const me = requireSiteAdmin(c); | |
| 1288 | + const rows = await c.get("dir").auditLog(200); | |
| 1289 | + return page( | |
| 1290 | + c, | |
| 1291 | + me, | |
| 1292 | + "audit", | |
| 1293 | + "Audit log", | |
| 1294 | + <> | |
| 1295 | + <h1>Audit log</h1> | |
| 1296 | + <p class="muted">The 200 most recent security-relevant events.</p> | |
| 1297 | + <div class="box" style="overflow:auto"> | |
| 1298 | + <table class="tbl"> | |
| 1299 | + <thead> | |
| 1300 | + <tr> | |
| 1301 | + <th>When</th> | |
| 1302 | + <th>Actor</th> | |
| 1303 | + <th>Action</th> | |
| 1304 | + <th>Target</th> | |
| 1305 | + <th>Detail</th> | |
| 1306 | + </tr> | |
| 1307 | + </thead> | |
| 1308 | + <tbody> | |
| 1309 | + {rows.map((r) => ( | |
| 1310 | + <tr> | |
| 1311 | + <td class="small nowrap"> | |
| 1312 | + <Time at={r.at} /> | |
| 1313 | + </td> | |
| 1314 | + <td class="small"> | |
| 1315 | + {r.actor_login ?? "system"} | |
| 1316 | + {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null} | |
| 1317 | + </td> | |
| 1318 | + <td> | |
| 1319 | + <span class="badge">{r.action}</span> | |
| 1320 | + </td> | |
| 1321 | + <td class="small mono">{r.target ?? ""}</td> | |
| 1322 | + <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}> | |
| 1323 | + {r.detail ?? ""} | |
| 1324 | + </td> | |
| 1325 | + </tr> | |
| 1326 | + ))} | |
| 1327 | + </tbody> | |
| 1328 | + </table> | |
| 1329 | + {rows.length === 0 ? <div class="empty">No events yet.</div> : null} | |
| 1330 | + </div> | |
| 1331 | + </>, | |
| 1332 | + ); | |
| 1333 | + }); | |
| 1334 | +} | |
| 1335 | + | |
| 1336 | +// ---------------------------------------------------------------- /:owner profiles | |
| 1337 | + | |
| 1338 | +type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings"; | |
| 1339 | + | |
| 1340 | +function registerProfiles(web: WebApp): void { | |
| 1341 | + const orgContext = async (c: AppContext) => { | |
| 1342 | + const me = requireAccount(c); | |
| 1343 | + const dir = c.get("dir"); | |
| 1344 | + const org = await dir.accountByLogin(prm(c, "owner")); | |
| 1345 | + if (!org) throw new HttpError(404, "not found"); | |
| 1346 | + if (org.kind !== "org") throw new HttpError(404, "not an organization"); | |
| 1347 | + const role = await dir.orgRole(org.id, me.id); | |
| 1348 | + const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; | |
| 1349 | + const isOwner = !!me.site_admin || role === "owner"; | |
| 1350 | + return { me, org, role, isAdmin, isOwner, dir }; | |
| 1351 | + }; | |
| 1352 | + | |
| 1353 | + const profile = async (c: AppContext, invite?: { url: string; email: string }) => { | |
| 1354 | + const me = requireAccount(c); | |
| 1355 | + const dir = c.get("dir"); | |
| 1356 | + const t = csrfToken(c); | |
| 1357 | + const acct = await dir.accountByLogin(prm(c, "owner")); | |
| 1358 | + if (!acct) throw new HttpError(404, "not found"); | |
| 1359 | + if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); | |
| 1360 | + | |
| 1361 | + if (acct.kind === "agent") { | |
| 1362 | + const owner = acct.owner_id ? await dir.account(acct.owner_id) : null; | |
| 1363 | + const policy = parsePolicy(acct); | |
| 1364 | + return render( | |
| 1365 | + c, | |
| 1366 | + { title: acct.login }, | |
| 1367 | + <div style="max-width:720px;margin:24px auto" class="box"> | |
| 1368 | + <div class="body row" style="gap:18px"> | |
| 1369 | + <Avatar login={acct.login} kind="agent" size="xl" /> | |
| 1370 | + <div> | |
| 1371 | + <h1 class="m0"> | |
| 1372 | + {acct.name ?? acct.login} <span class="badge agent">agent</span> | |
| 1373 | + </h1> | |
| 1374 | + <p class="muted">{acct.login}</p> | |
| 1375 | + <dl class="kv"> | |
| 1376 | + <dt>Acts on behalf of</dt> | |
| 1377 | + <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd> | |
| 1378 | + <dt>Maximum role</dt> | |
| 1379 | + <dd>{policy.maxRole ?? "write"}</dd> | |
| 1380 | + <dt>Status</dt> | |
| 1381 | + <dd>{acct.status}</dd> | |
| 1382 | + <dt>Created</dt> | |
| 1383 | + <dd> | |
| 1384 | + <Time at={acct.created_at} /> | |
| 1385 | + </dd> | |
| 1386 | + </dl> | |
| 1387 | + </div> | |
| 1388 | + </div> | |
| 1389 | + </div>, | |
| 1390 | + ); | |
| 1391 | + } | |
| 1392 | + | |
| 1393 | + const repos = await dir.reposVisibleTo(me, acct.id); | |
| 1394 | + if (acct.kind === "user") { | |
| 1395 | + const orgs = await dir.orgsFor(acct.id); | |
| 1396 | + return render( | |
| 1397 | + c, | |
| 1398 | + { title: acct.login }, | |
| 1399 | + <div class="grid left"> | |
| 1400 | + <aside class="side"> | |
| 1401 | + <section> | |
| 1402 | + <Avatar login={acct.login} size="xl" /> | |
| 1403 | + <h1 class="mt m0">{acct.name ?? acct.login}</h1> | |
| 1404 | + <p class="muted">{acct.login}</p> | |
| 1405 | + {acct.id === me.id ? ( | |
| 1406 | + <a class="btn sm" href="/settings"> | |
| 1407 | + Edit profile | |
| 1408 | + </a> | |
| 1409 | + ) : null} | |
| 1410 | + </section> | |
| 1411 | + {orgs.length ? ( | |
| 1412 | + <section> | |
| 1413 | + <h3>Organizations</h3> | |
| 1414 | + <div class="nav"> | |
| 1415 | + {orgs.map((o) => ( | |
| 1416 | + <a href={`/${o.login}`}> | |
| 1417 | + <Avatar login={o.login} /> {o.login} | |
| 1418 | + </a> | |
| 1419 | + ))} | |
| 1420 | + </div> | |
| 1421 | + </section> | |
| 1422 | + ) : null} | |
| 1423 | + </aside> | |
| 1424 | + <div> | |
| 1425 | + <div class="row between mb"> | |
| 1426 | + <h2 class="m0">Repositories</h2> | |
| 1427 | + {acct.id === me.id ? ( | |
| 1428 | + <a class="btn sm primary" href="/new"> | |
| 1429 | + {raw(ICONS.plus)} New | |
| 1430 | + </a> | |
| 1431 | + ) : null} | |
| 1432 | + </div> | |
| 1433 | + <div class="box"> | |
| 1434 | + <RepoList repos={repos} showOwner={false} /> | |
| 1435 | + </div> | |
| 1436 | + </div> | |
| 1437 | + </div>, | |
| 1438 | + ); | |
| 1439 | + } | |
| 1440 | + | |
| 1441 | + // Organization | |
| 1442 | + const role = await dir.orgRole(acct.id, me.id); | |
| 1443 | + const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; | |
| 1444 | + const isOwner = !!me.site_admin || role === "owner"; | |
| 1445 | + const requested = (c.req.query("tab") ?? "repositories") as OrgTab; | |
| 1446 | + const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories"; | |
| 1447 | + if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only"); | |
| 1448 | + let body: Child = null; | |
| 1449 | + const base = `/${acct.login}`; | |
| 1450 | + if (tab === "repositories") { | |
| 1451 | + body = ( | |
| 1452 | + <> | |
| 1453 | + <div class="row between mb"> | |
| 1454 | + <h2 class="m0">Repositories</h2> | |
| 1455 | + {role ? ( | |
| 1456 | + <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}> | |
| 1457 | + {raw(ICONS.plus)} New repository | |
| 1458 | + </a> | |
| 1459 | + ) : null} | |
| 1460 | + </div> | |
| 1461 | + <div class="box"> | |
| 1462 | + <RepoList repos={repos} showOwner={false} /> | |
| 1463 | + </div> | |
| 1464 | + </> | |
| 1465 | + ); | |
| 1466 | + } else if (tab === "people") { | |
| 1467 | + const members = await dir.orgMembers(acct.id); | |
| 1468 | + body = ( | |
| 1469 | + <> | |
| 1470 | + <h2>People</h2> | |
| 1471 | + <div class="box"> | |
| 1472 | + <table class="tbl"> | |
| 1473 | + <thead> | |
| 1474 | + <tr> | |
| 1475 | + <th>Member</th> | |
| 1476 | + <th>Role</th> | |
| 1477 | + {isAdmin ? <th /> : null} | |
| 1478 | + </tr> | |
| 1479 | + </thead> | |
| 1480 | + <tbody> | |
| 1481 | + {members.map((m) => ( | |
| 1482 | + <tr> | |
| 1483 | + <td> | |
| 1484 | + <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null} | |
| 1485 | + </td> | |
| 1486 | + <td> | |
| 1487 | + {isAdmin && (isOwner || m.role !== "owner") ? ( | |
| 1488 | + <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 1489 | + <input type="hidden" name="login" value={m.login} /> | |
| 1490 | + <select name="role" style="width:auto;padding:3px 6px"> | |
| 1491 | + {(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ( | |
| 1492 | + <option value={r} selected={m.role === r}> | |
| 1493 | + {r} | |
| 1494 | + </option> | |
| 1495 | + ))} | |
| 1496 | + </select> | |
| 1497 | + <button class="btn sm">Save</button> | |
| 1498 | + </Form> | |
| 1499 | + ) : ( | |
| 1500 | + <span class="badge">{m.role}</span> | |
| 1501 | + )} | |
| 1502 | + </td> | |
| 1503 | + {isAdmin ? ( | |
| 1504 | + <td class="right"> | |
| 1505 | + {isOwner || m.role !== "owner" ? ( | |
| 1506 | + <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}> | |
| 1507 | + <input type="hidden" name="login" value={m.login} /> | |
| 1508 | + <input type="hidden" name="role" value="remove" /> | |
| 1509 | + <button class="btn sm danger">Remove</button> | |
| 1510 | + </Form> | |
| 1511 | + ) : null} | |
| 1512 | + </td> | |
| 1513 | + ) : null} | |
| 1514 | + </tr> | |
| 1515 | + ))} | |
| 1516 | + </tbody> | |
| 1517 | + </table> | |
| 1518 | + </div> | |
| 1519 | + {isAdmin ? ( | |
| 1520 | + <div class="row" style="align-items:stretch"> | |
| 1521 | + <Form action={`${base}/-/org/members`} t={t} class="box grow"> | |
| 1522 | + <div class="head"> | |
| 1523 | + <h3>Add an existing user</h3> | |
| 1524 | + </div> | |
| 1525 | + <div class="body row" style="align-items:flex-end"> | |
| 1526 | + <div class="field grow m0"> | |
| 1527 | + <label>Username</label> | |
| 1528 | + <input name="login" type="text" required /> | |
| 1529 | + </div> | |
| 1530 | + <div class="field m0"> | |
| 1531 | + <label>Role</label> | |
| 1532 | + <select name="role"> | |
| 1533 | + <option value="member">member</option> | |
| 1534 | + <option value="admin">admin</option> | |
| 1535 | + {isOwner ? <option value="owner">owner</option> : null} | |
| 1536 | + </select> | |
| 1537 | + </div> | |
| 1538 | + <button class="btn primary">Add</button> | |
| 1539 | + </div> | |
| 1540 | + </Form> | |
| 1541 | + <div class="box grow"> | |
| 1542 | + <div class="head"> | |
| 1543 | + <h3>Invite by email</h3> | |
| 1544 | + </div> | |
| 1545 | + <div class="body"> | |
| 1546 | + <p class="muted small"> | |
| 1547 | + New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a> | |
| 1548 | + </p> | |
| 1549 | + </div> | |
| 1550 | + </div> | |
| 1551 | + </div> | |
| 1552 | + ) : null} | |
| 1553 | + </> | |
| 1554 | + ); | |
| 1555 | + } else if (tab === "invites") { | |
| 1556 | + const invites = await dir.invites({ orgId: acct.id }); | |
| 1557 | + body = ( | |
| 1558 | + <> | |
| 1559 | + <h2>Invites</h2> | |
| 1560 | + {invite ? ( | |
| 1561 | + <div class="box" style="border-color:var(--amber)"> | |
| 1562 | + <div class="body"> | |
| 1563 | + <h3>Invite created for {invite.email}</h3> | |
| 1564 | + <p class="muted">Send them this link. It expires in 14 days. Opening it lets them create a passkey and join.</p> | |
| 1565 | + <div class="row"> | |
| 1566 | + <div class="secret grow">{invite.url}</div> | |
| 1567 | + <CopyButton text={invite.url} /> | |
| 1568 | + </div> | |
| 1569 | + </div> | |
| 1570 | + </div> | |
| 1571 | + ) : null} | |
| 1572 | + <Form action={`${base}/-/org/invites`} t={t} class="box"> | |
| 1573 | + <div class="head"> | |
| 1574 | + <h3>Invite someone</h3> | |
| 1575 | + </div> | |
| 1576 | + <div class="body row" style="align-items:flex-end"> | |
| 1577 | + <div class="field grow m0"> | |
| 1578 | + <label>Email address</label> | |
| 1579 | + <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 1580 | + </div> | |
| 1581 | + <div class="field m0"> | |
| 1582 | + <label>Role</label> | |
| 1583 | + <select name="role"> | |
| 1584 | + <option value="member">member</option> | |
| 1585 | + <option value="admin">admin</option> | |
| 1586 | + {isOwner ? <option value="owner">owner</option> : null} | |
| 1587 | + </select> | |
| 1588 | + </div> | |
| 1589 | + <button class="btn primary">Create invite</button> | |
| 1590 | + </div> | |
| 1591 | + </Form> | |
| 1592 | + <div class="box"> | |
| 1593 | + {invites.length ? ( | |
| 1594 | + <table class="tbl"> | |
| 1595 | + <thead> | |
| 1596 | + <tr> | |
| 1597 | + <th>Email</th> | |
| 1598 | + <th>Role</th> | |
| 1599 | + <th>Created</th> | |
| 1600 | + <th>Status</th> | |
| 1601 | + </tr> | |
| 1602 | + </thead> | |
| 1603 | + <tbody> | |
| 1604 | + {invites.map((i) => ( | |
| 1605 | + <tr> | |
| 1606 | + <td>{i.email}</td> | |
| 1607 | + <td>{i.role}</td> | |
| 1608 | + <td class="small">{ago(i.createdAt)}</td> | |
| 1609 | + <td class="small"> | |
| 1610 | + {i.acceptedAt ? ( | |
| 1611 | + <span class="badge green">accepted</span> | |
| 1612 | + ) : Date.parse(i.expiresAt) < Date.now() ? ( | |
| 1613 | + <span class="badge">expired</span> | |
| 1614 | + ) : ( | |
| 1615 | + <span class="badge amber">pending</span> | |
| 1616 | + )} | |
| 1617 | + </td> | |
| 1618 | + </tr> | |
| 1619 | + ))} | |
| 1620 | + </tbody> | |
| 1621 | + </table> | |
| 1622 | + ) : ( | |
| 1623 | + <div class="empty">No invites yet.</div> | |
| 1624 | + )} | |
| 1625 | + </div> | |
| 1626 | + </> | |
| 1627 | + ); | |
| 1628 | + } else if (tab === "agents") { | |
| 1629 | + const agents = await dir.agentsOwnedBy([acct.id]); | |
| 1630 | + body = ( | |
| 1631 | + <> | |
| 1632 | + <div class="row between mb"> | |
| 1633 | + <h2 class="m0">Agents</h2> | |
| 1634 | + {isAdmin ? ( | |
| 1635 | + <a class="btn sm primary" href="/settings/agents"> | |
| 1636 | + Manage agents | |
| 1637 | + </a> | |
| 1638 | + ) : null} | |
| 1639 | + </div> | |
| 1640 | + <div class="box"> | |
| 1641 | + {agents.length ? ( | |
| 1642 | + <ul class="list"> | |
| 1643 | + {agents.map((a) => ( | |
| 1644 | + <li> | |
| 1645 | + <Actor login={a.login} kind="agent" /> | |
| 1646 | + <span class="grow" /> | |
| 1647 | + <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span> | |
| 1648 | + <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span> | |
| 1649 | + </li> | |
| 1650 | + ))} | |
| 1651 | + </ul> | |
| 1652 | + ) : ( | |
| 1653 | + <div class="empty">This organization has no agents.</div> | |
| 1654 | + )} | |
| 1655 | + </div> | |
| 1656 | + </> | |
| 1657 | + ); | |
| 1658 | + } else { | |
| 1659 | + body = ( | |
| 1660 | + <> | |
| 1661 | + <h2>Settings</h2> | |
| 1662 | + <Form action={`${base}/-/org/settings`} t={t} class="box"> | |
| 1663 | + <div class="body"> | |
| 1664 | + <div class="field"> | |
| 1665 | + <label>Organization name</label> | |
| 1666 | + <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 1667 | + <div class="hint">Renaming changes every repository URL in this organization.</div> | |
| 1668 | + </div> | |
| 1669 | + <div class="field"> | |
| 1670 | + <label>Display name</label> | |
| 1671 | + <input name="name" type="text" value={acct.name ?? ""} /> | |
| 1672 | + </div> | |
| 1673 | + <button class="btn primary">Save</button> | |
| 1674 | + </div> | |
| 1675 | + </Form> | |
| 1676 | + </> | |
| 1677 | + ); | |
| 1678 | + } | |
| 1679 | + return render( | |
| 1680 | + c, | |
| 1681 | + { title: acct.login }, | |
| 1682 | + <> | |
| 1683 | + <div class="row mb" style="gap:16px"> | |
| 1684 | + <Avatar login={acct.login} size="lg" /> | |
| 1685 | + <div> | |
| 1686 | + <h1 class="m0">{acct.name ?? acct.login}</h1> | |
| 1687 | + <div class="muted small"> | |
| 1688 | + Organization · {acct.login} | |
| 1689 | + {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""} | |
| 1690 | + </div> | |
| 1691 | + </div> | |
| 1692 | + </div> | |
| 1693 | + <nav class="tabs mb" style="border-bottom:1px solid var(--border)"> | |
| 1694 | + {( | |
| 1695 | + [ | |
| 1696 | + ["repositories", "Repositories", true], | |
| 1697 | + ["people", "People", true], | |
| 1698 | + ["invites", "Invites", isAdmin], | |
| 1699 | + ["agents", "Agents", true], | |
| 1700 | + ["settings", "Settings", isOwner], | |
| 1701 | + ] as const | |
| 1702 | + ) | |
| 1703 | + .filter((x) => x[2]) | |
| 1704 | + .map(([k, label]) => ( | |
| 1705 | + <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}> | |
| 1706 | + {label} | |
| 1707 | + </a> | |
| 1708 | + ))} | |
| 1709 | + </nav> | |
| 1710 | + {body} | |
| 1711 | + </>, | |
| 1712 | + ); | |
| 1713 | + }; | |
| 1714 | + | |
| 1715 | + web.get("/:owner", async (c) => profile(c)); | |
| 1716 | + | |
| 1717 | + post(web, "/:owner/-/org/invites", async (c, f) => { | |
| 1718 | + const { me, org, isAdmin, isOwner, dir } = await orgContext(c); | |
| 1719 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1720 | + const email = f.s("email").trim(); | |
| 1721 | + const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member"; | |
| 1722 | + if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners"); | |
| 1723 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) { | |
| 1724 | + return act(c, `/${org.login}?tab=invites`, async () => { | |
| 1725 | + throw new HttpError(422, "enter a valid email address"); | |
| 1726 | + }); | |
| 1727 | + } | |
| 1728 | + const inv = await dir.createInvite(me, { email, orgId: org.id, role }); | |
| 1729 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } })); | |
| 1730 | + return profile(c, { url: inv.url, email }); | |
| 1731 | + }); | |
| 1732 | + | |
| 1733 | + post(web, "/:owner/-/org/members", async (c, f) => { | |
| 1734 | + const { me, org, isAdmin, isOwner, dir } = await orgContext(c); | |
| 1735 | + const back = `/${org.login}?tab=people`; | |
| 1736 | + return act(c, back, async () => { | |
| 1737 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1738 | + const user = await dir.accountByLogin(f.s("login").trim()); | |
| 1739 | + if (!user || user.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1740 | + const current = await dir.orgRole(org.id, user.id); | |
| 1741 | + const role = f.s("role"); | |
| 1742 | + if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners"); | |
| 1743 | + if (role === "remove") await dir.setOrgMember(org.id, user.id, null); | |
| 1744 | + else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role); | |
| 1745 | + else throw new HttpError(422, "invalid role"); | |
| 1746 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } })); | |
| 1747 | + }); | |
| 1748 | + }); | |
| 1749 | + | |
| 1750 | + post(web, "/:owner/-/org/settings", async (c, f) => { | |
| 1751 | + const { me, org, isOwner, dir } = await orgContext(c); | |
| 1752 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 1753 | + if (!isOwner) throw new HttpError(403, "organization owners only"); | |
| 1754 | + const login = f.s("login").trim(); | |
| 1755 | + await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() }); | |
| 1756 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } })); | |
| 1757 | + return `/${login || org.login}?tab=settings`; | |
| 1758 | + }, "Organization updated."); | |
| 1759 | + }); | |
| 1760 | +} |
addedapps/diffinery/src/web/actions.tsx+579−0View file
| @@ -1,0 +1,579 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml"; | |
| 3 | +import { raw } from "hono/html"; | |
| 4 | +import type { FC } from "hono/jsx"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { JobInfo, RunInfo } from "../repo/actions.js"; | |
| 8 | +import { atLeast } from "../repo/types.js"; | |
| 9 | +import { ICONS } from "./assets.js"; | |
| 10 | +import { csrfToken } from "./csrf.js"; | |
| 11 | +import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js"; | |
| 12 | + | |
| 13 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 14 | +type LogLine = { seq: number; t: string; step: number; text: string }; | |
| 15 | +/** Plain (non-RPC-wrapped) shape of a run, which keeps type checking shallow. */ | |
| 16 | +type RunWithJobs = RunInfo & { jobs: JobInfo[] }; | |
| 17 | + | |
| 18 | +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; | |
| 19 | +const MAX_LOG_LINES = 20000; | |
| 20 | + | |
| 21 | +const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 22 | + | |
| 23 | +function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string { | |
| 24 | + return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null); | |
| 25 | +} | |
| 26 | + | |
| 27 | +function matrixLabel(m: Record<string, unknown>): string { | |
| 28 | + const entries = Object.entries(m ?? {}); | |
| 29 | + if (!entries.length) return ""; | |
| 30 | + return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", "); | |
| 31 | +} | |
| 32 | + | |
| 33 | +const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => ( | |
| 34 | + <li> | |
| 35 | + <Status status={r.status} conclusion={r.conclusion} /> | |
| 36 | + <div class="grow"> | |
| 37 | + <a class="title" href={`${base}/actions/runs/${r.id}`}> | |
| 38 | + {r.title || r.workflowName} | |
| 39 | + </a> | |
| 40 | + <div class="meta"> | |
| 41 | + <b>{r.workflowName}</b> #{r.number} | |
| 42 | + {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")} | |
| 43 | + {r.prNumber ? ( | |
| 44 | + <> | |
| 45 | + {" "} | |
| 46 | + for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a> | |
| 47 | + </> | |
| 48 | + ) : null}{" "} | |
| 49 | + by {r.actor.login} | |
| 50 | + {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null} | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + <span class="badge mono nowrap" title={r.ref}> | |
| 54 | + {refLabel(r.ref)} | |
| 55 | + </span> | |
| 56 | + <div class="right small muted nowrap" style="min-width:110px"> | |
| 57 | + <div> | |
| 58 | + <Time at={r.createdAt} /> | |
| 59 | + </div> | |
| 60 | + <div>{runDuration(r)}</div> | |
| 61 | + </div> | |
| 62 | + </li> | |
| 63 | +); | |
| 64 | + | |
| 65 | +/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */ | |
| 66 | +async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> { | |
| 67 | + try { | |
| 68 | + const blob = await l.stub.blob(l.repo.default_branch, path); | |
| 69 | + if (!blob || blob.binary) return null; | |
| 70 | + const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path); | |
| 71 | + const d = workflow?.on.workflow_dispatch; | |
| 72 | + return d ? (d.inputs ?? {}) : null; | |
| 73 | + } catch { | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +function classify(text: string): { cls: string; text: string } { | |
| 79 | + const t = text.replace(ANSI, ""); | |
| 80 | + if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` }; | |
| 81 | + if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" }; | |
| 82 | + if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t }; | |
| 83 | + if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t }; | |
| 84 | + return { cls: "", text: t }; | |
| 85 | +} | |
| 86 | + | |
| 87 | +async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> { | |
| 88 | + const out: LogLine[] = []; | |
| 89 | + let after = -1; | |
| 90 | + let complete = false; | |
| 91 | + for (let i = 0; i < 5; i++) { | |
| 92 | + const page = await l.stub.jobLogs(jobId, after); | |
| 93 | + complete = page.complete; | |
| 94 | + out.push(...page.lines); | |
| 95 | + if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break; | |
| 96 | + after = page.lines[page.lines.length - 1]!.seq; | |
| 97 | + } | |
| 98 | + return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES }; | |
| 99 | +} | |
| 100 | + | |
| 101 | +export function registerActions(web: WebApp): void { | |
| 102 | + web.get("/:owner/:repo/actions", async (c) => { | |
| 103 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 104 | + const { repo, caller, stub } = l; | |
| 105 | + const base = repoUrl(repo); | |
| 106 | + const workflow = c.req.query("workflow") ?? ""; | |
| 107 | + const branch = c.req.query("branch") ?? ""; | |
| 108 | + const status = c.req.query("status") ?? ""; | |
| 109 | + const page = pageNum(c); | |
| 110 | + const per = 25; | |
| 111 | + const [runs, workflows, names] = await Promise.all([ | |
| 112 | + stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }), | |
| 113 | + stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>), | |
| 114 | + refNames(stub), | |
| 115 | + ]); | |
| 116 | + const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined; | |
| 117 | + const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; | |
| 118 | + const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null; | |
| 119 | + const t = csrfToken(c); | |
| 120 | + const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`; | |
| 121 | + return render( | |
| 122 | + c, | |
| 123 | + { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" }, | |
| 124 | + <div class="grid left"> | |
| 125 | + <aside class="side"> | |
| 126 | + <section> | |
| 127 | + <h3>Workflows</h3> | |
| 128 | + <div class="nav"> | |
| 129 | + <a href={`${base}/actions`} class={!workflow ? "on" : ""}> | |
| 130 | + All workflows | |
| 131 | + </a> | |
| 132 | + {workflows.map((w) => ( | |
| 133 | + <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}> | |
| 134 | + {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name} | |
| 135 | + </a> | |
| 136 | + ))} | |
| 137 | + </div> | |
| 138 | + {workflows.length === 0 ? ( | |
| 139 | + <p class="muted small"> | |
| 140 | + No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>. | |
| 141 | + </p> | |
| 142 | + ) : null} | |
| 143 | + </section> | |
| 144 | + <section> | |
| 145 | + <h3>Help</h3> | |
| 146 | + <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p> | |
| 147 | + </section> | |
| 148 | + </aside> | |
| 149 | + <div> | |
| 150 | + <div class="row between mb"> | |
| 151 | + <h1 class="m0">{selected ? selected.name : "All workflows"}</h1> | |
| 152 | + {selected ? <span class="mono muted small">{selected.path}</span> : null} | |
| 153 | + </div> | |
| 154 | + {selected?.errors.length ? ( | |
| 155 | + <div class="failure"> | |
| 156 | + <b>This workflow file has errors</b> | |
| 157 | + <pre>{selected.errors.join("\n")}</pre> | |
| 158 | + </div> | |
| 159 | + ) : null} | |
| 160 | + {!selected && workflows.some((w) => w.errors.length) ? ( | |
| 161 | + <div class="failure"> | |
| 162 | + <b>Some workflow files have errors</b> | |
| 163 | + <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre> | |
| 164 | + </div> | |
| 165 | + ) : null} | |
| 166 | + {selected && inputs ? ( | |
| 167 | + <details class="box"> | |
| 168 | + <summary class="head" style="cursor:pointer"> | |
| 169 | + {raw(ICONS.play)} <b>Run workflow</b> | |
| 170 | + <span class="muted small">This workflow has a workflow_dispatch trigger.</span> | |
| 171 | + </summary> | |
| 172 | + <Form action={`${base}/actions/dispatch`} t={t} class="body"> | |
| 173 | + <input type="hidden" name="workflow" value={selected.path} /> | |
| 174 | + <div class="field" style="max-width:320px"> | |
| 175 | + <label>Use workflow from</label> | |
| 176 | + <select name="ref"> | |
| 177 | + {names.branches.map((b) => ( | |
| 178 | + <option value={b} selected={b === repo.default_branch}> | |
| 179 | + {b} | |
| 180 | + </option> | |
| 181 | + ))} | |
| 182 | + {names.tags.map((tg) => ( | |
| 183 | + <option value={tg}>tag: {tg}</option> | |
| 184 | + ))} | |
| 185 | + </select> | |
| 186 | + </div> | |
| 187 | + {Object.entries(inputs).map(([name, d]) => ( | |
| 188 | + <div class="field" style="max-width:520px"> | |
| 189 | + {d.type === "boolean" ? ( | |
| 190 | + <> | |
| 191 | + <input type="hidden" name={`in_${name}`} value="false" /> | |
| 192 | + <label class="inline"> | |
| 193 | + <input type="checkbox" name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} /> {d.description ?? name} | |
| 194 | + </label> | |
| 195 | + </> | |
| 196 | + ) : ( | |
| 197 | + <> | |
| 198 | + <label> | |
| 199 | + {d.description ?? name} {d.required ? <span class="bad">*</span> : null} | |
| 200 | + </label> | |
| 201 | + {d.type === "choice" && d.options?.length ? ( | |
| 202 | + <select name={`in_${name}`}> | |
| 203 | + {d.options.map((o) => ( | |
| 204 | + <option value={o} selected={String(d.default ?? "") === o}> | |
| 205 | + {o} | |
| 206 | + </option> | |
| 207 | + ))} | |
| 208 | + </select> | |
| 209 | + ) : ( | |
| 210 | + <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} /> | |
| 211 | + )} | |
| 212 | + </> | |
| 213 | + )} | |
| 214 | + {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null} | |
| 215 | + </div> | |
| 216 | + ))} | |
| 217 | + <button class="btn green">{raw(ICONS.play)} Run workflow</button> | |
| 218 | + </Form> | |
| 219 | + </details> | |
| 220 | + ) : null} | |
| 221 | + <div class="box" data-fragment="runs"> | |
| 222 | + <div class="head"> | |
| 223 | + <b> | |
| 224 | + {runs.total} workflow run{runs.total === 1 ? "" : "s"} | |
| 225 | + </b> | |
| 226 | + <span class="grow" /> | |
| 227 | + <form method="get" action={`${base}/actions`} class="row" style="gap:6px"> | |
| 228 | + {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null} | |
| 229 | + <select name="branch" style="width:auto;padding:3px 6px"> | |
| 230 | + <option value="">All branches</option> | |
| 231 | + {names.branches.map((b) => ( | |
| 232 | + <option value={b} selected={b === branch}> | |
| 233 | + {b} | |
| 234 | + </option> | |
| 235 | + ))} | |
| 236 | + </select> | |
| 237 | + <select name="status" style="width:auto;padding:3px 6px"> | |
| 238 | + <option value="">Any status</option> | |
| 239 | + {["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => ( | |
| 240 | + <option value={s} selected={s === status}> | |
| 241 | + {s.replace("_", " ")} | |
| 242 | + </option> | |
| 243 | + ))} | |
| 244 | + </select> | |
| 245 | + <button class="btn sm">Filter</button> | |
| 246 | + </form> | |
| 247 | + </div> | |
| 248 | + {runs.runs.length ? ( | |
| 249 | + <ul class="list"> | |
| 250 | + {runs.runs.map((r) => ( | |
| 251 | + <RunRow base={base} r={r} /> | |
| 252 | + ))} | |
| 253 | + </ul> | |
| 254 | + ) : ( | |
| 255 | + <div class="empty">{workflow || branch || status ? "No runs match these filters." : "No workflow runs yet. Push a commit with a workflow file to start one."}</div> | |
| 256 | + )} | |
| 257 | + </div> | |
| 258 | + <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} /> | |
| 259 | + </div> | |
| 260 | + </div>, | |
| 261 | + ); | |
| 262 | + }); | |
| 263 | + | |
| 264 | + post(web, "/:owner/:repo/actions/dispatch", async (c, f) => { | |
| 265 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 266 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 267 | + const base = repoUrl(l.repo); | |
| 268 | + const workflow = f.s("workflow"); | |
| 269 | + const inputs: Record<string, unknown> = {}; | |
| 270 | + for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop(); | |
| 271 | + return act(c, `${base}/actions${qs({ workflow })}`, async () => { | |
| 272 | + const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller); | |
| 273 | + return `${base}/actions/runs/${run.id}`; | |
| 274 | + }); | |
| 275 | + }); | |
| 276 | + | |
| 277 | + web.get("/:owner/:repo/actions/runs/:runId", async (c) => { | |
| 278 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 279 | + const { repo, caller, stub } = l; | |
| 280 | + const base = repoUrl(repo); | |
| 281 | + const runId = prm(c, "runId"); | |
| 282 | + const run = (await stub.run(runId)) as unknown as RunWithJobs; | |
| 283 | + const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>); | |
| 284 | + const groups = new Map<string, JobInfo[]>(); | |
| 285 | + for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]); | |
| 286 | + const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; | |
| 287 | + const t = csrfToken(c); | |
| 288 | + const behalf = run.actor.onBehalfOf; | |
| 289 | + return render( | |
| 290 | + c, | |
| 291 | + { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined }, | |
| 292 | + <div data-fragment="run"> | |
| 293 | + <div class="row between mb" style="align-items:flex-start"> | |
| 294 | + <div class="grow"> | |
| 295 | + <div class="muted small"> | |
| 296 | + <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number} | |
| 297 | + {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""} | |
| 298 | + </div> | |
| 299 | + <h1 class="row" style="gap:8px;margin-top:4px"> | |
| 300 | + <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName} | |
| 301 | + </h1> | |
| 302 | + </div> | |
| 303 | + <div class="row"> | |
| 304 | + {canWrite && run.status !== "completed" ? ( | |
| 305 | + <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?"> | |
| 306 | + <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button> | |
| 307 | + </Form> | |
| 308 | + ) : null} | |
| 309 | + {canWrite && run.status === "completed" ? ( | |
| 310 | + <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline"> | |
| 311 | + <button class="btn">{raw(ICONS.history)} Re-run all jobs</button> | |
| 312 | + </Form> | |
| 313 | + ) : null} | |
| 314 | + </div> | |
| 315 | + </div> | |
| 316 | + <div class="box"> | |
| 317 | + <div class="body"> | |
| 318 | + <dl class="kv"> | |
| 319 | + <dt>Status</dt> | |
| 320 | + <dd> | |
| 321 | + <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b> | |
| 322 | + {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>} | |
| 323 | + </dd> | |
| 324 | + <dt>Triggered</dt> | |
| 325 | + <dd> | |
| 326 | + {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} /> | |
| 327 | + </dd> | |
| 328 | + <dt>Commit</dt> | |
| 329 | + <dd> | |
| 330 | + <a class="sha" href={`${base}/commit/${run.sha}`}> | |
| 331 | + {shortSha(run.sha)} | |
| 332 | + </a>{" "} | |
| 333 | + on{" "} | |
| 334 | + <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}> | |
| 335 | + {refLabel(run.ref)} | |
| 336 | + </a> | |
| 337 | + {run.prNumber ? ( | |
| 338 | + <> | |
| 339 | + {" "} | |
| 340 | + · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a> | |
| 341 | + </> | |
| 342 | + ) : null} | |
| 343 | + </dd> | |
| 344 | + <dt>Workflow file</dt> | |
| 345 | + <dd> | |
| 346 | + <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}> | |
| 347 | + {run.workflowPath} | |
| 348 | + </a> | |
| 349 | + </dd> | |
| 350 | + </dl> | |
| 351 | + </div> | |
| 352 | + </div> | |
| 353 | + {[...groups.entries()].map(([key, jobs]) => ( | |
| 354 | + <div class="box jobgroup"> | |
| 355 | + <div class="head"> | |
| 356 | + <b class="mono">{key}</b> | |
| 357 | + {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null} | |
| 358 | + </div> | |
| 359 | + {jobs.map((j) => ( | |
| 360 | + <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}> | |
| 361 | + <Status status={j.status} conclusion={j.conclusion} /> | |
| 362 | + <div class="grow"> | |
| 363 | + <b>{j.name}</b> | |
| 364 | + {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null} | |
| 365 | + {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null} | |
| 366 | + </div> | |
| 367 | + <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span> | |
| 368 | + </a> | |
| 369 | + ))} | |
| 370 | + </div> | |
| 371 | + ))} | |
| 372 | + {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null} | |
| 373 | + {artifacts.length ? ( | |
| 374 | + <div class="box"> | |
| 375 | + <div class="head"> | |
| 376 | + <h3>{raw(ICONS.download)} Artifacts</h3> | |
| 377 | + </div> | |
| 378 | + <ul class="list"> | |
| 379 | + {artifacts.map((a) => ( | |
| 380 | + <li> | |
| 381 | + <div class="grow"> | |
| 382 | + <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}> | |
| 383 | + {a.name} | |
| 384 | + </a> | |
| 385 | + <div class="meta"> | |
| 386 | + {fmtBytes(a.size)} · <Time at={a.createdAt} /> | |
| 387 | + </div> | |
| 388 | + </div> | |
| 389 | + </li> | |
| 390 | + ))} | |
| 391 | + </ul> | |
| 392 | + </div> | |
| 393 | + ) : null} | |
| 394 | + </div>, | |
| 395 | + ); | |
| 396 | + }); | |
| 397 | + | |
| 398 | + post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => { | |
| 399 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 400 | + const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; | |
| 401 | + return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested."); | |
| 402 | + }); | |
| 403 | + | |
| 404 | + post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => { | |
| 405 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 406 | + const base = repoUrl(l.repo); | |
| 407 | + return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => { | |
| 408 | + const run = await l.stub.rerun(prm(c, "runId"), l.caller); | |
| 409 | + return `${base}/actions/runs/${run.id}`; | |
| 410 | + }); | |
| 411 | + }); | |
| 412 | + | |
| 413 | + web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => { | |
| 414 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 415 | + const { repo, caller, stub } = l; | |
| 416 | + const base = repoUrl(repo); | |
| 417 | + const runId = prm(c, "runId"); | |
| 418 | + const jobId = prm(c, "jobId"); | |
| 419 | + const run = (await stub.run(runId)) as unknown as RunWithJobs; | |
| 420 | + const job = (await stub.job(jobId)) as unknown as JobInfo; | |
| 421 | + if (job.runId !== run.id) throw new HttpError(404, "job not found"); | |
| 422 | + const logs = await allLogs(l, jobId); | |
| 423 | + const byStep = new Map<number, LogLine[]>(); | |
| 424 | + for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]); | |
| 425 | + const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]); | |
| 426 | + const sections = [...stepIdx].sort((a, b) => a - b); | |
| 427 | + const stepOf = new Map(job.steps.map((s) => [s.index, s])); | |
| 428 | + const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1; | |
| 429 | + const live = job.status !== "completed"; | |
| 430 | + const failedStep = job.failure?.step; | |
| 431 | + return render( | |
| 432 | + c, | |
| 433 | + { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true }, | |
| 434 | + <div class="grid left"> | |
| 435 | + <aside class="side"> | |
| 436 | + <section> | |
| 437 | + <a href={`${base}/actions/runs/${run.id}`} class="small"> | |
| 438 | + ← {run.workflowName} #{run.number} | |
| 439 | + </a> | |
| 440 | + <h3 class="mt">Jobs</h3> | |
| 441 | + <div class="nav"> | |
| 442 | + {run.jobs.map((j) => ( | |
| 443 | + <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center"> | |
| 444 | + <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span> | |
| 445 | + </a> | |
| 446 | + ))} | |
| 447 | + </div> | |
| 448 | + </section> | |
| 449 | + </aside> | |
| 450 | + <div> | |
| 451 | + <div class="row between mb"> | |
| 452 | + <h1 class="row m0" style="gap:8px"> | |
| 453 | + <Status status={job.status} conclusion={job.conclusion} /> {job.name} | |
| 454 | + </h1> | |
| 455 | + <div class="row"> | |
| 456 | + <span class="muted small"> | |
| 457 | + {job.startedAt ? ( | |
| 458 | + <> | |
| 459 | + {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)} | |
| 460 | + </> | |
| 461 | + ) : ( | |
| 462 | + job.status | |
| 463 | + )} | |
| 464 | + </span> | |
| 465 | + <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}> | |
| 466 | + {raw(ICONS.download)} Raw logs | |
| 467 | + </a> | |
| 468 | + </div> | |
| 469 | + </div> | |
| 470 | + {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null} | |
| 471 | + {job.failure ? ( | |
| 472 | + <div class="failure"> | |
| 473 | + <b> | |
| 474 | + {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"} | |
| 475 | + {failedStep !== undefined && failedStep !== null ? ( | |
| 476 | + <> | |
| 477 | + {" "} | |
| 478 | + at{" "} | |
| 479 | + <a href={`#step-${failedStep}`} class="bad"> | |
| 480 | + {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`} | |
| 481 | + </a> | |
| 482 | + </> | |
| 483 | + ) : null} | |
| 484 | + </b> | |
| 485 | + <pre>{job.failure.message}</pre> | |
| 486 | + </div> | |
| 487 | + ) : null} | |
| 488 | + {logs.truncated ? ( | |
| 489 | + <div class="flash info"> | |
| 490 | + Showing the first {MAX_LOG_LINES.toLocaleString("en-US")} lines. <a href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>Download the full log</a>. | |
| 491 | + </div> | |
| 492 | + ) : null} | |
| 493 | + <div class="log"> | |
| 494 | + {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null} | |
| 495 | + {sections.map((idx) => { | |
| 496 | + const s = stepOf.get(idx); | |
| 497 | + const lines = byStep.get(idx) ?? []; | |
| 498 | + const failed = s?.conclusion === "failure" || failedStep === idx; | |
| 499 | + const running = s?.status === "in_progress"; | |
| 500 | + return ( | |
| 501 | + <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}> | |
| 502 | + <summary> | |
| 503 | + {s ? ( | |
| 504 | + <Status status={s.status} conclusion={s.conclusion} /> | |
| 505 | + ) : idx < 0 ? ( | |
| 506 | + <Status conclusion={job.startedAt ? "success" : "queued"} /> | |
| 507 | + ) : ( | |
| 508 | + <Status conclusion="neutral" /> | |
| 509 | + )} | |
| 510 | + {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)} | |
| 511 | + <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span> | |
| 512 | + </summary> | |
| 513 | + <pre> | |
| 514 | + {lines.map((ln) => { | |
| 515 | + const k = classify(ln.text); | |
| 516 | + return <span class={k.cls || undefined}>{k.text}</span>; | |
| 517 | + })} | |
| 518 | + </pre> | |
| 519 | + </details> | |
| 520 | + ); | |
| 521 | + })} | |
| 522 | + </div> | |
| 523 | + {job.summary ? ( | |
| 524 | + <div class="box"> | |
| 525 | + <div class="head"> | |
| 526 | + <h3>Job summary</h3> | |
| 527 | + </div> | |
| 528 | + <div class="body"> | |
| 529 | + <Markdown text={job.summary} /> | |
| 530 | + </div> | |
| 531 | + </div> | |
| 532 | + ) : null} | |
| 533 | + {Object.keys(job.outputs ?? {}).length ? ( | |
| 534 | + <div class="box"> | |
| 535 | + <div class="head"> | |
| 536 | + <h3>Outputs</h3> | |
| 537 | + </div> | |
| 538 | + <div class="body"> | |
| 539 | + <dl class="kv"> | |
| 540 | + {Object.entries(job.outputs).map(([k, v]) => ( | |
| 541 | + <> | |
| 542 | + <dt class="mono">{k}</dt> | |
| 543 | + <dd class="mono" style="word-break:break-all"> | |
| 544 | + {v} | |
| 545 | + </dd> | |
| 546 | + </> | |
| 547 | + ))} | |
| 548 | + </dl> | |
| 549 | + </div> | |
| 550 | + </div> | |
| 551 | + ) : null} | |
| 552 | + </div> | |
| 553 | + </div>, | |
| 554 | + ); | |
| 555 | + }); | |
| 556 | + | |
| 557 | + web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => { | |
| 558 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 559 | + const job = await l.stub.job(prm(c, "jobId")); | |
| 560 | + if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found"); | |
| 561 | + const out: string[] = []; | |
| 562 | + let after = -1; | |
| 563 | + for (let i = 0; i < 40; i++) { | |
| 564 | + const page = await l.stub.jobLogs(job.id, after); | |
| 565 | + for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`); | |
| 566 | + if (page.lines.length < 5000) break; | |
| 567 | + after = page.lines[page.lines.length - 1]!.seq; | |
| 568 | + } | |
| 569 | + return new Response(`${out.join("\n")}\n`, { | |
| 570 | + headers: { | |
| 571 | + "content-type": "text/plain; charset=utf-8", | |
| 572 | + "x-content-type-options": "nosniff", | |
| 573 | + "content-security-policy": "sandbox", | |
| 574 | + "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`, | |
| 575 | + "cache-control": "private, no-store", | |
| 576 | + }, | |
| 577 | + }); | |
| 578 | + }); | |
| 579 | +} |
addedapps/diffinery/src/web/assets.ts+391−0View file
| @@ -1,0 +1,391 @@ | ||
| 1 | +/** Inline stylesheet, client script and icons for the web UI (no external assets). */ | |
| 2 | + | |
| 3 | +export const CSS = ` | |
| 4 | +:root{color-scheme:light dark; | |
| 5 | +/* Colors, radii and type follow Cloudflare's Kumo design system tokens (kumo-ui.com, MIT). */ | |
| 6 | +--canvas:oklch(98.75% 0 0);--bg:#fff;--bg2:oklch(98% 0 0);--bg3:oklch(96% 0 0);--bg-hover:oklch(97% 0 0); | |
| 7 | +--fg:oklch(21% 0.006 285.885);--fg2:oklch(55.6% 0 0);--fg3:oklch(70.8% 0 0); | |
| 8 | +--border:oklch(14.5% 0 0 / .1);--border2:oklch(93.5% 0 0); | |
| 9 | +--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(93.2% 0.032 255.6 / .45); | |
| 10 | +--link:oklch(42.4% 0.199 265.638);--focus:oklch(15% 0 0); | |
| 11 | +--green:oklch(43.2% 0.095 166.913);--green-soft:oklch(96.2% 0.043 156.7 / .57);--red:oklch(50.5% 0.213 27.518);--red-soft:oklch(93.6% 0.032 17.7 / .42); | |
| 12 | +--amber:oklch(59.7% 0.144 57.5);--amber-soft:oklch(93.1% 0.107 94.6 / .2); | |
| 13 | +--purple:oklch(55.8% 0.288 302.321);--purple-soft:oklch(55.8% 0.288 302.321 / .1);--blue:oklch(42.4% 0.199 265.638);--blue-soft:oklch(93.2% 0.032 255.6 / .45); | |
| 14 | +--brand:#f6821f; | |
| 15 | +--add-bg:oklch(96.2% 0.043 156.7 / .57);--add-ln:oklch(90.5% 0.093 164.15 / .7);--del-bg:oklch(93.6% 0.032 17.7 / .42);--del-ln:oklch(88.5% 0.062 18.334 / .7);--hunk-bg:oklch(93.2% 0.032 255.6 / .45);--hl:oklch(97.3% 0.071 103.193); | |
| 16 | +--code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem; | |
| 17 | +--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; | |
| 18 | +--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"} | |
| 19 | +@media (prefers-color-scheme:dark){:root{ | |
| 20 | +--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0); | |
| 21 | +--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0); | |
| 22 | +--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0); | |
| 23 | +--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22); | |
| 24 | +--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0); | |
| 25 | +--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17); | |
| 26 | +--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37); | |
| 27 | +--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22); | |
| 28 | +--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5); | |
| 29 | +--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}} | |
| 30 | +*{box-sizing:border-box} | |
| 31 | +html{-webkit-text-size-adjust:100%} | |
| 32 | +body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)} | |
| 33 | +a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} | |
| 34 | +code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px} | |
| 35 | +kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)} | |
| 36 | +h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px} | |
| 37 | +p{margin:0 0 10px} | |
| 38 | +hr{border:0;border-top:1px solid var(--border);margin:16px 0} | |
| 39 | +.muted{color:var(--fg2)}.faint{color:var(--fg3)}.small{font-size:12px}.nowrap{white-space:nowrap} | |
| 40 | +.ok{color:var(--green)}.bad{color:var(--red)}.warn{color:var(--amber)}.run{color:var(--amber)}.merged{color:var(--purple)} | |
| 41 | +.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)} | |
| 42 | +.top .wrap{display:flex;align-items:center;gap:14px;height:52px} | |
| 43 | +.brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em} | |
| 44 | +.brand:hover{text-decoration:none}.brand svg{color:var(--brand)} | |
| 45 | +.top nav{display:flex;align-items:center;gap:4px;margin-left:auto} | |
| 46 | +.top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none} | |
| 47 | +.pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer} | |
| 48 | +.pal-btn:hover{border-color:var(--fg3)}.pal-btn kbd{margin-left:auto} | |
| 49 | +.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:top} | |
| 50 | +.wrap{max-width:1280px;margin:0 auto;padding:0 20px} | |
| 51 | +main.wrap{padding-top:20px;padding-bottom:60px} | |
| 52 | +.flash{border:1px solid;border-radius:var(--r);padding:10px 14px;margin:0 0 16px} | |
| 53 | +.flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)} | |
| 54 | +.flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)} | |
| 55 | +.flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)} | |
| 56 | +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important} | |
| 57 | +.btn:hover{background:var(--bg-hover);border-color:var(--fg3)} | |
| 58 | +.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)} | |
| 59 | +.btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)} | |
| 60 | +.btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff} | |
| 61 | +.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0} | |
| 62 | +.btn[disabled]{opacity:.5;cursor:not-allowed} | |
| 63 | +input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{ | |
| 64 | +width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit} | |
| 65 | +textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre} | |
| 66 | +input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)} | |
| 67 | +:focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px} | |
| 68 | +input[type=checkbox],input[type=radio]{accent-color:var(--accent)} | |
| 69 | +label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400} | |
| 70 | +.field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400} | |
| 71 | +.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between} | |
| 72 | +.grow{flex:1;min-width:0} | |
| 73 | +.grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px} | |
| 74 | +.grid.left{grid-template-columns:260px minmax(0,1fr)} | |
| 75 | +@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}} | |
| 76 | +.box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden} | |
| 77 | +.box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap} | |
| 78 | +.box>.body{padding:14px} | |
| 79 | +.box>.head h2,.box>.head h3{margin:0;font-size:14px} | |
| 80 | +.list{list-style:none;margin:0;padding:0} | |
| 81 | +.list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)} | |
| 82 | +.list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)} | |
| 83 | +.list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)} | |
| 84 | +.list .meta{font-size:12px;color:var(--fg2);margin-top:2px} | |
| 85 | +.empty{padding:32px 16px;text-align:center;color:var(--fg2)} | |
| 86 | +.blank{border:1px dashed var(--border);border-radius:var(--r);padding:32px;text-align:center;color:var(--fg2)} | |
| 87 | +.badge{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border:1px solid var(--border);border-radius:999px;font-size:12px;font-weight:500;color:var(--fg2);line-height:18px;white-space:nowrap;vertical-align:middle} | |
| 88 | +.badge.agent{border-color:color-mix(in srgb,var(--purple) 45%,transparent);color:var(--purple);background:var(--purple-soft)} | |
| 89 | +.badge.green{border-color:transparent;background:var(--green-soft);color:var(--green)} | |
| 90 | +.badge.red{border-color:transparent;background:var(--red-soft);color:var(--red)} | |
| 91 | +.badge.amber{border-color:transparent;background:var(--amber-soft);color:var(--amber)} | |
| 92 | +.badge.purple{border-color:transparent;background:var(--purple-soft);color:var(--purple)} | |
| 93 | +.badge.blue{border-color:transparent;background:var(--blue-soft);color:var(--blue)} | |
| 94 | +.state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff} | |
| 95 | +.state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)} | |
| 96 | +.lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px} | |
| 97 | +.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle} | |
| 98 | +.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px} | |
| 99 | +.who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)} | |
| 100 | +.repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)} | |
| 101 | +.repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px} | |
| 102 | +.repohead h1 a{color:var(--fg)}.repohead h1 strong a{font-weight:650} | |
| 103 | +.tabs{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;margin-top:10px} | |
| 104 | +.tabs a{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;color:var(--fg2);border-bottom:2px solid transparent;white-space:nowrap} | |
| 105 | +.tabs a:hover{color:var(--fg);text-decoration:none;border-bottom-color:var(--border)} | |
| 106 | +.tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)} | |
| 107 | +.tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)} | |
| 108 | +.subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%} | |
| 109 | +.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0} | |
| 110 | +.subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)} | |
| 111 | +.side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px} | |
| 112 | +.side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)} | |
| 113 | +.side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none} | |
| 114 | +details.menu{position:relative;display:inline-block} | |
| 115 | +details.menu>summary{list-style:none;cursor:pointer}details.menu>summary::-webkit-details-marker{display:none} | |
| 116 | +details.menu>.pop{position:absolute;z-index:30;top:calc(100% + 4px);left:0;min-width:260px;max-height:360px;overflow:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:6px} | |
| 117 | +details.menu>.pop.right{left:auto;right:0} | |
| 118 | +details.menu .pop a{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:5px;color:var(--fg)}details.menu .pop a:hover{background:var(--bg-hover);text-decoration:none} | |
| 119 | +details.menu .pop h4{font-size:11px;text-transform:uppercase;color:var(--fg3);margin:6px 8px 4px} | |
| 120 | +.crumbs{font-size:15px;display:flex;flex-wrap:wrap;align-items:center;gap:4px}.crumbs a{font-weight:600} | |
| 121 | +table.files{width:100%;border-collapse:collapse} | |
| 122 | +table.files td{padding:7px 14px;border-top:1px solid var(--border2)}table.files tr:first-child td{border-top:0} | |
| 123 | +table.files tr:hover td{background:var(--bg2)}table.files td.ic{width:20px;padding-right:0;color:var(--fg3)} | |
| 124 | +table.files td.sz{text-align:right;color:var(--fg2);width:100px;white-space:nowrap} | |
| 125 | +table.files a{color:var(--fg)}table.files a:hover{color:var(--accent)} | |
| 126 | +.ic-dir{color:var(--accent)!important} | |
| 127 | +.clone{display:flex;gap:6px;align-items:center} | |
| 128 | +.clone input{font-family:var(--mono);font-size:12px} | |
| 129 | +pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px} | |
| 130 | +.code-wrap{overflow:auto} | |
| 131 | +table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px} | |
| 132 | +table.blob td{padding:0 12px;vertical-align:top;white-space:pre} | |
| 133 | +table.blob td.ln{width:1%;min-width:50px;text-align:right;color:var(--fg3);user-select:none;padding-right:10px} | |
| 134 | +table.blob td.ln a{color:inherit}table.blob tr:target td{background:var(--hl)} | |
| 135 | +table.diff{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12px;line-height:20px} | |
| 136 | +table.diff td{padding:0 8px;vertical-align:top} | |
| 137 | +table.diff td.ln{width:1%;min-width:44px;text-align:right;color:var(--fg3);user-select:none;white-space:nowrap} | |
| 138 | +table.diff td.code{white-space:pre-wrap;word-break:break-all;width:100%} | |
| 139 | +table.diff tr.add td{background:var(--add-bg)}table.diff tr.add td.ln{background:var(--add-ln)} | |
| 140 | +table.diff tr.del td{background:var(--del-bg)}table.diff tr.del td.ln{background:var(--del-ln)} | |
| 141 | +table.diff tr.hunk td{background:var(--hunk-bg);color:var(--fg2);padding-top:2px;padding-bottom:2px} | |
| 142 | +table.diff tr.meta td{color:var(--fg3);font-style:italic} | |
| 143 | +table.diff td.act{width:1%;padding:0 2px} | |
| 144 | +table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0} | |
| 145 | +table.diff tr:hover .addc{visibility:visible} | |
| 146 | +table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal} | |
| 147 | +.filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap} | |
| 148 | +details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:hidden} | |
| 149 | +details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none} | |
| 150 | +details.file:not([open])>.filehead{border-bottom:0} | |
| 151 | +.stat-add{color:var(--green);font-weight:600}.stat-del{color:var(--red);font-weight:600} | |
| 152 | +.bar{display:inline-flex;gap:1px;vertical-align:middle}.bar i{width:8px;height:8px;border-radius:1px;background:var(--bg3)}.bar i.a{background:var(--green)}.bar i.d{background:var(--red)} | |
| 153 | +.comment{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;background:var(--bg)} | |
| 154 | +.comment>.head{display:flex;gap:8px;align-items:center;padding:8px 14px;background:var(--bg2);border-bottom:1px solid var(--border);border-radius:var(--r) var(--r) 0 0;flex-wrap:wrap;font-size:13px} | |
| 155 | +.comment>.body{padding:14px} | |
| 156 | +.comment.intent{border-color:color-mix(in srgb,var(--accent) 45%,transparent)}.comment.intent>.head{background:var(--accent-soft)} | |
| 157 | +.timeline{position:relative} | |
| 158 | +.event{display:flex;gap:10px;align-items:center;padding:6px 0 6px 14px;margin:0 0 16px;color:var(--fg2);font-size:13px} | |
| 159 | +.event .ic{display:inline-flex;width:28px;height:28px;border-radius:50%;align-items:center;justify-content:center;background:var(--bg3);flex:none} | |
| 160 | +.event.approved .ic{background:var(--green-soft);color:var(--green)}.event.changes .ic{background:var(--red-soft);color:var(--red)} | |
| 161 | +.linec{border-left:3px solid var(--border);padding:6px 10px;margin:8px 0;background:var(--bg2);border-radius:0 6px 6px 0} | |
| 162 | +.linec .where{font-family:var(--mono);font-size:12px;color:var(--fg2)} | |
| 163 | +.mergebox{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;overflow:hidden} | |
| 164 | +.mergebox>div{padding:12px 14px;border-top:1px solid var(--border2)}.mergebox>div:first-child{border-top:0} | |
| 165 | +.mergebox.clean{border-color:color-mix(in srgb,var(--green) 50%,transparent)}.mergebox.blocked,.mergebox.conflicting{border-color:color-mix(in srgb,var(--amber) 55%,transparent)} | |
| 166 | +.mergebox.merged{border-color:color-mix(in srgb,var(--purple) 50%,transparent)} | |
| 167 | +.checks{list-style:none;margin:0;padding:0}.checks li{display:flex;gap:8px;align-items:center;padding:4px 0} | |
| 168 | +.st{display:inline-flex;width:16px;height:16px;flex:none;align-items:center;justify-content:center} | |
| 169 | +.st svg{width:16px;height:16px} | |
| 170 | +.st.spin svg{animation:spin 1.1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} | |
| 171 | +.job{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--border2);color:var(--fg)} | |
| 172 | +.job:hover{background:var(--bg2);text-decoration:none}.job:first-of-type{border-top:0} | |
| 173 | +.jobgroup{margin:0 0 16px} | |
| 174 | +.failure{border:1px solid color-mix(in srgb,var(--red) 45%,transparent);background:var(--red-soft);border-radius:var(--r);padding:12px 14px;margin:0 0 16px} | |
| 175 | +.failure pre{white-space:pre-wrap;margin:6px 0 0;color:var(--fg)} | |
| 176 | +.log{background:#0d1014;color:#d5d9de;border-radius:var(--r);overflow:hidden;margin:0 0 16px;border:1px solid var(--border)} | |
| 177 | +.log details{border-top:1px solid #1d2229}.log details:first-child{border-top:0} | |
| 178 | +.log summary{display:flex;gap:10px;align-items:center;padding:8px 14px;cursor:pointer;list-style:none;color:#e6e8eb} | |
| 179 | +.log summary::-webkit-details-marker{display:none}.log summary:hover{background:#151a20} | |
| 180 | +.log summary .dur{margin-left:auto;color:#7f8792;font-size:12px} | |
| 181 | +.log pre{margin:0;padding:6px 0 10px;overflow:auto;font-size:12px;line-height:19px;counter-reset:l} | |
| 182 | +.log pre span{display:block;padding:0 14px 0 64px;position:relative;white-space:pre-wrap;word-break:break-all;min-height:19px} | |
| 183 | +.log pre span::before{counter-increment:l;content:counter(l);position:absolute;left:0;width:48px;text-align:right;color:#59616c} | |
| 184 | +.log pre span.grp{color:#8fb3ff;font-weight:600}.log pre span.err{color:#ff8c84}.log pre span.wrn{color:#e5c07b} | |
| 185 | +.markdown{line-height:1.65;word-wrap:break-word} | |
| 186 | +.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0} | |
| 187 | +.markdown h1,.markdown h2{padding-bottom:6px;border-bottom:1px solid var(--border2);margin:22px 0 12px} | |
| 188 | +.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin:18px 0 8px} | |
| 189 | +.markdown h1 .anchor,.markdown h2 .anchor,.markdown h3 .anchor,.markdown h4 .anchor,.markdown h5 .anchor,.markdown h6 .anchor{float:left;margin-left:-18px;padding-right:4px;opacity:0;color:var(--fg3)} | |
| 190 | +.markdown :is(h1,h2,h3,h4,h5,h6):hover .anchor{opacity:1} | |
| 191 | +.markdown p,.markdown ul,.markdown ol,.markdown blockquote,.markdown pre,.markdown .table-wrap{margin:0 0 14px} | |
| 192 | +.markdown ul,.markdown ol{padding-left:2em}.markdown li+li{margin-top:3px}.markdown li.task{list-style:none;margin-left:-1.4em} | |
| 193 | +.markdown code{background:var(--code-bg);padding:.15em .4em;border-radius:5px;font-size:85%} | |
| 194 | +.markdown pre{background:var(--code-bg);padding:14px;border-radius:7px;overflow:auto;line-height:1.45;border:1px solid var(--border2)} | |
| 195 | +.markdown pre code{background:none;padding:0;font-size:12.5px} | |
| 196 | +.markdown blockquote{padding:0 1em;color:var(--fg2);border-left:3px solid var(--border)} | |
| 197 | +.markdown img{max-width:100%}.markdown hr{height:2px;background:var(--border2);border:0} | |
| 198 | +.markdown .table-wrap{overflow:auto}.markdown table{border-collapse:collapse} | |
| 199 | +.markdown th,.markdown td{border:1px solid var(--border);padding:6px 12px}.markdown th{background:var(--bg2);font-weight:600} | |
| 200 | +.readme{margin-top:16px}.readme>.body{padding:24px 32px} | |
| 201 | +@media (max-width:700px){.readme>.body{padding:16px}.wrap{padding:0 12px}} | |
| 202 | +dialog#palette{border:1px solid var(--border);border-radius:12px;padding:0;width:min(640px,94vw);background:var(--bg);color:var(--fg);box-shadow:var(--shadow);margin-top:12vh} | |
| 203 | +dialog#palette::backdrop{background:rgba(10,12,16,.45)} | |
| 204 | +#palette input{border:0;border-bottom:1px solid var(--border);border-radius:0;padding:14px 16px;font-size:15px;outline:none} | |
| 205 | +#palette ul{list-style:none;margin:0;padding:6px;max-height:min(420px,60vh);overflow:auto} | |
| 206 | +#palette li a{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;color:var(--fg)} | |
| 207 | +#palette li a span{margin-left:auto;color:var(--fg3);font-size:12px} | |
| 208 | +#palette li.sel a,#palette li a:hover{background:var(--accent-soft);text-decoration:none} | |
| 209 | +#palette .foot{display:flex;gap:14px;padding:8px 14px;border-top:1px solid var(--border);font-size:12px;color:var(--fg3)} | |
| 210 | +.hero{padding:72px 0 40px;text-align:center} | |
| 211 | +.hero h1{font-size:44px;letter-spacing:-.03em;margin:0 0 14px} | |
| 212 | +.hero p{font-size:18px;color:var(--fg2);max-width:640px;margin:0 auto 26px} | |
| 213 | +.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0} | |
| 214 | +.features .box{padding:18px}.features h3{margin:0 0 6px} | |
| 215 | +.stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px} | |
| 216 | +.stats b{color:var(--fg)} | |
| 217 | +.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px}.kv dt{color:var(--fg2)}.kv dd{margin:0} | |
| 218 | +table.tbl{width:100%;border-collapse:collapse;font-size:13px} | |
| 219 | +table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap} | |
| 220 | +table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle} | |
| 221 | +.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all} | |
| 222 | +.pager{display:flex;gap:8px;justify-content:center;margin:16px 0} | |
| 223 | +.sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)} | |
| 224 | +a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)} | |
| 225 | +.right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0} | |
| 226 | +form.inline{display:inline} | |
| 227 | +.navbtn{background:none;border:0;color:var(--fg2);padding:6px 10px;border-radius:6px;font:inherit;cursor:pointer}.navbtn:hover{background:var(--bg-hover);color:var(--fg)} | |
| 228 | +.btn.block{display:flex;width:100%;justify-content:center} | |
| 229 | +.authcard{max-width:440px;margin:56px auto}.authcard .body{padding:28px}.authcard h1{font-size:24px;margin:12px 0 8px} | |
| 230 | +.authmark{width:44px;height:44px;border-radius:var(--r);display:grid;place-items:center;background:color-mix(in srgb,var(--brand) 14%,transparent);color:var(--brand)}.authmark svg{width:22px;height:22px} | |
| 231 | +.authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px} | |
| 232 | +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} | |
| 233 | +`; | |
| 234 | + | |
| 235 | +/** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */ | |
| 236 | +export const JS = `(()=>{ | |
| 237 | +const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)]; | |
| 238 | +const repo=b.dataset.repo||'',ref=b.dataset.ref||''; | |
| 239 | +const enc=p=>p.split('/').map(encodeURIComponent).join('/'); | |
| 240 | +d.addEventListener('click',e=>{ | |
| 241 | + const c=e.target.closest('[data-copy]'); | |
| 242 | + if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return} | |
| 243 | + const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return} | |
| 244 | + const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return} | |
| 245 | + const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()} | |
| 246 | + if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open')); | |
| 247 | +}); | |
| 248 | +d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.passkey){e.preventDefault();passkey(f);return} | |
| 249 | + if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return} | |
| 250 | + if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}} | |
| 251 | + $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true); | |
| 252 | +// Passkeys (WebAuthn): forms with data-passkey="login" or "register" | |
| 253 | +const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')}; | |
| 254 | +const u2b=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const bin=atob(s);const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}; | |
| 255 | +async function pkPost(url,body){const r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); | |
| 256 | + let j={};try{j=await r.json()}catch(_){}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j} | |
| 257 | +async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f); | |
| 258 | + const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)}; | |
| 259 | + if(!window.PublicKeyCredential||!navigator.credentials){show('This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox.');return} | |
| 260 | + show('');btns.forEach(x=>{x.disabled=true}); | |
| 261 | + const data={};new FormData(f).forEach((v,k)=>{if(typeof v==='string')data[k]=v});data.next=f.dataset.next||'/'; | |
| 262 | + try{const o=await pkPost('/auth/passkey/'+mode+'/options',data);o.challenge=u2b(o.challenge);let cred,res; | |
| 263 | + if(mode==='login'){(o.allowCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.get({publicKey:o});const r=cred.response; | |
| 264 | + res={authenticatorData:b2u(r.authenticatorData),clientDataJSON:b2u(r.clientDataJSON),signature:b2u(r.signature)};if(r.userHandle)res.userHandle=b2u(r.userHandle)} | |
| 265 | + else{o.user.id=u2b(o.user.id);(o.excludeCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.create({publicKey:o});const r=cred.response; | |
| 266 | + res={attestationObject:b2u(r.attestationObject),clientDataJSON:b2u(r.clientDataJSON),transports:r.getTransports?r.getTransports():[]}} | |
| 267 | + const v=await pkPost('/auth/passkey/'+mode+'/verify',Object.assign({},data,{response:{id:cred.id,rawId:b2u(cred.rawId),type:cred.type,response:res,clientExtensionResults:cred.getClientExtensionResults(),authenticatorAttachment:cred.authenticatorAttachment||undefined}})); | |
| 268 | + location.assign(v.redirect||'/')} | |
| 269 | + catch(e){btns.forEach(x=>{x.disabled=false}); | |
| 270 | + show(e&&e.name==='NotAllowedError'?'The passkey prompt was closed or timed out. Try again when you are ready.':e&&e.name==='InvalidStateError'?'This device already has a passkey for this account.':(e&&e.message)||String(e))}} | |
| 271 | +function lineComment(btn){ | |
| 272 | + const tpl=$('#line-comment-tpl');if(!tpl)return; | |
| 273 | + const tr=btn.closest('tr');const next=tr.nextElementSibling; | |
| 274 | + if(next&&next.classList.contains('newc')){next.querySelector('textarea').focus();return} | |
| 275 | + const row=d.createElement('tr');row.className='thread newc';const td=d.createElement('td');td.colSpan=tr.children.length; | |
| 276 | + td.append(tpl.content.cloneNode(true));row.append(td);tr.after(row); | |
| 277 | + row.querySelector('[name=path]').value=btn.dataset.path;row.querySelector('[name=line]').value=btn.dataset.commentLine; | |
| 278 | + row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine; | |
| 279 | + row.querySelector('textarea').focus(); | |
| 280 | +} | |
| 281 | +// Palette | |
| 282 | +const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false; | |
| 283 | +function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'})); | |
| 284 | + if(repo){const r='/'+repo;[['Code',''],['Issues','/issues'],['New issue','/issues/new'],['Pull requests','/pulls'],['Actions','/actions'],['Branches','/branches'],['Tags','/tags'],['Commits','/commits/'+enc(ref||'HEAD')],['Compare','/compare'],['Settings','/settings']].forEach(([t,p])=>a.unshift({t:repo+' › '+t,u:r+p,k:'Repository'}))} | |
| 285 | + return a} | |
| 286 | +async function load(){if(items||loading)return;loading=true;items=base();draw(); | |
| 287 | + try{const r=await fetch('/-/palette.json'+(repo?'?repo='+encodeURIComponent(repo)+'&ref='+encodeURIComponent(ref):''),{credentials:'same-origin'}); | |
| 288 | + if(r.ok){const j=await r.json();(j.repos||[]).forEach(x=>items.push({t:x,u:'/'+x,k:'Repository'}));(j.files||[]).forEach(f=>items.push({t:f,u:'/'+repo+'/blob/'+enc(j.ref||ref||'HEAD')+'/'+enc(f),k:'File'}));draw()}}catch(_){}} | |
| 289 | +function score(q,s){if(!q)return 1;let i=0,sc=0,last=-2;const lo=s.toLowerCase();const at=lo.indexOf(q);if(at>=0)return 1000-at-(s.length/100); | |
| 290 | + for(const ch of q){const k=lo.indexOf(ch,i);if(k<0)return -1;sc+=k===last+1?5:1;if(k===0||'/ -_.'.includes(lo[k-1]))sc+=3;last=k;i=k+1}return sc-(s.length/100)} | |
| 291 | +function draw(){if(!ul)return;const q=inp.value.trim().toLowerCase();let list=(items||[]).map(x=>[score(q,x.t),x]).filter(a=>a[0]>=0).sort((a,b)=>b[0]-a[0]).slice(0,60).map(a=>a[1]); | |
| 292 | + const m=/^#?(\\d+)$/.exec(q);if(m&&repo)list.unshift({t:'Go to #'+m[1],u:'/'+repo+'/issues/'+m[1],k:'Issue / PR'}); | |
| 293 | + shown=list;if(sel>=shown.length)sel=Math.max(0,shown.length-1); | |
| 294 | + ul.replaceChildren(...shown.map((x,i)=>{const li=d.createElement('li');if(i===sel)li.className='sel';const a=d.createElement('a');a.href=x.u;a.textContent=x.t;const k=d.createElement('span');k.textContent=x.k;a.append(k);li.append(a);return li})); | |
| 295 | + const s=$('li.sel',ul);s&&s.scrollIntoView({block:'nearest'})} | |
| 296 | +function openPalette(){if(!dlg)return;if(!dlg.open)dlg.showModal();inp.value='';sel=0;load();draw();inp.focus()} | |
| 297 | +if(dlg){inp.addEventListener('input',()=>{sel=0;draw()}); | |
| 298 | + inp.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();sel=Math.min(sel+1,shown.length-1);draw()}else if(e.key==='ArrowUp'){e.preventDefault();sel=Math.max(sel-1,0);draw()}else if(e.key==='Enter'){e.preventDefault();const x=shown[sel];if(x)location.href=x.u}}); | |
| 299 | + dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()})} | |
| 300 | +// Shortcuts | |
| 301 | +let g=0; | |
| 302 | +d.addEventListener('keydown',e=>{ | |
| 303 | + if((e.metaKey||e.ctrlKey)&&!e.altKey&&e.key.toLowerCase()==='k'){e.preventDefault();openPalette();return} | |
| 304 | + const t=e.target;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable],dialog'))return; | |
| 305 | + if(e.metaKey||e.ctrlKey||e.altKey)return; | |
| 306 | + if(g&&Date.now()-g<1500){g=0;const m={c:'',i:'/issues',p:'/pulls',a:'/actions',s:'/settings'}[e.key];if(repo&&m!==undefined){e.preventDefault();location.href='/'+repo+m}else if(e.key==='d'){location.href='/'}else if(e.key==='n'){location.href='/notifications'}return} | |
| 307 | + if(e.key==='g'){g=Date.now();return} | |
| 308 | + if(e.key==='t'&&repo){e.preventDefault();openPalette();return} | |
| 309 | + if(e.key==='/'){const s=$('input[type=search]');if(s){e.preventDefault();s.focus()}} | |
| 310 | +}); | |
| 311 | +// Live updates | |
| 312 | +const live=b.dataset.live,mode=b.dataset.liveMode||'reload'; | |
| 313 | +if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false; | |
| 314 | + function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live)); | |
| 315 | + ws.onopen=()=>{if(opened){if(mode==='log')location.reload();else refresh()}opened=true;tries=0}; | |
| 316 | + ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)}; | |
| 317 | + ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}} | |
| 318 | + setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000); | |
| 319 | + function handle(m){const a=m.data&&m.data.action; | |
| 320 | + if(mode==='log'){if(!String(m.topic).startsWith('job:'))return; | |
| 321 | + if(a==='logs'){append(m.data.lines||[]);return} | |
| 322 | + if(a==='step'&&m.data.step){stepState(m.data.step);return} | |
| 323 | + if(a==='completed'){setTimeout(()=>location.reload(),700)}return} | |
| 324 | + clearTimeout(timer);timer=setTimeout(refresh,350)} | |
| 325 | + async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return} | |
| 326 | + try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html'); | |
| 327 | + for(const f of frags){if(f.contains(d.activeElement)&&d.activeElement!==b)continue;const n=doc.querySelector('[data-fragment="'+f.dataset.fragment+'"]');if(n)f.replaceWith(d.importNode(n,true))}}catch(_){}} | |
| 328 | + function clean(s){return s.replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g,'')} | |
| 329 | + function section(step){let s=$('.log [data-step="'+step+'"]');if(!s){const log=$('.log');if(!log)return null;s=d.createElement('details');s.open=true;s.dataset.step=step; | |
| 330 | + const sm=d.createElement('summary');sm.innerHTML='<span class="st spin run"></span>';sm.append(step<0?'Set up job':'Step '+(step+1));s.append(sm,d.createElement('pre'));log.append(s)} | |
| 331 | + return $('pre',s)} | |
| 332 | + function append(lines){const follow=(innerHeight+scrollY)>=d.body.scrollHeight-80; | |
| 333 | + for(const l of lines){if(l.seq<=lastSeq)continue;lastSeq=l.seq;const pre=section(l.step);if(!pre)continue;const sp=d.createElement('span');const t=clean(l.text); | |
| 334 | + if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t; | |
| 335 | + pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true} | |
| 336 | + if(follow)scrollTo(0,d.body.scrollHeight)} | |
| 337 | + function stepState(s){if(!$('.log [data-step="'+s.index+'"]'))section(s.index);const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm); | |
| 338 | + if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'} | |
| 339 | + if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}} | |
| 340 | + connect()} | |
| 341 | +})();`; | |
| 342 | + | |
| 343 | +/** Inline SVG icons (16×16, stroke-based). Static strings only. */ | |
| 344 | +const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`; | |
| 345 | + | |
| 346 | +export const ICONS = { | |
| 347 | + logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`, | |
| 348 | + success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 349 | + failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'), | |
| 350 | + running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'), | |
| 351 | + queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'), | |
| 352 | + cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'), | |
| 353 | + skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 354 | + neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 355 | + issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'), | |
| 356 | + issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 357 | + pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'), | |
| 358 | + prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'), | |
| 359 | + prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'), | |
| 360 | + dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'), | |
| 361 | + file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'), | |
| 362 | + submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'), | |
| 363 | + branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'), | |
| 364 | + tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'), | |
| 365 | + commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'), | |
| 366 | + lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'), | |
| 367 | + globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'), | |
| 368 | + search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'), | |
| 369 | + bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'), | |
| 370 | + plus: I('<path d="M8 3v10M3 8h10"/>'), | |
| 371 | + gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'), | |
| 372 | + play: I('<path d="M5 3.5v9l7-4.5z"/>'), | |
| 373 | + code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'), | |
| 374 | + comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'), | |
| 375 | + eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'), | |
| 376 | + bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'), | |
| 377 | + history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'), | |
| 378 | + copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'), | |
| 379 | + download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'), | |
| 380 | + edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'), | |
| 381 | + x: I('<path d="M4 4l8 8M12 4l-8 8"/>'), | |
| 382 | + check: I('<path d="M3 8.5l3 3 7-7"/>'), | |
| 383 | + people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'), | |
| 384 | + key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'), | |
| 385 | + shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'), | |
| 386 | + repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'), | |
| 387 | + org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'), | |
| 388 | + zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'), | |
| 389 | +} as const; | |
| 390 | + | |
| 391 | +export type IconName = keyof typeof ICONS; |
addedapps/diffinery/src/web/auth.tsx+424−0View file
| @@ -1,0 +1,424 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { | |
| 3 | + generateAuthenticationOptions, | |
| 4 | + generateRegistrationOptions, | |
| 5 | + verifyAuthenticationResponse, | |
| 6 | + verifyRegistrationResponse, | |
| 7 | + type AuthenticationResponseJSON, | |
| 8 | + type AuthenticatorTransport, | |
| 9 | + type RegistrationResponseJSON, | |
| 10 | +} from "@simplewebauthn/server"; | |
| 11 | +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; | |
| 12 | +import { raw } from "hono/html"; | |
| 13 | +import type { FC, PropsWithChildren } from "hono/jsx"; | |
| 14 | +import { accessToken, type AppContext } from "../auth/principal.js"; | |
| 15 | +import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js"; | |
| 16 | +import type { Account } from "../lib/directory.js"; | |
| 17 | +import { verifyAccessJwt } from "../lib/jwt.js"; | |
| 18 | +import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js"; | |
| 19 | +import { ICONS } from "./assets.js"; | |
| 20 | +import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a | |
| 24 | + * session cookie. New people create their passkey from an invite link, and | |
| 25 | + * admins can issue one-time sign-in links to add a passkey to an existing | |
| 26 | + * account. Cloudflare Access, when configured, protects only /auth/access and | |
| 27 | + * serves as an email-based recovery path. | |
| 28 | + */ | |
| 29 | + | |
| 30 | +const CEREMONY_TIMEOUT = 120_000; | |
| 31 | + | |
| 32 | +/** Only same-site relative paths are allowed as post-sign-in destinations. */ | |
| 33 | +export function safeNext(next: unknown): string { | |
| 34 | + return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : "/"; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const str = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 38 | + | |
| 39 | +/** A login suggestion from an email address. */ | |
| 40 | +export function loginFromEmail(email: string): string { | |
| 41 | + return (email.split("@")[0] ?? "").replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user"; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** A friendly default name for a new passkey, from the browser's user agent. */ | |
| 45 | +export function deviceLabel(ua: string | undefined): string { | |
| 46 | + const u = ua ?? ""; | |
| 47 | + const os = /iPhone/.test(u) ? "iPhone" : /iPad/.test(u) ? "iPad" : /Android/.test(u) ? "Android" : /Mac OS X|Macintosh/.test(u) ? "Mac" : /Windows/.test(u) ? "Windows" : /CrOS/.test(u) ? "ChromeOS" : /Linux/.test(u) ? "Linux" : ""; | |
| 48 | + const browser = /Edg\//.test(u) ? "Edge" : /Firefox\//.test(u) ? "Firefox" : /Chrome\//.test(u) ? "Chrome" : /Safari\//.test(u) ? "Safari" : ""; | |
| 49 | + return [os, browser].filter(Boolean).join(" · ") || "Passkey"; | |
| 50 | +} | |
| 51 | + | |
| 52 | +async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> { | |
| 53 | + const old = getCookie(c, SESSION_COOKIE); | |
| 54 | + if (old) await auth.endSession(old); | |
| 55 | + const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null); | |
| 56 | + setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function setChallenge(c: AppContext, token: string): void { | |
| 60 | + setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 }); | |
| 61 | +} | |
| 62 | + | |
| 63 | +function takeChallengeCookie(c: AppContext): string | undefined { | |
| 64 | + const v = getCookie(c, CHALLENGE_COOKIE); | |
| 65 | + deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true }); | |
| 66 | + return v; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */ | |
| 70 | +function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void { | |
| 71 | + web.post(path, async (c) => { | |
| 72 | + try { | |
| 73 | + // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints. | |
| 74 | + if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON"); | |
| 75 | + const body = await c.req.json<Record<string, unknown>>().catch(() => { | |
| 76 | + throw new HttpError(400, "invalid JSON"); | |
| 77 | + }); | |
| 78 | + if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON"); | |
| 79 | + return await handler(c, body); | |
| 80 | + } catch (e) { | |
| 81 | + const s = statusOf(e); | |
| 82 | + if (s.status >= 500) console.error(e); | |
| 83 | + return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message }, s.status as 400); | |
| 84 | + } | |
| 85 | + }); | |
| 86 | +} | |
| 87 | + | |
| 88 | +const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined); | |
| 89 | + | |
| 90 | +// ---------------------------------------------------------------- pages | |
| 91 | + | |
| 92 | +const KeyMark: FC = () => ( | |
| 93 | + <div class="authmark" aria-hidden="true"> | |
| 94 | + {raw(ICONS.key)} | |
| 95 | + </div> | |
| 96 | +); | |
| 97 | + | |
| 98 | +/** Error slot that the passkey script fills in. */ | |
| 99 | +const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>; | |
| 100 | + | |
| 101 | +const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => ( | |
| 102 | + <div class="authcard box"> | |
| 103 | + <div class="body"> | |
| 104 | + <KeyMark /> | |
| 105 | + <h1>{title}</h1> | |
| 106 | + {children} | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | +); | |
| 110 | + | |
| 111 | +function accessConfigured(c: AppContext): boolean { | |
| 112 | + return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD); | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** Invite landing for someone without an account: create a passkey and join in one step. */ | |
| 116 | +export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> { | |
| 117 | + const auth = new Auth(c.env); | |
| 118 | + const inv = await auth.invite(token); | |
| 119 | + if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired"); | |
| 120 | + const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null; | |
| 121 | + const next = `/invite/${encodeURIComponent(token)}`; | |
| 122 | + if (!inv.email || exists) { | |
| 123 | + return render( | |
| 124 | + c, | |
| 125 | + { title: "Accept invite" }, | |
| 126 | + <AuthCard title="You have been invited"> | |
| 127 | + <p class="muted"> | |
| 128 | + {inv.email ? ( | |
| 129 | + <> | |
| 130 | + <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite. | |
| 131 | + </> | |
| 132 | + ) : ( | |
| 133 | + "Sign in to accept this invite." | |
| 134 | + )} | |
| 135 | + </p> | |
| 136 | + <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}> | |
| 137 | + Sign in | |
| 138 | + </a> | |
| 139 | + </AuthCard>, | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + return render( | |
| 143 | + c, | |
| 144 | + { title: "Join Diffinery" }, | |
| 145 | + <AuthCard title="Join Diffinery"> | |
| 146 | + <p class="muted"> | |
| 147 | + You were invited as <b>{inv.email}</b>. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember. | |
| 148 | + </p> | |
| 149 | + <form data-passkey="register" data-next="/"> | |
| 150 | + <input type="hidden" name="invite" value={token} /> | |
| 151 | + <div class="field"> | |
| 152 | + <label for="login">Username</label> | |
| 153 | + <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" /> | |
| 154 | + <div class="hint">Your repositories live at /{"{username}"}/…</div> | |
| 155 | + </div> | |
| 156 | + <div class="field"> | |
| 157 | + <label for="name">Display name</label> | |
| 158 | + <input id="name" name="name" type="text" maxlength={100} autocomplete="name" /> | |
| 159 | + </div> | |
| 160 | + <PasskeyMessage /> | |
| 161 | + <button class="btn primary block">Create a passkey and join</button> | |
| 162 | + </form> | |
| 163 | + </AuthCard>, | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +const PasskeyUnsupported: FC = () => ( | |
| 168 | + <noscript> | |
| 169 | + <p class="flash error">Passkeys need JavaScript.</p> | |
| 170 | + </noscript> | |
| 171 | +); | |
| 172 | + | |
| 173 | +export function registerAuth(web: WebApp): void { | |
| 174 | + web.get("/login", async (c) => { | |
| 175 | + const next = safeNext(c.req.query("next")); | |
| 176 | + const p = c.get("principal"); | |
| 177 | + if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302); | |
| 178 | + return render( | |
| 179 | + c, | |
| 180 | + { title: "Sign in" }, | |
| 181 | + <AuthCard title="Sign in to Diffinery"> | |
| 182 | + <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p> | |
| 183 | + <PasskeyUnsupported /> | |
| 184 | + <form data-passkey="login" data-next={next}> | |
| 185 | + <PasskeyMessage /> | |
| 186 | + <button class="btn primary block">Sign in with a passkey</button> | |
| 187 | + </form> | |
| 188 | + <div class="authfoot muted small"> | |
| 189 | + <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p> | |
| 190 | + {accessConfigured(c) ? ( | |
| 191 | + <p> | |
| 192 | + Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>. | |
| 193 | + </p> | |
| 194 | + ) : ( | |
| 195 | + <p>Lost your passkey? Ask an administrator for a sign-in link.</p> | |
| 196 | + )} | |
| 197 | + </div> | |
| 198 | + </AuthCard>, | |
| 199 | + ); | |
| 200 | + }); | |
| 201 | + | |
| 202 | + // Admin-issued sign-in link: add a passkey to an existing account. | |
| 203 | + web.get("/signin/:token", async (c) => { | |
| 204 | + const token = prm(c, "token"); | |
| 205 | + const link = await new Auth(c.env).signinLink(token); | |
| 206 | + if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired"); | |
| 207 | + return render( | |
| 208 | + c, | |
| 209 | + { title: "Add a passkey" }, | |
| 210 | + <AuthCard title="Add a passkey"> | |
| 211 | + <p class="muted"> | |
| 212 | + This link lets you add a passkey to <b>{link.account.login}</b> | |
| 213 | + {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey. | |
| 214 | + </p> | |
| 215 | + <form data-passkey="register" data-next="/settings/security"> | |
| 216 | + <input type="hidden" name="link" value={token} /> | |
| 217 | + <div class="field"> | |
| 218 | + <label for="label">Name for this passkey</label> | |
| 219 | + <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 220 | + </div> | |
| 221 | + <PasskeyMessage /> | |
| 222 | + <button class="btn primary block">Create a passkey</button> | |
| 223 | + </form> | |
| 224 | + </AuthCard>, | |
| 225 | + ); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + // ------------------------------------------------------------ sign in | |
| 229 | + | |
| 230 | + jsonPost(web, "/auth/passkey/login/options", async (c) => { | |
| 231 | + const auth = new Auth(c.env); | |
| 232 | + const rp = auth.rp(); | |
| 233 | + // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials). | |
| 234 | + const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT }); | |
| 235 | + setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" })); | |
| 236 | + return c.json(opts); | |
| 237 | + }); | |
| 238 | + | |
| 239 | + jsonPost(web, "/auth/passkey/login/verify", async (c, body) => { | |
| 240 | + const auth = new Auth(c.env); | |
| 241 | + const dir = c.get("dir"); | |
| 242 | + const rp = auth.rp(); | |
| 243 | + const ch = await auth.takeChallenge(takeChallengeCookie(c), "login"); | |
| 244 | + if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again."); | |
| 245 | + const response = body.response as AuthenticationResponseJSON | undefined; | |
| 246 | + if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response"); | |
| 247 | + const key = await auth.passkey(response.id); | |
| 248 | + if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed."); | |
| 249 | + const result = await verifyAuthenticationResponse({ | |
| 250 | + response, | |
| 251 | + expectedChallenge: ch.challenge, | |
| 252 | + expectedOrigin: rp.origin, | |
| 253 | + expectedRPID: rp.id, | |
| 254 | + requireUserVerification: true, | |
| 255 | + credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) }, | |
| 256 | + }).catch((e: Error) => { | |
| 257 | + throw new HttpError(401, `Passkey check failed: ${e.message}`); | |
| 258 | + }); | |
| 259 | + if (!result.verified) throw new HttpError(401, "Passkey check failed."); | |
| 260 | + const account = await dir.account(key.account_id); | |
| 261 | + if (!account) throw new HttpError(401, "This passkey's account no longer exists."); | |
| 262 | + if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); | |
| 263 | + await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp); | |
| 264 | + await startSession(c, auth, account.id, "passkey", key.id); | |
| 265 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name } })); | |
| 266 | + return c.json({ redirect: safeNext(body.next) }); | |
| 267 | + }); | |
| 268 | + | |
| 269 | + // ------------------------------------------------------------ register a passkey | |
| 270 | + | |
| 271 | + jsonPost(web, "/auth/passkey/register/options", async (c, body) => { | |
| 272 | + const auth = new Auth(c.env); | |
| 273 | + const dir = c.get("dir"); | |
| 274 | + const rp = auth.rp(); | |
| 275 | + let subject: Subject; | |
| 276 | + let user: { id: string; name: string; displayName: string }; | |
| 277 | + let existing: PasskeyRow[] = []; | |
| 278 | + const inviteToken = str(body.invite); | |
| 279 | + const linkToken = str(body.link); | |
| 280 | + if (inviteToken) { | |
| 281 | + const inv = await auth.invite(inviteToken); | |
| 282 | + if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired."); | |
| 283 | + if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again."); | |
| 284 | + if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`); | |
| 285 | + const login = str(body.login).trim() || loginFromEmail(inv.email); | |
| 286 | + if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved."); | |
| 287 | + if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`); | |
| 288 | + const name = str(body.name).trim().slice(0, 100); | |
| 289 | + const id = newId(); | |
| 290 | + subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id }; | |
| 291 | + user = { id, name: login, displayName: name || login }; | |
| 292 | + } else if (linkToken) { | |
| 293 | + const link = await auth.signinLink(linkToken); | |
| 294 | + if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired."); | |
| 295 | + if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled."); | |
| 296 | + subject = { kind: "link", linkHash: link.hash, accountId: link.account.id }; | |
| 297 | + user = { id: link.account.id, name: link.account.login, displayName: link.account.name ?? link.account.login }; | |
| 298 | + existing = await auth.passkeys(link.account.id); | |
| 299 | + } else { | |
| 300 | + const me = browserAccount(c); | |
| 301 | + subject = { kind: "account", accountId: me.id }; | |
| 302 | + user = { id: me.id, name: me.login, displayName: me.name ?? me.login }; | |
| 303 | + existing = await auth.passkeys(me.id); | |
| 304 | + } | |
| 305 | + const opts = await generateRegistrationOptions({ | |
| 306 | + rpName: rp.name, | |
| 307 | + rpID: rp.id, | |
| 308 | + userName: user.name, | |
| 309 | + userDisplayName: user.displayName, | |
| 310 | + userID: new Uint8Array(new TextEncoder().encode(user.id)), | |
| 311 | + attestationType: "none", | |
| 312 | + excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })), | |
| 313 | + authenticatorSelection: { residentKey: "required", userVerification: "required" }, | |
| 314 | + timeout: CEREMONY_TIMEOUT, | |
| 315 | + }); | |
| 316 | + setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject)); | |
| 317 | + return c.json(opts); | |
| 318 | + }); | |
| 319 | + | |
| 320 | + jsonPost(web, "/auth/passkey/register/verify", async (c, body) => { | |
| 321 | + const auth = new Auth(c.env); | |
| 322 | + const dir = c.get("dir"); | |
| 323 | + const rp = auth.rp(); | |
| 324 | + const ch = await auth.takeChallenge(takeChallengeCookie(c), "register"); | |
| 325 | + if (!ch) throw new HttpError(400, "This attempt expired. Please try again."); | |
| 326 | + const response = body.response as RegistrationResponseJSON | undefined; | |
| 327 | + if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response"); | |
| 328 | + const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => { | |
| 329 | + throw new HttpError(400, `The passkey could not be verified: ${e.message}`); | |
| 330 | + }); | |
| 331 | + if (!result.verified) throw new HttpError(400, "The passkey could not be verified."); | |
| 332 | + const info = result.registrationInfo; | |
| 333 | + if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered."); | |
| 334 | + const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent")); | |
| 335 | + const key = { | |
| 336 | + id: info.credential.id, | |
| 337 | + public_key: b64url(info.credential.publicKey), | |
| 338 | + counter: info.credential.counter, | |
| 339 | + transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null, | |
| 340 | + device_type: info.credentialDeviceType, | |
| 341 | + backed_up: info.credentialBackedUp ? 1 : 0, | |
| 342 | + name: label, | |
| 343 | + }; | |
| 344 | + const s = ch.subject; | |
| 345 | + let account: Account; | |
| 346 | + let redirect: string; | |
| 347 | + if (s.kind === "account") { | |
| 348 | + const me = browserAccount(c); | |
| 349 | + if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey."); | |
| 350 | + await auth.addPasskey({ ...key, account_id: me.id }); | |
| 351 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label } })); | |
| 352 | + flash(c, "notice", `Passkey "${label}" added.`); | |
| 353 | + return c.json({ redirect: "/settings/security" }); | |
| 354 | + } else if (s.kind === "invite") { | |
| 355 | + // Re-check: the invite or the username may have been taken while the browser was busy. | |
| 356 | + if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime."); | |
| 357 | + if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`); | |
| 358 | + if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`); | |
| 359 | + // Creating the account accepts every open invite for this email, including this one. | |
| 360 | + account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId }); | |
| 361 | + await auth.addPasskey({ ...key, account_id: account.id }); | |
| 362 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey" } })); | |
| 363 | + flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); | |
| 364 | + redirect = "/"; | |
| 365 | + } else if (s.kind === "link") { | |
| 366 | + if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime."); | |
| 367 | + const found = await dir.account(s.accountId); | |
| 368 | + if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled."); | |
| 369 | + account = found; | |
| 370 | + await auth.addPasskey({ ...key, account_id: account.id }); | |
| 371 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link" } })); | |
| 372 | + flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`); | |
| 373 | + redirect = "/settings/security"; | |
| 374 | + } else { | |
| 375 | + throw new HttpError(400, "unexpected sign-in attempt"); | |
| 376 | + } | |
| 377 | + await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id); | |
| 378 | + return c.json({ redirect }); | |
| 379 | + }); | |
| 380 | + | |
| 381 | + // ------------------------------------------------------------ sign out | |
| 382 | + | |
| 383 | + post(web, "/auth/logout", async (c) => { | |
| 384 | + const token = getCookie(c, SESSION_COOKIE); | |
| 385 | + if (token) await new Auth(c.env).endSession(token); | |
| 386 | + deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true }); | |
| 387 | + return c.redirect("/login", 303); | |
| 388 | + }); | |
| 389 | + | |
| 390 | + // ------------------------------------------------------------ recovery through Cloudflare Access | |
| 391 | + | |
| 392 | + // Cloudflare Access protects only this path. It verifies the person's email, | |
| 393 | + // and this handler turns that into a session so they can add a new passkey. | |
| 394 | + web.get("/auth/access", async (c) => { | |
| 395 | + if (!accessConfigured(c)) throw new HttpError(404, "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link."); | |
| 396 | + const jwt = accessToken(c.req.raw); | |
| 397 | + if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check."); | |
| 398 | + const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => { | |
| 399 | + throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`); | |
| 400 | + }); | |
| 401 | + const email = typeof claims.email === "string" ? claims.email : null; | |
| 402 | + if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address."); | |
| 403 | + const dir = c.get("dir"); | |
| 404 | + const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined }); | |
| 405 | + if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); | |
| 406 | + const auth = new Auth(c.env); | |
| 407 | + await startSession(c, auth, account.id, "access", null); | |
| 408 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access" } })); | |
| 409 | + if (account.status === "pending") return c.redirect("/", 302); | |
| 410 | + const keys = await auth.passkeys(account.id); | |
| 411 | + if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302); | |
| 412 | + flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time."); | |
| 413 | + return c.redirect("/settings/security", 302); | |
| 414 | + }); | |
| 415 | +} | |
| 416 | + | |
| 417 | +/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */ | |
| 418 | +export function browserAccount(c: AppContext): Account { | |
| 419 | + const p = c.get("principal"); | |
| 420 | + if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first."); | |
| 421 | + if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys"); | |
| 422 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 423 | + return p.account; | |
| 424 | +} |
addedapps/diffinery/src/web/code.tsx+1107−0View file
| @@ -1,0 +1,1107 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import type { AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import { atLeast, globToRegExp } from "../repo/types.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { | |
| 11 | + CommitRow, | |
| 12 | + CopyButton, | |
| 13 | + DiffStats, | |
| 14 | + DiffView, | |
| 15 | + FileSummary, | |
| 16 | + Form, | |
| 17 | + Markdown, | |
| 18 | + Pager, | |
| 19 | + RefSwitcher, | |
| 20 | + Time, | |
| 21 | + act, | |
| 22 | + cloneUrl, | |
| 23 | + encPath, | |
| 24 | + firstLine, | |
| 25 | + fmtBytes, | |
| 26 | + pageNum, | |
| 27 | + post, | |
| 28 | + qs, | |
| 29 | + refNames, | |
| 30 | + render, | |
| 31 | + repoUrl, | |
| 32 | + restOfPath, | |
| 33 | + shortSha, | |
| 34 | + splitRefPath, | |
| 35 | + statusOf, | |
| 36 | + Status, | |
| 37 | + type DiffFile, | |
| 38 | + type WebApp, | |
| 39 | + prm, | |
| 40 | +} from "./ui.js"; | |
| 41 | + | |
| 42 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 43 | + | |
| 44 | +const IMAGE_TYPES: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", bmp: "image/bmp", svg: "image/svg+xml" }; | |
| 45 | +const MAX_RENDER = 1024 * 1024; | |
| 46 | + | |
| 47 | +const extOf = (path: string): string => (/\.([A-Za-z0-9]+)$/.exec(path)?.[1] ?? "").toLowerCase(); | |
| 48 | +const isMarkdown = (path: string): boolean => /\.(md|markdown|mdown|mkd)$/i.test(path); | |
| 49 | +const decoder = () => new TextDecoder(); | |
| 50 | + | |
| 51 | +/** Normalise a user-supplied repository path; null when invalid. */ | |
| 52 | +function cleanPath(p: string): string | null { | |
| 53 | + const segs = p.replace(/\\/g, "/").split("/").filter((s) => s && s !== "."); | |
| 54 | + if (!segs.length || segs.some((s) => s === ".." || s === ".git")) return null; | |
| 55 | + return segs.join("/"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +const Crumbs: FC<{ base: string; ref: string; path: string; last?: boolean }> = ({ base, ref, path, last }) => { | |
| 59 | + const segs = path ? path.split("/") : []; | |
| 60 | + return ( | |
| 61 | + <div class="crumbs"> | |
| 62 | + <a href={`${base}/tree/${encPath(ref)}`}>{base.split("/")[2]}</a> | |
| 63 | + {segs.map((s, i) => ( | |
| 64 | + <> | |
| 65 | + <span class="faint">/</span> | |
| 66 | + {i === segs.length - 1 && last !== false ? <b>{s}</b> : <a href={`${base}/tree/${encPath(ref)}/${encPath(segs.slice(0, i + 1).join("/"))}`}>{s}</a>} | |
| 67 | + </> | |
| 68 | + ))} | |
| 69 | + </div> | |
| 70 | + ); | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const CloneBox: FC<{ url: string }> = ({ url }) => ( | |
| 74 | + <div class="clone"> | |
| 75 | + <input type="text" readonly value={url} aria-label="Clone URL" /> | |
| 76 | + <CopyButton text={url} /> | |
| 77 | + </div> | |
| 78 | +); | |
| 79 | + | |
| 80 | +const QuickSetup: FC<{ c: AppContext; l: Loaded }> = ({ c, l }) => { | |
| 81 | + const url = cloneUrl(c, l.repo); | |
| 82 | + const b = l.repo.default_branch; | |
| 83 | + const base = repoUrl(l.repo); | |
| 84 | + const canWrite = atLeast(l.caller.role, "write"); | |
| 85 | + return ( | |
| 86 | + <div style="max-width:900px"> | |
| 87 | + <div class="box"> | |
| 88 | + <div class="head"> | |
| 89 | + <h2>Quick setup</h2> | |
| 90 | + <span class="muted small">This repository is empty.</span> | |
| 91 | + </div> | |
| 92 | + <div class="body"> | |
| 93 | + <CloneBox url={url} /> | |
| 94 | + {canWrite ? ( | |
| 95 | + <p class="mt muted"> | |
| 96 | + Get started by <a href={`${base}/new/${encPath(b)}?filename=README.md`}>creating a README on the web</a>, or push an existing repository from the command line. | |
| 97 | + </p> | |
| 98 | + ) : null} | |
| 99 | + <h3 class="mt">…create a new repository on the command line</h3> | |
| 100 | + <pre class="cmd">{`echo "# ${l.repo.name}" >> README.md\ngit init -b ${b}\ngit add README.md\ngit commit -m "first commit"\ngit remote add origin ${url}\ngit push -u origin ${b}`}</pre> | |
| 101 | + <h3>…or push an existing repository</h3> | |
| 102 | + <pre class="cmd">{`git remote add origin ${url}\ngit push -u origin ${b}`}</pre> | |
| 103 | + <h3>Authentication</h3> | |
| 104 | + <p class="muted"> | |
| 105 | + When Git asks for credentials, use your username and a <a href="/settings/tokens">personal access token</a> as the password. To avoid typing it every time, run <code>git config --global credential.helper store</code> (or use | |
| 106 | + your OS keychain helper). Git 2.26 or newer is required. | |
| 107 | + </p> | |
| 108 | + <p class="muted m0"> | |
| 109 | + Workflows in <code>.github/workflows/*.yml</code> run automatically on push. AI agents should use their own <a href="/settings/agents">agent account and token</a>. | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + ); | |
| 115 | +}; | |
| 116 | + | |
| 117 | +async function treePage(c: AppContext, l: Loaded, ref: string, path: string, root: boolean): Promise<Response> { | |
| 118 | + const { repo, caller, stub } = l; | |
| 119 | + const base = repoUrl(repo); | |
| 120 | + const [tree, names, stats, last] = await Promise.all([ | |
| 121 | + stub.tree(ref, path), | |
| 122 | + refNames(stub), | |
| 123 | + root && !path ? stub.stats() : Promise.resolve(null), | |
| 124 | + !path ? stub.log(ref, { limit: 1 }).catch(() => []) : Promise.resolve([]), | |
| 125 | + ]); | |
| 126 | + const counts = stats ? { issues: stats.openIssues, pulls: stats.openPulls } : undefined; | |
| 127 | + if (!tree) { | |
| 128 | + if (!names.branches.length && !names.tags.length) { | |
| 129 | + return render(c, { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", counts } }, <QuickSetup c={c} l={l} />); | |
| 130 | + } | |
| 131 | + const blob = path ? await stub.blob(ref, path).catch(() => null) : null; | |
| 132 | + if (blob) return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 302); | |
| 133 | + throw new HttpError(404, "path not found"); | |
| 134 | + } | |
| 135 | + const readme = tree.items.find((i) => i.type === "blob" && /^readme(\.(md|markdown|txt))?$/i.test(i.name)); | |
| 136 | + const readmeBlob = readme && (readme.size ?? 0) < MAX_RENDER ? await stub.blob(ref, readme.path).catch(() => null) : null; | |
| 137 | + const head = last[0]; | |
| 138 | + const canWrite = atLeast(caller.role, "write") && names.branches.includes(ref); | |
| 139 | + const main = ( | |
| 140 | + <div> | |
| 141 | + <div class="row between mb"> | |
| 142 | + <div class="row"> | |
| 143 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/tree/${encPath(r)}${path ? `/${encPath(path)}` : ""}`} /> | |
| 144 | + {path ? <Crumbs base={base} ref={ref} path={path} /> : null} | |
| 145 | + {!path ? ( | |
| 146 | + <span class="muted small"> | |
| 147 | + <a href={`${base}/branches`}> | |
| 148 | + {raw(ICONS.branch)} {names.branches.length} branch{names.branches.length === 1 ? "" : "es"} | |
| 149 | + </a>{" "} | |
| 150 | + ·{" "} | |
| 151 | + <a href={`${base}/tags`}> | |
| 152 | + {raw(ICONS.tag)} {names.tags.length} tag{names.tags.length === 1 ? "" : "s"} | |
| 153 | + </a> | |
| 154 | + </span> | |
| 155 | + ) : null} | |
| 156 | + </div> | |
| 157 | + <div class="row"> | |
| 158 | + {canWrite ? ( | |
| 159 | + <a class="btn sm" href={`${base}/new/${encPath(ref)}${path ? `/${encPath(path)}` : ""}`}> | |
| 160 | + {raw(ICONS.plus)} Add file | |
| 161 | + </a> | |
| 162 | + ) : null} | |
| 163 | + <a class="btn sm" href={`${base}/commits/${encPath(ref)}${path ? qs({ path }) : ""}`}> | |
| 164 | + {raw(ICONS.history)} History | |
| 165 | + </a> | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + <div class="box"> | |
| 169 | + {head ? ( | |
| 170 | + <div class="head small"> | |
| 171 | + <b>{head.author.name}</b> | |
| 172 | + <a href={`${base}/commit/${head.sha}`} class="grow" style="color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> | |
| 173 | + {firstLine(head.message)} | |
| 174 | + </a> | |
| 175 | + <a class="sha" href={`${base}/commit/${head.sha}`}> | |
| 176 | + {shortSha(head.sha)} | |
| 177 | + </a> | |
| 178 | + <Time at={new Date(head.committer.timestamp * 1000).toISOString()} /> | |
| 179 | + </div> | |
| 180 | + ) : null} | |
| 181 | + <table class="files"> | |
| 182 | + {path ? ( | |
| 183 | + <tr> | |
| 184 | + <td class="ic" /> | |
| 185 | + <td colspan={2}> | |
| 186 | + <a href={`${base}/tree/${encPath(ref)}${path.includes("/") ? `/${encPath(path.split("/").slice(0, -1).join("/"))}` : ""}`}>..</a> | |
| 187 | + </td> | |
| 188 | + </tr> | |
| 189 | + ) : null} | |
| 190 | + {tree.items.map((it) => ( | |
| 191 | + <tr> | |
| 192 | + <td class={`ic ${it.type === "tree" ? "ic-dir" : ""}`}>{raw(it.type === "tree" ? ICONS.dir : it.type === "commit" ? ICONS.submodule : ICONS.file)}</td> | |
| 193 | + <td> | |
| 194 | + {it.type === "commit" ? ( | |
| 195 | + <span> | |
| 196 | + {it.name} <span class="faint mono small">@ {shortSha(it.oid)}</span> | |
| 197 | + </span> | |
| 198 | + ) : ( | |
| 199 | + <a href={`${base}/${it.type === "tree" ? "tree" : "blob"}/${encPath(ref)}/${encPath(it.path)}`}>{it.name}</a> | |
| 200 | + )} | |
| 201 | + </td> | |
| 202 | + <td class="sz">{it.type === "blob" ? fmtBytes(it.size) : ""}</td> | |
| 203 | + </tr> | |
| 204 | + ))} | |
| 205 | + </table> | |
| 206 | + {tree.items.length === 0 ? <div class="empty">This directory is empty.</div> : null} | |
| 207 | + </div> | |
| 208 | + {readmeBlob && !readmeBlob.binary ? ( | |
| 209 | + <div class="box readme"> | |
| 210 | + <div class="head"> | |
| 211 | + {raw(ICONS.file)} <b>{readme!.name}</b> | |
| 212 | + </div> | |
| 213 | + <div class="body"> | |
| 214 | + {isMarkdown(readme!.name) ? ( | |
| 215 | + <Markdown text={decoder().decode(readmeBlob.content)} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path }} /> | |
| 216 | + ) : ( | |
| 217 | + <pre style="white-space:pre-wrap;margin:0">{decoder().decode(readmeBlob.content)}</pre> | |
| 218 | + )} | |
| 219 | + </div> | |
| 220 | + </div> | |
| 221 | + ) : null} | |
| 222 | + </div> | |
| 223 | + ); | |
| 224 | + if (path) return render(c, { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, main); | |
| 225 | + return render( | |
| 226 | + c, | |
| 227 | + { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref, counts } }, | |
| 228 | + <div class="grid"> | |
| 229 | + {main} | |
| 230 | + <aside class="side"> | |
| 231 | + <section> | |
| 232 | + <h3>Clone</h3> | |
| 233 | + <CloneBox url={cloneUrl(c, repo)} /> | |
| 234 | + <p class="small muted" style="margin-top:6px"> | |
| 235 | + Use a <a href="/settings/tokens">token</a> as the password. | |
| 236 | + </p> | |
| 237 | + </section> | |
| 238 | + <section> | |
| 239 | + <h3>About</h3> | |
| 240 | + <p class={repo.description ? "" : "muted"}>{repo.description || "No description."}</p> | |
| 241 | + {stats ? ( | |
| 242 | + <dl class="kv"> | |
| 243 | + <dt>Default branch</dt> | |
| 244 | + <dd class="mono">{repo.default_branch}</dd> | |
| 245 | + <dt>Open issues</dt> | |
| 246 | + <dd> | |
| 247 | + <a href={`${base}/issues`}>{stats.openIssues}</a> | |
| 248 | + </dd> | |
| 249 | + <dt>Open pull requests</dt> | |
| 250 | + <dd> | |
| 251 | + <a href={`${base}/pulls`}>{stats.openPulls}</a> | |
| 252 | + </dd> | |
| 253 | + <dt>Refs</dt> | |
| 254 | + <dd>{stats.refs}</dd> | |
| 255 | + <dt>Objects</dt> | |
| 256 | + <dd>{stats.objects.toLocaleString("en-US")}</dd> | |
| 257 | + <dt>Size</dt> | |
| 258 | + <dd>{fmtBytes(stats.bytes)}</dd> | |
| 259 | + <dt>Last push</dt> | |
| 260 | + <dd>{repo.pushed_at ? <Time at={repo.pushed_at} /> : "never"}</dd> | |
| 261 | + </dl> | |
| 262 | + ) : null} | |
| 263 | + </section> | |
| 264 | + <section> | |
| 265 | + <h3>Shortcuts</h3> | |
| 266 | + <p class="small muted m0"> | |
| 267 | + <kbd>Ctrl</kbd> <kbd>K</kbd> jump anywhere · <kbd>t</kbd> find a file · <kbd>g</kbd> <kbd>i</kbd> issues · <kbd>g</kbd> <kbd>p</kbd> pull requests · <kbd>g</kbd> <kbd>a</kbd> actions | |
| 268 | + </p> | |
| 269 | + </section> | |
| 270 | + </aside> | |
| 271 | + </div>, | |
| 272 | + ); | |
| 273 | +} | |
| 274 | + | |
| 275 | +// ---------------------------------------------------------------- editor | |
| 276 | + | |
| 277 | +interface EditorValues { | |
| 278 | + path: string; | |
| 279 | + content: string; | |
| 280 | + message: string; | |
| 281 | + description: string; | |
| 282 | + choice: "direct" | "branch"; | |
| 283 | + newBranch: string; | |
| 284 | + head: string; | |
| 285 | +} | |
| 286 | + | |
| 287 | +const Editor: FC<{ c: AppContext; l: Loaded; ref: string; mode: "edit" | "new"; orig: string; dir: string; v: EditorValues; error?: string; canDirect: boolean }> = ({ c, l, ref, mode, orig, dir, v, error, canDirect }) => { | |
| 288 | + const base = repoUrl(l.repo); | |
| 289 | + const t = csrfToken(c); | |
| 290 | + const action = mode === "edit" ? `${base}/edit/${encPath(ref)}/${encPath(orig)}` : `${base}/new/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`; | |
| 291 | + return ( | |
| 292 | + <div> | |
| 293 | + {error ? <div class="flash error">{error}</div> : null} | |
| 294 | + <Form action={action} t={t}> | |
| 295 | + <input type="hidden" name="head" value={v.head} /> | |
| 296 | + <div class="row mb"> | |
| 297 | + <span class="badge">{raw(ICONS.branch)} {ref}</span> | |
| 298 | + <div class="grow"> | |
| 299 | + <input type="text" name="path" value={v.path} required placeholder="path/to/file.ext" aria-label="File path" class="mono" /> | |
| 300 | + </div> | |
| 301 | + {mode === "edit" ? ( | |
| 302 | + <a class="btn sm" href={`${base}/blob/${encPath(ref)}/${encPath(orig)}`}> | |
| 303 | + Cancel | |
| 304 | + </a> | |
| 305 | + ) : null} | |
| 306 | + </div> | |
| 307 | + <div class="box"> | |
| 308 | + <textarea name="content" class="code" spellcheck={false} style="border:0;border-radius:0;min-height:480px" aria-label="File contents"> | |
| 309 | + {`\n${v.content}`} | |
| 310 | + </textarea> | |
| 311 | + </div> | |
| 312 | + <div class="box"> | |
| 313 | + <div class="head"> | |
| 314 | + <h3>Commit changes</h3> | |
| 315 | + </div> | |
| 316 | + <div class="body"> | |
| 317 | + <div class="field"> | |
| 318 | + <input type="text" name="message" value={v.message} required aria-label="Commit message" /> | |
| 319 | + </div> | |
| 320 | + <div class="field"> | |
| 321 | + <textarea name="description" placeholder="Add an optional extended description…" style="min-height:70px"> | |
| 322 | + {v.description} | |
| 323 | + </textarea> | |
| 324 | + </div> | |
| 325 | + <div class="field"> | |
| 326 | + {canDirect ? ( | |
| 327 | + <label class="inline" style="margin-bottom:6px"> | |
| 328 | + <input type="radio" name="choice" value="direct" checked={v.choice === "direct"} /> Commit directly to the <code>{ref}</code> branch | |
| 329 | + </label> | |
| 330 | + ) : ( | |
| 331 | + <p class="muted small"> | |
| 332 | + {raw(ICONS.shield)} <code>{ref}</code> is protected; your change goes to a new branch and a pull request. | |
| 333 | + </p> | |
| 334 | + )} | |
| 335 | + <label class="inline"> | |
| 336 | + <input type="radio" name="choice" value="branch" checked={v.choice === "branch" || !canDirect} /> Create a new branch for this commit and start a pull request: | |
| 337 | + </label> | |
| 338 | + <input type="text" name="new_branch" value={v.newBranch} style="max-width:320px;margin:6px 0 0 24px" class="mono" aria-label="New branch name" /> | |
| 339 | + </div> | |
| 340 | + <button class="btn primary">Commit changes</button> | |
| 341 | + </div> | |
| 342 | + </div> | |
| 343 | + </Form> | |
| 344 | + {mode === "edit" ? ( | |
| 345 | + <Form action={action} t={t} class="box" confirm={`Delete ${orig}?`}> | |
| 346 | + <input type="hidden" name="_delete" value="1" /> | |
| 347 | + <input type="hidden" name="head" value={v.head} /> | |
| 348 | + <div class="body row between"> | |
| 349 | + <span class="muted">Delete this file in a new commit on {ref}.</span> | |
| 350 | + <button class="btn sm danger">Delete file</button> | |
| 351 | + </div> | |
| 352 | + </Form> | |
| 353 | + ) : null} | |
| 354 | + </div> | |
| 355 | + ); | |
| 356 | +}; | |
| 357 | + | |
| 358 | +// ---------------------------------------------------------------- compare and pull request creation | |
| 359 | + | |
| 360 | +export interface PullFormValues { | |
| 361 | + title: string; | |
| 362 | + body: string; | |
| 363 | + intent: string; | |
| 364 | + draft: boolean; | |
| 365 | +} | |
| 366 | + | |
| 367 | +async function comparePage(c: AppContext, l: Loaded, baseRef: string, headRef: string, form?: { values: PullFormValues; error: string }): Promise<Response> { | |
| 368 | + const { repo, caller, stub } = l; | |
| 369 | + const base = repoUrl(repo); | |
| 370 | + const t = csrfToken(c); | |
| 371 | + const names = await refNames(stub); | |
| 372 | + const all = [...names.branches, ...names.tags.filter((x) => !names.branches.includes(x))]; | |
| 373 | + let cmp: Awaited<ReturnType<typeof stub.compare>> | null = null; | |
| 374 | + let error: string | null = null; | |
| 375 | + try { | |
| 376 | + cmp = await stub.compare(baseRef, headRef); | |
| 377 | + } catch (e) { | |
| 378 | + const s = statusOf(e); | |
| 379 | + if (s.status >= 500) throw e; | |
| 380 | + error = s.message; | |
| 381 | + } | |
| 382 | + const bothBranches = names.branches.includes(baseRef) && names.branches.includes(headRef); | |
| 383 | + const canPr = c.get("principal").type === "account" && atLeast(caller.role, "write") && bothBranches && !!cmp && cmp.aheadBy > 0; | |
| 384 | + let existing: number | null = null; | |
| 385 | + if (canPr) { | |
| 386 | + const open = await stub.issues({ kind: "pull", state: "open", limit: 100 }); | |
| 387 | + existing = open.items.find((i) => i.pull?.head === headRef && i.pull?.base === baseRef)?.number ?? null; | |
| 388 | + } | |
| 389 | + const defaultTitle = cmp && cmp.commits.length === 1 ? firstLine(cmp.commits[0]!.message) : headRef.replace(/[-_/]+/g, " ").replace(/^\w/, (m) => m.toUpperCase()); | |
| 390 | + const v = form?.values ?? { title: defaultTitle, body: cmp && cmp.commits.length === 1 ? cmp.commits[0]!.message.split("\n").slice(1).join("\n").trim() : "", intent: "", draft: false }; | |
| 391 | + return render( | |
| 392 | + c, | |
| 393 | + { title: `Comparing ${baseRef}...${headRef}`, repo: { repo, caller, tab: "pulls", ref: headRef }, wide: !!cmp && cmp.files.length > 0 }, | |
| 394 | + <div> | |
| 395 | + <h1>Compare changes</h1> | |
| 396 | + <p class="muted">Choose two branches to see what changed or to start a new pull request.</p> | |
| 397 | + <form method="get" action={`${base}/compare`} class="box"> | |
| 398 | + <div class="body row"> | |
| 399 | + {raw(ICONS.branch)} | |
| 400 | + <label class="inline"> | |
| 401 | + base: | |
| 402 | + <select name="base" style="width:auto"> | |
| 403 | + {all.map((n) => ( | |
| 404 | + <option value={n} selected={n === baseRef}> | |
| 405 | + {n} | |
| 406 | + </option> | |
| 407 | + ))} | |
| 408 | + </select> | |
| 409 | + </label> | |
| 410 | + <span class="faint">←</span> | |
| 411 | + <label class="inline"> | |
| 412 | + compare: | |
| 413 | + <select name="head" style="width:auto"> | |
| 414 | + {all.map((n) => ( | |
| 415 | + <option value={n} selected={n === headRef}> | |
| 416 | + {n} | |
| 417 | + </option> | |
| 418 | + ))} | |
| 419 | + </select> | |
| 420 | + </label> | |
| 421 | + <button class="btn sm">Compare</button> | |
| 422 | + {cmp ? ( | |
| 423 | + <span class="muted small" style="margin-left:auto"> | |
| 424 | + {cmp.aheadBy} commit{cmp.aheadBy === 1 ? "" : "s"} ahead, {cmp.behindBy} behind {baseRef} | |
| 425 | + </span> | |
| 426 | + ) : null} | |
| 427 | + </div> | |
| 428 | + </form> | |
| 429 | + {error ? <div class="flash error">{error}</div> : null} | |
| 430 | + {cmp && cmp.aheadBy === 0 ? ( | |
| 431 | + <div class="blank mb"> | |
| 432 | + <h3>There isn't anything to compare.</h3> | |
| 433 | + <p class="m0"> | |
| 434 | + <b>{baseRef}</b> is up to date with all commits from <b>{headRef}</b>. | |
| 435 | + </p> | |
| 436 | + </div> | |
| 437 | + ) : null} | |
| 438 | + {existing ? ( | |
| 439 | + <div class="flash info"> | |
| 440 | + A pull request for these branches already exists:{" "} | |
| 441 | + <a href={`${base}/pull/${existing}`}> | |
| 442 | + #{existing} | |
| 443 | + </a> | |
| 444 | + </div> | |
| 445 | + ) : canPr ? ( | |
| 446 | + <Form action={`${base}/pulls`} t={t} class="box" style="max-width:980px"> | |
| 447 | + <input type="hidden" name="base" value={baseRef} /> | |
| 448 | + <input type="hidden" name="head" value={headRef} /> | |
| 449 | + <div class="head"> | |
| 450 | + <h2>Open a pull request</h2> | |
| 451 | + <span class="muted small"> | |
| 452 | + {headRef} → {baseRef} | |
| 453 | + </span> | |
| 454 | + </div> | |
| 455 | + <div class="body"> | |
| 456 | + {form?.error ? <div class="flash error">{form.error}</div> : null} | |
| 457 | + <div class="field"> | |
| 458 | + <label for="pt">Title</label> | |
| 459 | + <input id="pt" name="title" type="text" required value={v.title} /> | |
| 460 | + </div> | |
| 461 | + <div class="field"> | |
| 462 | + <label for="pi">Intent (what this change is meant to do — helps reviewers and AI reviewers)</label> | |
| 463 | + <textarea id="pi" name="intent" style="min-height:80px" placeholder="e.g. Fix the race in token refresh without changing the public API."> | |
| 464 | + {v.intent} | |
| 465 | + </textarea> | |
| 466 | + </div> | |
| 467 | + <div class="field"> | |
| 468 | + <label for="pb">Description</label> | |
| 469 | + <textarea id="pb" name="body" placeholder="Markdown is supported."> | |
| 470 | + {v.body} | |
| 471 | + </textarea> | |
| 472 | + </div> | |
| 473 | + <div class="row between"> | |
| 474 | + <label class="inline"> | |
| 475 | + <input type="checkbox" name="draft" value="1" checked={v.draft} /> Create as draft | |
| 476 | + </label> | |
| 477 | + <button class="btn green">Create pull request</button> | |
| 478 | + </div> | |
| 479 | + </div> | |
| 480 | + </Form> | |
| 481 | + ) : null} | |
| 482 | + {cmp && cmp.commits.length ? ( | |
| 483 | + <div class="box"> | |
| 484 | + <div class="head"> | |
| 485 | + <h3> | |
| 486 | + {raw(ICONS.commit)} {cmp.commits.length} commit{cmp.commits.length === 1 ? "" : "s"} | |
| 487 | + </h3> | |
| 488 | + </div> | |
| 489 | + <ul class="list"> | |
| 490 | + {cmp.commits.map((cm) => ( | |
| 491 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 492 | + ))} | |
| 493 | + </ul> | |
| 494 | + </div> | |
| 495 | + ) : null} | |
| 496 | + {cmp && cmp.files.length ? ( | |
| 497 | + <> | |
| 498 | + <div class="row between mb"> | |
| 499 | + <DiffStats files={cmp.totalFiles} additions={cmp.additions} deletions={cmp.deletions} /> | |
| 500 | + {cmp.truncated ? <span class="badge amber">showing the first {cmp.files.length} files</span> : null} | |
| 501 | + </div> | |
| 502 | + <FileSummary files={cmp.files as DiffFile[]} /> | |
| 503 | + <DiffView files={cmp.files as DiffFile[]} opts={{ viewHref: (p) => `${base}/blob/${encPath(headRef)}/${encPath(p)}` }} /> | |
| 504 | + </> | |
| 505 | + ) : null} | |
| 506 | + </div>, | |
| 507 | + ); | |
| 508 | +} | |
| 509 | + | |
| 510 | +// ---------------------------------------------------------------- routes | |
| 511 | + | |
| 512 | +export function registerCode(web: WebApp): void { | |
| 513 | + web.get("/:owner/:repo", async (c) => { | |
| 514 | + const name = prm(c, "repo"); | |
| 515 | + if (name.endsWith(".git")) return c.redirect(`/${prm(c, "owner")}/${name.slice(0, -4)}`, 301); | |
| 516 | + const l = await loadRepo(c, prm(c, "owner"), name, "read"); | |
| 517 | + if (l.repo.owner_login !== prm(c, "owner") || l.repo.name !== name) return c.redirect(repoUrl(l.repo), 301); | |
| 518 | + return treePage(c, l, l.repo.default_branch, "", true); | |
| 519 | + }); | |
| 520 | + | |
| 521 | + web.get("/:owner/:repo/tree/*", async (c) => { | |
| 522 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 523 | + const rest = restOfPath(c, 3); | |
| 524 | + if (!rest) return c.redirect(repoUrl(l.repo), 302); | |
| 525 | + const { ref, path } = await splitRefPath(l.stub, rest); | |
| 526 | + return treePage(c, l, ref, path, !path); | |
| 527 | + }); | |
| 528 | + | |
| 529 | + web.get("/:owner/:repo/blob/*", async (c) => { | |
| 530 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 531 | + const { repo, caller, stub } = l; | |
| 532 | + const base = repoUrl(repo); | |
| 533 | + const { ref, path } = await splitRefPath(stub, restOfPath(c, 3)); | |
| 534 | + if (!path) return c.redirect(`${base}/tree/${encPath(ref)}`, 302); | |
| 535 | + const [blob, names] = await Promise.all([stub.blob(ref, path), refNames(stub)]); | |
| 536 | + if (!blob) { | |
| 537 | + const tree = await stub.tree(ref, path); | |
| 538 | + if (tree) return c.redirect(`${base}/tree/${encPath(ref)}/${encPath(path)}`, 302); | |
| 539 | + throw new HttpError(404, "file not found"); | |
| 540 | + } | |
| 541 | + const ext = extOf(path); | |
| 542 | + const rawUrl = `${base}/raw/${encPath(ref)}/${encPath(path)}`; | |
| 543 | + const plain = c.req.query("plain") === "1"; | |
| 544 | + const canEdit = atLeast(caller.role, "write") && names.branches.includes(ref) && !blob.binary && blob.size <= MAX_RENDER && !repo.archived; | |
| 545 | + let content: Child; | |
| 546 | + let lineCount = 0; | |
| 547 | + if (IMAGE_TYPES[ext] && ext !== "svg") { | |
| 548 | + content = ( | |
| 549 | + <div class="body" style="text-align:center;background:var(--bg2)"> | |
| 550 | + <img src={rawUrl} alt={path} style="max-width:100%" /> | |
| 551 | + </div> | |
| 552 | + ); | |
| 553 | + } else if (blob.binary) { | |
| 554 | + content = ( | |
| 555 | + <div class="empty"> | |
| 556 | + Binary file, {fmtBytes(blob.size)} ({blob.size.toLocaleString("en-US")} bytes).{" "} | |
| 557 | + <a class="btn sm" href={rawUrl}> | |
| 558 | + {raw(ICONS.download)} Download | |
| 559 | + </a> | |
| 560 | + </div> | |
| 561 | + ); | |
| 562 | + } else if (blob.size > MAX_RENDER) { | |
| 563 | + content = ( | |
| 564 | + <div class="empty"> | |
| 565 | + This file is too large to display ({fmtBytes(blob.size)}). <a href={rawUrl}>View raw</a> | |
| 566 | + </div> | |
| 567 | + ); | |
| 568 | + } else { | |
| 569 | + const text = decoder().decode(blob.content); | |
| 570 | + const lines = text.split("\n"); | |
| 571 | + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); | |
| 572 | + lineCount = lines.length; | |
| 573 | + if (isMarkdown(path) && !plain) { | |
| 574 | + content = ( | |
| 575 | + <div class="body readme" style="margin:0;padding:24px 32px"> | |
| 576 | + <Markdown text={text} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path.split("/").slice(0, -1).join("/") }} empty="Empty file." /> | |
| 577 | + </div> | |
| 578 | + ); | |
| 579 | + } else { | |
| 580 | + content = ( | |
| 581 | + <div class="code-wrap"> | |
| 582 | + <table class="blob"> | |
| 583 | + {lines.map((line, i) => ( | |
| 584 | + <tr id={`L${i + 1}`}> | |
| 585 | + <td class="ln"> | |
| 586 | + <a href={`#L${i + 1}`}>{i + 1}</a> | |
| 587 | + </td> | |
| 588 | + <td>{line.replace(/\r$/, "")}</td> | |
| 589 | + </tr> | |
| 590 | + ))} | |
| 591 | + </table> | |
| 592 | + </div> | |
| 593 | + ); | |
| 594 | + } | |
| 595 | + } | |
| 596 | + return render( | |
| 597 | + c, | |
| 598 | + { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 599 | + <div> | |
| 600 | + <div class="row between mb"> | |
| 601 | + <div class="row"> | |
| 602 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/blob/${encPath(r)}/${encPath(path)}`} /> | |
| 603 | + <Crumbs base={base} ref={ref} path={path} /> | |
| 604 | + <CopyButton text={path} label="Copy path" /> | |
| 605 | + </div> | |
| 606 | + </div> | |
| 607 | + <div class="box"> | |
| 608 | + <div class="head small"> | |
| 609 | + <span class="muted"> | |
| 610 | + {lineCount ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ` : ""} | |
| 611 | + {fmtBytes(blob.size)} | |
| 612 | + {blob.mode === "100755" ? " · executable" : ""} | |
| 613 | + </span> | |
| 614 | + <span class="grow" /> | |
| 615 | + {isMarkdown(path) && !blob.binary ? ( | |
| 616 | + <div class="subnav"> | |
| 617 | + <a href={`?`} class={!plain ? "on" : ""}> | |
| 618 | + Preview | |
| 619 | + </a> | |
| 620 | + <a href={`?plain=1`} class={plain ? "on" : ""}> | |
| 621 | + Code | |
| 622 | + </a> | |
| 623 | + </div> | |
| 624 | + ) : null} | |
| 625 | + <a class="btn sm" href={rawUrl}> | |
| 626 | + Raw | |
| 627 | + </a> | |
| 628 | + <a class="btn sm" href={`${base}/commits/${encPath(ref)}${qs({ path })}`}> | |
| 629 | + {raw(ICONS.history)} History | |
| 630 | + </a> | |
| 631 | + {canEdit ? ( | |
| 632 | + <a class="btn sm" href={`${base}/edit/${encPath(ref)}/${encPath(path)}`}> | |
| 633 | + {raw(ICONS.edit)} Edit | |
| 634 | + </a> | |
| 635 | + ) : null} | |
| 636 | + </div> | |
| 637 | + {content} | |
| 638 | + </div> | |
| 639 | + </div>, | |
| 640 | + ); | |
| 641 | + }); | |
| 642 | + | |
| 643 | + web.get("/:owner/:repo/raw/*", async (c) => { | |
| 644 | + const { stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 645 | + const { ref, path } = await splitRefPath(stub, restOfPath(c, 3)); | |
| 646 | + const blob = path ? await stub.blob(ref, path) : null; | |
| 647 | + if (!blob) throw new HttpError(404, "file not found"); | |
| 648 | + const ext = extOf(path); | |
| 649 | + const type = IMAGE_TYPES[ext] ?? (blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8"); | |
| 650 | + const name = path.split("/").pop() ?? "file"; | |
| 651 | + const headers: Record<string, string> = { | |
| 652 | + "content-type": type, | |
| 653 | + "x-content-type-options": "nosniff", | |
| 654 | + "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'", | |
| 655 | + "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=86400, immutable" : "private, max-age=60", | |
| 656 | + "content-disposition": `${type === "application/octet-stream" ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`, | |
| 657 | + "x-frame-options": "DENY", | |
| 658 | + }; | |
| 659 | + return new Response(blob.content, { headers }); | |
| 660 | + }); | |
| 661 | + | |
| 662 | + // Editor (edit an existing file / create a new one). | |
| 663 | + const editorGet = async (c: AppContext, mode: "edit" | "new") => { | |
| 664 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 665 | + const { repo, caller, stub } = l; | |
| 666 | + const base = repoUrl(repo); | |
| 667 | + const names = await refNames(stub); | |
| 668 | + const empty = !names.branches.length && !names.tags.length; | |
| 669 | + const rest = restOfPath(c, 3); | |
| 670 | + const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest); | |
| 671 | + const ref = split.ref; | |
| 672 | + if (!empty && !names.branches.includes(ref)) throw new HttpError(422, "files can only be edited on a branch"); | |
| 673 | + const meName = caller.login; | |
| 674 | + const patchBranch = `${meName}-patch-${Math.floor(Date.now() / 1000) % 100000}`; | |
| 675 | + const canDirect = await canCommitDirect(l, ref); | |
| 676 | + if (mode === "edit") { | |
| 677 | + const blob = split.path ? await stub.blob(ref, split.path) : null; | |
| 678 | + if (!blob) throw new HttpError(404, "file not found"); | |
| 679 | + if (blob.binary) throw new HttpError(422, "binary files cannot be edited in the browser"); | |
| 680 | + return render( | |
| 681 | + c, | |
| 682 | + { title: `Editing ${split.path}`, repo: { repo, caller, tab: "code", ref } }, | |
| 683 | + <Editor | |
| 684 | + c={c} | |
| 685 | + l={l} | |
| 686 | + ref={ref} | |
| 687 | + mode="edit" | |
| 688 | + orig={split.path} | |
| 689 | + dir="" | |
| 690 | + canDirect={canDirect} | |
| 691 | + v={{ path: split.path, content: decoder().decode(blob.content), message: `Update ${split.path.split("/").pop()}`, description: "", choice: canDirect ? "direct" : "branch", newBranch: patchBranch, head: blob.sha }} | |
| 692 | + />, | |
| 693 | + ); | |
| 694 | + } | |
| 695 | + const headSha = empty ? "" : ((await stub.resolve(ref)) ?? ""); | |
| 696 | + const filename = c.req.query("filename") ?? ""; | |
| 697 | + const dir = split.path; | |
| 698 | + return render( | |
| 699 | + c, | |
| 700 | + { title: `New file · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 701 | + <> | |
| 702 | + <p class="muted"> | |
| 703 | + Create a new file in <a href={`${base}/tree/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`}>{dir || repo.name}</a>. | |
| 704 | + </p> | |
| 705 | + <Editor | |
| 706 | + c={c} | |
| 707 | + l={l} | |
| 708 | + ref={ref} | |
| 709 | + mode="new" | |
| 710 | + orig="" | |
| 711 | + dir={dir} | |
| 712 | + canDirect={canDirect || empty} | |
| 713 | + v={{ path: `${dir ? `${dir}/` : ""}${cleanPath(filename) ?? ""}`, content: "", message: filename ? `Create ${filename}` : "Create new file", description: "", choice: canDirect || empty ? "direct" : "branch", newBranch: patchBranch, head: headSha }} | |
| 714 | + /> | |
| 715 | + </>, | |
| 716 | + ); | |
| 717 | + }; | |
| 718 | + | |
| 719 | + const editorPost = async (c: AppContext, mode: "edit" | "new", f: FormData) => { | |
| 720 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 721 | + const { repo, caller, stub } = l; | |
| 722 | + const base = repoUrl(repo); | |
| 723 | + const names = await refNames(stub); | |
| 724 | + const empty = !names.branches.length && !names.tags.length; | |
| 725 | + const rest = restOfPath(c, 3); | |
| 726 | + const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest); | |
| 727 | + const ref = split.ref; | |
| 728 | + const orig = mode === "edit" ? split.path : ""; | |
| 729 | + const head = f.s("head"); | |
| 730 | + | |
| 731 | + if (mode === "edit" && f.b("_delete")) { | |
| 732 | + return act(c, `${base}/blob/${encPath(ref)}/${encPath(orig)}`, async () => { | |
| 733 | + await stub.commitFiles({ branch: ref, message: `Delete ${orig}`, changes: [{ path: orig, delete: true }], expectedHeadSha: head || undefined }, caller); | |
| 734 | + const parent = orig.split("/").slice(0, -1).join("/"); | |
| 735 | + return `${base}/tree/${encPath(ref)}${parent ? `/${encPath(parent)}` : ""}`; | |
| 736 | + }); | |
| 737 | + } | |
| 738 | + | |
| 739 | + let content = f.s("content"); | |
| 740 | + if (content.startsWith("\r\n")) content = content.slice(2); | |
| 741 | + else if (content.startsWith("\n")) content = content.slice(1); | |
| 742 | + let mode100: "100644" | "100755" | "120000" | undefined; | |
| 743 | + if (mode === "edit") { | |
| 744 | + const old = await stub.blob(ref, orig); | |
| 745 | + const crlf = !!old && decoder().decode(old.content.slice(0, 65536)).includes("\r\n"); | |
| 746 | + if (!crlf) content = content.replace(/\r\n/g, "\n"); | |
| 747 | + if (old && (old.mode === "100755" || old.mode === "120000")) mode100 = old.mode; | |
| 748 | + } else content = content.replace(/\r\n/g, "\n"); | |
| 749 | + const values: EditorValues = { | |
| 750 | + path: f.s("path"), | |
| 751 | + content, | |
| 752 | + message: f.s("message"), | |
| 753 | + description: f.s("description"), | |
| 754 | + choice: f.s("choice") === "direct" ? "direct" : "branch", | |
| 755 | + newBranch: f.s("new_branch").trim(), | |
| 756 | + head, | |
| 757 | + }; | |
| 758 | + const canDirect = empty || (await canCommitDirect(l, ref)); | |
| 759 | + try { | |
| 760 | + const path = cleanPath(values.path); | |
| 761 | + if (!path) throw new HttpError(422, "enter a valid file path"); | |
| 762 | + if (!values.message.trim()) throw new HttpError(422, "a commit message is required"); | |
| 763 | + const toBranch = values.choice === "direct" && canDirect ? ref : values.newBranch; | |
| 764 | + if (!toBranch) throw new HttpError(422, "enter a branch name"); | |
| 765 | + const changes: Array<{ path: string; content?: Uint8Array; delete?: boolean; mode?: "100644" | "100755" | "120000" }> = []; | |
| 766 | + if (mode === "edit" && path !== orig) changes.push({ path: orig, delete: true }); | |
| 767 | + if (mode === "new" && !empty && (await stub.blob(ref, path))) throw new HttpError(422, `${path} already exists`); | |
| 768 | + changes.push({ path, content: new TextEncoder().encode(content), mode: mode100 }); | |
| 769 | + const message = values.description.trim() ? `${values.message.trim()}\n\n${values.description.trim()}` : values.message.trim(); | |
| 770 | + const newBranch = toBranch !== ref; | |
| 771 | + if (newBranch && names.branches.includes(toBranch)) throw new HttpError(422, `branch ${toBranch} already exists`); | |
| 772 | + await stub.commitFiles({ branch: toBranch, message, changes, expectedHeadSha: !newBranch && head ? head : undefined, createBranchFrom: newBranch ? ref : undefined }, caller); | |
| 773 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); | |
| 774 | + if (newBranch) return c.redirect(`${base}/compare/${encPath(ref)}...${encPath(toBranch)}`, 303); | |
| 775 | + return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 303); | |
| 776 | + } catch (e) { | |
| 777 | + const s = statusOf(e); | |
| 778 | + if (s.status >= 500) throw e; | |
| 779 | + return render( | |
| 780 | + c, | |
| 781 | + { title: mode === "edit" ? `Editing ${orig}` : "New file", repo: { repo, caller, tab: "code", ref }, status: s.status }, | |
| 782 | + <Editor c={c} l={l} ref={ref} mode={mode} orig={orig} dir={mode === "new" ? split.path : ""} canDirect={canDirect} v={values} error={s.message} />, | |
| 783 | + ); | |
| 784 | + } | |
| 785 | + }; | |
| 786 | + | |
| 787 | + web.get("/:owner/:repo/edit/*", (c) => editorGet(c, "edit")); | |
| 788 | + post(web, "/:owner/:repo/edit/*", (c, f) => editorPost(c, "edit", f)); | |
| 789 | + web.get("/:owner/:repo/new/*", (c) => editorGet(c, "new")); | |
| 790 | + post(web, "/:owner/:repo/new/*", (c, f) => editorPost(c, "new", f)); | |
| 791 | + | |
| 792 | + // History | |
| 793 | + web.get("/:owner/:repo/commits/*", async (c) => { | |
| 794 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 795 | + const { repo, caller, stub } = l; | |
| 796 | + const base = repoUrl(repo); | |
| 797 | + const ref = restOfPath(c, 3) || repo.default_branch; | |
| 798 | + const path = c.req.query("path") ?? ""; | |
| 799 | + const page = pageNum(c); | |
| 800 | + const per = 30; | |
| 801 | + const [commits, names] = await Promise.all([stub.log(ref, { path: path || undefined, limit: per + 1, skip: (page - 1) * per }), refNames(stub)]); | |
| 802 | + const hasNext = commits.length > per; | |
| 803 | + const shown = commits.slice(0, per); | |
| 804 | + const groups = new Map<string, typeof shown>(); | |
| 805 | + for (const cm of shown) { | |
| 806 | + const day = new Date(cm.committer.timestamp * 1000).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); | |
| 807 | + groups.set(day, [...(groups.get(day) ?? []), cm]); | |
| 808 | + } | |
| 809 | + return render( | |
| 810 | + c, | |
| 811 | + { title: `Commits · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 812 | + <div> | |
| 813 | + <div class="row between mb"> | |
| 814 | + <div class="row"> | |
| 815 | + <h1 class="m0">Commits</h1> | |
| 816 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/commits/${encPath(r)}${qs({ path })}`} /> | |
| 817 | + {path ? ( | |
| 818 | + <span class="muted"> | |
| 819 | + for <span class="mono">{path}</span> · <a href={`${base}/commits/${encPath(ref)}`}>all files</a> | |
| 820 | + </span> | |
| 821 | + ) : null} | |
| 822 | + </div> | |
| 823 | + </div> | |
| 824 | + {shown.length === 0 ? <div class="blank">No commits found.</div> : null} | |
| 825 | + {[...groups.entries()].map(([day, list]) => ( | |
| 826 | + <div class="mb"> | |
| 827 | + <h3 class="muted small" style="font-weight:500"> | |
| 828 | + {raw(ICONS.commit)} Commits on {day} | |
| 829 | + </h3> | |
| 830 | + <div class="box"> | |
| 831 | + <ul class="list"> | |
| 832 | + {list.map((cm) => ( | |
| 833 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 834 | + ))} | |
| 835 | + </ul> | |
| 836 | + </div> | |
| 837 | + </div> | |
| 838 | + ))} | |
| 839 | + <Pager page={page} hasNext={hasNext} href={(p) => `${base}/commits/${encPath(ref)}${qs({ path, page: p > 1 ? p : undefined })}`} /> | |
| 840 | + </div>, | |
| 841 | + ); | |
| 842 | + }); | |
| 843 | + web.get("/:owner/:repo/commits", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/commits/HEAD`, 302)); | |
| 844 | + | |
| 845 | + web.get("/:owner/:repo/commit/:sha", async (c) => { | |
| 846 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 847 | + const { repo, caller, stub } = l; | |
| 848 | + const base = repoUrl(repo); | |
| 849 | + const cm = await stub.commitWithDiff(prm(c, "sha")); | |
| 850 | + const checks = await stub.checks(cm.sha).catch(() => []); | |
| 851 | + const [title, ...rest] = cm.message.split("\n"); | |
| 852 | + const body = rest.join("\n").trim(); | |
| 853 | + const files = cm.files as DiffFile[]; | |
| 854 | + return render( | |
| 855 | + c, | |
| 856 | + { title: `${firstLine(cm.message)} · ${shortSha(cm.sha)}`, repo: { repo, caller, tab: "code", ref: cm.sha }, wide: true }, | |
| 857 | + <div> | |
| 858 | + <div class="box"> | |
| 859 | + <div class="body"> | |
| 860 | + <h2 style="font-size:18px">{title}</h2> | |
| 861 | + {body ? <pre style="white-space:pre-wrap;margin:0 0 12px;font-size:13px">{body}</pre> : null} | |
| 862 | + <div class="row small muted"> | |
| 863 | + <b style="color:var(--fg)">{cm.author.name}</b> authored <Time at={new Date(cm.author.timestamp * 1000).toISOString()} /> | |
| 864 | + {cm.committer.name !== cm.author.name ? ( | |
| 865 | + <span> | |
| 866 | + · <b style="color:var(--fg)">{cm.committer.name}</b> committed <Time at={new Date(cm.committer.timestamp * 1000).toISOString()} /> | |
| 867 | + </span> | |
| 868 | + ) : null} | |
| 869 | + {cm.signed ? <span class="badge green">signed</span> : null} | |
| 870 | + <span class="grow" /> | |
| 871 | + <span> | |
| 872 | + {cm.parents.length} parent{cm.parents.length === 1 ? "" : "s"}{" "} | |
| 873 | + {cm.parents.map((p) => ( | |
| 874 | + <a class="sha" href={`${base}/commit/${p}`} style="margin-left:4px"> | |
| 875 | + {shortSha(p)} | |
| 876 | + </a> | |
| 877 | + ))} | |
| 878 | + </span> | |
| 879 | + <span> | |
| 880 | + commit <span class="sha">{cm.sha}</span> | |
| 881 | + </span> | |
| 882 | + <CopyButton text={cm.sha} /> | |
| 883 | + <a class="btn sm" href={`${base}/tree/${cm.sha}`}> | |
| 884 | + Browse files | |
| 885 | + </a> | |
| 886 | + </div> | |
| 887 | + {checks.length ? ( | |
| 888 | + <ul class="checks mt"> | |
| 889 | + {checks.map((ch) => ( | |
| 890 | + <li> | |
| 891 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 892 | + {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>} | |
| 893 | + <span class="muted small">{ch.output?.title ?? ""}</span> | |
| 894 | + </li> | |
| 895 | + ))} | |
| 896 | + </ul> | |
| 897 | + ) : null} | |
| 898 | + </div> | |
| 899 | + </div> | |
| 900 | + <div class="row between mb"> | |
| 901 | + <DiffStats files={cm.totalFiles} additions={cm.additions} deletions={cm.deletions} /> | |
| 902 | + {cm.truncated ? <span class="badge amber">showing the first {files.length} files</span> : null} | |
| 903 | + </div> | |
| 904 | + <FileSummary files={files} /> | |
| 905 | + <DiffView files={files} opts={{ viewHref: (p) => `${base}/blob/${cm.sha}/${encPath(p)}` }} /> | |
| 906 | + </div>, | |
| 907 | + ); | |
| 908 | + }); | |
| 909 | + | |
| 910 | + // Branches and tags | |
| 911 | + web.get("/:owner/:repo/branches", async (c) => { | |
| 912 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 913 | + const { repo, caller, stub } = l; | |
| 914 | + const base = repoUrl(repo); | |
| 915 | + const t = csrfToken(c); | |
| 916 | + const branches = await stub.branches(); | |
| 917 | + branches.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : a.name.localeCompare(b.name))); | |
| 918 | + const infos = await Promise.all(branches.slice(0, 40).map((b) => stub.commitInfo(b.sha).catch(() => null))); | |
| 919 | + const canWrite = atLeast(caller.role, "write") && !repo.archived; | |
| 920 | + return render( | |
| 921 | + c, | |
| 922 | + { title: `Branches · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } }, | |
| 923 | + <div> | |
| 924 | + <div class="row between mb"> | |
| 925 | + <h1 class="m0">Branches</h1> | |
| 926 | + </div> | |
| 927 | + <div class="box"> | |
| 928 | + {branches.length ? ( | |
| 929 | + <ul class="list"> | |
| 930 | + {branches.map((b, i) => { | |
| 931 | + const info = infos[i]; | |
| 932 | + return ( | |
| 933 | + <li> | |
| 934 | + <span class="st faint">{raw(ICONS.branch)}</span> | |
| 935 | + <div class="grow"> | |
| 936 | + <a class="title mono" href={`${base}/tree/${encPath(b.name)}`}> | |
| 937 | + {b.name} | |
| 938 | + </a>{" "} | |
| 939 | + {b.isDefault ? <span class="badge blue">default</span> : null} {b.protected ? <span class="badge">{raw(ICONS.shield)} protected</span> : null} | |
| 940 | + {info ? ( | |
| 941 | + <div class="meta"> | |
| 942 | + {firstLine(info.message)} · {info.author.name} · <Time at={new Date(info.committer.timestamp * 1000).toISOString()} /> | |
| 943 | + </div> | |
| 944 | + ) : null} | |
| 945 | + </div> | |
| 946 | + <a class="sha" href={`${base}/commit/${b.sha}`}> | |
| 947 | + {shortSha(b.sha)} | |
| 948 | + </a> | |
| 949 | + {!b.isDefault ? ( | |
| 950 | + <a class="btn sm" href={`${base}/compare/${encPath(repo.default_branch)}...${encPath(b.name)}`}> | |
| 951 | + {raw(ICONS.pr)} Compare | |
| 952 | + </a> | |
| 953 | + ) : null} | |
| 954 | + {canWrite && !b.isDefault ? ( | |
| 955 | + <Form action={`${base}/branches/delete`} t={t} class="inline" confirm={`Delete branch ${b.name}?`}> | |
| 956 | + <input type="hidden" name="name" value={b.name} /> | |
| 957 | + <button class="btn sm danger" title="Delete branch"> | |
| 958 | + {raw(ICONS.x)} | |
| 959 | + </button> | |
| 960 | + </Form> | |
| 961 | + ) : null} | |
| 962 | + </li> | |
| 963 | + ); | |
| 964 | + })} | |
| 965 | + </ul> | |
| 966 | + ) : ( | |
| 967 | + <div class="empty">No branches yet.</div> | |
| 968 | + )} | |
| 969 | + </div> | |
| 970 | + {canWrite && branches.length ? ( | |
| 971 | + <Form action={`${base}/branches`} t={t} class="box"> | |
| 972 | + <div class="head"> | |
| 973 | + <h3>Create a branch</h3> | |
| 974 | + </div> | |
| 975 | + <div class="body row" style="align-items:flex-end"> | |
| 976 | + <div class="field grow m0"> | |
| 977 | + <label>Branch name</label> | |
| 978 | + <input name="name" type="text" required class="mono" /> | |
| 979 | + </div> | |
| 980 | + <div class="field m0"> | |
| 981 | + <label>From</label> | |
| 982 | + <select name="from"> | |
| 983 | + {branches.map((b) => ( | |
| 984 | + <option value={b.name} selected={b.isDefault}> | |
| 985 | + {b.name} | |
| 986 | + </option> | |
| 987 | + ))} | |
| 988 | + </select> | |
| 989 | + </div> | |
| 990 | + <button class="btn primary">Create branch</button> | |
| 991 | + </div> | |
| 992 | + </Form> | |
| 993 | + ) : null} | |
| 994 | + </div>, | |
| 995 | + ); | |
| 996 | + }); | |
| 997 | + | |
| 998 | + post(web, "/:owner/:repo/branches", async (c, f) => { | |
| 999 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1000 | + const base = repoUrl(repo); | |
| 1001 | + return act(c, `${base}/branches`, async () => { | |
| 1002 | + const name = f.s("name").trim(); | |
| 1003 | + const sha = await stub.resolve(f.s("from")); | |
| 1004 | + if (!sha) throw new HttpError(422, "unknown source branch"); | |
| 1005 | + await stub.createRef(`refs/heads/${name}`, sha, caller); | |
| 1006 | + return `${base}/tree/${encPath(name)}`; | |
| 1007 | + }); | |
| 1008 | + }); | |
| 1009 | + | |
| 1010 | + post(web, "/:owner/:repo/branches/delete", async (c, f) => { | |
| 1011 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1012 | + return act(c, `${repoUrl(repo)}/branches`, async () => stub.deleteRef(`refs/heads/${f.s("name")}`, caller), "Branch deleted."); | |
| 1013 | + }); | |
| 1014 | + | |
| 1015 | + web.get("/:owner/:repo/tags", async (c) => { | |
| 1016 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1017 | + const base = repoUrl(repo); | |
| 1018 | + const tags = (await stub.tags()).reverse(); | |
| 1019 | + const infos = await Promise.all(tags.slice(0, 40).map((tg) => stub.commitInfo(tg.target).catch(() => null))); | |
| 1020 | + return render( | |
| 1021 | + c, | |
| 1022 | + { title: `Tags · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } }, | |
| 1023 | + <div> | |
| 1024 | + <h1>Tags</h1> | |
| 1025 | + <div class="box"> | |
| 1026 | + {tags.length ? ( | |
| 1027 | + <ul class="list"> | |
| 1028 | + {tags.map((tg, i) => ( | |
| 1029 | + <li> | |
| 1030 | + <span class="st faint">{raw(ICONS.tag)}</span> | |
| 1031 | + <div class="grow"> | |
| 1032 | + <a class="title mono" href={`${base}/tree/${encPath(tg.name)}`}> | |
| 1033 | + {tg.name} | |
| 1034 | + </a> | |
| 1035 | + {infos[i] ? ( | |
| 1036 | + <div class="meta"> | |
| 1037 | + {firstLine(infos[i]!.message)} · <Time at={new Date(infos[i]!.committer.timestamp * 1000).toISOString()} /> | |
| 1038 | + </div> | |
| 1039 | + ) : null} | |
| 1040 | + </div> | |
| 1041 | + <a class="sha" href={`${base}/commit/${tg.target}`}> | |
| 1042 | + {shortSha(tg.target)} | |
| 1043 | + </a> | |
| 1044 | + <a class="btn sm" href={`${base}/compare/${encPath(tg.name)}...${encPath(repo.default_branch)}`}> | |
| 1045 | + Compare | |
| 1046 | + </a> | |
| 1047 | + </li> | |
| 1048 | + ))} | |
| 1049 | + </ul> | |
| 1050 | + ) : ( | |
| 1051 | + <div class="empty"> | |
| 1052 | + No tags yet. Create one with <code>git tag v1.0.0 && git push --tags</code>. | |
| 1053 | + </div> | |
| 1054 | + )} | |
| 1055 | + </div> | |
| 1056 | + </div>, | |
| 1057 | + ); | |
| 1058 | + }); | |
| 1059 | + | |
| 1060 | + // Compare | |
| 1061 | + web.get("/:owner/:repo/compare", async (c) => { | |
| 1062 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1063 | + const base = repoUrl(l.repo); | |
| 1064 | + const b = c.req.query("base"); | |
| 1065 | + const h = c.req.query("head"); | |
| 1066 | + if (b && h) return c.redirect(`${base}/compare/${encPath(b)}...${encPath(h)}`, 302); | |
| 1067 | + const names = await refNames(l.stub); | |
| 1068 | + const other = names.branches.find((x) => x !== l.repo.default_branch); | |
| 1069 | + return comparePage(c, l, l.repo.default_branch, h ?? other ?? l.repo.default_branch); | |
| 1070 | + }); | |
| 1071 | + | |
| 1072 | + web.get("/:owner/:repo/compare/*", async (c) => { | |
| 1073 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1074 | + const spec = restOfPath(c, 3); | |
| 1075 | + const idx = spec.indexOf("..."); | |
| 1076 | + const baseRef = idx >= 0 ? spec.slice(0, idx) : l.repo.default_branch; | |
| 1077 | + const headRef = idx >= 0 ? spec.slice(idx + 3) : spec; | |
| 1078 | + if (!headRef) return c.redirect(`${repoUrl(l.repo)}/compare`, 302); | |
| 1079 | + return comparePage(c, l, baseRef || l.repo.default_branch, headRef); | |
| 1080 | + }); | |
| 1081 | + | |
| 1082 | + post(web, "/:owner/:repo/pulls", async (c, f) => { | |
| 1083 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1084 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 1085 | + const values: PullFormValues = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), intent: f.s("intent").replace(/\r\n/g, "\n").trim(), draft: f.b("draft") }; | |
| 1086 | + const baseRef = f.s("base") || l.repo.default_branch; | |
| 1087 | + const headRef = f.s("head"); | |
| 1088 | + try { | |
| 1089 | + const pr = await l.stub.createPull({ title: values.title, body: values.body, head: headRef, base: baseRef, draft: values.draft, intent: values.intent || undefined }, l.caller); | |
| 1090 | + return c.redirect(`${repoUrl(l.repo)}/pull/${pr.number}`, 303); | |
| 1091 | + } catch (e) { | |
| 1092 | + const s = statusOf(e); | |
| 1093 | + if (s.status >= 500) throw e; | |
| 1094 | + return comparePage(c, l, baseRef, headRef, { values, error: s.message }); | |
| 1095 | + } | |
| 1096 | + }); | |
| 1097 | +} | |
| 1098 | + | |
| 1099 | +/** Whether a web commit straight to `branch` would pass protection rules for this caller. */ | |
| 1100 | +async function canCommitDirect(l: Loaded, branch: string): Promise<boolean> { | |
| 1101 | + const rules = (await l.stub.listProtections()).find((p) => globToRegExp(p.pattern).test(branch))?.rules; | |
| 1102 | + if (l.caller.kind === "agent" && (branch === l.repo.default_branch || rules) && !rules?.agentsMayPush) return false; | |
| 1103 | + if (!rules) return true; | |
| 1104 | + if (l.caller.role === "admin" && !rules.enforceAdmins) return true; | |
| 1105 | + return !rules.requirePullRequest; | |
| 1106 | +} | |
| 1107 | + |
addedapps/diffinery/src/web/collab.tsx+1198−0View file
| @@ -1,0 +1,1198 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import type { AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js"; | |
| 8 | +import { atLeast, type Role } from "../repo/types.js"; | |
| 9 | +import { ICONS } from "./assets.js"; | |
| 10 | +import { csrfToken } from "./csrf.js"; | |
| 11 | +import { | |
| 12 | + Actor, | |
| 13 | + CommitRow, | |
| 14 | + DiffStats, | |
| 15 | + DiffView, | |
| 16 | + FileSummary, | |
| 17 | + Form, | |
| 18 | + Label, | |
| 19 | + Markdown, | |
| 20 | + Pager, | |
| 21 | + Status, | |
| 22 | + Time, | |
| 23 | + act, | |
| 24 | + duration, | |
| 25 | + encPath, | |
| 26 | + onBehalfOf, | |
| 27 | + pageNum, | |
| 28 | + post, | |
| 29 | + qs, | |
| 30 | + render, | |
| 31 | + repoUrl, | |
| 32 | + shortSha, | |
| 33 | + statusOf, | |
| 34 | + type DiffFile, | |
| 35 | + type LineThread, | |
| 36 | + type WebApp, | |
| 37 | + prm, | |
| 38 | +} from "./ui.js"; | |
| 39 | + | |
| 40 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 41 | +type LabelDef = { name: string; color: string; description: string }; | |
| 42 | + | |
| 43 | +/** Load a repo for a state-changing action by a signed-in account. */ | |
| 44 | +async function forAction(c: AppContext, min: Role = "read"): Promise<Loaded> { | |
| 45 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 46 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), min); | |
| 47 | + if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); | |
| 48 | + return l; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function issueNumber(c: AppContext): number { | |
| 52 | + const n = Number(prm(c, "n")); | |
| 53 | + if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found"); | |
| 54 | + return n; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const kindPath = (i: Pick<Issue, "kind">) => (i.kind === "pull" ? "pull" : "issues"); | |
| 58 | + | |
| 59 | +const StateBadge: FC<{ i: Issue }> = ({ i }) => { | |
| 60 | + if (i.kind === "pull") { | |
| 61 | + if (i.pull?.merged) | |
| 62 | + return ( | |
| 63 | + <span class="state merged"> | |
| 64 | + {raw(ICONS.prMerged)} Merged | |
| 65 | + </span> | |
| 66 | + ); | |
| 67 | + if (i.state === "closed") | |
| 68 | + return ( | |
| 69 | + <span class="state closed"> | |
| 70 | + {raw(ICONS.prClosed)} Closed | |
| 71 | + </span> | |
| 72 | + ); | |
| 73 | + if (i.pull?.draft) | |
| 74 | + return ( | |
| 75 | + <span class="state draft"> | |
| 76 | + {raw(ICONS.pr)} Draft | |
| 77 | + </span> | |
| 78 | + ); | |
| 79 | + return ( | |
| 80 | + <span class="state open"> | |
| 81 | + {raw(ICONS.pr)} Open | |
| 82 | + </span> | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + return i.state === "open" ? ( | |
| 86 | + <span class="state open"> | |
| 87 | + {raw(ICONS.issueOpen)} Open | |
| 88 | + </span> | |
| 89 | + ) : ( | |
| 90 | + <span class="state merged" style={i.stateReason === "not_planned" ? "background:var(--fg3)" : ""}> | |
| 91 | + {raw(ICONS.issueClosed)} Closed | |
| 92 | + </span> | |
| 93 | + ); | |
| 94 | +}; | |
| 95 | + | |
| 96 | +const StateIcon: FC<{ i: Issue }> = ({ i }) => { | |
| 97 | + if (i.kind === "pull") { | |
| 98 | + if (i.pull?.merged) return <span class="st merged">{raw(ICONS.prMerged)}</span>; | |
| 99 | + if (i.state === "closed") return <span class="st bad">{raw(ICONS.prClosed)}</span>; | |
| 100 | + return <span class={`st ${i.pull?.draft ? "faint" : "ok"}`}>{raw(ICONS.pr)}</span>; | |
| 101 | + } | |
| 102 | + return i.state === "open" ? <span class="st ok">{raw(ICONS.issueOpen)}</span> : <span class="st merged">{raw(ICONS.issueClosed)}</span>; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +// ---------------------------------------------------------------- lists | |
| 106 | + | |
| 107 | +async function listPage(c: AppContext, kind: "issue" | "pull"): Promise<Response> { | |
| 108 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 109 | + const base = repoUrl(repo); | |
| 110 | + const state = c.req.query("state") === "closed" ? "closed" : "open"; | |
| 111 | + const label = c.req.query("label") ?? ""; | |
| 112 | + const q = (c.req.query("q") ?? "").slice(0, 200); | |
| 113 | + const author = c.req.query("author") ?? ""; | |
| 114 | + const page = pageNum(c); | |
| 115 | + const per = 25; | |
| 116 | + const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined }; | |
| 117 | + const [list, other, labels] = await Promise.all([ | |
| 118 | + stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }), | |
| 119 | + stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }), | |
| 120 | + stub.labels(), | |
| 121 | + ]); | |
| 122 | + const colors = new Map(labels.map((l) => [l.name, l.color])); | |
| 123 | + const path = kind === "pull" ? "pulls" : "issues"; | |
| 124 | + const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, ...p })}`; | |
| 125 | + const openCount = state === "open" ? list.total : other.total; | |
| 126 | + const closedCount = state === "closed" ? list.total : other.total; | |
| 127 | + const isAccount = c.get("principal").type === "account"; | |
| 128 | + const behalf = await onBehalfOf(c, list.items.map((i) => i.author)); | |
| 129 | + return render( | |
| 130 | + c, | |
| 131 | + { title: `${kind === "pull" ? "Pull requests" : "Issues"} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: kind === "pull" ? "pulls" : "issues" } }, | |
| 132 | + <div> | |
| 133 | + <div class="row between mb"> | |
| 134 | + <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px"> | |
| 135 | + <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} /> | |
| 136 | + {label ? <input type="hidden" name="label" value={label} /> : null} | |
| 137 | + <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" /> | |
| 138 | + </form> | |
| 139 | + <div class="row"> | |
| 140 | + <a class="btn sm" href={`${base}/labels`}> | |
| 141 | + Labels | |
| 142 | + </a> | |
| 143 | + {kind === "pull" ? ( | |
| 144 | + <a class="btn sm primary" href={`${base}/compare`}> | |
| 145 | + New pull request | |
| 146 | + </a> | |
| 147 | + ) : isAccount && !repo.archived ? ( | |
| 148 | + <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 149 | + New issue | |
| 150 | + </a> | |
| 151 | + ) : null} | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + <div class="box"> | |
| 155 | + <div class="head"> | |
| 156 | + <a href={href({ state: undefined, page: undefined })} style={state === "open" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 157 | + {raw(kind === "pull" ? ICONS.pr : ICONS.issueOpen)} {openCount} Open | |
| 158 | + </a> | |
| 159 | + <a href={href({ state: "closed", page: undefined })} style={state === "closed" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 160 | + {raw(ICONS.check)} {closedCount} Closed | |
| 161 | + </a> | |
| 162 | + <span class="grow" /> | |
| 163 | + {label ? ( | |
| 164 | + <span class="small"> | |
| 165 | + Label: <Label name={label} color={colors.get(label)} /> <a href={`${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, q: q || undefined })}`}>clear</a> | |
| 166 | + </span> | |
| 167 | + ) : null} | |
| 168 | + {author ? ( | |
| 169 | + <span class="small"> | |
| 170 | + Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a> | |
| 171 | + </span> | |
| 172 | + ) : null} | |
| 173 | + {labels.length ? ( | |
| 174 | + <details class="menu"> | |
| 175 | + <summary class="btn sm">Label ▾</summary> | |
| 176 | + <div class="pop right"> | |
| 177 | + {labels.map((l) => ( | |
| 178 | + <a href={href({ label: l.name, page: undefined })}> | |
| 179 | + <Label name={l.name} color={l.color} /> | |
| 180 | + <span class="muted small">{l.description}</span> | |
| 181 | + </a> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 184 | + </details> | |
| 185 | + ) : null} | |
| 186 | + </div> | |
| 187 | + {list.items.length ? ( | |
| 188 | + <ul class="list"> | |
| 189 | + {list.items.map((i) => ( | |
| 190 | + <li> | |
| 191 | + <StateIcon i={i} /> | |
| 192 | + <div class="grow"> | |
| 193 | + <a class="title" href={`${base}/${kindPath(i)}/${i.number}`}> | |
| 194 | + {i.title} | |
| 195 | + </a>{" "} | |
| 196 | + {i.labels.map((l) => ( | |
| 197 | + <Label name={l} color={colors.get(l)} href={href({ label: l, page: undefined })} /> | |
| 198 | + ))} | |
| 199 | + {i.pull?.draft && i.state === "open" ? <span class="badge">draft</span> : null} | |
| 200 | + <div class="meta"> | |
| 201 | + #{i.number} {i.state === "open" ? "opened" : i.pull?.merged ? "merged" : "closed"} <Time at={i.state === "open" ? i.createdAt : (i.pull?.mergedAt ?? i.closedAt ?? i.updatedAt)} /> by{" "} | |
| 202 | + <a href={href({ author: i.author.login, page: undefined })}>{i.author.login}</a> | |
| 203 | + {i.author.kind === "agent" ? ( | |
| 204 | + <> | |
| 205 | + {" "} | |
| 206 | + <span class="badge agent">agent</span> | |
| 207 | + {behalf.get(i.author.login) ? <span> on behalf of {behalf.get(i.author.login)}</span> : null} | |
| 208 | + </> | |
| 209 | + ) : null} | |
| 210 | + {i.pull ? ( | |
| 211 | + <span class="mono"> | |
| 212 | + {" "} | |
| 213 | + · {i.pull.head} → {i.pull.base} | |
| 214 | + </span> | |
| 215 | + ) : null} | |
| 216 | + </div> | |
| 217 | + </div> | |
| 218 | + {i.comments ? ( | |
| 219 | + <a class="muted small nowrap" href={`${base}/${kindPath(i)}/${i.number}`}> | |
| 220 | + {raw(ICONS.comment)} {i.comments} | |
| 221 | + </a> | |
| 222 | + ) : null} | |
| 223 | + </li> | |
| 224 | + ))} | |
| 225 | + </ul> | |
| 226 | + ) : ( | |
| 227 | + <div class="empty"> | |
| 228 | + {q || label || author ? "No results match your filters." : state === "open" ? `There are no open ${kind === "pull" ? "pull requests" : "issues"}.` : `No closed ${kind === "pull" ? "pull requests" : "issues"}.`} | |
| 229 | + </div> | |
| 230 | + )} | |
| 231 | + </div> | |
| 232 | + <Pager page={page} hasNext={page * per < list.total} href={(p) => href({ page: p > 1 ? p : undefined })} /> | |
| 233 | + </div>, | |
| 234 | + ); | |
| 235 | +} | |
| 236 | + | |
| 237 | +// ---------------------------------------------------------------- conversation pieces | |
| 238 | + | |
| 239 | +interface ConvCtx { | |
| 240 | + c: AppContext; | |
| 241 | + l: Loaded; | |
| 242 | + issue: Issue; | |
| 243 | + comments: Comment[]; | |
| 244 | + reviews: Review[]; | |
| 245 | + labels: LabelDef[]; | |
| 246 | + behalf: Map<string, string>; | |
| 247 | +} | |
| 248 | + | |
| 249 | +const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => ( | |
| 250 | + <div class={`comment ${cls ?? ""}`} id={id}> | |
| 251 | + <div class="head"> | |
| 252 | + <Actor login={author.login} kind={author.kind} onBehalfOf={x.behalf.get(author.login)} /> | |
| 253 | + <span class="muted"> | |
| 254 | + {verb ?? "commented"} <Time at={at} /> | |
| 255 | + </span> | |
| 256 | + <span class="grow" /> | |
| 257 | + {author.login === x.issue.author.login ? <span class="badge">Author</span> : null} | |
| 258 | + {extra} | |
| 259 | + </div> | |
| 260 | + <div class="body"> | |
| 261 | + <Markdown text={body} base={{ owner: x.l.repo.owner_login, repo: x.l.repo.name, ref: x.l.repo.default_branch, dir: "" }} /> | |
| 262 | + </div> | |
| 263 | + </div> | |
| 264 | +); | |
| 265 | + | |
| 266 | +const LineComments: FC<{ x: ConvCtx; list: Comment[] }> = ({ x, list }) => { | |
| 267 | + const base = repoUrl(x.l.repo); | |
| 268 | + return ( | |
| 269 | + <div> | |
| 270 | + {list.map((cm) => ( | |
| 271 | + <div class="linec" id={`c-${cm.id}`}> | |
| 272 | + <div class="row small" style="gap:8px"> | |
| 273 | + <a class="where" href={`${base}/pull/${x.issue.number}/files#diff-${encodeURIComponent(cm.path ?? "")}`}> | |
| 274 | + {cm.path} | |
| 275 | + {cm.line ? `:${cm.line}` : ""} | |
| 276 | + </a> | |
| 277 | + {cm.commitId && x.issue.pull && cm.commitId !== x.issue.pull.headSha ? <span class="badge">outdated</span> : null} | |
| 278 | + {cm.resolved ? <span class="badge green">resolved</span> : null} | |
| 279 | + <span class="grow" /> | |
| 280 | + <Actor login={cm.author.login} kind={cm.author.kind} avatar={false} /> | |
| 281 | + <Time at={cm.createdAt} /> | |
| 282 | + </div> | |
| 283 | + <Markdown text={cm.body} /> | |
| 284 | + </div> | |
| 285 | + ))} | |
| 286 | + </div> | |
| 287 | + ); | |
| 288 | +}; | |
| 289 | + | |
| 290 | +const Timeline: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 291 | + type Item = { at: string; node: Child }; | |
| 292 | + const items: Item[] = []; | |
| 293 | + for (const cm of x.comments.filter((c) => !c.reviewId)) { | |
| 294 | + items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} /> }); | |
| 295 | + } | |
| 296 | + for (const rv of x.reviews) { | |
| 297 | + const lines = x.comments.filter((c) => c.reviewId === rv.id); | |
| 298 | + const cls = rv.state === "APPROVED" ? "approved" : rv.state === "CHANGES_REQUESTED" ? "changes" : ""; | |
| 299 | + const verb = rv.state === "APPROVED" ? "approved these changes" : rv.state === "CHANGES_REQUESTED" ? "requested changes" : rv.state === "DISMISSED" ? "reviewed (dismissed)" : "reviewed"; | |
| 300 | + items.push({ | |
| 301 | + at: rv.submittedAt, | |
| 302 | + node: ( | |
| 303 | + <div> | |
| 304 | + <div class={`event ${cls}`}> | |
| 305 | + <span class="ic">{raw(rv.state === "APPROVED" ? ICONS.check : rv.state === "CHANGES_REQUESTED" ? ICONS.x : ICONS.eye)}</span> | |
| 306 | + <Actor login={rv.author.login} kind={rv.author.kind} onBehalfOf={x.behalf.get(rv.author.login)} avatar={false} /> | |
| 307 | + <span> | |
| 308 | + {verb} <Time at={rv.submittedAt} /> | |
| 309 | + </span> | |
| 310 | + <span class="faint mono small">on {shortSha(rv.commitId)}</span> | |
| 311 | + </div> | |
| 312 | + {rv.body.trim() || lines.length ? ( | |
| 313 | + <div class="comment" style="margin-left:24px"> | |
| 314 | + <div class="body"> | |
| 315 | + {rv.body.trim() ? <Markdown text={rv.body} /> : null} | |
| 316 | + {lines.length ? <LineComments x={x} list={lines} /> : null} | |
| 317 | + </div> | |
| 318 | + </div> | |
| 319 | + ) : null} | |
| 320 | + </div> | |
| 321 | + ), | |
| 322 | + }); | |
| 323 | + } | |
| 324 | + if (x.issue.state === "closed" && x.issue.closedAt) { | |
| 325 | + items.push({ | |
| 326 | + at: x.issue.closedAt, | |
| 327 | + node: ( | |
| 328 | + <div class="event"> | |
| 329 | + <span class="ic" style={x.issue.pull?.merged ? "color:var(--purple)" : ""}> | |
| 330 | + {raw(x.issue.pull?.merged ? ICONS.prMerged : ICONS.issueClosed)} | |
| 331 | + </span> | |
| 332 | + {x.issue.pull?.merged ? ( | |
| 333 | + <span> | |
| 334 | + <b>{x.issue.pull.mergedBy}</b> merged commit{" "} | |
| 335 | + {x.issue.pull.mergeCommit ? ( | |
| 336 | + <a class="sha" href={`${repoUrl(x.l.repo)}/commit/${x.issue.pull.mergeCommit}`}> | |
| 337 | + {shortSha(x.issue.pull.mergeCommit)} | |
| 338 | + </a> | |
| 339 | + ) : null}{" "} | |
| 340 | + into <span class="mono">{x.issue.pull.base}</span> <Time at={x.issue.pull.mergedAt} /> | |
| 341 | + </span> | |
| 342 | + ) : ( | |
| 343 | + <span> | |
| 344 | + Closed{x.issue.stateReason && x.issue.stateReason !== "completed" ? ` as ${x.issue.stateReason.replace(/_/g, " ")}` : ""} <Time at={x.issue.closedAt} /> | |
| 345 | + </span> | |
| 346 | + )} | |
| 347 | + </div> | |
| 348 | + ), | |
| 349 | + }); | |
| 350 | + } | |
| 351 | + items.sort((a, b) => a.at.localeCompare(b.at)); | |
| 352 | + return <div class="timeline">{items.map((i) => i.node)}</div>; | |
| 353 | +}; | |
| 354 | + | |
| 355 | +const CommentForm: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 356 | + const p = x.c.get("principal"); | |
| 357 | + if (p.type !== "account") return <div class="blank">Sign in to join the conversation.</div>; | |
| 358 | + if (x.l.repo.archived) return <div class="blank">This repository is archived; the conversation is read-only.</div>; | |
| 359 | + const base = repoUrl(x.l.repo); | |
| 360 | + const canState = x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage"); | |
| 361 | + const merged = !!x.issue.pull?.merged; | |
| 362 | + return ( | |
| 363 | + <Form action={`${base}/issues/${x.issue.number}/comment`} t={csrfToken(x.c)} class="comment"> | |
| 364 | + <div class="head"> | |
| 365 | + <b>Add a comment</b> | |
| 366 | + <span class="muted small">Markdown supported</span> | |
| 367 | + </div> | |
| 368 | + <div class="body"> | |
| 369 | + <textarea name="body" placeholder="Leave a comment" aria-label="Comment"></textarea> | |
| 370 | + <div class="row end mt"> | |
| 371 | + {canState && !merged ? ( | |
| 372 | + x.issue.state === "open" ? ( | |
| 373 | + <> | |
| 374 | + {x.issue.kind === "issue" ? ( | |
| 375 | + <button class="btn" name="action" value="not_planned"> | |
| 376 | + Close as not planned | |
| 377 | + </button> | |
| 378 | + ) : null} | |
| 379 | + <button class="btn" name="action" value="close"> | |
| 380 | + {raw(ICONS.issueClosed)} Close {x.issue.kind === "pull" ? "pull request" : "issue"} | |
| 381 | + </button> | |
| 382 | + </> | |
| 383 | + ) : ( | |
| 384 | + <button class="btn" name="action" value="reopen"> | |
| 385 | + Reopen | |
| 386 | + </button> | |
| 387 | + ) | |
| 388 | + ) : null} | |
| 389 | + <button class="btn primary" name="action" value="comment"> | |
| 390 | + Comment | |
| 391 | + </button> | |
| 392 | + </div> | |
| 393 | + </div> | |
| 394 | + </Form> | |
| 395 | + ); | |
| 396 | +}; | |
| 397 | + | |
| 398 | +const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 399 | + const base = repoUrl(x.l.repo); | |
| 400 | + const t = csrfToken(x.c); | |
| 401 | + const canTriage = atLeast(x.l.caller.role, "triage") && !x.l.repo.archived && x.c.get("principal").type === "account"; | |
| 402 | + const colors = new Map(x.labels.map((l) => [l.name, l.color])); | |
| 403 | + const participants = [...new Map([x.issue.author, ...x.comments.map((c) => c.author), ...x.reviews.map((r) => r.author)].map((a) => [a.login, a])).values()]; | |
| 404 | + return ( | |
| 405 | + <aside class="side"> | |
| 406 | + {x.issue.pull ? ( | |
| 407 | + <section> | |
| 408 | + <h3>Reviewers</h3> | |
| 409 | + {x.reviews.length || x.issue.pull.requestedReviewers.length ? ( | |
| 410 | + <div> | |
| 411 | + {[...new Map(x.reviews.filter((r) => r.state !== "COMMENTED").map((r) => [r.author.login, r])).values()].map((r) => ( | |
| 412 | + <div class="row small" style="margin:4px 0"> | |
| 413 | + <Actor login={r.author.login} kind={r.author.kind} /> | |
| 414 | + <span class="grow" /> | |
| 415 | + <span class={r.state === "APPROVED" ? "ok" : r.state === "CHANGES_REQUESTED" ? "bad" : "faint"}>{r.state === "APPROVED" ? "approved" : r.state === "CHANGES_REQUESTED" ? "changes requested" : "dismissed"}</span> | |
| 416 | + </div> | |
| 417 | + ))} | |
| 418 | + {x.issue.pull.requestedReviewers.map((r) => ( | |
| 419 | + <div class="row small" style="margin:4px 0"> | |
| 420 | + <Actor login={r} /> | |
| 421 | + <span class="grow" /> | |
| 422 | + <span class="warn">pending</span> | |
| 423 | + </div> | |
| 424 | + ))} | |
| 425 | + </div> | |
| 426 | + ) : ( | |
| 427 | + <p class="muted small m0">No reviews yet.</p> | |
| 428 | + )} | |
| 429 | + </section> | |
| 430 | + ) : null} | |
| 431 | + <section> | |
| 432 | + <h3>Labels</h3> | |
| 433 | + {x.issue.labels.length ? ( | |
| 434 | + <div> | |
| 435 | + {x.issue.labels.map((l) => ( | |
| 436 | + <Label name={l} color={colors.get(l)} href={`${base}/${x.issue.kind === "pull" ? "pulls" : "issues"}${qs({ label: l })}`} /> | |
| 437 | + ))} | |
| 438 | + </div> | |
| 439 | + ) : ( | |
| 440 | + <p class="muted small m0">None yet</p> | |
| 441 | + )} | |
| 442 | + {canTriage ? ( | |
| 443 | + <details class="mt" style="margin-top:8px"> | |
| 444 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 445 | + Edit labels | |
| 446 | + </summary> | |
| 447 | + <Form action={`${base}/issues/${x.issue.number}/labels`} t={t} class="mt" style="margin-top:8px"> | |
| 448 | + {x.labels.map((l) => ( | |
| 449 | + <label class="inline" style="margin:3px 0"> | |
| 450 | + <input type="checkbox" name="labels" value={l.name} checked={x.issue.labels.includes(l.name)} /> <Label name={l.name} color={l.color} /> | |
| 451 | + </label> | |
| 452 | + ))} | |
| 453 | + {x.issue.labels | |
| 454 | + .filter((l) => !colors.has(l)) | |
| 455 | + .map((l) => ( | |
| 456 | + <label class="inline" style="margin:3px 0"> | |
| 457 | + <input type="checkbox" name="labels" value={l} checked /> <Label name={l} /> | |
| 458 | + </label> | |
| 459 | + ))} | |
| 460 | + <input type="text" name="new_label" placeholder="New label…" style="margin:6px 0" /> | |
| 461 | + <button class="btn sm">Apply</button> | |
| 462 | + </Form> | |
| 463 | + </details> | |
| 464 | + ) : null} | |
| 465 | + </section> | |
| 466 | + <section> | |
| 467 | + <h3>Assignees</h3> | |
| 468 | + {x.issue.assignees.length ? ( | |
| 469 | + x.issue.assignees.map((a) => ( | |
| 470 | + <div style="margin:4px 0"> | |
| 471 | + <Actor login={a} /> | |
| 472 | + </div> | |
| 473 | + )) | |
| 474 | + ) : ( | |
| 475 | + <p class="muted small m0">No one assigned</p> | |
| 476 | + )} | |
| 477 | + {canTriage ? ( | |
| 478 | + <details style="margin-top:8px"> | |
| 479 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 480 | + Edit assignees | |
| 481 | + </summary> | |
| 482 | + <Form action={`${base}/issues/${x.issue.number}/assignees`} t={t} style="margin-top:8px"> | |
| 483 | + <input type="text" name="assignees" value={x.issue.assignees.join(", ")} placeholder="login, login" /> | |
| 484 | + <button class="btn sm" style="margin-top:6px"> | |
| 485 | + Save | |
| 486 | + </button> | |
| 487 | + </Form> | |
| 488 | + </details> | |
| 489 | + ) : null} | |
| 490 | + </section> | |
| 491 | + <section> | |
| 492 | + <h3>Participants</h3> | |
| 493 | + <div class="row" style="gap:4px"> | |
| 494 | + {participants.map((a) => ( | |
| 495 | + <a href={`/${encodeURIComponent(a.login)}`} title={a.login}> | |
| 496 | + <span class={`av ${a.kind === "agent" ? "bot" : ""}`} style={`--h:${[...a.login].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360}`}> | |
| 497 | + {(a.login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 498 | + </span> | |
| 499 | + </a> | |
| 500 | + ))} | |
| 501 | + </div> | |
| 502 | + </section> | |
| 503 | + </aside> | |
| 504 | + ); | |
| 505 | +}; | |
| 506 | + | |
| 507 | +const TitleEditor: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 508 | + const can = x.c.get("principal").type === "account" && !x.l.repo.archived && (x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage")); | |
| 509 | + if (!can) return null; | |
| 510 | + return ( | |
| 511 | + <details class="menu"> | |
| 512 | + <summary class="btn sm">Edit</summary> | |
| 513 | + <div class="pop right" style="width:min(560px,90vw)"> | |
| 514 | + <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px"> | |
| 515 | + <div class="field"> | |
| 516 | + <label>Title</label> | |
| 517 | + <input type="text" name="title" value={x.issue.title} required /> | |
| 518 | + </div> | |
| 519 | + <div class="field"> | |
| 520 | + <label>Description</label> | |
| 521 | + <textarea name="body">{x.issue.body}</textarea> | |
| 522 | + </div> | |
| 523 | + <button class="btn primary sm">Save</button> | |
| 524 | + </Form> | |
| 525 | + </div> | |
| 526 | + </details> | |
| 527 | + ); | |
| 528 | +}; | |
| 529 | + | |
| 530 | +const IssueHeader: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 531 | + const i = x.issue; | |
| 532 | + const base = repoUrl(x.l.repo); | |
| 533 | + return ( | |
| 534 | + <div class="mb" data-fragment="header"> | |
| 535 | + <div class="row between" style="align-items:flex-start"> | |
| 536 | + <h1 class="grow" style="font-size:24px;font-weight:500"> | |
| 537 | + {i.title} <span class="faint">#{i.number}</span> | |
| 538 | + </h1> | |
| 539 | + <div class="row"> | |
| 540 | + <TitleEditor x={x} /> | |
| 541 | + {i.kind === "issue" ? ( | |
| 542 | + <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 543 | + New issue | |
| 544 | + </a> | |
| 545 | + ) : null} | |
| 546 | + </div> | |
| 547 | + </div> | |
| 548 | + <div class="row" style="gap:8px"> | |
| 549 | + <StateBadge i={i} /> | |
| 550 | + <Actor login={i.author.login} kind={i.author.kind} onBehalfOf={x.behalf.get(i.author.login)} avatar={false} /> | |
| 551 | + {i.pull ? ( | |
| 552 | + <span class="muted"> | |
| 553 | + {i.pull.merged ? "merged" : "wants to merge"} into <a class="sha" href={`${base}/tree/${encPath(i.pull.base)}`}>{i.pull.base}</a> from{" "} | |
| 554 | + <a class="sha" href={`${base}/tree/${encPath(i.pull.head)}`}> | |
| 555 | + {i.pull.head} | |
| 556 | + </a> | |
| 557 | + </span> | |
| 558 | + ) : ( | |
| 559 | + <span class="muted"> | |
| 560 | + opened this issue <Time at={i.createdAt} /> · {i.comments} comment{i.comments === 1 ? "" : "s"} | |
| 561 | + </span> | |
| 562 | + )} | |
| 563 | + </div> | |
| 564 | + </div> | |
| 565 | + ); | |
| 566 | +}; | |
| 567 | + | |
| 568 | +const PullTabs: FC<{ x: ConvCtx; tab: "conversation" | "commits" | "files" | "checks" }> = ({ x, tab }) => { | |
| 569 | + const b = `${repoUrl(x.l.repo)}/pull/${x.issue.number}`; | |
| 570 | + return ( | |
| 571 | + <nav class="tabs mb" style="border-bottom:1px solid var(--border);margin-top:0"> | |
| 572 | + <a href={b} class={tab === "conversation" ? "on" : ""}> | |
| 573 | + {raw(ICONS.comment)} Conversation <span class="n">{x.issue.comments}</span> | |
| 574 | + </a> | |
| 575 | + <a href={`${b}/commits`} class={tab === "commits" ? "on" : ""}> | |
| 576 | + {raw(ICONS.commit)} Commits | |
| 577 | + </a> | |
| 578 | + <a href={`${b}/checks`} class={tab === "checks" ? "on" : ""}> | |
| 579 | + {raw(ICONS.play)} Checks | |
| 580 | + </a> | |
| 581 | + <a href={`${b}/files`} class={tab === "files" ? "on" : ""}> | |
| 582 | + {raw(ICONS.file)} Files changed | |
| 583 | + </a> | |
| 584 | + </nav> | |
| 585 | + ); | |
| 586 | +}; | |
| 587 | + | |
| 588 | +// ---------------------------------------------------------------- merge box and review | |
| 589 | + | |
| 590 | +const MergeBox: FC<{ x: ConvCtx; m: Mergeability | null; checks: Check[] }> = ({ x, m, checks }) => { | |
| 591 | + const i = x.issue; | |
| 592 | + const p = i.pull!; | |
| 593 | + const base = repoUrl(x.l.repo); | |
| 594 | + const caller = x.l.caller; | |
| 595 | + const isAccount = x.c.get("principal").type === "account"; | |
| 596 | + if (p.merged) { | |
| 597 | + return ( | |
| 598 | + <div class="mergebox merged" data-fragment="merge"> | |
| 599 | + <div class="row"> | |
| 600 | + <span class="st merged">{raw(ICONS.prMerged)}</span> | |
| 601 | + <div> | |
| 602 | + <b>Pull request successfully merged and closed</b> | |
| 603 | + <div class="muted small"> | |
| 604 | + {p.mergedBy} merged {p.mergeCommit ? <a href={`${base}/commit/${p.mergeCommit}`}>{shortSha(p.mergeCommit)}</a> : null} into {p.base} <Time at={p.mergedAt} /> | |
| 605 | + </div> | |
| 606 | + </div> | |
| 607 | + </div> | |
| 608 | + </div> | |
| 609 | + ); | |
| 610 | + } | |
| 611 | + if (i.state === "closed") { | |
| 612 | + return ( | |
| 613 | + <div class="mergebox" data-fragment="merge"> | |
| 614 | + <div> | |
| 615 | + <b>This pull request is closed.</b> | |
| 616 | + <span class="muted"> Reopen it to continue.</span> | |
| 617 | + </div> | |
| 618 | + </div> | |
| 619 | + ); | |
| 620 | + } | |
| 621 | + if (!m) return <div class="mergebox" data-fragment="merge" />; | |
| 622 | + const headline = | |
| 623 | + m.state === "clean" | |
| 624 | + ? "This branch has no conflicts with the base branch" | |
| 625 | + : m.state === "conflicting" | |
| 626 | + ? "This branch has conflicts that must be resolved" | |
| 627 | + : m.state === "draft" | |
| 628 | + ? "This pull request is still a work in progress" | |
| 629 | + : "Merging is blocked"; | |
| 630 | + const bypass = !m.mergeable && caller.role === "admin" && caller.kind === "user" && m.conflicts.length === 0 && !p.draft; | |
| 631 | + const showChecks = checks.length ? checks : m.checks.map((ch) => ({ name: ch.name, status: ch.state === "queued" || ch.state === "in_progress" ? ch.state : "completed", conclusion: ch.state, runId: null, jobId: null, output: null }) as unknown as Check); | |
| 632 | + const failing = showChecks.filter((ch) => ch.status === "completed" && ch.conclusion !== "success" && ch.conclusion !== "skipped" && ch.conclusion !== "neutral").length; | |
| 633 | + const pending = showChecks.filter((ch) => ch.status !== "completed").length; | |
| 634 | + return ( | |
| 635 | + <div class={`mergebox ${m.state}`} data-fragment="merge"> | |
| 636 | + {showChecks.length ? ( | |
| 637 | + <div> | |
| 638 | + <div class="row mb" style="margin-bottom:6px"> | |
| 639 | + <Status status={pending ? "in_progress" : "completed"} conclusion={failing ? "failure" : "success"} /> | |
| 640 | + <b>{pending ? "Some checks haven't completed yet" : failing ? `${failing} failing check${failing === 1 ? "" : "s"}` : "All checks have passed"}</b> | |
| 641 | + <span class="muted small"> | |
| 642 | + {showChecks.length} check{showChecks.length === 1 ? "" : "s"} | |
| 643 | + </span> | |
| 644 | + </div> | |
| 645 | + <ul class="checks"> | |
| 646 | + {showChecks.map((ch) => ( | |
| 647 | + <li> | |
| 648 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 649 | + {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>} | |
| 650 | + <span class="muted small grow">{ch.output?.title && ch.output.title !== ch.conclusion ? ch.output.title : ""}</span> | |
| 651 | + <span class="muted small">{ch.startedAt ? duration(ch.startedAt, ch.completedAt) : ""}</span> | |
| 652 | + </li> | |
| 653 | + ))} | |
| 654 | + </ul> | |
| 655 | + </div> | |
| 656 | + ) : null} | |
| 657 | + <div> | |
| 658 | + <div class="row"> | |
| 659 | + <Status conclusion={m.approvals >= m.requiredApprovals && !m.changesRequested.length ? "success" : "pending"} /> | |
| 660 | + <span> | |
| 661 | + <b> | |
| 662 | + {m.approvals} approval{m.approvals === 1 ? "" : "s"} | |
| 663 | + </b> | |
| 664 | + {m.requiredApprovals ? <span class="muted"> of {m.requiredApprovals} required</span> : null} | |
| 665 | + {m.changesRequested.length ? <span class="bad"> · changes requested by {m.changesRequested.join(", ")}</span> : null} | |
| 666 | + </span> | |
| 667 | + </div> | |
| 668 | + </div> | |
| 669 | + <div> | |
| 670 | + <div class="row"> | |
| 671 | + <Status conclusion={m.mergeable ? "success" : m.state === "conflicting" ? "failure" : "pending"} /> | |
| 672 | + <b>{headline}</b> | |
| 673 | + </div> | |
| 674 | + {m.conflicts.length ? ( | |
| 675 | + <ul class="small mono" style="margin:6px 0 0 24px"> | |
| 676 | + {m.conflicts.map((f) => ( | |
| 677 | + <li>{f}</li> | |
| 678 | + ))} | |
| 679 | + </ul> | |
| 680 | + ) : null} | |
| 681 | + {m.blockers.length ? ( | |
| 682 | + <ul class="small" style="margin:6px 0 0 24px;color:var(--fg2)"> | |
| 683 | + {m.blockers.map((b) => ( | |
| 684 | + <li>{b}</li> | |
| 685 | + ))} | |
| 686 | + </ul> | |
| 687 | + ) : null} | |
| 688 | + </div> | |
| 689 | + {isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? ( | |
| 690 | + <div> | |
| 691 | + <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}> | |
| 692 | + <input type="hidden" name="expected_head" value={p.headSha} /> | |
| 693 | + <select name="method" style="width:auto"> | |
| 694 | + <option value="merge">Create a merge commit</option> | |
| 695 | + <option value="squash">Squash and merge</option> | |
| 696 | + <option value="rebase">Rebase and merge (fast-forward)</option> | |
| 697 | + </select> | |
| 698 | + <label class="inline"> | |
| 699 | + <input type="checkbox" name="delete_branch" value="1" checked /> Delete <span class="mono">{p.head}</span> after merge | |
| 700 | + </label> | |
| 701 | + <span class="grow" /> | |
| 702 | + {m.mergeable ? ( | |
| 703 | + <button class="btn green">Merge pull request</button> | |
| 704 | + ) : bypass ? ( | |
| 705 | + <button class="btn danger">Merge without waiting for requirements (bypass)</button> | |
| 706 | + ) : ( | |
| 707 | + <button class="btn" disabled> | |
| 708 | + Merge pull request | |
| 709 | + </button> | |
| 710 | + )} | |
| 711 | + </Form> | |
| 712 | + </div> | |
| 713 | + ) : null} | |
| 714 | + </div> | |
| 715 | + ); | |
| 716 | +}; | |
| 717 | + | |
| 718 | +const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 719 | + if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null; | |
| 720 | + const own = x.issue.author.id === x.l.caller.id; | |
| 721 | + const canApprove = !own && atLeast(x.l.caller.role, "write"); | |
| 722 | + return ( | |
| 723 | + <details class="box"> | |
| 724 | + <summary class="head" style="cursor:pointer"> | |
| 725 | + {raw(ICONS.eye)} <b>Review changes</b> | |
| 726 | + <span class="muted small">{own ? "You can comment on your own pull request." : "Approve, request changes or leave a review comment."}</span> | |
| 727 | + </summary> | |
| 728 | + <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/review`} t={csrfToken(x.c)} class="body"> | |
| 729 | + <div class="field"> | |
| 730 | + <textarea name="body" placeholder="Review summary (Markdown)"></textarea> | |
| 731 | + </div> | |
| 732 | + <div class="field"> | |
| 733 | + <label class="inline"> | |
| 734 | + <input type="radio" name="event" value="COMMENT" checked /> Comment | |
| 735 | + </label> | |
| 736 | + <label class="inline"> | |
| 737 | + <input type="radio" name="event" value="APPROVE" disabled={!canApprove} /> <span class="ok">Approve</span> | |
| 738 | + </label> | |
| 739 | + <label class="inline"> | |
| 740 | + <input type="radio" name="event" value="REQUEST_CHANGES" disabled={!canApprove} /> <span class="bad">Request changes</span> | |
| 741 | + </label> | |
| 742 | + </div> | |
| 743 | + <button class="btn primary">Submit review</button> | |
| 744 | + </Form> | |
| 745 | + </details> | |
| 746 | + ); | |
| 747 | +}; | |
| 748 | + | |
| 749 | +// ---------------------------------------------------------------- pages | |
| 750 | + | |
| 751 | +async function loadConv(c: AppContext): Promise<ConvCtx> { | |
| 752 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 753 | + const n = issueNumber(c); | |
| 754 | + const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]); | |
| 755 | + const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]); | |
| 756 | + return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf }; | |
| 757 | +} | |
| 758 | + | |
| 759 | +async function issuePage(c: AppContext): Promise<Response> { | |
| 760 | + const x = await loadConv(c); | |
| 761 | + const base = repoUrl(x.l.repo); | |
| 762 | + if (x.issue.kind === "pull") return c.redirect(`${base}/pull/${x.issue.number}${new URL(c.req.url).search}`, 302); | |
| 763 | + return render( | |
| 764 | + c, | |
| 765 | + { title: `${x.issue.title} · Issue #${x.issue.number}`, repo: { repo: x.l.repo, caller: x.l.caller, tab: "issues" }, live: `pr:${x.issue.number}` }, | |
| 766 | + <div> | |
| 767 | + <IssueHeader x={x} /> | |
| 768 | + <div class="grid"> | |
| 769 | + <div> | |
| 770 | + <div data-fragment="timeline"> | |
| 771 | + <CommentCard x={x} author={x.issue.author} at={x.issue.createdAt} body={x.issue.body} verb="opened" /> | |
| 772 | + <Timeline x={x} /> | |
| 773 | + </div> | |
| 774 | + <CommentForm x={x} /> | |
| 775 | + </div> | |
| 776 | + <Sidebar x={x} /> | |
| 777 | + </div> | |
| 778 | + </div>, | |
| 779 | + ); | |
| 780 | +} | |
| 781 | + | |
| 782 | +async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> { | |
| 783 | + const x = await loadConv(c); | |
| 784 | + const { repo, caller, stub } = x.l; | |
| 785 | + const base = repoUrl(repo); | |
| 786 | + const i = x.issue; | |
| 787 | + if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302); | |
| 788 | + const n = i.number; | |
| 789 | + const page = (body: Child, wide = false) => | |
| 790 | + render( | |
| 791 | + c, | |
| 792 | + { title: `${i.title} · Pull request #${n}`, repo: { repo, caller, tab: "pulls", ref: i.pull!.head }, live: `pr:${n}`, wide }, | |
| 793 | + <div> | |
| 794 | + <IssueHeader x={x} /> | |
| 795 | + <PullTabs x={x} tab={tab} /> | |
| 796 | + {body} | |
| 797 | + </div>, | |
| 798 | + ); | |
| 799 | + | |
| 800 | + if (tab === "conversation") { | |
| 801 | + const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checks(i.pull.headSha).catch(() => [] as Check[])]); | |
| 802 | + return page( | |
| 803 | + <div class="grid"> | |
| 804 | + <div> | |
| 805 | + <div data-fragment="timeline"> | |
| 806 | + <CommentCard x={x} author={i.author} at={i.createdAt} body={i.body} verb="opened" /> | |
| 807 | + {i.pull.intent ? ( | |
| 808 | + <div class="comment intent"> | |
| 809 | + <div class="head"> | |
| 810 | + {raw(ICONS.zap)} <b>Intent</b> | |
| 811 | + <span class="muted small">what this change is meant to do</span> | |
| 812 | + </div> | |
| 813 | + <div class="body"> | |
| 814 | + <Markdown text={i.pull.intent} /> | |
| 815 | + </div> | |
| 816 | + </div> | |
| 817 | + ) : null} | |
| 818 | + <Timeline x={x} /> | |
| 819 | + </div> | |
| 820 | + <MergeBox x={x} m={m} checks={checks} /> | |
| 821 | + <ReviewForm x={x} /> | |
| 822 | + <CommentForm x={x} /> | |
| 823 | + </div> | |
| 824 | + <Sidebar x={x} /> | |
| 825 | + </div>, | |
| 826 | + ); | |
| 827 | + } | |
| 828 | + | |
| 829 | + if (tab === "commits") { | |
| 830 | + const commits = await stub.pullCommits(n).catch(() => []); | |
| 831 | + return page( | |
| 832 | + <div class="box"> | |
| 833 | + {commits.length ? ( | |
| 834 | + <ul class="list"> | |
| 835 | + {commits.map((cm) => ( | |
| 836 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 837 | + ))} | |
| 838 | + </ul> | |
| 839 | + ) : ( | |
| 840 | + <div class="empty">No commits.</div> | |
| 841 | + )} | |
| 842 | + </div>, | |
| 843 | + ); | |
| 844 | + } | |
| 845 | + | |
| 846 | + if (tab === "checks") { | |
| 847 | + const [checks, runs] = await Promise.all([stub.checks(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]); | |
| 848 | + return page( | |
| 849 | + <div data-fragment="checks"> | |
| 850 | + <div class="box"> | |
| 851 | + <div class="head"> | |
| 852 | + <h3>Checks for {shortSha(i.pull.headSha)}</h3> | |
| 853 | + </div> | |
| 854 | + {checks.length ? ( | |
| 855 | + <ul class="list"> | |
| 856 | + {checks.map((ch) => ( | |
| 857 | + <li> | |
| 858 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 859 | + <div class="grow"> | |
| 860 | + {ch.runId ? ( | |
| 861 | + <a class="title" href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}> | |
| 862 | + {ch.name} | |
| 863 | + </a> | |
| 864 | + ) : ( | |
| 865 | + <span class="title">{ch.name}</span> | |
| 866 | + )} | |
| 867 | + <div class="meta"> | |
| 868 | + {ch.app} · {ch.status === "completed" ? (ch.conclusion ?? "neutral") : ch.status.replace("_", " ")} | |
| 869 | + {ch.output?.summary ? ` · ${ch.output.summary.slice(0, 200)}` : ""} | |
| 870 | + </div> | |
| 871 | + </div> | |
| 872 | + <span class="muted small">{duration(ch.startedAt, ch.completedAt)}</span> | |
| 873 | + </li> | |
| 874 | + ))} | |
| 875 | + </ul> | |
| 876 | + ) : ( | |
| 877 | + <div class="empty">No checks have reported for the latest commit.</div> | |
| 878 | + )} | |
| 879 | + </div> | |
| 880 | + <div class="box"> | |
| 881 | + <div class="head"> | |
| 882 | + <h3>Workflow runs</h3> | |
| 883 | + </div> | |
| 884 | + {runs.runs.length ? ( | |
| 885 | + <ul class="list"> | |
| 886 | + {runs.runs.map((r) => ( | |
| 887 | + <li> | |
| 888 | + <Status status={r.status} conclusion={r.conclusion} /> | |
| 889 | + <div class="grow"> | |
| 890 | + <a class="title" href={`${base}/actions/runs/${r.id}`}> | |
| 891 | + {r.title} | |
| 892 | + </a> | |
| 893 | + <div class="meta"> | |
| 894 | + {r.workflowName} #{r.number} · {r.event} · {shortSha(r.sha)} · <Time at={r.createdAt} /> | |
| 895 | + </div> | |
| 896 | + </div> | |
| 897 | + </li> | |
| 898 | + ))} | |
| 899 | + </ul> | |
| 900 | + ) : ( | |
| 901 | + <div class="empty">No workflow runs for this pull request.</div> | |
| 902 | + )} | |
| 903 | + </div> | |
| 904 | + </div>, | |
| 905 | + ); | |
| 906 | + } | |
| 907 | + | |
| 908 | + // Files changed | |
| 909 | + const diff = await stub.pullFiles(n); | |
| 910 | + const threads = new Map<string, LineThread[]>(); | |
| 911 | + const byKey = new Map<string, Comment[]>(); | |
| 912 | + for (const cm of x.comments) { | |
| 913 | + if (!cm.path || !cm.line) continue; | |
| 914 | + const key = `${cm.path}\u0000${cm.side === "LEFT" ? "LEFT" : "RIGHT"}\u0000${cm.line}`; | |
| 915 | + byKey.set(key, [...(byKey.get(key) ?? []), cm]); | |
| 916 | + } | |
| 917 | + for (const [key, list] of byKey) { | |
| 918 | + const [path, side, line] = key.split("\u0000") as [string, "LEFT" | "RIGHT", string]; | |
| 919 | + threads.set(path, [...(threads.get(path) ?? []), { side, line: Number(line), node: <LineComments x={x} list={list} /> }]); | |
| 920 | + } | |
| 921 | + const canComment = c.get("principal").type === "account" && i.state === "open" && !repo.archived; | |
| 922 | + const files = diff.files as DiffFile[]; | |
| 923 | + return page( | |
| 924 | + <div> | |
| 925 | + <div class="row between mb"> | |
| 926 | + <DiffStats files={diff.totalFiles} additions={diff.additions} deletions={diff.deletions} /> | |
| 927 | + <span class="muted small"> | |
| 928 | + {diff.mergeBase ? ( | |
| 929 | + <> | |
| 930 | + base <span class="sha">{shortSha(diff.mergeBase)}</span> →{" "} | |
| 931 | + </> | |
| 932 | + ) : null} | |
| 933 | + head <span class="sha">{shortSha(diff.headSha)}</span> | |
| 934 | + </span> | |
| 935 | + </div> | |
| 936 | + {canComment ? ( | |
| 937 | + <template id="line-comment-tpl"> | |
| 938 | + <Form action={`${base}/pull/${n}/line-comment`} t={csrfToken(c)}> | |
| 939 | + <input type="hidden" name="path" /> | |
| 940 | + <input type="hidden" name="line" /> | |
| 941 | + <input type="hidden" name="side" /> | |
| 942 | + <div class="small muted mb" style="margin-bottom:6px"> | |
| 943 | + Comment on <span class="where mono" /> | |
| 944 | + </div> | |
| 945 | + <textarea name="body" required placeholder="Leave a comment on this line" style="min-height:80px"></textarea> | |
| 946 | + <div class="row end" style="margin-top:6px"> | |
| 947 | + <button type="button" class="btn sm" data-cancel-line="1"> | |
| 948 | + Cancel | |
| 949 | + </button> | |
| 950 | + <button class="btn sm primary">Add comment</button> | |
| 951 | + </div> | |
| 952 | + </Form> | |
| 953 | + </template> | |
| 954 | + ) : null} | |
| 955 | + <FileSummary files={files} /> | |
| 956 | + <DiffView files={files} opts={{ commentable: canComment, threads, viewHref: (p) => `${base}/blob/${diff.headSha}/${encPath(p)}` }} /> | |
| 957 | + {diff.truncated ? <div class="flash info">Only the first {files.length} files are shown.</div> : null} | |
| 958 | + </div>, | |
| 959 | + true, | |
| 960 | + ); | |
| 961 | +} | |
| 962 | + | |
| 963 | +// ---------------------------------------------------------------- routes | |
| 964 | + | |
| 965 | +export function registerCollab(web: WebApp): void { | |
| 966 | + web.get("/:owner/:repo/issues", (c) => listPage(c, "issue")); | |
| 967 | + web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull")); | |
| 968 | + | |
| 969 | + const NewIssue: FC<{ c: AppContext; l: Loaded; labels: LabelDef[]; v: { title: string; body: string; labels: string[] }; error?: string }> = ({ c, l, labels, v, error }) => ( | |
| 970 | + <div style="max-width:900px"> | |
| 971 | + <h1>New issue</h1> | |
| 972 | + {error ? <div class="flash error">{error}</div> : null} | |
| 973 | + <Form action={`${repoUrl(l.repo)}/issues`} t={csrfToken(c)} class="box"> | |
| 974 | + <div class="body"> | |
| 975 | + <div class="field"> | |
| 976 | + <label for="it">Title</label> | |
| 977 | + <input id="it" name="title" type="text" required value={v.title} autofocus /> | |
| 978 | + </div> | |
| 979 | + <div class="field"> | |
| 980 | + <label for="ib">Description</label> | |
| 981 | + <textarea id="ib" name="body" style="min-height:220px" placeholder="Markdown is supported."> | |
| 982 | + {v.body} | |
| 983 | + </textarea> | |
| 984 | + </div> | |
| 985 | + {labels.length && atLeast(l.caller.role, "triage") ? ( | |
| 986 | + <div class="field"> | |
| 987 | + <label>Labels</label> | |
| 988 | + {labels.map((lb) => ( | |
| 989 | + <label class="inline" style="margin-right:12px"> | |
| 990 | + <input type="checkbox" name="labels" value={lb.name} checked={v.labels.includes(lb.name)} /> <Label name={lb.name} color={lb.color} /> | |
| 991 | + </label> | |
| 992 | + ))} | |
| 993 | + </div> | |
| 994 | + ) : null} | |
| 995 | + <button class="btn green">Submit new issue</button> | |
| 996 | + </div> | |
| 997 | + </Form> | |
| 998 | + </div> | |
| 999 | + ); | |
| 1000 | + | |
| 1001 | + web.get("/:owner/:repo/issues/new", async (c) => { | |
| 1002 | + const l = await forAction(c); | |
| 1003 | + const labels = await l.stub.labels(); | |
| 1004 | + return render(c, { title: `New issue · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "issues" } }, <NewIssue c={c} l={l} labels={labels} v={{ title: c.req.query("title") ?? "", body: "", labels: [] }} />); | |
| 1005 | + }); | |
| 1006 | + | |
| 1007 | + post(web, "/:owner/:repo/issues", async (c, f) => { | |
| 1008 | + const l = await forAction(c); | |
| 1009 | + const v = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), labels: atLeast(l.caller.role, "triage") ? f.all("labels") : [] }; | |
| 1010 | + try { | |
| 1011 | + const issue = await l.stub.createIssue({ title: v.title, body: v.body, labels: v.labels }, l.caller); | |
| 1012 | + return c.redirect(`${repoUrl(l.repo)}/issues/${issue.number}`, 303); | |
| 1013 | + } catch (e) { | |
| 1014 | + const s = statusOf(e); | |
| 1015 | + if (s.status >= 500) throw e; | |
| 1016 | + const labels = await l.stub.labels(); | |
| 1017 | + return render(c, { title: "New issue", repo: { repo: l.repo, caller: l.caller, tab: "issues" }, status: s.status }, <NewIssue c={c} l={l} labels={labels} v={v} error={s.message} />); | |
| 1018 | + } | |
| 1019 | + }); | |
| 1020 | + | |
| 1021 | + web.get("/:owner/:repo/issues/:n", (c) => issuePage(c)); | |
| 1022 | + web.get("/:owner/:repo/pull/:n", (c) => pullPage(c, "conversation")); | |
| 1023 | + web.get("/:owner/:repo/pull/:n/commits", (c) => pullPage(c, "commits")); | |
| 1024 | + web.get("/:owner/:repo/pull/:n/files", (c) => pullPage(c, "files")); | |
| 1025 | + web.get("/:owner/:repo/pull/:n/checks", (c) => pullPage(c, "checks")); | |
| 1026 | + web.get("/:owner/:repo/pulls/:n", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/pull/${prm(c, "n")}`, 301)); | |
| 1027 | + | |
| 1028 | + const backTo = async (l: Loaded, n: number): Promise<string> => { | |
| 1029 | + const { issue } = await l.stub.issue(n); | |
| 1030 | + return `${repoUrl(l.repo)}/${kindPath(issue)}/${n}`; | |
| 1031 | + }; | |
| 1032 | + | |
| 1033 | + post(web, "/:owner/:repo/issues/:n/comment", async (c, f) => { | |
| 1034 | + const l = await forAction(c); | |
| 1035 | + const n = issueNumber(c); | |
| 1036 | + const back = await backTo(l, n); | |
| 1037 | + const body = f.s("body").replace(/\r\n/g, "\n"); | |
| 1038 | + const action = f.s("action"); | |
| 1039 | + return act(c, back, async () => { | |
| 1040 | + if (body.trim()) await l.stub.addComment(n, body, l.caller); | |
| 1041 | + else if (action === "comment") throw new HttpError(422, "write a comment first"); | |
| 1042 | + if (action === "close" || action === "not_planned") await l.stub.updateIssue(n, { state: "closed", stateReason: action === "not_planned" ? "not_planned" : "completed" }, l.caller); | |
| 1043 | + if (action === "reopen") await l.stub.updateIssue(n, { state: "open" }, l.caller); | |
| 1044 | + }); | |
| 1045 | + }); | |
| 1046 | + | |
| 1047 | + post(web, "/:owner/:repo/issues/:n/edit", async (c, f) => { | |
| 1048 | + const l = await forAction(c); | |
| 1049 | + const n = issueNumber(c); | |
| 1050 | + const back = await backTo(l, n); | |
| 1051 | + return act(c, back, async () => { | |
| 1052 | + const title = f.s("title").trim(); | |
| 1053 | + if (!title) throw new HttpError(422, "title is required"); | |
| 1054 | + await l.stub.updateIssue(n, { title, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller); | |
| 1055 | + }); | |
| 1056 | + }); | |
| 1057 | + | |
| 1058 | + post(web, "/:owner/:repo/issues/:n/labels", async (c, f) => { | |
| 1059 | + const l = await forAction(c, "triage"); | |
| 1060 | + const n = issueNumber(c); | |
| 1061 | + const back = await backTo(l, n); | |
| 1062 | + return act(c, back, async () => { | |
| 1063 | + const labels = new Set(f.all("labels").map((x) => x.trim()).filter(Boolean)); | |
| 1064 | + const fresh = f.s("new_label").trim().slice(0, 50); | |
| 1065 | + if (fresh) { | |
| 1066 | + const existing = await l.stub.labels(); | |
| 1067 | + if (!existing.some((x) => x.name === fresh)) await l.stub.setLabel(fresh, LABEL_COLORS[existing.length % LABEL_COLORS.length]!, ""); | |
| 1068 | + labels.add(fresh); | |
| 1069 | + } | |
| 1070 | + await l.stub.updateIssue(n, { labels: [...labels] }, l.caller); | |
| 1071 | + }); | |
| 1072 | + }); | |
| 1073 | + | |
| 1074 | + post(web, "/:owner/:repo/issues/:n/assignees", async (c, f) => { | |
| 1075 | + const l = await forAction(c, "triage"); | |
| 1076 | + const n = issueNumber(c); | |
| 1077 | + const back = await backTo(l, n); | |
| 1078 | + return act(c, back, async () => { | |
| 1079 | + const assignees = [...new Set(f.s("assignees").split(/[\s,]+/).map((s) => s.replace(/^@/, "")).filter(Boolean))].slice(0, 10); | |
| 1080 | + await l.stub.updateIssue(n, { assignees }, l.caller); | |
| 1081 | + }); | |
| 1082 | + }); | |
| 1083 | + | |
| 1084 | + post(web, "/:owner/:repo/pull/:n/review", async (c, f) => { | |
| 1085 | + const l = await forAction(c); | |
| 1086 | + const n = issueNumber(c); | |
| 1087 | + const event = (["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const).find((e) => e === f.s("event")) ?? "COMMENT"; | |
| 1088 | + return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => { | |
| 1089 | + await l.stub.submitReview(n, { event, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller); | |
| 1090 | + }); | |
| 1091 | + }); | |
| 1092 | + | |
| 1093 | + post(web, "/:owner/:repo/pull/:n/line-comment", async (c, f) => { | |
| 1094 | + const l = await forAction(c); | |
| 1095 | + const n = issueNumber(c); | |
| 1096 | + const line = parseInt(f.s("line"), 10); | |
| 1097 | + const side = f.s("side") === "LEFT" ? "LEFT" : "RIGHT"; | |
| 1098 | + return act(c, `${repoUrl(l.repo)}/pull/${n}/files`, async () => { | |
| 1099 | + const body = f.s("body").replace(/\r\n/g, "\n"); | |
| 1100 | + if (!body.trim()) throw new HttpError(422, "comment is empty"); | |
| 1101 | + if (!Number.isInteger(line) || line <= 0 || !f.s("path")) throw new HttpError(422, "invalid line"); | |
| 1102 | + await l.stub.submitReview(n, { event: "COMMENT", comments: [{ path: f.s("path"), line, side, body }] }, l.caller); | |
| 1103 | + }); | |
| 1104 | + }); | |
| 1105 | + | |
| 1106 | + post(web, "/:owner/:repo/pull/:n/merge", async (c, f) => { | |
| 1107 | + const l = await forAction(c, "write"); | |
| 1108 | + const n = issueNumber(c); | |
| 1109 | + const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method")) ?? "merge"; | |
| 1110 | + return act( | |
| 1111 | + c, | |
| 1112 | + `${repoUrl(l.repo)}/pull/${n}`, | |
| 1113 | + async () => { | |
| 1114 | + await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined }, l.caller); | |
| 1115 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id)); | |
| 1116 | + }, | |
| 1117 | + "Pull request merged.", | |
| 1118 | + ); | |
| 1119 | + }); | |
| 1120 | + | |
| 1121 | + // Labels | |
| 1122 | + web.get("/:owner/:repo/labels", async (c) => { | |
| 1123 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1124 | + const base = repoUrl(repo); | |
| 1125 | + const t = csrfToken(c); | |
| 1126 | + const labels = await stub.labels(); | |
| 1127 | + const canEdit = atLeast(caller.role, "triage") && !repo.archived && c.get("principal").type === "account"; | |
| 1128 | + return render( | |
| 1129 | + c, | |
| 1130 | + { title: `Labels · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "issues" } }, | |
| 1131 | + <div style="max-width:900px"> | |
| 1132 | + <h1>Labels</h1> | |
| 1133 | + <div class="box"> | |
| 1134 | + {labels.length ? ( | |
| 1135 | + <ul class="list"> | |
| 1136 | + {labels.map((lb) => ( | |
| 1137 | + <li style="align-items:center"> | |
| 1138 | + <div style="width:220px"> | |
| 1139 | + <Label name={lb.name} color={lb.color} href={`${base}/issues${qs({ label: lb.name })}`} /> | |
| 1140 | + </div> | |
| 1141 | + <span class="grow muted small">{lb.description}</span> | |
| 1142 | + {canEdit ? ( | |
| 1143 | + <Form action={`${base}/labels/delete`} t={t} class="inline" confirm={`Delete label ${lb.name}?`}> | |
| 1144 | + <input type="hidden" name="name" value={lb.name} /> | |
| 1145 | + <button class="btn sm danger">Delete</button> | |
| 1146 | + </Form> | |
| 1147 | + ) : null} | |
| 1148 | + </li> | |
| 1149 | + ))} | |
| 1150 | + </ul> | |
| 1151 | + ) : ( | |
| 1152 | + <div class="empty">No labels yet.</div> | |
| 1153 | + )} | |
| 1154 | + </div> | |
| 1155 | + {canEdit ? ( | |
| 1156 | + <Form action={`${base}/labels`} t={t} class="box"> | |
| 1157 | + <div class="head"> | |
| 1158 | + <h3>New label</h3> | |
| 1159 | + </div> | |
| 1160 | + <div class="body row" style="align-items:flex-end"> | |
| 1161 | + <div class="field m0"> | |
| 1162 | + <label>Name</label> | |
| 1163 | + <input name="name" type="text" required maxlength={50} /> | |
| 1164 | + </div> | |
| 1165 | + <div class="field grow m0"> | |
| 1166 | + <label>Description</label> | |
| 1167 | + <input name="description" type="text" maxlength={100} /> | |
| 1168 | + </div> | |
| 1169 | + <div class="field m0"> | |
| 1170 | + <label>Color</label> | |
| 1171 | + <input name="color" type="color" value={`#${LABEL_COLORS[labels.length % LABEL_COLORS.length]}`} style="width:56px;height:36px;padding:2px" /> | |
| 1172 | + </div> | |
| 1173 | + <button class="btn primary">Create label</button> | |
| 1174 | + </div> | |
| 1175 | + </Form> | |
| 1176 | + ) : null} | |
| 1177 | + </div>, | |
| 1178 | + ); | |
| 1179 | + }); | |
| 1180 | + | |
| 1181 | + post(web, "/:owner/:repo/labels", async (c, f) => { | |
| 1182 | + const l = await forAction(c, "triage"); | |
| 1183 | + return act(c, `${repoUrl(l.repo)}/labels`, async () => { | |
| 1184 | + const name = f.s("name").trim().slice(0, 50); | |
| 1185 | + const color = f.s("color").replace(/^#/, ""); | |
| 1186 | + if (!name) throw new HttpError(422, "name is required"); | |
| 1187 | + if (!/^[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(422, "invalid color"); | |
| 1188 | + await l.stub.setLabel(name, color, f.s("description").trim().slice(0, 100)); | |
| 1189 | + }); | |
| 1190 | + }); | |
| 1191 | + | |
| 1192 | + post(web, "/:owner/:repo/labels/delete", async (c, f) => { | |
| 1193 | + const l = await forAction(c, "triage"); | |
| 1194 | + return act(c, `${repoUrl(l.repo)}/labels`, async () => l.stub.setLabel(f.s("name"), null)); | |
| 1195 | + }); | |
| 1196 | +} | |
| 1197 | + | |
| 1198 | +const LABEL_COLORS = ["d73a4a", "0075ca", "a2eeef", "7057ff", "008672", "e4e669", "d876e3", "fbca04", "0e8a16", "5319e7", "b60205", "1d76db"]; |
addedapps/diffinery/src/web/csrf.ts+74−0View file
| @@ -1,0 +1,74 @@ | ||
| 1 | +import { getCookie, setCookie } from "hono/cookie"; | |
| 2 | +import type { AppContext } from "../auth/principal.js"; | |
| 3 | +import { HttpError, randomToken, safeEqual } from "../lib/util.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Double-submit cookie CSRF protection for the web UI. Every rendered page | |
| 7 | + * ensures a random `dfy_csrf` cookie (HttpOnly, SameSite=Strict, Secure) and | |
| 8 | + * every form carries the same value in a hidden `_csrf` field. | |
| 9 | + */ | |
| 10 | +export const CSRF_COOKIE = "dfy_csrf"; | |
| 11 | +export const CSRF_FIELD = "_csrf"; | |
| 12 | + | |
| 13 | +const memo = new WeakMap<Request, string>(); | |
| 14 | +const VALID = /^[A-Za-z0-9_-]{20,64}$/; | |
| 15 | + | |
| 16 | +/** The CSRF token for this request; sets the cookie when the browser has none. */ | |
| 17 | +export function csrfToken(c: AppContext): string { | |
| 18 | + const cached = memo.get(c.req.raw); | |
| 19 | + if (cached) return cached; | |
| 20 | + let token = getCookie(c, CSRF_COOKIE); | |
| 21 | + if (!token || !VALID.test(token)) { | |
| 22 | + token = randomToken(24); | |
| 23 | + setCookie(c, CSRF_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 * 60 * 24 * 30 }); | |
| 24 | + } | |
| 25 | + memo.set(c.req.raw, token); | |
| 26 | + return token; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface FormData { | |
| 30 | + /** First string value of a field ("" when absent). */ | |
| 31 | + s(name: string): string; | |
| 32 | + /** All string values of a (repeated) field. */ | |
| 33 | + all(name: string): string[]; | |
| 34 | + /** Checkbox semantics: present and not "false"/"0". */ | |
| 35 | + b(name: string): boolean; | |
| 36 | + /** Field names present in the form. */ | |
| 37 | + keys(): string[]; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Parse an urlencoded/multipart form once per request (Hono caches the body). */ | |
| 41 | +export async function formData(c: AppContext): Promise<FormData> { | |
| 42 | + const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>; | |
| 43 | + const all = (name: string): string[] => { | |
| 44 | + const v = raw[name]; | |
| 45 | + const list = Array.isArray(v) ? v : v === undefined ? [] : [v]; | |
| 46 | + return list.filter((x): x is string => typeof x === "string"); | |
| 47 | + }; | |
| 48 | + return { | |
| 49 | + s: (name) => all(name)[0] ?? "", | |
| 50 | + all, | |
| 51 | + b: (name) => { | |
| 52 | + const v = all(name)[0]; | |
| 53 | + return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off"; | |
| 54 | + }, | |
| 55 | + keys: () => Object.keys(raw), | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Reject state-changing requests without a matching token (and cross-origin requests). */ | |
| 60 | +export async function verifyCsrf(c: AppContext): Promise<FormData> { | |
| 61 | + const origin = c.req.header("origin"); | |
| 62 | + if (origin && origin !== "null") { | |
| 63 | + const self = new URL(c.req.url).origin; | |
| 64 | + const pub = c.env.PUBLIC_URL ? new URL(c.env.PUBLIC_URL).origin : null; | |
| 65 | + if (origin !== self && origin !== pub) throw new HttpError(403, "cross-origin form submission rejected"); | |
| 66 | + } | |
| 67 | + const type = c.req.header("content-type") ?? ""; | |
| 68 | + if (!/^(application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type)) throw new HttpError(415, "expected a form submission"); | |
| 69 | + const form = await formData(c); | |
| 70 | + const cookie = getCookie(c, CSRF_COOKIE); | |
| 71 | + const sent = form.s(CSRF_FIELD); | |
| 72 | + if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again"); | |
| 73 | + return form; | |
| 74 | +} |
addedapps/diffinery/src/web/markdown.ts+544−0View file
| @@ -1,0 +1,544 @@ | ||
| 1 | +/** | |
| 2 | + * A small, safe Markdown renderer for READMEs, issues, pull requests and job | |
| 3 | + * summaries. All raw HTML in the source is escaped; only http(s), mailto-free | |
| 4 | + * relative and fragment URLs survive in links and images. | |
| 5 | + * | |
| 6 | + * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic | |
| 7 | + * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline | |
| 8 | + * code, bold, italic, strikethrough, links, images and bare/auto links. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export interface MarkdownBase { | |
| 12 | + /** Repository owner and name, for resolving relative links. */ | |
| 13 | + owner: string; | |
| 14 | + repo: string; | |
| 15 | + /** Ref (branch, tag or sha) that relative links point at. */ | |
| 16 | + ref: string; | |
| 17 | + /** Directory of the document inside the repository ("" for the root). */ | |
| 18 | + dir: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface MarkdownOptions { | |
| 22 | + base?: MarkdownBase; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const MAX_SOURCE = 512 * 1024; | |
| 26 | +const MAX_DEPTH = 12; | |
| 27 | +const MAX_INLINE = 60_000; | |
| 28 | +const MAX_SCAN = 2000; | |
| 29 | + | |
| 30 | +const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; | |
| 31 | +export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!); | |
| 32 | + | |
| 33 | +const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); | |
| 34 | + | |
| 35 | +export function slugify(text: string): string { | |
| 36 | + return text | |
| 37 | + .toLowerCase() | |
| 38 | + .replace(/<[^>]*>/g, "") | |
| 39 | + .replace(/[^\p{L}\p{N}\s_-]/gu, "") | |
| 40 | + .trim() | |
| 41 | + .replace(/\s+/g, "-") | |
| 42 | + .slice(0, 80); | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */ | |
| 46 | +function normalise(path: string): string | null { | |
| 47 | + const out: string[] = []; | |
| 48 | + for (const seg of path.split("/")) { | |
| 49 | + if (!seg || seg === ".") continue; | |
| 50 | + if (seg === "..") { | |
| 51 | + if (!out.length) return null; | |
| 52 | + out.pop(); | |
| 53 | + } else out.push(seg); | |
| 54 | + } | |
| 55 | + return out.join("/"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * Validate and resolve a URL from Markdown source. Returns null for anything | |
| 60 | + * that is not http(s), a fragment, or a relative path. | |
| 61 | + */ | |
| 62 | +export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null { | |
| 63 | + const u = url.trim(); | |
| 64 | + if (!u) return null; | |
| 65 | + // Browsers ignore control characters and whitespace when parsing schemes. | |
| 66 | + const probe = u.replace(/[\x00-\x20\x7f]/g, ""); | |
| 67 | + if (/^https?:\/\//i.test(probe)) return u; | |
| 68 | + if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || probe.startsWith("//") || probe.startsWith("\\\\") || probe.startsWith("/\\")) return null; | |
| 69 | + if (u.startsWith("#")) return kind === "link" ? `#h-${slugify(decodeSafe(u.slice(1)))}` : null; | |
| 70 | + const base = opts.base; | |
| 71 | + if (!base) return u.startsWith("/") ? u : null; | |
| 72 | + const hashAt = u.search(/[?#]/); | |
| 73 | + const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u; | |
| 74 | + const suffix = hashAt >= 0 ? u.slice(hashAt) : ""; | |
| 75 | + const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`; | |
| 76 | + const norm = normalise(decodeSafe(joined)); | |
| 77 | + if (norm === null) return null; | |
| 78 | + const root = `/${base.owner}/${base.repo}`; | |
| 79 | + if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`; | |
| 80 | + const isDir = pathPart.endsWith("/") || norm === ""; | |
| 81 | + return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`; | |
| 82 | +} | |
| 83 | + | |
| 84 | +function decodeSafe(s: string): string { | |
| 85 | + try { | |
| 86 | + return decodeURIComponent(s); | |
| 87 | + } catch { | |
| 88 | + return s; | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +// ---------------------------------------------------------------- inline | |
| 93 | + | |
| 94 | +function findClosing(src: string, delim: string, from: number, wordy: boolean): number { | |
| 95 | + let at = from; | |
| 96 | + const limit = Math.min(src.length, from + MAX_SCAN * 2); | |
| 97 | + for (;;) { | |
| 98 | + const idx = src.indexOf(delim, at); | |
| 99 | + if (idx < 0 || idx >= limit) return -1; | |
| 100 | + const before = src[idx - 1]; | |
| 101 | + const after = src[idx + delim.length]; | |
| 102 | + if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) { | |
| 103 | + // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text. | |
| 104 | + if (delim.length === 1 && after === delim && src[idx + 2] !== delim) { | |
| 105 | + at = idx + 2; | |
| 106 | + continue; | |
| 107 | + } | |
| 108 | + return idx; | |
| 109 | + } | |
| 110 | + at = idx + 1; | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +interface LinkMatch { | |
| 115 | + text: string; | |
| 116 | + url: string; | |
| 117 | + title: string | null; | |
| 118 | + end: number; | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Parse `[text](url "title")` starting at the "[" at `i`. */ | |
| 122 | +function parseLink(src: string, i: number): LinkMatch | null { | |
| 123 | + let depth = 0; | |
| 124 | + let j = i; | |
| 125 | + const limit = Math.min(src.length, i + MAX_SCAN); | |
| 126 | + for (; j < limit; j++) { | |
| 127 | + const ch = src[j]; | |
| 128 | + if (ch === "\\") { | |
| 129 | + j++; | |
| 130 | + continue; | |
| 131 | + } | |
| 132 | + if (ch === "`") { | |
| 133 | + const close = src.indexOf("`", j + 1); | |
| 134 | + if (close > 0 && close < limit) j = close; | |
| 135 | + continue; | |
| 136 | + } | |
| 137 | + if (ch === "[") depth++; | |
| 138 | + else if (ch === "]") { | |
| 139 | + depth--; | |
| 140 | + if (depth === 0) break; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null; | |
| 144 | + const text = src.slice(i + 1, j); | |
| 145 | + let k = j + 2; | |
| 146 | + while (src[k] === " ") k++; | |
| 147 | + let url = ""; | |
| 148 | + if (src[k] === "<") { | |
| 149 | + const close = src.indexOf(">", k); | |
| 150 | + if (close < 0 || close - k > MAX_SCAN) return null; | |
| 151 | + url = src.slice(k + 1, close); | |
| 152 | + k = close + 1; | |
| 153 | + } else { | |
| 154 | + let parens = 0; | |
| 155 | + const start = k; | |
| 156 | + for (; k < src.length && k - start < MAX_SCAN; k++) { | |
| 157 | + const ch = src[k]!; | |
| 158 | + if (ch === "\\") { | |
| 159 | + k++; | |
| 160 | + continue; | |
| 161 | + } | |
| 162 | + if (/\s/.test(ch)) break; | |
| 163 | + if (ch === "(") parens++; | |
| 164 | + else if (ch === ")") { | |
| 165 | + if (parens === 0) break; | |
| 166 | + parens--; | |
| 167 | + } | |
| 168 | + } | |
| 169 | + url = src.slice(start, k).replace(/\\([()])/g, "$1"); | |
| 170 | + } | |
| 171 | + while (src[k] === " ") k++; | |
| 172 | + let title: string | null = null; | |
| 173 | + const q = src[k]; | |
| 174 | + if (q === '"' || q === "'" || q === "(") { | |
| 175 | + const closeCh = q === "(" ? ")" : q; | |
| 176 | + const close = src.indexOf(closeCh, k + 1); | |
| 177 | + if (close < 0) return null; | |
| 178 | + title = src.slice(k + 1, close); | |
| 179 | + k = close + 1; | |
| 180 | + while (src[k] === " ") k++; | |
| 181 | + } | |
| 182 | + if (src[k] !== ")") return null; | |
| 183 | + return { text, url, title, end: k + 1 }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i; | |
| 187 | + | |
| 188 | +function inline(src: string, opts: MarkdownOptions, depth = 0): string { | |
| 189 | + if (src.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(src); | |
| 190 | + let out = ""; | |
| 191 | + let i = 0; | |
| 192 | + const n = src.length; | |
| 193 | + while (i < n) { | |
| 194 | + const ch = src[i]!; | |
| 195 | + // Backslash escapes and hard line breaks. | |
| 196 | + if (ch === "\\") { | |
| 197 | + const next = src[i + 1]; | |
| 198 | + if (next === "\n") { | |
| 199 | + out += "<br>\n"; | |
| 200 | + i += 2; | |
| 201 | + continue; | |
| 202 | + } | |
| 203 | + if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) { | |
| 204 | + out += escapeHtml(next); | |
| 205 | + i += 2; | |
| 206 | + continue; | |
| 207 | + } | |
| 208 | + } | |
| 209 | + if (ch === "\n") { | |
| 210 | + if (out.endsWith(" ")) out = `${out.trimEnd()}<br>`; | |
| 211 | + out += "\n"; | |
| 212 | + i++; | |
| 213 | + continue; | |
| 214 | + } | |
| 215 | + // Code spans. | |
| 216 | + if (ch === "`") { | |
| 217 | + let run = 1; | |
| 218 | + while (src[i + run] === "`") run++; | |
| 219 | + const fence = "`".repeat(run); | |
| 220 | + let close = src.indexOf(fence, i + run); | |
| 221 | + while (close >= 0 && src[close + run] === "`") { | |
| 222 | + let skip = close; | |
| 223 | + while (src[skip] === "`") skip++; | |
| 224 | + close = src.indexOf(fence, skip); | |
| 225 | + } | |
| 226 | + if (close >= 0) { | |
| 227 | + let code = src.slice(i + run, close).replace(/\n/g, " "); | |
| 228 | + if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1); | |
| 229 | + out += `<code>${escapeHtml(code)}</code>`; | |
| 230 | + i = close + run; | |
| 231 | + continue; | |
| 232 | + } | |
| 233 | + out += fence; | |
| 234 | + i += run; | |
| 235 | + continue; | |
| 236 | + } | |
| 237 | + // Images. | |
| 238 | + if (ch === "!" && src[i + 1] === "[") { | |
| 239 | + const m = parseLink(src, i + 1); | |
| 240 | + if (m) { | |
| 241 | + const url = safeUrl(m.url, "image", opts); | |
| 242 | + const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, "")); | |
| 243 | + if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`; | |
| 244 | + else out += alt; | |
| 245 | + i = m.end; | |
| 246 | + continue; | |
| 247 | + } | |
| 248 | + } | |
| 249 | + // Links. | |
| 250 | + if (ch === "[") { | |
| 251 | + const m = parseLink(src, i); | |
| 252 | + if (m) { | |
| 253 | + const url = safeUrl(m.url, "link", opts); | |
| 254 | + const label = inline(m.text, opts, depth + 1); | |
| 255 | + if (url) { | |
| 256 | + const external = /^https?:/i.test(url); | |
| 257 | + out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`; | |
| 258 | + } else out += label; | |
| 259 | + i = m.end; | |
| 260 | + continue; | |
| 261 | + } | |
| 262 | + } | |
| 263 | + // Autolinks <https://…>. | |
| 264 | + if (ch === "<") { | |
| 265 | + const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN)); | |
| 266 | + if (m) { | |
| 267 | + const url = escapeHtml(m[1]!); | |
| 268 | + out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`; | |
| 269 | + i += m[0].length; | |
| 270 | + continue; | |
| 271 | + } | |
| 272 | + } | |
| 273 | + // Bare URLs. | |
| 274 | + if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) { | |
| 275 | + const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN)); | |
| 276 | + if (m) { | |
| 277 | + const url = escapeHtml(m[0]); | |
| 278 | + out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`; | |
| 279 | + i += m[0].length; | |
| 280 | + continue; | |
| 281 | + } | |
| 282 | + } | |
| 283 | + // Strikethrough. | |
| 284 | + if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) { | |
| 285 | + const close = findClosing(src, "~~", i + 2, false); | |
| 286 | + if (close > i + 2) { | |
| 287 | + out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`; | |
| 288 | + i = close + 2; | |
| 289 | + continue; | |
| 290 | + } | |
| 291 | + } | |
| 292 | + // Emphasis. | |
| 293 | + if (ch === "*" || ch === "_") { | |
| 294 | + let run = 1; | |
| 295 | + while (src[i + run] === ch) run++; | |
| 296 | + const wordy = ch === "_"; | |
| 297 | + const prev = src[i - 1]; | |
| 298 | + const nextCh = src[i + run]; | |
| 299 | + const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev)); | |
| 300 | + let matched = false; | |
| 301 | + if (canOpen && run <= 3) { | |
| 302 | + for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) { | |
| 303 | + const delim = ch.repeat(size); | |
| 304 | + const start = i + run; | |
| 305 | + const close = findClosing(src, delim, start, wordy); | |
| 306 | + if (close > start) { | |
| 307 | + const inner = inline(src.slice(start, close), opts, depth + 1); | |
| 308 | + out += escapeHtml(ch.repeat(run - size)); | |
| 309 | + out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`; | |
| 310 | + i = close + size; | |
| 311 | + matched = true; | |
| 312 | + break; | |
| 313 | + } | |
| 314 | + } | |
| 315 | + } | |
| 316 | + if (matched) continue; | |
| 317 | + out += escapeHtml(ch.repeat(run)); | |
| 318 | + i += run; | |
| 319 | + continue; | |
| 320 | + } | |
| 321 | + out += ESC[ch] ?? ch; | |
| 322 | + i++; | |
| 323 | + } | |
| 324 | + return out; | |
| 325 | +} | |
| 326 | + | |
| 327 | +// ---------------------------------------------------------------- blocks | |
| 328 | + | |
| 329 | +const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/; | |
| 330 | +const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/; | |
| 331 | +const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/; | |
| 332 | +const QUOTE = /^ {0,3}> ?(.*)$/; | |
| 333 | +const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+(.*))?$/; | |
| 334 | +const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/; | |
| 335 | + | |
| 336 | +function splitRow(line: string): string[] { | |
| 337 | + let s = line.trim(); | |
| 338 | + if (s.startsWith("|")) s = s.slice(1); | |
| 339 | + if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1); | |
| 340 | + const cells: string[] = []; | |
| 341 | + let cur = ""; | |
| 342 | + let inCode = false; | |
| 343 | + for (let i = 0; i < s.length; i++) { | |
| 344 | + const ch = s[i]!; | |
| 345 | + if (ch === "\\" && s[i + 1] === "|") { | |
| 346 | + cur += "|"; | |
| 347 | + i++; | |
| 348 | + continue; | |
| 349 | + } | |
| 350 | + if (ch === "`") inCode = !inCode; | |
| 351 | + if (ch === "|" && !inCode) { | |
| 352 | + cells.push(cur.trim()); | |
| 353 | + cur = ""; | |
| 354 | + continue; | |
| 355 | + } | |
| 356 | + cur += ch; | |
| 357 | + } | |
| 358 | + cells.push(cur.trim()); | |
| 359 | + return cells; | |
| 360 | +} | |
| 361 | + | |
| 362 | +function startsBlock(line: string, next: string | undefined): boolean { | |
| 363 | + return ( | |
| 364 | + FENCE.test(line) || | |
| 365 | + HEADING.test(line) || | |
| 366 | + HR.test(line) || | |
| 367 | + QUOTE.test(line) || | |
| 368 | + /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) || | |
| 369 | + (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-")) | |
| 370 | + ); | |
| 371 | +} | |
| 372 | + | |
| 373 | +function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string { | |
| 374 | + if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`; | |
| 375 | + let i = 0; | |
| 376 | + const parts: string[] = []; | |
| 377 | + while (i < lines.length) { | |
| 378 | + const line = lines[i]!; | |
| 379 | + if (!line.trim()) { | |
| 380 | + i++; | |
| 381 | + continue; | |
| 382 | + } | |
| 383 | + // Fenced code. | |
| 384 | + const fence = FENCE.exec(line); | |
| 385 | + if (fence) { | |
| 386 | + const marker = fence[1]!; | |
| 387 | + const lang = fence[2] ?? ""; | |
| 388 | + const indent = line.length - line.trimStart().length; | |
| 389 | + const body: string[] = []; | |
| 390 | + i++; | |
| 391 | + while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) { | |
| 392 | + body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), "")); | |
| 393 | + i++; | |
| 394 | + } | |
| 395 | + i++; | |
| 396 | + const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : ""; | |
| 397 | + parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`); | |
| 398 | + continue; | |
| 399 | + } | |
| 400 | + // Indented code (4 spaces), only outside lists. | |
| 401 | + if (/^( {4}|\t)/.test(line) && !tight) { | |
| 402 | + const body: string[] = []; | |
| 403 | + while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) { | |
| 404 | + body.push(lines[i]!.replace(/^( {4}|\t)/, "")); | |
| 405 | + i++; | |
| 406 | + } | |
| 407 | + while (body.length && !body[body.length - 1]!.trim()) body.pop(); | |
| 408 | + parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`); | |
| 409 | + continue; | |
| 410 | + } | |
| 411 | + const h = HEADING.exec(line); | |
| 412 | + if (h) { | |
| 413 | + const level = h[1]!.length; | |
| 414 | + const text = h[2] ?? ""; | |
| 415 | + const html = inline(text, opts, depth); | |
| 416 | + const slug = slugify(text); | |
| 417 | + parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${html}</h${level}>`); | |
| 418 | + i++; | |
| 419 | + continue; | |
| 420 | + } | |
| 421 | + if (HR.test(line)) { | |
| 422 | + parts.push("<hr>"); | |
| 423 | + i++; | |
| 424 | + continue; | |
| 425 | + } | |
| 426 | + if (QUOTE.test(line)) { | |
| 427 | + const body: string[] = []; | |
| 428 | + while (i < lines.length && lines[i]!.trim()) { | |
| 429 | + const m = QUOTE.exec(lines[i]!); | |
| 430 | + if (m) body.push(m[1]!); | |
| 431 | + else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!); | |
| 432 | + else break; | |
| 433 | + i++; | |
| 434 | + } | |
| 435 | + parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`); | |
| 436 | + continue; | |
| 437 | + } | |
| 438 | + // Tables. | |
| 439 | + if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!) && lines[i + 1]!.includes("-")) { | |
| 440 | + const head = splitRow(line); | |
| 441 | + const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "")); | |
| 442 | + i += 2; | |
| 443 | + const rows: string[][] = []; | |
| 444 | + while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) { | |
| 445 | + rows.push(splitRow(lines[i]!)); | |
| 446 | + i++; | |
| 447 | + } | |
| 448 | + const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`; | |
| 449 | + let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`; | |
| 450 | + if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`; | |
| 451 | + parts.push(`${t}</table></div>`); | |
| 452 | + continue; | |
| 453 | + } | |
| 454 | + // Lists. | |
| 455 | + const li = LIST_ITEM.exec(line); | |
| 456 | + if (li) { | |
| 457 | + const ordered = /\d/.test(li[2]!); | |
| 458 | + const baseIndent = li[1]!.length; | |
| 459 | + const start = ordered ? parseInt(li[2]!, 10) : 1; | |
| 460 | + const items: string[][] = []; | |
| 461 | + let loose = false; | |
| 462 | + while (i < lines.length) { | |
| 463 | + const m = LIST_ITEM.exec(lines[i]!); | |
| 464 | + if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break; | |
| 465 | + const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length)); | |
| 466 | + const item: string[] = [m[4] ?? ""]; | |
| 467 | + i++; | |
| 468 | + let sawBlank = false; | |
| 469 | + while (i < lines.length) { | |
| 470 | + const l = lines[i]!; | |
| 471 | + if (!l.trim()) { | |
| 472 | + sawBlank = true; | |
| 473 | + item.push(""); | |
| 474 | + i++; | |
| 475 | + continue; | |
| 476 | + } | |
| 477 | + const ind = l.length - l.trimStart().length; | |
| 478 | + if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) { | |
| 479 | + item.push(l.slice(Math.min(ind, contentIndent))); | |
| 480 | + sawBlank = false; | |
| 481 | + i++; | |
| 482 | + continue; | |
| 483 | + } | |
| 484 | + if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) { | |
| 485 | + // Lazy continuation of the item's paragraph. | |
| 486 | + item.push(l.trim()); | |
| 487 | + i++; | |
| 488 | + continue; | |
| 489 | + } | |
| 490 | + break; | |
| 491 | + } | |
| 492 | + while (item.length && !item[item.length - 1]!.trim()) { | |
| 493 | + item.pop(); | |
| 494 | + if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true; | |
| 495 | + } | |
| 496 | + if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true; | |
| 497 | + items.push(item); | |
| 498 | + } | |
| 499 | + const rendered = items.map((item) => { | |
| 500 | + let first = item[0] ?? ""; | |
| 501 | + let task = ""; | |
| 502 | + const tm = /^\[([ xX])\][ \t]+/.exec(first); | |
| 503 | + if (tm) { | |
| 504 | + task = `<input type="checkbox" disabled${tm[1] !== " " ? " checked" : ""}> `; | |
| 505 | + first = first.slice(tm[0].length); | |
| 506 | + } | |
| 507 | + const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose); | |
| 508 | + return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`; | |
| 509 | + }); | |
| 510 | + parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`); | |
| 511 | + continue; | |
| 512 | + } | |
| 513 | + // Paragraph. | |
| 514 | + const para: string[] = [line.trimStart()]; | |
| 515 | + i++; | |
| 516 | + while (i < lines.length && lines[i]!.trim()) { | |
| 517 | + // Setext headings (checked before "---" is taken as a thematic break). | |
| 518 | + if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) { | |
| 519 | + const level = lines[i]!.includes("=") ? 1 : 2; | |
| 520 | + const text = para.join(" "); | |
| 521 | + const slug = slugify(text); | |
| 522 | + para.length = 0; | |
| 523 | + parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`); | |
| 524 | + i++; | |
| 525 | + break; | |
| 526 | + } | |
| 527 | + if (startsBlock(lines[i]!, lines[i + 1])) break; | |
| 528 | + para.push(lines[i]!.trimStart()); | |
| 529 | + i++; | |
| 530 | + } | |
| 531 | + if (para.length) { | |
| 532 | + const html = inline(para.join("\n").replace(/[ \t]+$/, ""), opts, depth); | |
| 533 | + parts.push(tight ? html : `<p>${html}</p>`); | |
| 534 | + } | |
| 535 | + } | |
| 536 | + return parts.join("\n"); | |
| 537 | +} | |
| 538 | + | |
| 539 | +/** Render Markdown to sanitized HTML. */ | |
| 540 | +export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string { | |
| 541 | + if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`; | |
| 542 | + const lines = source.replace(/\r\n?/g, "\n").split("\n"); | |
| 543 | + return blocks(lines, opts, 0); | |
| 544 | +} |
addedapps/diffinery/src/web/repo-settings.tsx+744−0View file
| @@ -1,0 +1,744 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { Actor, CopyButton, Form, ROLES, Time, act, ago, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 11 | + | |
| 12 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 13 | +type Tab = "general" | "access" | "branches" | "secrets" | "webhooks"; | |
| 14 | + | |
| 15 | +const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; | |
| 16 | + | |
| 17 | +const load = (c: AppContext) => loadRepo(c, prm(c, "owner"), prm(c, "repo"), "admin"); | |
| 18 | + | |
| 19 | +function audit(c: AppContext, l: Loaded, action: string, detail?: Record<string, unknown>): void { | |
| 20 | + const p = c.get("principal"); | |
| 21 | + const actor = p.type === "account" ? p.account : null; | |
| 22 | + c.executionCtx.waitUntil( | |
| 23 | + c | |
| 24 | + .get("dir") | |
| 25 | + .audit({ actorId: actor?.id, actorLogin: actor?.login, onBehalfOf: l.caller.onBehalfOf, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail }) | |
| 26 | + .catch((e) => console.error("audit failed", e)), | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number) { | |
| 31 | + const base = `${repoUrl(l.repo)}/settings`; | |
| 32 | + const nav: Array<[Tab, string, string]> = [ | |
| 33 | + ["general", "General", base], | |
| 34 | + ["access", "Collaborators", `${base}/access`], | |
| 35 | + ["branches", "Branch protection", `${base}/branches`], | |
| 36 | + ["secrets", "Secrets and variables", `${base}/secrets`], | |
| 37 | + ["webhooks", "Webhooks", `${base}/webhooks`], | |
| 38 | + ]; | |
| 39 | + return render( | |
| 40 | + c, | |
| 41 | + { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status }, | |
| 42 | + <div class="grid left"> | |
| 43 | + <aside class="side"> | |
| 44 | + <section> | |
| 45 | + <div class="nav"> | |
| 46 | + {nav.map(([k, label, href]) => ( | |
| 47 | + <a href={href} class={tab === k ? "on" : ""}> | |
| 48 | + {label} | |
| 49 | + </a> | |
| 50 | + ))} | |
| 51 | + </div> | |
| 52 | + </section> | |
| 53 | + </aside> | |
| 54 | + <div>{body}</div> | |
| 55 | + </div>, | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +const Once: FC<{ title: string; value: string; note: string }> = ({ title, value, note }) => ( | |
| 60 | + <div class="box" style="border-color:var(--amber)"> | |
| 61 | + <div class="body"> | |
| 62 | + <h3>{title}</h3> | |
| 63 | + <p class="muted">{note}</p> | |
| 64 | + <div class="row"> | |
| 65 | + <div class="secret grow">{value}</div> | |
| 66 | + <CopyButton text={value} /> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | +); | |
| 71 | + | |
| 72 | +// ---------------------------------------------------------------- protection form | |
| 73 | + | |
| 74 | +const RULE_FLAGS: Array<[keyof ProtectionRules, string, string]> = [ | |
| 75 | + ["requirePullRequest", "Require a pull request before merging", "Direct pushes are rejected; changes land through reviewed pull requests."], | |
| 76 | + ["dismissStaleReviews", "Dismiss stale approvals when new commits are pushed", ""], | |
| 77 | + ["requireLinearHistory", "Require linear history", "Merge commits are rejected; use squash or rebase merges."], | |
| 78 | + ["allowForcePush", "Allow force pushes", "Off by default for protected branches."], | |
| 79 | + ["allowDeletion", "Allow deletion", ""], | |
| 80 | + ["enforceAdmins", "Do not allow administrators to bypass these rules", ""], | |
| 81 | + ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."], | |
| 82 | + ["agentsMayMerge", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."], | |
| 83 | +]; | |
| 84 | + | |
| 85 | +const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => ( | |
| 86 | + <Form action={`${base}/settings/branches`} t={csrfToken(c)} class="body"> | |
| 87 | + <input type="hidden" name="original" value={pattern ?? ""} /> | |
| 88 | + <div class="row" style="align-items:flex-start"> | |
| 89 | + <div class="field grow"> | |
| 90 | + <label>Branch name pattern</label> | |
| 91 | + <input name="pattern" type="text" required value={pattern ?? ""} placeholder="main, release/*, **" class="mono" /> | |
| 92 | + <div class="hint"> | |
| 93 | + <code>*</code> matches within a path segment, <code>**</code> across segments. | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + <div class="field" style="width:180px"> | |
| 97 | + <label>Required approvals</label> | |
| 98 | + <input name="requiredApprovals" type="number" min={0} max={10} value={String(rules.requiredApprovals ?? 0)} /> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + <div class="field"> | |
| 102 | + <label>Required status checks</label> | |
| 103 | + <input name="requiredChecks" type="text" value={(rules.requiredChecks ?? []).join(", ")} placeholder="ci / test, lint" /> | |
| 104 | + <div class="hint">Comma-separated check names, exactly as they appear on pull requests.</div> | |
| 105 | + </div> | |
| 106 | + <div class="field"> | |
| 107 | + {RULE_FLAGS.map(([key, label, hint]) => ( | |
| 108 | + <label class="inline" style="margin:4px 0;align-items:flex-start"> | |
| 109 | + <input type="checkbox" name={key} value="1" checked={!!rules[key]} style="margin-top:4px" /> | |
| 110 | + <span> | |
| 111 | + {label} | |
| 112 | + {hint ? <span class="muted small"> — {hint}</span> : null} | |
| 113 | + </span> | |
| 114 | + </label> | |
| 115 | + ))} | |
| 116 | + </div> | |
| 117 | + <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button> | |
| 118 | + </Form> | |
| 119 | +); | |
| 120 | + | |
| 121 | +function rulesFrom(f: FormData): ProtectionRules { | |
| 122 | + const rules: ProtectionRules = {}; | |
| 123 | + for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true; | |
| 124 | + const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0)); | |
| 125 | + if (approvals) rules.requiredApprovals = approvals; | |
| 126 | + const checks = f | |
| 127 | + .s("requiredChecks") | |
| 128 | + .split(/[,\n]/) | |
| 129 | + .map((s) => s.trim()) | |
| 130 | + .filter(Boolean); | |
| 131 | + if (checks.length) rules.requiredChecks = checks; | |
| 132 | + return rules; | |
| 133 | +} | |
| 134 | + | |
| 135 | +// ---------------------------------------------------------------- pages | |
| 136 | + | |
| 137 | +async function generalPage(c: AppContext, l: Loaded) { | |
| 138 | + const { repo } = l; | |
| 139 | + const base = repoUrl(repo); | |
| 140 | + const t = csrfToken(c); | |
| 141 | + const names = await refNames(l.stub); | |
| 142 | + const full = `${repo.owner_login}/${repo.name}`; | |
| 143 | + return page( | |
| 144 | + c, | |
| 145 | + l, | |
| 146 | + "general", | |
| 147 | + <> | |
| 148 | + <h1>General</h1> | |
| 149 | + <Form action={`${base}/settings/general`} t={t} class="box"> | |
| 150 | + <div class="body"> | |
| 151 | + <div class="field"> | |
| 152 | + <label>Repository name</label> | |
| 153 | + <input name="name" type="text" value={repo.name} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 154 | + <div class="hint">Renaming changes the clone URL; update your remotes afterwards.</div> | |
| 155 | + </div> | |
| 156 | + <div class="field"> | |
| 157 | + <label>Description</label> | |
| 158 | + <input name="description" type="text" value={repo.description} maxlength={350} /> | |
| 159 | + </div> | |
| 160 | + <div class="field" style="max-width:320px"> | |
| 161 | + <label>Default branch</label> | |
| 162 | + {names.branches.length ? ( | |
| 163 | + <select name="default_branch"> | |
| 164 | + {names.branches.map((b) => ( | |
| 165 | + <option value={b} selected={b === repo.default_branch}> | |
| 166 | + {b} | |
| 167 | + </option> | |
| 168 | + ))} | |
| 169 | + </select> | |
| 170 | + ) : ( | |
| 171 | + <input name="default_branch" type="text" value={repo.default_branch} /> | |
| 172 | + )} | |
| 173 | + </div> | |
| 174 | + <div class="field"> | |
| 175 | + <label>Visibility</label> | |
| 176 | + {(["private", "internal", "public"] as const).map((v) => ( | |
| 177 | + <label class="inline" style="margin-right:16px"> | |
| 178 | + <input type="radio" name="visibility" value={v} checked={repo.visibility === v} /> {v} | |
| 179 | + </label> | |
| 180 | + ))} | |
| 181 | + <div class="hint">Internal repositories are visible to everyone signed in to this instance; public ones to anyone.</div> | |
| 182 | + </div> | |
| 183 | + <button class="btn primary">Save changes</button> | |
| 184 | + </div> | |
| 185 | + </Form> | |
| 186 | + <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> | |
| 187 | + <div class="head" style="background:var(--red-soft)"> | |
| 188 | + <h2 class="bad">Danger zone</h2> | |
| 189 | + </div> | |
| 190 | + <div class="body"> | |
| 191 | + <Form action={`${base}/settings/archive`} t={t} class="row between mb" confirm={repo.archived ? "Unarchive this repository?" : "Archive this repository? It becomes read-only."}> | |
| 192 | + <input type="hidden" name="archived" value={repo.archived ? "0" : "1"} /> | |
| 193 | + <div> | |
| 194 | + <b>{repo.archived ? "Unarchive this repository" : "Archive this repository"}</b> | |
| 195 | + <div class="muted small">{repo.archived ? "Make the repository writable again." : "Mark it read-only: no pushes, issues, pull requests or runs."}</div> | |
| 196 | + </div> | |
| 197 | + <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button> | |
| 198 | + </Form> | |
| 199 | + <hr /> | |
| 200 | + <Form action={`${base}/settings/delete`} t={t} match={full}> | |
| 201 | + <b>Delete this repository</b> | |
| 202 | + <p class="muted small"> | |
| 203 | + This permanently deletes the repository, its history, issues, pull requests, runs and secrets. Type <b class="mono">{full}</b> to confirm. | |
| 204 | + </p> | |
| 205 | + <div class="row"> | |
| 206 | + <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> | |
| 207 | + <button class="btn danger">Delete this repository</button> | |
| 208 | + </div> | |
| 209 | + </Form> | |
| 210 | + </div> | |
| 211 | + </div> | |
| 212 | + </>, | |
| 213 | + ); | |
| 214 | +} | |
| 215 | + | |
| 216 | +async function accessPage(c: AppContext, l: Loaded, invite?: { url: string; email: string }) { | |
| 217 | + const { repo } = l; | |
| 218 | + const base = repoUrl(repo); | |
| 219 | + const t = csrfToken(c); | |
| 220 | + const collaborators = await c.get("dir").collaborators(repo.id); | |
| 221 | + return page( | |
| 222 | + c, | |
| 223 | + l, | |
| 224 | + "access", | |
| 225 | + <> | |
| 226 | + <h1>Collaborators</h1> | |
| 227 | + <p class="muted"> | |
| 228 | + {repo.owner_kind === "org" ? ( | |
| 229 | + <> | |
| 230 | + Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have write access (owners and admins: admin). Add people here for extra or narrower access. | |
| 231 | + </> | |
| 232 | + ) : ( | |
| 233 | + <> | |
| 234 | + <b>{repo.owner_login}</b> owns this repository. Add collaborators to share it with friends or agents. | |
| 235 | + </> | |
| 236 | + )} | |
| 237 | + </p> | |
| 238 | + {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days. Opening it lets them create a passkey and join." /> : null} | |
| 239 | + <div class="box"> | |
| 240 | + {collaborators.length ? ( | |
| 241 | + <table class="tbl"> | |
| 242 | + <thead> | |
| 243 | + <tr> | |
| 244 | + <th>Account</th> | |
| 245 | + <th>Role</th> | |
| 246 | + <th /> | |
| 247 | + </tr> | |
| 248 | + </thead> | |
| 249 | + <tbody> | |
| 250 | + {collaborators.map((x) => ( | |
| 251 | + <tr> | |
| 252 | + <td> | |
| 253 | + <Actor login={x.account.login} kind={x.account.kind} /> | |
| 254 | + </td> | |
| 255 | + <td> | |
| 256 | + <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 257 | + <input type="hidden" name="login" value={x.account.login} /> | |
| 258 | + <select name="role" style="width:auto;padding:3px 6px"> | |
| 259 | + {ROLES.map((r) => ( | |
| 260 | + <option value={r} selected={x.role === r}> | |
| 261 | + {r} | |
| 262 | + </option> | |
| 263 | + ))} | |
| 264 | + </select> | |
| 265 | + <button class="btn sm">Save</button> | |
| 266 | + </Form> | |
| 267 | + </td> | |
| 268 | + <td class="right"> | |
| 269 | + <Form action={`${base}/settings/access`} t={t} class="inline" confirm={`Remove ${x.account.login}'s access?`}> | |
| 270 | + <input type="hidden" name="login" value={x.account.login} /> | |
| 271 | + <input type="hidden" name="role" value="remove" /> | |
| 272 | + <button class="btn sm danger">Remove</button> | |
| 273 | + </Form> | |
| 274 | + </td> | |
| 275 | + </tr> | |
| 276 | + ))} | |
| 277 | + </tbody> | |
| 278 | + </table> | |
| 279 | + ) : ( | |
| 280 | + <div class="empty">No direct collaborators.</div> | |
| 281 | + )} | |
| 282 | + </div> | |
| 283 | + <div class="row" style="align-items:stretch"> | |
| 284 | + <Form action={`${base}/settings/access`} t={t} class="box grow"> | |
| 285 | + <div class="head"> | |
| 286 | + <h3>Add by username</h3> | |
| 287 | + </div> | |
| 288 | + <div class="body"> | |
| 289 | + <div class="field"> | |
| 290 | + <label>Username (person or agent)</label> | |
| 291 | + <input name="login" type="text" required /> | |
| 292 | + </div> | |
| 293 | + <div class="field"> | |
| 294 | + <label>Role</label> | |
| 295 | + <select name="role"> | |
| 296 | + {ROLES.map((r) => ( | |
| 297 | + <option value={r} selected={r === "write"}> | |
| 298 | + {r} | |
| 299 | + </option> | |
| 300 | + ))} | |
| 301 | + </select> | |
| 302 | + </div> | |
| 303 | + <button class="btn primary">Add collaborator</button> | |
| 304 | + </div> | |
| 305 | + </Form> | |
| 306 | + <Form action={`${base}/settings/access/invite`} t={t} class="box grow"> | |
| 307 | + <div class="head"> | |
| 308 | + <h3>Invite by email</h3> | |
| 309 | + </div> | |
| 310 | + <div class="body"> | |
| 311 | + <div class="field"> | |
| 312 | + <label>Email address</label> | |
| 313 | + <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 314 | + </div> | |
| 315 | + <div class="field"> | |
| 316 | + <label>Role</label> | |
| 317 | + <select name="role"> | |
| 318 | + {ROLES.map((r) => ( | |
| 319 | + <option value={r} selected={r === "write"}> | |
| 320 | + {r} | |
| 321 | + </option> | |
| 322 | + ))} | |
| 323 | + </select> | |
| 324 | + </div> | |
| 325 | + <button class="btn primary">Create invite link</button> | |
| 326 | + </div> | |
| 327 | + </Form> | |
| 328 | + </div> | |
| 329 | + <div class="box"> | |
| 330 | + <div class="body small muted"> | |
| 331 | + <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access. | |
| 332 | + </div> | |
| 333 | + </div> | |
| 334 | + </>, | |
| 335 | + ); | |
| 336 | +} | |
| 337 | + | |
| 338 | +async function branchesPage(c: AppContext, l: Loaded) { | |
| 339 | + const base = repoUrl(l.repo); | |
| 340 | + const t = csrfToken(c); | |
| 341 | + const rules = await l.stub.listProtections(); | |
| 342 | + return page( | |
| 343 | + c, | |
| 344 | + l, | |
| 345 | + "branches", | |
| 346 | + <> | |
| 347 | + <h1>Branch protection</h1> | |
| 348 | + <p class="muted"> | |
| 349 | + Rules protect matching branches from force pushes, deletion and unreviewed changes. The default branch (<span class="mono">{l.repo.default_branch}</span>) is always protected from agents. | |
| 350 | + </p> | |
| 351 | + {rules.map((r) => ( | |
| 352 | + <details class="box"> | |
| 353 | + <summary class="head" style="cursor:pointer"> | |
| 354 | + {raw(ICONS.shield)} <b class="mono">{r.pattern}</b> | |
| 355 | + <span class="muted small grow"> | |
| 356 | + {[ | |
| 357 | + r.rules.requirePullRequest ? "PR required" : "", | |
| 358 | + r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "", | |
| 359 | + r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "", | |
| 360 | + r.rules.requireLinearHistory ? "linear history" : "", | |
| 361 | + r.rules.enforceAdmins ? "admins included" : "", | |
| 362 | + ] | |
| 363 | + .filter(Boolean) | |
| 364 | + .join(" · ") || "basic protection"} | |
| 365 | + </span> | |
| 366 | + <Form action={`${base}/settings/branches/delete`} t={t} class="inline" confirm={`Delete the rule for ${r.pattern}?`}> | |
| 367 | + <input type="hidden" name="pattern" value={r.pattern} /> | |
| 368 | + <button class="btn sm danger">Delete</button> | |
| 369 | + </Form> | |
| 370 | + </summary> | |
| 371 | + <RuleForm c={c} base={base} pattern={r.pattern} rules={r.rules} /> | |
| 372 | + </details> | |
| 373 | + ))} | |
| 374 | + {rules.length === 0 ? <div class="blank mb">No branch protection rules yet.</div> : null} | |
| 375 | + <div class="box"> | |
| 376 | + <div class="head"> | |
| 377 | + <h3>Add a rule</h3> | |
| 378 | + </div> | |
| 379 | + <RuleForm c={c} base={base} rules={{ requirePullRequest: true, requiredApprovals: 1 }} /> | |
| 380 | + </div> | |
| 381 | + </>, | |
| 382 | + ); | |
| 383 | +} | |
| 384 | + | |
| 385 | +async function secretsPage(c: AppContext, l: Loaded) { | |
| 386 | + const base = repoUrl(l.repo); | |
| 387 | + const t = csrfToken(c); | |
| 388 | + const [secrets, vars] = await Promise.all([l.stub.secretNames(), l.stub.variables()]); | |
| 389 | + return page( | |
| 390 | + c, | |
| 391 | + l, | |
| 392 | + "secrets", | |
| 393 | + <> | |
| 394 | + <h1>Secrets and variables</h1> | |
| 395 | + <p class="muted"> | |
| 396 | + Use them in workflows as <code>{"${{ secrets.NAME }}"}</code> and <code>{"${{ vars.NAME }}"}</code>. Secret values are encrypted and never shown again; they are masked in logs. | |
| 397 | + </p> | |
| 398 | + <div class="box"> | |
| 399 | + <div class="head"> | |
| 400 | + <h2>{raw(ICONS.key)} Repository secrets</h2> | |
| 401 | + </div> | |
| 402 | + {secrets.length ? ( | |
| 403 | + <ul class="list"> | |
| 404 | + {secrets.map((s) => ( | |
| 405 | + <li style="align-items:center"> | |
| 406 | + <span class="mono grow"> | |
| 407 | + <b>{s.name}</b> | |
| 408 | + </span> | |
| 409 | + <span class="muted small">updated {ago(s.updatedAt)}</span> | |
| 410 | + <Form action={`${base}/settings/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> | |
| 411 | + <input type="hidden" name="name" value={s.name} /> | |
| 412 | + <button class="btn sm danger">Delete</button> | |
| 413 | + </Form> | |
| 414 | + </li> | |
| 415 | + ))} | |
| 416 | + </ul> | |
| 417 | + ) : ( | |
| 418 | + <div class="empty">No secrets yet.</div> | |
| 419 | + )} | |
| 420 | + <Form action={`${base}/settings/secrets`} t={t} class="body" style="border-top:1px solid var(--border2)"> | |
| 421 | + <div class="field" style="max-width:360px"> | |
| 422 | + <label>Name</label> | |
| 423 | + <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="API_TOKEN" autocomplete="off" /> | |
| 424 | + </div> | |
| 425 | + <div class="field"> | |
| 426 | + <label>Value</label> | |
| 427 | + <textarea name="value" required class="mono" style="min-height:80px" autocomplete="off" spellcheck={false}></textarea> | |
| 428 | + <div class="hint">Setting an existing name replaces its value.</div> | |
| 429 | + </div> | |
| 430 | + <button class="btn primary">Save secret</button> | |
| 431 | + </Form> | |
| 432 | + </div> | |
| 433 | + <div class="box"> | |
| 434 | + <div class="head"> | |
| 435 | + <h2>Variables</h2> | |
| 436 | + </div> | |
| 437 | + {Object.keys(vars).length ? ( | |
| 438 | + <table class="tbl"> | |
| 439 | + <tbody> | |
| 440 | + {Object.entries(vars) | |
| 441 | + .sort(([a], [b]) => a.localeCompare(b)) | |
| 442 | + .map(([k, v]) => ( | |
| 443 | + <tr> | |
| 444 | + <td class="mono"> | |
| 445 | + <b>{k}</b> | |
| 446 | + </td> | |
| 447 | + <td class="mono small" style="word-break:break-all"> | |
| 448 | + {v} | |
| 449 | + </td> | |
| 450 | + <td class="right"> | |
| 451 | + <Form action={`${base}/settings/variables/delete`} t={t} class="inline" confirm={`Delete variable ${k}?`}> | |
| 452 | + <input type="hidden" name="name" value={k} /> | |
| 453 | + <button class="btn sm danger">Delete</button> | |
| 454 | + </Form> | |
| 455 | + </td> | |
| 456 | + </tr> | |
| 457 | + ))} | |
| 458 | + </tbody> | |
| 459 | + </table> | |
| 460 | + ) : ( | |
| 461 | + <div class="empty">No variables yet.</div> | |
| 462 | + )} | |
| 463 | + <Form action={`${base}/settings/variables`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> | |
| 464 | + <div class="field m0" style="min-width:200px"> | |
| 465 | + <label>Name</label> | |
| 466 | + <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" /> | |
| 467 | + </div> | |
| 468 | + <div class="field grow m0"> | |
| 469 | + <label>Value</label> | |
| 470 | + <input name="value" type="text" class="mono" /> | |
| 471 | + </div> | |
| 472 | + <button class="btn primary">Save variable</button> | |
| 473 | + </Form> | |
| 474 | + </div> | |
| 475 | + </>, | |
| 476 | + ); | |
| 477 | +} | |
| 478 | + | |
| 479 | +async function webhooksPage(c: AppContext, l: Loaded, created?: { secret: string; url: string }) { | |
| 480 | + const base = repoUrl(l.repo); | |
| 481 | + const t = csrfToken(c); | |
| 482 | + const hooks = await l.stub.webhooks(); | |
| 483 | + return page( | |
| 484 | + c, | |
| 485 | + l, | |
| 486 | + "webhooks", | |
| 487 | + <> | |
| 488 | + <h1>Webhooks</h1> | |
| 489 | + <p class="muted"> | |
| 490 | + Diffinery POSTs JSON events to these URLs, signed with an HMAC-SHA256 of the body using the webhook's secret. | |
| 491 | + </p> | |
| 492 | + {created ? <Once title={`Webhook created for ${created.url}`} value={created.secret} note="This is the signing secret. Copy it now; it will not be shown again." /> : null} | |
| 493 | + <div class="box"> | |
| 494 | + {hooks.length ? ( | |
| 495 | + <ul class="list"> | |
| 496 | + {hooks.map((h) => ( | |
| 497 | + <li style="align-items:center"> | |
| 498 | + <span class={`st ${h.lastStatus === null ? "faint" : h.lastStatus >= 200 && h.lastStatus < 300 ? "ok" : "bad"}`}>{raw(h.lastStatus === null ? ICONS.queued : h.lastStatus >= 200 && h.lastStatus < 300 ? ICONS.success : ICONS.failure)}</span> | |
| 499 | + <div class="grow"> | |
| 500 | + <b class="mono" style="word-break:break-all"> | |
| 501 | + {h.url} | |
| 502 | + </b> | |
| 503 | + <div class="meta"> | |
| 504 | + {h.events.join(", ")} · {h.active ? "active" : "inactive"} | |
| 505 | + {h.lastDeliveryAt ? ( | |
| 506 | + <> | |
| 507 | + {" "} | |
| 508 | + · last delivery {h.lastStatus ?? "?"} <Time at={h.lastDeliveryAt} /> | |
| 509 | + </> | |
| 510 | + ) : ( | |
| 511 | + " · no deliveries yet" | |
| 512 | + )} | |
| 513 | + </div> | |
| 514 | + </div> | |
| 515 | + <Form action={`${base}/settings/webhooks/delete`} t={t} class="inline" confirm="Delete this webhook?"> | |
| 516 | + <input type="hidden" name="id" value={h.id} /> | |
| 517 | + <button class="btn sm danger">Delete</button> | |
| 518 | + </Form> | |
| 519 | + </li> | |
| 520 | + ))} | |
| 521 | + </ul> | |
| 522 | + ) : ( | |
| 523 | + <div class="empty">No webhooks yet.</div> | |
| 524 | + )} | |
| 525 | + </div> | |
| 526 | + <Form action={`${base}/settings/webhooks`} t={t} class="box"> | |
| 527 | + <div class="head"> | |
| 528 | + <h3>Add a webhook</h3> | |
| 529 | + </div> | |
| 530 | + <div class="body"> | |
| 531 | + <div class="field"> | |
| 532 | + <label>Payload URL</label> | |
| 533 | + <input name="url" type="url" required placeholder="https://example.com/hooks/diffinery" pattern="https://.*" /> | |
| 534 | + </div> | |
| 535 | + <div class="field"> | |
| 536 | + <label>Events</label> | |
| 537 | + <label class="inline" style="margin-right:14px"> | |
| 538 | + <input type="checkbox" name="events" value="*" checked /> Everything | |
| 539 | + </label> | |
| 540 | + {WEBHOOK_EVENTS.map((e) => ( | |
| 541 | + <label class="inline" style="margin-right:14px"> | |
| 542 | + <input type="checkbox" name="events" value={e} /> <span class="mono small">{e}</span> | |
| 543 | + </label> | |
| 544 | + ))} | |
| 545 | + </div> | |
| 546 | + <div class="field" style="max-width:420px"> | |
| 547 | + <label> | |
| 548 | + Secret <span class="faint">(optional — generated when empty)</span> | |
| 549 | + </label> | |
| 550 | + <input name="secret" type="text" autocomplete="off" class="mono" /> | |
| 551 | + </div> | |
| 552 | + <button class="btn primary">Add webhook</button> | |
| 553 | + </div> | |
| 554 | + </Form> | |
| 555 | + </>, | |
| 556 | + ); | |
| 557 | +} | |
| 558 | + | |
| 559 | +// ---------------------------------------------------------------- routes | |
| 560 | + | |
| 561 | +export function registerRepoSettings(web: WebApp): void { | |
| 562 | + web.get("/:owner/:repo/settings", async (c) => generalPage(c, await load(c))); | |
| 563 | + web.get("/:owner/:repo/settings/access", async (c) => accessPage(c, await load(c))); | |
| 564 | + web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c))); | |
| 565 | + web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c))); | |
| 566 | + web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c))); | |
| 567 | + | |
| 568 | + post(web, "/:owner/:repo/settings/general", async (c, f) => { | |
| 569 | + const l = await load(c); | |
| 570 | + const dir = c.get("dir"); | |
| 571 | + return act( | |
| 572 | + c, | |
| 573 | + `${repoUrl(l.repo)}/settings`, | |
| 574 | + async () => { | |
| 575 | + const name = f.s("name").trim(); | |
| 576 | + const visibility = (["private", "internal", "public"] as const).find((v) => v === f.s("visibility")); | |
| 577 | + const defaultBranch = f.s("default_branch").trim(); | |
| 578 | + if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists"); | |
| 579 | + const updated = await dir.updateRepo(l.repo, { | |
| 580 | + name: name && name !== l.repo.name ? name : undefined, | |
| 581 | + description: f.s("description").trim(), | |
| 582 | + visibility: visibility && visibility !== l.repo.visibility ? visibility : undefined, | |
| 583 | + defaultBranch: defaultBranch && defaultBranch !== l.repo.default_branch ? defaultBranch : undefined, | |
| 584 | + }); | |
| 585 | + audit(c, l, "repo.update", { name: updated.name, visibility: updated.visibility, defaultBranch: updated.default_branch }); | |
| 586 | + return `${repoUrl(updated)}/settings`; | |
| 587 | + }, | |
| 588 | + "Settings saved.", | |
| 589 | + ); | |
| 590 | + }); | |
| 591 | + | |
| 592 | + post(web, "/:owner/:repo/settings/archive", async (c, f) => { | |
| 593 | + const l = await load(c); | |
| 594 | + const archived = f.s("archived") === "1"; | |
| 595 | + return act( | |
| 596 | + c, | |
| 597 | + `${repoUrl(l.repo)}/settings`, | |
| 598 | + async () => { | |
| 599 | + await c.get("dir").updateRepo(l.repo, { archived }); | |
| 600 | + audit(c, l, archived ? "repo.archive" : "repo.unarchive"); | |
| 601 | + }, | |
| 602 | + archived ? "Repository archived." : "Repository unarchived.", | |
| 603 | + ); | |
| 604 | + }); | |
| 605 | + | |
| 606 | + post(web, "/:owner/:repo/settings/delete", async (c, f) => { | |
| 607 | + const l = await load(c); | |
| 608 | + requireAccount(c); | |
| 609 | + const full = `${l.repo.owner_login}/${l.repo.name}`; | |
| 610 | + return act( | |
| 611 | + c, | |
| 612 | + `${repoUrl(l.repo)}/settings`, | |
| 613 | + async () => { | |
| 614 | + if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`); | |
| 615 | + await c.get("dir").deleteRepo(l.repo); | |
| 616 | + audit(c, l, "repo.delete"); | |
| 617 | + return "/"; | |
| 618 | + }, | |
| 619 | + `Deleted ${full}.`, | |
| 620 | + ); | |
| 621 | + }); | |
| 622 | + | |
| 623 | + post(web, "/:owner/:repo/settings/access", async (c, f) => { | |
| 624 | + const l = await load(c); | |
| 625 | + const dir = c.get("dir"); | |
| 626 | + return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { | |
| 627 | + const who = await dir.accountByLogin(f.s("login").trim()); | |
| 628 | + if (!who || who.kind === "org") throw new HttpError(404, "no person or agent with that username"); | |
| 629 | + const roleIn = f.s("role"); | |
| 630 | + const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null); | |
| 631 | + if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role"); | |
| 632 | + await dir.setCollaborator(l.repo.id, who.id, role); | |
| 633 | + audit(c, l, role ? "repo.collaborator.set" : "repo.collaborator.remove", { login: who.login, role }); | |
| 634 | + }); | |
| 635 | + }); | |
| 636 | + | |
| 637 | + post(web, "/:owner/:repo/settings/access/invite", async (c, f) => { | |
| 638 | + const l = await load(c); | |
| 639 | + const me = requireAccount(c); | |
| 640 | + const email = f.s("email").trim(); | |
| 641 | + const role = ROLES.find((r) => r === f.s("role")) ?? "write"; | |
| 642 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); | |
| 643 | + const inv = await c.get("dir").createInvite(me, { email, repoId: l.repo.id, role }); | |
| 644 | + audit(c, l, "repo.invite", { email, role }); | |
| 645 | + return accessPage(c, l, { url: inv.url, email }); | |
| 646 | + }); | |
| 647 | + | |
| 648 | + post(web, "/:owner/:repo/settings/branches", async (c, f) => { | |
| 649 | + const l = await load(c); | |
| 650 | + return act( | |
| 651 | + c, | |
| 652 | + `${repoUrl(l.repo)}/settings/branches`, | |
| 653 | + async () => { | |
| 654 | + const pattern = f.s("pattern").trim(); | |
| 655 | + if (!pattern || /\s/.test(pattern)) throw new HttpError(422, "enter a branch name pattern"); | |
| 656 | + const original = f.s("original").trim(); | |
| 657 | + const rules = rulesFrom(f); | |
| 658 | + if (original && original !== pattern) await l.stub.setProtection(original, null); | |
| 659 | + await l.stub.setProtection(pattern, rules); | |
| 660 | + audit(c, l, "repo.protection.set", { pattern, rules }); | |
| 661 | + }, | |
| 662 | + "Branch protection saved.", | |
| 663 | + ); | |
| 664 | + }); | |
| 665 | + | |
| 666 | + post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => { | |
| 667 | + const l = await load(c); | |
| 668 | + return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => { | |
| 669 | + await l.stub.setProtection(f.s("pattern"), null); | |
| 670 | + audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") }); | |
| 671 | + }); | |
| 672 | + }); | |
| 673 | + | |
| 674 | + post(web, "/:owner/:repo/settings/secrets", async (c, f) => { | |
| 675 | + const l = await load(c); | |
| 676 | + return act( | |
| 677 | + c, | |
| 678 | + `${repoUrl(l.repo)}/settings/secrets`, | |
| 679 | + async () => { | |
| 680 | + const name = f.s("name").trim(); | |
| 681 | + try { | |
| 682 | + await l.stub.setSecret(name, f.s("value")); | |
| 683 | + } catch (e) { | |
| 684 | + const s = statusOf(e); | |
| 685 | + if (s.status < 500) throw e; | |
| 686 | + throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); | |
| 687 | + } | |
| 688 | + audit(c, l, "repo.secret.set", { name: name.toUpperCase() }); | |
| 689 | + }, | |
| 690 | + "Secret saved.", | |
| 691 | + ); | |
| 692 | + }); | |
| 693 | + | |
| 694 | + post(web, "/:owner/:repo/settings/secrets/delete", async (c, f) => { | |
| 695 | + const l = await load(c); | |
| 696 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 697 | + await l.stub.deleteSecret(f.s("name")); | |
| 698 | + audit(c, l, "repo.secret.delete", { name: f.s("name") }); | |
| 699 | + }); | |
| 700 | + }); | |
| 701 | + | |
| 702 | + post(web, "/:owner/:repo/settings/variables", async (c, f) => { | |
| 703 | + const l = await load(c); | |
| 704 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 705 | + const name = f.s("name").trim(); | |
| 706 | + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new HttpError(422, "invalid variable name"); | |
| 707 | + await l.stub.setVariable(name, f.s("value")); | |
| 708 | + audit(c, l, "repo.variable.set", { name }); | |
| 709 | + }); | |
| 710 | + }); | |
| 711 | + | |
| 712 | + post(web, "/:owner/:repo/settings/variables/delete", async (c, f) => { | |
| 713 | + const l = await load(c); | |
| 714 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 715 | + await l.stub.setVariable(f.s("name"), null); | |
| 716 | + audit(c, l, "repo.variable.delete", { name: f.s("name") }); | |
| 717 | + }); | |
| 718 | + }); | |
| 719 | + | |
| 720 | + post(web, "/:owner/:repo/settings/webhooks", async (c, f) => { | |
| 721 | + const l = await load(c); | |
| 722 | + const url = f.s("url").trim(); | |
| 723 | + const events = f.all("events").filter((e) => e === "*" || WEBHOOK_EVENTS.includes(e)); | |
| 724 | + try { | |
| 725 | + const hook = await l.stub.createWebhook(url, events.includes("*") ? ["*"] : events, f.s("secret").trim() || undefined); | |
| 726 | + audit(c, l, "repo.webhook.create", { url, events }); | |
| 727 | + return webhooksPage(c, l, { secret: hook.secret, url }); | |
| 728 | + } catch (e) { | |
| 729 | + const s = statusOf(e); | |
| 730 | + if (s.status >= 500) throw e; | |
| 731 | + return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { | |
| 732 | + throw e; | |
| 733 | + }); | |
| 734 | + } | |
| 735 | + }); | |
| 736 | + | |
| 737 | + post(web, "/:owner/:repo/settings/webhooks/delete", async (c, f) => { | |
| 738 | + const l = await load(c); | |
| 739 | + return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { | |
| 740 | + await l.stub.deleteWebhook(f.s("id")); | |
| 741 | + audit(c, l, "repo.webhook.delete", { id: f.s("id") }); | |
| 742 | + }); | |
| 743 | + }); | |
| 744 | +} |
addedapps/diffinery/src/web/routes.tsx+37−0View file
| @@ -1,0 +1,37 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { Hono } from "hono"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import type { AppVars } from "../auth/principal.js"; | |
| 5 | +import { registerAccount } from "./account.js"; | |
| 6 | +import { registerAuth } from "./auth.js"; | |
| 7 | +import { registerActions } from "./actions.js"; | |
| 8 | +import { registerCode } from "./code.js"; | |
| 9 | +import { registerCollab } from "./collab.js"; | |
| 10 | +import { registerRepoSettings } from "./repo-settings.js"; | |
| 11 | +import { ICONS } from "./assets.js"; | |
| 12 | +import { errorPage } from "./ui.js"; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Server-rendered web UI. Mount at "/" after the API, Git and runner routes: | |
| 16 | + * it owns `/`, `/login`, `/auth/*`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 17 | + * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`). | |
| 18 | + * | |
| 19 | + * Expects the `principal` and `dir` variables to be set by an earlier middleware. | |
| 20 | + * Every POST is CSRF-checked (double-submit cookie, see ./csrf.ts). | |
| 21 | + */ | |
| 22 | +export const web = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 23 | + | |
| 24 | +web.onError((err, c) => errorPage(c, err)); | |
| 25 | + | |
| 26 | +// Browsers and crawlers ask for these on every origin; keep them off the /:owner route. | |
| 27 | +web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#f6821f"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" })); | |
| 28 | +web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" })); | |
| 29 | + | |
| 30 | +// Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be | |
| 31 | +// registered before the /:owner/:repo patterns they would otherwise match. | |
| 32 | +registerAuth(web); | |
| 33 | +registerAccount(web); | |
| 34 | +registerRepoSettings(web); | |
| 35 | +registerActions(web); | |
| 36 | +registerCollab(web); | |
| 37 | +registerCode(web); |
addedapps/diffinery/src/web/ui.tsx+761−0View file
| @@ -1,0 +1,761 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import type { Hono } from "hono"; | |
| 3 | +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; | |
| 4 | +import { raw } from "hono/html"; | |
| 5 | +import type { Child, FC, PropsWithChildren } from "hono/jsx"; | |
| 6 | +import type { Account, Repo } from "../lib/directory.js"; | |
| 7 | +import type { Env } from "../env.js"; | |
| 8 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 9 | +import { HttpError, randomToken } from "../lib/util.js"; | |
| 10 | +import { atLeast, type Caller } from "../repo/types.js"; | |
| 11 | +import type { repoStub } from "../http/common.js"; | |
| 12 | +import { CSS, ICONS, JS, type IconName } from "./assets.js"; | |
| 13 | +import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; | |
| 14 | +import { renderMarkdown, type MarkdownBase } from "./markdown.js"; | |
| 15 | + | |
| 16 | +export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; | |
| 17 | +export type Stub = ReturnType<typeof repoStub>; | |
| 18 | + | |
| 19 | +// ---------------------------------------------------------------- small helpers | |
| 20 | + | |
| 21 | +export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); | |
| 22 | +export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`; | |
| 23 | +export const shortSha = (sha: string): string => sha.slice(0, 7); | |
| 24 | +export const firstLine = (s: string): string => s.split("\n")[0] ?? ""; | |
| 25 | + | |
| 26 | +export function publicUrl(c: AppContext): string { | |
| 27 | + return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, ""); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function cloneUrl(c: AppContext, repo: Repo): string { | |
| 31 | + return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function fmtBytes(n: number | undefined | null): string { | |
| 35 | + if (n === undefined || n === null) return ""; | |
| 36 | + if (n < 1024) return `${n} B`; | |
| 37 | + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; | |
| 38 | + if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`; | |
| 39 | + return `${(n / 1024 ** 3).toFixed(2)} GB`; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function fmtDuration(ms: number): string { | |
| 43 | + if (!Number.isFinite(ms) || ms < 0) return ""; | |
| 44 | + const s = Math.round(ms / 1000); | |
| 45 | + if (s < 60) return `${s}s`; | |
| 46 | + const m = Math.floor(s / 60); | |
| 47 | + if (m < 60) return `${m}m ${s % 60}s`; | |
| 48 | + return `${Math.floor(m / 60)}h ${m % 60}m`; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function duration(start: string | null | undefined, end: string | null | undefined): string { | |
| 52 | + if (!start) return ""; | |
| 53 | + return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start)); | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function ago(iso: string | null | undefined): string { | |
| 57 | + if (!iso) return ""; | |
| 58 | + const t = Date.parse(iso); | |
| 59 | + if (!Number.isFinite(t)) return iso; | |
| 60 | + const s = Math.round((Date.now() - t) / 1000); | |
| 61 | + if (s < 45) return "just now"; | |
| 62 | + const m = Math.round(s / 60); | |
| 63 | + if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; | |
| 64 | + const h = Math.round(m / 60); | |
| 65 | + if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; | |
| 66 | + const d = Math.round(h / 24); | |
| 67 | + if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; | |
| 68 | + return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); | |
| 69 | +} | |
| 70 | + | |
| 71 | +export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />); | |
| 72 | + | |
| 73 | +export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>; | |
| 74 | + | |
| 75 | +/** Status icon for runs, jobs, checks and steps. */ | |
| 76 | +export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => { | |
| 77 | + if (status && status !== "completed") { | |
| 78 | + if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; | |
| 79 | + return <span class="st warn" title={status === "waiting" ? "Waiting" : "Queued"}>{raw(ICONS.queued)}</span>; | |
| 80 | + } | |
| 81 | + switch (conclusion) { | |
| 82 | + case "success": | |
| 83 | + return <span class="st ok" title="Success">{raw(ICONS.success)}</span>; | |
| 84 | + case "failure": | |
| 85 | + case "timed_out": | |
| 86 | + case "startup_failure": | |
| 87 | + return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>; | |
| 88 | + case "cancelled": | |
| 89 | + return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>; | |
| 90 | + case "skipped": | |
| 91 | + return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>; | |
| 92 | + case "in_progress": | |
| 93 | + return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; | |
| 94 | + case "queued": | |
| 95 | + case "waiting": | |
| 96 | + case "pending": | |
| 97 | + return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>; | |
| 98 | + default: | |
| 99 | + return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>; | |
| 100 | + } | |
| 101 | +}; | |
| 102 | + | |
| 103 | +function hue(s: string): number { | |
| 104 | + let h = 0; | |
| 105 | + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; | |
| 106 | + return h % 360; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => ( | |
| 110 | + <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true"> | |
| 111 | + {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 112 | + </span> | |
| 113 | +); | |
| 114 | + | |
| 115 | +/** Linked actor name with an "agent" badge and the human it acts for. */ | |
| 116 | +export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => ( | |
| 117 | + <span class="who"> | |
| 118 | + {avatar ? <Avatar login={login} kind={kind} /> : null} | |
| 119 | + {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>} | |
| 120 | + {kind === "agent" ? <span class="badge agent">agent</span> : null} | |
| 121 | + {kind === "system" ? <span class="badge">bot</span> : null} | |
| 122 | + {onBehalfOf ? ( | |
| 123 | + <span class="muted small" style="font-weight:400"> | |
| 124 | + on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a> | |
| 125 | + </span> | |
| 126 | + ) : null} | |
| 127 | + </span> | |
| 128 | +); | |
| 129 | + | |
| 130 | +export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => { | |
| 131 | + const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c"; | |
| 132 | + return href ? ( | |
| 133 | + <a class="lbl" style={`--lc:${c}`} href={href}> | |
| 134 | + {name} | |
| 135 | + </a> | |
| 136 | + ) : ( | |
| 137 | + <span class="lbl" style={`--lc:${c}`}>{name}</span> | |
| 138 | + ); | |
| 139 | +}; | |
| 140 | + | |
| 141 | +export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) => | |
| 142 | + text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>; | |
| 143 | + | |
| 144 | +/** CSRF-protected POST form. */ | |
| 145 | +export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => ( | |
| 146 | + <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}> | |
| 147 | + <input type="hidden" name={CSRF_FIELD} value={p.t} /> | |
| 148 | + {p.children} | |
| 149 | + </form> | |
| 150 | +); | |
| 151 | + | |
| 152 | +export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => ( | |
| 153 | + <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard"> | |
| 154 | + {label ?? "Copy"} | |
| 155 | + </button> | |
| 156 | +); | |
| 157 | + | |
| 158 | +export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) => | |
| 159 | + page > 1 || hasNext ? ( | |
| 160 | + <div class="pager"> | |
| 161 | + {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>} | |
| 162 | + <span class="muted small" style="align-self:center">Page {page}</span> | |
| 163 | + {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>} | |
| 164 | + </div> | |
| 165 | + ) : null; | |
| 166 | + | |
| 167 | +export function qs(params: Record<string, string | number | undefined | null | false>): string { | |
| 168 | + const u = new URLSearchParams(); | |
| 169 | + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v)); | |
| 170 | + const s = u.toString(); | |
| 171 | + return s ? `?${s}` : ""; | |
| 172 | +} | |
| 173 | + | |
| 174 | +/** A required route parameter (404 when missing). */ | |
| 175 | +export function prm(c: AppContext, name: string): string { | |
| 176 | + const v = c.req.param(name); | |
| 177 | + if (!v) throw new HttpError(404, "not found"); | |
| 178 | + return v; | |
| 179 | +} | |
| 180 | + | |
| 181 | +export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1)); | |
| 182 | + | |
| 183 | +// ---------------------------------------------------------------- errors, flash and actions | |
| 184 | + | |
| 185 | +export function statusOf(e: unknown): { status: number; message: string } { | |
| 186 | + if (e instanceof HttpError) return { status: e.status, message: e.detail }; | |
| 187 | + const msg = (e as Error)?.message ?? String(e); | |
| 188 | + const m = /^(\d{3}): ([\s\S]*)$/.exec(msg); | |
| 189 | + if (m) return { status: Number(m[1]), message: m[2]! }; | |
| 190 | + return { status: 500, message: "internal error" }; | |
| 191 | +} | |
| 192 | + | |
| 193 | +const FLASH = "dfy_flash"; | |
| 194 | + | |
| 195 | +export function flash(c: AppContext, kind: "error" | "notice", message: string): void { | |
| 196 | + setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 }); | |
| 197 | +} | |
| 198 | + | |
| 199 | +function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null { | |
| 200 | + const v = getCookie(c, FLASH); | |
| 201 | + if (!v) return null; | |
| 202 | + deleteCookie(c, FLASH, { path: "/", secure: true }); | |
| 203 | + try { | |
| 204 | + const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string }; | |
| 205 | + if (typeof f.message !== "string") return null; | |
| 206 | + return { kind: f.kind === "notice" ? "notice" : "error", message: f.message }; | |
| 207 | + } catch { | |
| 208 | + return null; | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +/** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */ | |
| 213 | +export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> { | |
| 214 | + try { | |
| 215 | + const to = await fn(); | |
| 216 | + if (notice) flash(c, "notice", notice); | |
| 217 | + return c.redirect(to ?? back, 303); | |
| 218 | + } catch (e) { | |
| 219 | + const s = statusOf(e); | |
| 220 | + if (s.status >= 500) throw e; | |
| 221 | + flash(c, "error", s.message); | |
| 222 | + return c.redirect(back, 303); | |
| 223 | + } | |
| 224 | +} | |
| 225 | + | |
| 226 | +/** Register a CSRF-checked POST route. */ | |
| 227 | +export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>): void { | |
| 228 | + app.post(path, async (c) => handler(c, await verifyCsrf(c))); | |
| 229 | +} | |
| 230 | + | |
| 231 | +export function principalAccount(c: AppContext): Account | null { | |
| 232 | + const p = c.get("principal"); | |
| 233 | + return p?.type === "account" ? p.account : null; | |
| 234 | +} | |
| 235 | + | |
| 236 | +// ---------------------------------------------------------------- layout | |
| 237 | + | |
| 238 | +export interface RepoCtx { | |
| 239 | + repo: Repo; | |
| 240 | + caller: Caller; | |
| 241 | + tab?: "code" | "issues" | "pulls" | "actions" | "settings" | ""; | |
| 242 | + /** Current ref, used by the palette's "go to file". */ | |
| 243 | + ref?: string; | |
| 244 | + counts?: { issues?: number; pulls?: number }; | |
| 245 | +} | |
| 246 | + | |
| 247 | +export interface PageOpts { | |
| 248 | + title: string; | |
| 249 | + repo?: RepoCtx; | |
| 250 | + /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */ | |
| 251 | + live?: string; | |
| 252 | + liveMode?: "reload" | "log"; | |
| 253 | + lastSeq?: number; | |
| 254 | + status?: number; | |
| 255 | + /** Full-width page (diffs, logs). */ | |
| 256 | + wide?: boolean; | |
| 257 | +} | |
| 258 | + | |
| 259 | +const RepoHeader: FC<{ r: RepoCtx }> = ({ r }) => { | |
| 260 | + const base = repoUrl(r.repo); | |
| 261 | + const tab = r.tab ?? ""; | |
| 262 | + const vis = r.repo.visibility; | |
| 263 | + return ( | |
| 264 | + <div class="repohead"> | |
| 265 | + <div class="wrap"> | |
| 266 | + <h1> | |
| 267 | + <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span> | |
| 268 | + <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a> | |
| 269 | + <span class="faint">/</span> | |
| 270 | + <strong> | |
| 271 | + <a href={base}>{r.repo.name}</a> | |
| 272 | + </strong> | |
| 273 | + <span class="badge">{vis}</span> | |
| 274 | + {r.repo.archived ? <span class="badge amber">archived</span> : null} | |
| 275 | + </h1> | |
| 276 | + {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null} | |
| 277 | + <nav class="tabs"> | |
| 278 | + <a href={base} class={tab === "code" ? "on" : ""}> | |
| 279 | + {raw(ICONS.code)} Code | |
| 280 | + </a> | |
| 281 | + <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}> | |
| 282 | + {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null} | |
| 283 | + </a> | |
| 284 | + <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}> | |
| 285 | + {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null} | |
| 286 | + </a> | |
| 287 | + <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}> | |
| 288 | + {raw(ICONS.play)} Actions | |
| 289 | + </a> | |
| 290 | + {atLeast(r.caller.role, "admin") ? ( | |
| 291 | + <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}> | |
| 292 | + {raw(ICONS.gear)} Settings | |
| 293 | + </a> | |
| 294 | + ) : null} | |
| 295 | + </nav> | |
| 296 | + </div> | |
| 297 | + </div> | |
| 298 | + ); | |
| 299 | +}; | |
| 300 | + | |
| 301 | +function csp(nonce: string, c: AppContext): string { | |
| 302 | + const host = new URL(c.req.url).host; | |
| 303 | + return [ | |
| 304 | + "default-src 'self'", | |
| 305 | + `script-src 'nonce-${nonce}'`, | |
| 306 | + "style-src 'self' 'unsafe-inline'", | |
| 307 | + "img-src 'self' https: data:", | |
| 308 | + `connect-src 'self' wss://${host} ws://${host}`, | |
| 309 | + "font-src 'self'", | |
| 310 | + "object-src 'none'", | |
| 311 | + "base-uri 'none'", | |
| 312 | + "form-action 'self'", | |
| 313 | + "frame-ancestors 'none'", | |
| 314 | + ].join("; "); | |
| 315 | +} | |
| 316 | + | |
| 317 | +/** Render a full page. */ | |
| 318 | +export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> { | |
| 319 | + csrfToken(c); | |
| 320 | + const nonce = randomToken(16); | |
| 321 | + const fl = takeFlash(c); | |
| 322 | + const p = c.get("principal"); | |
| 323 | + const me = p?.type === "account" ? p.account : null; | |
| 324 | + const active = me?.status === "active"; | |
| 325 | + c.header("content-security-policy", csp(nonce, c)); | |
| 326 | + c.header("x-frame-options", "DENY"); | |
| 327 | + c.header("x-content-type-options", "nosniff"); | |
| 328 | + c.header("referrer-policy", "same-origin"); | |
| 329 | + c.header("cache-control", "private, no-store"); | |
| 330 | + const r = opts.repo; | |
| 331 | + const page = ( | |
| 332 | + <html lang="en"> | |
| 333 | + <head> | |
| 334 | + <meta charset="utf-8" /> | |
| 335 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 336 | + <meta name="color-scheme" content="light dark" /> | |
| 337 | + <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title> | |
| 338 | + <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#f6821f"))}`} /> | |
| 339 | + <style>{raw(CSS)}</style> | |
| 340 | + </head> | |
| 341 | + <body | |
| 342 | + data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined} | |
| 343 | + data-ref={r?.ref ?? r?.repo.default_branch} | |
| 344 | + data-live={opts.live} | |
| 345 | + data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined} | |
| 346 | + data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined} | |
| 347 | + > | |
| 348 | + <header class="top"> | |
| 349 | + <div class="wrap"> | |
| 350 | + <a class="brand" href="/"> | |
| 351 | + {raw(ICONS.logo)} Diffinery | |
| 352 | + </a> | |
| 353 | + {active ? ( | |
| 354 | + <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette"> | |
| 355 | + {raw(ICONS.search)} | |
| 356 | + <span>Jump to…</span> | |
| 357 | + <kbd>Ctrl K</kbd> | |
| 358 | + </button> | |
| 359 | + ) : null} | |
| 360 | + <nav> | |
| 361 | + {active && me ? ( | |
| 362 | + <> | |
| 363 | + <a href="/new" title="New repository"> | |
| 364 | + {raw(ICONS.plus)} | |
| 365 | + </a> | |
| 366 | + <a href="/notifications" title="Notifications"> | |
| 367 | + {raw(ICONS.bell)} | |
| 368 | + </a> | |
| 369 | + <a href="/settings" title="Settings" class="who"> | |
| 370 | + <Avatar login={me.login} /> <span>{me.login}</span> | |
| 371 | + </a> | |
| 372 | + </> | |
| 373 | + ) : null} | |
| 374 | + {p?.type === "account" && p.via === "session" ? ( | |
| 375 | + <form method="post" action="/auth/logout" class="inline"> | |
| 376 | + <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} /> | |
| 377 | + <button class="navbtn">Sign out</button> | |
| 378 | + </form> | |
| 379 | + ) : null} | |
| 380 | + {!p || p.type === "anonymous" ? ( | |
| 381 | + c.req.path === "/login" ? null : ( | |
| 382 | + <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm"> | |
| 383 | + Sign in | |
| 384 | + </a> | |
| 385 | + ) | |
| 386 | + ) : null} | |
| 387 | + </nav> | |
| 388 | + </div> | |
| 389 | + </header> | |
| 390 | + {r ? <RepoHeader r={r} /> : null} | |
| 391 | + <main class="wrap" style={opts.wide ? "max-width:none" : undefined}> | |
| 392 | + {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null} | |
| 393 | + {body} | |
| 394 | + </main> | |
| 395 | + {active ? ( | |
| 396 | + <dialog id="palette" aria-label="Command palette"> | |
| 397 | + <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} /> | |
| 398 | + <ul role="listbox"></ul> | |
| 399 | + <div class="foot"> | |
| 400 | + <span> | |
| 401 | + <kbd>↑</kbd> <kbd>↓</kbd> navigate | |
| 402 | + </span> | |
| 403 | + <span> | |
| 404 | + <kbd>Enter</kbd> open | |
| 405 | + </span> | |
| 406 | + <span> | |
| 407 | + <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions | |
| 408 | + </span> | |
| 409 | + </div> | |
| 410 | + </dialog> | |
| 411 | + ) : null} | |
| 412 | + <script nonce={nonce}>{raw(JS)}</script> | |
| 413 | + </body> | |
| 414 | + </html> | |
| 415 | + ); | |
| 416 | + return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200); | |
| 417 | +} | |
| 418 | + | |
| 419 | +/** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */ | |
| 420 | +export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> { | |
| 421 | + const s = statusOf(e); | |
| 422 | + if (s.status >= 500) console.error(e); | |
| 423 | + // Signed-out visitors who open a page go straight to sign-in, and come back afterwards. | |
| 424 | + if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) { | |
| 425 | + const url = new URL(c.req.url); | |
| 426 | + return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302); | |
| 427 | + } | |
| 428 | + const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" }; | |
| 429 | + const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed"); | |
| 430 | + const p = c.get("principal"); | |
| 431 | + return render( | |
| 432 | + c, | |
| 433 | + { title, status: s.status }, | |
| 434 | + <div class="blank" style="max-width:640px;margin:48px auto"> | |
| 435 | + <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div> | |
| 436 | + <h1>{title}</h1> | |
| 437 | + {s.status === 401 ? ( | |
| 438 | + <> | |
| 439 | + <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p> | |
| 440 | + <p> | |
| 441 | + <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}> | |
| 442 | + Sign in | |
| 443 | + </a> | |
| 444 | + </p> | |
| 445 | + </> | |
| 446 | + ) : s.status === 404 ? ( | |
| 447 | + <p class="muted">The page or repository does not exist, or you do not have access to it.</p> | |
| 448 | + ) : ( | |
| 449 | + <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p> | |
| 450 | + )} | |
| 451 | + {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? ( | |
| 452 | + <p> | |
| 453 | + <a class="btn primary" href="/"> | |
| 454 | + Request access | |
| 455 | + </a> | |
| 456 | + </p> | |
| 457 | + ) : s.status === 401 ? null : ( | |
| 458 | + <p> | |
| 459 | + <a class="btn" href="/"> | |
| 460 | + Go to dashboard | |
| 461 | + </a> | |
| 462 | + </p> | |
| 463 | + )} | |
| 464 | + </div>, | |
| 465 | + ); | |
| 466 | +} | |
| 467 | + | |
| 468 | +// ---------------------------------------------------------------- refs and paths | |
| 469 | + | |
| 470 | +/** Path segments after a fixed number of leading segments, decoded. */ | |
| 471 | +export function restOfPath(c: AppContext, skip: number): string { | |
| 472 | + const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1); | |
| 473 | + return parts | |
| 474 | + .filter((p) => p !== "") | |
| 475 | + .map((p) => { | |
| 476 | + try { | |
| 477 | + return decodeURIComponent(p); | |
| 478 | + } catch { | |
| 479 | + return p; | |
| 480 | + } | |
| 481 | + }) | |
| 482 | + .join("/"); | |
| 483 | +} | |
| 484 | + | |
| 485 | +/** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */ | |
| 486 | +export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> { | |
| 487 | + const segs = rest.split("/").filter(Boolean); | |
| 488 | + if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" }; | |
| 489 | + const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, ""))); | |
| 490 | + for (let i = segs.length; i >= 1; i--) { | |
| 491 | + const cand = segs.slice(0, i).join("/"); | |
| 492 | + if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") }; | |
| 493 | + } | |
| 494 | + return { ref: segs[0]!, path: segs.slice(1).join("/") }; | |
| 495 | +} | |
| 496 | + | |
| 497 | +/** Branch/tag switcher; `to` builds the target URL for a ref. */ | |
| 498 | +export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => ( | |
| 499 | + <details class="menu"> | |
| 500 | + <summary class="btn sm"> | |
| 501 | + {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)} | |
| 502 | + <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾ | |
| 503 | + </summary> | |
| 504 | + <div class="pop"> | |
| 505 | + <h4>Branches</h4> | |
| 506 | + {branches.length ? ( | |
| 507 | + branches.map((b) => ( | |
| 508 | + <a href={to(b)}> | |
| 509 | + {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b} | |
| 510 | + </a> | |
| 511 | + )) | |
| 512 | + ) : ( | |
| 513 | + <p class="muted small" style="margin:4px 8px">No branches</p> | |
| 514 | + )} | |
| 515 | + {tags.length ? <h4>Tags</h4> : null} | |
| 516 | + {tags.map((t) => ( | |
| 517 | + <a href={to(t)}> | |
| 518 | + {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t} | |
| 519 | + </a> | |
| 520 | + ))} | |
| 521 | + </div> | |
| 522 | + </details> | |
| 523 | +); | |
| 524 | + | |
| 525 | +export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> { | |
| 526 | + const refs = await stub.refs("refs/"); | |
| 527 | + return { | |
| 528 | + branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)), | |
| 529 | + tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)), | |
| 530 | + }; | |
| 531 | +} | |
| 532 | + | |
| 533 | +// ---------------------------------------------------------------- diffs | |
| 534 | + | |
| 535 | +export interface DiffHunk { | |
| 536 | + oldStart: number; | |
| 537 | + oldLines: number; | |
| 538 | + newStart: number; | |
| 539 | + newLines: number; | |
| 540 | + lines: string[]; | |
| 541 | +} | |
| 542 | + | |
| 543 | +export interface DiffFile { | |
| 544 | + path: string; | |
| 545 | + status: "added" | "modified" | "deleted"; | |
| 546 | + binary: boolean; | |
| 547 | + additions: number; | |
| 548 | + deletions: number; | |
| 549 | + hunks: DiffHunk[]; | |
| 550 | + truncated?: boolean; | |
| 551 | +} | |
| 552 | + | |
| 553 | +export interface LineThread { | |
| 554 | + side: "LEFT" | "RIGHT"; | |
| 555 | + line: number; | |
| 556 | + node: Child; | |
| 557 | +} | |
| 558 | + | |
| 559 | +const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => { | |
| 560 | + const total = a + d; | |
| 561 | + const blocks = 5; | |
| 562 | + const ga = total ? Math.round((a / total) * blocks) : 0; | |
| 563 | + const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0; | |
| 564 | + return ( | |
| 565 | + <span class="bar" aria-hidden="true"> | |
| 566 | + {Array.from({ length: blocks }, (_, i) => ( | |
| 567 | + <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} /> | |
| 568 | + ))} | |
| 569 | + </span> | |
| 570 | + ); | |
| 571 | +}; | |
| 572 | + | |
| 573 | +export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => ( | |
| 574 | + <span class="row" style="gap:10px"> | |
| 575 | + <span> | |
| 576 | + <b>{files}</b> file{files === 1 ? "" : "s"} changed | |
| 577 | + </span> | |
| 578 | + <span class="stat-add">+{additions}</span> | |
| 579 | + <span class="stat-del">−{deletions}</span> | |
| 580 | + <StatBar a={additions} d={deletions} /> | |
| 581 | + </span> | |
| 582 | +); | |
| 583 | + | |
| 584 | +export interface DiffOptions { | |
| 585 | + /** Link to view the file at the new revision. */ | |
| 586 | + viewHref?: (path: string) => string | null; | |
| 587 | + /** Show "+" buttons for line comments (requires the line comment template on the page). */ | |
| 588 | + commentable?: boolean; | |
| 589 | + /** Existing review threads per file path. */ | |
| 590 | + threads?: Map<string, LineThread[]>; | |
| 591 | +} | |
| 592 | + | |
| 593 | +const LINE_BUDGET = 20000; | |
| 594 | + | |
| 595 | +/** Unified diff with line numbers; each file is collapsible. */ | |
| 596 | +export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => { | |
| 597 | + let budget = LINE_BUDGET; | |
| 598 | + return ( | |
| 599 | + <div> | |
| 600 | + {files.map((f) => { | |
| 601 | + const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0); | |
| 602 | + const big = lines > 800; | |
| 603 | + const render = budget > 0 && !f.binary && !f.truncated; | |
| 604 | + budget -= lines; | |
| 605 | + const threads = opts.threads?.get(f.path) ?? []; | |
| 606 | + const view = opts.viewHref?.(f.path); | |
| 607 | + return ( | |
| 608 | + <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}> | |
| 609 | + <summary class="filehead"> | |
| 610 | + <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span> | |
| 611 | + <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span> | |
| 612 | + <span class="stat-add small">+{f.additions}</span> | |
| 613 | + <span class="stat-del small">−{f.deletions}</span> | |
| 614 | + {view && f.status !== "deleted" ? ( | |
| 615 | + <a class="btn sm" href={view}> | |
| 616 | + View file | |
| 617 | + </a> | |
| 618 | + ) : null} | |
| 619 | + </summary> | |
| 620 | + {f.binary ? ( | |
| 621 | + <div class="empty">Binary file not shown.</div> | |
| 622 | + ) : f.truncated ? ( | |
| 623 | + <div class="empty">This diff is too large to display.</div> | |
| 624 | + ) : !render ? ( | |
| 625 | + <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div> | |
| 626 | + ) : f.hunks.length === 0 ? ( | |
| 627 | + <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div> | |
| 628 | + ) : ( | |
| 629 | + <div class="code-wrap"> | |
| 630 | + <table class="diff"> | |
| 631 | + {f.hunks.map((h) => { | |
| 632 | + let oldN = h.oldStart; | |
| 633 | + let newN = h.newStart; | |
| 634 | + return ( | |
| 635 | + <> | |
| 636 | + <tr class="hunk"> | |
| 637 | + {opts.commentable ? <td class="act" /> : null} | |
| 638 | + <td class="ln" /> | |
| 639 | + <td class="ln" /> | |
| 640 | + <td class="code"> | |
| 641 | + @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@ | |
| 642 | + </td> | |
| 643 | + </tr> | |
| 644 | + {h.lines.map((l) => { | |
| 645 | + const k = l[0]; | |
| 646 | + if (k === "\\") { | |
| 647 | + return ( | |
| 648 | + <tr class="meta"> | |
| 649 | + {opts.commentable ? <td class="act" /> : null} | |
| 650 | + <td class="ln" /> | |
| 651 | + <td class="ln" /> | |
| 652 | + <td class="code">{l}</td> | |
| 653 | + </tr> | |
| 654 | + ); | |
| 655 | + } | |
| 656 | + const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx"; | |
| 657 | + const o = k === "+" ? null : oldN++; | |
| 658 | + const n = k === "-" ? null : newN++; | |
| 659 | + const side = k === "-" ? "LEFT" : "RIGHT"; | |
| 660 | + const lineNo = side === "LEFT" ? o : n; | |
| 661 | + const here = threads.filter((t) => t.side === side && t.line === lineNo); | |
| 662 | + return ( | |
| 663 | + <> | |
| 664 | + <tr class={cls}> | |
| 665 | + {opts.commentable ? ( | |
| 666 | + <td class="act"> | |
| 667 | + <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}> | |
| 668 | + + | |
| 669 | + </button> | |
| 670 | + </td> | |
| 671 | + ) : null} | |
| 672 | + <td class="ln">{o ?? ""}</td> | |
| 673 | + <td class="ln">{n ?? ""}</td> | |
| 674 | + <td class="code"> | |
| 675 | + {k === "+" || k === "-" ? k : " "} | |
| 676 | + {l.slice(1)} | |
| 677 | + </td> | |
| 678 | + </tr> | |
| 679 | + {here.length ? ( | |
| 680 | + <tr class="thread"> | |
| 681 | + <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td> | |
| 682 | + </tr> | |
| 683 | + ) : null} | |
| 684 | + </> | |
| 685 | + ); | |
| 686 | + })} | |
| 687 | + </> | |
| 688 | + ); | |
| 689 | + })} | |
| 690 | + </table> | |
| 691 | + </div> | |
| 692 | + )} | |
| 693 | + </details> | |
| 694 | + ); | |
| 695 | + })} | |
| 696 | + </div> | |
| 697 | + ); | |
| 698 | +}; | |
| 699 | + | |
| 700 | +/** Changed-files summary list with anchors into the diff. */ | |
| 701 | +export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) => | |
| 702 | + files.length > 3 ? ( | |
| 703 | + <details class="box"> | |
| 704 | + <summary class="head" style="cursor:pointer"> | |
| 705 | + <span>Files ({files.length})</span> | |
| 706 | + </summary> | |
| 707 | + <ul class="list"> | |
| 708 | + {files.map((f) => ( | |
| 709 | + <li style="padding:6px 14px"> | |
| 710 | + <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}> | |
| 711 | + {f.path} | |
| 712 | + </a> | |
| 713 | + <span class="stat-add small">+{f.additions}</span> | |
| 714 | + <span class="stat-del small">−{f.deletions}</span> | |
| 715 | + </li> | |
| 716 | + ))} | |
| 717 | + </ul> | |
| 718 | + </details> | |
| 719 | + ) : null; | |
| 720 | + | |
| 721 | +/** Commit list row. */ | |
| 722 | +export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => ( | |
| 723 | + <li> | |
| 724 | + <div class="grow"> | |
| 725 | + <a class="title" href={`${base}/commit/${sha}`}> | |
| 726 | + {firstLine(message)} | |
| 727 | + </a> | |
| 728 | + <div class="meta"> | |
| 729 | + <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} /> | |
| 730 | + {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null} | |
| 731 | + </div> | |
| 732 | + </div> | |
| 733 | + {extra} | |
| 734 | + <CopyButton text={sha} label="Copy" /> | |
| 735 | + <a class="sha" href={`${base}/commit/${sha}`}> | |
| 736 | + {shortSha(sha)} | |
| 737 | + </a> | |
| 738 | + <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point"> | |
| 739 | + {raw(ICONS.code)} | |
| 740 | + </a> | |
| 741 | + </li> | |
| 742 | +); | |
| 743 | + | |
| 744 | +/** Look up agents' owners for "on behalf of" labels. */ | |
| 745 | +export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> { | |
| 746 | + const out = new Map<string, string>(); | |
| 747 | + const dir = c.get("dir"); | |
| 748 | + const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20); | |
| 749 | + await Promise.all( | |
| 750 | + logins.map(async (login) => { | |
| 751 | + const a = await dir.accountByLogin(login); | |
| 752 | + if (a?.owner_id) { | |
| 753 | + const owner = await dir.account(a.owner_id); | |
| 754 | + if (owner) out.set(login, owner.login); | |
| 755 | + } | |
| 756 | + }), | |
| 757 | + ); | |
| 758 | + return out; | |
| 759 | +} | |
| 760 | + | |
| 761 | +export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const; |
addedapps/diffinery/test/e2e/e2e.test.mjs+342−0View file
| @@ -1,0 +1,342 @@ | ||
| 1 | +// End-to-end: a real `wrangler dev` Worker, the real git CLI and the real runner agent. | |
| 2 | +// | |
| 3 | +// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e | |
| 4 | +// | |
| 5 | +// Covers: token auth, repo creation, git push/clone/fetch (protocol v2), push | |
| 6 | +// protection, workflows triggered by push and pull_request and executed by a | |
| 7 | +// self-hosted runner (the same agent that runs in Cloudflare Containers), | |
| 8 | +// check runs, branch protection with required checks and human review for | |
| 9 | +// agent-authored PRs, merging, the REST contents API and the MCP server. | |
| 10 | +import { spawn, spawnSync, execFileSync } from "node:child_process"; | |
| 11 | +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs"; | |
| 12 | +import { tmpdir } from "node:os"; | |
| 13 | +import { join, dirname, resolve } from "node:path"; | |
| 14 | +import { fileURLToPath } from "node:url"; | |
| 15 | +import { createServer } from "node:net"; | |
| 16 | +import { test, before, after } from "node:test"; | |
| 17 | +import assert from "node:assert/strict"; | |
| 18 | + | |
| 19 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 20 | +const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); | |
| 21 | +const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-")); | |
| 22 | +let base; | |
| 23 | +let dev; | |
| 24 | +let devLog = ""; | |
| 25 | +let token; | |
| 26 | +let agentToken; | |
| 27 | + | |
| 28 | +const freePort = () => | |
| 29 | + new Promise((res) => { | |
| 30 | + const s = createServer(); | |
| 31 | + s.listen(0, "127.0.0.1", () => { | |
| 32 | + const { port } = s.address(); | |
| 33 | + s.close(() => res(port)); | |
| 34 | + }); | |
| 35 | + }); | |
| 36 | + | |
| 37 | +async function api(method, path, body, tok = token) { | |
| 38 | + const res = await fetch(`${base}/api/v3${path}`, { | |
| 39 | + method, | |
| 40 | + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 41 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 42 | + }); | |
| 43 | + const text = await res.text(); | |
| 44 | + let json = null; | |
| 45 | + try { | |
| 46 | + json = text ? JSON.parse(text) : null; | |
| 47 | + } catch { | |
| 48 | + json = text; | |
| 49 | + } | |
| 50 | + return { status: res.status, json }; | |
| 51 | +} | |
| 52 | +async function ok(method, path, body, tok) { | |
| 53 | + const r = await api(method, path, body, tok); | |
| 54 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 55 | + return r.json; | |
| 56 | +} | |
| 57 | + | |
| 58 | +function git(cwd, args, tok = token, opts = {}) { | |
| 59 | + const auth = Buffer.from(`x-token:${tok}`).toString("base64"); | |
| 60 | + return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], { | |
| 61 | + cwd, | |
| 62 | + encoding: "utf8", | |
| 63 | + stdio: ["ignore", "pipe", "pipe"], | |
| 64 | + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" }, | |
| 65 | + ...opts, | |
| 66 | + }); | |
| 67 | +} | |
| 68 | +function gitFails(cwd, args, tok) { | |
| 69 | + try { | |
| 70 | + git(cwd, args, tok); | |
| 71 | + } catch (e) { | |
| 72 | + return `${e.stdout ?? ""}${e.stderr ?? ""}`; | |
| 73 | + } | |
| 74 | + assert.fail(`expected git ${args.join(" ")} to fail`); | |
| 75 | +} | |
| 76 | + | |
| 77 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 78 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 79 | + const end = Date.now() + timeoutMs; | |
| 80 | + let last; | |
| 81 | + while (Date.now() < end) { | |
| 82 | + last = await fn(); | |
| 83 | + if (last) return last; | |
| 84 | + await sleep(500); | |
| 85 | + } | |
| 86 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Claim one waiting self-hosted job and run it with the real agent. */ | |
| 90 | +async function runOneJob(owner, repo) { | |
| 91 | + const claim = await until("a job to claim", async () => { | |
| 92 | + const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); | |
| 93 | + return r.status === 200 ? r.json : null; | |
| 94 | + }); | |
| 95 | + const jobEnv = { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token }; | |
| 96 | + // E2E_RUNNER_IMAGE runs the job inside the real runner image instead of the local agent bundle. | |
| 97 | + const image = process.env.E2E_RUNNER_IMAGE; | |
| 98 | + const r = image | |
| 99 | + ? spawnSync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], { encoding: "utf8", timeout: 300_000 }) | |
| 100 | + : spawnSync(process.execPath, [AGENT], { encoding: "utf8", env: { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, timeout: 120_000 }); | |
| 101 | + const out = `${r.stdout}${r.stderr}`; | |
| 102 | + if (r.status !== 0) { | |
| 103 | + const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 104 | + const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`); | |
| 105 | + assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}`); | |
| 106 | + } | |
| 107 | + return { claim, out }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +before(async () => { | |
| 111 | + assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); | |
| 112 | + const port = await freePort(); | |
| 113 | + base = `http://127.0.0.1:${port}`; | |
| 114 | + const persist = join(work, "state"); | |
| 115 | + execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 116 | + // Until the web UI exists, run the same app through the API-only entry. | |
| 117 | + const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"]; | |
| 118 | + dev = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"], { | |
| 119 | + cwd: APP, | |
| 120 | + env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, | |
| 121 | + stdio: ["ignore", "pipe", "pipe"], | |
| 122 | + detached: true, | |
| 123 | + }); | |
| 124 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 125 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 126 | + await until("wrangler dev", async () => { | |
| 127 | + try { | |
| 128 | + return (await fetch(`${base}/healthz`)).ok; | |
| 129 | + } catch { | |
| 130 | + return false; | |
| 131 | + } | |
| 132 | + }, 120_000); | |
| 133 | + // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else. | |
| 134 | + const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) }); | |
| 135 | + assert.equal(res.status, 201, await res.clone().text()); | |
| 136 | + token = (await res.json()).token; | |
| 137 | +}); | |
| 138 | + | |
| 139 | +after(() => { | |
| 140 | + if (dev?.pid) { | |
| 141 | + try { | |
| 142 | + process.kill(-dev.pid, "SIGTERM"); | |
| 143 | + } catch {} | |
| 144 | + } | |
| 145 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 146 | +}); | |
| 147 | + | |
| 148 | +test("git hosting, Actions, protected PR flow and MCP", async () => { | |
| 149 | + const me = await ok("GET", "/user"); | |
| 150 | + const owner = me.login; | |
| 151 | + assert.ok(me.site_admin, "dev user is a bootstrap admin"); | |
| 152 | + | |
| 153 | + // ------------------------------------------------------------ repo + first push | |
| 154 | + const repo = await ok("POST", "/user/repos", { name: "demo", description: "e2e" }); | |
| 155 | + assert.equal(repo.full_name, `${owner}/demo`); | |
| 156 | + const src = join(work, "src"); | |
| 157 | + mkdirSync(join(src, ".github/workflows"), { recursive: true }); | |
| 158 | + writeFileSync(join(src, "README.md"), "# demo\n"); | |
| 159 | + writeFileSync(join(src, "app.txt"), "one\ntwo\nthree\n"); | |
| 160 | + writeFileSync( | |
| 161 | + join(src, ".github/workflows/ci.yml"), | |
| 162 | + `name: CI | |
| 163 | +on: | |
| 164 | + push: | |
| 165 | + branches: [main] | |
| 166 | + pull_request: | |
| 167 | +jobs: | |
| 168 | + build: | |
| 169 | + runs-on: [self-hosted, linux] | |
| 170 | + steps: | |
| 171 | + - uses: actions/checkout@v4 | |
| 172 | + - name: Check files | |
| 173 | + run: | | |
| 174 | + test -f README.md | |
| 175 | + echo "event=\${{ github.event_name }} sha=\${{ github.sha }}" | |
| 176 | + grep -q bad app.txt && { echo "found bad line"; exit 1; } || true | |
| 177 | + - id: out | |
| 178 | + run: echo "lines=$(wc -l < app.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" | |
| 179 | + - run: echo "app.txt has \${{ steps.out.outputs.lines }} lines" | |
| 180 | +`, | |
| 181 | + ); | |
| 182 | + git(src, ["init", "-q", "-b", "main"]); | |
| 183 | + git(src, ["add", "-A"]); | |
| 184 | + git(src, ["commit", "-q", "-m", "initial"]); | |
| 185 | + git(src, ["remote", "add", "origin", `${base}/${owner}/demo.git`]); | |
| 186 | + git(src, ["push", "-q", "origin", "main"]); | |
| 187 | + const mainSha = git(src, ["rev-parse", "HEAD"]).trim(); | |
| 188 | + | |
| 189 | + const branch = await ok("GET", `/repos/${owner}/demo/branches/main`); | |
| 190 | + assert.equal(branch.commit.sha, mainSha); | |
| 191 | + | |
| 192 | + // ------------------------------------------------------------ the push ran CI on a self-hosted runner | |
| 193 | + const pushRun = await until("push run", async () => (await ok("GET", `/repos/${owner}/demo/actions/runs?event=push`)).workflow_runs[0]); | |
| 194 | + assert.equal(pushRun.head_sha, mainSha); | |
| 195 | + const job1 = await runOneJob(owner, "demo"); | |
| 196 | + const finished = await until("push run to complete", async () => { | |
| 197 | + const r = await ok("GET", `/repos/${owner}/demo/actions/runs/${pushRun.id}`); | |
| 198 | + return r.status === "completed" ? r : null; | |
| 199 | + }); | |
| 200 | + assert.equal(finished.conclusion, "success", job1.out); | |
| 201 | + const logs = await (await fetch(`${base}/api/v3/repos/${owner}/demo/actions/jobs/${job1.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 202 | + assert.match(logs, /event=push/); | |
| 203 | + assert.match(logs, /app\.txt has 3 lines/); | |
| 204 | + const checks = await ok("GET", `/repos/${owner}/demo/commits/${mainSha}/check-runs`); | |
| 205 | + assert.equal(checks.check_runs.find((c) => c.name === "CI / build")?.conclusion, "success"); | |
| 206 | + | |
| 207 | + // ------------------------------------------------------------ clone over protocol v2 | |
| 208 | + const clone = join(work, "clone"); | |
| 209 | + git(work, ["clone", "-q", `${base}/${owner}/demo.git`, clone]); | |
| 210 | + assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n"); | |
| 211 | + | |
| 212 | + // ------------------------------------------------------------ secrets are blocked on push | |
| 213 | + writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n"); | |
| 214 | + git(clone, ["add", "-A"]); | |
| 215 | + git(clone, ["commit", "-q", "-m", "oops"]); | |
| 216 | + assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i); | |
| 217 | + git(clone, ["reset", "-q", "--hard", "origin/main"]); | |
| 218 | + | |
| 219 | + // ------------------------------------------------------------ protect main: PR + green CI + review | |
| 220 | + await ok("PUT", `/repos/${owner}/demo/branches/main/protection`, { | |
| 221 | + requirePullRequest: true, | |
| 222 | + requiredApprovals: 0, | |
| 223 | + dismissStaleReviews: true, | |
| 224 | + requiredChecks: ["CI / build"], | |
| 225 | + allowForcePush: false, | |
| 226 | + allowDeletion: false, | |
| 227 | + requireLinearHistory: false, | |
| 228 | + enforceAdmins: true, | |
| 229 | + agentsMayPush: false, | |
| 230 | + agentsMayMerge: false, | |
| 231 | + }); | |
| 232 | + writeFileSync(join(clone, "app.txt"), "one\ntwo\nthree\nfour\n"); | |
| 233 | + git(clone, ["commit", "-q", "-am", "direct push"]); | |
| 234 | + assert.match(gitFails(clone, ["push", "origin", "main"]), /pull request|protected/i); | |
| 235 | + git(clone, ["reset", "-q", "--hard", "origin/main"]); | |
| 236 | + | |
| 237 | + // ------------------------------------------------------------ an AI agent opens a PR | |
| 238 | + const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" }); | |
| 239 | + agentToken = created.token.token; | |
| 240 | + const agentClone = join(work, "agent"); | |
| 241 | + git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken); | |
| 242 | + git(agentClone, ["checkout", "-q", "-b", "feature"], agentToken); | |
| 243 | + writeFileSync(join(agentClone, "app.txt"), "one\ntwo\nthree\nfour\n"); | |
| 244 | + git(agentClone, ["commit", "-q", "-am", "Add a fourth line"], agentToken); | |
| 245 | + assert.match(gitFails(agentClone, ["push", "origin", "HEAD:main"], agentToken), /agent|protected|pull request/i); | |
| 246 | + git(agentClone, ["push", "-q", "origin", "feature"], agentToken); | |
| 247 | + const pr = await ok("POST", `/repos/${owner}/demo/pulls`, { title: "Add a fourth line", head: "feature", base: "main", intent: "Extend app.txt for the e2e test" }, agentToken); | |
| 248 | + assert.equal(pr.number, 1); | |
| 249 | + | |
| 250 | + // The pull_request run executes; until then the PR is blocked on the required check. | |
| 251 | + let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 252 | + assert.equal(m.mergeable, false); | |
| 253 | + await runOneJob(owner, "demo"); | |
| 254 | + await until("PR check to pass", async () => { | |
| 255 | + const r = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 256 | + return r.checks?.length > 0 && r.checks.every((c) => c.state === "success") ? r : null; | |
| 257 | + }); | |
| 258 | + m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 259 | + assert.equal(m.mergeable, false, "agent-authored PRs need a human approval"); | |
| 260 | + assert.ok(JSON.stringify(m.blockers).match(/approv|human/i), JSON.stringify(m.blockers)); | |
| 261 | + | |
| 262 | + // The agent cannot approve or merge its own work. | |
| 263 | + const agentMerge = await api("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash" }, agentToken); | |
| 264 | + assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge)); | |
| 265 | + | |
| 266 | + // Another agent's approval is advisory and does not unblock the merge. | |
| 267 | + const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" })).token.token; | |
| 268 | + await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "bot says ok" }, reviewer); | |
| 269 | + assert.equal((await ok("GET", `/repos/${owner}/demo/pulls/1/merge`)).mergeable, false); | |
| 270 | + | |
| 271 | + // A human reviews (line comment + approval) and merges. | |
| 272 | + await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "LGTM", comments: [{ path: "app.txt", line: 4, body: "nice line" }] }); | |
| 273 | + m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 274 | + assert.equal(m.mergeable, true, JSON.stringify(m)); | |
| 275 | + const merged = await ok("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash", delete_branch: true }); | |
| 276 | + assert.ok(merged.sha); | |
| 277 | + const prAfter = await ok("GET", `/repos/${owner}/demo/pulls/1`); | |
| 278 | + assert.equal(prAfter.merged, true); | |
| 279 | + | |
| 280 | + // ------------------------------------------------------------ fetch the merge, read through REST | |
| 281 | + git(clone, ["pull", "-q", "--ff-only", "origin", "main"]); | |
| 282 | + assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n"); | |
| 283 | + const content = await ok("GET", `/repos/${owner}/demo/contents/app.txt`); | |
| 284 | + assert.equal(Buffer.from(content.content, "base64").toString(), "one\ntwo\nthree\nfour\n"); | |
| 285 | + const branches = await ok("GET", `/repos/${owner}/demo/branches`); | |
| 286 | + assert.ok(!branches.some((b) => b.name === "feature"), "head branch deleted after merge"); | |
| 287 | + | |
| 288 | + // ------------------------------------------------------------ shallow clone, then deepen | |
| 289 | + const shallow = join(work, "shallow"); | |
| 290 | + git(work, ["clone", "-q", "--depth", "1", `${base}/${owner}/demo.git`, shallow]); | |
| 291 | + assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "1"); | |
| 292 | + assert.equal(readFileSync(join(shallow, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n"); | |
| 293 | + git(shallow, ["fetch", "-q", "--unshallow"]); | |
| 294 | + assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "2"); | |
| 295 | + git(shallow, ["fsck", "--no-progress"]); | |
| 296 | + | |
| 297 | + // ------------------------------------------------------------ MCP: the agent works without git | |
| 298 | + const rpc = async (method, params, id = 1) => { | |
| 299 | + const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${agentToken}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) }); | |
| 300 | + assert.equal(r.status, 200); | |
| 301 | + return (await r.json()).result; | |
| 302 | + }; | |
| 303 | + const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "1" } }); | |
| 304 | + assert.equal(init.serverInfo.name, "diffinery"); | |
| 305 | + const tools = await rpc("tools/list", {}); | |
| 306 | + assert.ok(tools.tools.some((t) => t.name === "commit_files")); | |
| 307 | + const file = await rpc("tools/call", { name: "get_file", arguments: { owner, repo: "demo", path: "README.md" } }); | |
| 308 | + assert.equal(file.isError, false); | |
| 309 | + assert.equal(file.structuredContent.content, "# demo\n"); | |
| 310 | + const commit = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "mcp-change", create_branch_from: "main", message: "Edit via MCP", files: [{ path: "docs/notes.md", content: "hello\n" }] } }); | |
| 311 | + assert.equal(commit.isError, false, JSON.stringify(commit)); | |
| 312 | + const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } }); | |
| 313 | + assert.equal(blocked.isError, true); | |
| 314 | + const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } }); | |
| 315 | + assert.equal(opened.isError, false, JSON.stringify(opened)); | |
| 316 | + assert.equal(opened.structuredContent.number, 2); | |
| 317 | + | |
| 318 | + // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH) | |
| 319 | + for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) { | |
| 320 | + const res = await fetch(`${base}${path}`); | |
| 321 | + const html = await res.text(); | |
| 322 | + assert.equal(res.status, 200, `${path} -> ${res.status}\n${html.slice(0, 500)}`); | |
| 323 | + assert.match(res.headers.get("content-type") ?? "", /text\/html/, path); | |
| 324 | + } | |
| 325 | + assert.match(await (await fetch(`${base}/${owner}/demo/pull/1`)).text(), /Add a fourth line/); | |
| 326 | + const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" }); | |
| 327 | + assert.equal(forged.status, 403); | |
| 328 | + | |
| 329 | + // ------------------------------------------------------------ the CLI against the same server | |
| 330 | + const CLI = resolve(APP, "../cli/dist/diffinery.mjs"); | |
| 331 | + if (existsSync(CLI)) { | |
| 332 | + const cli = (...args) => execFileSync(process.execPath, [CLI, ...args], { encoding: "utf8", cwd: clone, env: { ...process.env, DIFFINERY_URL: base, DIFFINERY_TOKEN: token, NO_COLOR: "1", XDG_CONFIG_HOME: join(work, "cfg") } }); | |
| 333 | + assert.match(cli("whoami"), new RegExp(owner)); | |
| 334 | + const prs = JSON.parse(cli("pr", "list", "--state", "all", "--json")); | |
| 335 | + assert.ok(prs.some((p) => p.number === 1)); | |
| 336 | + assert.match(cli("workflow", "validate"), /ci\.yml/); | |
| 337 | + } | |
| 338 | + | |
| 339 | + // The commit made through MCP carries the agent's identity. | |
| 340 | + const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`); | |
| 341 | + assert.match(JSON.stringify(agentCommit), /e2e-bot/); | |
| 342 | +}); |
addedapps/diffinery/test/e2e/entry.ts+10−0View file
| @@ -1,0 +1,10 @@ | ||
| 1 | +// Worker entry for end-to-end tests while the web UI is optional: same app, no HTML routes. | |
| 2 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import { app, queue, scheduled } from "../../src/app.js"; | |
| 5 | + | |
| 6 | +export { RepoDO } from "../../src/repo/RepoDO.js"; | |
| 7 | +export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js"; | |
| 8 | +export { RunWorkflow } from "../../src/runner/workflow.js"; | |
| 9 | + | |
| 10 | +export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent>; |
addedapps/diffinery/test/e2e/passkeys.test.mjs+236−0View file
| @@ -1,0 +1,236 @@ | ||
| 1 | +// End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium | |
| 2 | +// with a virtual WebAuthn authenticator (Chrome DevTools Protocol). | |
| 3 | +// | |
| 4 | +// pnpm --filter @diffinery/app e2e | |
| 5 | +// | |
| 6 | +// Covers: signed-out redirects, joining from an invite link with a new passkey | |
| 7 | +// (which also applies the invite's org membership), adding and removing | |
| 8 | +// passkeys, signing out and back in, admin-issued one-time sign-in links, | |
| 9 | +// single-use invites and links, and refusing cross-site and token-driven calls. | |
| 10 | +import { spawn, execFileSync } from "node:child_process"; | |
| 11 | +import { createHash } from "node:crypto"; | |
| 12 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 13 | +import { tmpdir } from "node:os"; | |
| 14 | +import { join, dirname, resolve } from "node:path"; | |
| 15 | +import { fileURLToPath } from "node:url"; | |
| 16 | +import { createServer } from "node:net"; | |
| 17 | +import { test, before, after } from "node:test"; | |
| 18 | +import assert from "node:assert/strict"; | |
| 19 | +import { chromium } from "playwright"; | |
| 20 | + | |
| 21 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 22 | +const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-")); | |
| 23 | +let base; | |
| 24 | +let dev; | |
| 25 | +let devLog = ""; | |
| 26 | +let browser; | |
| 27 | + | |
| 28 | +const INVITE = "invite-token-for-pal-0123456789abcdef"; | |
| 29 | +const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef"; | |
| 30 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 31 | + | |
| 32 | +const freePort = () => | |
| 33 | + new Promise((res) => { | |
| 34 | + const s = createServer(); | |
| 35 | + s.listen(0, "127.0.0.1", () => { | |
| 36 | + const { port } = s.address(); | |
| 37 | + s.close(() => res(port)); | |
| 38 | + }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 42 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 43 | + const end = Date.now() + timeoutMs; | |
| 44 | + while (Date.now() < end) { | |
| 45 | + const v = await fn(); | |
| 46 | + if (v) return v; | |
| 47 | + await sleep(500); | |
| 48 | + } | |
| 49 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */ | |
| 53 | +async function person() { | |
| 54 | + const context = await browser.newContext(); | |
| 55 | + const page = await context.newPage(); | |
| 56 | + page.on("dialog", (d) => d.accept()); | |
| 57 | + const cdp = await context.newCDPSession(page); | |
| 58 | + await cdp.send("WebAuthn.enable"); | |
| 59 | + const addAuthenticator = async () => | |
| 60 | + ( | |
| 61 | + await cdp.send("WebAuthn.addVirtualAuthenticator", { | |
| 62 | + options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true }, | |
| 63 | + }) | |
| 64 | + ).authenticatorId; | |
| 65 | + let authenticatorId = await addAuthenticator(); | |
| 66 | + return { | |
| 67 | + context, | |
| 68 | + page, | |
| 69 | + /** Swap in a new, empty authenticator (another device). */ | |
| 70 | + async newDevice() { | |
| 71 | + await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); | |
| 72 | + authenticatorId = await addAuthenticator(); | |
| 73 | + }, | |
| 74 | + async credentials() { | |
| 75 | + return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials; | |
| 76 | + }, | |
| 77 | + }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +const text = async (page) => (await page.textContent("main")) ?? ""; | |
| 81 | + | |
| 82 | +before(async () => { | |
| 83 | + const port = await freePort(); | |
| 84 | + // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not. | |
| 85 | + base = `http://localhost:${port}`; | |
| 86 | + const persist = join(work, "state"); | |
| 87 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 88 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 89 | + const at = new Date().toISOString(); | |
| 90 | + const later = new Date(Date.now() + 86400_000).toISOString(); | |
| 91 | + wrangler( | |
| 92 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 93 | + [ | |
| 94 | + `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`, | |
| 95 | + `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`, | |
| 96 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`, | |
| 97 | + `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`, | |
| 98 | + `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`, | |
| 99 | + ].join("; "), | |
| 100 | + ); | |
| 101 | + dev = spawn( | |
| 102 | + "pnpm", | |
| 103 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], | |
| 104 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 105 | + ); | |
| 106 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 107 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 108 | + await until( | |
| 109 | + "wrangler dev", | |
| 110 | + async () => { | |
| 111 | + try { | |
| 112 | + return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; | |
| 113 | + } catch { | |
| 114 | + return false; | |
| 115 | + } | |
| 116 | + }, | |
| 117 | + 120_000, | |
| 118 | + ); | |
| 119 | + browser = await chromium.launch(); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +after(async () => { | |
| 123 | + await browser?.close(); | |
| 124 | + if (dev?.pid) { | |
| 125 | + try { | |
| 126 | + process.kill(-dev.pid, "SIGTERM"); | |
| 127 | + } catch {} | |
| 128 | + } | |
| 129 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 130 | +}); | |
| 131 | + | |
| 132 | +test("passkey sign-in, invites and sign-in links", async () => { | |
| 133 | + // ------------------------------------------------------------ signed out | |
| 134 | + const pal = await person(); | |
| 135 | + const { page } = pal; | |
| 136 | + await page.goto(`${base}/settings`); | |
| 137 | + assert.equal(new URL(page.url()).pathname, "/login"); | |
| 138 | + assert.equal(new URL(page.url()).searchParams.get("next"), "/settings"); | |
| 139 | + assert.match(await text(page), /Sign in with a passkey/); | |
| 140 | + | |
| 141 | + // A passkey nobody registered is refused. | |
| 142 | + await page.click("text=Sign in with a passkey"); | |
| 143 | + await page.waitForSelector("[data-passkey-msg]:not([hidden])"); | |
| 144 | + assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i); | |
| 145 | + | |
| 146 | + // ------------------------------------------------------------ join from an invite link | |
| 147 | + await page.goto(`${base}/invite/${INVITE}`); | |
| 148 | + assert.match(await text(page), /invited as pal@example\.com/); | |
| 149 | + await page.fill("#login", "pal"); | |
| 150 | + await page.fill("#name", "Pal Friend"); | |
| 151 | + await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]); | |
| 152 | + assert.match(await text(page), /Welcome to Diffinery, pal/); | |
| 153 | + assert.equal((await pal.credentials()).length, 1); | |
| 154 | + const cookies = await pal.context.cookies(base); | |
| 155 | + const session = cookies.find((c) => c.name === "__Host-dfy_session"); | |
| 156 | + assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`); | |
| 157 | + assert.equal(session.httpOnly, true); | |
| 158 | + assert.equal(session.sameSite, "Lax"); | |
| 159 | + // The invite's org membership was applied. | |
| 160 | + await page.goto(`${base}/settings/orgs`); | |
| 161 | + assert.match(await text(page), /crew/); | |
| 162 | + | |
| 163 | + // Invites work once. | |
| 164 | + const stranger = await person(); | |
| 165 | + const reused = await stranger.page.goto(`${base}/invite/${INVITE}`); | |
| 166 | + assert.equal(reused.status(), 404); | |
| 167 | + | |
| 168 | + // ------------------------------------------------------------ manage passkeys | |
| 169 | + await page.goto(`${base}/settings/security`); | |
| 170 | + let body = await text(page); | |
| 171 | + assert.match(body, /Passkeys and sessions/); | |
| 172 | + assert.match(body, /this browser/); | |
| 173 | + assert.match(body, /Only passkey/); | |
| 174 | + await pal.newDevice(); | |
| 175 | + await page.fill("#pklabel", "Laptop"); | |
| 176 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]); | |
| 177 | + await page.waitForSelector("text=Passkey \"Laptop\" added."); | |
| 178 | + body = await text(page); | |
| 179 | + assert.match(body, /Laptop/); | |
| 180 | + assert.equal(await page.locator("button:has-text('Remove')").count(), 2); | |
| 181 | + // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in. | |
| 182 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]); | |
| 183 | + await page.waitForSelector("text=Passkey removed."); | |
| 184 | + body = await text(page); | |
| 185 | + assert.match(body, /Only passkey/); | |
| 186 | + assert.match(body, /Laptop/); | |
| 187 | + | |
| 188 | + // ------------------------------------------------------------ sign out, then in with the remaining passkey | |
| 189 | + await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]); | |
| 190 | + await page.goto(`${base}/settings`); | |
| 191 | + assert.equal(new URL(page.url()).pathname, "/login"); | |
| 192 | + await page.goto(`${base}/login?next=/settings/security`); | |
| 193 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]); | |
| 194 | + assert.match(await text(page), /Laptop/); | |
| 195 | + | |
| 196 | + // ------------------------------------------------------------ admin sign-in links | |
| 197 | + const boss = await person(); | |
| 198 | + await boss.page.goto(`${base}/signin/${ADMIN_LINK}`); | |
| 199 | + assert.match(await text(boss.page), /add a passkey to boss/); | |
| 200 | + await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]); | |
| 201 | + assert.match(await text(boss.page), /Use it to sign in from now on/); | |
| 202 | + const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`); | |
| 203 | + assert.equal(usedLink.status(), 404); | |
| 204 | + | |
| 205 | + // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device. | |
| 206 | + await boss.page.goto(`${base}/settings/admin`); | |
| 207 | + await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]); | |
| 208 | + await boss.page.waitForSelector("text=Sign-in link for pal"); | |
| 209 | + const link = (await boss.page.textContent(".secret")).trim(); | |
| 210 | + assert.match(link, new RegExp(`^${base}/signin/`)); | |
| 211 | + await pal.newDevice(); | |
| 212 | + await page.goto(link); | |
| 213 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]); | |
| 214 | + assert.equal(await page.locator("button:has-text('Remove')").count(), 2); | |
| 215 | + | |
| 216 | + // ------------------------------------------------------------ refused requests | |
| 217 | + const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" }); | |
| 218 | + assert.equal(cross.status, 403); | |
| 219 | + const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" }); | |
| 220 | + assert.equal(form.status, 415); | |
| 221 | + const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) }); | |
| 222 | + assert.equal(noChallenge.status, 400); | |
| 223 | + const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); | |
| 224 | + assert.equal(anonymous.status, 401); | |
| 225 | + | |
| 226 | + // Screenshots for review (kept with E2E_KEEP=1). | |
| 227 | + if (process.env.E2E_SCREENSHOTS) { | |
| 228 | + const out = process.env.E2E_SCREENSHOTS; | |
| 229 | + await stranger.page.goto(`${base}/login`); | |
| 230 | + await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true }); | |
| 231 | + await page.goto(`${base}/settings/security`); | |
| 232 | + await page.screenshot({ path: join(out, "security.png"), fullPage: true }); | |
| 233 | + await boss.page.goto(`${base}/settings/admin`); | |
| 234 | + await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true }); | |
| 235 | + } | |
| 236 | +}); |
addedapps/diffinery/tsconfig.json+11−0View file
| @@ -1,0 +1,11 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "noEmit": true, | |
| 5 | + "lib": ["ES2023"], | |
| 6 | + "types": ["@cloudflare/workers-types"], | |
| 7 | + "jsx": "react-jsx", | |
| 8 | + "jsxImportSource": "hono/jsx" | |
| 9 | + }, | |
| 10 | + "include": ["src"] | |
| 11 | +} |
addedapps/diffinery/wrangler.jsonc+78−0View file
| @@ -1,0 +1,78 @@ | ||
| 1 | +// Diffinery: one Worker, everything on Cloudflare. | |
| 2 | +// `scripts/bootstrap.sh` creates the account resources and writes | |
| 3 | +// wrangler.deploy.jsonc (this file plus real IDs) for CI deploys. | |
| 4 | +{ | |
| 5 | + "$schema": "node_modules/wrangler/config-schema.json", | |
| 6 | + "name": "diffinery", | |
| 7 | + "main": "src/index.ts", | |
| 8 | + "compatibility_date": "2026-10-01", | |
| 9 | + "compatibility_flags": ["nodejs_compat"], | |
| 10 | + "workers_dev": false, | |
| 11 | + "routes": [{ "pattern": "diffinery.com", "custom_domain": true }], | |
| 12 | + "observability": { "enabled": true, "head_sampling_rate": 1 }, | |
| 13 | + "upload_source_maps": true, | |
| 14 | + "limits": { "cpu_ms": 300000 }, | |
| 15 | + | |
| 16 | + "vars": { | |
| 17 | + "PUBLIC_URL": "https://diffinery.com", | |
| 18 | + "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com", | |
| 19 | + "ACCESS_TEAM_DOMAIN": "", | |
| 20 | + "ACCESS_AUD": "" | |
| 21 | + }, | |
| 22 | + | |
| 23 | + "d1_databases": [ | |
| 24 | + { "binding": "DB", "database_name": "diffinery", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" } | |
| 25 | + ], | |
| 26 | + "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data" }], | |
| 27 | + "queues": { | |
| 28 | + "producers": [{ "binding": "EVENTS", "queue": "diffinery-events" }], | |
| 29 | + "consumers": [ | |
| 30 | + { "queue": "diffinery-events", "max_batch_size": 50, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-events-dlq" } | |
| 31 | + ] | |
| 32 | + }, | |
| 33 | + "durable_objects": { | |
| 34 | + "bindings": [ | |
| 35 | + { "name": "REPO", "class_name": "RepoDO" }, | |
| 36 | + { "name": "RUNNER", "class_name": "RunnerContainer" }, | |
| 37 | + { "name": "SANDBOX", "class_name": "SandboxContainer" } | |
| 38 | + ] | |
| 39 | + }, | |
| 40 | + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RepoDO", "RunnerContainer", "SandboxContainer"] }], | |
| 41 | + "workflows": [{ "name": "diffinery-run", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }], | |
| 42 | + "containers": [ | |
| 43 | + { "class_name": "RunnerContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-2", "max_instances": 100 }, | |
| 44 | + { "class_name": "SandboxContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-1", "max_instances": 50 } | |
| 45 | + ], | |
| 46 | + "triggers": { "crons": ["*/5 * * * *"] }, | |
| 47 | + | |
| 48 | + "env": { | |
| 49 | + // Local development and end-to-end tests: no Access, a fixed dev identity, no containers. | |
| 50 | + "dev": { | |
| 51 | + "routes": [], | |
| 52 | + "workers_dev": true, | |
| 53 | + "vars": { | |
| 54 | + "PUBLIC_URL": "http://localhost:8787", | |
| 55 | + "BOOTSTRAP_ADMINS": "dev@diffinery.local", | |
| 56 | + "ACCESS_TEAM_DOMAIN": "", | |
| 57 | + "ACCESS_AUD": "", | |
| 58 | + "DEV_AUTH": "dev@diffinery.local" | |
| 59 | + }, | |
| 60 | + "d1_databases": [{ "binding": "DB", "database_name": "diffinery-dev", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }], | |
| 61 | + "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data-dev" }], | |
| 62 | + "queues": { | |
| 63 | + "producers": [{ "binding": "EVENTS", "queue": "diffinery-events-dev" }], | |
| 64 | + "consumers": [{ "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 }] | |
| 65 | + }, | |
| 66 | + "durable_objects": { | |
| 67 | + "bindings": [ | |
| 68 | + { "name": "REPO", "class_name": "RepoDO" }, | |
| 69 | + { "name": "RUNNER", "class_name": "RunnerContainer" }, | |
| 70 | + { "name": "SANDBOX", "class_name": "SandboxContainer" } | |
| 71 | + ] | |
| 72 | + }, | |
| 73 | + "workflows": [{ "name": "diffinery-run-dev", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }], | |
| 74 | + "triggers": { "crons": [] }, | |
| 75 | + "containers": [] | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} |
addedcontainers/runner/.dockerignore+2−0View file
| @@ -1,0 +1,2 @@ | ||
| 1 | +* | |
| 2 | +!dist/agent.mjs |
addedcontainers/runner/Dockerfile+51−0View file
| @@ -1,0 +1,51 @@ | ||
| 1 | +# Diffinery runner image (Cloudflare Containers, linux/amd64). | |
| 2 | +# Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists. | |
| 3 | +# | |
| 4 | +# docker build --platform linux/amd64 -t diffinery-runner containers/runner | |
| 5 | +ARG BASE_IMAGE=ubuntu:24.04 | |
| 6 | +FROM ${BASE_IMAGE} | |
| 7 | + | |
| 8 | +ARG NODE_VERSION=22.22.0 | |
| 9 | + | |
| 10 | +ENV DEBIAN_FRONTEND=noninteractive \ | |
| 11 | + LANG=C.UTF-8 \ | |
| 12 | + LC_ALL=C.UTF-8 \ | |
| 13 | + DIFFINERY_WORK_ROOT=/workspace \ | |
| 14 | + RUNNER_TOOL_CACHE=/opt/hostedtoolcache \ | |
| 15 | + AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache \ | |
| 16 | + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ | |
| 17 | + # Ubuntu 24.04 marks the system Python as externally managed (PEP 668); | |
| 18 | + # CI scripts commonly `pip install` directly, so allow it like hosted runners do. | |
| 19 | + PIP_BREAK_SYSTEM_PACKAGES=1 | |
| 20 | + | |
| 21 | +RUN apt-get update \ | |
| 22 | + && apt-get install -y --no-install-recommends \ | |
| 23 | + ca-certificates curl git jq zip unzip xz-utils tar gzip \ | |
| 24 | + build-essential pkg-config openssh-client gnupg \ | |
| 25 | + python3 python3-pip python3-venv python-is-python3 \ | |
| 26 | + && git config --system --add safe.directory '*' \ | |
| 27 | + && apt-get clean \ | |
| 28 | + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* | |
| 29 | + | |
| 30 | +# Node.js LTS from the official tarball (checksum-verified), with corepack shims for pnpm/yarn. | |
| 31 | +RUN set -eux; \ | |
| 32 | + cd /tmp; \ | |
| 33 | + file="node-v${NODE_VERSION}-linux-x64.tar.xz"; \ | |
| 34 | + curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${file}"; \ | |
| 35 | + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${file}\$" | sha256sum -c -; \ | |
| 36 | + tar -xJf "${file}" -C /usr/local --strip-components=1 --no-same-owner \ | |
| 37 | + --exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE' --exclude='*/include/*' --exclude='*/share/doc/*'; \ | |
| 38 | + rm -f "${file}"; \ | |
| 39 | + corepack enable; \ | |
| 40 | + corepack prepare pnpm@10.28.0 --activate; \ | |
| 41 | + corepack prepare yarn@1.22.22 --activate; \ | |
| 42 | + node --version; npm --version; pnpm --version; yarn --version; \ | |
| 43 | + npm cache clean --force; \ | |
| 44 | + rm -rf /root/.npm /tmp/* | |
| 45 | + | |
| 46 | +RUN mkdir -p /workspace /opt/hostedtoolcache /opt/diffinery | |
| 47 | +COPY dist/agent.mjs /opt/diffinery/agent.mjs | |
| 48 | + | |
| 49 | +WORKDIR /workspace | |
| 50 | +EXPOSE 8080 | |
| 51 | +ENTRYPOINT ["node", "/opt/diffinery/agent.mjs"] |
addedcontainers/runner/package.json+13−0View file
| @@ -1,0 +1,13 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@diffinery/runner", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs \"--banner:js=import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 8 | + "typecheck": "tsc --noEmit", | |
| 9 | + "test": "vitest run" | |
| 10 | + }, | |
| 11 | + "dependencies": { "@diffinery/shared": "workspace:*", "@diffinery/workflow-yaml": "workspace:*" }, | |
| 12 | + "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" } | |
| 13 | +} |
addedcontainers/runner/src/agent.ts+65−0View file
| @@ -1,0 +1,65 @@ | ||
| 1 | +import type { JobCompletion, JobSpec } from "@diffinery/shared"; | |
| 2 | +import { LogBatcher, RunnerApi } from "./api.js"; | |
| 3 | +import { JobRunner, errMsg } from "./job.js"; | |
| 4 | + | |
| 5 | +export interface AgentOptions { | |
| 6 | + /** `${apiUrl}/runner/jobs/${jobId}`, or null for offline mode (logs to stdout, no reports). */ | |
| 7 | + apiBase: string | null; | |
| 8 | + workRoot: string; | |
| 9 | + toolCache?: string; | |
| 10 | + echoLogs?: boolean; | |
| 11 | + /** Install a SIGTERM/SIGINT handler that cancels the job. */ | |
| 12 | + handleSignals?: boolean; | |
| 13 | + /** Exposes the runner (e.g. so tests can cancel). */ | |
| 14 | + onRunner?: (r: JobRunner) => void; | |
| 15 | + logIntervalMs?: number; | |
| 16 | + baseEnv?: NodeJS.ProcessEnv; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function apiBaseFor(apiUrl: string | undefined | null, jobId: string): string | null { | |
| 20 | + if (!apiUrl) return null; | |
| 21 | + return `${apiUrl.replace(/\/$/, "")}/runner/jobs/${encodeURIComponent(jobId)}`; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Runs one job end-to-end: execute steps, ship logs, POST completion. */ | |
| 25 | +export async function runAgentJob(spec: JobSpec, o: AgentOptions): Promise<JobCompletion> { | |
| 26 | + const api = new RunnerApi(o.apiBase, spec.token); | |
| 27 | + const batcher = api.online | |
| 28 | + ? new LogBatcher((lines) => api.postLogs(lines), { | |
| 29 | + intervalMs: o.logIntervalMs ?? 500, | |
| 30 | + onError: (e) => process.stderr.write(`[diffinery-runner] log upload failed: ${errMsg(e)}\n`), | |
| 31 | + }) | |
| 32 | + : null; | |
| 33 | + const runner = new JobRunner(spec, api, null, { workRoot: o.workRoot, toolCache: o.toolCache, echoLogs: o.echoLogs ?? !api.online, baseEnv: o.baseEnv }, batcher); | |
| 34 | + o.onRunner?.(runner); | |
| 35 | + const onSignal = (sig: string) => { | |
| 36 | + runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`); | |
| 37 | + runner.cancel("cancelled"); | |
| 38 | + }; | |
| 39 | + const sigterm = () => onSignal("SIGTERM"); | |
| 40 | + const sigint = () => onSignal("SIGINT"); | |
| 41 | + if (o.handleSignals) { | |
| 42 | + process.on("SIGTERM", sigterm); | |
| 43 | + process.on("SIGINT", sigint); | |
| 44 | + } | |
| 45 | + let completion: JobCompletion; | |
| 46 | + try { | |
| 47 | + completion = await runner.run(); | |
| 48 | + } catch (e) { | |
| 49 | + completion = { conclusion: "failure", outputs: {}, failure: { message: `Runner error: ${errMsg(e)}` } }; | |
| 50 | + } | |
| 51 | + if (batcher) { | |
| 52 | + const ok = await batcher.close(120_000); | |
| 53 | + if (!ok) process.stderr.write(`[diffinery-runner] gave up shipping ${batcher.pending} log lines\n`); | |
| 54 | + } | |
| 55 | + try { | |
| 56 | + await api.complete(completion); | |
| 57 | + } catch (e) { | |
| 58 | + process.stderr.write(`[diffinery-runner] failed to report completion: ${errMsg(e)}\n`); | |
| 59 | + } | |
| 60 | + if (o.handleSignals) { | |
| 61 | + process.off("SIGTERM", sigterm); | |
| 62 | + process.off("SIGINT", sigint); | |
| 63 | + } | |
| 64 | + return completion; | |
| 65 | +} |
addedcontainers/runner/src/api.ts+222−0View file
| @@ -1,0 +1,222 @@ | ||
| 1 | +import { createReadStream } from "node:fs"; | |
| 2 | +import { stat } from "node:fs/promises"; | |
| 3 | +import { Readable } from "node:stream"; | |
| 4 | +import type { JobCompletion, JobSpec, LogLine, StepReport } from "@diffinery/shared"; | |
| 5 | + | |
| 6 | +export class HttpError extends Error { | |
| 7 | + constructor( | |
| 8 | + public status: number, | |
| 9 | + message: string, | |
| 10 | + public body?: string, | |
| 11 | + ) { | |
| 12 | + super(message); | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 17 | + | |
| 18 | +export interface RequestOptions { | |
| 19 | + method?: string; | |
| 20 | + /** JSON body, or a factory producing a raw body (re-invoked on retry). */ | |
| 21 | + json?: unknown; | |
| 22 | + body?: () => Promise<{ body: NonNullable<RequestInit["body"]>; length?: number; contentType?: string }>; | |
| 23 | + retries?: number; | |
| 24 | + /** Return the response for these statuses instead of throwing. */ | |
| 25 | + allow?: number[]; | |
| 26 | + headers?: Record<string, string>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Client for `${apiUrl}/runner/jobs/${jobId}` endpoints. When `base` is null | |
| 31 | + * (offline `--spec` mode) all calls are no-ops. | |
| 32 | + */ | |
| 33 | +export class RunnerApi { | |
| 34 | + constructor( | |
| 35 | + public readonly base: string | null, | |
| 36 | + private readonly token: string, | |
| 37 | + private readonly opts: { minBackoffMs?: number; maxBackoffMs?: number } = {}, | |
| 38 | + ) {} | |
| 39 | + | |
| 40 | + get online(): boolean { | |
| 41 | + return this.base !== null; | |
| 42 | + } | |
| 43 | + | |
| 44 | + url(path: string): string { | |
| 45 | + return `${this.base}${path}`; | |
| 46 | + } | |
| 47 | + | |
| 48 | + authHeaders(): Record<string, string> { | |
| 49 | + return { authorization: `Bearer ${this.token}` }; | |
| 50 | + } | |
| 51 | + | |
| 52 | + async request(path: string, o: RequestOptions = {}): Promise<Response> { | |
| 53 | + if (!this.base) throw new Error("runner API not configured"); | |
| 54 | + return this.requestUrl(this.url(path), o); | |
| 55 | + } | |
| 56 | + | |
| 57 | + async requestUrl(url: string, o: RequestOptions = {}, withAuth = true): Promise<Response> { | |
| 58 | + const retries = o.retries ?? 6; | |
| 59 | + const min = this.opts.minBackoffMs ?? 500; | |
| 60 | + const max = this.opts.maxBackoffMs ?? 15000; | |
| 61 | + let lastErr: unknown; | |
| 62 | + for (let attempt = 0; attempt <= retries; attempt++) { | |
| 63 | + if (attempt > 0) await sleep(Math.min(max, min * 2 ** (attempt - 1))); | |
| 64 | + try { | |
| 65 | + const headers: Record<string, string> = { ...(withAuth ? this.authHeaders() : {}), ...(o.headers ?? {}) }; | |
| 66 | + const init: RequestInit & { duplex?: string } = { method: o.method ?? "GET", headers }; | |
| 67 | + if (o.json !== undefined) { | |
| 68 | + headers["content-type"] = "application/json"; | |
| 69 | + init.body = JSON.stringify(o.json); | |
| 70 | + } else if (o.body) { | |
| 71 | + const b = await o.body(); | |
| 72 | + init.body = b.body; | |
| 73 | + if (b.contentType) headers["content-type"] = b.contentType; | |
| 74 | + if (b.length !== undefined) headers["content-length"] = String(b.length); | |
| 75 | + init.duplex = "half"; | |
| 76 | + } | |
| 77 | + const res = await fetch(url, init); | |
| 78 | + if (res.ok || o.allow?.includes(res.status)) return res; | |
| 79 | + const text = await res.text().catch(() => ""); | |
| 80 | + const err = new HttpError(res.status, `${init.method} ${redactUrl(url)} -> ${res.status} ${text.slice(0, 300)}`, text); | |
| 81 | + if (res.status === 408 || res.status === 429 || res.status >= 500) { | |
| 82 | + lastErr = err; | |
| 83 | + continue; | |
| 84 | + } | |
| 85 | + throw err; | |
| 86 | + } catch (e) { | |
| 87 | + if (e instanceof HttpError && !(e.status === 408 || e.status === 429 || e.status >= 500)) throw e; | |
| 88 | + lastErr = e; | |
| 89 | + } | |
| 90 | + } | |
| 91 | + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); | |
| 92 | + } | |
| 93 | + | |
| 94 | + async getSpec(): Promise<JobSpec> { | |
| 95 | + const res = await this.request("", { retries: 8 }); | |
| 96 | + return (await res.json()) as JobSpec; | |
| 97 | + } | |
| 98 | + | |
| 99 | + async postLogs(lines: LogLine[]): Promise<void> { | |
| 100 | + if (!this.base) return; | |
| 101 | + await this.request("/logs", { method: "POST", json: { lines }, retries: 0 }); | |
| 102 | + } | |
| 103 | + | |
| 104 | + async postStep(index: number, report: StepReport): Promise<void> { | |
| 105 | + if (!this.base) return; | |
| 106 | + await this.request(`/steps/${index}`, { method: "POST", json: report }); | |
| 107 | + } | |
| 108 | + | |
| 109 | + async complete(c: JobCompletion): Promise<void> { | |
| 110 | + if (!this.base) return; | |
| 111 | + await this.request("/complete", { method: "POST", json: c, retries: 10 }); | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** Looks up a cache entry. Returns null on miss. */ | |
| 115 | + async cacheLookup(key: string, restoreKeys: string[]): Promise<{ key: string; url: string } | null> { | |
| 116 | + const q = new URLSearchParams({ key }); | |
| 117 | + if (restoreKeys.length) q.set("restore", restoreKeys.join(",")); | |
| 118 | + const res = await this.request(`/cache?${q}`, { allow: [404, 204] }); | |
| 119 | + if (res.status === 404 || res.status === 204) return null; | |
| 120 | + const j = (await res.json()) as { key?: string; url?: string } | null; | |
| 121 | + if (!j || !j.key) return null; | |
| 122 | + return { key: j.key, url: j.url ?? `${this.base}/cache/${encodeURIComponent(j.key)}` }; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** Downloads a URL (relative URLs resolve against the API; auth sent only to the API origin). */ | |
| 126 | + async download(url: string): Promise<Response> { | |
| 127 | + const abs = new URL(url, this.base ?? undefined).toString(); | |
| 128 | + const sameOrigin = this.base ? new URL(abs).origin === new URL(this.base).origin : false; | |
| 129 | + return this.requestUrl(abs, {}, sameOrigin); | |
| 130 | + } | |
| 131 | + | |
| 132 | + async putFile(path: string, file: string, contentType = "application/gzip"): Promise<Response> { | |
| 133 | + return this.request(path, { | |
| 134 | + method: "PUT", | |
| 135 | + body: async () => { | |
| 136 | + const s = await stat(file); | |
| 137 | + return { body: Readable.toWeb(createReadStream(file)) as unknown as NonNullable<RequestInit["body"]>, length: s.size, contentType }; | |
| 138 | + }, | |
| 139 | + allow: [409], | |
| 140 | + }); | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +function redactUrl(u: string): string { | |
| 145 | + return u.replace(/([?&](?:token|sig|signature)=)[^&]+/gi, "$1***"); | |
| 146 | +} | |
| 147 | + | |
| 148 | +/** | |
| 149 | + * Batches log lines and ships them every `intervalMs` or when `maxLines` | |
| 150 | + * accumulate. Lines are never dropped: failed batches are retried with | |
| 151 | + * exponential backoff and kept in order. | |
| 152 | + */ | |
| 153 | +export class LogBatcher { | |
| 154 | + private queue: LogLine[] = []; | |
| 155 | + private timer: NodeJS.Timeout | null = null; | |
| 156 | + private inflight: Promise<void> | null = null; | |
| 157 | + private failures = 0; | |
| 158 | + private nextAttemptAt = 0; | |
| 159 | + private closed = false; | |
| 160 | + | |
| 161 | + constructor( | |
| 162 | + private readonly sink: (lines: LogLine[]) => Promise<void>, | |
| 163 | + private readonly opts: { intervalMs?: number; maxLines?: number; maxBatch?: number; maxBackoffMs?: number; onError?: (e: unknown) => void } = {}, | |
| 164 | + ) { | |
| 165 | + this.timer = setInterval(() => void this.tick(), this.opts.intervalMs ?? 500); | |
| 166 | + this.timer.unref?.(); | |
| 167 | + } | |
| 168 | + | |
| 169 | + push(line: LogLine): void { | |
| 170 | + this.queue.push(line); | |
| 171 | + if (this.queue.length >= (this.opts.maxLines ?? 200)) void this.tick(); | |
| 172 | + } | |
| 173 | + | |
| 174 | + get pending(): number { | |
| 175 | + return this.queue.length; | |
| 176 | + } | |
| 177 | + | |
| 178 | + private tick(): Promise<void> { | |
| 179 | + if (this.inflight) return this.inflight; | |
| 180 | + if (this.queue.length === 0) return Promise.resolve(); | |
| 181 | + if (Date.now() < this.nextAttemptAt) return Promise.resolve(); | |
| 182 | + const batch = this.queue.slice(0, this.opts.maxBatch ?? 1000); | |
| 183 | + this.inflight = this.sink(batch) | |
| 184 | + .then(() => { | |
| 185 | + this.queue.splice(0, batch.length); | |
| 186 | + this.failures = 0; | |
| 187 | + this.nextAttemptAt = 0; | |
| 188 | + }) | |
| 189 | + .catch((e) => { | |
| 190 | + this.failures++; | |
| 191 | + this.nextAttemptAt = Date.now() + Math.min(this.opts.maxBackoffMs ?? 30000, 250 * 2 ** this.failures); | |
| 192 | + this.opts.onError?.(e); | |
| 193 | + }) | |
| 194 | + .finally(() => { | |
| 195 | + this.inflight = null; | |
| 196 | + }); | |
| 197 | + return this.inflight; | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** Flushes everything, retrying until `deadlineMs` elapses. */ | |
| 201 | + async flush(deadlineMs = 120000): Promise<boolean> { | |
| 202 | + const until = Date.now() + deadlineMs; | |
| 203 | + while (this.queue.length > 0 || this.inflight) { | |
| 204 | + if (Date.now() > until) return false; | |
| 205 | + if (this.inflight) { | |
| 206 | + await this.inflight; | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + const wait = this.nextAttemptAt - Date.now(); | |
| 210 | + if (wait > 0) await sleep(Math.min(wait, Math.max(0, until - Date.now()))); | |
| 211 | + await this.tick(); | |
| 212 | + } | |
| 213 | + return true; | |
| 214 | + } | |
| 215 | + | |
| 216 | + async close(deadlineMs?: number): Promise<boolean> { | |
| 217 | + if (this.closed) return true; | |
| 218 | + this.closed = true; | |
| 219 | + if (this.timer) clearInterval(this.timer); | |
| 220 | + return this.flush(deadlineMs); | |
| 221 | + } | |
| 222 | +} |
addedcontainers/runner/src/builtins/artifacts.ts+96−0View file
| @@ -1,0 +1,96 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { mkdir, rm, stat, writeFile } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { errMsg, failure, StepFailure } from "../job.js"; | |
| 5 | +import { globPaths, inputList, truthyInput } from "../util.js"; | |
| 6 | +import { downloadTo } from "./cache.js"; | |
| 7 | +import type { Builtin } from "./index.js"; | |
| 8 | + | |
| 9 | +function lca(dirs: string[]): string { | |
| 10 | + if (dirs.length === 0) return "/"; | |
| 11 | + let parts = dirs[0]!.split("/"); | |
| 12 | + for (const d of dirs.slice(1)) { | |
| 13 | + const p = d.split("/"); | |
| 14 | + let i = 0; | |
| 15 | + while (i < parts.length && i < p.length && parts[i] === p[i]) i++; | |
| 16 | + parts = parts.slice(0, i); | |
| 17 | + } | |
| 18 | + return parts.join("/") || "/"; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export const artifactUpload: Builtin = async (c) => { | |
| 22 | + const name = c.input("name", "artifact"); | |
| 23 | + if (/[":<>|*?\\/\r\n]/.test(name)) throw failure(`The artifact name is not valid: ${name}. Contains invalid characters.`); | |
| 24 | + const patterns = inputList(c.input("path")); | |
| 25 | + if (!patterns.length) throw failure("Input required and not supplied: path"); | |
| 26 | + const ifNone = c.input("if-no-files-found", "warn").toLowerCase(); | |
| 27 | + const includeHidden = truthyInput(c.input("include-hidden-files"), false); | |
| 28 | + let files = globPaths(c.job.workspace, patterns, { filesOnly: true }); | |
| 29 | + if (!includeHidden) files = files.filter((f) => !path.relative(c.job.workspace, f).split("/").some((s) => s.startsWith(".") && s !== "..")); | |
| 30 | + if (files.length === 0) { | |
| 31 | + const msg = `No files were found with the provided path: ${patterns.join(", ")}. No artifacts will be uploaded.`; | |
| 32 | + if (ifNone === "error") throw failure(msg); | |
| 33 | + if (ifNone === "warn") c.warn(msg); | |
| 34 | + else c.log(msg); | |
| 35 | + return; | |
| 36 | + } | |
| 37 | + if (!c.job.api.online) { | |
| 38 | + c.warn("Artifact service is not available in offline mode; skipping upload."); | |
| 39 | + return; | |
| 40 | + } | |
| 41 | + const root = lca(files.map((f) => path.dirname(f))); | |
| 42 | + const rel = files.map((f) => path.relative(root, f)); | |
| 43 | + c.log(`With the provided path, there will be ${files.length} file(s) uploaded (root: ${root})`); | |
| 44 | + const list = path.join(c.job.tempDir, `artifact-${randomUUID()}.list`); | |
| 45 | + const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); | |
| 46 | + try { | |
| 47 | + await writeFile(list, rel.join("\0") + "\0"); | |
| 48 | + await c.run("tar", ["-czf", archive, "-C", root, "--null", "-T", list], { silent: true }); | |
| 49 | + const size = (await stat(archive)).size; | |
| 50 | + const q = new URLSearchParams(); | |
| 51 | + const retention = c.input("retention-days"); | |
| 52 | + if (retention) q.set("retention-days", retention); | |
| 53 | + if (truthyInput(c.input("overwrite"), false)) q.set("overwrite", "true"); | |
| 54 | + const qs = q.toString(); | |
| 55 | + const res = await c.job.api.putFile(`/artifacts/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`, archive); | |
| 56 | + if (res.status === 409) throw failure(`An artifact with the name '${name}' already exists. Set 'overwrite: true' to replace it.`); | |
| 57 | + let body: { id?: string | number; url?: string } = {}; | |
| 58 | + try { | |
| 59 | + body = (await res.json()) as typeof body; | |
| 60 | + } catch { | |
| 61 | + /* no body */ | |
| 62 | + } | |
| 63 | + c.log(`Artifact '${name}' uploaded (${size} bytes compressed).`); | |
| 64 | + if (body.id !== undefined) c.setOutput("artifact-id", String(body.id)); | |
| 65 | + if (body.url) c.setOutput("artifact-url", body.url); | |
| 66 | + } catch (e) { | |
| 67 | + if (e instanceof StepFailure) throw e; | |
| 68 | + throw failure(`Failed to upload artifact '${name}': ${errMsg(e)}`); | |
| 69 | + } finally { | |
| 70 | + await rm(list, { force: true }); | |
| 71 | + await rm(archive, { force: true }); | |
| 72 | + } | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const artifactDownload: Builtin = async (c) => { | |
| 76 | + const name = c.input("name"); | |
| 77 | + if (!name) { | |
| 78 | + throw failure("download-artifact without `name` (download all / `pattern`) is not supported on Diffinery runners yet; specify `name`."); | |
| 79 | + } | |
| 80 | + if (!c.job.api.online) throw failure("Artifact service is not available in offline mode."); | |
| 81 | + const dest = path.resolve(c.job.workspace, c.input("path", ".")); | |
| 82 | + await mkdir(dest, { recursive: true }); | |
| 83 | + const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); | |
| 84 | + try { | |
| 85 | + try { | |
| 86 | + await downloadTo(c, c.job.api.url(`/artifacts/${encodeURIComponent(name)}`), archive); | |
| 87 | + } catch (e) { | |
| 88 | + throw failure(`Unable to download artifact '${name}': ${errMsg(e)}`); | |
| 89 | + } | |
| 90 | + await c.run("tar", ["-xzf", archive, "-C", dest], { silent: true }); | |
| 91 | + c.log(`Artifact '${name}' downloaded to ${dest}`); | |
| 92 | + c.setOutput("download-path", dest); | |
| 93 | + } finally { | |
| 94 | + await rm(archive, { force: true }); | |
| 95 | + } | |
| 96 | +}; |
addedcontainers/runner/src/builtins/cache.ts+130−0View file
| @@ -1,0 +1,130 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { createWriteStream } from "node:fs"; | |
| 3 | +import { rm, stat } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Readable } from "node:stream"; | |
| 6 | +import { pipeline } from "node:stream/promises"; | |
| 7 | +import type { ReadableStream as WebReadableStream } from "node:stream/web"; | |
| 8 | +import { errMsg, failure } from "../job.js"; | |
| 9 | +import { globPaths, inputList, truthyInput } from "../util.js"; | |
| 10 | +import type { Builtin, BuiltinContext } from "./index.js"; | |
| 11 | + | |
| 12 | +export async function downloadTo(c: BuiltinContext, url: string, file: string): Promise<void> { | |
| 13 | + const res = await c.job.api.download(url); | |
| 14 | + if (!res.body) throw new Error("empty response body"); | |
| 15 | + await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file)); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface RestoreResult { | |
| 19 | + matchedKey?: string; | |
| 20 | + exact: boolean; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Restores a cache entry; never throws for transport errors (warns instead). */ | |
| 24 | +export async function restoreCache(c: BuiltinContext, paths: string[], key: string, restoreKeys: string[], lookupOnly = false): Promise<RestoreResult> { | |
| 25 | + if (!key) throw failure("Input required and not supplied: key"); | |
| 26 | + if (paths.length === 0) throw failure("Input required and not supplied: path"); | |
| 27 | + if (!c.job.api.online) { | |
| 28 | + c.warn("Cache service is not available in offline mode; skipping restore."); | |
| 29 | + return { exact: false }; | |
| 30 | + } | |
| 31 | + let hit: { key: string; url: string } | null; | |
| 32 | + try { | |
| 33 | + hit = await c.job.api.cacheLookup(key, restoreKeys); | |
| 34 | + } catch (e) { | |
| 35 | + c.warn(`Failed to look up cache: ${errMsg(e)}`); | |
| 36 | + return { exact: false }; | |
| 37 | + } | |
| 38 | + if (!hit) { | |
| 39 | + c.log(`Cache not found for input keys: ${[key, ...restoreKeys].join(", ")}`); | |
| 40 | + return { exact: false }; | |
| 41 | + } | |
| 42 | + const exact = hit.key === key; | |
| 43 | + if (lookupOnly) { | |
| 44 | + c.log(`Cache found and can be restored from key: ${hit.key}`); | |
| 45 | + return { matchedKey: hit.key, exact }; | |
| 46 | + } | |
| 47 | + const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`); | |
| 48 | + try { | |
| 49 | + await downloadTo(c, hit.url, file); | |
| 50 | + const size = (await stat(file)).size; | |
| 51 | + c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`); | |
| 52 | + await c.run("tar", ["-xzPf", file], { silent: true }); | |
| 53 | + c.log(`Cache restored from key: ${hit.key}`); | |
| 54 | + return { matchedKey: hit.key, exact }; | |
| 55 | + } catch (e) { | |
| 56 | + c.warn(`Failed to restore cache: ${errMsg(e)}`); | |
| 57 | + return { exact: false }; | |
| 58 | + } finally { | |
| 59 | + await rm(file, { force: true }); | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Archives `paths` (tar.gz, absolute paths) and uploads it under `key`. */ | |
| 64 | +export async function saveCache(c: BuiltinContext, paths: string[], key: string): Promise<boolean> { | |
| 65 | + if (!c.job.api.online) { | |
| 66 | + c.warn("Cache service is not available in offline mode; skipping save."); | |
| 67 | + return false; | |
| 68 | + } | |
| 69 | + const resolved = globPaths(c.job.workspace, paths); | |
| 70 | + if (resolved.length === 0) { | |
| 71 | + c.warn("Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved."); | |
| 72 | + return false; | |
| 73 | + } | |
| 74 | + const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`); | |
| 75 | + try { | |
| 76 | + await c.run("tar", ["-czPf", file, "--", ...resolved], { silent: true }); | |
| 77 | + const size = (await stat(file)).size; | |
| 78 | + c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`); | |
| 79 | + const res = await c.job.api.putFile(`/cache/${encodeURIComponent(key)}`, file); | |
| 80 | + if (res.status === 409) { | |
| 81 | + c.log(`Cache entry with key ${key} already exists; not saving.`); | |
| 82 | + return false; | |
| 83 | + } | |
| 84 | + c.log(`Cache saved with key: ${key}`); | |
| 85 | + return true; | |
| 86 | + } catch (e) { | |
| 87 | + c.warn(`Failed to save cache: ${errMsg(e)}`); | |
| 88 | + return false; | |
| 89 | + } finally { | |
| 90 | + await rm(file, { force: true }); | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +export const cacheAction: Builtin = async (c) => { | |
| 95 | + const key = c.input("key"); | |
| 96 | + const paths = inputList(c.input("path")); | |
| 97 | + const restoreKeys = inputList(c.input("restore-keys")); | |
| 98 | + const r = await restoreCache(c, paths, key, restoreKeys, truthyInput(c.input("lookup-only"), false)); | |
| 99 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 100 | + c.setOutput("cache-primary-key", key); | |
| 101 | + c.setOutput("cache-matched-key", r.matchedKey ?? ""); | |
| 102 | + if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) { | |
| 103 | + throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`); | |
| 104 | + } | |
| 105 | + if (r.exact) return; | |
| 106 | + const saveAlways = truthyInput(c.input("save-always"), false); | |
| 107 | + c.registerPost(`Post ${c.name}`, saveAlways ? "always()" : "success()", async (pc) => { | |
| 108 | + await saveCache(pc, paths, key); | |
| 109 | + }); | |
| 110 | +}; | |
| 111 | + | |
| 112 | +export const cacheRestoreAction: Builtin = async (c) => { | |
| 113 | + const key = c.input("key"); | |
| 114 | + const paths = inputList(c.input("path")); | |
| 115 | + const r = await restoreCache(c, paths, key, inputList(c.input("restore-keys")), truthyInput(c.input("lookup-only"), false)); | |
| 116 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 117 | + c.setOutput("cache-primary-key", key); | |
| 118 | + c.setOutput("cache-matched-key", r.matchedKey ?? ""); | |
| 119 | + if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) { | |
| 120 | + throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`); | |
| 121 | + } | |
| 122 | +}; | |
| 123 | + | |
| 124 | +export const cacheSaveAction: Builtin = async (c) => { | |
| 125 | + const key = c.input("key"); | |
| 126 | + if (!key) throw failure("Input required and not supplied: key"); | |
| 127 | + const paths = inputList(c.input("path")); | |
| 128 | + if (!paths.length) throw failure("Input required and not supplied: path"); | |
| 129 | + await saveCache(c, paths, key); | |
| 130 | +}; |
addedcontainers/runner/src/builtins/checkout.ts+207−0View file
| @@ -1,0 +1,207 @@ | ||
| 1 | +import { existsSync } from "node:fs"; | |
| 2 | +import { mkdir, readdir, rm } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { failure } from "../job.js"; | |
| 5 | +import { truthyInput } from "../util.js"; | |
| 6 | +import type { Builtin, BuiltinContext } from "./index.js"; | |
| 7 | + | |
| 8 | +export function basicAuthHeader(token: string): string { | |
| 9 | + return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** Derives the clone URL of `owner/repo` from the job's own clone URL. */ | |
| 13 | +export function cloneUrlFor(spec: { repo: { owner: string; name: string; cloneUrl: string } }, repository: string): string { | |
| 14 | + const own = `${spec.repo.owner}/${spec.repo.name}`; | |
| 15 | + if (repository.toLowerCase() === own.toLowerCase()) return spec.repo.cloneUrl; | |
| 16 | + const u = spec.repo.cloneUrl; | |
| 17 | + const idx = u.toLowerCase().lastIndexOf(own.toLowerCase()); | |
| 18 | + if (idx >= 0) return u.slice(0, idx) + repository + u.slice(idx + own.length); | |
| 19 | + try { | |
| 20 | + const parsed = new URL(u); | |
| 21 | + return `${parsed.origin}/${repository}.git`; | |
| 22 | + } catch { | |
| 23 | + return u; | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +function isHttp(url: string): boolean { | |
| 28 | + return /^https?:\/\//i.test(url); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const checkout: Builtin = async (c) => { | |
| 32 | + const spec = c.job.spec; | |
| 33 | + const ownRepo = `${spec.repo.owner}/${spec.repo.name}`; | |
| 34 | + const repository = c.input("repository", ownRepo); | |
| 35 | + const isOwn = repository.toLowerCase() === ownRepo.toLowerCase(); | |
| 36 | + const token = c.input("token", spec.token); | |
| 37 | + const refInput = c.input("ref"); | |
| 38 | + const depthRaw = Number(c.input("fetch-depth", "1")); | |
| 39 | + const fetchDepth = Number.isFinite(depthRaw) && depthRaw >= 0 ? Math.floor(depthRaw) : 1; | |
| 40 | + const dest = path.resolve(c.job.workspace, c.input("path", ".")); | |
| 41 | + const clean = truthyInput(c.input("clean"), true); | |
| 42 | + const submodules = c.input("submodules", "false").toLowerCase(); | |
| 43 | + const persist = truthyInput(c.input("persist-credentials"), true); | |
| 44 | + const fetchTags = truthyInput(c.input("fetch-tags"), false); | |
| 45 | + if (truthyInput(c.input("lfs"), false)) c.warn("Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved."); | |
| 46 | + if (c.input("sparse-checkout")) c.warn("sparse-checkout is not supported by the Diffinery checkout built-in; doing a full checkout."); | |
| 47 | + | |
| 48 | + const url = cloneUrlFor(spec, repository); | |
| 49 | + const header = basicAuthHeader(token); | |
| 50 | + c.job.masker.add(token); | |
| 51 | + c.job.masker.add(header.slice("AUTHORIZATION: basic ".length)); | |
| 52 | + const authArgs = isHttp(url) && token ? ["-c", `http.extraheader=${header}`] : []; | |
| 53 | + const env = c.toolEnv({ GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" }); | |
| 54 | + const git = (args: string[], o: { silent?: boolean; allowFail?: boolean } = {}) => | |
| 55 | + o.allowFail ? c.exec("git", args, { cwd: dest, env, capture: true, silent: o.silent }) : c.run("git", args, { cwd: dest, env, silent: o.silent }); | |
| 56 | + | |
| 57 | + c.log(`Syncing repository: ${repository}`); | |
| 58 | + await mkdir(dest, { recursive: true }); | |
| 59 | + const hasGit = existsSync(path.join(dest, ".git")); | |
| 60 | + if (hasGit && clean) { | |
| 61 | + await git(["clean", "-ffdx"], { allowFail: true }); | |
| 62 | + await git(["reset", "--hard", "HEAD"], { allowFail: true, silent: true }); | |
| 63 | + } else if (!hasGit) { | |
| 64 | + const entries = await readdir(dest); | |
| 65 | + if (entries.length && clean) { | |
| 66 | + c.log(`Deleting the contents of '${dest}'`); | |
| 67 | + await Promise.all(entries.map((e) => rm(path.join(dest, e), { recursive: true, force: true }))); | |
| 68 | + } | |
| 69 | + } | |
| 70 | + if (!hasGit) await git(["init", "-q", dest]); | |
| 71 | + const setUrl = await git(["remote", "set-url", "origin", url], { allowFail: true, silent: true }); | |
| 72 | + if (setUrl.exitCode !== 0) await git(["remote", "add", "origin", url]); | |
| 73 | + await git(["config", "--local", "gc.auto", "0"]); | |
| 74 | + | |
| 75 | + // Decide what to fetch | |
| 76 | + let sha: string | undefined; | |
| 77 | + let ref: string | undefined; | |
| 78 | + if (refInput) { | |
| 79 | + if (/^[0-9a-f]{40}$/i.test(refInput)) sha = refInput; | |
| 80 | + else ref = refInput; | |
| 81 | + } else if (isOwn) { | |
| 82 | + sha = spec.sha; | |
| 83 | + ref = spec.ref; | |
| 84 | + } else { | |
| 85 | + ref = "HEAD"; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const depthArgs = fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []; | |
| 89 | + const tagArgs = fetchDepth === 0 || fetchTags ? [] : ["--no-tags"]; | |
| 90 | + const fetch = (refspecs: string[], allowFail = false) => | |
| 91 | + git([...authArgs, "fetch", ...tagArgs, "--prune", "--no-recurse-submodules", ...depthArgs, "origin", ...refspecs], { allowFail }); | |
| 92 | + | |
| 93 | + let checkoutTarget: string | undefined; | |
| 94 | + let branch: string | undefined; | |
| 95 | + if (ref?.startsWith("refs/heads/")) branch = ref.slice("refs/heads/".length); | |
| 96 | + | |
| 97 | + if (fetchDepth === 0) { | |
| 98 | + c.log("Fetching the repository (full history)"); | |
| 99 | + await fetch(["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"]); | |
| 100 | + if (ref && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/") && ref !== "HEAD") { | |
| 101 | + if (ref.startsWith("refs/")) await fetch([`+${ref}:${ref.replace(/^refs\//, "refs/remotes/")}`], true); | |
| 102 | + } | |
| 103 | + checkoutTarget = sha ?? (await resolveRef(git, ref!, (b) => (branch = b))); | |
| 104 | + } else { | |
| 105 | + c.log(`Fetching the repository (depth ${fetchDepth})`); | |
| 106 | + let fetched = false; | |
| 107 | + if (sha) { | |
| 108 | + const r = await fetch([`+${sha}:refs/remotes/origin/${branch ?? "diffinery-checkout"}`], true); | |
| 109 | + fetched = r.exitCode === 0; | |
| 110 | + if (!fetched) c.log("Fetching by commit SHA failed; falling back to fetching the ref."); | |
| 111 | + } | |
| 112 | + if (!fetched && ref) { | |
| 113 | + const specs = refspecsFor(ref); | |
| 114 | + let ok = false; | |
| 115 | + for (const rs of specs) { | |
| 116 | + const r = await fetch([rs.refspec], true); | |
| 117 | + if (r.exitCode === 0) { | |
| 118 | + ok = true; | |
| 119 | + if (rs.branch) branch = rs.branch; | |
| 120 | + break; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + if (!ok) throw failure(`Failed to fetch ref '${ref}' from ${repository}. Check that the ref exists and the token has read access.`); | |
| 124 | + if (!sha) { | |
| 125 | + const target = specs.find((s) => s.branch === branch)?.local ?? "FETCH_HEAD"; | |
| 126 | + checkoutTarget = target; | |
| 127 | + } | |
| 128 | + } else if (!fetched) { | |
| 129 | + throw failure(`Failed to fetch commit ${sha} from ${repository}.`); | |
| 130 | + } | |
| 131 | + checkoutTarget ??= sha ?? "FETCH_HEAD"; | |
| 132 | + } | |
| 133 | + | |
| 134 | + if (branch) await git(["checkout", "--progress", "--force", "-B", branch, checkoutTarget!]); | |
| 135 | + else await git(["checkout", "--progress", "--force", checkoutTarget!]); | |
| 136 | + const head = (await git(["rev-parse", "HEAD"], { silent: true })).stdout.trim(); | |
| 137 | + if (sha && /^[0-9a-f]{40}$/i.test(sha) && head.toLowerCase() !== sha.toLowerCase()) { | |
| 138 | + throw failure(`Checked out ${head} but expected ${sha}.`); | |
| 139 | + } | |
| 140 | + c.log(`HEAD is now at ${head}`); | |
| 141 | + c.setOutput("commit", head); | |
| 142 | + c.setOutput("ref", ref && ref !== "HEAD" ? ref : branch ? `refs/heads/${branch}` : ""); | |
| 143 | + | |
| 144 | + if (submodules === "true" || submodules === "recursive") { | |
| 145 | + c.log("Fetching submodules"); | |
| 146 | + await git(["submodule", "sync", ...(submodules === "recursive" ? ["--recursive"] : [])], { allowFail: true }); | |
| 147 | + const r = await git( | |
| 148 | + [...authArgs, "submodule", "update", "--init", "--force", ...(fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []), ...(submodules === "recursive" ? ["--recursive"] : [])], | |
| 149 | + { allowFail: true }, | |
| 150 | + ); | |
| 151 | + if (r.exitCode !== 0) c.warn("Failed to update submodules (best effort)."); | |
| 152 | + } | |
| 153 | + | |
| 154 | + if (persist && authArgs.length) { | |
| 155 | + const key = `http.${new URL(url).origin}/.extraheader`; | |
| 156 | + await git(["config", "--local", key, header], { silent: true }); | |
| 157 | + c.registerPost(`Post ${c.name}`, "always()", async (pc: BuiltinContext) => { | |
| 158 | + if (!existsSync(path.join(dest, ".git"))) return; | |
| 159 | + pc.log("Removing persisted credentials"); | |
| 160 | + await pc.exec("git", ["config", "--local", "--unset-all", key], { cwd: dest, capture: true, silent: true }); | |
| 161 | + }); | |
| 162 | + } | |
| 163 | +}; | |
| 164 | + | |
| 165 | +function refspecsFor(ref: string): Array<{ refspec: string; local: string; branch?: string }> { | |
| 166 | + if (ref === "HEAD") return [{ refspec: "HEAD", local: "FETCH_HEAD" }]; | |
| 167 | + if (ref.startsWith("refs/heads/")) { | |
| 168 | + const b = ref.slice(11); | |
| 169 | + return [{ refspec: `+${ref}:refs/remotes/origin/${b}`, local: `refs/remotes/origin/${b}`, branch: b }]; | |
| 170 | + } | |
| 171 | + if (ref.startsWith("refs/tags/")) return [{ refspec: `+${ref}:${ref}`, local: ref }]; | |
| 172 | + if (ref.startsWith("refs/")) { | |
| 173 | + const local = ref.replace(/^refs\//, "refs/remotes/"); | |
| 174 | + return [{ refspec: `+${ref}:${local}`, local }]; | |
| 175 | + } | |
| 176 | + return [ | |
| 177 | + { refspec: `+refs/heads/${ref}:refs/remotes/origin/${ref}`, local: `refs/remotes/origin/${ref}`, branch: ref }, | |
| 178 | + { refspec: `+refs/tags/${ref}:refs/tags/${ref}`, local: `refs/tags/${ref}` }, | |
| 179 | + ]; | |
| 180 | +} | |
| 181 | + | |
| 182 | +async function resolveRef( | |
| 183 | + git: (args: string[], o?: { silent?: boolean; allowFail?: boolean }) => Promise<{ exitCode: number; stdout: string }>, | |
| 184 | + ref: string, | |
| 185 | + setBranch: (b: string) => void, | |
| 186 | +): Promise<string> { | |
| 187 | + if (ref === "HEAD") { | |
| 188 | + const r = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFail: true, silent: true }); | |
| 189 | + if (r.exitCode === 0) return r.stdout.trim(); | |
| 190 | + } | |
| 191 | + const short = ref.replace(/^refs\/heads\//, ""); | |
| 192 | + const candidates = ref.startsWith("refs/tags/") | |
| 193 | + ? [ref] | |
| 194 | + : ref.startsWith("refs/heads/") | |
| 195 | + ? [`refs/remotes/origin/${short}`] | |
| 196 | + : ref.startsWith("refs/") | |
| 197 | + ? [ref.replace(/^refs\//, "refs/remotes/")] | |
| 198 | + : [`refs/remotes/origin/${ref}`, `refs/tags/${ref}`]; | |
| 199 | + for (const cand of candidates) { | |
| 200 | + const r = await git(["rev-parse", "--verify", "--quiet", cand], { allowFail: true, silent: true }); | |
| 201 | + if (r.exitCode === 0) { | |
| 202 | + if (cand.startsWith("refs/remotes/origin/")) setBranch(cand.slice("refs/remotes/origin/".length)); | |
| 203 | + return cand; | |
| 204 | + } | |
| 205 | + } | |
| 206 | + throw failure(`A branch or tag with the name '${ref}' could not be found`); | |
| 207 | +} |
addedcontainers/runner/src/builtins/index.ts+114−0View file
| @@ -1,0 +1,114 @@ | ||
| 1 | +import { failure, type JobRunner, type Scope, type StepIO } from "../job.js"; | |
| 2 | +import type { StepExec } from "../steps/run.js"; | |
| 3 | +import type { ExecResult } from "../util.js"; | |
| 4 | +import { artifactDownload, artifactUpload } from "./artifacts.js"; | |
| 5 | +import { cacheAction, cacheRestoreAction, cacheSaveAction } from "./cache.js"; | |
| 6 | +import { checkout } from "./checkout.js"; | |
| 7 | +import { setupGo } from "./setup-go.js"; | |
| 8 | +import { setupNode } from "./setup-node.js"; | |
| 9 | +import { setupPython } from "./setup-python.js"; | |
| 10 | + | |
| 11 | +export interface BuiltinContext { | |
| 12 | + job: JobRunner; | |
| 13 | + io: StepIO; | |
| 14 | + scope: Scope; | |
| 15 | + ctx: Record<string, unknown>; | |
| 16 | + envCtx: Record<string, string>; | |
| 17 | + uses: string; | |
| 18 | + name: string; | |
| 19 | + /** Raw `with:` values (already interpolated); lookups are case-insensitive. */ | |
| 20 | + inputs: Record<string, string>; | |
| 21 | + input(name: string, dflt?: string): string; | |
| 22 | + log(msg: string): void; | |
| 23 | + warn(msg: string): void; | |
| 24 | + setOutput(name: string, value: string): void; | |
| 25 | + exportVariable(name: string, value: string): void; | |
| 26 | + addPath(dir: string): void; | |
| 27 | + /** Env for child tools (job env + GITHUB_ENV + PATH additions). */ | |
| 28 | + toolEnv(extra?: Record<string, string>): NodeJS.ProcessEnv; | |
| 29 | + exec(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>; | |
| 30 | + /** Like exec but throws on non-zero exit. */ | |
| 31 | + run(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>; | |
| 32 | + registerPost(name: string, condition: string, fn: (c: BuiltinContext) => Promise<void>): void; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export type Builtin = (c: BuiltinContext) => Promise<void>; | |
| 36 | + | |
| 37 | +const githubScript: Builtin = async () => { | |
| 38 | + throw failure("actions/github-script is not supported on Diffinery runners yet. Use a `run:` step with `node` or `curl` against $GITHUB_API_URL instead."); | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const BUILTINS: Record<string, Builtin> = { | |
| 42 | + "actions/checkout": checkout, | |
| 43 | + "actions/cache": cacheAction, | |
| 44 | + "actions/cache/restore": cacheRestoreAction, | |
| 45 | + "actions/cache/save": cacheSaveAction, | |
| 46 | + "actions/upload-artifact": artifactUpload, | |
| 47 | + "actions/download-artifact": artifactDownload, | |
| 48 | + "actions/setup-node": setupNode, | |
| 49 | + "actions/setup-python": setupPython, | |
| 50 | + "actions/setup-go": setupGo, | |
| 51 | + "actions/github-script": githubScript, | |
| 52 | +}; | |
| 53 | + | |
| 54 | +export function getBuiltin(name: string, _ref: string): Builtin | null { | |
| 55 | + if (process.env.DIFFINERY_DISABLE_BUILTINS === "1") return null; | |
| 56 | + return BUILTINS[name.toLowerCase()] ?? null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function makeBuiltinContext(job: JobRunner, x: StepExec, inputs: Record<string, string>, uses: string): BuiltinContext { | |
| 60 | + const lower = new Map(Object.entries(inputs).map(([k, v]) => [k.toLowerCase(), v])); | |
| 61 | + const make = (io: StepIO, envCtx: Record<string, string>, name: string): BuiltinContext => { | |
| 62 | + const toolEnv = (extra: Record<string, string> = {}) => job.processEnv({ ...envCtx, ...job.envAccum }, extra); | |
| 63 | + const c: BuiltinContext = { | |
| 64 | + job, | |
| 65 | + io, | |
| 66 | + scope: x.scope, | |
| 67 | + ctx: x.ctx, | |
| 68 | + envCtx, | |
| 69 | + uses, | |
| 70 | + name, | |
| 71 | + inputs, | |
| 72 | + input: (n, d = "") => { | |
| 73 | + const v = lower.get(n.toLowerCase()); | |
| 74 | + return v === undefined || v === "" ? d : v; | |
| 75 | + }, | |
| 76 | + log: (m) => job.logger.line(io.logIndex, m), | |
| 77 | + warn: (m) => { | |
| 78 | + job.logger.line(io.logIndex, `##[warning]${m}`); | |
| 79 | + job.addAnnotation(io.logIndex, { level: "warning", message: m }); | |
| 80 | + }, | |
| 81 | + setOutput: (k, v) => { | |
| 82 | + io.outputs[k] = v; | |
| 83 | + }, | |
| 84 | + exportVariable: (k, v) => { | |
| 85 | + job.envAccum[k] = v; | |
| 86 | + envCtx[k] = v; | |
| 87 | + }, | |
| 88 | + addPath: (d) => job.addPath(d), | |
| 89 | + toolEnv, | |
| 90 | + exec: (cmd, args, o = {}) => job.tool(cmd, args, { io, cwd: o.cwd, env: o.env ?? toolEnv(), capture: o.capture, silent: o.silent, input: o.input }), | |
| 91 | + run: async (cmd, args, o = {}) => { | |
| 92 | + const r = await c.exec(cmd, args, { ...o, capture: o.capture ?? true }); | |
| 93 | + if (job.isStopping) throw failure("cancelled"); | |
| 94 | + if (r.exitCode !== 0) { | |
| 95 | + const detail = o.silent ? ` ${r.stderr.trim().split("\n").slice(-5).join("\n")}` : ""; | |
| 96 | + throw failure(`The process '${cmd}' failed with exit code ${r.exitCode}${detail ? ":" + detail : ""}`); | |
| 97 | + } | |
| 98 | + return r; | |
| 99 | + }, | |
| 100 | + registerPost: (pname, condition, fn) => { | |
| 101 | + job.registerPost({ | |
| 102 | + name: pname, | |
| 103 | + condition, | |
| 104 | + run: async (logIndex) => { | |
| 105 | + const pio: StepIO = { logIndex, outputs: {}, state: {} }; | |
| 106 | + await fn(make(pio, { ...envCtx, ...job.envAccum }, pname)); | |
| 107 | + }, | |
| 108 | + }); | |
| 109 | + }, | |
| 110 | + }; | |
| 111 | + return c; | |
| 112 | + }; | |
| 113 | + return make(x.io, x.envCtx, x.name); | |
| 114 | +} |
addedcontainers/runner/src/builtins/setup-go.ts+73−0View file
| @@ -1,0 +1,73 @@ | ||
| 1 | +import { existsSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { errMsg, failure } from "../job.js"; | |
| 4 | +import { maxSatisfying, satisfies, truthyInput } from "../util.js"; | |
| 5 | +import type { Builtin } from "./index.js"; | |
| 6 | +import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js"; | |
| 7 | + | |
| 8 | +interface GoRelease { | |
| 9 | + version: string; // "go1.22.3" | |
| 10 | + stable: boolean; | |
| 11 | + files: Array<{ filename: string; os: string; arch: string; kind: string }>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export const setupGo: Builtin = async (c) => { | |
| 15 | + let spec = c.input("go-version"); | |
| 16 | + const file = c.input("go-version-file"); | |
| 17 | + if (!spec && file) spec = await readVersionFile(c, file, "go"); | |
| 18 | + spec = spec.trim().replace(/^go/, ""); | |
| 19 | + const installedRaw = await probe(c, "go", ["version"]); | |
| 20 | + const installed = /go(\d+\.\d+(?:\.\d+)?)/.exec(installedRaw ?? "")?.[1] ?? null; | |
| 21 | + let version = installed; | |
| 22 | + | |
| 23 | + if (!spec && !installed) throw failure("No go-version specified and Go is not installed on this runner."); | |
| 24 | + const useInstalled = installed && (!spec || (!/^(stable|oldstable)$/.test(spec) && satisfies(installed, spec))) && !truthyInput(c.input("check-latest"), false); | |
| 25 | + if (useInstalled) { | |
| 26 | + c.log(`Using pre-installed Go ${installed}`); | |
| 27 | + } else { | |
| 28 | + let releases: GoRelease[]; | |
| 29 | + try { | |
| 30 | + releases = await fetchJson<GoRelease[]>(process.env.DIFFINERY_GO_INDEX ?? "https://go.dev/dl/?mode=json&include=all"); | |
| 31 | + } catch (e) { | |
| 32 | + throw failure(`Unable to fetch the Go versions index: ${errMsg(e)}`); | |
| 33 | + } | |
| 34 | + const arch = toolArch("go"); | |
| 35 | + const stable = releases.filter((r) => r.stable && r.files.some((f) => f.os === "linux" && f.arch === arch && f.kind === "archive")); | |
| 36 | + let v: string | null; | |
| 37 | + if (spec === "stable") v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), "*"); | |
| 38 | + else if (spec === "oldstable") { | |
| 39 | + const sorted = stable.map((r) => r.version.replace(/^go/, "")); | |
| 40 | + const latest = maxSatisfying(sorted, "*"); | |
| 41 | + const latestMinor = latest ? latest.split(".").slice(0, 2).join(".") : ""; | |
| 42 | + v = maxSatisfying(sorted.filter((x) => !x.startsWith(latestMinor + ".") && x !== latestMinor), "*"); | |
| 43 | + } else v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), spec); | |
| 44 | + if (!v) throw failure(`Unable to find Go version '${spec}' for platform linux and architecture ${arch}.`); | |
| 45 | + const rel = stable.find((r) => r.version === `go${v}`)!; | |
| 46 | + const f = rel.files.find((x) => x.os === "linux" && x.arch === arch && x.kind === "archive")!; | |
| 47 | + const dir = toolDir(c, "go", v, arch); | |
| 48 | + if (!isCached(dir)) await installTarball(c, `https://go.dev/dl/${f.filename}`, dir); | |
| 49 | + else c.log(`Found in cache @ ${dir}`); | |
| 50 | + c.addPath(path.join(dir, "bin")); | |
| 51 | + version = v; | |
| 52 | + } | |
| 53 | + const gopath = await probe(c, "go", ["env", "GOPATH"], c.toolEnv()); | |
| 54 | + if (gopath) c.addPath(path.join(gopath, "bin")); | |
| 55 | + if (version) c.setOutput("go-version", version); | |
| 56 | + | |
| 57 | + if (truthyInput(c.input("cache"), true)) { | |
| 58 | + const deps = c.input("cache-dependency-path"); | |
| 59 | + if (!deps && !existsSync(path.join(c.job.workspace, "go.sum"))) { | |
| 60 | + c.log("go.sum not found; skipping dependency cache."); | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + const env = c.toolEnv(); | |
| 64 | + const modcache = await probe(c, "go", ["env", "GOMODCACHE"], env); | |
| 65 | + const buildcache = await probe(c, "go", ["env", "GOCACHE"], env); | |
| 66 | + await packageCache(c, { | |
| 67 | + prefix: `setup-go-Linux-${toolArch("go")}-go-${version}`, | |
| 68 | + paths: [modcache ?? "", buildcache ?? ""], | |
| 69 | + lockPatterns: ["**/go.sum"], | |
| 70 | + what: "go", | |
| 71 | + }); | |
| 72 | + } | |
| 73 | +}; |
addedcontainers/runner/src/builtins/setup-node.ts+125−0View file
| @@ -1,0 +1,125 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { errMsg, failure } from "../job.js"; | |
| 3 | +import { maxSatisfying, parseVer, satisfies, truthyInput } from "../util.js"; | |
| 4 | +import type { Builtin } from "./index.js"; | |
| 5 | +import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js"; | |
| 6 | + | |
| 7 | +interface NodeDistEntry { | |
| 8 | + version: string; // "v22.11.0" | |
| 9 | + lts: string | false; | |
| 10 | + files: string[]; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function nodeMirror(): string { | |
| 14 | + return (process.env.DIFFINERY_NODE_MIRROR ?? "https://nodejs.org/dist").replace(/\/$/, ""); | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Resolves a setup-node version spec against nodejs.org's index. */ | |
| 18 | +export function resolveNodeVersion(index: NodeDistEntry[], spec: string): string | null { | |
| 19 | + const s = spec.trim().toLowerCase(); | |
| 20 | + const arch = `linux-${toolArch("node")}`; | |
| 21 | + const avail = index.filter((e) => e.files.includes(arch) || e.files.length === 0); | |
| 22 | + if (s.startsWith("lts/")) { | |
| 23 | + const code = s.slice(4); | |
| 24 | + const lts = avail.filter((e) => e.lts && (code === "*" || String(e.lts).toLowerCase() === code)); | |
| 25 | + if (code.startsWith("-")) { | |
| 26 | + // lts/-1: previous LTS line | |
| 27 | + const n = Number(code.slice(1)); | |
| 28 | + const majors = [...new Set(avail.filter((e) => e.lts).map((e) => parseVer(e.version)![0]))].sort((a, b) => b - a); | |
| 29 | + const major = majors[n]; | |
| 30 | + return major === undefined ? null : maxSatisfying(avail.filter((e) => e.lts).map((e) => e.version), String(major))?.replace(/^v/, "") ?? null; | |
| 31 | + } | |
| 32 | + return maxSatisfying(lts.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; | |
| 33 | + } | |
| 34 | + if (s === "latest" || s === "current" || s === "node") return maxSatisfying(avail.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; | |
| 35 | + return maxSatisfying(avail.map((e) => e.version), s.replace(/^v/, ""))?.replace(/^v/, "") ?? null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const setupNode: Builtin = async (c) => { | |
| 39 | + let spec = c.input("node-version"); | |
| 40 | + const file = c.input("node-version-file"); | |
| 41 | + if (!spec && file) spec = await readVersionFile(c, file, "node"); | |
| 42 | + const installed = (await probe(c, "node", ["--version"]))?.replace(/^v/, "") ?? null; | |
| 43 | + let version = installed; | |
| 44 | + | |
| 45 | + if (spec) { | |
| 46 | + const s = spec.trim().replace(/^v(?=\d)/, ""); | |
| 47 | + let ok = false; | |
| 48 | + if (installed && !truthyInput(c.input("check-latest"), false)) { | |
| 49 | + if (/^(lts\/|latest|current|node$)/i.test(s)) { | |
| 50 | + try { | |
| 51 | + const target = resolveNodeVersion(await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`), s); | |
| 52 | + ok = !!target && parseVer(target)![0] === parseVer(installed)![0]; | |
| 53 | + } catch (e) { | |
| 54 | + c.warn(`Could not resolve '${s}' (${errMsg(e)}); using installed node ${installed}.`); | |
| 55 | + ok = true; | |
| 56 | + } | |
| 57 | + } else { | |
| 58 | + const want = parseVer(s); | |
| 59 | + ok = satisfies(installed, s) || (!!want && /^\d/.test(s) && want[0] === parseVer(installed)![0]); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + if (ok) { | |
| 63 | + c.log(`Found in cache @ ${installed} (pre-installed node satisfies '${s}')`); | |
| 64 | + } else { | |
| 65 | + let index: NodeDistEntry[]; | |
| 66 | + try { | |
| 67 | + index = await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`); | |
| 68 | + } catch (e) { | |
| 69 | + throw failure(`Unable to fetch the Node.js version index: ${errMsg(e)}`); | |
| 70 | + } | |
| 71 | + const v = resolveNodeVersion(index, s); | |
| 72 | + if (!v) throw failure(`Unable to find Node version '${s}' for platform linux and architecture ${toolArch("node")}.`); | |
| 73 | + const dir = toolDir(c, "node", v, toolArch("node")); | |
| 74 | + if (!isCached(dir)) { | |
| 75 | + await installTarball(c, `${nodeMirror()}/v${v}/node-v${v}-linux-${toolArch("node")}.tar.gz`, dir); | |
| 76 | + } else c.log(`Found in cache @ ${dir}`); | |
| 77 | + c.addPath(path.join(dir, "bin")); | |
| 78 | + version = v; | |
| 79 | + } | |
| 80 | + } else { | |
| 81 | + c.log(`No node-version specified; using the pre-installed node ${installed ?? "(none)"}.`); | |
| 82 | + } | |
| 83 | + if (version) c.setOutput("node-version", `v${version}`); | |
| 84 | + | |
| 85 | + const registry = c.input("registry-url"); | |
| 86 | + if (registry) { | |
| 87 | + // Minimal setup-node `registry-url` support: write an .npmrc that reads NODE_AUTH_TOKEN. | |
| 88 | + const { writeFile, mkdir } = await import("node:fs/promises"); | |
| 89 | + const npmrc = path.join(c.job.tempDir, ".npmrc"); | |
| 90 | + await mkdir(c.job.tempDir, { recursive: true }); | |
| 91 | + const url = registry.endsWith("/") ? registry : `${registry}/`; | |
| 92 | + const scope = c.input("scope"); | |
| 93 | + const authLine = `${url.replace(/^\w+:/, "")}:_authToken=\${NODE_AUTH_TOKEN}`; | |
| 94 | + const regLine = scope ? `${scope.startsWith("@") ? scope : "@" + scope}:registry=${url}` : `registry=${url}`; | |
| 95 | + await writeFile(npmrc, `${authLine}\n${regLine}\n`); | |
| 96 | + c.exportVariable("NPM_CONFIG_USERCONFIG", npmrc); | |
| 97 | + c.exportVariable("NODE_AUTH_TOKEN", "XXXXX-XXXXX-XXXXX-XXXXX"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + const pm = c.input("cache").toLowerCase(); | |
| 101 | + if (pm) { | |
| 102 | + const env = c.toolEnv(); | |
| 103 | + let dir: string | null = null; | |
| 104 | + let locks: string[]; | |
| 105 | + if (pm === "npm") { | |
| 106 | + dir = await probe(c, "npm", ["config", "get", "cache"], env); | |
| 107 | + locks = ["**/package-lock.json", "**/npm-shrinkwrap.json"]; | |
| 108 | + } else if (pm === "pnpm") { | |
| 109 | + dir = await probe(c, "pnpm", ["store", "path", "--silent"], env); | |
| 110 | + locks = ["**/pnpm-lock.yaml"]; | |
| 111 | + } else if (pm === "yarn") { | |
| 112 | + const ver = await probe(c, "yarn", ["--version"], env); | |
| 113 | + dir = ver && parseVer(ver)![0] >= 2 ? await probe(c, "yarn", ["config", "get", "cacheFolder"], env) : await probe(c, "yarn", ["cache", "dir"], env); | |
| 114 | + locks = ["**/yarn.lock"]; | |
| 115 | + } else { | |
| 116 | + throw failure(`Caching for '${pm}' is not supported`); | |
| 117 | + } | |
| 118 | + await packageCache(c, { | |
| 119 | + prefix: `node-cache-Linux-${toolArch("node")}-${pm}`, | |
| 120 | + paths: dir ? [dir] : [], | |
| 121 | + lockPatterns: locks, | |
| 122 | + what: pm, | |
| 123 | + }); | |
| 124 | + } | |
| 125 | +}; |
addedcontainers/runner/src/builtins/setup-python.ts+101−0View file
| @@ -1,0 +1,101 @@ | ||
| 1 | +import { existsSync, realpathSync } from "node:fs"; | |
| 2 | +import { mkdir, rm, symlink } from "node:fs/promises"; | |
| 3 | +import { randomUUID } from "node:crypto"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { errMsg, failure } from "../job.js"; | |
| 6 | +import { maxSatisfying, satisfies, which } from "../util.js"; | |
| 7 | +import type { Builtin } from "./index.js"; | |
| 8 | +import { fetchJson, fetchToFile, packageCache, probe, readVersionFile, toolArch } from "./tools.js"; | |
| 9 | + | |
| 10 | +interface PyManifestEntry { | |
| 11 | + version: string; | |
| 12 | + stable: boolean; | |
| 13 | + files: Array<{ filename: string; arch: string; platform: string; platform_version?: string; download_url: string }>; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const MANIFEST = "https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json"; | |
| 17 | + | |
| 18 | +export const setupPython: Builtin = async (c) => { | |
| 19 | + let spec = c.input("python-version"); | |
| 20 | + const file = c.input("python-version-file"); | |
| 21 | + if (!spec && file) spec = await readVersionFile(c, file, "python"); | |
| 22 | + if (!spec && existsSync(path.join(c.job.workspace, ".python-version"))) spec = await readVersionFile(c, ".python-version", "python"); | |
| 23 | + spec = spec.trim(); | |
| 24 | + if (/^pypy|graalpy/i.test(spec)) throw failure(`'${spec}' is not supported by the Diffinery setup-python built-in.`); | |
| 25 | + const env = c.toolEnv(); | |
| 26 | + const installedRaw = await probe(c, "python3", ["--version"], env); | |
| 27 | + const installed = installedRaw?.replace(/^Python\s+/, "") ?? null; | |
| 28 | + | |
| 29 | + let binDir: string; | |
| 30 | + let version: string; | |
| 31 | + const want = spec.replace(/-dev$/, "").replace(/\.x$/, ""); | |
| 32 | + if (installed && (!spec || satisfies(installed, want))) { | |
| 33 | + c.log(`Using pre-installed Python ${installed}`); | |
| 34 | + const py3 = which("python3", env.PATH)!; | |
| 35 | + binDir = path.dirname(realpathSync(py3)); | |
| 36 | + version = installed; | |
| 37 | + // make sure `python`/`pip` resolve to python3 | |
| 38 | + if (!which("python", env.PATH) || !which("pip", env.PATH)) { | |
| 39 | + const shim = path.join(c.job.tempDir, "_python_shim"); | |
| 40 | + await mkdir(shim, { recursive: true }); | |
| 41 | + for (const [n, t] of [["python", "python3"], ["pip", "pip3"]] as const) { | |
| 42 | + const target = which(t, env.PATH); | |
| 43 | + const link = path.join(shim, n); | |
| 44 | + if (target && !existsSync(link)) await symlink(target, link).catch(() => {}); | |
| 45 | + } | |
| 46 | + c.addPath(shim); | |
| 47 | + } | |
| 48 | + } else { | |
| 49 | + let manifest: PyManifestEntry[]; | |
| 50 | + try { | |
| 51 | + manifest = await fetchJson<PyManifestEntry[]>(process.env.DIFFINERY_PYTHON_MANIFEST ?? MANIFEST); | |
| 52 | + } catch (e) { | |
| 53 | + throw failure(`Unable to fetch the Python versions manifest: ${errMsg(e)}`); | |
| 54 | + } | |
| 55 | + const arch = toolArch("python"); | |
| 56 | + const candidates = manifest.filter((m) => m.stable && m.files.some((f) => f.platform === "linux" && f.arch === arch)); | |
| 57 | + const v = maxSatisfying(candidates.map((m) => m.version), want); | |
| 58 | + if (!v) throw failure(`The version '${spec}' with architecture '${arch}' was not found for this operating system.`); | |
| 59 | + const entry = candidates.find((m) => m.version === v)!; | |
| 60 | + const files = entry.files.filter((f) => f.platform === "linux" && f.arch === arch); | |
| 61 | + const f = files.find((x) => x.platform_version === "24.04") ?? files.find((x) => x.platform_version === "22.04") ?? files[0]!; | |
| 62 | + const installDir = path.join(c.job.toolCache, "Python", v, arch); | |
| 63 | + if (!existsSync(`${installDir}.complete`)) { | |
| 64 | + const work = path.join(c.job.tempDir, `python-${randomUUID()}`); | |
| 65 | + await mkdir(work, { recursive: true }); | |
| 66 | + try { | |
| 67 | + c.log(`Downloading ${f.download_url}`); | |
| 68 | + const tarball = path.join(work, "python.tar.gz"); | |
| 69 | + await fetchToFile(f.download_url, tarball); | |
| 70 | + await c.run("tar", ["-xzf", tarball, "-C", work], { silent: true }); | |
| 71 | + c.log("Running python installer (setup.sh)"); | |
| 72 | + await c.run("bash", ["./setup.sh"], { cwd: work, env: c.toolEnv({ RUNNER_TOOL_CACHE: c.job.toolCache, AGENT_TOOLSDIRECTORY: c.job.toolCache }) }); | |
| 73 | + } finally { | |
| 74 | + await rm(work, { recursive: true, force: true }); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + binDir = path.join(installDir, "bin"); | |
| 78 | + version = v; | |
| 79 | + c.addPath(installDir); | |
| 80 | + c.addPath(binDir); | |
| 81 | + c.exportVariable("pythonLocation", installDir); | |
| 82 | + c.exportVariable("Python_ROOT_DIR", installDir); | |
| 83 | + c.exportVariable("Python3_ROOT_DIR", installDir); | |
| 84 | + c.exportVariable("PKG_CONFIG_PATH", path.join(installDir, "lib", "pkgconfig")); | |
| 85 | + c.exportVariable("LD_LIBRARY_PATH", [path.join(installDir, "lib"), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":")); | |
| 86 | + } | |
| 87 | + c.setOutput("python-version", version); | |
| 88 | + c.setOutput("python-path", path.join(binDir, existsSync(path.join(binDir, "python")) ? "python" : "python3")); | |
| 89 | + | |
| 90 | + const cache = c.input("cache").toLowerCase(); | |
| 91 | + if (cache) { | |
| 92 | + if (cache !== "pip") throw failure(`Caching for '${cache}' is not supported by the Diffinery setup-python built-in (only pip).`); | |
| 93 | + const dir = await probe(c, "python3", ["-m", "pip", "cache", "dir"], c.toolEnv()); | |
| 94 | + await packageCache(c, { | |
| 95 | + prefix: `setup-python-Linux-${toolArch("python")}-python-${version}-pip`, | |
| 96 | + paths: dir ? [dir] : [], | |
| 97 | + lockPatterns: ["**/requirements*.txt", "**/pyproject.toml"], | |
| 98 | + what: "pip", | |
| 99 | + }); | |
| 100 | + } | |
| 101 | +}; |
addedcontainers/runner/src/builtins/tools.ts+148−0View file
| @@ -1,0 +1,148 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { createWriteStream, existsSync } from "node:fs"; | |
| 3 | +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Readable } from "node:stream"; | |
| 6 | +import { pipeline } from "node:stream/promises"; | |
| 7 | +import type { ReadableStream as WebReadableStream } from "node:stream/web"; | |
| 8 | +import { failure } from "../job.js"; | |
| 9 | +import { hashFiles, inputList } from "../util.js"; | |
| 10 | +import { restoreCache, saveCache } from "./cache.js"; | |
| 11 | +import type { BuiltinContext } from "./index.js"; | |
| 12 | + | |
| 13 | +/** Downloads a public URL to a file with a few retries. */ | |
| 14 | +export async function fetchToFile(url: string, file: string): Promise<void> { | |
| 15 | + let last: unknown; | |
| 16 | + for (let i = 0; i < 4; i++) { | |
| 17 | + if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); | |
| 18 | + try { | |
| 19 | + const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" }); | |
| 20 | + if (res.status === 404) throw failure(`Not found: ${url}`); | |
| 21 | + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`); | |
| 22 | + await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file)); | |
| 23 | + return; | |
| 24 | + } catch (e) { | |
| 25 | + if (e instanceof Error && e.message.startsWith("Not found:")) throw e; | |
| 26 | + last = e; | |
| 27 | + } | |
| 28 | + } | |
| 29 | + throw last instanceof Error ? last : new Error(String(last)); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function fetchJson<T>(url: string): Promise<T> { | |
| 33 | + let last: unknown; | |
| 34 | + for (let i = 0; i < 3; i++) { | |
| 35 | + if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); | |
| 36 | + try { | |
| 37 | + const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } }); | |
| 38 | + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); | |
| 39 | + return (await res.json()) as T; | |
| 40 | + } catch (e) { | |
| 41 | + last = e; | |
| 42 | + } | |
| 43 | + } | |
| 44 | + throw last instanceof Error ? last : new Error(String(last)); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function toolArch(kind: "node" | "go" | "python"): string { | |
| 48 | + const a = process.arch; | |
| 49 | + if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64"; | |
| 50 | + return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64"; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string { | |
| 54 | + return path.join(c.job.toolCache, tool, version, arch); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function isCached(dir: string): boolean { | |
| 58 | + return existsSync(`${dir}.complete`) && existsSync(dir); | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */ | |
| 62 | +export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1): Promise<void> { | |
| 63 | + c.log(`Downloading ${url}`); | |
| 64 | + const file = path.join(c.job.tempDir, `tool-${randomUUID()}${url.endsWith(".xz") ? ".tar.xz" : ".tar.gz"}`); | |
| 65 | + try { | |
| 66 | + await fetchToFile(url, file); | |
| 67 | + await rm(dir, { recursive: true, force: true }); | |
| 68 | + await mkdir(dir, { recursive: true }); | |
| 69 | + c.log(`Extracting to ${dir}`); | |
| 70 | + await c.run("tar", [url.endsWith(".xz") ? "-xJf" : "-xzf", file, "-C", dir, `--strip-components=${strip}`], { silent: true }); | |
| 71 | + await writeFile(`${dir}.complete`, ""); | |
| 72 | + } finally { | |
| 73 | + await rm(file, { force: true }); | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Runs `cmd args` and returns trimmed stdout, or null if it fails. */ | |
| 78 | +export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> { | |
| 79 | + const r = await c.exec(cmd, args, { capture: true, silent: true, env }); | |
| 80 | + if (r.exitCode !== 0) return null; | |
| 81 | + return (r.stdout || r.stderr).trim(); | |
| 82 | +} | |
| 83 | + | |
| 84 | +export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> { | |
| 85 | + const p = path.resolve(c.job.workspace, file); | |
| 86 | + if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`); | |
| 87 | + const content = await readFile(p, "utf8"); | |
| 88 | + const base = path.basename(p); | |
| 89 | + if (base === "package.json") { | |
| 90 | + try { | |
| 91 | + const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } }; | |
| 92 | + const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node; | |
| 93 | + if (v) return v; | |
| 94 | + } catch { | |
| 95 | + /* fallthrough */ | |
| 96 | + } | |
| 97 | + throw failure(`No node version found in ${p}`); | |
| 98 | + } | |
| 99 | + if (base === "go.mod" || base === "go.work") { | |
| 100 | + const tc = /^toolchain\s+go(\S+)/m.exec(content); | |
| 101 | + const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content); | |
| 102 | + const v = tc?.[1] ?? g?.[1]; | |
| 103 | + if (!v) throw failure(`No go version found in ${p}`); | |
| 104 | + return v; | |
| 105 | + } | |
| 106 | + if (base === ".tool-versions") { | |
| 107 | + const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"]; | |
| 108 | + for (const line of content.split("\n")) { | |
| 109 | + const [n, v] = line.trim().split(/\s+/); | |
| 110 | + if (n && v && names.includes(n)) return v; | |
| 111 | + } | |
| 112 | + throw failure(`No ${kind} version found in ${p}`); | |
| 113 | + } | |
| 114 | + if (base === "pyproject.toml") { | |
| 115 | + const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content); | |
| 116 | + if (m) return m[1]!.replace(/,/g, " "); | |
| 117 | + throw failure(`No python version found in ${p}`); | |
| 118 | + } | |
| 119 | + const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")); | |
| 120 | + if (!first) throw failure(`The ${kind} version file at ${p} is empty`); | |
| 121 | + return first.replace(/^v(?=\d)/, ""); | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * Restores a package-manager cache keyed by lockfile hash and registers a | |
| 126 | + * post-step save, mirroring setup-node/setup-python/setup-go `cache:`. | |
| 127 | + */ | |
| 128 | +export async function packageCache(c: BuiltinContext, o: { prefix: string; paths: string[]; lockPatterns: string[]; what: string }): Promise<void> { | |
| 129 | + const patterns = inputList(c.input("cache-dependency-path")).length ? inputList(c.input("cache-dependency-path")) : o.lockPatterns; | |
| 130 | + const hash = hashFiles(c.job.workspace, patterns); | |
| 131 | + if (!hash) { | |
| 132 | + throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${patterns.join(", ")}`); | |
| 133 | + } | |
| 134 | + const key = `${o.prefix}-${hash}`; | |
| 135 | + const paths = o.paths.filter(Boolean); | |
| 136 | + if (!paths.length) { | |
| 137 | + c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + const r = await restoreCache(c, paths, key, []); | |
| 141 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 142 | + if (r.exact) return; | |
| 143 | + c.registerPost(`Post ${c.name}`, "success()", async (pc) => { | |
| 144 | + for (const p of paths) await mkdir(p, { recursive: true }).catch(() => {}); | |
| 145 | + await saveCache(pc, paths, key); | |
| 146 | + }); | |
| 147 | +} | |
| 148 | + |
addedcontainers/runner/src/commands.ts+89−0View file
| @@ -1,0 +1,89 @@ | ||
| 1 | +/** | |
| 2 | + * GitHub Actions workflow commands (`::name k=v::message` on stdout) and file | |
| 3 | + * commands (`$GITHUB_ENV`, `$GITHUB_OUTPUT`, ... files). | |
| 4 | + */ | |
| 5 | + | |
| 6 | +export interface WorkflowCommand { | |
| 7 | + command: string; | |
| 8 | + properties: Record<string, string>; | |
| 9 | + message: string; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function unescapeData(s: string): string { | |
| 13 | + return s.replace(/%0D/gi, "\r").replace(/%0A/gi, "\n").replace(/%25/g, "%"); | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function unescapeProperty(s: string): string { | |
| 17 | + return s | |
| 18 | + .replace(/%0D/gi, "\r") | |
| 19 | + .replace(/%0A/gi, "\n") | |
| 20 | + .replace(/%3A/gi, ":") | |
| 21 | + .replace(/%2C/gi, ",") | |
| 22 | + .replace(/%25/g, "%"); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Parses a `::command props::message` line. Returns null if not a command. */ | |
| 26 | +export function parseCommand(line: string): WorkflowCommand | null { | |
| 27 | + const trimmed = line.replace(/^\s+/, ""); | |
| 28 | + if (!trimmed.startsWith("::")) return null; | |
| 29 | + const end = trimmed.indexOf("::", 2); | |
| 30 | + if (end < 0) return null; | |
| 31 | + const head = trimmed.slice(2, end); | |
| 32 | + const message = trimmed.slice(end + 2); | |
| 33 | + const sp = head.indexOf(" "); | |
| 34 | + const command = (sp < 0 ? head : head.slice(0, sp)).trim(); | |
| 35 | + if (!command || !/^[A-Za-z0-9_.-]+$/.test(command)) return null; | |
| 36 | + const properties: Record<string, string> = {}; | |
| 37 | + if (sp >= 0) { | |
| 38 | + for (const part of head.slice(sp + 1).split(",")) { | |
| 39 | + const eq = part.indexOf("="); | |
| 40 | + if (eq <= 0) continue; | |
| 41 | + properties[part.slice(0, eq).trim()] = unescapeProperty(part.slice(eq + 1)); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + return { command, properties, message: unescapeData(message) }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * Parses an env-file style file command (GITHUB_ENV / GITHUB_OUTPUT / | |
| 49 | + * GITHUB_STATE): `name=value` lines and `name<<DELIM` heredocs. | |
| 50 | + */ | |
| 51 | +export function parseKeyValueFile(content: string): Array<[string, string]> { | |
| 52 | + const out: Array<[string, string]> = []; | |
| 53 | + if (!content) return out; | |
| 54 | + const lines = content.split(/\r?\n/); | |
| 55 | + for (let i = 0; i < lines.length; i++) { | |
| 56 | + const line = lines[i]!; | |
| 57 | + if (line === "") continue; | |
| 58 | + const eq = line.indexOf("="); | |
| 59 | + const hd = line.indexOf("<<"); | |
| 60 | + if (hd > 0 && (eq < 0 || hd < eq)) { | |
| 61 | + const name = line.slice(0, hd); | |
| 62 | + const delim = line.slice(hd + 2); | |
| 63 | + if (!name || !delim) throw new Error(`Invalid file command format: ${line}`); | |
| 64 | + const buf: string[] = []; | |
| 65 | + let found = false; | |
| 66 | + for (i++; i < lines.length; i++) { | |
| 67 | + if (lines[i] === delim) { | |
| 68 | + found = true; | |
| 69 | + break; | |
| 70 | + } | |
| 71 | + buf.push(lines[i]!); | |
| 72 | + } | |
| 73 | + if (!found) throw new Error(`Matching delimiter not found '${delim}'`); | |
| 74 | + out.push([name, buf.join("\n")]); | |
| 75 | + continue; | |
| 76 | + } | |
| 77 | + if (eq <= 0) throw new Error(`Invalid file command format: ${line}`); | |
| 78 | + out.push([line.slice(0, eq), line.slice(eq + 1)]); | |
| 79 | + } | |
| 80 | + return out; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** GITHUB_PATH: one directory per line. */ | |
| 84 | +export function parsePathFile(content: string): string[] { | |
| 85 | + return content | |
| 86 | + .split(/\r?\n/) | |
| 87 | + .map((l) => l.trim()) | |
| 88 | + .filter(Boolean); | |
| 89 | +} |
addedcontainers/runner/src/job.ts+678−0View file
| @@ -1,0 +1,678 @@ | ||
| 1 | +import type { ChildProcess } from "node:child_process"; | |
| 2 | +import { randomUUID } from "node:crypto"; | |
| 3 | +import { existsSync } from "node:fs"; | |
| 4 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import type { Conclusion, JobCompletion, JobSpec, StepReport, StepSpec } from "@diffinery/shared"; | |
| 7 | +import { evaluate, evaluateIf, interpolate, type StatusFns } from "@diffinery/workflow-yaml"; | |
| 8 | +import type { RunnerApi } from "./api.js"; | |
| 9 | +import { parseKeyValueFile, parsePathFile } from "./commands.js"; | |
| 10 | +import { JobLogger, OutputProcessor, type Annotation, type OutputSink } from "./log.js"; | |
| 11 | +import { Masker } from "./mask.js"; | |
| 12 | +import { runAction } from "./steps/actions.js"; | |
| 13 | +import { runScript } from "./steps/run.js"; | |
| 14 | +import { exec, hashFiles, killTree, nowIso, runnerArch, type ExecResult } from "./util.js"; | |
| 15 | + | |
| 16 | +export type Outcome = "success" | "failure" | "cancelled" | "skipped"; | |
| 17 | + | |
| 18 | +export interface StepContextEntry { | |
| 19 | + outputs: Record<string, string>; | |
| 20 | + outcome: Outcome; | |
| 21 | + conclusion: Outcome; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Execution scope: the job itself, or the inside of a composite action. */ | |
| 25 | +export interface Scope { | |
| 26 | + steps: Record<string, StepContextEntry>; | |
| 27 | + inputs: Record<string, unknown>; | |
| 28 | + /** A step in this scope failed (without continue-on-error). */ | |
| 29 | + failed: boolean; | |
| 30 | + /** Env inherited from the calling step (composite actions). */ | |
| 31 | + inheritedEnv: Record<string, string>; | |
| 32 | + composite: boolean; | |
| 33 | + action?: { path: string; repository: string; ref: string }; | |
| 34 | + depth: number; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface StepIO extends OutputSink { | |
| 38 | + /** Short reason recorded when the step fails (e.g. exit code). */ | |
| 39 | + reason?: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface StepResult { | |
| 43 | + outcome: Outcome; | |
| 44 | + conclusion: Outcome; | |
| 45 | + outputs: Record<string, string>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export interface PostStep { | |
| 49 | + name: string; | |
| 50 | + /** Expression evaluated with job status (e.g. `always()`, `success()`). */ | |
| 51 | + condition: string; | |
| 52 | + run: (logIndex: number) => Promise<void>; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export interface RunnerOptions { | |
| 56 | + workRoot: string; | |
| 57 | + /** Tool cache dir; defaults to `${workRoot}/_tool` (or RUNNER_TOOL_CACHE). */ | |
| 58 | + toolCache?: string; | |
| 59 | + echoLogs?: boolean; | |
| 60 | + /** Base env for child processes (defaults to process.env). */ | |
| 61 | + baseEnv?: NodeJS.ProcessEnv; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export class StepFailure extends Error {} | |
| 65 | + | |
| 66 | +const SENSITIVE_ENV = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"]; | |
| 67 | + | |
| 68 | +export class JobRunner { | |
| 69 | + readonly masker = new Masker(); | |
| 70 | + readonly logger: JobLogger; | |
| 71 | + readonly workspace: string; | |
| 72 | + readonly tempDir: string; | |
| 73 | + readonly toolCache: string; | |
| 74 | + readonly actionsDir: string; | |
| 75 | + readonly fileCmdDir: string; | |
| 76 | + readonly eventPath: string; | |
| 77 | + readonly debug: boolean; | |
| 78 | + | |
| 79 | + /** Accumulated GITHUB_ENV values. */ | |
| 80 | + envAccum: Record<string, string> = {}; | |
| 81 | + /** Accumulated GITHUB_PATH entries, most recent first. */ | |
| 82 | + pathAccum: string[] = []; | |
| 83 | + jobEnv: Record<string, string> = {}; | |
| 84 | + readonly summaries: string[] = []; | |
| 85 | + readonly posts: PostStep[] = []; | |
| 86 | + readonly children = new Set<ChildProcess>(); | |
| 87 | + readonly stepErrors = new Map<number, Annotation[]>(); | |
| 88 | + readonly stepReasons = new Map<number, string>(); | |
| 89 | + | |
| 90 | + private stopping: null | "cancelled" | "timeout" = null; | |
| 91 | + private firstFailure: { step?: number; message: string } | null = null; | |
| 92 | + private githubCtx: Record<string, unknown> = {}; | |
| 93 | + private defaultEnv: Record<string, string> = {}; | |
| 94 | + private baseEnv: NodeJS.ProcessEnv; | |
| 95 | + private secrets: Record<string, string>; | |
| 96 | + | |
| 97 | + constructor( | |
| 98 | + readonly spec: JobSpec, | |
| 99 | + readonly api: RunnerApi, | |
| 100 | + logger: JobLogger | null, | |
| 101 | + readonly opts: RunnerOptions, | |
| 102 | + batcher: import("./api.js").LogBatcher | null = null, | |
| 103 | + ) { | |
| 104 | + this.logger = logger ?? new JobLogger(this.masker, batcher, !!opts.echoLogs); | |
| 105 | + this.workspace = path.join(opts.workRoot, spec.repo.name); | |
| 106 | + this.tempDir = path.join(opts.workRoot, "_temp"); | |
| 107 | + this.toolCache = opts.toolCache ?? process.env.RUNNER_TOOL_CACHE ?? path.join(opts.workRoot, "_tool"); | |
| 108 | + this.actionsDir = path.join(opts.workRoot, "_actions"); | |
| 109 | + this.fileCmdDir = path.join(this.tempDir, "_runner_file_commands"); | |
| 110 | + this.eventPath = path.join(this.tempDir, "_github_workflow", "event.json"); | |
| 111 | + this.secrets = { ...spec.secrets, GITHUB_TOKEN: spec.token }; | |
| 112 | + this.debug = this.secrets.ACTIONS_STEP_DEBUG === "true" || spec.vars?.ACTIONS_STEP_DEBUG === "true"; | |
| 113 | + const base = { ...(opts.baseEnv ?? process.env) }; | |
| 114 | + for (const k of SENSITIVE_ENV) delete base[k]; | |
| 115 | + this.baseEnv = base; | |
| 116 | + for (const v of Object.values(this.secrets)) this.masker.add(v); | |
| 117 | + } | |
| 118 | + | |
| 119 | + // ------------------------------------------------------------ public | |
| 120 | + | |
| 121 | + get isStopping(): boolean { | |
| 122 | + return this.stopping !== null; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** SIGTERM / external cancellation. */ | |
| 126 | + cancel(reason: "cancelled" | "timeout" = "cancelled"): void { | |
| 127 | + if (this.stopping) return; | |
| 128 | + this.stopping = reason; | |
| 129 | + for (const c of this.children) killTree(c); | |
| 130 | + } | |
| 131 | + | |
| 132 | + async run(): Promise<JobCompletion> { | |
| 133 | + const spec = this.spec; | |
| 134 | + let jobTimer: NodeJS.Timeout | undefined; | |
| 135 | + if (spec.timeoutMinutes > 0) { | |
| 136 | + jobTimer = setTimeout(() => { | |
| 137 | + this.logger.line(-1, `##[error]The job running on runner has exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.`); | |
| 138 | + this.cancel("timeout"); | |
| 139 | + }, spec.timeoutMinutes * 60_000); | |
| 140 | + jobTimer.unref(); | |
| 141 | + } | |
| 142 | + const top: Scope = { steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }; | |
| 143 | + try { | |
| 144 | + await this.setup(); | |
| 145 | + for (let i = 0; i < spec.steps.length; i++) { | |
| 146 | + const step = spec.steps[i]!; | |
| 147 | + const fallbackName = defaultStepName(step); | |
| 148 | + if (this.stopping) { | |
| 149 | + await this.report(i, { status: "completed", conclusion: "cancelled", name: safeName(step, fallbackName), completedAt: nowIso() }); | |
| 150 | + continue; | |
| 151 | + } | |
| 152 | + const startedAt = nowIso(); | |
| 153 | + let started = false; | |
| 154 | + const res = await this.executeStep(step, top, i, async (name) => { | |
| 155 | + started = true; | |
| 156 | + await this.report(i, { status: "in_progress", name, startedAt }); | |
| 157 | + }); | |
| 158 | + await this.report(i, { | |
| 159 | + status: "completed", | |
| 160 | + conclusion: res.conclusion, | |
| 161 | + name: res.name ?? fallbackName, | |
| 162 | + ...(started ? { startedAt } : {}), | |
| 163 | + completedAt: nowIso(), | |
| 164 | + }); | |
| 165 | + } | |
| 166 | + if (!this.stopping) await this.runPosts(top); | |
| 167 | + } catch (e) { | |
| 168 | + const msg = e instanceof Error ? e.message : String(e); | |
| 169 | + this.logger.line(-1, `##[error]${msg}`); | |
| 170 | + top.failed = true; | |
| 171 | + this.firstFailure ??= { message: msg }; | |
| 172 | + } finally { | |
| 173 | + if (jobTimer) clearTimeout(jobTimer); | |
| 174 | + } | |
| 175 | + | |
| 176 | + let conclusion: Conclusion = top.failed ? "failure" : "success"; | |
| 177 | + if (this.stopping === "cancelled") conclusion = "cancelled"; | |
| 178 | + if (this.stopping === "timeout") { | |
| 179 | + conclusion = "failure"; | |
| 180 | + this.firstFailure = { ...(this.firstFailure ?? {}), message: `The job exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.` }; | |
| 181 | + } | |
| 182 | + const outputs = this.jobOutputs(top); | |
| 183 | + const completion: JobCompletion = { conclusion, outputs }; | |
| 184 | + const summary = this.summaries.filter((s) => s.trim() !== "").join("\n"); | |
| 185 | + if (summary) completion.summary = this.masker.mask(summary); | |
| 186 | + if (conclusion === "failure" && this.firstFailure) completion.failure = { ...this.firstFailure, message: this.masker.mask(this.firstFailure.message) }; | |
| 187 | + if (conclusion === "cancelled") completion.failure = { message: "The job was cancelled." }; | |
| 188 | + this.logger.line(-1, `Job completed with conclusion: ${conclusion}`); | |
| 189 | + return completion; | |
| 190 | + } | |
| 191 | + | |
| 192 | + // ------------------------------------------------------------ setup | |
| 193 | + | |
| 194 | + private async setup(): Promise<void> { | |
| 195 | + const spec = this.spec; | |
| 196 | + await mkdir(this.workspace, { recursive: true }); | |
| 197 | + await mkdir(this.fileCmdDir, { recursive: true }); | |
| 198 | + await mkdir(this.toolCache, { recursive: true }); | |
| 199 | + await mkdir(this.actionsDir, { recursive: true }); | |
| 200 | + await mkdir(path.dirname(this.eventPath), { recursive: true }); | |
| 201 | + const g = spec.github ?? {}; | |
| 202 | + const refName = String(g.ref_name ?? spec.ref.replace(/^refs\/(heads|tags)\//, "")); | |
| 203 | + const refType = String(g.ref_type ?? (spec.ref.startsWith("refs/tags/") ? "tag" : "branch")); | |
| 204 | + const server = spec.apiUrl.replace(/\/$/, ""); | |
| 205 | + const repoFull = `${spec.repo.owner}/${spec.repo.name}`; | |
| 206 | + this.githubCtx = { | |
| 207 | + repository: repoFull, | |
| 208 | + repository_owner: spec.repo.owner, | |
| 209 | + sha: spec.sha, | |
| 210 | + ref: spec.ref, | |
| 211 | + ref_name: refName, | |
| 212 | + ref_type: refType, | |
| 213 | + run_id: spec.runId, | |
| 214 | + run_number: g.run_number ?? "1", | |
| 215 | + run_attempt: g.run_attempt ?? "1", | |
| 216 | + job: spec.jobKey, | |
| 217 | + workflow: spec.workflowPath, | |
| 218 | + event_name: "push", | |
| 219 | + event: {}, | |
| 220 | + actor: "diffinery", | |
| 221 | + server_url: server, | |
| 222 | + api_url: `${server}/api/v3`, | |
| 223 | + graphql_url: `${server}/api/graphql`, | |
| 224 | + ...g, | |
| 225 | + workspace: this.workspace, | |
| 226 | + token: spec.token, | |
| 227 | + action: "", | |
| 228 | + action_path: "", | |
| 229 | + }; | |
| 230 | + await writeFile(this.eventPath, JSON.stringify(this.githubCtx.event ?? {}, null, 2)); | |
| 231 | + const str = (v: unknown) => (v === undefined || v === null ? "" : String(v)); | |
| 232 | + const d: Record<string, string> = { | |
| 233 | + CI: "true", | |
| 234 | + GITHUB_ACTIONS: "true", | |
| 235 | + DIFFINERY: "true", | |
| 236 | + GITHUB_REPOSITORY: repoFull, | |
| 237 | + GITHUB_REPOSITORY_OWNER: spec.repo.owner, | |
| 238 | + GITHUB_SHA: spec.sha, | |
| 239 | + GITHUB_REF: spec.ref, | |
| 240 | + GITHUB_REF_NAME: refName, | |
| 241 | + GITHUB_REF_TYPE: refType, | |
| 242 | + GITHUB_HEAD_REF: str(g.head_ref), | |
| 243 | + GITHUB_BASE_REF: str(g.base_ref), | |
| 244 | + GITHUB_RUN_ID: spec.runId, | |
| 245 | + GITHUB_RUN_NUMBER: str(this.githubCtx.run_number), | |
| 246 | + GITHUB_RUN_ATTEMPT: str(this.githubCtx.run_attempt), | |
| 247 | + GITHUB_JOB: spec.jobKey, | |
| 248 | + GITHUB_ACTOR: str(this.githubCtx.actor), | |
| 249 | + GITHUB_TRIGGERING_ACTOR: str(g.triggering_actor ?? this.githubCtx.actor), | |
| 250 | + GITHUB_EVENT_NAME: str(this.githubCtx.event_name), | |
| 251 | + GITHUB_EVENT_PATH: this.eventPath, | |
| 252 | + GITHUB_SERVER_URL: server, | |
| 253 | + GITHUB_API_URL: `${server}/api/v3`, | |
| 254 | + GITHUB_GRAPHQL_URL: `${server}/api/graphql`, | |
| 255 | + GITHUB_WORKFLOW: str(g.workflow ?? spec.workflowPath), | |
| 256 | + GITHUB_WORKFLOW_REF: str(g.workflow_ref ?? `${repoFull}/${spec.workflowPath}@${spec.ref}`), | |
| 257 | + GITHUB_WORKSPACE: this.workspace, | |
| 258 | + GITHUB_RETENTION_DAYS: "90", | |
| 259 | + RUNNER_OS: "Linux", | |
| 260 | + RUNNER_ARCH: runnerArch(), | |
| 261 | + RUNNER_NAME: "diffinery", | |
| 262 | + RUNNER_ENVIRONMENT: "diffinery-hosted", | |
| 263 | + RUNNER_TEMP: this.tempDir, | |
| 264 | + RUNNER_TOOL_CACHE: this.toolCache, | |
| 265 | + RUNNER_WORKSPACE: path.dirname(this.workspace), | |
| 266 | + AGENT_TOOLSDIRECTORY: this.toolCache, | |
| 267 | + }; | |
| 268 | + if (this.debug) d.RUNNER_DEBUG = "1"; | |
| 269 | + if (spec.idToken && this.api.online) { | |
| 270 | + // @actions/core appends `&audience=...`, so the URL must already carry a query string. | |
| 271 | + d.ACTIONS_ID_TOKEN_REQUEST_URL = `${this.api.url("/oidc")}?api-version=2.0`; | |
| 272 | + d.ACTIONS_ID_TOKEN_REQUEST_TOKEN = spec.token; | |
| 273 | + } | |
| 274 | + this.defaultEnv = d; | |
| 275 | + this.logger.line(-1, `Diffinery runner: job ${spec.jobKey} (${spec.name}) for ${repoFull}@${spec.sha.slice(0, 12)}`); | |
| 276 | + this.logger.line(-1, `Workspace: ${this.workspace}`); | |
| 277 | + // job-level env | |
| 278 | + const ctx = this.contexts({ steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }, {}); | |
| 279 | + for (const [k, v] of Object.entries(spec.env ?? {})) { | |
| 280 | + this.jobEnv[k] = this.interp(String(v ?? ""), ctx); | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + // ------------------------------------------------------------ contexts | |
| 285 | + | |
| 286 | + statusFns(scope: Scope): StatusFns { | |
| 287 | + return { | |
| 288 | + success: () => !scope.failed && this.stopping === null, | |
| 289 | + failure: () => scope.failed, | |
| 290 | + cancelled: () => this.stopping !== null, | |
| 291 | + always: () => true, | |
| 292 | + }; | |
| 293 | + } | |
| 294 | + | |
| 295 | + extra(): { hashFiles: (...p: string[]) => string } { | |
| 296 | + return { hashFiles: (...patterns: string[]) => hashFiles(this.workspace, patterns) }; | |
| 297 | + } | |
| 298 | + | |
| 299 | + /** Env context visible to a step before its own `env:` is applied. */ | |
| 300 | + baseEnvContext(scope: Scope): Record<string, string> { | |
| 301 | + return { ...this.jobEnv, ...this.envAccum, ...scope.inheritedEnv }; | |
| 302 | + } | |
| 303 | + | |
| 304 | + contexts(scope: Scope, env: Record<string, string>): Record<string, unknown> { | |
| 305 | + const spec = this.spec; | |
| 306 | + const github = { ...this.githubCtx }; | |
| 307 | + if (scope.action) { | |
| 308 | + github.action_path = scope.action.path; | |
| 309 | + github.action_repository = scope.action.repository; | |
| 310 | + github.action_ref = scope.action.ref; | |
| 311 | + } | |
| 312 | + return { | |
| 313 | + github, | |
| 314 | + env, | |
| 315 | + vars: spec.vars ?? {}, | |
| 316 | + secrets: this.secrets, | |
| 317 | + matrix: spec.matrix ?? {}, | |
| 318 | + needs: spec.needs ?? {}, | |
| 319 | + steps: scope.steps, | |
| 320 | + runner: { | |
| 321 | + os: "Linux", | |
| 322 | + arch: runnerArch(), | |
| 323 | + name: "diffinery", | |
| 324 | + temp: this.tempDir, | |
| 325 | + tool_cache: this.toolCache, | |
| 326 | + workspace: path.dirname(this.workspace), | |
| 327 | + debug: this.debug ? "1" : "", | |
| 328 | + environment: "diffinery-hosted", | |
| 329 | + }, | |
| 330 | + inputs: scope.inputs, | |
| 331 | + job: { status: this.stopping ? "cancelled" : scope.failed ? "failure" : "success", container: {}, services: {} }, | |
| 332 | + strategy: spec.strategy ?? {}, | |
| 333 | + }; | |
| 334 | + } | |
| 335 | + | |
| 336 | + interp(template: string, ctx: Record<string, unknown>, scope?: Scope): string { | |
| 337 | + if (!template.includes("${{")) return template; | |
| 338 | + const status = scope ? this.statusFns(scope) : undefined; | |
| 339 | + return interpolate(template, ctx, status, this.extra()); | |
| 340 | + } | |
| 341 | + | |
| 342 | + evalIf(cond: string | undefined, ctx: Record<string, unknown>, scope: Scope): boolean { | |
| 343 | + return evaluateIf(cond, ctx, this.statusFns(scope), this.extra()); | |
| 344 | + } | |
| 345 | + | |
| 346 | + evalExpr(expr: string, ctx: Record<string, unknown>, scope: Scope): unknown { | |
| 347 | + return evaluate(expr, ctx, this.statusFns(scope), this.extra()); | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** Builds the environment for a child process. */ | |
| 351 | + processEnv(envCtx: Record<string, string>, extra: Record<string, string> = {}): NodeJS.ProcessEnv { | |
| 352 | + const env: NodeJS.ProcessEnv = { ...this.baseEnv, ...envCtx, ...this.defaultEnv, ...extra }; | |
| 353 | + const basePath = envCtx.PATH ?? this.baseEnv.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | |
| 354 | + env.PATH = [...this.pathAccum, basePath].filter(Boolean).join(path.delimiter); | |
| 355 | + return env; | |
| 356 | + } | |
| 357 | + | |
| 358 | + // ------------------------------------------------------------ steps | |
| 359 | + | |
| 360 | + /** | |
| 361 | + * Evaluates `if`, env, name; runs the step; updates the scope's steps | |
| 362 | + * context and failure state. | |
| 363 | + */ | |
| 364 | + async executeStep(step: StepSpec, scope: Scope, logIndex: number, onStart?: (name: string) => Promise<void>): Promise<StepResult & { name: string }> { | |
| 365 | + const baseEnv = this.baseEnvContext(scope); | |
| 366 | + let ctx = this.contexts(scope, baseEnv); | |
| 367 | + let name = defaultStepName(step); | |
| 368 | + try { | |
| 369 | + name = step.name !== undefined ? this.interp(String(step.name), ctx, scope) : name; | |
| 370 | + } catch { | |
| 371 | + /* keep default */ | |
| 372 | + } | |
| 373 | + const id = step.id; | |
| 374 | + const record = (outcome: Outcome, conclusion: Outcome, outputs: Record<string, string>) => { | |
| 375 | + if (id) scope.steps[id] = { outputs, outcome, conclusion }; | |
| 376 | + }; | |
| 377 | + | |
| 378 | + let run: boolean; | |
| 379 | + try { | |
| 380 | + run = evaluateIf(step.if, ctx, this.statusFns(scope), this.extra()); | |
| 381 | + } catch (e) { | |
| 382 | + this.logger.line(logIndex, `##[error]Error evaluating 'if' condition: ${errMsg(e)}`); | |
| 383 | + this.noteFailure(logIndex, `Error evaluating 'if' condition of step '${name}': ${errMsg(e)}`); | |
| 384 | + scope.failed = true; | |
| 385 | + record("failure", "failure", {}); | |
| 386 | + return { outcome: "failure", conclusion: "failure", outputs: {}, name }; | |
| 387 | + } | |
| 388 | + if (!run) { | |
| 389 | + if (scope.depth > 0) this.logger.line(logIndex, `Skipping step '${name}' (condition not met)`); | |
| 390 | + record("skipped", "skipped", {}); | |
| 391 | + return { outcome: "skipped", conclusion: "skipped", outputs: {}, name }; | |
| 392 | + } | |
| 393 | + await onStart?.(name); | |
| 394 | + | |
| 395 | + const io: StepIO = { logIndex, outputs: {}, state: {} }; | |
| 396 | + let outcome: Outcome = "success"; | |
| 397 | + let continueOnError = false; | |
| 398 | + try { | |
| 399 | + continueOnError = this.evalBoolish(step["continue-on-error"], ctx, scope); | |
| 400 | + const stepEnv: Record<string, string> = {}; | |
| 401 | + const envCtx = { ...baseEnv }; | |
| 402 | + ctx = this.contexts(scope, envCtx); | |
| 403 | + for (const [k, v] of Object.entries(step.env ?? {})) { | |
| 404 | + stepEnv[k] = this.interp(String(v ?? ""), ctx, scope); | |
| 405 | + envCtx[k] = stepEnv[k]!; | |
| 406 | + } | |
| 407 | + const timeoutMin = this.evalNumber(step["timeout-minutes"], ctx, scope); | |
| 408 | + const timeoutMs = timeoutMin > 0 ? timeoutMin * 60_000 : 0; | |
| 409 | + if (step.run !== undefined) { | |
| 410 | + await runScript(this, { step, scope, ctx, envCtx, io, timeoutMs, name }); | |
| 411 | + } else if (step.uses) { | |
| 412 | + await runAction(this, { step, scope, ctx, envCtx, io, timeoutMs, name }); | |
| 413 | + } else { | |
| 414 | + throw new StepFailure("Step has neither `run` nor `uses`."); | |
| 415 | + } | |
| 416 | + if (this.stopping) outcome = "cancelled"; | |
| 417 | + } catch (e) { | |
| 418 | + if (this.stopping) { | |
| 419 | + outcome = "cancelled"; | |
| 420 | + } else { | |
| 421 | + outcome = "failure"; | |
| 422 | + const msg = errMsg(e); | |
| 423 | + if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) { | |
| 424 | + this.logger.line(logIndex, `##[error]${msg}`); | |
| 425 | + this.addAnnotation(logIndex, { level: "error", message: msg }); | |
| 426 | + } | |
| 427 | + io.reason ??= msg; | |
| 428 | + } | |
| 429 | + } | |
| 430 | + | |
| 431 | + let conclusion: Outcome = outcome; | |
| 432 | + if (outcome === "failure") { | |
| 433 | + if (continueOnError) { | |
| 434 | + conclusion = "success"; | |
| 435 | + this.logger.line(logIndex, "##[warning]Step failed but continue-on-error is set; continuing."); | |
| 436 | + } else { | |
| 437 | + scope.failed = true; | |
| 438 | + this.noteFailure(logIndex, io.reason); | |
| 439 | + } | |
| 440 | + } | |
| 441 | + record(outcome, conclusion, io.outputs); | |
| 442 | + return { outcome, conclusion, outputs: io.outputs, name }; | |
| 443 | + } | |
| 444 | + | |
| 445 | + private evalBoolish(v: unknown, ctx: Record<string, unknown>, scope: Scope): boolean { | |
| 446 | + if (v === undefined || v === null || v === false) return false; | |
| 447 | + if (v === true) return true; | |
| 448 | + let s = String(v).trim(); | |
| 449 | + if (s.includes("${{")) s = this.interp(s, ctx, scope); | |
| 450 | + return s === "true"; | |
| 451 | + } | |
| 452 | + | |
| 453 | + private evalNumber(v: unknown, ctx: Record<string, unknown>, scope: Scope): number { | |
| 454 | + if (v === undefined || v === null || v === "") return 0; | |
| 455 | + if (typeof v === "number") return v; | |
| 456 | + let s = String(v); | |
| 457 | + if (s.includes("${{")) s = this.interp(s, ctx, scope); | |
| 458 | + const n = Number(s); | |
| 459 | + return Number.isFinite(n) ? n : 0; | |
| 460 | + } | |
| 461 | + | |
| 462 | + addAnnotation(logIndex: number, a: Annotation): void { | |
| 463 | + let list = this.stepErrors.get(logIndex); | |
| 464 | + if (!list) this.stepErrors.set(logIndex, (list = [])); | |
| 465 | + list.push(a); | |
| 466 | + } | |
| 467 | + | |
| 468 | + private noteFailure(logIndex: number, reason?: string): void { | |
| 469 | + if (this.firstFailure) return; | |
| 470 | + const errs = (this.stepErrors.get(logIndex) ?? []).filter((a) => a.level === "error"); | |
| 471 | + // prefer annotations emitted by the step itself over our generic "exit code" message | |
| 472 | + const userErr = [...errs].reverse().find((a) => a.message !== reason); | |
| 473 | + let message: string; | |
| 474 | + if (userErr) { | |
| 475 | + const loc = userErr.file ? `${userErr.file}${userErr.line ? `:${userErr.line}` : ""}: ` : ""; | |
| 476 | + message = `${loc}${userErr.title ? `${userErr.title}: ` : ""}${userErr.message}`; | |
| 477 | + } else { | |
| 478 | + const tail = this.logger.tail(logIndex, 20).filter((l) => !l.startsWith("##[error]")); | |
| 479 | + message = [reason ?? "Step failed", ...(tail.length ? ["", ...tail] : [])].join("\n"); | |
| 480 | + } | |
| 481 | + this.firstFailure = { step: logIndex >= 0 ? logIndex : undefined, message }; | |
| 482 | + if (this.firstFailure.step === undefined) delete this.firstFailure.step; | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** | |
| 486 | + * Runs a process for a step with fresh file-command files, interpreting | |
| 487 | + * workflow commands, then applies GITHUB_ENV/PATH/OUTPUT/STATE/SUMMARY. | |
| 488 | + */ | |
| 489 | + async runStepProcess( | |
| 490 | + argv: string[], | |
| 491 | + o: { cwd: string; envCtx: Record<string, string>; io: StepIO; timeoutMs?: number; extraEnv?: Record<string, string>; timeoutLabel?: string }, | |
| 492 | + ): Promise<ExecResult> { | |
| 493 | + const id = randomUUID(); | |
| 494 | + const files = { | |
| 495 | + GITHUB_ENV: path.join(this.fileCmdDir, `set_env_${id}`), | |
| 496 | + GITHUB_PATH: path.join(this.fileCmdDir, `add_path_${id}`), | |
| 497 | + GITHUB_OUTPUT: path.join(this.fileCmdDir, `set_output_${id}`), | |
| 498 | + GITHUB_STATE: path.join(this.fileCmdDir, `save_state_${id}`), | |
| 499 | + GITHUB_STEP_SUMMARY: path.join(this.fileCmdDir, `step_summary_${id}`), | |
| 500 | + }; | |
| 501 | + await mkdir(this.fileCmdDir, { recursive: true }); | |
| 502 | + await Promise.all(Object.values(files).map((f) => writeFile(f, ""))); | |
| 503 | + const proc = this.processor(o.io); | |
| 504 | + if (!existsSync(o.cwd)) { | |
| 505 | + throw new StepFailure(`An error occurred trying to start process '${argv[0]}' with working directory '${o.cwd}'. No such file or directory`); | |
| 506 | + } | |
| 507 | + const res = await exec(argv[0]!, argv.slice(1), { | |
| 508 | + cwd: o.cwd, | |
| 509 | + env: this.processEnv(o.envCtx, { ...(o.extraEnv ?? {}), ...files }), | |
| 510 | + timeoutMs: o.timeoutMs, | |
| 511 | + onLine: (l) => proc.handle(l), | |
| 512 | + onSpawn: (c) => { | |
| 513 | + this.children.add(c); | |
| 514 | + c.once("close", () => this.children.delete(c)); | |
| 515 | + }, | |
| 516 | + }); | |
| 517 | + await this.applyFileCommands(files, o.io); | |
| 518 | + if (res.timedOut) { | |
| 519 | + const mins = (o.timeoutMs ?? 0) / 60000; | |
| 520 | + const msg = `The ${o.timeoutLabel ?? "step"} has timed out after ${Number(mins.toFixed(2))} minutes.`; | |
| 521 | + this.logger.line(o.io.logIndex, `##[error]${msg}`); | |
| 522 | + this.addAnnotation(o.io.logIndex, { level: "error", message: msg }); | |
| 523 | + o.io.reason = msg; | |
| 524 | + } | |
| 525 | + return res; | |
| 526 | + } | |
| 527 | + | |
| 528 | + processor(io: StepIO): OutputProcessor { | |
| 529 | + return new OutputProcessor(this.logger, this.masker, io, { | |
| 530 | + debug: this.debug, | |
| 531 | + onAnnotation: (a) => this.addAnnotation(io.logIndex, a), | |
| 532 | + }); | |
| 533 | + } | |
| 534 | + | |
| 535 | + private async applyFileCommands(files: Record<string, string>, io: StepIO): Promise<void> { | |
| 536 | + const read = async (f: string) => { | |
| 537 | + try { | |
| 538 | + return await readFile(f, "utf8"); | |
| 539 | + } catch { | |
| 540 | + return ""; | |
| 541 | + } | |
| 542 | + }; | |
| 543 | + const [envC, pathC, outC, stateC, sumC] = await Promise.all([ | |
| 544 | + read(files.GITHUB_ENV!), | |
| 545 | + read(files.GITHUB_PATH!), | |
| 546 | + read(files.GITHUB_OUTPUT!), | |
| 547 | + read(files.GITHUB_STATE!), | |
| 548 | + read(files.GITHUB_STEP_SUMMARY!), | |
| 549 | + ]); | |
| 550 | + const safe = (label: string, fn: () => void) => { | |
| 551 | + try { | |
| 552 | + fn(); | |
| 553 | + } catch (e) { | |
| 554 | + this.logger.line(io.logIndex, `##[error]Unable to process file command '${label}' successfully: ${errMsg(e)}`); | |
| 555 | + this.addAnnotation(io.logIndex, { level: "error", message: `Unable to process file command '${label}': ${errMsg(e)}` }); | |
| 556 | + } | |
| 557 | + }; | |
| 558 | + safe("env", () => { | |
| 559 | + for (const [k, v] of parseKeyValueFile(envC)) { | |
| 560 | + if (k === "NODE_OPTIONS" && /--require|-r\b/.test(v)) continue; | |
| 561 | + this.envAccum[k] = v; | |
| 562 | + } | |
| 563 | + }); | |
| 564 | + safe("path", () => { | |
| 565 | + for (const p of parsePathFile(pathC)) this.addPath(p); | |
| 566 | + }); | |
| 567 | + safe("output", () => { | |
| 568 | + for (const [k, v] of parseKeyValueFile(outC)) io.outputs[k] = v; | |
| 569 | + }); | |
| 570 | + safe("state", () => { | |
| 571 | + for (const [k, v] of parseKeyValueFile(stateC)) io.state[k] = v; | |
| 572 | + }); | |
| 573 | + if (sumC.trim()) this.summaries.push(sumC); | |
| 574 | + } | |
| 575 | + | |
| 576 | + addPath(p: string): void { | |
| 577 | + this.pathAccum = [p, ...this.pathAccum.filter((x) => x !== p)]; | |
| 578 | + } | |
| 579 | + | |
| 580 | + /** Runs an internal tool (git, tar, ...) streaming its output into the step log. */ | |
| 581 | + async tool(cmd: string, args: string[], o: { io: StepIO; cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult> { | |
| 582 | + return exec(cmd, args, { | |
| 583 | + cwd: o.cwd ?? this.workspace, | |
| 584 | + env: o.env ?? this.processEnv(this.baseEnvContext({ steps: {}, inputs: {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 })), | |
| 585 | + capture: o.capture, | |
| 586 | + input: o.input, | |
| 587 | + onLine: o.silent ? undefined : (l) => this.logger.line(o.io.logIndex, l), | |
| 588 | + onSpawn: (c) => { | |
| 589 | + this.children.add(c); | |
| 590 | + c.once("close", () => this.children.delete(c)); | |
| 591 | + }, | |
| 592 | + }); | |
| 593 | + } | |
| 594 | + | |
| 595 | + registerPost(p: PostStep): void { | |
| 596 | + this.posts.push(p); | |
| 597 | + } | |
| 598 | + | |
| 599 | + private async runPosts(top: Scope): Promise<void> { | |
| 600 | + const posts = [...this.posts].reverse(); | |
| 601 | + let index = this.spec.steps.length; | |
| 602 | + for (const p of posts) { | |
| 603 | + const logIndex = index++; | |
| 604 | + if (this.stopping) break; | |
| 605 | + let ok: boolean; | |
| 606 | + try { | |
| 607 | + ok = evaluateIf(p.condition, this.contexts(top, this.baseEnvContext(top)), this.statusFns(top), this.extra()); | |
| 608 | + } catch { | |
| 609 | + ok = false; | |
| 610 | + } | |
| 611 | + if (!ok) continue; | |
| 612 | + const startedAt = nowIso(); | |
| 613 | + await this.report(logIndex, { status: "in_progress", name: p.name, startedAt }); | |
| 614 | + let conclusion: Conclusion = "success"; | |
| 615 | + try { | |
| 616 | + await p.run(logIndex); | |
| 617 | + } catch (e) { | |
| 618 | + conclusion = "failure"; | |
| 619 | + const msg = errMsg(e); | |
| 620 | + if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) { | |
| 621 | + this.logger.line(logIndex, `##[error]${msg}`); | |
| 622 | + this.addAnnotation(logIndex, { level: "error", message: msg }); | |
| 623 | + } | |
| 624 | + top.failed = true; | |
| 625 | + this.noteFailure(logIndex, msg); | |
| 626 | + } | |
| 627 | + if (this.stopping) conclusion = "cancelled"; | |
| 628 | + await this.report(logIndex, { status: "completed", conclusion, name: p.name, startedAt, completedAt: nowIso() }); | |
| 629 | + } | |
| 630 | + } | |
| 631 | + | |
| 632 | + private jobOutputs(top: Scope): Record<string, string> { | |
| 633 | + const out: Record<string, string> = {}; | |
| 634 | + const ctx = this.contexts(top, this.baseEnvContext(top)); | |
| 635 | + for (const [k, expr] of Object.entries(this.spec.outputs ?? {})) { | |
| 636 | + try { | |
| 637 | + const v = this.interp(String(expr), ctx, top); | |
| 638 | + if (this.masker.containsSecret(v)) { | |
| 639 | + this.logger.line(-1, `##[warning]Skip output '${k}' since it may contain secret.`); | |
| 640 | + continue; | |
| 641 | + } | |
| 642 | + out[k] = v; | |
| 643 | + } catch (e) { | |
| 644 | + this.logger.line(-1, `##[warning]Failed to evaluate job output '${k}': ${errMsg(e)}`); | |
| 645 | + } | |
| 646 | + } | |
| 647 | + return out; | |
| 648 | + } | |
| 649 | + | |
| 650 | + private async report(index: number, r: StepReport): Promise<void> { | |
| 651 | + try { | |
| 652 | + await this.api.postStep(index, { ...r, name: this.masker.mask(r.name) }); | |
| 653 | + } catch (e) { | |
| 654 | + this.logger.line(-1, `##[warning]Failed to report step ${index}: ${errMsg(e)}`); | |
| 655 | + } | |
| 656 | + } | |
| 657 | +} | |
| 658 | + | |
| 659 | +export function errMsg(e: unknown): string { | |
| 660 | + return e instanceof Error ? e.message : String(e); | |
| 661 | +} | |
| 662 | + | |
| 663 | +export function failure(message: string, logged = false): StepFailure { | |
| 664 | + const f = new StepFailure(message) as StepFailure & { logged?: boolean }; | |
| 665 | + f.logged = logged; | |
| 666 | + return f; | |
| 667 | +} | |
| 668 | + | |
| 669 | +export function defaultStepName(step: StepSpec): string { | |
| 670 | + if (step.name) return String(step.name); | |
| 671 | + if (step.uses) return `Run ${step.uses}`; | |
| 672 | + if (step.run) return `Run ${String(step.run).trim().split("\n")[0]}`; | |
| 673 | + return "Step"; | |
| 674 | +} | |
| 675 | + | |
| 676 | +function safeName(step: StepSpec, fallback: string): string { | |
| 677 | + return step.name && !String(step.name).includes("${{") ? String(step.name) : fallback; | |
| 678 | +} |
addedcontainers/runner/src/log.ts+135−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/main.ts+106−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/mask.ts+49−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/sandbox.ts+217−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/steps/actions.ts+333−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/steps/run.ts+114−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/util.ts+440−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/yaml.ts+393−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/helpers.ts+157−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/job.test.ts+610−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/sandbox.test.ts+91−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/unit.test.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addeddocs/PREFERENCES.md+24−0View file
Diff not rendered to keep this page fast. View the file
addedpackage.json+22−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/package.json+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/bytes.ts+79−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/delta.ts+47−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/diff.ts+227−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/index.ts+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/memory-store.ts+18−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/objects.ts+165−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/pack.ts+156−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/pktline.ts+55−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/sha1.ts+15−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/store.ts+373−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/zlib.ts+33−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/test/pack.test.ts+139−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/package.json+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/cron.ts+171−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/coerce.ts+143−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/evaluate.ts+292−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/lexer.ts+143−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/parser.ts+216−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/template.ts+68−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/filters.ts+194−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/graph.ts+73−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/index.ts+50−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/matrix.ts+120−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/model.ts+174−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/parse.ts+736−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/cron.test.ts+82−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/expr.test.ts+356−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/filters.test.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/matrix.test.ts+149−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/parse.test.ts+600−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addedpnpm-lock.yaml+3067−0View file
Diff not rendered to keep this page fast. View the file
addedpnpm-workspace.yaml+4−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/bootstrap.sh+126−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/ensure-secrets.sh+29−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/render-config.mjs+43−0View file
Diff not rendered to keep this page fast. View the file
addedtsconfig.base.json+17−0View file
Diff not rendered to keep this page fast. View the file
addedturbo.json+8−0View file
Diff not rendered to keep this page fast. View the file