GitHub REST API compatibility: releases, statuses, deployments, git data, search, files (#19)
* Add tables, events and a topics column for GitHub-compatible extras Releases and assets, commit statuses, deployments and environments, reactions and issue events live in each repo's SQLite. Topics live on the D1 repos row (migration 0003). New event types: release, status, deployment, deployment_status and repository_dispatch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Add repo modules for releases, statuses, deployments, git data and search Release notes generation, zip and tar.gz archives, issue and repository search query parsing, labels, reactions, issue events, and the git data API (blobs, trees, commits, tags and contents writes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Wire releases, statuses and environments into RepoDO, PRs and jobs Commit statuses count as checks for mergeability, required checks and the merge queue. Issue updates record timeline events. Jobs with an environment create deployments and get the environment's secrets and variables. Webhooks deliver the new events. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Make the REST API GitHub-compatible GitHub JSON shapes (node_id, url, html_url, user objects, label objects), Link pagination and GitHub error bodies across /api/v3. New routes: releases and asset uploads and downloads, commit statuses, check runs and suites, the git data API, issue and PR sub-resources, reactions, issue events and timelines, deployments and environments, search, topics, dispatches, run attempts and logs, archives and rate_limit. Diffinery-only fields stay for the CLI, MCP and web UI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Add MCP tools for releases list_releases, get_release, create_release, update_release, upload_release_asset (up to 25 MB) and generate_release_notes, with the same permission checks as the REST API. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Add release pages and show commit statuses in the web UI A releases list, a page per tag with assets and source archives, and a new/edit form with a Kumo tag picker, target branch, pre-release and latest checkboxes and file uploads. The repository home links the latest release and shows topics. PR merge boxes, PR checks tabs and commit pages list commit statuses with their target URLs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Use numeric comment and review ids in GitHub JSON Comments and reviews keep their internal ids, but the REST API now reports the row id as `id` (and as pull_request_review_id and in_reply_to_id), like GitHub. Routes accept either form, so the CLI and existing links keep working. Raw contents and blobs come back as text/plain, which Octokit reads as text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Test the GitHub-compatible API with Octokit, plus unit tests An end-to-end test drives the real @octokit/rest client through contents, the git data API, commit statuses with required checks, pull request review comments, releases with asset uploads and downloads, the release web form, archives, issue labels, reactions, locks, events and pagination, search, topics, deployments and environments, dispatches and variables. Unit tests cover release notes, search query parsing and the zip and tar writers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Document the GitHub-compatible REST API List what works, how workflows react to releases, dispatches and environments, and what isn't supported yet (GraphQL, sealed-box secrets, environment protection rules, forks, team reviewers, code and commit search). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Renumber the topics migration to 0004 0003 is the container registry's migration on main. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Renumber the topics migration to 0005 0004 is the email migration on main. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Keep the topics migration at 0004 The lead reserved 0005 for another team. Wrangler orders migrations by file name and records them by name, so 0004_repo_topics.sql applies after 0004_email.sql. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Limit search to repositories the caller can read, and bound its cost Search candidates now go through callerFor, pending and disabled accounts and agents of inactive owners see nothing, and disabled accounts lose their sessions. Each repository returns only numbers and sort keys; the page is fetched afterwards. Queries are capped at 256 characters, 50 repositories and 2,000 comments for in:comments. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Decide required checks by their trusted reporter A required check fails if any counted report fails. Diffinery Actions is the authority for its check names, branch protection can bind a check to a reporter with requiredCheckSources, and statuses or check runs from agents never count. Only the creator of a check run can update it, and Actions check runs can't be changed over REST. Check runs get numeric ids. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Keep environments and their secrets away from agents and pull requests Only human admins change or delete environments and branch policies. Environment secrets never go to refs/pull runs, and go to agent runs only when the environment allows protected branches only. The first secret limits an environment without rules to protected branches, and tags pushed by agents never match tag patterns. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Refuse unsafe tree names and bound tree walks, archives and their memory Every write path refuses ., .., .git in any spelling (.GIT, .git., git~1, HFS-ignorable characters), backslashes and NUL. Archives and languages walk at most 100,000 entries, counting reused subtrees each time. Archives stream through the compressor into R2 (multipart for big ones), hold at most 24 MB of files, refuse more than 65,535 zip entries with a clear error, and each repository builds at most 20 per hour. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Bound upload bodies before buffering them Asset uploads without a Content-Length are counted as they stream to R2 and stop at 100 MB, the real Workers request limit. Web forms check Content-Length against a per-route cap before parsing (10 MB, or the release form's 101 MB) and refuse bodies without one. Form and MCP uploads stream instead of copying. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Harden release asset imports and clean up orphaned asset bytes Imports copy staged objects from repos/<id>/import/ instead of adopting any key, download only from GitHub's hosts (or IMPORT_GHE_HOST), drop every credential header when a redirect leaves the original host, and stream with a byte limit. Asset deletes only touch keys under the repository's release prefix, and abandoned uploads lose their bytes. Draft release events no longer reach readers' live pages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Respect locks in review comments and reactions; restrict review dismissal Locked conversations refuse review comments, replies and reactions from people without triage access. Dismissing a review takes maintain access, and never the pull request's author or an agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Make archived repositories read-only and fix GitHub API details Compat routes refuse every change to an archived repository. Path pieces are decoded once (a label named 50% works) and malformed escapes return 400. User and repository ids are numbers. private: false makes a repository public. A deployment without required_contexts needs every context to pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Test the security fixes end to end and document them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Number the topics migration 0007 so it doesn't share 0004 with email 0005 and 0006 are taken by the avatars and importer branches. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent ec41a5acommit 7800b99ac577ad8ca3929c1717105045b38ab185Browse files
61 files changed+8715−245
Files (61)
- README.md+102−1
- apps/diffinery/migrations/0007_repo_topics.sql+2−0
- apps/diffinery/package.json+4−2
- apps/diffinery/src/app.ts+14−1
- apps/diffinery/src/auth/sessions.ts+1−1
- apps/diffinery/src/env.ts+2−0
- apps/diffinery/src/http/api.ts+358−189
- apps/diffinery/src/http/common.ts+3−2
- apps/diffinery/src/http/compat.ts+40−0
- apps/diffinery/src/http/deployments.ts+239−0
- apps/diffinery/src/http/files.ts+132−0
- apps/diffinery/src/http/gh.ts+753−0
- apps/diffinery/src/http/ghctx.ts+137−0
- apps/diffinery/src/http/gitdata.ts+193−0
- apps/diffinery/src/http/issues.ts+335−0
- apps/diffinery/src/http/misc.ts+146−0
- apps/diffinery/src/http/releases.ts+110−0
- apps/diffinery/src/http/search.ts+134−0
- apps/diffinery/src/http/statuses.ts+137−0
- apps/diffinery/src/lib/archive.ts+209−0
- apps/diffinery/src/lib/directory.ts+13−0
- apps/diffinery/src/lib/import-hosts.ts+18−0
- apps/diffinery/src/lib/r2-sink.ts+83−0
- apps/diffinery/src/lib/search-query.ts+227−0
- apps/diffinery/src/lib/tree-names.ts+37−0
- apps/diffinery/src/mcp/releases.ts+175−0
- apps/diffinery/src/mcp/server.ts+3−1
- apps/diffinery/src/repo/RepoDO.ts+358−3
- apps/diffinery/src/repo/actions.ts+17−2
- apps/diffinery/src/repo/collab.ts+95−12
- apps/diffinery/src/repo/deployments.ts+512−0
- apps/diffinery/src/repo/extra-triggers.ts+29−0
- apps/diffinery/src/repo/gitdata.ts+614−0
- apps/diffinery/src/repo/queue.ts+13−13
- apps/diffinery/src/repo/release-notes.ts+82−0
- apps/diffinery/src/repo/releases.ts+713−0
- apps/diffinery/src/repo/required.ts+49−0
- apps/diffinery/src/repo/schema.ts+53−0
- apps/diffinery/src/repo/search.ts+117−0
- apps/diffinery/src/repo/social.ts+471−0
- apps/diffinery/src/repo/statuses.ts+154−0
- apps/diffinery/src/repo/types.ts+2−0
- apps/diffinery/src/web/checks.tsx+23−0
- apps/diffinery/src/web/code.tsx+7−3
- apps/diffinery/src/web/collab.tsx+5−10
- apps/diffinery/src/web/csrf.ts+20−3
- apps/diffinery/src/web/releases.tsx+478−0
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/src/web/ui.tsx+2−2
- apps/diffinery/test/e2e/e2e.test.mjs+291−0
- apps/diffinery/test/e2e/security.test.mjs+301−0
- apps/diffinery/test/unit/archive-stream.test.ts+33−0
- apps/diffinery/test/unit/archive.test.ts+105−0
- apps/diffinery/test/unit/environment-secrets.test.ts+24−0
- apps/diffinery/test/unit/r2-sink.test.ts+102−0
- apps/diffinery/test/unit/release-notes.test.ts+87−0
- apps/diffinery/test/unit/required.test.ts+50−0
- apps/diffinery/test/unit/search-query.test.ts+112−0
- apps/diffinery/test/unit/tree-names.test.ts+31−0
- packages/shared/src/events.ts+2−0
- pnpm-lock.yaml+154−0
modifiedREADME.md+102−1View file
| @@ -166,7 +166,8 @@ | ||
| 166 | 166 | Tools include `get_file`, `list_tree`, `commit_files` (no git client needed), |
| 167 | 167 | `create_branch`, `create_pull_request` (with an `intent` for reviewers), |
| 168 | 168 | `review_pull_request`, `merge_pull_request` (policy-guarded), issues, |
| 169 | -`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, and | |
| 169 | +`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, releases | |
| 170 | +(`create_release`, `upload_release_asset`, `generate_release_notes`), and | |
| 170 | 171 | `sandbox_create`/`sandbox_exec` for running builds and tests. |
| 171 | 172 | |
| 172 | 173 | Agent guardrails apply everywhere: git, REST, MCP and the web UI. |
| @@ -177,6 +178,10 @@ | ||
| 177 | 178 | - A pull request opened by an agent needs at least one human approval, and |
| 178 | 179 | approvals from agents don't count toward it. |
| 179 | 180 | - Agents can't change branch protection or read or set secrets. |
| 181 | +- Agents can't change or delete environments or their branch policies, and | |
| 182 | + can't dismiss reviews. | |
| 183 | +- Commit statuses and check runs that agents report never satisfy a required | |
| 184 | + check. | |
| 180 | 185 | |
| 181 | 186 | ## Workflows |
| 182 | 187 | |
| @@ -250,6 +255,99 @@ | ||
| 250 | 255 | `deepen-not` / partial clone filters. A job that uses an unsupported feature fails |
| 251 | 256 | with a clear message instead of misbehaving. |
| 252 | 257 | |
| 258 | +## REST API | |
| 259 | + | |
| 260 | +The API lives at `/api/v3`, with the same paths, JSON shapes and errors as | |
| 261 | +GitHub's REST API. Point a client at it and it works: | |
| 262 | + | |
| 263 | +```js | |
| 264 | +const octokit = new Octokit({ auth: token, baseUrl: "https://diffinery.example.com/api/v3" }); | |
| 265 | +``` | |
| 266 | + | |
| 267 | +Responses carry `node_id`, `url`, `html_url`, full user objects and label | |
| 268 | +objects. Lists take `per_page` and `page` and send a `Link` header, so | |
| 269 | +`octokit.paginate` works. Errors have `message` and `documentation_url`. | |
| 270 | +Responses also keep a few Diffinery fields (such as `merge_blockers` on pull | |
| 271 | +requests) for the CLI, MCP and web UI. | |
| 272 | + | |
| 273 | +What works: | |
| 274 | + | |
| 275 | +- **Repositories:** get, create, update, delete, topics, languages, | |
| 276 | + collaborators, branches, tags, readme, contents (GET, and PUT and DELETE | |
| 277 | + with `sha`; a stale `sha` returns 409), compare, commits, `merges`, | |
| 278 | + `dispatches`, and `zipball`/`tarball` archives. `private: false` makes a | |
| 279 | + repository public. | |
| 280 | +- **Archives:** `zipball` and `tarball` are built on demand and cached in R2. | |
| 281 | + The cache can be evicted at any time; the archive is then built again. An | |
| 282 | + archive holds at most 24 MB of files and 100,000 tree entries. A zip holds | |
| 283 | + at most 65,535 files; use the tarball for more. Each repository can build | |
| 284 | + 20 new archives per hour. Clone bigger repositories with git. | |
| 285 | +- **Git data:** blobs, trees (with `base_tree` and `recursive`), commits, | |
| 286 | + annotated tags, `git/ref`, `git/refs` and `git/matching-refs`. | |
| 287 | +- **Releases:** list, get by id or tag, latest, create (the tag is created | |
| 288 | + if missing), update, delete, `generate-notes` (honours | |
| 289 | + `.github/release.yml`), `make_latest`, and drafts (visible to writers only). | |
| 290 | + Upload assets to the release's `upload_url`. Download them from | |
| 291 | + `browser_download_url`, or from the asset URL with | |
| 292 | + `Accept: application/octet-stream`. Bytes live in R2. An asset can be up to | |
| 293 | + 100 MB, the largest request body Workers accept. | |
| 294 | +- **Commit statuses:** create, list and the combined status. A status counts | |
| 295 | + as a check, so a branch protection's required checks can name a status | |
| 296 | + context. | |
| 297 | +- **Required checks:** when Diffinery Actions reports a check, it alone | |
| 298 | + decides that check. To have a required check decided by someone else, bind | |
| 299 | + it in branch protection, for example | |
| 300 | + `"requiredCheckSources": { "ci/external": "ci-bot" }`. A required check | |
| 301 | + fails if any counted report of it fails. Only the reporter that created a | |
| 302 | + check run can update it, and Actions check runs can't be changed over REST. | |
| 303 | +- **Checks:** check runs (create, update, get, annotations) and check suites. | |
| 304 | +- **Issues and pull requests:** labels, assignees, lock and unlock, | |
| 305 | + comments (by issue and repository-wide), reactions (issues, comments, review | |
| 306 | + comments and releases), events and the timeline, reviews, review comments | |
| 307 | + and replies, review dismissal, requested reviewers, `update-branch`, merge, | |
| 308 | + `commits/{sha}/pulls`, and list filters (`head`, `base`, `sort`, | |
| 309 | + `direction`, `labels`, `assignee`, `since`). Pull requests report | |
| 310 | + `mergeable`, `mergeable_state` and `rebaseable`. Comment, review, check | |
| 311 | + run, user and repository ids are numbers, as on GitHub. Locked | |
| 312 | + conversations refuse comments, review comments and reactions from people | |
| 313 | + without triage access. Dismissing a review takes maintain access, and the | |
| 314 | + pull request's author can't do it. | |
| 315 | +- **Archived repositories** are read-only through the API, for everyone. | |
| 316 | +- **Tree entry names:** every write refuses `.`, `..`, `.git` (in any case, | |
| 317 | + and spellings like `.git.` or `git~1`), and names with `\` or NUL. | |
| 318 | +- **Deployments and environments:** deployments, deployment statuses, | |
| 319 | + environments, deployment branch policies, and environment secrets and | |
| 320 | + variables. A deployment without `required_contexts` needs every context on | |
| 321 | + the commit to have passed, as on GitHub; send `[]` to skip that. An | |
| 322 | + environment with no branch policy gets "protected branches only" when its | |
| 323 | + first secret is added. Environment secrets never go to `refs/pull/*` runs. | |
| 324 | + Runs started by agents only get them when the environment allows protected | |
| 325 | + branches only. Tags pushed by agents never match an environment's tag | |
| 326 | + patterns. | |
| 327 | +- **Actions:** runs, run attempts, jobs, logs (a zip per run), artifacts, | |
| 328 | + workflows, secrets and variables. | |
| 329 | +- **Search:** `/search/issues` (`repo:`, `is:`, `label:`, `author:`, | |
| 330 | + `assignee:`, `no:`, `head:`, `base:`, `in:`, date ranges and `sort:`) and | |
| 331 | + `/search/repositories` (`user:`, `org:`, `topic:`, `is:public`, | |
| 332 | + `archived:`). Search only covers repositories you can read. A query | |
| 333 | + can be up to 256 characters, and one search looks at up to 50 repositories. | |
| 334 | +- `/rate_limit`. Diffinery doesn't rate-limit the API, so it reports a | |
| 335 | + generous budget. | |
| 336 | + | |
| 337 | +Workflows can react to these too. A published release triggers `on: release`, | |
| 338 | +a `POST /dispatches` triggers `on: repository_dispatch`, and webhooks get | |
| 339 | +`release`, `status`, `deployment` and `deployment_status` events. A job with | |
| 340 | +`environment:` creates a deployment, gets that environment's secrets and | |
| 341 | +variables, and is refused if the environment's branch policy doesn't allow | |
| 342 | +the ref. As on GitHub, events caused by `GITHUB_TOKEN` don't start new | |
| 343 | +workflow runs, except `repository_dispatch`. | |
| 344 | + | |
| 345 | +**Not supported yet:** GraphQL (`/api/graphql`), setting secrets with a | |
| 346 | +sealed-box `encrypted_value` (send `value` instead), environment protection | |
| 347 | +rules (required reviewers, wait timers), forks, team reviewers, and code and | |
| 348 | +commit search. Search qualifiers for stars, forks and languages are accepted | |
| 349 | +but ignored. | |
| 350 | + | |
| 253 | 351 | ## Developing |
| 254 | 352 | |
| 255 | 353 | ```sh |
| @@ -275,6 +373,9 @@ | ||
| 275 | 373 | 8. In Chromium with a virtual authenticator, joins from an invite with a new |
| 276 | 374 | passkey, adds and removes passkeys, signs out and in, and uses an admin |
| 277 | 375 | sign-in link. |
| 376 | +9. Drives the REST API with the real `@octokit/rest` client: contents, git | |
| 377 | + data, statuses as required checks, releases with asset uploads and | |
| 378 | + downloads, issues, reactions, pagination, search and deployments. | |
| 278 | 379 | |
| 279 | 380 | Team preferences for product and design decisions are in |
| 280 | 381 | [docs/PREFERENCES.md](docs/PREFERENCES.md). |
addedapps/diffinery/migrations/0007_repo_topics.sql+2−0View file
| @@ -1,0 +1,2 @@ | ||
| 1 | +-- Repository topics (GitHub's `topics`), shown in repository JSON and searchable with `topic:`. | |
| 2 | +ALTER TABLE repos ADD COLUMN topics TEXT NOT NULL DEFAULT '[]'; |
modifiedapps/diffinery/package.json+4−2View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | "typecheck": "tsc --noEmit && tsc --noEmit -p islands", |
| 10 | 10 | "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'", |
| 11 | 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 test/e2e/registry.test.mjs test/e2e/email.test.mjs", | |
| 12 | + "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs", | |
| 13 | 13 | "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev", |
| 14 | 14 | "build": "node islands/build.mjs", |
| 15 | 15 | "predev": "node islands/build.mjs" |
| @@ -20,12 +20,14 @@ | ||
| 20 | 20 | "@diffinery/shared": "workspace:*", |
| 21 | 21 | "@diffinery/workflow-yaml": "workspace:*", |
| 22 | 22 | "@simplewebauthn/server": "14.0.3", |
| 23 | - "hono": "^4.13.0" | |
| 23 | + "hono": "^4.13.0", | |
| 24 | + "yaml": "^2.9.1" | |
| 24 | 25 | }, |
| 25 | 26 | "devDependencies": { |
| 26 | 27 | "@cloudflare/kumo": "2.14.0", |
| 27 | 28 | "@cloudflare/vitest-pool-workers": "^0.23.0", |
| 28 | 29 | "@cloudflare/workers-types": "^5.20261012.1", |
| 30 | + "@octokit/rest": "22.0.1", | |
| 29 | 31 | "@phosphor-icons/react": "2.1.10", |
| 30 | 32 | "@types/react": "19.2.2", |
| 31 | 33 | "@types/react-dom": "19.2.2", |
modifiedapps/diffinery/src/app.ts+14−1View file
| @@ -13,6 +13,8 @@ | ||
| 13 | 13 | import { runner } from "./http/runner.js"; |
| 14 | 14 | import { api, wellKnown } from "./http/api.js"; |
| 15 | 15 | import { mailApi } from "./mail/api.js"; |
| 16 | +import { files } from "./http/files.js"; | |
| 17 | +import { nodeId, numericId } from "./http/gh.js"; | |
| 16 | 18 | import { mcp } from "./mcp/server.js"; |
| 17 | 19 | import { registry } from "./registry/http.js"; |
| 18 | 20 | import { packagesApi } from "./registry/api.js"; |
| @@ -71,6 +73,7 @@ | ||
| 71 | 73 | app.route("/", registry); |
| 72 | 74 | app.route("/", packagesApi); |
| 73 | 75 | app.route("/", api); |
| 76 | +app.route("/", files); | |
| 74 | 77 | app.route("/", mcp); |
| 75 | 78 | |
| 76 | 79 | // Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. |
| @@ -105,11 +108,16 @@ | ||
| 105 | 108 | pull_request_review: "pull_request_review", |
| 106 | 109 | workflow_run: "workflow_run", |
| 107 | 110 | workflow_dispatch: "workflow_dispatch", |
| 111 | + release: "release", | |
| 112 | + status: "status", | |
| 113 | + deployment: "deployment", | |
| 114 | + deployment_status: "deployment_status", | |
| 115 | + repository_dispatch: "repository_dispatch", | |
| 108 | 116 | audit: "audit", |
| 109 | 117 | }; |
| 110 | 118 | |
| 111 | 119 | function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) { |
| 112 | - 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` }; | |
| 120 | + const repository = { id: numericId(ev.repoId), node_id: nodeId("Repository", 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` }; | |
| 113 | 121 | 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 }; |
| 114 | 122 | const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev; |
| 115 | 123 | const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined; |
| @@ -118,6 +126,11 @@ | ||
| 118 | 126 | 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" }; |
| 119 | 127 | } |
| 120 | 128 | 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}` }; |
| 129 | + // GitHub-shaped events (release, status, deployment, ...) carry their payload ready-made. | |
| 130 | + if ("payload" in ev) { | |
| 131 | + const { payload, at: _at, ...head } = rest as typeof rest & { payload: Record<string, unknown>; at: string }; | |
| 132 | + return { ...head, ...payload, action, repository, sender }; | |
| 133 | + } | |
| 121 | 134 | return { ...rest, action, repository, sender, ...extra }; |
| 122 | 135 | } |
| 123 | 136 |
modifiedapps/diffinery/src/auth/sessions.ts+1−1View file
| @@ -78,7 +78,7 @@ | ||
| 78 | 78 | const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>(); |
| 79 | 79 | if (!row || row.expires_at < now()) return null; |
| 80 | 80 | const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); |
| 81 | - if (!account || account.kind !== "user") return null; | |
| 81 | + if (!account || account.kind !== "user" || account.status === "disabled") return null; | |
| 82 | 82 | if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { |
| 83 | 83 | await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run(); |
| 84 | 84 | } |
modifiedapps/diffinery/src/env.ts+2−0View file
| @@ -35,4 +35,6 @@ | ||
| 35 | 35 | OIDC_PRIVATE_KEY?: string; |
| 36 | 36 | /** Secret: optional GitHub token used by the importer. */ |
| 37 | 37 | GITHUB_IMPORT_TOKEN?: string; |
| 38 | + /** Optional GitHub Enterprise host (e.g. github.example.com) the importer may download release assets from. */ | |
| 39 | + IMPORT_GHE_HOST?: string; | |
| 38 | 40 | } |
modifiedapps/diffinery/src/http/api.ts+358−189View file
| @@ -7,10 +7,14 @@ | ||
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| 8 | 8 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 9 | 9 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| 10 | -import type { RunInfo } from "../repo/actions.js"; | |
| 10 | +import type { JobInfo, RunInfo } from "../repo/actions.js"; | |
| 11 | +import type { CommitInfo } from "../repo/RepoDO.js"; | |
| 11 | 12 | import type { QueueEntry } from "../repo/queue.js"; |
| 12 | 13 | import { loadRepo, repoStub } from "./common.js"; |
| 13 | 14 | import { repoCreated } from "../mail/notify.js"; |
| 15 | +import { compat } from "./compat.js"; | |
| 16 | +import { baseOf, encPath, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; | |
| 17 | +import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js"; | |
| 14 | 18 | |
| 15 | 19 | /** |
| 16 | 20 | * REST API. Paths and payloads follow GitHub's REST API v3 where practical so |
| @@ -18,31 +22,14 @@ | ||
| 18 | 22 | */ |
| 19 | 23 | export const api = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); |
| 20 | 24 | |
| 21 | -const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ | |
| 22 | - id: a.id, | |
| 23 | - login: a.login, | |
| 24 | - name: a.name, | |
| 25 | - type: a.kind === "org" ? "Organization" : a.kind === "agent" ? "Bot" : "User", | |
| 26 | - html_url: `${c.env.PUBLIC_URL}/${a.login}`, | |
| 27 | -}); | |
| 25 | +// GitHub-compatible endpoints that live in their own files (releases, statuses, Git data, ...). | |
| 26 | +// Mounted first: some of their paths (like /issues/comments) would otherwise match routes below. | |
| 27 | +api.route("/", compat); | |
| 28 | 28 | |
| 29 | -const repoJson = (c: AppContext, r: Repo) => ({ | |
| 30 | - id: r.id, | |
| 31 | - name: r.name, | |
| 32 | - full_name: `${r.owner_login}/${r.name}`, | |
| 33 | - owner: { login: r.owner_login, type: r.owner_kind === "org" ? "Organization" : "User" }, | |
| 34 | - private: r.visibility !== "public", | |
| 35 | - visibility: r.visibility, | |
| 36 | - description: r.description, | |
| 37 | - default_branch: r.default_branch, | |
| 38 | - archived: !!r.archived, | |
| 39 | - html_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}`, | |
| 40 | - clone_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}.git`, | |
| 41 | - created_at: r.created_at, | |
| 42 | - updated_at: r.updated_at, | |
| 43 | - pushed_at: r.pushed_at, | |
| 44 | -}); | |
| 29 | +const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ ...ghUser(baseOf(c), a)!, name: a.name }); | |
| 45 | 30 | |
| 31 | +const repoJson = repoOut; | |
| 32 | + | |
| 46 | 33 | async function body<T>(c: AppContext): Promise<T> { |
| 47 | 34 | try { |
| 48 | 35 | return (await c.req.json()) as T; |
| @@ -119,7 +106,7 @@ | ||
| 119 | 106 | |
| 120 | 107 | // ---------------------------------------------------------------- repositories |
| 121 | 108 | |
| 122 | -api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))); | |
| 109 | +api.get("/user/repos", async (c) => c.json(paginate(c, (await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))))); | |
| 123 | 110 | api.post("/user/repos", async (c) => { |
| 124 | 111 | const a = requireAccount(c); |
| 125 | 112 | const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); |
| @@ -132,13 +119,13 @@ | ||
| 132 | 119 | const a = requireAccount(c); |
| 133 | 120 | const owner = await c.get("dir").accountByLogin(c.req.param("login")); |
| 134 | 121 | if (!owner) throw new HttpError(404, "not found"); |
| 135 | - return c.json((await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r))); | |
| 122 | + return c.json(paginate(c, (await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r)))); | |
| 136 | 123 | }); |
| 137 | 124 | api.get("/orgs/:org/repos", async (c) => { |
| 138 | 125 | const a = requireAccount(c); |
| 139 | 126 | const org = await c.get("dir").accountByLogin(c.req.param("org")); |
| 140 | 127 | if (!org) throw new HttpError(404, "not found"); |
| 141 | - return c.json((await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r))); | |
| 128 | + return c.json(paginate(c, (await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r)))); | |
| 142 | 129 | }); |
| 143 | 130 | api.post("/orgs/:org/repos", async (c) => { |
| 144 | 131 | const a = requireAccount(c); |
| @@ -151,12 +138,17 @@ | ||
| 151 | 138 | }); |
| 152 | 139 | api.get("/repos/:owner/:repo", async (c) => { |
| 153 | 140 | const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 154 | - return c.json({ ...repoJson(c, repo), permissions: { role: caller.role }, stats: await stub.stats() }); | |
| 141 | + const stats = await stub.stats(); | |
| 142 | + return c.json({ ...repoOut(c, repo, caller.role), open_issues_count: stats.openIssues + stats.openPulls, open_issues: stats.openIssues + stats.openPulls, stats }); | |
| 155 | 143 | }); |
| 156 | 144 | api.patch("/repos/:owner/:repo", async (c) => { |
| 157 | - const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 158 | - const b = await body<{ name?: string; description?: string; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c); | |
| 159 | - 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 }))); | |
| 145 | + const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 146 | + const b = await body<{ name?: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c); | |
| 147 | + if (b.visibility !== undefined && b.visibility !== "public" && b.visibility !== "private") throw new HttpError(422, "visibility must be public or private", { errors: [{ resource: "Repository", code: "invalid", field: "visibility" }] }); | |
| 148 | + if (b.private !== undefined && typeof b.private !== "boolean") throw new HttpError(422, "private must be a boolean", { errors: [{ resource: "Repository", code: "invalid", field: "private" }] }); | |
| 149 | + // GitHub's `private: false` makes the repository public. | |
| 150 | + const visibility = b.visibility ?? (b.private === true ? "private" : b.private === false ? "public" : undefined); | |
| 151 | + return c.json(repoOut(c, await c.get("dir").updateRepo(repo, { name: b.name, description: b.description, visibility, defaultBranch: b.default_branch, archived: b.archived }), caller.role)); | |
| 160 | 152 | }); |
| 161 | 153 | api.delete("/repos/:owner/:repo", async (c) => { |
| 162 | 154 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -167,7 +159,7 @@ | ||
| 167 | 159 | }); |
| 168 | 160 | api.get("/repos/:owner/:repo/collaborators", async (c) => { |
| 169 | 161 | const { repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 170 | - return c.json((await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), role_name: x.role }))); | |
| 162 | + return c.json(paginate(c, (await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), permissions: permissionsOf(x.role), role_name: x.role })))); | |
| 171 | 163 | }); |
| 172 | 164 | api.put("/repos/:owner/:repo/collaborators/:login", async (c) => { |
| 173 | 165 | const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -189,86 +181,162 @@ | ||
| 189 | 181 | |
| 190 | 182 | api.get("/repos/:owner/:repo/branches", async (c) => { |
| 191 | 183 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 192 | - return c.json((await stub.branches()).map((b) => ({ name: b.name, commit: { sha: b.sha }, protected: b.protected, default: b.isDefault }))); | |
| 184 | + const api = repoApi(baseOf(c), P(c).owner, P(c).repo); | |
| 185 | + let list = await stub.branches(); | |
| 186 | + if (c.req.query("protected") !== undefined) list = list.filter((b) => b.protected === (c.req.query("protected") === "true")); | |
| 187 | + return c.json(paginate(c, list.map((b) => ({ name: b.name, commit: { sha: b.sha, url: `${api}/commits/${b.sha}` }, protected: b.protected, default: b.isDefault })))); | |
| 193 | 188 | }); |
| 194 | 189 | api.get("/repos/:owner/:repo/branches/:branch", async (c) => { |
| 195 | 190 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 196 | - const sha = await stub.resolve(c.req.param("branch")); | |
| 197 | - if (!sha) throw new HttpError(404, "branch not found"); | |
| 198 | - return c.json({ name: c.req.param("branch"), commit: await stub.commitInfo(sha) }); | |
| 191 | + const name = c.req.param("branch"); | |
| 192 | + const b = (await stub.branches()).find((x) => x.name === name); | |
| 193 | + if (!b) throw new HttpError(404, "Branch not found"); | |
| 194 | + const api = repoApi(baseOf(c), P(c).owner, P(c).repo); | |
| 195 | + return c.json({ | |
| 196 | + name, | |
| 197 | + commit: ghCommit(baseOf(c), P(c).owner, P(c).repo, await stub.commitInfo(b.sha)), | |
| 198 | + _links: { self: `${api}/branches/${encPath(name)}`, html: `${repoHtml(baseOf(c), P(c).owner, P(c).repo)}/tree/${encPath(name)}` }, | |
| 199 | + protected: b.protected, | |
| 200 | + protection: { enabled: b.protected, required_status_checks: { enforcement_level: b.protected ? "non_admins" : "off", contexts: [], checks: [] } }, | |
| 201 | + protection_url: `${api}/branches/${encPath(name)}/protection`, | |
| 202 | + }); | |
| 199 | 203 | }); |
| 200 | 204 | api.get("/repos/:owner/:repo/tags", async (c) => { |
| 201 | 205 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 202 | - return c.json((await stub.tags()).map((t) => ({ name: t.name, commit: { sha: t.target } }))); | |
| 206 | + const api = repoApi(baseOf(c), P(c).owner, P(c).repo); | |
| 207 | + const tags = (await stub.tags()).reverse(); | |
| 208 | + return c.json(paginate(c, tags.map((t) => ({ name: t.name, zipball_url: `${api}/zipball/refs/tags/${encPath(t.name)}`, tarball_url: `${api}/tarball/refs/tags/${encPath(t.name)}`, commit: { sha: t.target, url: `${api}/commits/${t.target}` }, node_id: nodeId("Ref", `${P(c).owner}/${P(c).repo}/refs/tags/${t.name}`) })))); | |
| 203 | 209 | }); |
| 210 | +// GET /git/refs/<ref>: one ref when the path names it exactly, else every ref under it (GitHub's legacy form). | |
| 204 | 211 | api.get("/repos/:owner/:repo/git/refs/*", async (c) => { |
| 205 | 212 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 206 | - const prefix = `refs/${c.req.path.split("/git/refs/")[1] ?? ""}`; | |
| 207 | - return c.json(stub.refs ? (await stub.refs(prefix)).map((r) => ({ ref: r.name, object: { sha: r.oid } })) : []); | |
| 213 | + const name = `refs/${decodePath(c.req.path.split("/git/refs/")[1] ?? "")}`.replace(/\/$/, ""); | |
| 214 | + const all = await stub.refsWithTypes("refs/"); | |
| 215 | + const exact = all.find((r) => r.ref === name); | |
| 216 | + if (exact) return c.json(refJson(c, exact)); | |
| 217 | + const matches = all.filter((r) => r.ref.startsWith(`${name}/`) && !r.ref.startsWith("refs/pull/")); | |
| 218 | + if (!matches.length) throw new HttpError(404, "Not Found"); | |
| 219 | + return c.json(paginate(c, matches.map((r) => refJson(c, r)), 100)); | |
| 208 | 220 | }); |
| 209 | 221 | api.post("/repos/:owner/:repo/git/refs", async (c) => { |
| 210 | 222 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 211 | 223 | const b = await body<{ ref: string; sha: string }>(c); |
| 224 | + if (typeof b.ref !== "string" || !b.ref.startsWith("refs/") || b.ref.split("/").length < 3) throw new HttpError(422, "Reference name must start with 'refs/' and have at least two slashes.", { errors: [{ resource: "Reference", code: "invalid", field: "ref" }] }); | |
| 225 | + if (typeof b.sha !== "string") throw new HttpError(422, "sha is required", { errors: [{ resource: "Reference", code: "missing_field", field: "sha" }] }); | |
| 212 | 226 | await stub.createRef(b.ref, b.sha, caller); |
| 213 | - return c.json({ ref: b.ref, object: { sha: b.sha } }, 201); | |
| 227 | + return c.json(refJson(c, { ref: b.ref, sha: b.sha, type: (await stub.objectType(b.sha)) ?? "commit" }), 201); | |
| 214 | 228 | }); |
| 215 | 229 | api.patch("/repos/:owner/:repo/git/refs/*", async (c) => { |
| 216 | 230 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 217 | - const ref = `refs/${c.req.path.split("/git/refs/")[1]}`; | |
| 231 | + const ref = `refs/${decodePath(c.req.path.split("/git/refs/")[1] ?? "")}`; | |
| 218 | 232 | const b = await body<{ sha: string; force?: boolean }>(c); |
| 233 | + if (typeof b.sha !== "string") throw new HttpError(422, "sha is required", { errors: [{ resource: "Reference", code: "missing_field", field: "sha" }] }); | |
| 219 | 234 | await stub.updateRef(ref, b.sha, !!b.force, caller); |
| 220 | - return c.json({ ref, object: { sha: b.sha } }); | |
| 235 | + return c.json(refJson(c, { ref, sha: b.sha, type: (await stub.objectType(b.sha)) ?? "commit" })); | |
| 221 | 236 | }); |
| 222 | 237 | api.delete("/repos/:owner/:repo/git/refs/*", async (c) => { |
| 223 | 238 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 224 | - await stub.deleteRef(`refs/${c.req.path.split("/git/refs/")[1]}`, caller); | |
| 239 | + await stub.deleteRef(`refs/${decodePath(c.req.path.split("/git/refs/")[1] ?? "")}`, caller); | |
| 225 | 240 | return c.body(null, 204); |
| 226 | 241 | }); |
| 227 | 242 | api.get("/repos/:owner/:repo/commits", async (c) => { |
| 228 | 243 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 229 | - const perPage = Number(c.req.query("per_page") ?? 30); | |
| 230 | - const page = Number(c.req.query("page") ?? 1); | |
| 231 | - return c.json(await stub.log(c.req.query("sha") ?? "HEAD", { path: c.req.query("path"), limit: perPage, skip: (page - 1) * perPage })); | |
| 244 | + const p = pageOf(c); | |
| 245 | + const rev = c.req.query("sha") ?? "HEAD"; | |
| 246 | + if (!(await stub.resolve(rev))) { | |
| 247 | + if (!(await stub.resolve("HEAD"))) throw new HttpError(409, "Git Repository is empty."); | |
| 248 | + throw new HttpError(404, `No commit found for SHA: ${rev}`); | |
| 249 | + } | |
| 250 | + let list = await stub.log(rev, { path: c.req.query("path"), limit: p.perPage + 1, skip: p.offset }); | |
| 251 | + const author = c.req.query("author")?.toLowerCase(); | |
| 252 | + if (author) list = list.filter((x) => x.author.email.toLowerCase() === author || x.author.name.toLowerCase() === author || x.author.email.toLowerCase().startsWith(`${author}@`)); | |
| 253 | + const since = c.req.query("since") ? Date.parse(c.req.query("since")!) / 1000 : null; | |
| 254 | + const until = c.req.query("until") ? Date.parse(c.req.query("until")!) / 1000 : null; | |
| 255 | + if (since) list = list.filter((x) => x.committer.timestamp >= since); | |
| 256 | + if (until) list = list.filter((x) => x.committer.timestamp <= until); | |
| 257 | + setLinks(c, p, null, list.length > p.perPage); | |
| 258 | + return c.json(list.slice(0, p.perPage).map((x) => ghCommit(baseOf(c), P(c).owner, P(c).repo, x))); | |
| 232 | 259 | }); |
| 233 | 260 | api.get("/repos/:owner/:repo/commits/:ref", async (c) => { |
| 234 | 261 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 235 | - return c.json(await stub.commitWithDiff(c.req.param("ref"))); | |
| 262 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 263 | + if (!sha) throw new HttpError(422, `No commit found for SHA: ${c.req.param("ref")}`); | |
| 264 | + const x = await stub.commitWithDiff(sha); | |
| 265 | + const files = x.files.map((f) => ghFile(baseOf(c), P(c).owner, P(c).repo, sha, f)); | |
| 266 | + return c.json({ ...ghCommit(baseOf(c), P(c).owner, P(c).repo, x), stats: { total: x.additions + x.deletions, additions: x.additions, deletions: x.deletions }, files, truncated: x.truncated, total_files: x.totalFiles }); | |
| 236 | 267 | }); |
| 237 | -api.get("/repos/:owner/:repo/compare/:spec", async (c) => { | |
| 268 | +api.get("/repos/:owner/:repo/compare/:spec{.+}", async (c) => { | |
| 238 | 269 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 239 | - const [base, head] = c.req.param("spec").split("..."); | |
| 270 | + const spec = c.req.param("spec"); | |
| 271 | + const [base, head] = spec.includes("...") ? spec.split("...") : spec.split(".."); | |
| 240 | 272 | if (!base || !head) throw new HttpError(422, "use base...head"); |
| 241 | - return c.json(await stub.compare(base, head)); | |
| 273 | + const r = await stub.compare(base.includes(":") ? base.split(":")[1]! : base, head.includes(":") ? head.split(":")[1]! : head); | |
| 274 | + const b = baseOf(c); | |
| 275 | + const { owner, repo } = P(c); | |
| 276 | + const api = repoApi(b, owner, repo); | |
| 277 | + const html = repoHtml(b, owner, repo); | |
| 278 | + const commits = paginate(c, r.commits, 250); | |
| 279 | + return c.json({ | |
| 280 | + url: `${api}/compare/${spec}`, | |
| 281 | + html_url: `${html}/compare/${spec}`, | |
| 282 | + permalink_url: `${html}/compare/${r.baseSha}...${r.headSha}`, | |
| 283 | + diff_url: `${html}/compare/${spec}.diff`, | |
| 284 | + patch_url: `${html}/compare/${spec}.patch`, | |
| 285 | + base_commit: ghCommit(b, owner, repo, await stub.commitInfo(r.baseSha)), | |
| 286 | + merge_base_commit: r.mergeBase ? ghCommit(b, owner, repo, await stub.commitInfo(r.mergeBase)) : null, | |
| 287 | + status: r.aheadBy && r.behindBy ? "diverged" : r.aheadBy ? "ahead" : r.behindBy ? "behind" : "identical", | |
| 288 | + ahead_by: r.aheadBy, | |
| 289 | + behind_by: r.behindBy, | |
| 290 | + total_commits: r.aheadBy, | |
| 291 | + commits: commits.map((x) => ghCommit(b, owner, repo, x)), | |
| 292 | + files: r.files.map((f) => ghFile(b, owner, repo, r.headSha, f)), | |
| 293 | + // Diffinery: the same comparison in its own words. | |
| 294 | + base_sha: r.baseSha, | |
| 295 | + head_sha: r.headSha, | |
| 296 | + merge_base: r.mergeBase, | |
| 297 | + }); | |
| 242 | 298 | }); |
| 243 | -api.get("/repos/:owner/:repo/contents/*", async (c) => { | |
| 244 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 245 | - const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 246 | - const ref = c.req.query("ref") ?? "HEAD"; | |
| 247 | - const tree = await stub.tree(ref, path); | |
| 248 | - 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" }))); | |
| 249 | - const blob = await stub.blob(ref, path); | |
| 250 | - if (!blob) throw new HttpError(404, "not found"); | |
| 251 | - 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 }); | |
| 252 | -}); | |
| 253 | -api.get("/repos/:owner/:repo/contents", async (c) => { | |
| 254 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 255 | - const tree = await stub.tree(c.req.query("ref") ?? "HEAD", ""); | |
| 256 | - if (!tree) return c.json([]); | |
| 257 | - 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" }))); | |
| 258 | -}); | |
| 299 | +// Contents: files come back base64-encoded with GitHub's links; `Accept: application/vnd.github.raw` returns the bytes. | |
| 300 | +async function getContents(c: AppContext) { | |
| 301 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 302 | + const path = decodePath(c.req.path.split("/contents/")[1] ?? "").replace(/^\/+|\/+$/g, ""); | |
| 303 | + const ref = c.req.query("ref") || repo.default_branch; | |
| 304 | + const sha = await stub.resolve(ref); | |
| 305 | + if (!sha) throw new HttpError(404, `No commit found for the ref ${ref}`); | |
| 306 | + const tree = await stub.tree(sha, path); | |
| 307 | + if (tree) return c.json(tree.items.map((i) => contentJson(c, ref, i.path, i.oid, i.size ?? 0, i.type === "tree" ? "dir" : i.type === "commit" ? "submodule" : i.mode === "120000" ? "symlink" : "file"))); | |
| 308 | + const blob = await stub.blob(sha, path); | |
| 309 | + if (!blob) throw new HttpError(404, "Not Found"); | |
| 310 | + if (/application\/vnd\.github(\.v3)?\.raw/.test(c.req.header("accept") ?? "")) { | |
| 311 | + return new Response(blob.content, { headers: { "content-type": "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox", etag: `"${blob.oid}"` } }); | |
| 312 | + } | |
| 313 | + const type = blob.mode === "120000" ? "symlink" : "file"; | |
| 314 | + return c.json({ ...contentJson(c, ref, path, blob.oid, blob.size, type), ...(type === "symlink" ? { target: new TextDecoder().decode(blob.content) } : {}), encoding: "base64", content: b64(blob.content).replace(/(.{60})/g, "$1\n"), commit: blob.sha }); | |
| 315 | +} | |
| 316 | +api.get("/repos/:owner/:repo/contents/*", getContents); | |
| 317 | +api.get("/repos/:owner/:repo/contents", getContents); | |
| 259 | 318 | api.put("/repos/:owner/:repo/contents/*", async (c) => { |
| 260 | - const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 319 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 261 | 320 | const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); |
| 262 | - const b = await body<{ message: string; content: string; branch?: string; sha?: string }>(c); | |
| 263 | - const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, content: fromB64(b.content) }] }, caller); | |
| 264 | - return c.json({ commit: { sha: res.sha }, content: { path } }, 201); | |
| 321 | + const b = await body<{ message: string; content: string; branch?: string; sha?: string; committer?: { name: string; email: string }; author?: { name: string; email: string } }>(c); | |
| 322 | + if (typeof b.content !== "string") throw new HttpError(422, "content is required (base64)", { errors: [{ resource: "Contents", code: "missing_field", field: "content" }] }); | |
| 323 | + let bytes: Uint8Array; | |
| 324 | + try { | |
| 325 | + bytes = fromB64(b.content.replace(/\s+/g, "")); | |
| 326 | + } catch { | |
| 327 | + throw new HttpError(422, "content is not valid Base64", { errors: [{ resource: "Contents", code: "invalid", field: "content" }] }); | |
| 328 | + } | |
| 329 | + const sig = (s?: { name: string; email: string }) => (s && typeof s.name === "string" && typeof s.email === "string" ? { name: s.name, email: s.email } : undefined); | |
| 330 | + const res = await stub.putContents({ path, content: bytes, message: b.message, branch: b.branch, sha: b.sha, author: sig(b.author), committer: sig(b.committer) ?? sig(b.author) }, caller); | |
| 331 | + const ref = b.branch || (await loadRepo(c, P(c).owner, P(c).repo)).repo.default_branch; | |
| 332 | + return c.json({ content: contentJson(c, ref, path, res.blob.sha, res.blob.size, "file"), commit: gitCommitJson(c, res.commit) }, res.created ? 201 : 200); | |
| 265 | 333 | }); |
| 266 | 334 | api.delete("/repos/:owner/:repo/contents/*", async (c) => { |
| 267 | - const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 335 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 268 | 336 | const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); |
| 269 | - const b = await body<{ message: string; branch?: string }>(c); | |
| 270 | - const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, delete: true }] }, caller); | |
| 271 | - return c.json({ commit: { sha: res.sha } }); | |
| 337 | + const b = await body<{ message: string; branch?: string; sha?: string; committer?: { name: string; email: string }; author?: { name: string; email: string } }>(c); | |
| 338 | + const commit = await stub.deleteContents({ path, message: b.message, branch: b.branch, sha: b.sha, author: b.author, committer: b.committer ?? b.author }, caller); | |
| 339 | + return c.json({ content: null, commit: gitCommitJson(c, commit) }); | |
| 272 | 340 | }); |
| 273 | 341 | |
| 274 | 342 | /** Agent-friendly multi-file commit without a Git client. */ |
| @@ -312,121 +380,137 @@ | ||
| 312 | 380 | |
| 313 | 381 | // ---------------------------------------------------------------- issues & pulls |
| 314 | 382 | |
| 315 | -const issueJson = (c: AppContext, owner: string, repo: string, i: Awaited<ReturnType<ReturnType<typeof repoStub>["issue"]>>["issue"]) => ({ | |
| 316 | - number: i.number, | |
| 317 | - title: i.title, | |
| 318 | - body: i.body, | |
| 319 | - state: i.pull?.merged ? "closed" : i.state, | |
| 320 | - state_reason: i.stateReason, | |
| 321 | - user: { login: i.author.login, type: i.author.kind === "agent" ? "Bot" : "User" }, | |
| 322 | - labels: i.labels.map((name) => ({ name })), | |
| 323 | - assignees: i.assignees.map((login) => ({ login })), | |
| 324 | - comments: i.comments, | |
| 325 | - created_at: i.createdAt, | |
| 326 | - updated_at: i.updatedAt, | |
| 327 | - closed_at: i.closedAt, | |
| 328 | - html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`, | |
| 329 | - ...(i.pull | |
| 330 | - ? { | |
| 331 | - pull_request: { url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${repo}/pulls/${i.number}` }, | |
| 332 | - head: { ref: i.pull.head, sha: i.pull.headSha }, | |
| 333 | - base: { ref: i.pull.base }, | |
| 334 | - draft: i.pull.draft, | |
| 335 | - merged: i.pull.merged, | |
| 336 | - merged_at: i.pull.mergedAt, | |
| 337 | - merged_by: i.pull.mergedBy ? { login: i.pull.mergedBy } : null, | |
| 338 | - merge_commit_sha: i.pull.mergeCommit, | |
| 339 | - requested_reviewers: i.pull.requestedReviewers.map((login) => ({ login })), | |
| 340 | - intent: i.pull.intent, | |
| 341 | - } | |
| 342 | - : {}), | |
| 343 | -}); | |
| 383 | +/** Label names from a GitHub payload (strings or `{ name }` objects). */ | |
| 384 | +const labelNames = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map((l) => (typeof l === "string" ? l : String((l as { name?: unknown })?.name ?? ""))).filter(Boolean) : undefined); | |
| 385 | +/** Assignee logins from `assignees` or the older singular `assignee`. */ | |
| 386 | +const assigneeLogins = (b: { assignees?: unknown; assignee?: unknown }): string[] | undefined => | |
| 387 | + Array.isArray(b.assignees) ? b.assignees.map(String) : b.assignee === null ? [] : typeof b.assignee === "string" ? [b.assignee] : undefined; | |
| 388 | +const sortParam = (v: string | undefined) => (v === "updated" || v === "comments" || v === "created" ? v : undefined); | |
| 344 | 389 | |
| 345 | 390 | for (const kind of ["issues", "pulls"] as const) { |
| 346 | 391 | api.get(`/repos/:owner/:repo/${kind}`, async (c) => { |
| 347 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 348 | - const perPage = Math.min(Number(c.req.query("per_page") ?? 30), 100); | |
| 349 | - const page = Number(c.req.query("page") ?? 1); | |
| 350 | - 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 }); | |
| 392 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 393 | + const p = pageOf(c); | |
| 394 | + const q = (k: string) => c.req.query(k) || undefined; | |
| 395 | + const labels = q("labels")?.split(",").map((l) => l.trim()).filter(Boolean); | |
| 396 | + const head = q("head"); | |
| 397 | + const res = await stub.issues({ | |
| 398 | + kind: kind === "pulls" ? "pull" : undefined, | |
| 399 | + state: (q("state") as "open" | "closed" | "all") ?? "open", | |
| 400 | + labels, | |
| 401 | + author: q("creator"), | |
| 402 | + assignee: q("assignee"), | |
| 403 | + since: q("since"), | |
| 404 | + head: head?.includes(":") ? head.slice(head.indexOf(":") + 1) : head, | |
| 405 | + base: q("base"), | |
| 406 | + sort: kind === "pulls" && q("sort") === "popularity" ? "comments" : sortParam(q("sort")), | |
| 407 | + direction: q("direction") === "asc" ? "asc" : q("direction") === "desc" ? "desc" : undefined, | |
| 408 | + q: q("q"), | |
| 409 | + limit: p.perPage, | |
| 410 | + offset: p.offset, | |
| 411 | + }); | |
| 351 | 412 | c.header("x-total-count", String(res.total)); |
| 352 | - return c.json(res.items.map((i) => issueJson(c, P(c).owner, P(c).repo, i))); | |
| 413 | + setLinks(c, p, res.total); | |
| 414 | + return c.json(kind === "pulls" ? await pullsOut(c, repo, stub, res.items) : await issuesOut(c, repo, stub, res.items)); | |
| 353 | 415 | }); |
| 354 | 416 | } |
| 355 | 417 | api.post("/repos/:owner/:repo/issues", async (c) => { |
| 356 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 357 | - const b = await body<{ title: string; body?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 358 | - return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createIssue(b, caller)), 201); | |
| 418 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 419 | + const b = await body<{ title: string; body?: string; labels?: unknown; assignees?: unknown; assignee?: unknown }>(c); | |
| 420 | + if (typeof b.title !== "string" && typeof b.title !== "number") throw new HttpError(422, "title is required", { errors: [{ resource: "Issue", code: "missing_field", field: "title" }] }); | |
| 421 | + const issue = await stub.createIssue({ title: String(b.title), body: b.body ?? undefined, labels: labelNames(b.labels), assignees: assigneeLogins(b) }, caller); | |
| 422 | + return c.json((await issuesOut(c, repo, stub, [issue]))[0], 201); | |
| 359 | 423 | }); |
| 360 | 424 | api.get("/repos/:owner/:repo/issues/:number", async (c) => { |
| 361 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 362 | - return c.json(issueJson(c, P(c).owner, P(c).repo, (await stub.issue(num(c))).issue)); | |
| 425 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 426 | + return c.json((await issuesOut(c, repo, stub, [(await stub.issue(num(c))).issue]))[0]); | |
| 363 | 427 | }); |
| 364 | 428 | api.patch("/repos/:owner/:repo/issues/:number", async (c) => { |
| 365 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 366 | - const b = await body<{ title?: string; body?: string; state?: "open" | "closed"; state_reason?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 367 | - return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), { ...b, stateReason: b.state_reason }, caller))); | |
| 429 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 430 | + const b = await body<{ title?: string; body?: string | null; state?: "open" | "closed"; state_reason?: string | null; labels?: unknown; assignees?: unknown; assignee?: unknown }>(c); | |
| 431 | + const issue = await stub.updateIssue(num(c), { title: b.title, body: b.body ?? undefined, state: b.state, stateReason: b.state_reason ?? undefined, labels: labelNames(b.labels), assignees: assigneeLogins(b) }, caller); | |
| 432 | + return c.json((await issuesOut(c, repo, stub, [issue]))[0]); | |
| 368 | 433 | }); |
| 369 | 434 | api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => { |
| 370 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 371 | - 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 }))); | |
| 435 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 436 | + const n = num(c); | |
| 437 | + const { issue, comments } = await stub.issue(n); | |
| 438 | + const since = c.req.query("since"); | |
| 439 | + const list = paginate(c, comments.filter((x) => !x.reviewId && (!since || x.updatedAt >= since))); | |
| 440 | + const x = await issueCtx(c, repo, stub, [], list.map((cm) => `comment:${cm.id}`)); | |
| 441 | + return c.json(list.map((cm) => ghComment(x, n, issue.kind === "pull", cm))); | |
| 372 | 442 | }); |
| 373 | 443 | api.post("/repos/:owner/:repo/issues/:number/comments", async (c) => { |
| 374 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 444 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 445 | + const n = num(c); | |
| 375 | 446 | const b = await body<{ body: string }>(c); |
| 376 | - const x = await stub.addComment(num(c), b.body, caller); | |
| 377 | - return c.json({ id: x.id, body: x.body, user: { login: x.author.login }, created_at: x.createdAt }, 201); | |
| 447 | + if (typeof b.body !== "string") throw new HttpError(422, "body is required", { errors: [{ resource: "IssueComment", code: "missing_field", field: "body" }] }); | |
| 448 | + const cm = await stub.addComment(n, b.body, caller); | |
| 449 | + const { issue } = await stub.issue(n); | |
| 450 | + return c.json(ghComment(await issueCtx(c, repo, stub, []), n, issue.kind === "pull", cm), 201); | |
| 378 | 451 | }); |
| 379 | 452 | api.patch("/repos/:owner/:repo/issues/comments/:id", async (c) => { |
| 380 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 381 | - await stub.editComment(c.req.param("id"), (await body<{ body: string }>(c)).body, caller); | |
| 382 | - return c.json({ id: c.req.param("id") }); | |
| 453 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 454 | + const b = await body<{ body: string }>(c); | |
| 455 | + if (typeof b.body !== "string" || !b.body.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "IssueComment", code: "missing_field", field: "body" }] }); | |
| 456 | + await stub.editComment(c.req.param("id"), b.body, caller); | |
| 457 | + const found = await stub.comment(c.req.param("id")); | |
| 458 | + return c.json(ghComment(await issueCtx(c, repo, stub, [], [`comment:${found.comment.id}`]), found.number, found.isPull, found.comment)); | |
| 383 | 459 | }); |
| 384 | 460 | api.get("/repos/:owner/:repo/labels", async (c) => { |
| 385 | 461 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 386 | - return c.json(await stub.labels()); | |
| 462 | + return c.json(paginate(c, (await stub.labelRows()).map((l) => ghLabel(baseOf(c), P(c).owner, P(c).repo, l)))); | |
| 387 | 463 | }); |
| 388 | 464 | api.post("/repos/:owner/:repo/labels", async (c) => { |
| 389 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage"); | |
| 465 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "triage"); | |
| 390 | 466 | const b = await body<{ name: string; color?: string; description?: string }>(c); |
| 391 | - await stub.setLabel(b.name, b.color ?? "888888", b.description ?? ""); | |
| 392 | - return c.json(b, 201); | |
| 467 | + return c.json(ghLabel(baseOf(c), P(c).owner, P(c).repo, await stub.createLabel(b, caller)), 201); | |
| 393 | 468 | }); |
| 394 | 469 | |
| 395 | 470 | api.post("/repos/:owner/:repo/pulls", async (c) => { |
| 396 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 471 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 397 | 472 | const b = await body<{ title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }>(c); |
| 398 | - 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); | |
| 473 | + if (typeof b.head !== "string" || !b.head) throw new HttpError(422, "head is required", { errors: [{ resource: "PullRequest", code: "missing_field", field: "head" }] }); | |
| 474 | + const issue = await stub.createPull({ ...b, head: b.head.includes(":") ? b.head.split(":")[1]! : b.head }, caller); | |
| 475 | + return c.json((await pullsOut(c, repo, stub, [issue]))[0], 201); | |
| 399 | 476 | }); |
| 400 | 477 | api.get("/repos/:owner/:repo/pulls/:number", async (c) => { |
| 401 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 402 | - const n = num(c); | |
| 403 | - const { issue } = await stub.issue(n); | |
| 404 | - if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); | |
| 405 | - const m = await stub.mergeability(n, caller); | |
| 406 | - 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 }); | |
| 478 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 479 | + return c.json(await pullDetail(c, repo, stub, num(c), caller)); | |
| 407 | 480 | }); |
| 408 | 481 | api.patch("/repos/:owner/:repo/pulls/:number", async (c) => { |
| 409 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 410 | - const b = await body<{ title?: string; body?: string; state?: "open" | "closed" }>(c); | |
| 411 | - return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), b, caller))); | |
| 482 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 483 | + const b = await body<{ title?: string; body?: string | null; state?: "open" | "closed" }>(c); | |
| 484 | + const issue = await stub.updateIssue(num(c), { title: b.title, body: b.body ?? undefined, state: b.state }, caller); | |
| 485 | + if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); | |
| 486 | + return c.json((await pullsOut(c, repo, stub, [issue]))[0]); | |
| 412 | 487 | }); |
| 413 | 488 | api.get("/repos/:owner/:repo/pulls/:number/files", async (c) => { |
| 414 | 489 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 415 | 490 | const res = await stub.pullFiles(num(c)); |
| 416 | - 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 }))); | |
| 491 | + return c.json(paginate(c, res.files.map((f) => ({ ...ghFile(baseOf(c), P(c).owner, P(c).repo, res.headSha, f), binary: f.binary })))); | |
| 417 | 492 | }); |
| 418 | 493 | api.get("/repos/:owner/:repo/pulls/:number/commits", async (c) => { |
| 419 | 494 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 420 | - return c.json(await stub.pullCommits(num(c))); | |
| 495 | + return c.json(paginate(c, (await stub.pullCommits(num(c))).map((x) => ghCommit(baseOf(c), P(c).owner, P(c).repo, x)), 250)); | |
| 421 | 496 | }); |
| 497 | +// GitHub clients (Accept: application/vnd.github...) get GitHub's "is it merged" answer: 204 or 404. | |
| 498 | +// Other clients get Diffinery's mergeability report: blockers, approvals, checks and the merge queue. | |
| 422 | 499 | api.get("/repos/:owner/:repo/pulls/:number/merge", async (c) => { |
| 423 | 500 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); |
| 424 | - return c.json(await stub.mergeability(num(c), caller)); | |
| 501 | + const n = num(c); | |
| 502 | + if ((c.req.header("accept") ?? "").includes("application/vnd.github")) { | |
| 503 | + const { issue } = await stub.issue(n); | |
| 504 | + if (issue.pull?.merged) return c.body(null, 204); | |
| 505 | + throw new HttpError(404, "Not Found"); | |
| 506 | + } | |
| 507 | + return c.json(await stub.mergeability(n, caller)); | |
| 425 | 508 | }); |
| 426 | 509 | api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => { |
| 427 | 510 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 428 | 511 | const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean; bypass_merge_queue?: boolean }>(c).catch(() => ({}) as Record<string, undefined>); |
| 429 | - 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, bypassMergeQueue: b.bypass_merge_queue }, caller)); | |
| 512 | + const r = await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch, bypassMergeQueue: b.bypass_merge_queue }, caller); | |
| 513 | + return c.json({ ...r, message: "Pull Request successfully merged" }); | |
| 430 | 514 | }); |
| 431 | 515 | |
| 432 | 516 | // Merge queue (GitHub exposes it through GraphQL only; these follow its REST conventions). |
| @@ -462,24 +546,35 @@ | ||
| 462 | 546 | return c.body(null, 204); |
| 463 | 547 | }); |
| 464 | 548 | api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { |
| 465 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 466 | - 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" } }))); | |
| 549 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 550 | + const n = num(c); | |
| 551 | + const x = await issueCtx(c, repo, stub, []); | |
| 552 | + return c.json(paginate(c, (await stub.issue(n)).reviews.map((r) => ghReview(x, n, r)))); | |
| 467 | 553 | }); |
| 468 | 554 | api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { |
| 469 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 470 | - const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c); | |
| 471 | - const r = await stub.submitReview(num(c), { event: b.event ?? "COMMENT", body: b.body, comments: b.comments }, caller); | |
| 472 | - return c.json({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt }, 201); | |
| 555 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 556 | + const n = num(c); | |
| 557 | + const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; commit_id?: string; comments?: Array<{ path: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c); | |
| 558 | + if (b.event && !["APPROVE", "REQUEST_CHANGES", "COMMENT"].includes(b.event)) throw new HttpError(422, "event must be APPROVE, REQUEST_CHANGES or COMMENT", { errors: [{ resource: "PullRequestReview", code: "invalid", field: "event" }] }); | |
| 559 | + const comments = Array.isArray(b.comments) ? b.comments.map((cm) => ({ path: String(cm.path), line: Number(cm.line ?? cm.position), side: cm.side, body: String(cm.body ?? "") })) : undefined; | |
| 560 | + if (comments?.some((cm) => !cm.path || !Number.isInteger(cm.line) || cm.line < 1 || !cm.body)) throw new HttpError(422, "each review comment needs a path, a line and a body", { errors: [{ resource: "PullRequestReviewComment", code: "invalid", field: "comments" }] }); | |
| 561 | + const r = await stub.submitReview(n, { event: b.event ?? "COMMENT", body: b.body, comments }, caller); | |
| 562 | + return c.json(ghReview(await issueCtx(c, repo, stub, []), n, r), 201); | |
| 473 | 563 | }); |
| 474 | 564 | api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => { |
| 475 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 476 | - 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 }))); | |
| 565 | + const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 566 | + const n = num(c); | |
| 567 | + const list = paginate(c, (await stub.issue(n)).comments.filter((x) => x.reviewId)); | |
| 568 | + const x = await issueCtx(c, repo, stub, [], list.map((cm) => `review_comment:${cm.id}`)); | |
| 569 | + return c.json(list.map((cm) => ghReviewComment(x, n, cm))); | |
| 477 | 570 | }); |
| 478 | 571 | api.post("/repos/:owner/:repo/pulls/:number/requested_reviewers", async (c) => { |
| 479 | - const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 480 | - const b = await body<{ reviewers: string[] }>(c); | |
| 481 | - await stub.requestReviewers(num(c), b.reviewers ?? [], caller); | |
| 482 | - return c.json({ requested_reviewers: b.reviewers }, 201); | |
| 572 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 573 | + const n = num(c); | |
| 574 | + const b = await body<{ reviewers?: string[]; team_reviewers?: string[] }>(c); | |
| 575 | + if (b.team_reviewers?.length) throw new HttpError(422, "team reviewers are not supported; request people by login"); | |
| 576 | + await stub.requestReviewers(n, (b.reviewers ?? []).map(String), caller); | |
| 577 | + return c.json((await pullsOut(c, repo, stub, [(await stub.issue(n)).issue]))[0], 201); | |
| 483 | 578 | }); |
| 484 | 579 | |
| 485 | 580 | // ---------------------------------------------------------------- checks (for plugins and external CI) |
| @@ -488,39 +583,31 @@ | ||
| 488 | 583 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 489 | 584 | const sha = await stub.resolve(c.req.param("ref")); |
| 490 | 585 | if (!sha) throw new HttpError(404, "ref not found"); |
| 491 | - const runs = await stub.checks(sha); | |
| 492 | - 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 })) }); | |
| 586 | + let runs = await stub.checks(sha); | |
| 587 | + const name = c.req.query("check_name"); | |
| 588 | + const status = c.req.query("status"); | |
| 589 | + if (name) runs = runs.filter((r) => r.name === name); | |
| 590 | + if (status) runs = runs.filter((r) => r.status === status); | |
| 591 | + const page = paginate(c, runs); | |
| 592 | + return c.json({ total_count: runs.length, check_runs: page.map((r) => checkRunJson(c, r)) }); | |
| 493 | 593 | }); |
| 494 | 594 | api.post("/repos/:owner/:repo/check-runs", async (c) => { |
| 495 | 595 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 496 | - 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); | |
| 497 | - 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 }); | |
| 498 | - return c.json(check, 201); | |
| 596 | + const b = await body<{ name: string; head_sha: string; status?: "queued" | "in_progress" | "completed"; conclusion?: string; details_url?: string; external_id?: string; started_at?: string; completed_at?: string; output?: { title?: string; summary?: string; text?: string; annotations?: unknown[] } }>(c); | |
| 597 | + if (typeof b.name !== "string" || !b.name || b.name.length > 255) throw new HttpError(422, "name is required", { errors: [{ resource: "CheckRun", code: "missing_field", field: "name" }] }); | |
| 598 | + if (caller.login.toLowerCase().startsWith("diffinery-actions")) throw new HttpError(403, "this account can't report check runs"); | |
| 599 | + const sha = typeof b.head_sha === "string" ? await stub.resolve(b.head_sha) : null; | |
| 600 | + if (!sha) throw new HttpError(422, "head_sha is not a commit in this repository", { errors: [{ resource: "CheckRun", code: "invalid", field: "head_sha" }] }); | |
| 601 | + const status = b.conclusion ? "completed" : (b.status ?? "queued"); | |
| 602 | + const { annotations: _a, text: _t, ...output } = b.output ?? {}; | |
| 603 | + let check = await stub.upsertCheck({ sha, name: b.name, status, conclusion: b.conclusion, detailsUrl: b.details_url, output: b.output ? output : undefined, app: caller.login, creator: { id: caller.id, login: caller.login, kind: caller.kind } }); | |
| 604 | + if (b.external_id !== undefined || b.output?.text !== undefined || Array.isArray(b.output?.annotations)) check = await stub.updateCheckRun(check.id, { external_id: b.external_id, output: b.output }, caller); | |
| 605 | + return c.json(checkRunJson(c, check), 201); | |
| 499 | 606 | }); |
| 500 | 607 | |
| 501 | 608 | // ---------------------------------------------------------------- actions |
| 502 | 609 | |
| 503 | -const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ({ | |
| 504 | - id: r.id, | |
| 505 | - run_number: r.number, | |
| 506 | - run_attempt: r.attempt, | |
| 507 | - name: r.workflowName, | |
| 508 | - display_title: r.title, | |
| 509 | - path: r.workflowPath, | |
| 510 | - event: r.event, | |
| 511 | - head_branch: r.headBranch, | |
| 512 | - head_sha: r.headSha, | |
| 513 | - // The commit the run checked out (GITHUB_SHA): the test merge commit for pull_request runs. | |
| 514 | - sha: r.sha, | |
| 515 | - ref: r.ref, | |
| 516 | - status: r.status, | |
| 517 | - conclusion: r.conclusion, | |
| 518 | - actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" }, | |
| 519 | - created_at: r.createdAt, | |
| 520 | - updated_at: r.updatedAt, | |
| 521 | - html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/actions/runs/${r.id}`, | |
| 522 | - pull_request_number: r.prNumber, | |
| 523 | -}); | |
| 610 | +const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ghRun(baseOf(c), owner, repo, r); | |
| 524 | 611 | |
| 525 | 612 | api.get("/repos/:owner/:repo/actions/workflows", async (c) => { |
| 526 | 613 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| @@ -546,6 +633,7 @@ | ||
| 546 | 633 | const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100); |
| 547 | 634 | const page = Number(c.req.query("page") ?? 1); |
| 548 | 635 | 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 }); |
| 636 | + setLinks(c, { page, perPage, offset: (page - 1) * perPage }, res.total); | |
| 549 | 637 | return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => runJson(c, P(c).owner, P(c).repo, r)) }); |
| 550 | 638 | }); |
| 551 | 639 | api.get("/repos/:owner/:repo/actions/runs/:id", async (c) => { |
| @@ -555,8 +643,9 @@ | ||
| 555 | 643 | }); |
| 556 | 644 | api.get("/repos/:owner/:repo/actions/runs/:id/jobs", async (c) => { |
| 557 | 645 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 558 | - const jobs = await stub.runJobs(c.req.param("id")); | |
| 559 | - return c.json({ total_count: jobs.length, jobs }); | |
| 646 | + const run = await stub.run(c.req.param("id")); | |
| 647 | + const jobs = (await stub.runJobs(c.req.param("id"))) as unknown as JobInfo[]; | |
| 648 | + return c.json({ total_count: jobs.length, jobs: paginate(c, jobs).map((j) => jobJson(c, P(c).owner, P(c).repo, run, j)) }); | |
| 560 | 649 | }); |
| 561 | 650 | api.post("/repos/:owner/:repo/actions/runs/:id/cancel", async (c) => { |
| 562 | 651 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| @@ -572,7 +661,8 @@ | ||
| 572 | 661 | } |
| 573 | 662 | api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => { |
| 574 | 663 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 575 | - return c.json(await stub.job(c.req.param("id"))); | |
| 664 | + const job = (await stub.job(c.req.param("id"))) as unknown as JobInfo; | |
| 665 | + return c.json(jobJson(c, P(c).owner, P(c).repo, await stub.run(job.runId), job)); | |
| 576 | 666 | }); |
| 577 | 667 | api.get("/repos/:owner/:repo/actions/jobs/:id/logs", async (c) => { |
| 578 | 668 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| @@ -583,7 +673,19 @@ | ||
| 583 | 673 | api.get("/repos/:owner/:repo/actions/runs/:id/artifacts", async (c) => { |
| 584 | 674 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 585 | 675 | const list = await stub.artifacts(c.req.param("id")); |
| 586 | - 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)}` })) }); | |
| 676 | + const runId = c.req.param("id"); | |
| 677 | + return c.json({ | |
| 678 | + total_count: list.length, | |
| 679 | + artifacts: paginate(c, list).map((a) => ({ | |
| 680 | + name: a.name, | |
| 681 | + size_in_bytes: a.size, | |
| 682 | + archive_download_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${runId}/artifacts/${encodeURIComponent(a.name)}`, | |
| 683 | + expired: false, | |
| 684 | + created_at: isoSeconds(a.createdAt), | |
| 685 | + updated_at: isoSeconds(a.createdAt), | |
| 686 | + workflow_run: { id: runId }, | |
| 687 | + })), | |
| 688 | + }); | |
| 587 | 689 | }); |
| 588 | 690 | api.get("/repos/:owner/:repo/actions/secrets", async (c) => { |
| 589 | 691 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -593,7 +695,8 @@ | ||
| 593 | 695 | api.put("/repos/:owner/:repo/actions/secrets/:name", async (c) => { |
| 594 | 696 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 595 | 697 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 596 | - const b = await body<{ value: string }>(c); | |
| 698 | + const b = await body<{ value: string; encrypted_value?: string }>(c); | |
| 699 | + if (typeof b.value !== "string" && typeof b.encrypted_value === "string") throw new HttpError(422, "encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest"); | |
| 597 | 700 | if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); |
| 598 | 701 | await stub.setSecret(c.req.param("name"), b.value); |
| 599 | 702 | return c.body(null, 204); |
| @@ -605,7 +708,8 @@ | ||
| 605 | 708 | }); |
| 606 | 709 | api.get("/repos/:owner/:repo/actions/variables", async (c) => { |
| 607 | 710 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 608 | - return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) }); | |
| 711 | + const all = Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })); | |
| 712 | + return c.json({ total_count: all.length, variables: paginate(c, all) }); | |
| 609 | 713 | }); |
| 610 | 714 | api.delete("/repos/:owner/:repo/actions/variables/:name", async (c) => { |
| 611 | 715 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -650,12 +754,16 @@ | ||
| 650 | 754 | |
| 651 | 755 | api.get("/repos/:owner/:repo/hooks", async (c) => { |
| 652 | 756 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 653 | - return c.json((await stub.webhooks()).map(({ secret: _s, ...h }) => h)); | |
| 757 | + return c.json(paginate(c, (await stub.webhooks()).map(({ secret: _s, ...h }) => hookJson(c, h)))); | |
| 654 | 758 | }); |
| 655 | 759 | api.post("/repos/:owner/:repo/hooks", async (c) => { |
| 656 | 760 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 657 | 761 | const b = await body<{ config: { url: string; secret?: string }; events?: string[] }>(c); |
| 658 | - return c.json(await stub.createWebhook(b.config?.url, b.events ?? ["*"], b.config?.secret), 201); | |
| 762 | + if (typeof b.config?.url !== "string") throw new HttpError(422, "config.url is required", { errors: [{ resource: "Hook", code: "missing_field", field: "config.url" }] }); | |
| 763 | + const created = await stub.createWebhook(b.config.url, Array.isArray(b.events) ? b.events.map(String) : ["*"], b.config.secret); | |
| 764 | + const hook = (await stub.webhooks()).find((h) => h.id === created.id)!; | |
| 765 | + // The generated secret is shown once, here (GitHub never returns secrets; Diffinery makes one when none is given). | |
| 766 | + return c.json({ ...hookJson(c, { ...hook, secret: undefined }), secret: b.config.secret ? undefined : created.secret }, 201); | |
| 659 | 767 | }); |
| 660 | 768 | api.delete("/repos/:owner/:repo/hooks/:id", async (c) => { |
| 661 | 769 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -760,4 +868,65 @@ | ||
| 760 | 868 | ); |
| 761 | 869 | wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] })); |
| 762 | 870 | |
| 871 | +// ---------------------------------------------------------------- GitHub JSON helpers used above | |
| 872 | + | |
| 873 | +function permissionsOf(role: Role) { | |
| 874 | + return { admin: role === "admin", maintain: role === "admin" || role === "maintain", push: role !== "read" && role !== "triage", triage: role !== "read", pull: true }; | |
| 875 | +} | |
| 876 | + | |
| 877 | +function refJson(c: AppContext, r: { ref: string; sha: string; type: string }) { | |
| 878 | + return ghRef(baseOf(c), P(c).owner, P(c).repo, r); | |
| 879 | +} | |
| 880 | + | |
| 881 | +function contentJson(c: AppContext, ref: string, path: string, sha: string, size: number, type: string) { | |
| 882 | + const b = baseOf(c); | |
| 883 | + const api = repoApi(b, P(c).owner, P(c).repo); | |
| 884 | + const html = repoHtml(b, P(c).owner, P(c).repo); | |
| 885 | + const self = `${api}/contents/${encPath(path)}?ref=${encodeURIComponent(ref)}`; | |
| 886 | + const git = `${api}/git/${type === "dir" ? "trees" : "blobs"}/${sha}`; | |
| 887 | + const htmlUrl = `${html}/${type === "dir" ? "tree" : "blob"}/${encPath(ref)}/${encPath(path)}`; | |
| 888 | + return { | |
| 889 | + type, | |
| 890 | + size: type === "dir" ? 0 : size, | |
| 891 | + name: path.split("/").pop() ?? path, | |
| 892 | + path, | |
| 893 | + sha, | |
| 894 | + url: self, | |
| 895 | + git_url: git, | |
| 896 | + html_url: htmlUrl, | |
| 897 | + download_url: type === "file" || type === "symlink" ? `${html}/raw/${encPath(ref)}/${encPath(path)}` : null, | |
| 898 | + _links: { self, git, html: htmlUrl }, | |
| 899 | + }; | |
| 900 | +} | |
| 901 | + | |
| 902 | +function gitCommitJson(c: AppContext, x: CommitInfo) { | |
| 903 | + return ghGitCommit(baseOf(c), P(c).owner, P(c).repo, x); | |
| 904 | +} | |
| 905 | + | |
| 906 | +function checkRunJson(c: AppContext, r: Parameters<typeof ghCheckRun>[3]) { | |
| 907 | + return ghCheckRun(baseOf(c), P(c).owner, P(c).repo, r); | |
| 908 | +} | |
| 909 | + | |
| 910 | +function jobJson(c: AppContext, owner: string, repo: string, run: RunInfo, j: JobInfo): Record<string, unknown> { | |
| 911 | + return ghJob(baseOf(c), owner, repo, run, j); | |
| 912 | +} | |
| 913 | + | |
| 914 | +function hookJson(c: AppContext, h: { id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret?: string }) { | |
| 915 | + const api = repoApi(baseOf(c), P(c).owner, P(c).repo); | |
| 916 | + return { | |
| 917 | + type: "Repository", | |
| 918 | + id: h.id, | |
| 919 | + name: "web", | |
| 920 | + active: h.active, | |
| 921 | + events: h.events, | |
| 922 | + config: { url: h.url, content_type: "json", insecure_ssl: "0", ...(h.secret ? { secret: "********" } : {}) }, | |
| 923 | + updated_at: isoSeconds(h.lastDeliveryAt ?? h.createdAt), | |
| 924 | + created_at: isoSeconds(h.createdAt), | |
| 925 | + url: `${api}/hooks/${h.id}`, | |
| 926 | + ping_url: `${api}/hooks/${h.id}/pings`, | |
| 927 | + deliveries_url: `${api}/hooks/${h.id}/deliveries`, | |
| 928 | + last_response: { code: h.lastStatus, status: h.lastStatus === null ? "unused" : h.lastStatus >= 200 && h.lastStatus < 300 ? "active" : "failed", message: null }, | |
| 929 | + }; | |
| 930 | +} | |
| 931 | + | |
| 763 | 932 | export { fromUtf8 }; |
modifiedapps/diffinery/src/http/common.ts+3−2View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import type { Repo } from "../lib/directory.js"; |
| 3 | 3 | import { HttpError } from "../lib/util.js"; |
| 4 | +import { GITHUB_DOCS } from "./gh.js"; | |
| 4 | 5 | import { callerFor, type AppContext } from "../auth/principal.js"; |
| 5 | 6 | import { atLeast, type Caller, type Role } from "../repo/types.js"; |
| 6 | 7 | |
| @@ -24,12 +25,12 @@ | ||
| 24 | 25 | if (e instanceof HttpError) { |
| 25 | 26 | const headers: Record<string, string> = { "content-type": "application/json" }; |
| 26 | 27 | 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 | + return new Response(JSON.stringify({ message: e.detail, documentation_url: GITHUB_DOCS, ...e.extra }), { status: e.status, headers }); | |
| 28 | 29 | } |
| 29 | 30 | // Errors thrown inside Durable Objects arrive as plain Errors; recover HttpError-like status when present. |
| 30 | 31 | const msg = (e as Error)?.message ?? String(e); |
| 31 | 32 | 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 | + if (remote) return new Response(JSON.stringify({ message: msg.slice(5), documentation_url: GITHUB_DOCS }), { status: Number(remote[1]), headers: { "content-type": "application/json" } }); | |
| 33 | 34 | console.error(e); |
| 34 | 35 | return new Response(JSON.stringify({ message: "internal error" }), { status: 500, headers: { "content-type": "application/json" } }); |
| 35 | 36 | } |
addedapps/diffinery/src/http/compat.ts+40−0View file
| @@ -1,0 +1,40 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { deployments } from "./deployments.js"; | |
| 5 | +import { gitdata } from "./gitdata.js"; | |
| 6 | +import { issues } from "./issues.js"; | |
| 7 | +import { misc } from "./misc.js"; | |
| 8 | +import { releases } from "./releases.js"; | |
| 9 | +import { search } from "./search.js"; | |
| 10 | +import { statuses } from "./statuses.js"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * GitHub-compatible REST endpoints beyond the core in ./api.ts, so Octokit, | |
| 14 | + * `gh api`, actions/github-script and marketplace actions work unchanged. | |
| 15 | + * Mounted at the top of the /api/v3 app. | |
| 16 | + */ | |
| 17 | +export const compat = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 18 | + | |
| 19 | +// Headers GitHub clients look at: API version and media type, and a (generous, unmetered) rate limit. | |
| 20 | +compat.use("*", async (c, next) => { | |
| 21 | + await next(); | |
| 22 | + const h = c.res.headers; | |
| 23 | + h.set("x-github-media-type", "github.v3; format=json"); | |
| 24 | + h.set("x-github-api-version-selected", "2022-11-28"); | |
| 25 | + if (!h.has("x-ratelimit-limit")) { | |
| 26 | + h.set("x-ratelimit-limit", "15000"); | |
| 27 | + h.set("x-ratelimit-remaining", "15000"); | |
| 28 | + h.set("x-ratelimit-used", "0"); | |
| 29 | + h.set("x-ratelimit-reset", String(Math.floor(Date.now() / 1000) + 3600)); | |
| 30 | + h.set("x-ratelimit-resource", "core"); | |
| 31 | + } | |
| 32 | +}); | |
| 33 | + | |
| 34 | +compat.route("/", releases); | |
| 35 | +compat.route("/", statuses); | |
| 36 | +compat.route("/", gitdata); | |
| 37 | +compat.route("/", issues); | |
| 38 | +compat.route("/", deployments); | |
| 39 | +compat.route("/", search); | |
| 40 | +compat.route("/", misc); |
addedapps/diffinery/src/http/deployments.ts+239−0View file
| @@ -1,0 +1,239 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import { parsePayload, type Deployment, type DeploymentStatus, type Environment } from "../repo/deployments.js"; | |
| 6 | +import type { Caller } from "../repo/types.js"; | |
| 7 | +import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; | |
| 8 | +import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; | |
| 9 | + | |
| 10 | +/** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */ | |
| 11 | +export const deployments = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +const R = "/repos/:owner/:repo"; | |
| 14 | +const load = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadTarget(c, min); | |
| 15 | +const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo); | |
| 16 | +const envName = (c: AppContext) => (c.req.param("env") ?? ""); | |
| 17 | + | |
| 18 | +function deploymentJson(c: AppContext, d: Deployment): Record<string, unknown> { | |
| 19 | + const url = `${api(c)}/deployments/${d.id}`; | |
| 20 | + return { | |
| 21 | + url, | |
| 22 | + id: d.id, | |
| 23 | + node_id: nodeId("Deployment", `${repoParams(c).owner}/${repoParams(c).repo}/${d.id}`), | |
| 24 | + sha: d.sha, | |
| 25 | + ref: d.ref, | |
| 26 | + task: d.task, | |
| 27 | + payload: parsePayload(d.payload), | |
| 28 | + original_environment: d.originalEnvironment, | |
| 29 | + environment: d.environment, | |
| 30 | + description: d.description, | |
| 31 | + creator: ghUser(baseOf(c), d.creator), | |
| 32 | + created_at: isoSeconds(d.createdAt), | |
| 33 | + updated_at: isoSeconds(d.updatedAt), | |
| 34 | + statuses_url: `${url}/statuses`, | |
| 35 | + repository_url: api(c), | |
| 36 | + transient_environment: d.transient, | |
| 37 | + production_environment: d.production, | |
| 38 | + performed_via_github_app: null, | |
| 39 | + }; | |
| 40 | +} | |
| 41 | + | |
| 42 | +function statusJson(c: AppContext, s: DeploymentStatus) { | |
| 43 | + const dep = `${api(c)}/deployments/${s.deploymentId}`; | |
| 44 | + return { | |
| 45 | + url: `${dep}/statuses/${s.id}`, | |
| 46 | + id: s.id, | |
| 47 | + node_id: nodeId("DeploymentStatus", `${repoParams(c).owner}/${repoParams(c).repo}/${s.id}`), | |
| 48 | + state: s.state, | |
| 49 | + creator: ghUser(baseOf(c), s.creator), | |
| 50 | + description: s.description, | |
| 51 | + environment: s.environment, | |
| 52 | + target_url: s.logUrl, | |
| 53 | + log_url: s.logUrl, | |
| 54 | + environment_url: s.environmentUrl, | |
| 55 | + created_at: isoSeconds(s.createdAt), | |
| 56 | + updated_at: isoSeconds(s.createdAt), | |
| 57 | + deployment_url: dep, | |
| 58 | + repository_url: api(c), | |
| 59 | + performed_via_github_app: null, | |
| 60 | + }; | |
| 61 | +} | |
| 62 | + | |
| 63 | +function environmentJson(c: AppContext, e: Environment) { | |
| 64 | + const name = encodeURIComponent(e.name); | |
| 65 | + return { | |
| 66 | + id: e.id, | |
| 67 | + node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`), | |
| 68 | + name: e.name, | |
| 69 | + url: `${api(c)}/environments/${name}`, | |
| 70 | + html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/deployments/activity_log?environments_filter=${name}`, | |
| 71 | + created_at: isoSeconds(e.createdAt), | |
| 72 | + updated_at: isoSeconds(e.updatedAt), | |
| 73 | + can_admins_bypass: true, | |
| 74 | + protection_rules: e.policy ? [{ id: e.id, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }] : [], | |
| 75 | + deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, | |
| 76 | + }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** Environment secrets and settings are sensitive: changes go to the audit log. */ | |
| 80 | +function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) { | |
| 81 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action, target: `${repoParams(c).owner}/${repoParams(c).repo}`, detail })); | |
| 82 | +} | |
| 83 | + | |
| 84 | +// ---------------------------------------------------------------- deployments | |
| 85 | + | |
| 86 | +deployments.get(`${R}/deployments`, async (c) => { | |
| 87 | + const { stub } = await load(c); | |
| 88 | + const p = pageOf(c); | |
| 89 | + const q = (k: string) => c.req.query(k) || undefined; | |
| 90 | + const res = await stub.deployments({ sha: q("sha"), ref: q("ref"), task: q("task"), environment: q("environment"), limit: p.perPage, offset: p.offset }); | |
| 91 | + setLinks(c, p, res.total); | |
| 92 | + return c.json(res.items.map((d) => deploymentJson(c, d))); | |
| 93 | +}); | |
| 94 | +deployments.post(`${R}/deployments`, async (c) => { | |
| 95 | + const { stub, caller } = await load(c, "write"); | |
| 96 | + const b = await jsonBody<{ ref?: string; task?: string; auto_merge?: boolean; required_contexts?: unknown; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean }>(c); | |
| 97 | + if (b.required_contexts !== undefined && b.required_contexts !== null && (!Array.isArray(b.required_contexts) || b.required_contexts.length > 100 || b.required_contexts.some((x) => typeof x !== "string"))) { | |
| 98 | + throw new HttpError(422, "required_contexts must be an array of strings", { errors: [{ resource: "Deployment", code: "invalid", field: "required_contexts" }] }); | |
| 99 | + } | |
| 100 | + const required = Array.isArray(b.required_contexts) ? (b.required_contexts as string[]) : undefined; | |
| 101 | + const d = await stub.createDeployment({ ref: b.ref, task: b.task, payload: b.payload, environment: b.environment, description: b.description, transient_environment: b.transient_environment, production_environment: b.production_environment, required_contexts: required }, caller); | |
| 102 | + return c.json(deploymentJson(c, d), 201); | |
| 103 | +}); | |
| 104 | +deployments.get(`${R}/deployments/:id`, async (c) => { | |
| 105 | + const { stub } = await load(c); | |
| 106 | + return c.json(deploymentJson(c, await stub.deployment(intParam(c, "id")))); | |
| 107 | +}); | |
| 108 | +deployments.delete(`${R}/deployments/:id`, async (c) => { | |
| 109 | + const { stub, caller } = await load(c, "write"); | |
| 110 | + await stub.deleteDeployment(intParam(c, "id"), caller); | |
| 111 | + return c.body(null, 204); | |
| 112 | +}); | |
| 113 | +deployments.get(`${R}/deployments/:id/statuses`, async (c) => { | |
| 114 | + const { stub } = await load(c); | |
| 115 | + return c.json(paginate(c, (await stub.deploymentStatuses(intParam(c, "id"))).map((s) => statusJson(c, s)))); | |
| 116 | +}); | |
| 117 | +deployments.post(`${R}/deployments/:id/statuses`, async (c) => { | |
| 118 | + const { stub, caller } = await load(c, "write"); | |
| 119 | + const b = await jsonBody<{ state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }>(c); | |
| 120 | + return c.json(statusJson(c, await stub.createDeploymentStatus(intParam(c, "id"), b, caller)), 201); | |
| 121 | +}); | |
| 122 | +deployments.get(`${R}/deployments/:id/statuses/:status`, async (c) => { | |
| 123 | + const { stub } = await load(c); | |
| 124 | + return c.json(statusJson(c, await stub.deploymentStatus(intParam(c, "id"), intParam(c, "status")))); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +// ---------------------------------------------------------------- environments | |
| 128 | + | |
| 129 | +deployments.get(`${R}/environments`, async (c) => { | |
| 130 | + const { stub } = await load(c); | |
| 131 | + const all = await stub.environments(); | |
| 132 | + return c.json({ total_count: all.length, environments: paginate(c, all).map((e) => environmentJson(c, e)) }); | |
| 133 | +}); | |
| 134 | +deployments.get(`${R}/environments/:env`, async (c) => { | |
| 135 | + const { stub } = await load(c); | |
| 136 | + return c.json(environmentJson(c, await stub.environment(envName(c)))); | |
| 137 | +}); | |
| 138 | +deployments.put(`${R}/environments/:env`, async (c) => { | |
| 139 | + const { stub, caller } = await load(c, "admin"); | |
| 140 | + const b = await jsonBody<{ wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: boolean }>(c, true); | |
| 141 | + const e = await stub.upsertEnvironment(envName(c), b, caller); | |
| 142 | + audit(c, caller, "environment.update", { environment: e.name, deployment_branch_policy: b.deployment_branch_policy ?? null }); | |
| 143 | + return c.json(environmentJson(c, e)); | |
| 144 | +}); | |
| 145 | +deployments.delete(`${R}/environments/:env`, async (c) => { | |
| 146 | + const { stub, caller } = await load(c, "admin"); | |
| 147 | + await stub.deleteEnvironment(envName(c), caller); | |
| 148 | + audit(c, caller, "environment.delete", { environment: envName(c) }); | |
| 149 | + return c.body(null, 204); | |
| 150 | +}); | |
| 151 | +deployments.get(`${R}/environments/:env/deployment-branch-policies`, async (c) => { | |
| 152 | + const { stub } = await load(c); | |
| 153 | + const e = await stub.environment(envName(c)); | |
| 154 | + const list = e.policy?.patterns ?? []; | |
| 155 | + return c.json({ total_count: list.length, branch_policies: paginate(c, list).map((p) => ({ ...p, node_id: nodeId("BranchPolicy", `${e.id}/${p.id}`) })) }); | |
| 156 | +}); | |
| 157 | +deployments.post(`${R}/environments/:env/deployment-branch-policies`, async (c) => { | |
| 158 | + const { stub, caller } = await load(c, "admin"); | |
| 159 | + const b = await jsonBody<{ name?: string; type?: string }>(c); | |
| 160 | + const p = await stub.addEnvironmentBranchPattern(envName(c), b, caller); | |
| 161 | + audit(c, caller, "environment.branch_policy.create", { environment: envName(c), pattern: p.name, type: p.type }); | |
| 162 | + return c.json({ ...p, node_id: nodeId("BranchPolicy", p.id) }); | |
| 163 | +}); | |
| 164 | +deployments.delete(`${R}/environments/:env/deployment-branch-policies/:id`, async (c) => { | |
| 165 | + const { stub, caller } = await load(c, "admin"); | |
| 166 | + await stub.removeEnvironmentBranchPattern(envName(c), intParam(c, "id"), caller); | |
| 167 | + audit(c, caller, "environment.branch_policy.delete", { environment: envName(c), id: intParam(c, "id") }); | |
| 168 | + return c.body(null, 204); | |
| 169 | +}); | |
| 170 | + | |
| 171 | +// ---------------------------------------------------------------- environment secrets (names only, never values) and variables | |
| 172 | + | |
| 173 | +deployments.get(`${R}/environments/:env/secrets`, async (c) => { | |
| 174 | + const { stub } = await load(c, "admin"); | |
| 175 | + const list = await stub.environmentSecretNames(envName(c)); | |
| 176 | + return c.json({ total_count: list.length, secrets: paginate(c, list).map((s) => ({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) })) }); | |
| 177 | +}); | |
| 178 | +deployments.get(`${R}/environments/:env/secrets/:name`, async (c) => { | |
| 179 | + const { stub } = await load(c, "admin"); | |
| 180 | + const s = (await stub.environmentSecretNames(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); | |
| 181 | + if (!s) throw new HttpError(404, "Not Found"); | |
| 182 | + return c.json({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) }); | |
| 183 | +}); | |
| 184 | +deployments.put(`${R}/environments/:env/secrets/:name`, async (c) => { | |
| 185 | + const { stub, caller } = await load(c, "admin"); | |
| 186 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 187 | + const b = await jsonBody<{ value?: string; encrypted_value?: string }>(c); | |
| 188 | + if (typeof b.value !== "string" && typeof b.encrypted_value === "string") throw new HttpError(422, "encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest"); | |
| 189 | + if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); | |
| 190 | + const existed = (await stub.environmentSecretNames(envName(c))).some((x) => x.name === c.req.param("name").toUpperCase()); | |
| 191 | + await stub.setEnvironmentSecret(envName(c), c.req.param("name"), b.value, caller); | |
| 192 | + audit(c, caller, "environment.secret.set", { environment: envName(c), name: c.req.param("name").toUpperCase() }); | |
| 193 | + return c.body(null, existed ? 204 : 201); | |
| 194 | +}); | |
| 195 | +deployments.delete(`${R}/environments/:env/secrets/:name`, async (c) => { | |
| 196 | + const { stub, caller } = await load(c, "admin"); | |
| 197 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 198 | + await stub.deleteEnvironmentSecret(envName(c), c.req.param("name"), caller); | |
| 199 | + audit(c, caller, "environment.secret.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() }); | |
| 200 | + return c.body(null, 204); | |
| 201 | +}); | |
| 202 | + | |
| 203 | +const variableJson = (v: { name: string; value: string; createdAt: string; updatedAt: string }) => ({ name: v.name, value: v.value, created_at: isoSeconds(v.createdAt), updated_at: isoSeconds(v.updatedAt) }); | |
| 204 | + | |
| 205 | +deployments.get(`${R}/environments/:env/variables`, async (c) => { | |
| 206 | + const { stub } = await load(c, "write"); | |
| 207 | + const list = await stub.environmentVariables(envName(c)); | |
| 208 | + return c.json({ total_count: list.length, variables: paginate(c, list, 10).map(variableJson) }); | |
| 209 | +}); | |
| 210 | +deployments.get(`${R}/environments/:env/variables/:name`, async (c) => { | |
| 211 | + const { stub } = await load(c, "write"); | |
| 212 | + const v = (await stub.environmentVariables(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); | |
| 213 | + if (!v) throw new HttpError(404, "Not Found"); | |
| 214 | + return c.json(variableJson(v)); | |
| 215 | +}); | |
| 216 | +deployments.post(`${R}/environments/:env/variables`, async (c) => { | |
| 217 | + const { stub, caller } = await load(c, "admin"); | |
| 218 | + const b = await jsonBody<{ name?: string; value?: string }>(c); | |
| 219 | + if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required"); | |
| 220 | + if ((await stub.environmentVariables(envName(c))).some((x) => x.name === b.name!.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists"); | |
| 221 | + await stub.setEnvironmentVariable(envName(c), b.name, b.value, caller); | |
| 222 | + return c.json({}, 201); | |
| 223 | +}); | |
| 224 | +deployments.patch(`${R}/environments/:env/variables/:name`, async (c) => { | |
| 225 | + const { stub, caller } = await load(c, "admin"); | |
| 226 | + const b = await jsonBody<{ name?: string; value?: string }>(c); | |
| 227 | + const name = c.req.param("name"); | |
| 228 | + const current = (await stub.environmentVariables(envName(c))).find((x) => x.name === name.toUpperCase()); | |
| 229 | + if (!current) throw new HttpError(404, "Not Found"); | |
| 230 | + const next = typeof b.name === "string" && b.name ? b.name : name; | |
| 231 | + await stub.setEnvironmentVariable(envName(c), next, typeof b.value === "string" ? b.value : current.value, caller); | |
| 232 | + if (next.toUpperCase() !== name.toUpperCase()) await stub.setEnvironmentVariable(envName(c), name, null, caller); | |
| 233 | + return c.body(null, 204); | |
| 234 | +}); | |
| 235 | +deployments.delete(`${R}/environments/:env/variables/:name`, async (c) => { | |
| 236 | + const { stub, caller } = await load(c, "admin"); | |
| 237 | + await stub.setEnvironmentVariable(envName(c), c.req.param("name"), null, caller); | |
| 238 | + return c.body(null, 204); | |
| 239 | +}); |
addedapps/diffinery/src/http/files.ts+132−0View file
| @@ -1,0 +1,132 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import type { Caller } from "../repo/types.js"; | |
| 6 | +import { R2Sink } from "../lib/r2-sink.js"; | |
| 7 | +import { baseOf, ghAsset } from "./gh.js"; | |
| 8 | +import { decodePath, intParam, loadTarget, repoParams, type Stub } from "./ghctx.js"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Byte routes outside /api/v3, at the paths GitHub clients expect: | |
| 12 | + * release asset uploads (`upload_url`), browser downloads of release assets | |
| 13 | + * and source archives, and avatars for the `avatar_url` in user objects. | |
| 14 | + */ | |
| 15 | +export const files = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 16 | + | |
| 17 | +/** Workers accept request bodies up to 100 MB, so that is the real limit for an asset. */ | |
| 18 | +export const MAX_ASSET_BYTES = 100 * 1024 * 1024; | |
| 19 | +const TOO_LARGE = "release assets are limited to 100 MB"; | |
| 20 | + | |
| 21 | +/** Store an asset's bytes in R2 and record it. Streams either way; without a length, bytes are counted and the upload stops at the limit. */ | |
| 22 | +export async function storeAsset( | |
| 23 | + env: Env, | |
| 24 | + stub: Stub, | |
| 25 | + releaseId: number, | |
| 26 | + meta: { name: string; label?: string; contentType?: string }, | |
| 27 | + data: { stream: ReadableStream<Uint8Array>; length: number | null }, | |
| 28 | + caller: Caller, | |
| 29 | +) { | |
| 30 | + if (data.length !== null && data.length > MAX_ASSET_BYTES) throw new HttpError(413, TOO_LARGE); | |
| 31 | + const { id, r2Key } = await stub.startReleaseAsset(releaseId, meta, caller); | |
| 32 | + try { | |
| 33 | + let size: number; | |
| 34 | + if (data.length !== null) { | |
| 35 | + const fixed = new FixedLengthStream(data.length); | |
| 36 | + await Promise.all([data.stream.pipeTo(fixed.writable), env.BUCKET.put(r2Key, fixed.readable, { httpMetadata: { contentType: meta.contentType } })]); | |
| 37 | + size = data.length; | |
| 38 | + } else { | |
| 39 | + const sink = new R2Sink(env.BUCKET, r2Key, meta.contentType ?? "application/octet-stream", MAX_ASSET_BYTES, TOO_LARGE); | |
| 40 | + const reader = data.stream.getReader(); | |
| 41 | + try { | |
| 42 | + for (;;) { | |
| 43 | + const { done, value } = await reader.read(); | |
| 44 | + if (done) break; | |
| 45 | + await sink.write(value); | |
| 46 | + } | |
| 47 | + size = await sink.close(); | |
| 48 | + } catch (e) { | |
| 49 | + await reader.cancel().catch(() => undefined); | |
| 50 | + await sink.abort(); | |
| 51 | + throw e; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + return await stub.finishReleaseAsset(id, size); | |
| 55 | + } catch (e) { | |
| 56 | + await stub.abortReleaseAsset(id); | |
| 57 | + await env.BUCKET.delete(r2Key).catch(() => undefined); | |
| 58 | + throw e; | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** Serve stored bytes as a download that browsers never render inline. */ | |
| 63 | +export async function serveDownload(c: AppContext, key: string, name: string, contentType: string): Promise<Response> { | |
| 64 | + const obj = await c.env.BUCKET.get(key); | |
| 65 | + if (!obj) throw new HttpError(404, "Not Found"); | |
| 66 | + return new Response(obj.body, { | |
| 67 | + headers: { | |
| 68 | + "content-type": contentType || "application/octet-stream", | |
| 69 | + "content-length": String(obj.size), | |
| 70 | + "content-disposition": `attachment; filename="${name.replace(/[^\w.+@~-]+/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`, | |
| 71 | + "content-security-policy": "sandbox", | |
| 72 | + "x-content-type-options": "nosniff", | |
| 73 | + etag: obj.httpEtag, | |
| 74 | + }, | |
| 75 | + }); | |
| 76 | +} | |
| 77 | + | |
| 78 | +// POST {upload_url}?name=...&label=...: the raw request body is the file (Octokit's uploadReleaseAsset, `gh release upload`). | |
| 79 | +files.post("/api/uploads/repos/:owner/:repo/releases/:id/assets", async (c) => { | |
| 80 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 81 | + const name = c.req.query("name"); | |
| 82 | + if (!name) throw new HttpError(422, "the name query parameter is required", { errors: [{ resource: "ReleaseAsset", code: "missing_field", field: "name" }] }); | |
| 83 | + const body = c.req.raw.body; | |
| 84 | + if (!body) throw new HttpError(422, "the request body is the asset's content"); | |
| 85 | + const len = c.req.header("content-length"); | |
| 86 | + const length = len !== undefined && /^\d+$/.test(len) ? Number(len) : null; | |
| 87 | + const asset = await storeAsset(c.env, stub, intParam(c, "id"), { name, label: c.req.query("label") ?? undefined, contentType: c.req.header("content-type") }, { stream: body, length }, caller); | |
| 88 | + const release = await stub.getRelease(intParam(c, "id"), caller); | |
| 89 | + return c.json(ghAsset(baseOf(c), repo.owner_login, repo.name, release.tagName, asset), 201); | |
| 90 | +}); | |
| 91 | + | |
| 92 | +// Browser download of a release asset: /<owner>/<repo>/releases/download/<tag>/<name> (tags may contain slashes). | |
| 93 | +files.get("/:owner/:repo/releases/download/*", async (c) => { | |
| 94 | + const { stub, caller } = await loadTarget(c); | |
| 95 | + const rest = c.req.path.split("/releases/download/")[1] ?? ""; | |
| 96 | + const cut = rest.lastIndexOf("/"); | |
| 97 | + if (cut <= 0) throw new HttpError(404, "Not Found"); | |
| 98 | + const tag = decodePath(rest.slice(0, cut)); | |
| 99 | + const name = decodePath(rest.slice(cut + 1)); | |
| 100 | + const d = await stub.releaseDownload({ tag, name }, caller); | |
| 101 | + return serveDownload(c, d.r2Key, d.name, d.contentType); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +/** A source archive of `ref` (built once per commit and kept in R2). */ | |
| 105 | +export async function archiveResponse(c: AppContext, stub: Stub, owner: string, repo: string, ref: string, format: "zip" | "tar.gz"): Promise<Response> { | |
| 106 | + const sha = await stub.resolve(ref || "HEAD"); | |
| 107 | + if (!sha) throw new HttpError(404, "Not Found"); | |
| 108 | + let key = await stub.archiveKey(sha, format); | |
| 109 | + if (!(await c.env.BUCKET.head(key))) key = (await stub.buildArchive(sha, format)).key; | |
| 110 | + return serveDownload(c, key, `${owner}-${repo}-${sha.slice(0, 7)}.${format}`, format === "zip" ? "application/zip" : "application/gzip"); | |
| 111 | +} | |
| 112 | + | |
| 113 | +// /<owner>/<repo>/archive/<ref>.zip and .tar.gz (also refs/heads/<branch>.zip, like GitHub's archive links). | |
| 114 | +files.get("/:owner/:repo/archive/*", async (c) => { | |
| 115 | + const { stub, repo } = await loadTarget(c); | |
| 116 | + const spec = decodePath(c.req.path.split("/archive/")[1] ?? ""); | |
| 117 | + const m = /^(.+)\.(zip|tar\.gz)$/.exec(spec); | |
| 118 | + if (!m) throw new HttpError(404, "Not Found"); | |
| 119 | + return archiveResponse(c, stub, repo.owner_login, repo.name, m[1]!, m[2] as "zip" | "tar.gz"); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +// Letter avatars for `avatar_url`: a coloured square with the login's first letter. | |
| 123 | +files.get("/api/avatars/:login", (c) => { | |
| 124 | + const login = c.req.param("login").slice(0, 100); | |
| 125 | + let hash = 0; | |
| 126 | + for (const ch of login) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; | |
| 127 | + const hue = hash % 360; | |
| 128 | + const letter = (login.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase(); | |
| 129 | + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="12" fill="hsl(${hue} 55% 45%)"/><text x="48" y="64" font-family="system-ui, sans-serif" font-size="48" font-weight="600" text-anchor="middle" fill="#fff">${letter}</text></svg>`; | |
| 130 | + return c.body(svg, 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'" }); | |
| 131 | +}); | |
| 132 | + |
addedapps/diffinery/src/http/gh.ts+753−0View file
| @@ -1,0 +1,753 @@ | ||
| 1 | +/** | |
| 2 | + * GitHub REST v3 JSON shapes and pagination, shared by every API route. | |
| 3 | + * | |
| 4 | + * Octokit, `gh api`, `actions/github-script` and marketplace actions read | |
| 5 | + * fields like `node_id`, `url`, `html_url`, full `user` objects and `Link` | |
| 6 | + * headers, so responses carry them alongside Diffinery's own fields. The | |
| 7 | + * builders take the instance's public URL (no Worker context) so the | |
| 8 | + * repository Durable Object can use them for webhook payloads too. | |
| 9 | + */ | |
| 10 | +import type { Context } from "hono"; | |
| 11 | +import { b64 } from "../lib/util.js"; | |
| 12 | +import { atLeast, type Role } from "../repo/types.js"; | |
| 13 | + | |
| 14 | +export const GITHUB_DOCS = "https://docs.github.com/rest"; | |
| 15 | + | |
| 16 | +/** PUBLIC_URL without a trailing slash. */ | |
| 17 | +export const baseOf = (c: { env: { PUBLIC_URL: string } }): string => c.env.PUBLIC_URL.replace(/\/$/, ""); | |
| 18 | +export const apiRoot = (base: string): string => `${base}/api/v3`; | |
| 19 | +export const repoApi = (base: string, owner: string, repo: string): string => `${base}/api/v3/repos/${owner}/${repo}`; | |
| 20 | +export const repoHtml = (base: string, owner: string, repo: string): string => `${base}/${owner}/${repo}`; | |
| 21 | +export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); | |
| 22 | + | |
| 23 | +/** Opaque GraphQL-style node id, stable for a type and id. */ | |
| 24 | +export const nodeId = (type: string, id: string | number): string => b64(new TextEncoder().encode(`${type}:${id}`)).replace(/=+$/, ""); | |
| 25 | + | |
| 26 | +/** ISO 8601 without milliseconds, like GitHub. */ | |
| 27 | +export const isoSeconds = (iso: string | null | undefined): string | null => (iso ? iso.replace(/\.\d{3}Z$/, "Z") : null); | |
| 28 | +export const unixIso = (seconds: number): string => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); | |
| 29 | + | |
| 30 | +// ---------------------------------------------------------------- users and repositories | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * A stable positive integer for an internal string id (two FNV-1a hashes, 53 | |
| 34 | + * bits), so users and repositories get numeric `id`s like GitHub's. Internal | |
| 35 | + * ids stay strings; nothing looks an account or repository up by this number. | |
| 36 | + */ | |
| 37 | +export function numericId(s: string): number { | |
| 38 | + let a = 0x811c9dc5; | |
| 39 | + let b = 0x01000193 ^ 0x5bd1e995; | |
| 40 | + for (let i = 0; i < s.length; i++) { | |
| 41 | + const ch = s.charCodeAt(i); | |
| 42 | + a = Math.imul(a ^ ch, 0x01000193); | |
| 43 | + b = Math.imul(b ^ ch, 0x5bd1e995); | |
| 44 | + b ^= b >>> 13; | |
| 45 | + } | |
| 46 | + return ((a >>> 0) % 0x200000) * 0x100000000 + (b >>> 0) || 1; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export type Who = { id: string; login: string; kind?: string | null }; | |
| 50 | + | |
| 51 | +export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User"); | |
| 52 | + | |
| 53 | +export function ghUser(base: string, u: Who | null | undefined) { | |
| 54 | + if (!u) return null; | |
| 55 | + const api = `${apiRoot(base)}/users/${encodeURIComponent(u.login)}`; | |
| 56 | + return { | |
| 57 | + login: u.login, | |
| 58 | + id: numericId(u.id), | |
| 59 | + node_id: nodeId(userType(u.kind), u.id), | |
| 60 | + avatar_url: `${base}/api/avatars/${encodeURIComponent(u.login)}`, | |
| 61 | + gravatar_id: "", | |
| 62 | + url: api, | |
| 63 | + html_url: `${base}/${u.login}`, | |
| 64 | + repos_url: `${api}/repos`, | |
| 65 | + organizations_url: `${api}/orgs`, | |
| 66 | + type: userType(u.kind), | |
| 67 | + user_view_type: "public", | |
| 68 | + site_admin: false, | |
| 69 | + }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** The repository row as the directory stores it (topics arrive as a JSON string). */ | |
| 73 | +export interface RepoRow { | |
| 74 | + id: string; | |
| 75 | + owner_id: string; | |
| 76 | + owner_login: string; | |
| 77 | + owner_kind: string; | |
| 78 | + name: string; | |
| 79 | + description: string; | |
| 80 | + visibility: "private" | "internal" | "public"; | |
| 81 | + default_branch: string; | |
| 82 | + archived: number; | |
| 83 | + created_at: string; | |
| 84 | + updated_at: string; | |
| 85 | + pushed_at: string | null; | |
| 86 | + topics?: string | null; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export function topicsOf(r: Pick<RepoRow, "topics">): string[] { | |
| 90 | + try { | |
| 91 | + const t = JSON.parse(r.topics ?? "[]"); | |
| 92 | + return Array.isArray(t) ? t.map(String) : []; | |
| 93 | + } catch { | |
| 94 | + return []; | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +export function permissionsFor(role: Role | null | undefined) { | |
| 99 | + return { admin: atLeast(role, "admin"), maintain: atLeast(role, "maintain"), push: atLeast(role, "write"), triage: atLeast(role, "triage"), pull: atLeast(role, "read") }; | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function ghRepo(base: string, r: RepoRow, role?: Role | null, extra: { openIssues?: number } = {}) { | |
| 103 | + const full = `${r.owner_login}/${r.name}`; | |
| 104 | + const api = repoApi(base, r.owner_login, r.name); | |
| 105 | + const html = repoHtml(base, r.owner_login, r.name); | |
| 106 | + const open = extra.openIssues ?? 0; | |
| 107 | + return { | |
| 108 | + id: numericId(r.id), | |
| 109 | + node_id: nodeId("Repository", r.id), | |
| 110 | + name: r.name, | |
| 111 | + full_name: full, | |
| 112 | + private: r.visibility !== "public", | |
| 113 | + owner: ghUser(base, { id: r.owner_id, login: r.owner_login, kind: r.owner_kind }), | |
| 114 | + html_url: html, | |
| 115 | + description: r.description || null, | |
| 116 | + fork: false, | |
| 117 | + url: api, | |
| 118 | + archive_url: `${api}/{archive_format}{/ref}`, | |
| 119 | + branches_url: `${api}/branches{/branch}`, | |
| 120 | + commits_url: `${api}/commits{/sha}`, | |
| 121 | + compare_url: `${api}/compare/{base}...{head}`, | |
| 122 | + contents_url: `${api}/contents/{+path}`, | |
| 123 | + deployments_url: `${api}/deployments`, | |
| 124 | + git_refs_url: `${api}/git/refs{/sha}`, | |
| 125 | + hooks_url: `${api}/hooks`, | |
| 126 | + issues_url: `${api}/issues{/number}`, | |
| 127 | + labels_url: `${api}/labels{/name}`, | |
| 128 | + languages_url: `${api}/languages`, | |
| 129 | + merges_url: `${api}/merges`, | |
| 130 | + pulls_url: `${api}/pulls{/number}`, | |
| 131 | + releases_url: `${api}/releases{/id}`, | |
| 132 | + statuses_url: `${api}/statuses/{sha}`, | |
| 133 | + tags_url: `${api}/tags`, | |
| 134 | + git_url: `${html}.git`, | |
| 135 | + ssh_url: `${html}.git`, | |
| 136 | + clone_url: `${html}.git`, | |
| 137 | + svn_url: html, | |
| 138 | + homepage: null, | |
| 139 | + size: 0, | |
| 140 | + stargazers_count: 0, | |
| 141 | + watchers_count: 0, | |
| 142 | + language: null, | |
| 143 | + has_issues: true, | |
| 144 | + has_projects: false, | |
| 145 | + has_downloads: true, | |
| 146 | + has_wiki: false, | |
| 147 | + has_pages: false, | |
| 148 | + has_discussions: false, | |
| 149 | + forks_count: 0, | |
| 150 | + mirror_url: null, | |
| 151 | + archived: !!r.archived, | |
| 152 | + disabled: false, | |
| 153 | + open_issues_count: open, | |
| 154 | + license: null, | |
| 155 | + allow_forking: false, | |
| 156 | + is_template: false, | |
| 157 | + web_commit_signoff_required: false, | |
| 158 | + topics: topicsOf(r), | |
| 159 | + visibility: r.visibility, | |
| 160 | + forks: 0, | |
| 161 | + open_issues: open, | |
| 162 | + watchers: 0, | |
| 163 | + default_branch: r.default_branch, | |
| 164 | + created_at: isoSeconds(r.created_at), | |
| 165 | + updated_at: isoSeconds(r.updated_at), | |
| 166 | + pushed_at: isoSeconds(r.pushed_at), | |
| 167 | + ...(role ? { permissions: { ...permissionsFor(role), role } } : {}), | |
| 168 | + }; | |
| 169 | +} | |
| 170 | + | |
| 171 | +// ---------------------------------------------------------------- labels, reactions, issues | |
| 172 | + | |
| 173 | +export type LabelRow = { id: number; name: string; color: string; description: string }; | |
| 174 | + | |
| 175 | +export function ghLabel(base: string, owner: string, repo: string, l: Pick<LabelRow, "name"> & Partial<LabelRow>) { | |
| 176 | + return { | |
| 177 | + id: l.id ?? 0, | |
| 178 | + node_id: nodeId("Label", `${owner}/${repo}/${l.name}`), | |
| 179 | + url: `${repoApi(base, owner, repo)}/labels/${encodeURIComponent(l.name)}`, | |
| 180 | + name: l.name, | |
| 181 | + color: l.color ?? "ededed", | |
| 182 | + default: false, | |
| 183 | + description: l.description || null, | |
| 184 | + }; | |
| 185 | +} | |
| 186 | + | |
| 187 | +export const REACTIONS = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] as const; | |
| 188 | +export type ReactionContent = (typeof REACTIONS)[number]; | |
| 189 | +export type ReactionCounts = Partial<Record<ReactionContent, number>>; | |
| 190 | + | |
| 191 | +export function ghReactions(url: string, counts: ReactionCounts | undefined) { | |
| 192 | + const out: Record<string, number | string> = { url, total_count: 0 }; | |
| 193 | + let total = 0; | |
| 194 | + for (const r of REACTIONS) { | |
| 195 | + out[r] = counts?.[r] ?? 0; | |
| 196 | + total += counts?.[r] ?? 0; | |
| 197 | + } | |
| 198 | + out.total_count = total; | |
| 199 | + return out; | |
| 200 | +} | |
| 201 | + | |
| 202 | +/** Issue and pull request fields as `../repo/collab.ts` returns them (plus `locked`). */ | |
| 203 | +export interface IssueLike { | |
| 204 | + number: number; | |
| 205 | + kind: "issue" | "pull"; | |
| 206 | + title: string; | |
| 207 | + body: string; | |
| 208 | + state: "open" | "closed"; | |
| 209 | + stateReason: string | null; | |
| 210 | + author: { id: string; login: string; kind: string }; | |
| 211 | + assignees: string[]; | |
| 212 | + labels: string[]; | |
| 213 | + createdAt: string; | |
| 214 | + updatedAt: string; | |
| 215 | + closedAt: string | null; | |
| 216 | + comments: number; | |
| 217 | + locked?: boolean; | |
| 218 | + lockReason?: string | null; | |
| 219 | + pull?: { | |
| 220 | + head: string; | |
| 221 | + headSha: string; | |
| 222 | + base: string; | |
| 223 | + draft: boolean; | |
| 224 | + merged: boolean; | |
| 225 | + mergedAt: string | null; | |
| 226 | + mergedBy: string | null; | |
| 227 | + mergeCommit: string | null; | |
| 228 | + requestedReviewers: string[]; | |
| 229 | + intent: string | null; | |
| 230 | + }; | |
| 231 | +} | |
| 232 | + | |
| 233 | +/** Per-request lookups the issue builders need (labels by name, people by login, reactions by subject). */ | |
| 234 | +export interface IssueCtx { | |
| 235 | + base: string; | |
| 236 | + owner: string; | |
| 237 | + repo: string; | |
| 238 | + repoId: string; | |
| 239 | + labels?: Map<string, LabelRow>; | |
| 240 | + people?: Map<string, Who>; | |
| 241 | + reactions?: Record<string, ReactionCounts>; | |
| 242 | + lockReasons?: Record<number, string | null>; | |
| 243 | +} | |
| 244 | + | |
| 245 | +const personByLogin = (x: IssueCtx, login: string): Who => x.people?.get(login.toLowerCase()) ?? { id: login, login, kind: "user" }; | |
| 246 | +const association = (x: IssueCtx, login: string) => (login.toLowerCase() === x.owner.toLowerCase() ? "OWNER" : "NONE"); | |
| 247 | + | |
| 248 | +export function ghIssue(x: IssueCtx, i: IssueLike) { | |
| 249 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 250 | + const html = repoHtml(x.base, x.owner, x.repo); | |
| 251 | + const url = `${api}/issues/${i.number}`; | |
| 252 | + const assignees = i.assignees.map((l) => ghUser(x.base, personByLogin(x, l))); | |
| 253 | + const merged = !!i.pull?.merged; | |
| 254 | + return { | |
| 255 | + url, | |
| 256 | + repository_url: api, | |
| 257 | + labels_url: `${url}/labels{/name}`, | |
| 258 | + comments_url: `${url}/comments`, | |
| 259 | + events_url: `${url}/events`, | |
| 260 | + html_url: `${html}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`, | |
| 261 | + id: i.number, | |
| 262 | + node_id: nodeId(i.kind === "pull" ? "PullRequest" : "Issue", `${x.repoId}/${i.number}`), | |
| 263 | + number: i.number, | |
| 264 | + title: i.title, | |
| 265 | + user: ghUser(x.base, i.author), | |
| 266 | + labels: i.labels.map((name) => ghLabel(x.base, x.owner, x.repo, x.labels?.get(name) ?? { name })), | |
| 267 | + state: merged ? "closed" : i.state, | |
| 268 | + locked: !!i.locked, | |
| 269 | + assignee: assignees[0] ?? null, | |
| 270 | + assignees, | |
| 271 | + milestone: null, | |
| 272 | + comments: i.comments, | |
| 273 | + created_at: isoSeconds(i.createdAt), | |
| 274 | + updated_at: isoSeconds(i.updatedAt), | |
| 275 | + closed_at: isoSeconds(i.closedAt), | |
| 276 | + closed_by: null, | |
| 277 | + author_association: association(x, i.author.login), | |
| 278 | + active_lock_reason: i.locked ? (i.lockReason ?? x.lockReasons?.[i.number] ?? null) : null, | |
| 279 | + body: i.body || null, | |
| 280 | + reactions: ghReactions(`${url}/reactions`, x.reactions?.[`issue:${i.number}`]), | |
| 281 | + timeline_url: `${url}/timeline`, | |
| 282 | + performed_via_github_app: null, | |
| 283 | + state_reason: i.stateReason, | |
| 284 | + ...(i.pull | |
| 285 | + ? { | |
| 286 | + draft: i.pull.draft, | |
| 287 | + pull_request: { url: `${api}/pulls/${i.number}`, html_url: `${html}/pull/${i.number}`, diff_url: `${html}/pull/${i.number}.diff`, patch_url: `${html}/pull/${i.number}.patch`, merged_at: isoSeconds(i.pull.mergedAt) }, | |
| 288 | + } | |
| 289 | + : {}), | |
| 290 | + }; | |
| 291 | +} | |
| 292 | + | |
| 293 | +/** A pull request; `repoJson` is the full repository object used for `head.repo` and `base.repo`. */ | |
| 294 | +export function ghPull(x: IssueCtx, i: IssueLike, repoJson: unknown, owner: Who, baseSha: string | null) { | |
| 295 | + const p = i.pull!; | |
| 296 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 297 | + const html = repoHtml(x.base, x.owner, x.repo); | |
| 298 | + const url = `${api}/pulls/${i.number}`; | |
| 299 | + const issue = ghIssue(x, i); | |
| 300 | + const ownerJson = ghUser(x.base, owner); | |
| 301 | + return { | |
| 302 | + url, | |
| 303 | + id: i.number, | |
| 304 | + node_id: issue.node_id, | |
| 305 | + html_url: `${html}/pull/${i.number}`, | |
| 306 | + diff_url: `${html}/pull/${i.number}.diff`, | |
| 307 | + patch_url: `${html}/pull/${i.number}.patch`, | |
| 308 | + issue_url: `${api}/issues/${i.number}`, | |
| 309 | + commits_url: `${url}/commits`, | |
| 310 | + review_comments_url: `${url}/comments`, | |
| 311 | + review_comment_url: `${api}/pulls/comments{/number}`, | |
| 312 | + comments_url: `${api}/issues/${i.number}/comments`, | |
| 313 | + statuses_url: `${api}/statuses/${p.headSha}`, | |
| 314 | + number: i.number, | |
| 315 | + state: issue.state, | |
| 316 | + state_reason: i.stateReason, | |
| 317 | + locked: issue.locked, | |
| 318 | + title: i.title, | |
| 319 | + user: issue.user, | |
| 320 | + body: issue.body, | |
| 321 | + labels: issue.labels, | |
| 322 | + milestone: null, | |
| 323 | + active_lock_reason: issue.active_lock_reason, | |
| 324 | + created_at: issue.created_at, | |
| 325 | + updated_at: issue.updated_at, | |
| 326 | + closed_at: issue.closed_at, | |
| 327 | + merged_at: isoSeconds(p.mergedAt), | |
| 328 | + merge_commit_sha: p.mergeCommit, | |
| 329 | + assignee: issue.assignee, | |
| 330 | + assignees: issue.assignees, | |
| 331 | + requested_reviewers: p.requestedReviewers.map((l) => ghUser(x.base, personByLogin(x, l))), | |
| 332 | + requested_teams: [], | |
| 333 | + head: { label: `${x.owner}:${p.head}`, ref: p.head, sha: p.headSha, user: ownerJson, repo: repoJson }, | |
| 334 | + base: { label: `${x.owner}:${p.base}`, ref: p.base, sha: baseSha, user: ownerJson, repo: repoJson }, | |
| 335 | + _links: { | |
| 336 | + self: { href: url }, | |
| 337 | + html: { href: `${html}/pull/${i.number}` }, | |
| 338 | + issue: { href: `${api}/issues/${i.number}` }, | |
| 339 | + comments: { href: `${api}/issues/${i.number}/comments` }, | |
| 340 | + review_comments: { href: `${url}/comments` }, | |
| 341 | + review_comment: { href: `${api}/pulls/comments{/number}` }, | |
| 342 | + commits: { href: `${url}/commits` }, | |
| 343 | + statuses: { href: `${api}/statuses/${p.headSha}` }, | |
| 344 | + }, | |
| 345 | + author_association: issue.author_association, | |
| 346 | + auto_merge: null, | |
| 347 | + draft: p.draft, | |
| 348 | + merged: p.merged, | |
| 349 | + merged_by: p.mergedBy ? ghUser(x.base, personByLogin(x, p.mergedBy)) : null, | |
| 350 | + comments: i.comments, | |
| 351 | + maintainer_can_modify: false, | |
| 352 | + // Diffinery: the change's purpose, written by agents for reviewers. | |
| 353 | + intent: p.intent, | |
| 354 | + pull_request: { url }, | |
| 355 | + }; | |
| 356 | +} | |
| 357 | + | |
| 358 | +export type CommentLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; body: string; createdAt: string; updatedAt: string }; | |
| 359 | + | |
| 360 | +export function ghComment(x: IssueCtx, number: number, isPull: boolean, cm: CommentLike) { | |
| 361 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 362 | + const id = cm.seq ?? cm.id; | |
| 363 | + const url = `${api}/issues/comments/${id}`; | |
| 364 | + return { | |
| 365 | + url, | |
| 366 | + html_url: `${repoHtml(x.base, x.owner, x.repo)}/${isPull ? "pull" : "issues"}/${number}#issuecomment-${cm.id}`, | |
| 367 | + issue_url: `${api}/issues/${number}`, | |
| 368 | + id, | |
| 369 | + node_id: nodeId("IssueComment", `${x.repoId}/${cm.id}`), | |
| 370 | + user: ghUser(x.base, cm.author), | |
| 371 | + created_at: isoSeconds(cm.createdAt), | |
| 372 | + updated_at: isoSeconds(cm.updatedAt), | |
| 373 | + author_association: association(x, cm.author.login), | |
| 374 | + body: cm.body, | |
| 375 | + reactions: ghReactions(`${url}/reactions`, x.reactions?.[`comment:${cm.id}`]), | |
| 376 | + performed_via_github_app: null, | |
| 377 | + }; | |
| 378 | +} | |
| 379 | + | |
| 380 | +export type ReviewCommentLike = CommentLike & { reviewId: string | null; reviewSeq?: number | null; path: string | null; line: number | null; side: string | null; commitId: string | null; inReplyTo: string | null; inReplyToSeq?: number | null; resolved: boolean }; | |
| 381 | + | |
| 382 | +export function ghReviewComment(x: IssueCtx, number: number, cm: ReviewCommentLike) { | |
| 383 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 384 | + const html = `${repoHtml(x.base, x.owner, x.repo)}/pull/${number}`; | |
| 385 | + const id = cm.seq ?? cm.id; | |
| 386 | + const url = `${api}/pulls/comments/${id}`; | |
| 387 | + return { | |
| 388 | + url, | |
| 389 | + pull_request_review_id: cm.reviewSeq ?? cm.reviewId, | |
| 390 | + id, | |
| 391 | + node_id: nodeId("PullRequestReviewComment", `${x.repoId}/${cm.id}`), | |
| 392 | + diff_hunk: "", | |
| 393 | + path: cm.path, | |
| 394 | + position: cm.line, | |
| 395 | + original_position: cm.line, | |
| 396 | + commit_id: cm.commitId, | |
| 397 | + original_commit_id: cm.commitId, | |
| 398 | + in_reply_to_id: cm.inReplyTo ? (cm.inReplyToSeq ?? cm.inReplyTo) : undefined, | |
| 399 | + user: ghUser(x.base, cm.author), | |
| 400 | + body: cm.body, | |
| 401 | + created_at: isoSeconds(cm.createdAt), | |
| 402 | + updated_at: isoSeconds(cm.updatedAt), | |
| 403 | + html_url: `${html}#discussion_r${id}`, | |
| 404 | + pull_request_url: `${api}/pulls/${number}`, | |
| 405 | + author_association: association(x, cm.author.login), | |
| 406 | + _links: { self: { href: url }, html: { href: `${html}#discussion_r${id}` }, pull_request: { href: `${api}/pulls/${number}` } }, | |
| 407 | + start_line: null, | |
| 408 | + original_start_line: null, | |
| 409 | + start_side: null, | |
| 410 | + line: cm.line, | |
| 411 | + original_line: cm.line, | |
| 412 | + side: cm.side ?? "RIGHT", | |
| 413 | + subject_type: "line", | |
| 414 | + reactions: ghReactions(`${url}/reactions`, x.reactions?.[`review_comment:${cm.id}`]), | |
| 415 | + // Diffinery: whether the thread was resolved. | |
| 416 | + resolved: cm.resolved, | |
| 417 | + }; | |
| 418 | +} | |
| 419 | + | |
| 420 | +export type ReviewLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; state: string; body: string; commitId: string; submittedAt: string }; | |
| 421 | + | |
| 422 | +export function ghReview(x: IssueCtx, number: number, r: ReviewLike) { | |
| 423 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 424 | + const html = `${repoHtml(x.base, x.owner, x.repo)}/pull/${number}#pullrequestreview-${r.seq ?? r.id}`; | |
| 425 | + return { | |
| 426 | + id: r.seq ?? r.id, | |
| 427 | + node_id: nodeId("PullRequestReview", `${x.repoId}/${r.id}`), | |
| 428 | + user: ghUser(x.base, r.author), | |
| 429 | + body: r.body, | |
| 430 | + state: r.state, | |
| 431 | + html_url: html, | |
| 432 | + pull_request_url: `${api}/pulls/${number}`, | |
| 433 | + author_association: association(x, r.author.login), | |
| 434 | + _links: { html: { href: html }, pull_request: { href: `${api}/pulls/${number}` } }, | |
| 435 | + submitted_at: isoSeconds(r.submittedAt), | |
| 436 | + commit_id: r.commitId, | |
| 437 | + }; | |
| 438 | +} | |
| 439 | + | |
| 440 | +// ---------------------------------------------------------------- commits and files | |
| 441 | + | |
| 442 | +export type Sig = { name: string; email: string; timestamp: number; timezone: string }; | |
| 443 | +export type CommitLike = { sha: string; tree: string; parents: string[]; author: Sig; committer: Sig; message: string; signed: boolean }; | |
| 444 | + | |
| 445 | +export const sigJson = (s: Sig) => ({ name: s.name, email: s.email, date: unixIso(s.timestamp) }); | |
| 446 | + | |
| 447 | +/** Verification block; Diffinery does not verify signatures yet. */ | |
| 448 | +export const verification = (signed: boolean) => ({ verified: false, reason: signed ? "unknown_key" : "unsigned", signature: null, payload: null, verified_at: null }); | |
| 449 | + | |
| 450 | +export function ghCommit(base: string, owner: string, repo: string, c: CommitLike, people?: Map<string, Who>) { | |
| 451 | + const api = repoApi(base, owner, repo); | |
| 452 | + const html = repoHtml(base, owner, repo); | |
| 453 | + const who = (s: Sig) => people?.get(s.email.toLowerCase()) ?? null; | |
| 454 | + return { | |
| 455 | + sha: c.sha, | |
| 456 | + node_id: nodeId("Commit", c.sha), | |
| 457 | + commit: { | |
| 458 | + author: sigJson(c.author), | |
| 459 | + committer: sigJson(c.committer), | |
| 460 | + message: c.message.replace(/\n$/, ""), | |
| 461 | + tree: { sha: c.tree, url: `${api}/git/trees/${c.tree}` }, | |
| 462 | + url: `${api}/git/commits/${c.sha}`, | |
| 463 | + comment_count: 0, | |
| 464 | + verification: verification(c.signed), | |
| 465 | + }, | |
| 466 | + url: `${api}/commits/${c.sha}`, | |
| 467 | + html_url: `${html}/commit/${c.sha}`, | |
| 468 | + comments_url: `${api}/commits/${c.sha}/comments`, | |
| 469 | + author: ghUser(base, who(c.author)), | |
| 470 | + committer: ghUser(base, who(c.committer)), | |
| 471 | + parents: c.parents.map((p) => ({ sha: p, url: `${api}/commits/${p}`, html_url: `${html}/commit/${p}` })), | |
| 472 | + }; | |
| 473 | +} | |
| 474 | + | |
| 475 | +/** A reference (GET /git/ref/:ref): `type` is the kind of object it points at. */ | |
| 476 | +export function ghRef(base: string, owner: string, repo: string, r: { ref: string; sha: string; type: string }) { | |
| 477 | + const api = repoApi(base, owner, repo); | |
| 478 | + const kind = r.type === "tag" ? "tags" : r.type === "tree" ? "trees" : r.type === "blob" ? "blobs" : "commits"; | |
| 479 | + return { ref: r.ref, node_id: nodeId("Ref", `${owner}/${repo}/${r.ref}`), url: `${api}/git/${r.ref}`, object: { sha: r.sha, type: r.type, url: `${api}/git/${kind}/${r.sha}` } }; | |
| 480 | +} | |
| 481 | + | |
| 482 | +/** A commit as the Git data API returns it (GET /git/commits/:sha). */ | |
| 483 | +export function ghGitCommit(base: string, owner: string, repo: string, c: CommitLike) { | |
| 484 | + const api = repoApi(base, owner, repo); | |
| 485 | + const html = repoHtml(base, owner, repo); | |
| 486 | + return { | |
| 487 | + sha: c.sha, | |
| 488 | + node_id: nodeId("Commit", c.sha), | |
| 489 | + url: `${api}/git/commits/${c.sha}`, | |
| 490 | + html_url: `${html}/commit/${c.sha}`, | |
| 491 | + author: sigJson(c.author), | |
| 492 | + committer: sigJson(c.committer), | |
| 493 | + message: c.message, | |
| 494 | + tree: { sha: c.tree, url: `${api}/git/trees/${c.tree}` }, | |
| 495 | + parents: c.parents.map((p) => ({ sha: p, url: `${api}/git/commits/${p}`, html_url: `${html}/commit/${p}` })), | |
| 496 | + verification: verification(c.signed), | |
| 497 | + }; | |
| 498 | +} | |
| 499 | + | |
| 500 | +export type FileLike = { path: string; status: string; oldOid?: string; newOid?: string; additions: number; deletions: number; patch?: string; binary?: boolean }; | |
| 501 | + | |
| 502 | +export function ghFile(base: string, owner: string, repo: string, ref: string, f: FileLike) { | |
| 503 | + const api = repoApi(base, owner, repo); | |
| 504 | + const html = repoHtml(base, owner, repo); | |
| 505 | + return { | |
| 506 | + sha: f.newOid ?? f.oldOid ?? null, | |
| 507 | + filename: f.path, | |
| 508 | + status: f.status === "deleted" ? "removed" : f.status, | |
| 509 | + additions: f.additions, | |
| 510 | + deletions: f.deletions, | |
| 511 | + changes: f.additions + f.deletions, | |
| 512 | + blob_url: `${html}/blob/${ref}/${encPath(f.path)}`, | |
| 513 | + raw_url: `${html}/raw/${ref}/${encPath(f.path)}`, | |
| 514 | + contents_url: `${api}/contents/${encPath(f.path)}?ref=${ref}`, | |
| 515 | + patch: f.binary ? undefined : f.patch, | |
| 516 | + }; | |
| 517 | +} | |
| 518 | + | |
| 519 | +// ---------------------------------------------------------------- checks | |
| 520 | + | |
| 521 | +export function ghCheckRun(base: string, owner: string, repo: string, r: { id: string; seq?: number | null; sha: string; name: string; status: string; conclusion: string | null; runId: string | null; jobId: string | null; detailsUrl: string | null; output: Record<string, unknown> | null; app: string; startedAt: string | null; completedAt: string | null }) { | |
| 522 | + const api = repoApi(base, owner, repo); | |
| 523 | + const id = r.seq ?? r.id; | |
| 524 | + const out = (r.output ?? {}) as { title?: string; summary?: string; text?: string; annotations?: unknown[]; external_id?: string }; | |
| 525 | + return { | |
| 526 | + id, | |
| 527 | + head_sha: r.sha, | |
| 528 | + node_id: nodeId("CheckRun", r.id), | |
| 529 | + external_id: out.external_id ?? "", | |
| 530 | + url: `${api}/check-runs/${id}`, | |
| 531 | + html_url: r.detailsUrl, | |
| 532 | + details_url: r.detailsUrl, | |
| 533 | + status: r.status, | |
| 534 | + conclusion: r.conclusion, | |
| 535 | + started_at: isoSeconds(r.startedAt), | |
| 536 | + completed_at: isoSeconds(r.completedAt), | |
| 537 | + output: { title: out.title ?? null, summary: out.summary ?? null, text: out.text ?? null, annotations_count: out.annotations?.length ?? 0, annotations_url: `${api}/check-runs/${id}/annotations` }, | |
| 538 | + name: r.name, | |
| 539 | + check_suite: { id: r.runId ?? `app-${r.app}-${r.sha}` }, | |
| 540 | + app: { id: r.app, slug: r.app, node_id: nodeId("App", r.app), name: r.app, owner: null }, | |
| 541 | + pull_requests: [], | |
| 542 | + // Diffinery: the workflow run and job behind an Actions check. | |
| 543 | + run_id: r.runId, | |
| 544 | + job_id: r.jobId, | |
| 545 | + }; | |
| 546 | +} | |
| 547 | + | |
| 548 | +// ---------------------------------------------------------------- Actions runs and jobs | |
| 549 | + | |
| 550 | +export type RunLike = { | |
| 551 | + id: string; | |
| 552 | + number: number; | |
| 553 | + attempt: number; | |
| 554 | + workflowPath: string; | |
| 555 | + workflowName: string; | |
| 556 | + title: string; | |
| 557 | + event: string; | |
| 558 | + ref: string; | |
| 559 | + sha: string; | |
| 560 | + headSha: string; | |
| 561 | + headBranch: string; | |
| 562 | + actor: Who; | |
| 563 | + status: string; | |
| 564 | + conclusion: string | null; | |
| 565 | + createdAt: string; | |
| 566 | + updatedAt: string; | |
| 567 | + prNumber: number | null; | |
| 568 | +}; | |
| 569 | + | |
| 570 | +/** A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`. */ | |
| 571 | +export function ghRun(base: string, owner: string, repo: string, r: RunLike) { | |
| 572 | + const api = repoApi(base, owner, repo); | |
| 573 | + const url = `${api}/actions/runs/${r.id}`; | |
| 574 | + return { | |
| 575 | + id: r.id, | |
| 576 | + node_id: nodeId("WorkflowRun", r.id), | |
| 577 | + run_number: r.number, | |
| 578 | + run_attempt: r.attempt, | |
| 579 | + name: r.workflowName, | |
| 580 | + display_title: r.title, | |
| 581 | + path: r.workflowPath, | |
| 582 | + workflow_id: r.workflowPath, | |
| 583 | + check_suite_id: r.id, | |
| 584 | + event: r.event, | |
| 585 | + head_branch: r.headBranch, | |
| 586 | + head_sha: r.headSha, | |
| 587 | + // The commit the run checked out (GITHUB_SHA): the test merge commit for pull_request runs. | |
| 588 | + sha: r.sha, | |
| 589 | + ref: r.ref, | |
| 590 | + status: r.status, | |
| 591 | + conclusion: r.conclusion, | |
| 592 | + actor: ghUser(base, r.actor), | |
| 593 | + triggering_actor: ghUser(base, r.actor), | |
| 594 | + created_at: isoSeconds(r.createdAt), | |
| 595 | + updated_at: isoSeconds(r.updatedAt), | |
| 596 | + run_started_at: isoSeconds(r.createdAt), | |
| 597 | + url, | |
| 598 | + html_url: `${repoHtml(base, owner, repo)}/actions/runs/${r.id}`, | |
| 599 | + jobs_url: `${url}/jobs`, | |
| 600 | + logs_url: `${url}/logs`, | |
| 601 | + artifacts_url: `${url}/artifacts`, | |
| 602 | + cancel_url: `${url}/cancel`, | |
| 603 | + rerun_url: `${url}/rerun`, | |
| 604 | + workflow_url: `${api}/actions/workflows/${encodeURIComponent(r.workflowPath)}`, | |
| 605 | + previous_attempt_url: r.attempt > 1 ? `${url}/attempts/${r.attempt - 1}` : null, | |
| 606 | + pull_requests: r.prNumber ? [{ number: r.prNumber, url: `${api}/pulls/${r.prNumber}` }] : [], | |
| 607 | + pull_request_number: r.prNumber, | |
| 608 | + }; | |
| 609 | +} | |
| 610 | + | |
| 611 | +export type JobLike = { | |
| 612 | + id: string; | |
| 613 | + runId: string; | |
| 614 | + name: string; | |
| 615 | + status: string; | |
| 616 | + conclusion: string | null; | |
| 617 | + startedAt: string | null; | |
| 618 | + completedAt: string | null; | |
| 619 | + steps: Array<{ index: number; name: string; status: string; conclusion?: string | null; startedAt?: string; completedAt?: string }>; | |
| 620 | +}; | |
| 621 | + | |
| 622 | +/** A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read. */ | |
| 623 | +export function ghJob<J extends JobLike>(base: string, owner: string, repo: string, run: RunLike, j: J) { | |
| 624 | + const api = repoApi(base, owner, repo); | |
| 625 | + return { | |
| 626 | + ...j, | |
| 627 | + run_id: j.runId, | |
| 628 | + run_url: `${api}/actions/runs/${j.runId}`, | |
| 629 | + run_attempt: run.attempt, | |
| 630 | + node_id: nodeId("Job", j.id), | |
| 631 | + head_sha: run.headSha, | |
| 632 | + head_branch: run.headBranch, | |
| 633 | + url: `${api}/actions/jobs/${j.id}`, | |
| 634 | + html_url: `${repoHtml(base, owner, repo)}/actions/runs/${j.runId}/job/${j.id}`, | |
| 635 | + started_at: isoSeconds(j.startedAt), | |
| 636 | + completed_at: isoSeconds(j.completedAt), | |
| 637 | + workflow_name: run.workflowName, | |
| 638 | + check_run_url: `${api}/check-runs/${j.id}`, | |
| 639 | + labels: [] as string[], | |
| 640 | + steps: j.steps.map((s) => ({ ...s, number: s.index + 1, started_at: isoSeconds(s.startedAt), completed_at: isoSeconds(s.completedAt), conclusion: s.conclusion ?? null })), | |
| 641 | + }; | |
| 642 | +} | |
| 643 | + | |
| 644 | +// ---------------------------------------------------------------- pagination | |
| 645 | + | |
| 646 | +export interface Page { | |
| 647 | + page: number; | |
| 648 | + perPage: number; | |
| 649 | + offset: number; | |
| 650 | +} | |
| 651 | + | |
| 652 | +export function pageOf(c: Context, def = 30): Page { | |
| 653 | + const per = parseInt(c.req.query("per_page") ?? "", 10); | |
| 654 | + const perPage = Math.min(Math.max(Number.isFinite(per) && per > 0 ? per : def, 1), 100); | |
| 655 | + const pg = parseInt(c.req.query("page") ?? "", 10); | |
| 656 | + const page = Math.min(Math.max(Number.isFinite(pg) && pg > 0 ? pg : 1, 1), 100_000); | |
| 657 | + return { page, perPage, offset: (page - 1) * perPage }; | |
| 658 | +} | |
| 659 | + | |
| 660 | +/** | |
| 661 | + * `Link` header with first/prev/next/last, as Octokit's paginate() follows it. | |
| 662 | + * Pass `total` when known; otherwise `hasMore` decides whether there is a next page. | |
| 663 | + */ | |
| 664 | +export function setLinks(c: Context, p: Page, total: number | null, hasMore?: boolean): void { | |
| 665 | + const url = new URL(c.req.url); | |
| 666 | + const at = (n: number) => { | |
| 667 | + url.searchParams.set("per_page", String(p.perPage)); | |
| 668 | + url.searchParams.set("page", String(n)); | |
| 669 | + return url.toString(); | |
| 670 | + }; | |
| 671 | + const last = total === null ? null : Math.max(1, Math.ceil(total / p.perPage)); | |
| 672 | + const more = last === null ? !!hasMore : p.page < last; | |
| 673 | + const links: string[] = []; | |
| 674 | + if (p.page > 1) links.push(`<${at(p.page - 1)}>; rel="prev"`, `<${at(1)}>; rel="first"`); | |
| 675 | + if (more) links.push(`<${at(p.page + 1)}>; rel="next"`); | |
| 676 | + if (last !== null && p.page < last) links.push(`<${at(last)}>; rel="last"`); | |
| 677 | + if (links.length) c.header("link", links.join(", ")); | |
| 678 | +} | |
| 679 | + | |
| 680 | +/** Paginate an in-memory list and set the Link header. */ | |
| 681 | +export function paginate<T>(c: Context, items: readonly T[], def = 30): T[] { | |
| 682 | + const p = pageOf(c, def); | |
| 683 | + setLinks(c, p, items.length); | |
| 684 | + return items.slice(p.offset, p.offset + p.perPage); | |
| 685 | +} | |
| 686 | + | |
| 687 | +// ---------------------------------------------------------------- releases | |
| 688 | + | |
| 689 | +export type AssetLike = { id: number; name: string; label: string; contentType: string; size: number; state: string; downloadCount: number; uploader: Who | null; createdAt: string; updatedAt: string }; | |
| 690 | +export type ReleaseLike = { | |
| 691 | + id: number; | |
| 692 | + tagName: string; | |
| 693 | + targetCommitish: string; | |
| 694 | + name: string; | |
| 695 | + body: string; | |
| 696 | + draft: boolean; | |
| 697 | + prerelease: boolean; | |
| 698 | + author: Who; | |
| 699 | + createdAt: string; | |
| 700 | + publishedAt: string | null; | |
| 701 | + updatedAt: string; | |
| 702 | + latest?: boolean; | |
| 703 | + assets: AssetLike[]; | |
| 704 | +}; | |
| 705 | + | |
| 706 | +export function ghAsset(base: string, owner: string, repo: string, tag: string, a: AssetLike) { | |
| 707 | + return { | |
| 708 | + url: `${repoApi(base, owner, repo)}/releases/assets/${a.id}`, | |
| 709 | + id: a.id, | |
| 710 | + node_id: nodeId("ReleaseAsset", a.id), | |
| 711 | + name: a.name, | |
| 712 | + label: a.label || null, | |
| 713 | + uploader: ghUser(base, a.uploader), | |
| 714 | + content_type: a.contentType, | |
| 715 | + state: a.state, | |
| 716 | + size: a.size, | |
| 717 | + digest: null, | |
| 718 | + download_count: a.downloadCount, | |
| 719 | + created_at: isoSeconds(a.createdAt), | |
| 720 | + updated_at: isoSeconds(a.updatedAt), | |
| 721 | + browser_download_url: `${repoHtml(base, owner, repo)}/releases/download/${encPath(tag)}/${encodeURIComponent(a.name)}`, | |
| 722 | + }; | |
| 723 | +} | |
| 724 | + | |
| 725 | +export function ghRelease(base: string, owner: string, repo: string, r: ReleaseLike, reactions?: ReactionCounts) { | |
| 726 | + const api = repoApi(base, owner, repo); | |
| 727 | + const url = `${api}/releases/${r.id}`; | |
| 728 | + return { | |
| 729 | + url, | |
| 730 | + assets_url: `${url}/assets`, | |
| 731 | + upload_url: `${base}/api/uploads/repos/${owner}/${repo}/releases/${r.id}/assets{?name,label}`, | |
| 732 | + html_url: `${repoHtml(base, owner, repo)}/releases/tag/${encPath(r.tagName)}`, | |
| 733 | + id: r.id, | |
| 734 | + author: ghUser(base, r.author), | |
| 735 | + node_id: nodeId("Release", r.id), | |
| 736 | + tag_name: r.tagName, | |
| 737 | + target_commitish: r.targetCommitish, | |
| 738 | + name: r.name || null, | |
| 739 | + draft: r.draft, | |
| 740 | + immutable: false, | |
| 741 | + prerelease: r.prerelease, | |
| 742 | + created_at: isoSeconds(r.createdAt), | |
| 743 | + updated_at: isoSeconds(r.updatedAt), | |
| 744 | + published_at: isoSeconds(r.publishedAt), | |
| 745 | + assets: r.assets.map((a) => ghAsset(base, owner, repo, r.tagName, a)), | |
| 746 | + tarball_url: `${api}/tarball/${encPath(r.tagName)}`, | |
| 747 | + zipball_url: `${api}/zipball/${encPath(r.tagName)}`, | |
| 748 | + body: r.body || null, | |
| 749 | + reactions: ghReactions(`${url}/reactions`, reactions), | |
| 750 | + // Diffinery: whether this is the repository's latest release. | |
| 751 | + latest: !!r.latest, | |
| 752 | + }; | |
| 753 | +} |
addedapps/diffinery/src/http/ghctx.ts+137−0View file
| @@ -1,0 +1,137 @@ | ||
| 1 | +/** | |
| 2 | + * Request-scoped helpers around ./gh.ts: they fetch what the GitHub JSON | |
| 3 | + * builders need (label rows, reactions, people, branch heads) from the | |
| 4 | + * repository Durable Object and the directory in as few calls as possible. | |
| 5 | + */ | |
| 6 | +import type { AppContext } from "../auth/principal.js"; | |
| 7 | +import type { Repo } from "../lib/directory.js"; | |
| 8 | +import { HttpError } from "../lib/util.js"; | |
| 9 | +import type { Caller, Role } from "../repo/types.js"; | |
| 10 | +import { loadRepo, type repoStub } from "./common.js"; | |
| 11 | +import { baseOf, ghIssue, ghPull, ghRepo, type IssueCtx, type IssueLike, type Who } from "./gh.js"; | |
| 12 | + | |
| 13 | +export type Stub = ReturnType<typeof repoStub>; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * loadRepo for the `:owner/:repo` in the path. Archived repositories are | |
| 17 | + * read-only for everyone (comments, reactions and settings included), so any | |
| 18 | + * request that isn't a read is refused, whatever role the route needs. | |
| 19 | + */ | |
| 20 | +export async function loadTarget(c: AppContext, min: Role = "read"): Promise<Awaited<ReturnType<typeof loadRepo>>> { | |
| 21 | + const l = await loadRepo(c, c.req.param("owner") ?? "", c.req.param("repo") ?? "", min); | |
| 22 | + if (l.repo.archived && !["GET", "HEAD", "OPTIONS"].includes(c.req.method)) throw new HttpError(403, "repository is archived (read-only)"); | |
| 23 | + return l; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export const repoOut = (c: AppContext, r: Repo, role?: Role | null) => ghRepo(baseOf(c), r, role); | |
| 27 | + | |
| 28 | +export const ownerOf = (r: Repo): Who => ({ id: r.owner_id, login: r.owner_login, kind: r.owner_kind }); | |
| 29 | + | |
| 30 | +/** Accounts for a set of logins (assignees, reviewers), so user objects carry real ids and types. */ | |
| 31 | +export async function peopleFor(c: AppContext, logins: Iterable<string>): Promise<Map<string, Who>> { | |
| 32 | + const out = new Map<string, Who>(); | |
| 33 | + const dir = c.get("dir"); | |
| 34 | + for (const login of new Set([...logins].map((l) => l.toLowerCase()))) { | |
| 35 | + if (out.size >= 50) break; | |
| 36 | + const a = await dir.accountByLogin(login).catch(() => null); | |
| 37 | + if (a) out.set(login, { id: a.id, login: a.login, kind: a.kind }); | |
| 38 | + } | |
| 39 | + return out; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** Everything ghIssue/ghPull need for `issues`, plus reaction counts for extra `subjects` (comments). */ | |
| 43 | +export async function issueCtx(c: AppContext, repo: Repo, stub: Stub, issues: IssueLike[], subjects: string[] = []): Promise<IssueCtx> { | |
| 44 | + const extras = await stub.issueExtras( | |
| 45 | + issues.map((i) => i.number), | |
| 46 | + subjects, | |
| 47 | + ); | |
| 48 | + const logins = issues.flatMap((i) => [...i.assignees, ...(i.pull?.requestedReviewers ?? []), ...(i.pull?.mergedBy ? [i.pull.mergedBy] : [])]); | |
| 49 | + return { | |
| 50 | + base: baseOf(c), | |
| 51 | + owner: repo.owner_login, | |
| 52 | + repo: repo.name, | |
| 53 | + repoId: repo.id, | |
| 54 | + labels: new Map(extras.labels.map((l) => [l.name, l])), | |
| 55 | + people: await peopleFor(c, logins), | |
| 56 | + reactions: extras.reactions, | |
| 57 | + lockReasons: extras.lockReasons, | |
| 58 | + }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export async function issuesOut(c: AppContext, repo: Repo, stub: Stub, issues: IssueLike[]) { | |
| 62 | + const x = await issueCtx(c, repo, stub, issues); | |
| 63 | + return issues.map((i) => ghIssue(x, i)); | |
| 64 | +} | |
| 65 | + | |
| 66 | +export async function pullsOut(c: AppContext, repo: Repo, stub: Stub, issues: IssueLike[]) { | |
| 67 | + const x = await issueCtx(c, repo, stub, issues); | |
| 68 | + const heads = new Map((await stub.branches()).map((b) => [b.name, b.sha])); | |
| 69 | + const repoJson = ghRepo(x.base, repo); | |
| 70 | + return issues.map((i) => ghPull(x, i, repoJson, ownerOf(repo), heads.get(i.pull!.base) ?? null)); | |
| 71 | +} | |
| 72 | + | |
| 73 | +// ---------------------------------------------------------------- small request helpers for the route files | |
| 74 | + | |
| 75 | +export async function jsonBody<T>(c: AppContext, optional = false): Promise<T> { | |
| 76 | + const text = await c.req.text(); | |
| 77 | + if (!text.trim() && optional) return {} as T; | |
| 78 | + try { | |
| 79 | + const v = JSON.parse(text) as unknown; | |
| 80 | + if (!v || typeof v !== "object") throw new Error("not an object"); | |
| 81 | + return v as T; | |
| 82 | + } catch { | |
| 83 | + throw new HttpError(400, "Problems parsing JSON"); | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * Decode one piece of the request path. Hono leaves `%25` (and other reserved | |
| 89 | + * escapes) in c.req.path, so this is the only decoding a path piece needs; | |
| 90 | + * route params from c.req.param() are already decoded and must not go through it. | |
| 91 | + */ | |
| 92 | +export function decodePath(raw: string): string { | |
| 93 | + try { | |
| 94 | + return decodeURIComponent(raw); | |
| 95 | + } catch { | |
| 96 | + throw new HttpError(400, "malformed percent-encoding in the URL"); | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +export const repoParams = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! }); | |
| 101 | + | |
| 102 | +export function intParam(c: AppContext, name: string): number { | |
| 103 | + const n = Number(c.req.param(name)); | |
| 104 | + if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(404, "Not Found"); | |
| 105 | + return n; | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** GitHub's mergeable_state for Diffinery's mergeability state. */ | |
| 109 | +export const mergeableState = (s: string): string => (s === "conflicting" ? "dirty" : s === "merged" || s === "closed" ? "unknown" : s); | |
| 110 | + | |
| 111 | +/** A single pull request with mergeability (GitHub fields plus Diffinery's merge_blockers, approvals and checks). */ | |
| 112 | +export async function pullDetail(c: AppContext, repo: Repo, stub: Stub, number: number, caller: Caller) { | |
| 113 | + const { issue, comments } = await stub.issue(number); | |
| 114 | + if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); | |
| 115 | + const [pull] = await pullsOut(c, repo, stub, [issue]); | |
| 116 | + const m = await stub.mergeability(number, caller); | |
| 117 | + const open = issue.state === "open"; | |
| 118 | + const commits = open || issue.pull!.merged ? (await stub.pullCommits(number)).length : 0; | |
| 119 | + const files = await stub.pullFiles(number).catch(() => null); | |
| 120 | + return { | |
| 121 | + ...pull!, | |
| 122 | + mergeable: open ? m.conflicts.length === 0 : null, | |
| 123 | + mergeable_state: mergeableState(m.state), | |
| 124 | + rebaseable: open ? m.conflicts.length === 0 && m.mergeBase === (await stub.resolve(issue.pull!.base)) : null, | |
| 125 | + review_comments: comments.filter((x) => x.reviewId).length, | |
| 126 | + commits, | |
| 127 | + additions: files?.additions ?? 0, | |
| 128 | + deletions: files?.deletions ?? 0, | |
| 129 | + changed_files: files?.totalFiles ?? 0, | |
| 130 | + // Diffinery: why the pull request can't be merged yet, and what it is waiting for. | |
| 131 | + merge_blockers: m.blockers, | |
| 132 | + approvals: m.approvals, | |
| 133 | + required_approvals: m.requiredApprovals, | |
| 134 | + checks: m.checks, | |
| 135 | + merge_queue: m.mergeQueue, | |
| 136 | + }; | |
| 137 | +} |
addedapps/diffinery/src/http/gitdata.ts+193−0View file
| @@ -1,0 +1,193 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError, b64, fromB64 } from "../lib/util.js"; | |
| 5 | +import type { SigInput, TreeInput } from "../repo/gitdata.js"; | |
| 6 | +import { loadRepo } from "./common.js"; | |
| 7 | +import { archiveResponse } from "./files.js"; | |
| 8 | +import { baseOf, encPath, ghCommit, ghGitCommit, ghRef, nodeId, paginate, repoApi, repoHtml, sigJson } from "./gh.js"; | |
| 9 | +import { jsonBody, loadTarget, repoParams } from "./ghctx.js"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Git data API (blobs, trees, commits, annotated tags, refs) and the | |
| 13 | + * repository extras next to it: README, merges, languages and archives. | |
| 14 | + */ | |
| 15 | +export const gitdata = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 16 | + | |
| 17 | +const R = "/repos/:owner/:repo"; | |
| 18 | +const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo); | |
| 19 | +const wantsRaw = (c: AppContext) => /application\/vnd\.github(\.v3)?\.raw/.test(c.req.header("accept") ?? ""); | |
| 20 | +const base64Lines = (bytes: Uint8Array) => b64(bytes).replace(/(.{60})/g, "$1\n"); | |
| 21 | + | |
| 22 | +// ---------------------------------------------------------------- blobs | |
| 23 | + | |
| 24 | +gitdata.post(`${R}/git/blobs`, async (c) => { | |
| 25 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 26 | + const b = await jsonBody<{ content?: string; encoding?: string }>(c); | |
| 27 | + if (typeof b.content !== "string") throw new HttpError(422, "content is required", { errors: [{ resource: "Blob", code: "missing_field", field: "content" }] }); | |
| 28 | + const encoding = (b.encoding ?? "utf-8").toLowerCase(); | |
| 29 | + let bytes: Uint8Array; | |
| 30 | + if (encoding === "base64") { | |
| 31 | + try { | |
| 32 | + bytes = fromB64(b.content.replace(/\s+/g, "")); | |
| 33 | + } catch { | |
| 34 | + throw new HttpError(422, "content is not valid Base64", { errors: [{ resource: "Blob", code: "invalid", field: "content" }] }); | |
| 35 | + } | |
| 36 | + } else if (encoding === "utf-8" || encoding === "utf8") bytes = new TextEncoder().encode(b.content); | |
| 37 | + else throw new HttpError(422, "encoding must be utf-8 or base64", { errors: [{ resource: "Blob", code: "invalid", field: "encoding" }] }); | |
| 38 | + const blob = await stub.createBlob(bytes, caller); | |
| 39 | + return c.json({ url: `${api(c)}/git/blobs/${blob.sha}`, sha: blob.sha }, 201); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +gitdata.get(`${R}/git/blobs/:sha`, async (c) => { | |
| 43 | + const { stub } = await loadTarget(c); | |
| 44 | + const blob = await stub.getBlob(c.req.param("sha")); | |
| 45 | + if (wantsRaw(c)) return new Response(blob.content, { headers: { "content-type": "text/plain; charset=utf-8", "content-security-policy": "sandbox", "x-content-type-options": "nosniff" } }); | |
| 46 | + return c.json({ sha: blob.sha, node_id: nodeId("Blob", blob.sha), size: blob.size, url: `${api(c)}/git/blobs/${blob.sha}`, content: base64Lines(blob.content), encoding: "base64" }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +// ---------------------------------------------------------------- trees | |
| 50 | + | |
| 51 | +type Tree = { sha: string; tree: Array<{ path: string; mode: string; type: string; sha: string; size?: number }>; truncated: boolean }; | |
| 52 | +const treeJson = (c: AppContext, t: Tree) => ({ | |
| 53 | + sha: t.sha, | |
| 54 | + url: `${api(c)}/git/trees/${t.sha}`, | |
| 55 | + tree: t.tree.map((e) => ({ ...e, url: `${api(c)}/git/${e.type === "tree" ? "trees" : e.type === "blob" ? "blobs" : "commits"}/${e.sha}` })), | |
| 56 | + truncated: t.truncated, | |
| 57 | +}); | |
| 58 | + | |
| 59 | +gitdata.post(`${R}/git/trees`, async (c) => { | |
| 60 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 61 | + const b = await jsonBody<{ base_tree?: string; tree?: TreeInput[] }>(c); | |
| 62 | + if (!Array.isArray(b.tree)) throw new HttpError(422, "tree is required", { errors: [{ resource: "Tree", code: "missing_field", field: "tree" }] }); | |
| 63 | + if (b.tree.length > 10_000) throw new HttpError(422, "a tree request can change at most 10000 entries"); | |
| 64 | + const sha = await stub.createTree(typeof b.base_tree === "string" ? b.base_tree : undefined, b.tree, caller); | |
| 65 | + return c.json(treeJson(c, await stub.getTree(sha, false)), 201); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +gitdata.get(`${R}/git/trees/:sha{.+}`, async (c) => { | |
| 69 | + const { stub } = await loadTarget(c); | |
| 70 | + const recursive = !!c.req.query("recursive") && c.req.query("recursive") !== "0" && c.req.query("recursive") !== "false"; | |
| 71 | + return c.json(treeJson(c, await stub.getTree(c.req.param("sha"), recursive))); | |
| 72 | +}); | |
| 73 | + | |
| 74 | +// ---------------------------------------------------------------- commits | |
| 75 | + | |
| 76 | +gitdata.post(`${R}/git/commits`, async (c) => { | |
| 77 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 78 | + const b = await jsonBody<{ message?: string; tree?: string; parents?: string[]; author?: SigInput; committer?: SigInput; signature?: string }>(c); | |
| 79 | + const commit = await stub.createGitCommit(b, caller); | |
| 80 | + return c.json(ghGitCommit(baseOf(c), repo.owner_login, repo.name, commit), 201); | |
| 81 | +}); | |
| 82 | + | |
| 83 | +gitdata.get(`${R}/git/commits/:sha`, async (c) => { | |
| 84 | + const { stub, repo } = await loadTarget(c); | |
| 85 | + return c.json(ghGitCommit(baseOf(c), repo.owner_login, repo.name, await stub.gitCommit(c.req.param("sha")))); | |
| 86 | +}); | |
| 87 | + | |
| 88 | +// ---------------------------------------------------------------- annotated tags | |
| 89 | + | |
| 90 | +type Tag = Awaited<ReturnType<Awaited<ReturnType<typeof loadRepo>>["stub"]["gitTag"]>>; | |
| 91 | +const tagJson = (c: AppContext, t: Tag) => ({ | |
| 92 | + node_id: nodeId("Tag", t.sha), | |
| 93 | + tag: t.tag, | |
| 94 | + sha: t.sha, | |
| 95 | + url: `${api(c)}/git/tags/${t.sha}`, | |
| 96 | + message: t.message, | |
| 97 | + tagger: t.tagger ? sigJson(t.tagger) : null, | |
| 98 | + object: { type: t.object.type, sha: t.object.sha, url: `${api(c)}/git/${t.object.type}s/${t.object.sha}` }, | |
| 99 | + verification: { verified: false, reason: "unsigned", signature: null, payload: null, verified_at: null }, | |
| 100 | +}); | |
| 101 | + | |
| 102 | +gitdata.post(`${R}/git/tags`, async (c) => { | |
| 103 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 104 | + const b = await jsonBody<{ tag?: string; message?: string; object?: string; type?: string; tagger?: SigInput }>(c); | |
| 105 | + return c.json(tagJson(c, await stub.createGitTag(b, caller)), 201); | |
| 106 | +}); | |
| 107 | + | |
| 108 | +gitdata.get(`${R}/git/tags/:sha`, async (c) => { | |
| 109 | + const { stub } = await loadTarget(c); | |
| 110 | + return c.json(tagJson(c, await stub.gitTag(c.req.param("sha")))); | |
| 111 | +}); | |
| 112 | + | |
| 113 | +// ---------------------------------------------------------------- refs (single, matching, all) | |
| 114 | + | |
| 115 | +gitdata.get(`${R}/git/ref/:ref{.+}`, async (c) => { | |
| 116 | + const { stub, repo } = await loadTarget(c); | |
| 117 | + const name = `refs/${c.req.param("ref")}`; | |
| 118 | + const r = (await stub.refsWithTypes(name)).find((x) => x.ref === name); | |
| 119 | + if (!r) throw new HttpError(404, "Not Found"); | |
| 120 | + return c.json(ghRef(baseOf(c), repo.owner_login, repo.name, r)); | |
| 121 | +}); | |
| 122 | + | |
| 123 | +gitdata.get(`${R}/git/matching-refs/:ref{.*}`, async (c) => { | |
| 124 | + const { stub, repo } = await loadTarget(c); | |
| 125 | + const prefix = `refs/${(c.req.param("ref") ?? "")}`; | |
| 126 | + const list = (await stub.refsWithTypes(prefix)).filter((r) => !r.ref.startsWith("refs/pull/") || prefix.startsWith("refs/pull/")); | |
| 127 | + return c.json(paginate(c, list.map((r) => ghRef(baseOf(c), repo.owner_login, repo.name, r)), 100)); | |
| 128 | +}); | |
| 129 | + | |
| 130 | +gitdata.get(`${R}/git/refs`, async (c) => { | |
| 131 | + const { stub, repo } = await loadTarget(c); | |
| 132 | + const list = (await stub.refsWithTypes("refs/")).filter((r) => !r.ref.startsWith("refs/pull/")); | |
| 133 | + return c.json(paginate(c, list.map((r) => ghRef(baseOf(c), repo.owner_login, repo.name, r)), 100)); | |
| 134 | +}); | |
| 135 | + | |
| 136 | +// ---------------------------------------------------------------- README | |
| 137 | + | |
| 138 | +async function readme(c: AppContext) { | |
| 139 | + const { stub, repo } = await loadTarget(c); | |
| 140 | + const ref = c.req.query("ref") || repo.default_branch; | |
| 141 | + const dir = (c.req.param("dir") ?? "").replace(/^\/+|\/+$/g, ""); | |
| 142 | + const r = await stub.readme(ref, dir); | |
| 143 | + if (!r) throw new HttpError(404, "Not Found"); | |
| 144 | + if (wantsRaw(c)) return new Response(r.content, { headers: { "content-type": "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } }); | |
| 145 | + const html = repoHtml(baseOf(c), repo.owner_login, repo.name); | |
| 146 | + const self = `${api(c)}/contents/${encPath(r.path)}?ref=${encodeURIComponent(ref)}`; | |
| 147 | + const git = `${api(c)}/git/blobs/${r.sha}`; | |
| 148 | + const htmlUrl = `${html}/blob/${encPath(ref)}/${encPath(r.path)}`; | |
| 149 | + return c.json({ | |
| 150 | + type: "file", | |
| 151 | + encoding: "base64", | |
| 152 | + size: r.size, | |
| 153 | + name: r.path.split("/").pop(), | |
| 154 | + path: r.path, | |
| 155 | + content: base64Lines(r.content), | |
| 156 | + sha: r.sha, | |
| 157 | + url: self, | |
| 158 | + git_url: git, | |
| 159 | + html_url: htmlUrl, | |
| 160 | + download_url: `${html}/raw/${encPath(ref)}/${encPath(r.path)}`, | |
| 161 | + _links: { self, git, html: htmlUrl }, | |
| 162 | + }); | |
| 163 | +} | |
| 164 | +gitdata.get(`${R}/readme`, readme); | |
| 165 | +gitdata.get(`${R}/readme/:dir{.+}`, readme); | |
| 166 | + | |
| 167 | +// ---------------------------------------------------------------- merges, languages, archives | |
| 168 | + | |
| 169 | +gitdata.post(`${R}/merges`, async (c) => { | |
| 170 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 171 | + const b = await jsonBody<{ base?: string; head?: string; commit_message?: string }>(c); | |
| 172 | + if (typeof b.base !== "string" || typeof b.head !== "string") throw new HttpError(422, "base and head are required", { errors: [{ resource: "Merge", code: "missing_field", field: typeof b.base !== "string" ? "base" : "head" }] }); | |
| 173 | + const commit = await stub.mergeBranches(b, caller); | |
| 174 | + if (!commit) return c.body(null, 204); | |
| 175 | + return c.json(ghCommit(baseOf(c), repo.owner_login, repo.name, commit), 201); | |
| 176 | +}); | |
| 177 | + | |
| 178 | +gitdata.get(`${R}/languages`, async (c) => { | |
| 179 | + const { stub } = await loadTarget(c); | |
| 180 | + return c.json(await stub.languages()); | |
| 181 | +}); | |
| 182 | + | |
| 183 | +for (const [path, format] of [ | |
| 184 | + ["zipball", "zip"], | |
| 185 | + ["tarball", "tar.gz"], | |
| 186 | +] as const) { | |
| 187 | + const handler = async (c: AppContext) => { | |
| 188 | + const { stub, repo } = await loadTarget(c); | |
| 189 | + return archiveResponse(c, stub, repo.owner_login, repo.name, (c.req.param("ref") ?? "") || repo.default_branch, format); | |
| 190 | + }; | |
| 191 | + gitdata.get(`${R}/${path}`, handler); | |
| 192 | + gitdata.get(`${R}/${path}/:ref{.+}`, handler); | |
| 193 | +} |
addedapps/diffinery/src/http/issues.ts+335−0View file
| @@ -1,0 +1,335 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import type { Repo } from "../lib/directory.js"; | |
| 5 | +import { HttpError } from "../lib/util.js"; | |
| 6 | +import type { IssueEvent, Reaction } from "../repo/social.js"; | |
| 7 | +import { baseOf, ghComment, ghIssue, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, setLinks, type IssueCtx } from "./gh.js"; | |
| 8 | +import { intParam, issueCtx, jsonBody, loadTarget, pullsOut, repoParams, type Stub } from "./ghctx.js"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Issue and pull request sub-resources bots and workflows use: labels and | |
| 12 | + * assignees on one issue, single and repository-wide comments, locking, | |
| 13 | + * reactions, events and timelines, review comment replies, dismissing reviews, | |
| 14 | + * removing requested reviewers and updating a pull request's branch. | |
| 15 | + */ | |
| 16 | +export const issues = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 17 | + | |
| 18 | +const R = "/repos/:owner/:repo"; | |
| 19 | +const load = (c: AppContext, min: "read" | "triage" | "write" = "read") => loadTarget(c, min); | |
| 20 | +const labelJson = (c: AppContext, repo: Repo, l: { id?: number; name: string; color?: string; description?: string }) => ghLabel(baseOf(c), repo.owner_login, repo.name, l); | |
| 21 | +const names = (v: unknown): string[] => { | |
| 22 | + const list = Array.isArray(v) ? v : v && typeof v === "object" && Array.isArray((v as { labels?: unknown }).labels) ? (v as { labels: unknown[] }).labels : null; | |
| 23 | + if (!list) throw new HttpError(422, "labels must be an array of names", { errors: [{ resource: "Label", code: "invalid", field: "labels" }] }); | |
| 24 | + return list.map((l) => (typeof l === "string" ? l : String((l as { name?: unknown })?.name ?? ""))).filter(Boolean); | |
| 25 | +}; | |
| 26 | + | |
| 27 | +// ---------------------------------------------------------------- labels | |
| 28 | + | |
| 29 | +issues.get(`${R}/labels/:name`, async (c) => { | |
| 30 | + const { stub, repo } = await load(c); | |
| 31 | + return c.json(labelJson(c, repo, await stub.label(c.req.param("name")))); | |
| 32 | +}); | |
| 33 | +issues.patch(`${R}/labels/:name`, async (c) => { | |
| 34 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 35 | + const b = await jsonBody<{ new_name?: string; color?: string; description?: string }>(c); | |
| 36 | + return c.json(labelJson(c, repo, await stub.updateLabel(c.req.param("name"), b, caller))); | |
| 37 | +}); | |
| 38 | +issues.delete(`${R}/labels/:name`, async (c) => { | |
| 39 | + const { stub, caller } = await load(c, "triage"); | |
| 40 | + await stub.deleteLabel(c.req.param("name"), caller); | |
| 41 | + return c.body(null, 204); | |
| 42 | +}); | |
| 43 | + | |
| 44 | +issues.get(`${R}/issues/:number/labels`, async (c) => { | |
| 45 | + const { stub, repo } = await load(c); | |
| 46 | + return c.json(paginate(c, (await stub.issueLabels(intParam(c, "number"))).map((l) => labelJson(c, repo, l)))); | |
| 47 | +}); | |
| 48 | +issues.post(`${R}/issues/:number/labels`, async (c) => { | |
| 49 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 50 | + const list = await stub.addIssueLabels(intParam(c, "number"), names(await jsonBody(c)), caller); | |
| 51 | + return c.json(list.map((l) => labelJson(c, repo, l))); | |
| 52 | +}); | |
| 53 | +issues.put(`${R}/issues/:number/labels`, async (c) => { | |
| 54 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 55 | + const list = await stub.setIssueLabels(intParam(c, "number"), names(await jsonBody(c, true)), caller); | |
| 56 | + return c.json(list.map((l) => labelJson(c, repo, l))); | |
| 57 | +}); | |
| 58 | +issues.delete(`${R}/issues/:number/labels`, async (c) => { | |
| 59 | + const { stub, caller } = await load(c, "triage"); | |
| 60 | + await stub.setIssueLabels(intParam(c, "number"), [], caller); | |
| 61 | + return c.body(null, 204); | |
| 62 | +}); | |
| 63 | +issues.delete(`${R}/issues/:number/labels/:name`, async (c) => { | |
| 64 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 65 | + const list = await stub.removeIssueLabel(intParam(c, "number"), c.req.param("name"), caller); | |
| 66 | + return c.json(list.map((l) => labelJson(c, repo, l))); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +// ---------------------------------------------------------------- assignees and locking | |
| 70 | + | |
| 71 | +const oneIssue = async (c: AppContext, repo: Repo, stub: Stub, n: number) => { | |
| 72 | + const { issue } = await stub.issue(n); | |
| 73 | + return ghIssue(await issueCtx(c, repo, stub, [issue]), issue); | |
| 74 | +}; | |
| 75 | + | |
| 76 | +issues.post(`${R}/issues/:number/assignees`, async (c) => { | |
| 77 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 78 | + const n = intParam(c, "number"); | |
| 79 | + const b = await jsonBody<{ assignees?: unknown }>(c); | |
| 80 | + await stub.assignIssue(n, Array.isArray(b.assignees) ? b.assignees.map(String) : [], true, caller); | |
| 81 | + return c.json(await oneIssue(c, repo, stub, n), 201); | |
| 82 | +}); | |
| 83 | +issues.delete(`${R}/issues/:number/assignees`, async (c) => { | |
| 84 | + const { stub, caller, repo } = await load(c, "triage"); | |
| 85 | + const n = intParam(c, "number"); | |
| 86 | + const b = await jsonBody<{ assignees?: unknown }>(c, true); | |
| 87 | + await stub.assignIssue(n, Array.isArray(b.assignees) ? b.assignees.map(String) : [], false, caller); | |
| 88 | + return c.json(await oneIssue(c, repo, stub, n)); | |
| 89 | +}); | |
| 90 | +issues.get(`${R}/assignees`, async (c) => { | |
| 91 | + const { repo } = await load(c); | |
| 92 | + const people = (await c.get("dir").collaborators(repo.id)).filter((x) => x.role !== "read").map((x) => ghUser(baseOf(c), x.account)); | |
| 93 | + const owner = ghUser(baseOf(c), { id: repo.owner_id, login: repo.owner_login, kind: repo.owner_kind }); | |
| 94 | + return c.json(paginate(c, repo.owner_kind === "org" || people.some((p) => p?.login === owner?.login) ? people : [owner, ...people])); | |
| 95 | +}); | |
| 96 | +issues.get(`${R}/assignees/:login`, async (c) => { | |
| 97 | + const { repo } = await load(c); | |
| 98 | + const login = c.req.param("login").toLowerCase(); | |
| 99 | + if (login === repo.owner_login.toLowerCase()) return c.body(null, 204); | |
| 100 | + const ok = (await c.get("dir").collaborators(repo.id)).some((x) => x.account.login.toLowerCase() === login && x.role !== "read"); | |
| 101 | + if (!ok) throw new HttpError(404, "Not Found"); | |
| 102 | + return c.body(null, 204); | |
| 103 | +}); | |
| 104 | + | |
| 105 | +issues.put(`${R}/issues/:number/lock`, async (c) => { | |
| 106 | + const { stub, caller } = await load(c, "triage"); | |
| 107 | + const b = await jsonBody<{ lock_reason?: string }>(c, true); | |
| 108 | + await stub.lockIssue(intParam(c, "number"), b.lock_reason ?? null, caller); | |
| 109 | + return c.body(null, 204); | |
| 110 | +}); | |
| 111 | +issues.delete(`${R}/issues/:number/lock`, async (c) => { | |
| 112 | + const { stub, caller } = await load(c, "triage"); | |
| 113 | + await stub.unlockIssue(intParam(c, "number"), caller); | |
| 114 | + return c.body(null, 204); | |
| 115 | +}); | |
| 116 | + | |
| 117 | +// ---------------------------------------------------------------- comments (repository-wide and single) | |
| 118 | + | |
| 119 | +issues.get(`${R}/issues/comments`, async (c) => { | |
| 120 | + const { stub, repo } = await load(c); | |
| 121 | + const p = pageOf(c); | |
| 122 | + const res = await stub.repoComments({ review: false, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset }); | |
| 123 | + setLinks(c, p, res.total); | |
| 124 | + const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `comment:${i.comment.id}`)); | |
| 125 | + return c.json(res.items.map((i) => ghComment(x, i.number, i.isPull, i.comment))); | |
| 126 | +}); | |
| 127 | +issues.get(`${R}/issues/comments/:id`, async (c) => { | |
| 128 | + const { stub, repo } = await load(c); | |
| 129 | + const found = await stub.comment(c.req.param("id")); | |
| 130 | + return c.json(ghComment(await issueCtx(c, repo, stub, [], [`comment:${found.comment.id}`]), found.number, found.isPull, found.comment)); | |
| 131 | +}); | |
| 132 | +issues.delete(`${R}/issues/comments/:id`, async (c) => { | |
| 133 | + const { stub, caller } = await load(c); | |
| 134 | + await stub.deleteComment(c.req.param("id"), false, caller); | |
| 135 | + return c.body(null, 204); | |
| 136 | +}); | |
| 137 | + | |
| 138 | +// ---------------------------------------------------------------- pull request review comments | |
| 139 | + | |
| 140 | +issues.get(`${R}/pulls/comments`, async (c) => { | |
| 141 | + const { stub, repo } = await load(c); | |
| 142 | + const p = pageOf(c); | |
| 143 | + const res = await stub.repoComments({ review: true, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset }); | |
| 144 | + setLinks(c, p, res.total); | |
| 145 | + const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `review_comment:${i.comment.id}`)); | |
| 146 | + return c.json(res.items.map((i) => ghReviewComment(x, i.number, i.comment))); | |
| 147 | +}); | |
| 148 | +issues.get(`${R}/pulls/comments/:id`, async (c) => { | |
| 149 | + const { stub, repo } = await load(c); | |
| 150 | + const found = await stub.comment(c.req.param("id"), true); | |
| 151 | + return c.json(ghReviewComment(await issueCtx(c, repo, stub, [], [`review_comment:${found.comment.id}`]), found.number, found.comment)); | |
| 152 | +}); | |
| 153 | +issues.patch(`${R}/pulls/comments/:id`, async (c) => { | |
| 154 | + const { stub, caller, repo } = await load(c); | |
| 155 | + const b = await jsonBody<{ body?: string }>(c); | |
| 156 | + if (typeof b.body !== "string" || !b.body.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "body" }] }); | |
| 157 | + await stub.comment(c.req.param("id"), true); | |
| 158 | + await stub.editComment(c.req.param("id"), b.body, caller); | |
| 159 | + const found = await stub.comment(c.req.param("id"), true); | |
| 160 | + return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), found.number, found.comment)); | |
| 161 | +}); | |
| 162 | +issues.delete(`${R}/pulls/comments/:id`, async (c) => { | |
| 163 | + const { stub, caller } = await load(c); | |
| 164 | + await stub.deleteComment(c.req.param("id"), true, caller); | |
| 165 | + return c.body(null, 204); | |
| 166 | +}); | |
| 167 | +issues.post(`${R}/pulls/:number/comments`, async (c) => { | |
| 168 | + const { stub, caller, repo } = await load(c); | |
| 169 | + const n = intParam(c, "number"); | |
| 170 | + const b = await jsonBody<{ body?: string; commit_id?: string; path?: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; in_reply_to?: string | number }>(c); | |
| 171 | + const cm = await stub.createReviewComment(n, { ...b, in_reply_to: b.in_reply_to === undefined ? undefined : String(b.in_reply_to) }, caller); | |
| 172 | + return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), n, cm), 201); | |
| 173 | +}); | |
| 174 | +issues.post(`${R}/pulls/:number/comments/:id/replies`, async (c) => { | |
| 175 | + const { stub, caller, repo } = await load(c); | |
| 176 | + const n = intParam(c, "number"); | |
| 177 | + const b = await jsonBody<{ body?: string }>(c); | |
| 178 | + const cm = await stub.createReviewComment(n, { body: b.body, in_reply_to: c.req.param("id") }, caller); | |
| 179 | + return c.json(ghReviewComment(await issueCtx(c, repo, stub, []), n, cm), 201); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +// ---------------------------------------------------------------- reviews and reviewers | |
| 183 | + | |
| 184 | +issues.get(`${R}/pulls/:number/reviews/:id`, async (c) => { | |
| 185 | + const { stub, repo } = await load(c); | |
| 186 | + const n = intParam(c, "number"); | |
| 187 | + return c.json(ghReview(await issueCtx(c, repo, stub, []), n, await stub.review(n, c.req.param("id")))); | |
| 188 | +}); | |
| 189 | +issues.put(`${R}/pulls/:number/reviews/:id/dismissals`, async (c) => { | |
| 190 | + const { stub, caller, repo } = await load(c, "write"); | |
| 191 | + const n = intParam(c, "number"); | |
| 192 | + const b = await jsonBody<{ message?: string; event?: string }>(c); | |
| 193 | + if (typeof b.message !== "string" || !b.message.trim()) throw new HttpError(422, "message is required", { errors: [{ resource: "PullRequestReview", code: "missing_field", field: "message" }] }); | |
| 194 | + return c.json(ghReview(await issueCtx(c, repo, stub, []), n, await stub.dismissReview(n, c.req.param("id"), b.message, caller))); | |
| 195 | +}); | |
| 196 | +issues.get(`${R}/pulls/:number/requested_reviewers`, async (c) => { | |
| 197 | + const { stub, repo } = await load(c); | |
| 198 | + const { issue } = await stub.issue(intParam(c, "number")); | |
| 199 | + if (!issue.pull) throw new HttpError(404, "Not Found"); | |
| 200 | + const [pull] = await pullsOut(c, repo, stub, [issue]); | |
| 201 | + return c.json({ users: pull!.requested_reviewers, teams: [] }); | |
| 202 | +}); | |
| 203 | +issues.delete(`${R}/pulls/:number/requested_reviewers`, async (c) => { | |
| 204 | + const { stub, caller, repo } = await load(c); | |
| 205 | + const n = intParam(c, "number"); | |
| 206 | + const b = await jsonBody<{ reviewers?: unknown }>(c, true); | |
| 207 | + await stub.removeReviewers(n, Array.isArray(b.reviewers) ? b.reviewers.map(String) : [], caller); | |
| 208 | + return c.json((await pullsOut(c, repo, stub, [(await stub.issue(n)).issue]))[0]); | |
| 209 | +}); | |
| 210 | +issues.put(`${R}/pulls/:number/update-branch`, async (c) => { | |
| 211 | + const { stub, caller } = await load(c, "write"); | |
| 212 | + const n = intParam(c, "number"); | |
| 213 | + const b = await jsonBody<{ expected_head_sha?: string }>(c, true); | |
| 214 | + await stub.updatePullBranch(n, b.expected_head_sha, caller); | |
| 215 | + return c.json({ message: "Updating pull request branch.", url: `${repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/pulls/${n}` }, 202); | |
| 216 | +}); | |
| 217 | +issues.get(`${R}/commits/:sha/pulls`, async (c) => { | |
| 218 | + const { stub, repo } = await load(c); | |
| 219 | + const sha = await stub.resolve(c.req.param("sha")); | |
| 220 | + if (!sha) throw new HttpError(422, `No commit found for SHA: ${c.req.param("sha")}`); | |
| 221 | + const numbers = paginate(c, await stub.pullsForCommit(sha)); | |
| 222 | + const list = []; | |
| 223 | + for (const n of numbers) list.push((await stub.issue(n)).issue); | |
| 224 | + return c.json(await pullsOut(c, repo, stub, list)); | |
| 225 | +}); | |
| 226 | + | |
| 227 | +// ---------------------------------------------------------------- reactions | |
| 228 | + | |
| 229 | +function reactionJson(c: AppContext, r: Reaction) { | |
| 230 | + return { id: r.id, node_id: nodeId("Reaction", r.id), user: ghUser(baseOf(c), r.user), content: r.content, created_at: isoSeconds(r.createdAt) }; | |
| 231 | +} | |
| 232 | + | |
| 233 | +type Loaded = Awaited<ReturnType<typeof load>>; | |
| 234 | + | |
| 235 | +/** Reaction routes for one kind of subject; `subject` maps the path to `issue:N`, `comment:ID`, ... and `visible` checks the caller may see it. */ | |
| 236 | +function reactionRoutes(path: string, subject: (c: AppContext) => string, visible?: (c: AppContext, r: Loaded) => Promise<unknown>) { | |
| 237 | + const open = async (c: AppContext) => { | |
| 238 | + const r = await load(c); | |
| 239 | + if (visible) await visible(c, r); | |
| 240 | + return r; | |
| 241 | + }; | |
| 242 | + issues.get(`${R}/${path}/reactions`, async (c) => { | |
| 243 | + const { stub } = await open(c); | |
| 244 | + return c.json(paginate(c, (await stub.reactions(subject(c), c.req.query("content") || undefined)).map((r) => reactionJson(c, r)))); | |
| 245 | + }); | |
| 246 | + issues.post(`${R}/${path}/reactions`, async (c) => { | |
| 247 | + const { stub, caller } = await open(c); | |
| 248 | + const b = await jsonBody<{ content?: string }>(c); | |
| 249 | + const r = await stub.react(subject(c), String(b.content ?? ""), caller); | |
| 250 | + return c.json(reactionJson(c, r.reaction), r.created ? 201 : 200); | |
| 251 | + }); | |
| 252 | + issues.delete(`${R}/${path}/reactions/:reaction`, async (c) => { | |
| 253 | + const { stub, caller } = await open(c); | |
| 254 | + await stub.unreact(subject(c), intParam(c, "reaction"), caller); | |
| 255 | + return c.body(null, 204); | |
| 256 | + }); | |
| 257 | +} | |
| 258 | +reactionRoutes("issues/comments/:id", (c) => `comment:${c.req.param("id")}`); | |
| 259 | +reactionRoutes("pulls/comments/:id", (c) => `review_comment:${c.req.param("id")}`); | |
| 260 | +reactionRoutes("issues/:number", (c) => `issue:${intParam(c, "number")}`); | |
| 261 | +// Draft releases are hidden from readers, and so are their reactions. | |
| 262 | +reactionRoutes("releases/:id", (c) => `release:${intParam(c, "id")}`, (c, r) => r.stub.getRelease(intParam(c, "id"), r.caller)); | |
| 263 | + | |
| 264 | +// ---------------------------------------------------------------- events and timeline | |
| 265 | + | |
| 266 | +function eventJson(x: IssueCtx, e: IssueEvent, withIssue?: ReturnType<typeof ghIssue>): Record<string, unknown> { | |
| 267 | + const api = repoApi(x.base, x.owner, x.repo); | |
| 268 | + const d = JSON.parse(e.detail) as Record<string, unknown>; | |
| 269 | + const commit = typeof d.commit_id === "string" ? d.commit_id : null; | |
| 270 | + const person = (login: unknown) => (typeof login === "string" ? ghUser(x.base, x.people?.get(login.toLowerCase()) ?? { id: login, login }) : undefined); | |
| 271 | + return { | |
| 272 | + id: e.id, | |
| 273 | + node_id: nodeId("IssueEvent", `${x.repoId}/${e.id}`), | |
| 274 | + url: `${api}/issues/events/${e.id}`, | |
| 275 | + actor: ghUser(x.base, e.actor), | |
| 276 | + event: e.event, | |
| 277 | + commit_id: commit, | |
| 278 | + commit_url: commit ? `${api}/commits/${commit}` : null, | |
| 279 | + created_at: isoSeconds(e.createdAt), | |
| 280 | + performed_via_github_app: null, | |
| 281 | + ...(d.label ? { label: d.label } : {}), | |
| 282 | + ...(d.assignee ? { assignee: person(d.assignee), assigner: ghUser(x.base, e.actor) } : {}), | |
| 283 | + ...(d.rename ? { rename: d.rename } : {}), | |
| 284 | + ...("lock_reason" in d ? { lock_reason: d.lock_reason ?? null } : {}), | |
| 285 | + ...(d.requested_reviewer ? { requested_reviewer: person(d.requested_reviewer), review_requester: ghUser(x.base, e.actor) } : {}), | |
| 286 | + ...(d.dismissed_review ? { dismissed_review: d.dismissed_review } : {}), | |
| 287 | + ...(d.state_reason ? { state_reason: d.state_reason } : {}), | |
| 288 | + ...(withIssue ? { issue: withIssue } : {}), | |
| 289 | + }; | |
| 290 | +} | |
| 291 | + | |
| 292 | +issues.get(`${R}/issues/events`, async (c) => { | |
| 293 | + const { stub, repo } = await load(c); | |
| 294 | + const p = pageOf(c); | |
| 295 | + const res = await stub.repoIssueEvents(p.perPage, p.offset); | |
| 296 | + setLinks(c, p, res.total); | |
| 297 | + const numbers = [...new Set(res.items.map((e) => e.number))]; | |
| 298 | + const list = []; | |
| 299 | + for (const n of numbers) list.push((await stub.issue(n)).issue); | |
| 300 | + const x = await issueCtx(c, repo, stub, list); | |
| 301 | + const byNumber = new Map(list.map((i) => [i.number, ghIssue(x, i)])); | |
| 302 | + return c.json(res.items.map((e) => eventJson(x, e, byNumber.get(e.number)))); | |
| 303 | +}); | |
| 304 | +issues.get(`${R}/issues/events/:id`, async (c) => { | |
| 305 | + const { stub, repo } = await load(c); | |
| 306 | + const e = await stub.issueEvent(intParam(c, "id")); | |
| 307 | + const { issue } = await stub.issue(e.number); | |
| 308 | + const x = await issueCtx(c, repo, stub, [issue]); | |
| 309 | + return c.json(eventJson(x, e, ghIssue(x, issue))); | |
| 310 | +}); | |
| 311 | +issues.get(`${R}/issues/:number/events`, async (c) => { | |
| 312 | + const { stub, repo } = await load(c); | |
| 313 | + const x = await issueCtx(c, repo, stub, []); | |
| 314 | + return c.json(paginate(c, (await stub.issueEvents(intParam(c, "number"))).map((e) => eventJson(x, e)))); | |
| 315 | +}); | |
| 316 | +issues.get(`${R}/issues/:number/timeline`, async (c) => { | |
| 317 | + const { stub, repo } = await load(c); | |
| 318 | + const n = intParam(c, "number"); | |
| 319 | + const { issue } = await stub.issue(n); | |
| 320 | + const items = await stub.issueTimeline(n); | |
| 321 | + const x = await issueCtx(c, repo, stub, [], items.flatMap((i) => (i.kind === "comment" ? [`comment:${i.comment.id}`] : []))); | |
| 322 | + const isPull = issue.kind === "pull"; | |
| 323 | + return c.json( | |
| 324 | + paginate( | |
| 325 | + c, | |
| 326 | + items.map((i) => | |
| 327 | + i.kind === "event" | |
| 328 | + ? eventJson(x, i) | |
| 329 | + : i.kind === "comment" | |
| 330 | + ? { event: "commented", actor: ghUser(x.base, i.comment.author), ...ghComment(x, n, isPull, i.comment) } | |
| 331 | + : { event: "reviewed", actor: ghUser(x.base, i.review.author), ...ghReview(x, n, i.review), state: i.review.state.toLowerCase() }, | |
| 332 | + ), | |
| 333 | + ), | |
| 334 | + ); | |
| 335 | +}); |
addedapps/diffinery/src/http/misc.ts+146−0View file
| @@ -1,0 +1,146 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import { baseOf, ghRun, isoSeconds, nodeId, repoApi, repoHtml } from "./gh.js"; | |
| 6 | +import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Smaller GitHub endpoints scripts and actions call: rate limits, topics, | |
| 10 | + * repository_dispatch, workflow details, run attempts and log archives, and | |
| 11 | + * the POST/PATCH/GET forms of repository variables. | |
| 12 | + */ | |
| 13 | +export const misc = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 14 | + | |
| 15 | +const R = "/repos/:owner/:repo"; | |
| 16 | +const load = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadTarget(c, min); | |
| 17 | + | |
| 18 | +// Diffinery doesn't meter API calls; report a generous, constant budget so clients that check it carry on. | |
| 19 | +const LIMIT = 15_000; | |
| 20 | +misc.get("/rate_limit", (c) => { | |
| 21 | + const reset = Math.floor(Date.now() / 1000) + 3600; | |
| 22 | + const r = (limit: number) => ({ limit, used: 0, remaining: limit, reset }); | |
| 23 | + return c.json({ resources: { core: r(LIMIT), search: r(1000), graphql: r(LIMIT), integration_manifest: r(LIMIT), code_search: r(1000), actions_runner_registration: r(LIMIT) }, rate: r(LIMIT) }); | |
| 24 | +}); | |
| 25 | + | |
| 26 | +// ---------------------------------------------------------------- topics | |
| 27 | + | |
| 28 | +misc.get(`${R}/topics`, async (c) => { | |
| 29 | + const { repo } = await load(c); | |
| 30 | + let names: string[] = []; | |
| 31 | + try { | |
| 32 | + names = JSON.parse(repo.topics ?? "[]"); | |
| 33 | + } catch { | |
| 34 | + names = []; | |
| 35 | + } | |
| 36 | + return c.json({ names }); | |
| 37 | +}); | |
| 38 | +misc.put(`${R}/topics`, async (c) => { | |
| 39 | + const { repo, caller } = await load(c, "admin"); | |
| 40 | + const b = await jsonBody<{ names?: unknown }>(c); | |
| 41 | + if (!Array.isArray(b.names)) throw new HttpError(422, "names must be an array", { errors: [{ resource: "Repository", code: "invalid", field: "names" }] }); | |
| 42 | + const names = [...new Set(b.names.map((n) => String(n).trim().toLowerCase()).filter(Boolean))]; | |
| 43 | + const bad = names.find((n) => !/^[a-z0-9][a-z0-9-]{0,49}$/.test(n)); | |
| 44 | + if (bad) throw new HttpError(422, `${bad} is not a valid topic. Topics must start with a lowercase letter or number, consist of 50 characters or less, and can include hyphens.`, { errors: [{ resource: "Repository", code: "invalid", field: "names" }] }); | |
| 45 | + if (names.length > 20) throw new HttpError(422, "a repository can have at most 20 topics"); | |
| 46 | + await c.get("dir").setTopics(repo.id, names); | |
| 47 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action: "repo.topics", target: `${repo.owner_login}/${repo.name}`, detail: { names } })); | |
| 48 | + return c.json({ names }); | |
| 49 | +}); | |
| 50 | + | |
| 51 | +// ---------------------------------------------------------------- repository_dispatch | |
| 52 | + | |
| 53 | +misc.post(`${R}/dispatches`, async (c) => { | |
| 54 | + const { stub, caller } = await load(c, "write"); | |
| 55 | + const b = await jsonBody<{ event_type?: string; client_payload?: unknown }>(c); | |
| 56 | + if (typeof b.event_type !== "string" || !b.event_type || b.event_type.length > 100) throw new HttpError(422, "event_type is required (at most 100 characters)", { errors: [{ resource: "Repository", code: "invalid", field: "event_type" }] }); | |
| 57 | + const payload = b.client_payload ?? {}; | |
| 58 | + if (typeof payload !== "object" || Array.isArray(payload)) throw new HttpError(422, "client_payload must be an object"); | |
| 59 | + if (Object.keys(payload).length > 10) throw new HttpError(422, "client_payload can have at most 10 top-level properties"); | |
| 60 | + if (JSON.stringify(payload).length > 64 * 1024) throw new HttpError(422, "client_payload is too large"); | |
| 61 | + await stub.repositoryDispatch(b.event_type, payload as Record<string, unknown>, caller); | |
| 62 | + return c.body(null, 204); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +// ---------------------------------------------------------------- workflows | |
| 66 | + | |
| 67 | +async function findWorkflow(c: AppContext) { | |
| 68 | + const { stub } = await load(c); | |
| 69 | + const id = (c.req.param("workflow") ?? ""); | |
| 70 | + const wf = (await stub.workflows()).find((w) => w.path === id || w.path.endsWith(`/${id}`)); | |
| 71 | + if (!wf) throw new HttpError(404, "Not Found"); | |
| 72 | + return { stub, wf }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +misc.get(`${R}/actions/workflows/:workflow`, async (c) => { | |
| 76 | + const { wf } = await findWorkflow(c); | |
| 77 | + const b = baseOf(c); | |
| 78 | + const { owner, repo } = repoParams(c); | |
| 79 | + const now = isoSeconds(new Date().toISOString()); | |
| 80 | + return c.json({ | |
| 81 | + id: wf.path, | |
| 82 | + node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`), | |
| 83 | + name: wf.name, | |
| 84 | + path: wf.path, | |
| 85 | + state: wf.errors.length ? "invalid" : "active", | |
| 86 | + created_at: now, | |
| 87 | + updated_at: now, | |
| 88 | + url: `${repoApi(b, owner, repo)}/actions/workflows/${encodeURIComponent(wf.path)}`, | |
| 89 | + html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`, | |
| 90 | + badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`, | |
| 91 | + errors: wf.errors, | |
| 92 | + triggers: wf.triggers, | |
| 93 | + }); | |
| 94 | +}); | |
| 95 | + | |
| 96 | +misc.get(`${R}/actions/workflows/:workflow/runs`, async (c) => { | |
| 97 | + const { stub, wf } = await findWorkflow(c); | |
| 98 | + const perPage = Math.min(Math.max(Number(c.req.query("per_page")) || 30, 1), 100); | |
| 99 | + const page = Math.max(Number(c.req.query("page")) || 1, 1); | |
| 100 | + const res = await stub.runs({ workflow: wf.path, 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 }); | |
| 101 | + return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, r)) }); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +// ---------------------------------------------------------------- runs: attempts and logs | |
| 105 | + | |
| 106 | +misc.get(`${R}/actions/runs/:id/attempts/:attempt`, async (c) => { | |
| 107 | + const { stub } = await load(c); | |
| 108 | + const id = await stub.runAttemptId(c.req.param("id"), intParam(c, "attempt")); | |
| 109 | + return c.json(ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, await stub.run(id))); | |
| 110 | +}); | |
| 111 | + | |
| 112 | +misc.get(`${R}/actions/runs/:id/logs`, async (c) => { | |
| 113 | + const { stub } = await load(c); | |
| 114 | + const zip = await stub.runLogsZip(c.req.param("id")); | |
| 115 | + return new Response(zip, { headers: { "content-type": "application/zip", "content-disposition": `attachment; filename="logs_${c.req.param("id").replace(/[^\w-]/g, "")}.zip"` } }); | |
| 116 | +}); | |
| 117 | + | |
| 118 | +// ---------------------------------------------------------------- repository variables (GitHub's POST/GET/PATCH forms) | |
| 119 | + | |
| 120 | +misc.get(`${R}/actions/variables/:name`, async (c) => { | |
| 121 | + const { stub } = await load(c, "write"); | |
| 122 | + const name = c.req.param("name").toUpperCase(); | |
| 123 | + const vars = await stub.variables(); | |
| 124 | + if (!(name in vars)) throw new HttpError(404, "Not Found"); | |
| 125 | + return c.json({ name, value: vars[name], created_at: null, updated_at: null }); | |
| 126 | +}); | |
| 127 | +misc.post(`${R}/actions/variables`, async (c) => { | |
| 128 | + const { stub } = await load(c, "admin"); | |
| 129 | + const b = await jsonBody<{ name?: string; value?: string }>(c); | |
| 130 | + if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required", { errors: [{ resource: "ActionsVariable", code: "missing_field", field: typeof b.name !== "string" ? "name" : "value" }] }); | |
| 131 | + if (b.name.toUpperCase() in (await stub.variables())) throw new HttpError(409, "Already exists - Variable already exists"); | |
| 132 | + await stub.setVariable(b.name, b.value); | |
| 133 | + return c.json({}, 201); | |
| 134 | +}); | |
| 135 | +misc.patch(`${R}/actions/variables/:name`, async (c) => { | |
| 136 | + const { stub } = await load(c, "admin"); | |
| 137 | + const name = c.req.param("name"); | |
| 138 | + const vars = await stub.variables(); | |
| 139 | + if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found"); | |
| 140 | + const b = await jsonBody<{ name?: string; value?: string }>(c); | |
| 141 | + const next = typeof b.name === "string" && b.name ? b.name : name; | |
| 142 | + await stub.setVariable(next, typeof b.value === "string" ? b.value : vars[name.toUpperCase()]!); | |
| 143 | + if (next.toUpperCase() !== name.toUpperCase()) await stub.setVariable(name.toUpperCase(), null); | |
| 144 | + return c.body(null, 204); | |
| 145 | +}); | |
| 146 | + |
addedapps/diffinery/src/http/releases.ts+110−0View file
| @@ -1,0 +1,110 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import type { Repo } from "../lib/directory.js"; | |
| 5 | +import { HttpError } from "../lib/util.js"; | |
| 6 | +import type { Release } from "../repo/releases.js"; | |
| 7 | +import { serveDownload } from "./files.js"; | |
| 8 | +import { baseOf, ghAsset, ghRelease, pageOf, paginate, setLinks } from "./gh.js"; | |
| 9 | +import { intParam, jsonBody, loadTarget, repoParams, type Stub } from "./ghctx.js"; | |
| 10 | + | |
| 11 | +/** Releases and release assets (GitHub REST: /repos/{owner}/{repo}/releases). */ | |
| 12 | +export const releases = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 13 | + | |
| 14 | +type ReleaseBody = { tag_name?: string; target_commitish?: string; name?: string | null; body?: string | null; draft?: boolean; prerelease?: boolean; make_latest?: "true" | "false" | "legacy" | boolean; generate_release_notes?: boolean; discussion_category_name?: string }; | |
| 15 | + | |
| 16 | +const R = "/repos/:owner/:repo/releases"; | |
| 17 | + | |
| 18 | +async function out(c: AppContext, repo: Repo, stub: Stub, list: Release[]) { | |
| 19 | + const counts = (await stub.issueExtras([], list.map((r) => `release:${r.id}`))).reactions; | |
| 20 | + return list.map((r) => ghRelease(baseOf(c), repo.owner_login, repo.name, r, counts[`release:${r.id}`])); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function input(b: ReleaseBody): ReleaseBody { | |
| 24 | + for (const k of ["tag_name", "target_commitish"] as const) if (b[k] !== undefined && typeof b[k] !== "string") throw new HttpError(422, `${k} must be a string`, { errors: [{ resource: "Release", code: "invalid", field: k }] }); | |
| 25 | + if (b.body !== undefined && b.body !== null && typeof b.body !== "string") throw new HttpError(422, "body must be a string", { errors: [{ resource: "Release", code: "invalid", field: "body" }] }); | |
| 26 | + if (typeof b.body === "string" && b.body.length > 125_000) throw new HttpError(422, "body is too long (maximum is 125000 characters)", { errors: [{ resource: "Release", code: "custom", field: "body" }] }); | |
| 27 | + return { tag_name: b.tag_name, target_commitish: b.target_commitish, name: b.name, body: b.body, draft: b.draft === undefined ? undefined : !!b.draft, prerelease: b.prerelease === undefined ? undefined : !!b.prerelease, make_latest: b.make_latest, generate_release_notes: !!b.generate_release_notes }; | |
| 28 | +} | |
| 29 | + | |
| 30 | +releases.get(R, async (c) => { | |
| 31 | + const { stub, caller, repo } = await loadTarget(c); | |
| 32 | + const p = pageOf(c); | |
| 33 | + const res = await stub.listReleases({ limit: p.perPage, offset: p.offset }, caller); | |
| 34 | + setLinks(c, p, res.total); | |
| 35 | + return c.json(await out(c, repo, stub, res.items)); | |
| 36 | +}); | |
| 37 | + | |
| 38 | +releases.post(R, async (c) => { | |
| 39 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 40 | + const rel = await stub.createRelease(input(await jsonBody<ReleaseBody>(c)), caller); | |
| 41 | + return c.json((await out(c, repo, stub, [rel]))[0], 201); | |
| 42 | +}); | |
| 43 | + | |
| 44 | +releases.get(`${R}/latest`, async (c) => { | |
| 45 | + const { stub, repo } = await loadTarget(c); | |
| 46 | + return c.json((await out(c, repo, stub, [await stub.latestRelease()]))[0]); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +releases.get(`${R}/tags/:tag{.+}`, async (c) => { | |
| 50 | + const { stub, caller, repo } = await loadTarget(c); | |
| 51 | + return c.json((await out(c, repo, stub, [await stub.releaseByTag(c.req.param("tag"), caller)]))[0]); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +releases.post(`${R}/generate-notes`, async (c) => { | |
| 55 | + const { stub } = await loadTarget(c, "write"); | |
| 56 | + const b = await jsonBody<{ tag_name?: string; target_commitish?: string; previous_tag_name?: string; configuration_file_path?: string }>(c); | |
| 57 | + if (typeof b.tag_name !== "string" || !b.tag_name) throw new HttpError(422, "tag_name is required", { errors: [{ resource: "Release", code: "missing_field", field: "tag_name" }] }); | |
| 58 | + return c.json(await stub.generateReleaseNotes({ tag_name: b.tag_name, target_commitish: b.target_commitish, previous_tag_name: b.previous_tag_name, configuration_file_path: b.configuration_file_path })); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +// One asset: JSON, or the bytes with `Accept: application/octet-stream` (Octokit, `gh release download`). | |
| 62 | +releases.get(`${R}/assets/:id`, async (c) => { | |
| 63 | + const { stub, caller, repo } = await loadTarget(c); | |
| 64 | + const id = intParam(c, "id"); | |
| 65 | + if ((c.req.header("accept") ?? "").includes("application/octet-stream")) { | |
| 66 | + const d = await stub.releaseDownload({ assetId: id }, caller); | |
| 67 | + return serveDownload(c, d.r2Key, d.name, d.contentType); | |
| 68 | + } | |
| 69 | + const asset = await stub.releaseAsset(id, caller); | |
| 70 | + const rel = await stub.getRelease(asset.releaseId, caller); | |
| 71 | + return c.json(ghAsset(baseOf(c), repo.owner_login, repo.name, rel.tagName, asset)); | |
| 72 | +}); | |
| 73 | + | |
| 74 | +releases.patch(`${R}/assets/:id`, async (c) => { | |
| 75 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 76 | + const b = await jsonBody<{ name?: string; label?: string; state?: string }>(c); | |
| 77 | + const asset = await stub.updateReleaseAsset(intParam(c, "id"), { name: typeof b.name === "string" ? b.name : undefined, label: typeof b.label === "string" ? b.label : undefined }, caller); | |
| 78 | + const rel = await stub.getRelease(asset.releaseId, caller); | |
| 79 | + return c.json(ghAsset(baseOf(c), repo.owner_login, repo.name, rel.tagName, asset)); | |
| 80 | +}); | |
| 81 | + | |
| 82 | +releases.delete(`${R}/assets/:id`, async (c) => { | |
| 83 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 84 | + await stub.deleteReleaseAsset(intParam(c, "id"), caller); | |
| 85 | + return c.body(null, 204); | |
| 86 | +}); | |
| 87 | + | |
| 88 | +releases.get(`${R}/:id`, async (c) => { | |
| 89 | + const { stub, caller, repo } = await loadTarget(c); | |
| 90 | + return c.json((await out(c, repo, stub, [await stub.getRelease(intParam(c, "id"), caller)]))[0]); | |
| 91 | +}); | |
| 92 | + | |
| 93 | +releases.patch(`${R}/:id`, async (c) => { | |
| 94 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 95 | + const rel = await stub.updateRelease(intParam(c, "id"), input(await jsonBody<ReleaseBody>(c)), caller); | |
| 96 | + return c.json((await out(c, repo, stub, [rel]))[0]); | |
| 97 | +}); | |
| 98 | + | |
| 99 | +releases.delete(`${R}/:id`, async (c) => { | |
| 100 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 101 | + await stub.deleteRelease(intParam(c, "id"), caller); | |
| 102 | + return c.body(null, 204); | |
| 103 | +}); | |
| 104 | + | |
| 105 | +releases.get(`${R}/:id/assets`, async (c) => { | |
| 106 | + const { stub, caller, repo } = await loadTarget(c); | |
| 107 | + const id = intParam(c, "id"); | |
| 108 | + const rel = await stub.getRelease(id, caller); | |
| 109 | + return c.json(paginate(c, (await stub.releaseAssets(id, caller)).map((a) => ghAsset(baseOf(c), repo.owner_login, repo.name, rel.tagName, a)))); | |
| 110 | +}); |
addedapps/diffinery/src/http/search.ts+134−0View file
| @@ -1,0 +1,134 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { callerFor, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import type { Repo } from "../lib/directory.js"; | |
| 5 | +import { matchesText, parseIssueQuery, parseRepoQuery } from "../lib/search-query.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { Issue } from "../repo/collab.js"; | |
| 8 | +import { repoStub } from "./common.js"; | |
| 9 | +import { baseOf, ghIssue, ghRepo, pageOf, setLinks, topicsOf } from "./gh.js"; | |
| 10 | +import { issueCtx } from "./ghctx.js"; | |
| 11 | + | |
| 12 | +/** GET /search/issues and /search/repositories over the repositories the caller can read. */ | |
| 13 | +export const search = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 14 | + | |
| 15 | +/** Searches fan out to at most this many repositories; more sets `incomplete_results`. */ | |
| 16 | +const MAX_REPOS = 50; | |
| 17 | +const MAX_RESULTS = 1000; | |
| 18 | +/** GitHub's limit on the length of a search query. */ | |
| 19 | +const MAX_QUERY = 256; | |
| 20 | +/** Repository search checks access on at most this many candidates. */ | |
| 21 | +const MAX_REPO_CANDIDATES = 300; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Repositories the caller might read: only active accounts (and agents whose | |
| 25 | + * owner is active) get anything beyond public repositories, and every | |
| 26 | + * candidate is checked again with callerFor, which applies roles, token | |
| 27 | + * scopes and repository restrictions. | |
| 28 | + */ | |
| 29 | +async function candidates(c: AppContext): Promise<Repo[]> { | |
| 30 | + const p = c.get("principal"); | |
| 31 | + const dir = c.get("dir"); | |
| 32 | + if (p.type === "account") { | |
| 33 | + if (p.account.status !== "active") return []; | |
| 34 | + return dir.reposVisibleTo(p.account); | |
| 35 | + } | |
| 36 | + if (p.type === "repo-token") { | |
| 37 | + const r = await dir.repoById(p.repoId); | |
| 38 | + return r ? [r] : []; | |
| 39 | + } | |
| 40 | + return dir.publicRepos(500); | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** The first `limit` repositories (in order) the caller can actually read, and whether more were left unchecked. */ | |
| 44 | +async function authorized(c: AppContext, repos: Repo[], limit: number): Promise<{ repos: Repo[]; incomplete: boolean }> { | |
| 45 | + const out: Repo[] = []; | |
| 46 | + for (let i = 0; i < repos.length; i++) { | |
| 47 | + if (out.length >= limit) return { repos: out, incomplete: true }; | |
| 48 | + if (await callerFor(c, repos[i]!)) out.push(repos[i]!); | |
| 49 | + } | |
| 50 | + return { repos: out, incomplete: false }; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const fullName = (r: Repo) => `${r.owner_login}/${r.name}`.toLowerCase(); | |
| 54 | + | |
| 55 | +function queryOf(c: AppContext): string { | |
| 56 | + const raw = c.req.query("q") ?? ""; | |
| 57 | + if (!raw.trim()) throw new HttpError(422, "Validation Failed", { errors: [{ resource: "Search", field: "q", code: "missing" }] }); | |
| 58 | + if (raw.length > MAX_QUERY) throw new HttpError(422, `The search is longer than ${MAX_QUERY} characters.`, { errors: [{ resource: "Search", field: "q", code: "invalid" }] }); | |
| 59 | + return raw; | |
| 60 | +} | |
| 61 | + | |
| 62 | +search.get("/search/issues", async (c) => { | |
| 63 | + const q = parseIssueQuery(queryOf(c), { sort: c.req.query("sort"), order: c.req.query("order") }); | |
| 64 | + const p = pageOf(c); | |
| 65 | + if (p.offset >= MAX_RESULTS) throw new HttpError(422, "Only the first 1000 search results are available"); | |
| 66 | + let repos = await candidates(c); | |
| 67 | + if (q.repos.length) { | |
| 68 | + const want = new Set(q.repos.map((r) => r.toLowerCase())); | |
| 69 | + repos = repos.filter((r) => want.has(fullName(r))); | |
| 70 | + } | |
| 71 | + if (q.owners.length) { | |
| 72 | + const owners = new Set(q.owners.map((o) => o.toLowerCase())); | |
| 73 | + repos = repos.filter((r) => owners.has(r.owner_login.toLowerCase())); | |
| 74 | + } | |
| 75 | + const allowed = await authorized(c, repos, MAX_REPOS); | |
| 76 | + // Each repository returns at most `want` hits as (number, sort key); only the page is loaded afterwards. | |
| 77 | + const want = Math.min(p.offset + p.perPage, MAX_RESULTS); | |
| 78 | + const results = await Promise.all(allowed.repos.map(async (r) => ({ repo: r, ...(await repoStub(c.env, r.id).searchIssues(q, want)) }))); | |
| 79 | + const total = results.reduce((n, r) => n + r.total, 0); | |
| 80 | + const sort = q.sort ?? { field: "created", dir: "desc" }; | |
| 81 | + const page = results | |
| 82 | + .flatMap((r) => r.hits.map((h) => ({ repo: r.repo, number: h.number, key: h.key }))) | |
| 83 | + .sort((a, b) => { | |
| 84 | + const d = a.key < b.key ? -1 : a.key > b.key ? 1 : 0; | |
| 85 | + return (sort.dir === "asc" ? d : -d) || (a.repo.id < b.repo.id ? -1 : a.repo.id > b.repo.id ? 1 : b.number - a.number); | |
| 86 | + }) | |
| 87 | + .slice(p.offset, p.offset + p.perPage); | |
| 88 | + const byRepo = new Map<string, { repo: Repo; numbers: number[] }>(); | |
| 89 | + for (const h of page) { | |
| 90 | + const entry = byRepo.get(h.repo.id) ?? { repo: h.repo, numbers: [] }; | |
| 91 | + entry.numbers.push(h.number); | |
| 92 | + byRepo.set(h.repo.id, entry); | |
| 93 | + } | |
| 94 | + const shaped = new Map<string, ReturnType<typeof ghIssue>>(); | |
| 95 | + for (const { repo, numbers } of byRepo.values()) { | |
| 96 | + const stub = repoStub(c.env, repo.id); | |
| 97 | + const issues = (await stub.issuesByNumber(numbers)) as unknown as Issue[]; | |
| 98 | + const x = await issueCtx(c, repo, stub, issues); | |
| 99 | + for (const i of issues) shaped.set(`${repo.id}#${i.number}`, ghIssue(x, i)); | |
| 100 | + } | |
| 101 | + setLinks(c, p, Math.min(total, MAX_RESULTS)); | |
| 102 | + const items = page.flatMap((h) => { | |
| 103 | + const item = shaped.get(`${h.repo.id}#${h.number}`); | |
| 104 | + return item ? [{ ...item, score: 1 }] : []; | |
| 105 | + }); | |
| 106 | + return c.json({ total_count: total, incomplete_results: allowed.incomplete, items }); | |
| 107 | +}); | |
| 108 | + | |
| 109 | +search.get("/search/repositories", async (c) => { | |
| 110 | + const q = parseRepoQuery(queryOf(c)); | |
| 111 | + const p = pageOf(c); | |
| 112 | + const fields = q.in.length ? q.in : ["name", "description", "topics"]; | |
| 113 | + const owners = new Set(q.owners.map((o) => o.toLowerCase())); | |
| 114 | + const names = new Set(q.repos.map((r) => r.toLowerCase())); | |
| 115 | + const filtered = (await candidates(c)).filter((r) => { | |
| 116 | + if (owners.size && !owners.has(r.owner_login.toLowerCase())) return false; | |
| 117 | + if (names.size && !names.has(fullName(r))) return false; | |
| 118 | + if (q.visibility && (q.visibility === "public") !== (r.visibility === "public")) return false; | |
| 119 | + if (q.archived !== undefined && !!r.archived !== q.archived) return false; | |
| 120 | + const topics = topicsOf(r); | |
| 121 | + if (q.topics.some((t) => !topics.includes(t))) return false; | |
| 122 | + const hay: string[] = []; | |
| 123 | + if (fields.includes("name")) hay.push(r.name, fullName(r)); | |
| 124 | + if (fields.includes("description")) hay.push(r.description ?? ""); | |
| 125 | + if (fields.includes("topics")) hay.push(topics.join(" ")); | |
| 126 | + return matchesText(q.text, hay); | |
| 127 | + }); | |
| 128 | + const allowed = await authorized(c, filtered, MAX_REPO_CANDIDATES); | |
| 129 | + const hits = allowed.repos; | |
| 130 | + const order = c.req.query("order") === "asc" ? 1 : -1; | |
| 131 | + if (c.req.query("sort") === "updated") hits.sort((a, b) => ((a.pushed_at ?? a.updated_at) < (b.pushed_at ?? b.updated_at) ? -order : order)); | |
| 132 | + setLinks(c, p, hits.length); | |
| 133 | + return c.json({ total_count: hits.length, incomplete_results: allowed.incomplete, items: hits.slice(p.offset, p.offset + p.perPage).map((r) => ({ ...ghRepo(baseOf(c), r), score: 1 })) }); | |
| 134 | +}); |
addedapps/diffinery/src/http/statuses.ts+137−0View file
| @@ -1,0 +1,137 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import type { CommitStatus } from "../repo/statuses.js"; | |
| 6 | +import { loadRepo } from "./common.js"; | |
| 7 | +import { baseOf, ghCheckRun, ghRepo, ghUser, isoSeconds, nodeId, paginate, repoApi } from "./gh.js"; | |
| 8 | +import { jsonBody, loadTarget, repoParams } from "./ghctx.js"; | |
| 9 | + | |
| 10 | +/** Commit statuses, single check runs and check suites (GitHub REST). */ | |
| 11 | +export const statuses = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +const R = "/repos/:owner/:repo"; | |
| 14 | + | |
| 15 | +function statusJson(c: AppContext, s: CommitStatus) { | |
| 16 | + const b = baseOf(c); | |
| 17 | + const { owner, repo } = repoParams(c); | |
| 18 | + const creator = ghUser(b, s.creator); | |
| 19 | + return { | |
| 20 | + url: `${repoApi(b, owner, repo)}/statuses/${s.sha}`, | |
| 21 | + avatar_url: creator?.avatar_url ?? null, | |
| 22 | + id: s.id, | |
| 23 | + node_id: nodeId("StatusContext", `${owner}/${repo}/${s.id}`), | |
| 24 | + state: s.state, | |
| 25 | + description: s.description, | |
| 26 | + target_url: s.targetUrl, | |
| 27 | + context: s.context, | |
| 28 | + created_at: isoSeconds(s.createdAt), | |
| 29 | + updated_at: isoSeconds(s.updatedAt), | |
| 30 | + creator, | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +statuses.post(`${R}/statuses/:sha`, async (c) => { | |
| 35 | + const { stub, caller } = await loadTarget(c, "write"); | |
| 36 | + const b = await jsonBody<{ state?: string; target_url?: string | null; description?: string | null; context?: string }>(c); | |
| 37 | + const s = await stub.createStatus(c.req.param("sha"), { state: b.state, target_url: b.target_url, description: b.description, context: b.context }, caller); | |
| 38 | + return c.json(statusJson(c, s), 201); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +async function listStatuses(c: AppContext) { | |
| 42 | + const { stub } = await loadTarget(c); | |
| 43 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 44 | + if (!sha) throw new HttpError(404, "No commit found for the ref"); | |
| 45 | + return c.json(paginate(c, (await stub.commitStatuses(sha)).map((s) => statusJson(c, s)))); | |
| 46 | +} | |
| 47 | +statuses.get(`${R}/commits/:ref{.+}/statuses`, listStatuses); | |
| 48 | +statuses.get(`${R}/statuses/:ref{.+}`, listStatuses); | |
| 49 | + | |
| 50 | +statuses.get(`${R}/commits/:ref{.+}/status`, async (c) => { | |
| 51 | + const { stub, repo } = await loadTarget(c); | |
| 52 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 53 | + if (!sha) throw new HttpError(404, "No commit found for the ref"); | |
| 54 | + const combined = await stub.combinedStatus(sha); | |
| 55 | + const api = repoApi(baseOf(c), repo.owner_login, repo.name); | |
| 56 | + return c.json({ | |
| 57 | + state: combined.state, | |
| 58 | + statuses: paginate(c, combined.statuses.map((s) => statusJson(c, s)), 100), | |
| 59 | + sha, | |
| 60 | + total_count: combined.statuses.length, | |
| 61 | + repository: ghRepo(baseOf(c), repo), | |
| 62 | + commit_url: `${api}/commits/${sha}`, | |
| 63 | + url: `${api}/commits/${sha}/status`, | |
| 64 | + }); | |
| 65 | +}); | |
| 66 | + | |
| 67 | +// ---------------------------------------------------------------- check runs by id | |
| 68 | + | |
| 69 | +statuses.get(`${R}/check-runs/:id`, async (c) => { | |
| 70 | + const { stub, repo } = await loadTarget(c); | |
| 71 | + return c.json(ghCheckRun(baseOf(c), repo.owner_login, repo.name, await stub.checkRun(c.req.param("id")))); | |
| 72 | +}); | |
| 73 | + | |
| 74 | +statuses.patch(`${R}/check-runs/:id`, async (c) => { | |
| 75 | + const { stub, caller, repo } = await loadTarget(c, "write"); | |
| 76 | + const b = await jsonBody<Parameters<typeof stub.updateCheckRun>[1]>(c); | |
| 77 | + return c.json(ghCheckRun(baseOf(c), repo.owner_login, repo.name, await stub.updateCheckRun(c.req.param("id"), b, caller))); | |
| 78 | +}); | |
| 79 | + | |
| 80 | +statuses.get(`${R}/check-runs/:id/annotations`, async (c) => { | |
| 81 | + const { stub } = await loadTarget(c); | |
| 82 | + const check = await stub.checkRun(c.req.param("id")); | |
| 83 | + const list = ((check.output as { annotations?: Array<Record<string, unknown>> } | null)?.annotations ?? []).map((a) => ({ | |
| 84 | + path: a.path ?? null, | |
| 85 | + start_line: a.start_line ?? null, | |
| 86 | + end_line: a.end_line ?? a.start_line ?? null, | |
| 87 | + start_column: a.start_column ?? null, | |
| 88 | + end_column: a.end_column ?? null, | |
| 89 | + annotation_level: a.annotation_level ?? "notice", | |
| 90 | + title: a.title ?? null, | |
| 91 | + message: a.message ?? "", | |
| 92 | + raw_details: a.raw_details ?? null, | |
| 93 | + blob_href: null, | |
| 94 | + })); | |
| 95 | + return c.json(paginate(c, list)); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +// ---------------------------------------------------------------- check suites (one per workflow run, or per app) | |
| 99 | + | |
| 100 | +type Suite = Awaited<ReturnType<Awaited<ReturnType<typeof loadRepo>>["stub"]["checkSuite"]>>; | |
| 101 | + | |
| 102 | +function suiteJson(c: AppContext, s: Suite) { | |
| 103 | + const b = baseOf(c); | |
| 104 | + const { owner, repo } = repoParams(c); | |
| 105 | + return { | |
| 106 | + id: s.id, | |
| 107 | + node_id: nodeId("CheckSuite", s.id), | |
| 108 | + head_branch: null, | |
| 109 | + head_sha: s.headSha, | |
| 110 | + status: s.status, | |
| 111 | + conclusion: s.conclusion, | |
| 112 | + url: `${repoApi(b, owner, repo)}/check-suites/${s.id}`, | |
| 113 | + app: { id: s.app, slug: s.app, name: s.app }, | |
| 114 | + pull_requests: [], | |
| 115 | + latest_check_runs_count: s.runs.length, | |
| 116 | + check_runs_url: `${repoApi(b, owner, repo)}/check-suites/${s.id}/check-runs`, | |
| 117 | + }; | |
| 118 | +} | |
| 119 | + | |
| 120 | +statuses.get(`${R}/commits/:ref{.+}/check-suites`, async (c) => { | |
| 121 | + const { stub } = await loadTarget(c); | |
| 122 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 123 | + if (!sha) throw new HttpError(404, "ref not found"); | |
| 124 | + const suites = await stub.checkSuites(sha); | |
| 125 | + return c.json({ total_count: suites.length, check_suites: paginate(c, suites).map((s) => suiteJson(c, s)) }); | |
| 126 | +}); | |
| 127 | + | |
| 128 | +statuses.get(`${R}/check-suites/:id`, async (c) => { | |
| 129 | + const { stub } = await loadTarget(c); | |
| 130 | + return c.json(suiteJson(c, await stub.checkSuite(c.req.param("id")))); | |
| 131 | +}); | |
| 132 | + | |
| 133 | +statuses.get(`${R}/check-suites/:id/check-runs`, async (c) => { | |
| 134 | + const { stub, repo } = await loadTarget(c); | |
| 135 | + const suite = await stub.checkSuite(c.req.param("id")); | |
| 136 | + return c.json({ total_count: suite.runs.length, check_runs: paginate(c, suite.runs).map((r) => ghCheckRun(baseOf(c), repo.owner_login, repo.name, r)) }); | |
| 137 | +}); |
addedapps/diffinery/src/lib/archive.ts+209−0View file
| @@ -1,0 +1,209 @@ | ||
| 1 | +/** | |
| 2 | + * Minimal tar and zip writers for source archives (zipball/tarball) and run | |
| 3 | + * log bundles. Entries are buffered, which is fine for the size limits used. | |
| 4 | + */ | |
| 5 | + | |
| 6 | +export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true }; | |
| 7 | + | |
| 8 | +const enc = new TextEncoder(); | |
| 9 | + | |
| 10 | +// ---------------------------------------------------------------- tar (ustar + pax for long names) | |
| 11 | + | |
| 12 | +function octal(n: number, width: number): string { | |
| 13 | + return `${n.toString(8).padStart(width - 1, "0")}\0`; | |
| 14 | +} | |
| 15 | + | |
| 16 | +function tarHeader(name: string, size: number, mode: number, type: string, mtime: number, linkname = ""): Uint8Array { | |
| 17 | + const h = new Uint8Array(512); | |
| 18 | + const put = (s: string, off: number, len: number) => h.set(enc.encode(s).subarray(0, len), off); | |
| 19 | + put(name, 0, 100); | |
| 20 | + put(octal(mode, 8), 100, 8); | |
| 21 | + put(octal(0, 8), 108, 8); | |
| 22 | + put(octal(0, 8), 116, 8); | |
| 23 | + put(octal(size, 12), 124, 12); | |
| 24 | + put(octal(mtime, 12), 136, 12); | |
| 25 | + put(" ", 148, 8); | |
| 26 | + put(type, 156, 1); | |
| 27 | + put(linkname, 157, 100); | |
| 28 | + put("ustar\0", 257, 6); | |
| 29 | + put("00", 263, 2); | |
| 30 | + let sum = 0; | |
| 31 | + for (const b of h) sum += b; | |
| 32 | + put(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8); | |
| 33 | + return h; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function pad512(n: number): Uint8Array { | |
| 37 | + return new Uint8Array((512 - (n % 512)) % 512); | |
| 38 | +} | |
| 39 | + | |
| 40 | +function paxRecord(key: string, value: string): string { | |
| 41 | + const body = ` ${key}=${value}\n`; | |
| 42 | + const bytes = enc.encode(body).length; | |
| 43 | + let len = bytes + 1; | |
| 44 | + while (len !== bytes + String(len).length) len = bytes + String(len).length; | |
| 45 | + return `${len}${body}`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** A pax global header carrying `comment` (git archive puts the commit id there). */ | |
| 49 | +export function tarStart(mtime: number, comment?: string): Uint8Array[] { | |
| 50 | + if (!comment) return []; | |
| 51 | + const pax = enc.encode(paxRecord("comment", comment)); | |
| 52 | + return [tarHeader("pax_global_header", pax.length, 0o666, "g", mtime), pax, pad512(pax.length)]; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** The records for one entry: an optional pax header for long names, the header, the content and padding. */ | |
| 56 | +export function tarEntry(e: ArchiveEntry, mtime: number): Uint8Array[] { | |
| 57 | + const parts: Uint8Array[] = []; | |
| 58 | + const isDir = "dir" in e; | |
| 59 | + const isLink = !isDir && e.mode === "120000"; | |
| 60 | + const name = isDir && !e.path.endsWith("/") ? `${e.path}/` : e.path; | |
| 61 | + const link = isLink ? new TextDecoder().decode(e.content) : ""; | |
| 62 | + if (enc.encode(name).length > 100 || enc.encode(link).length > 100) { | |
| 63 | + const pax = enc.encode(paxRecord("path", name) + (link ? paxRecord("linkpath", link) : "")); | |
| 64 | + parts.push(tarHeader("PaxHeader", pax.length, 0o644, "x", mtime), pax, pad512(pax.length)); | |
| 65 | + } | |
| 66 | + if (isDir) parts.push(tarHeader(name, 0, 0o775, "5", mtime)); | |
| 67 | + else if (isLink) parts.push(tarHeader(name, 0, 0o777, "2", mtime, link)); | |
| 68 | + else parts.push(tarHeader(name, e.content.length, e.mode === "100755" ? 0o775 : 0o664, "0", mtime), e.content, pad512(e.content.length)); | |
| 69 | + return parts; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** The two zero blocks that end a tar archive. */ | |
| 73 | +export const tarEnd = (): Uint8Array => new Uint8Array(1024); | |
| 74 | + | |
| 75 | +/** An uncompressed tar archive; gzip it with CompressionStream("gzip"). */ | |
| 76 | +export function tar(entries: ArchiveEntry[], mtime: number, comment?: string): Uint8Array { | |
| 77 | + return concat([...tarStart(mtime, comment), ...entries.flatMap((e) => tarEntry(e, mtime)), tarEnd()]); | |
| 78 | +} | |
| 79 | + | |
| 80 | +// ---------------------------------------------------------------- zip | |
| 81 | + | |
| 82 | +let CRC_TABLE: Uint32Array | null = null; | |
| 83 | + | |
| 84 | +export function crc32(data: Uint8Array): number { | |
| 85 | + if (!CRC_TABLE) { | |
| 86 | + CRC_TABLE = new Uint32Array(256); | |
| 87 | + for (let n = 0; n < 256; n++) { | |
| 88 | + let c = n; | |
| 89 | + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; | |
| 90 | + CRC_TABLE[n] = c >>> 0; | |
| 91 | + } | |
| 92 | + } | |
| 93 | + let crc = 0xffffffff; | |
| 94 | + for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8); | |
| 95 | + return (crc ^ 0xffffffff) >>> 0; | |
| 96 | +} | |
| 97 | + | |
| 98 | +async function deflateRaw(data: Uint8Array): Promise<Uint8Array> { | |
| 99 | + const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("deflate-raw")); | |
| 100 | + return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function dosTime(seconds: number): { time: number; date: number } { | |
| 104 | + const d = new Date(seconds * 1000); | |
| 105 | + const year = Math.max(1980, d.getUTCFullYear()); | |
| 106 | + return { time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2), date: ((year - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate() }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** Classic zip (no ZIP64) holds at most this many entries and 4 GiB. */ | |
| 110 | +export const ZIP_MAX_ENTRIES = 0xffff; | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * Writes a zip one entry at a time (deflate, UTF-8 names, unix modes), keeping | |
| 114 | + * only the central directory in memory. Refuses what classic zip can't hold | |
| 115 | + * rather than writing a corrupt archive. | |
| 116 | + */ | |
| 117 | +export class ZipWriter { | |
| 118 | + private central: Uint8Array[] = []; | |
| 119 | + private offset = 0; | |
| 120 | + private count = 0; | |
| 121 | + private readonly time: number; | |
| 122 | + private readonly date: number; | |
| 123 | + | |
| 124 | + constructor(mtime: number) { | |
| 125 | + ({ time: this.time, date: this.date } = dosTime(mtime)); | |
| 126 | + } | |
| 127 | + | |
| 128 | + async entry(e: ArchiveEntry): Promise<Uint8Array[]> { | |
| 129 | + if (this.count >= ZIP_MAX_ENTRIES) throw new RangeError(`zip archives hold at most ${ZIP_MAX_ENTRIES} entries`); | |
| 130 | + const isDir = "dir" in e; | |
| 131 | + const name = enc.encode(isDir && !e.path.endsWith("/") ? `${e.path}/` : e.path); | |
| 132 | + const raw = isDir ? new Uint8Array(0) : e.content; | |
| 133 | + const deflated = raw.length ? await deflateRaw(raw) : raw; | |
| 134 | + const useDeflate = deflated.length < raw.length; | |
| 135 | + const data = useDeflate ? deflated : raw; | |
| 136 | + if (this.offset + 30 + name.length + data.length > 0xffffffff) throw new RangeError("zip archives are limited to 4 GiB"); | |
| 137 | + const crc = crc32(raw); | |
| 138 | + const unixMode = isDir ? 0o40775 : e.mode === "120000" ? 0o120777 : e.mode === "100755" ? 0o100775 : 0o100664; | |
| 139 | + const lh = new DataView(new ArrayBuffer(30)); | |
| 140 | + lh.setUint32(0, 0x04034b50, true); | |
| 141 | + lh.setUint16(4, 20, true); | |
| 142 | + lh.setUint16(6, 0x0800, true); | |
| 143 | + lh.setUint16(8, useDeflate ? 8 : 0, true); | |
| 144 | + lh.setUint16(10, this.time, true); | |
| 145 | + lh.setUint16(12, this.date, true); | |
| 146 | + lh.setUint32(14, crc, true); | |
| 147 | + lh.setUint32(18, data.length, true); | |
| 148 | + lh.setUint32(22, raw.length, true); | |
| 149 | + lh.setUint16(26, name.length, true); | |
| 150 | + const ch = new DataView(new ArrayBuffer(46)); | |
| 151 | + ch.setUint32(0, 0x02014b50, true); | |
| 152 | + ch.setUint16(4, (3 << 8) | 20, true); | |
| 153 | + ch.setUint16(6, 20, true); | |
| 154 | + ch.setUint16(8, 0x0800, true); | |
| 155 | + ch.setUint16(10, useDeflate ? 8 : 0, true); | |
| 156 | + ch.setUint16(12, this.time, true); | |
| 157 | + ch.setUint16(14, this.date, true); | |
| 158 | + ch.setUint32(16, crc, true); | |
| 159 | + ch.setUint32(20, data.length, true); | |
| 160 | + ch.setUint32(24, raw.length, true); | |
| 161 | + ch.setUint16(28, name.length, true); | |
| 162 | + ch.setUint32(38, ((unixMode << 16) | (isDir ? 0x10 : 0)) >>> 0, true); | |
| 163 | + ch.setUint32(42, this.offset, true); | |
| 164 | + this.central.push(new Uint8Array(ch.buffer), name); | |
| 165 | + this.offset += 30 + name.length + data.length; | |
| 166 | + this.count++; | |
| 167 | + return [new Uint8Array(lh.buffer), name, data]; | |
| 168 | + } | |
| 169 | + | |
| 170 | + /** The central directory and end record; `comment` becomes the archive comment. */ | |
| 171 | + end(comment = ""): Uint8Array[] { | |
| 172 | + const cd = concat(this.central); | |
| 173 | + const note = enc.encode(comment).subarray(0, 0xffff); | |
| 174 | + const end = new DataView(new ArrayBuffer(22)); | |
| 175 | + end.setUint32(0, 0x06054b50, true); | |
| 176 | + end.setUint16(8, this.count, true); | |
| 177 | + end.setUint16(10, this.count, true); | |
| 178 | + end.setUint32(12, cd.length, true); | |
| 179 | + end.setUint32(16, this.offset, true); | |
| 180 | + end.setUint16(20, note.length, true); | |
| 181 | + return [cd, new Uint8Array(end.buffer), note]; | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +/** A zip archive (deflate, UTF-8 names, unix modes); `comment` is stored as the archive comment. */ | |
| 186 | +export async function zip(entries: ArchiveEntry[], mtime: number, comment = ""): Promise<Uint8Array> { | |
| 187 | + const w = new ZipWriter(mtime); | |
| 188 | + const parts: Uint8Array[] = []; | |
| 189 | + for (const e of entries) parts.push(...(await w.entry(e))); | |
| 190 | + parts.push(...w.end(comment)); | |
| 191 | + return concat(parts); | |
| 192 | +} | |
| 193 | + | |
| 194 | +export function concat(parts: Uint8Array[]): Uint8Array { | |
| 195 | + let n = 0; | |
| 196 | + for (const p of parts) n += p.length; | |
| 197 | + const out = new Uint8Array(n); | |
| 198 | + let o = 0; | |
| 199 | + for (const p of parts) { | |
| 200 | + out.set(p, o); | |
| 201 | + o += p.length; | |
| 202 | + } | |
| 203 | + return out; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export async function gzip(data: Uint8Array): Promise<Uint8Array> { | |
| 207 | + const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("gzip")); | |
| 208 | + return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| 209 | +} |
modifiedapps/diffinery/src/lib/directory.ts+13−0View file
| @@ -31,6 +31,8 @@ | ||
| 31 | 31 | created_at: string; |
| 32 | 32 | updated_at: string; |
| 33 | 33 | pushed_at: string | null; |
| 34 | + /** JSON array of topic names (GitHub's repository topics). */ | |
| 35 | + topics?: string; | |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | export type TokenScope = "read" | "write" | "admin"; |
| @@ -163,6 +165,8 @@ | ||
| 163 | 165 | const before = await this.account(id); |
| 164 | 166 | await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run(); |
| 165 | 167 | if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run(); |
| 168 | + // A disabled account's browser sessions end too. | |
| 169 | + if (status === "disabled") await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(id).run(); | |
| 166 | 170 | // People hear when they are disabled or enabled again (not when an invite activates them). |
| 167 | 171 | if (before?.kind === "user" && before.status !== status && (status === "disabled" || before.status === "disabled")) { |
| 168 | 172 | await enqueueMail(this.env, [{ kind: "account_status", data: { status: status === "disabled" ? "disabled" : "active" }, to: { accountId: id }, reason: "account", at: now() }]); |
| @@ -317,6 +321,11 @@ | ||
| 317 | 321 | |
| 318 | 322 | /** Repositories an account can see. */ |
| 319 | 323 | async reposVisibleTo(account: Account, ownerId?: string): Promise<Repo[]> { |
| 324 | + if (account.status !== "active") return []; | |
| 325 | + if (account.kind === "agent") { | |
| 326 | + const owner = account.owner_id ? await this.account(account.owner_id) : null; | |
| 327 | + if (!owner || owner.status !== "active") return []; | |
| 328 | + } | |
| 320 | 329 | const params: unknown[] = []; |
| 321 | 330 | 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"; |
| 322 | 331 | const subjectId = account.kind === "agent" ? account.owner_id! : account.id; |
| @@ -377,6 +386,10 @@ | ||
| 377 | 386 | await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy(); |
| 378 | 387 | } |
| 379 | 388 | |
| 389 | + async setTopics(repoId: string, topics: string[]): Promise<void> { | |
| 390 | + await this.db.prepare("UPDATE repos SET topics = ?, updated_at = ? WHERE id = ?").bind(JSON.stringify(topics), now(), repoId).run(); | |
| 391 | + } | |
| 392 | + | |
| 380 | 393 | async touchPushed(repoId: string): Promise<void> { |
| 381 | 394 | await this.db.prepare("UPDATE repos SET pushed_at = ?, updated_at = ? WHERE id = ?").bind(now(), now(), repoId).run(); |
| 382 | 395 | } |
addedapps/diffinery/src/lib/import-hosts.ts+18−0View file
| @@ -1,0 +1,18 @@ | ||
| 1 | +/** Hosts the importer downloads release assets from: GitHub's, plus an optional GitHub Enterprise host. */ | |
| 2 | +const IMPORT_HOSTS = ["github.com", "api.github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]; | |
| 3 | + | |
| 4 | +export function importHostAllowed(host: string, gheHost?: string): boolean { | |
| 5 | + const h = host.toLowerCase().replace(/\.$/, ""); | |
| 6 | + const ghe = gheHost?.trim().toLowerCase(); | |
| 7 | + return IMPORT_HOSTS.includes(h) || (!!ghe && h === ghe); | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * The headers to keep when a redirect leaves the original host: only plain | |
| 12 | + * content negotiation. Authorization, cookies, tokens and anything else that | |
| 13 | + * could carry a credential are dropped. | |
| 14 | + */ | |
| 15 | +export function withoutCredentials(headers: Record<string, string>): Record<string, string> { | |
| 16 | + const keep = new Set(["accept", "accept-encoding", "accept-language", "user-agent", "x-github-api-version"]); | |
| 17 | + return Object.fromEntries(Object.entries(headers).filter(([k]) => keep.has(k.toLowerCase()))); | |
| 18 | +} |
addedapps/diffinery/src/lib/r2-sink.ts+83−0View file
| @@ -1,0 +1,83 @@ | ||
| 1 | +import { HttpError } from "./util.js"; | |
| 2 | + | |
| 3 | +/** R2 multipart parts must all be the same size (except the last) and at least 5 MiB. */ | |
| 4 | +export const PART_SIZE = 8 * 1024 * 1024; | |
| 5 | + | |
| 6 | +type Bucket = Pick<R2Bucket, "put" | "createMultipartUpload">; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Writes chunks to one R2 object with bounded memory: objects under one part | |
| 10 | + * go up with a single put, larger ones as a multipart upload of equal parts. | |
| 11 | + * Stops with a 413 once `limit` bytes have been written. | |
| 12 | + */ | |
| 13 | +export class R2Sink { | |
| 14 | + private buf: Uint8Array[] = []; | |
| 15 | + private buffered = 0; | |
| 16 | + private total = 0; | |
| 17 | + private upload: R2MultipartUpload | null = null; | |
| 18 | + private parts: R2UploadedPart[] = []; | |
| 19 | + | |
| 20 | + constructor( | |
| 21 | + private bucket: Bucket, | |
| 22 | + private key: string, | |
| 23 | + private contentType: string, | |
| 24 | + private limit: number, | |
| 25 | + private tooLarge = "the object is too large", | |
| 26 | + ) {} | |
| 27 | + | |
| 28 | + get size(): number { | |
| 29 | + return this.total; | |
| 30 | + } | |
| 31 | + | |
| 32 | + async write(chunk: Uint8Array): Promise<void> { | |
| 33 | + if (!chunk.length) return; | |
| 34 | + this.total += chunk.length; | |
| 35 | + if (this.total > this.limit) throw new HttpError(413, this.tooLarge); | |
| 36 | + this.buf.push(chunk); | |
| 37 | + this.buffered += chunk.length; | |
| 38 | + while (this.buffered >= PART_SIZE) await this.flushPart(PART_SIZE); | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** Take exactly `n` buffered bytes (or all of them) off the front. */ | |
| 42 | + private take(n: number): Uint8Array { | |
| 43 | + const out = new Uint8Array(Math.min(n, this.buffered)); | |
| 44 | + let o = 0; | |
| 45 | + while (o < out.length) { | |
| 46 | + const head = this.buf[0]!; | |
| 47 | + const want = out.length - o; | |
| 48 | + if (head.length <= want) { | |
| 49 | + out.set(head, o); | |
| 50 | + o += head.length; | |
| 51 | + this.buf.shift(); | |
| 52 | + } else { | |
| 53 | + out.set(head.subarray(0, want), o); | |
| 54 | + this.buf[0] = head.subarray(want); | |
| 55 | + o += want; | |
| 56 | + } | |
| 57 | + } | |
| 58 | + this.buffered -= out.length; | |
| 59 | + return out; | |
| 60 | + } | |
| 61 | + | |
| 62 | + private async flushPart(n: number): Promise<void> { | |
| 63 | + this.upload ??= await this.bucket.createMultipartUpload(this.key, { httpMetadata: { contentType: this.contentType } }); | |
| 64 | + this.parts.push(await this.upload.uploadPart(this.parts.length + 1, this.take(n))); | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** Finish the object; returns its size. */ | |
| 68 | + async close(): Promise<number> { | |
| 69 | + if (!this.upload) { | |
| 70 | + await this.bucket.put(this.key, this.take(this.buffered), { httpMetadata: { contentType: this.contentType } }); | |
| 71 | + return this.total; | |
| 72 | + } | |
| 73 | + if (this.buffered) await this.flushPart(this.buffered); | |
| 74 | + await this.upload.complete(this.parts); | |
| 75 | + return this.total; | |
| 76 | + } | |
| 77 | + | |
| 78 | + async abort(): Promise<void> { | |
| 79 | + this.buf = []; | |
| 80 | + this.buffered = 0; | |
| 81 | + await this.upload?.abort().catch(() => undefined); | |
| 82 | + } | |
| 83 | +} |
addedapps/diffinery/src/lib/search-query.ts+227−0View file
| @@ -1,0 +1,227 @@ | ||
| 1 | +/** | |
| 2 | + * GitHub search query syntax (`repo:o/r is:pr is:open label:bug fix crash`), | |
| 3 | + * parsed into filters for issue and repository search. Pure, so it is unit | |
| 4 | + * tested on its own. | |
| 5 | + */ | |
| 6 | + | |
| 7 | +export type Range = { gt?: string; gte?: string; lt?: string; lte?: string }; | |
| 8 | + | |
| 9 | +export type IssueQuery = { | |
| 10 | + repos: string[]; | |
| 11 | + owners: string[]; | |
| 12 | + kind?: "issue" | "pull"; | |
| 13 | + state?: "open" | "closed"; | |
| 14 | + merged?: boolean; | |
| 15 | + draft?: boolean; | |
| 16 | + locked?: boolean; | |
| 17 | + labels: string[]; | |
| 18 | + notLabels: string[]; | |
| 19 | + noLabel?: boolean; | |
| 20 | + author?: string; | |
| 21 | + notAuthor?: string; | |
| 22 | + assignee?: string; | |
| 23 | + noAssignee?: boolean; | |
| 24 | + head?: string; | |
| 25 | + base?: string; | |
| 26 | + in: Array<"title" | "body" | "comments">; | |
| 27 | + text: string[]; | |
| 28 | + created?: Range; | |
| 29 | + updated?: Range; | |
| 30 | + closed?: Range; | |
| 31 | + sort?: { field: "created" | "updated" | "comments"; dir: "asc" | "desc" }; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export type RepoQuery = { | |
| 35 | + owners: string[]; | |
| 36 | + repos: string[]; | |
| 37 | + topics: string[]; | |
| 38 | + visibility?: "public" | "private"; | |
| 39 | + archived?: boolean; | |
| 40 | + in: Array<"name" | "description" | "topics">; | |
| 41 | + text: string[]; | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export type Token = { key: string | null; value: string; negated: boolean }; | |
| 45 | + | |
| 46 | +/** Split a query into qualifiers and words, honouring double quotes and a leading `-`. */ | |
| 47 | +export function tokenize(q: string): Token[] { | |
| 48 | + const out: Token[] = []; | |
| 49 | + const re = /(-?)(?:([A-Za-z_-]+):)?(?:"([^"]*)"?|(\S+))/g; | |
| 50 | + for (const m of q.matchAll(re)) { | |
| 51 | + const key = m[2] ? m[2].toLowerCase() : null; | |
| 52 | + const value = m[3] ?? m[4] ?? ""; | |
| 53 | + if (key && value === "" && m[3] === undefined) out.push({ key: null, value: `${m[2]}:`, negated: false }); | |
| 54 | + else if (key || value) out.push({ key, value, negated: m[1] === "-" }); | |
| 55 | + } | |
| 56 | + return out; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const DATE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?$/; | |
| 60 | + | |
| 61 | +/** `>2024-01-01`, `>=…`, `<…`, `<=…`, `a..b`, `a..*`, `*..b` or an exact day. */ | |
| 62 | +export function parseRange(v: string): Range | null { | |
| 63 | + const m = /^(>=|<=|>|<)(.+)$/.exec(v); | |
| 64 | + if (m) { | |
| 65 | + const d = m[2]!; | |
| 66 | + if (!DATE.test(d)) return null; | |
| 67 | + const endOfDay = d.length === 10 ? `${d}T23:59:59.999Z` : d; | |
| 68 | + if (m[1] === ">") return { gt: endOfDay }; | |
| 69 | + if (m[1] === ">=") return { gte: d }; | |
| 70 | + if (m[1] === "<") return { lt: d }; | |
| 71 | + return { lte: endOfDay }; | |
| 72 | + } | |
| 73 | + const r = /^(.+)\.\.(.+)$/.exec(v); | |
| 74 | + if (r) { | |
| 75 | + const out: Range = {}; | |
| 76 | + if (r[1] !== "*") { | |
| 77 | + if (!DATE.test(r[1]!)) return null; | |
| 78 | + out.gte = r[1]; | |
| 79 | + } | |
| 80 | + if (r[2] !== "*") { | |
| 81 | + if (!DATE.test(r[2]!)) return null; | |
| 82 | + out.lte = r[2]!.length === 10 ? `${r[2]}T23:59:59.999Z` : r[2]; | |
| 83 | + } | |
| 84 | + return out; | |
| 85 | + } | |
| 86 | + if (!DATE.test(v)) return null; | |
| 87 | + return v.length === 10 ? { gte: v, lte: `${v}T23:59:59.999Z` } : { gte: v, lte: v }; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export function inRange(value: string | null | undefined, r: Range | undefined): boolean { | |
| 91 | + if (!r) return true; | |
| 92 | + if (!value) return false; | |
| 93 | + if (r.gt && !(value > r.gt)) return false; | |
| 94 | + if (r.gte && !(value >= r.gte)) return false; | |
| 95 | + if (r.lt && !(value < r.lt)) return false; | |
| 96 | + if (r.lte && !(value <= r.lte)) return false; | |
| 97 | + return true; | |
| 98 | +} | |
| 99 | + | |
| 100 | +/** Parse an issue/pull request search; `sort`/`order` query params override a `sort:` qualifier. */ | |
| 101 | +export function parseIssueQuery(q: string, params: { sort?: string; order?: string } = {}): IssueQuery { | |
| 102 | + const s: IssueQuery = { repos: [], owners: [], labels: [], notLabels: [], in: [], text: [] }; | |
| 103 | + for (const t of tokenize(q)) { | |
| 104 | + const v = t.value; | |
| 105 | + const lv = v.toLowerCase(); | |
| 106 | + switch (t.key) { | |
| 107 | + case null: | |
| 108 | + if (v) s.text.push(t.negated ? `-${v}` : v); | |
| 109 | + break; | |
| 110 | + case "repo": | |
| 111 | + if (/^[^/\s]+\/[^/\s]+$/.test(v)) s.repos.push(v); | |
| 112 | + break; | |
| 113 | + case "user": | |
| 114 | + case "org": | |
| 115 | + case "owner": | |
| 116 | + s.owners.push(v); | |
| 117 | + break; | |
| 118 | + case "is": | |
| 119 | + case "type": | |
| 120 | + case "state": | |
| 121 | + if (lv === "issue") s.kind = "issue"; | |
| 122 | + else if (lv === "pr" || lv === "pull-request" || lv === "pull_request") s.kind = "pull"; | |
| 123 | + else if (lv === "open" || lv === "closed") s.state = t.negated ? (lv === "open" ? "closed" : "open") : lv; | |
| 124 | + else if (lv === "merged" || lv === "unmerged") { | |
| 125 | + s.kind = "pull"; | |
| 126 | + s.merged = (lv === "merged") !== t.negated; | |
| 127 | + } else if (lv === "draft") s.draft = !t.negated; | |
| 128 | + else if (lv === "locked" || lv === "unlocked") s.locked = (lv === "locked") !== t.negated; | |
| 129 | + break; | |
| 130 | + case "draft": | |
| 131 | + s.draft = lv === "true"; | |
| 132 | + break; | |
| 133 | + case "label": | |
| 134 | + for (const l of v.split(",").filter(Boolean)) (t.negated ? s.notLabels : s.labels).push(l); | |
| 135 | + break; | |
| 136 | + case "no": | |
| 137 | + if (lv === "label") s.noLabel = true; | |
| 138 | + else if (lv === "assignee") s.noAssignee = true; | |
| 139 | + break; | |
| 140 | + case "author": | |
| 141 | + if (t.negated) s.notAuthor = v; | |
| 142 | + else s.author = v; | |
| 143 | + break; | |
| 144 | + case "assignee": | |
| 145 | + s.assignee = v; | |
| 146 | + break; | |
| 147 | + case "head": | |
| 148 | + s.head = v; | |
| 149 | + break; | |
| 150 | + case "base": | |
| 151 | + s.base = v; | |
| 152 | + break; | |
| 153 | + case "in": | |
| 154 | + for (const f of lv.split(",")) if ((f === "title" || f === "body" || f === "comments") && !s.in.includes(f)) s.in.push(f); | |
| 155 | + break; | |
| 156 | + case "created": | |
| 157 | + case "updated": | |
| 158 | + case "closed": { | |
| 159 | + const r = parseRange(v); | |
| 160 | + if (r) s[t.key] = r; | |
| 161 | + break; | |
| 162 | + } | |
| 163 | + case "sort": { | |
| 164 | + const [field, dir] = lv.split("-"); | |
| 165 | + if (field === "created" || field === "updated" || field === "comments") s.sort = { field, dir: dir === "asc" ? "asc" : "desc" }; | |
| 166 | + break; | |
| 167 | + } | |
| 168 | + default: | |
| 169 | + // Unknown qualifiers are searched as text, like GitHub does with words it doesn't recognise. | |
| 170 | + s.text.push(`${t.key}:${v}`); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + const sort = params.sort?.toLowerCase(); | |
| 174 | + if (sort === "created" || sort === "updated" || sort === "comments") s.sort = { field: sort, dir: params.order?.toLowerCase() === "asc" ? "asc" : "desc" }; | |
| 175 | + return s; | |
| 176 | +} | |
| 177 | + | |
| 178 | +export function parseRepoQuery(q: string): RepoQuery { | |
| 179 | + const s: RepoQuery = { owners: [], repos: [], topics: [], in: [], text: [] }; | |
| 180 | + for (const t of tokenize(q)) { | |
| 181 | + const lv = t.value.toLowerCase(); | |
| 182 | + switch (t.key) { | |
| 183 | + case null: | |
| 184 | + if (t.value) s.text.push(t.negated ? `-${t.value}` : t.value); | |
| 185 | + break; | |
| 186 | + case "user": | |
| 187 | + case "org": | |
| 188 | + case "owner": | |
| 189 | + s.owners.push(t.value); | |
| 190 | + break; | |
| 191 | + case "repo": | |
| 192 | + if (/^[^/\s]+\/[^/\s]+$/.test(t.value)) s.repos.push(t.value); | |
| 193 | + break; | |
| 194 | + case "topic": | |
| 195 | + s.topics.push(lv); | |
| 196 | + break; | |
| 197 | + case "is": | |
| 198 | + if (lv === "public" || lv === "private") s.visibility = lv; | |
| 199 | + break; | |
| 200 | + case "archived": | |
| 201 | + s.archived = lv === "true"; | |
| 202 | + break; | |
| 203 | + case "in": | |
| 204 | + for (const f of lv.split(",")) if ((f === "name" || f === "description" || f === "topics") && !s.in.includes(f)) s.in.push(f); | |
| 205 | + break; | |
| 206 | + case "fork": | |
| 207 | + case "language": | |
| 208 | + case "stars": | |
| 209 | + case "forks": | |
| 210 | + case "size": | |
| 211 | + case "pushed": | |
| 212 | + case "created": | |
| 213 | + // Accepted but not filtered on: Diffinery has no forks, stars or language index. | |
| 214 | + break; | |
| 215 | + default: | |
| 216 | + s.text.push(`${t.key}:${t.value}`); | |
| 217 | + } | |
| 218 | + } | |
| 219 | + return s; | |
| 220 | +} | |
| 221 | + | |
| 222 | +/** Case-insensitive text match: every word appears, and no `-word` does. */ | |
| 223 | +export function matchesText(words: string[], haystacks: string[]): boolean { | |
| 224 | + if (!words.length) return true; | |
| 225 | + const hay = haystacks.join("\n").toLowerCase(); | |
| 226 | + return words.every((w) => (w.length > 1 && w.startsWith("-") ? !hay.includes(w.slice(1).toLowerCase()) : hay.includes(w.toLowerCase()))); | |
| 227 | +} |
addedapps/diffinery/src/lib/tree-names.ts+37−0View file
| @@ -1,0 +1,37 @@ | ||
| 1 | +/** | |
| 2 | + * Tree entry names the server refuses to write (and leaves out of archives): | |
| 3 | + * empty names, `.` and `..`, names with `/`, `\` or NUL, and anything a | |
| 4 | + * checkout on Windows or macOS would treat as `.git` (`.GIT`, `.git.`, | |
| 5 | + * `.git ` , `git~1`, `.git`). Git's own fsck rejects the same names. | |
| 6 | + */ | |
| 7 | + | |
| 8 | +// Characters HFS+ ignores when comparing names (zero-width joiners, direction marks, BOM). | |
| 9 | +const HFS_IGNORABLE = /[---]/g; | |
| 10 | + | |
| 11 | +export function isDotGitName(name: string): boolean { | |
| 12 | + const n = name.replace(HFS_IGNORABLE, "").toLowerCase().replace(/[. ]+$/, ""); | |
| 13 | + return n === ".git" || n === "git~1"; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Why a single tree entry name is unsafe, or null when it is fine. */ | |
| 17 | +export function unsafeTreeName(name: string): string | null { | |
| 18 | + if (!name) return "empty name"; | |
| 19 | + if (name === "." || name === "..") return `'${name}' is not a valid name`; | |
| 20 | + if (name.includes("/") || name.includes("\\") || name.includes("\0")) return "names can't contain '/', '\\' or NUL"; | |
| 21 | + if (isDotGitName(name)) return "'.git' is reserved"; | |
| 22 | + if (name.length > 255) return "name is too long"; | |
| 23 | + return null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** Why a slash-separated path is unsafe to write, or null. */ | |
| 27 | +export function unsafeTreePath(path: string): string | null { | |
| 28 | + if (path.includes("\\") || path.includes("\0")) return "paths can't contain '\\' or NUL"; | |
| 29 | + const parts = path.split("/").filter((p) => p !== ""); | |
| 30 | + if (!parts.length) return "empty path"; | |
| 31 | + if (path.length > 4096) return "path is too long"; | |
| 32 | + for (const p of parts) { | |
| 33 | + const why = unsafeTreeName(p); | |
| 34 | + if (why) return why; | |
| 35 | + } | |
| 36 | + return null; | |
| 37 | +} |
addedapps/diffinery/src/mcp/releases.ts+175−0View file
| @@ -1,0 +1,175 @@ | ||
| 1 | +import type { AppContext } from "../auth/principal.js"; | |
| 2 | +import { HttpError, fromB64 } from "../lib/util.js"; | |
| 3 | +import type { Release } from "../repo/releases.js"; | |
| 4 | +import type { Role } from "../repo/types.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { storeAsset } from "../http/files.js"; | |
| 7 | +import { baseOf, repoHtml } from "../http/gh.js"; | |
| 8 | +import type { Tool } from "./server.js"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * MCP tools for releases: list, read, create, edit, upload assets and draft | |
| 12 | + * notes. They run through the same permission checks as the REST API | |
| 13 | + * (writers create and edit; drafts stay hidden from readers). | |
| 14 | + */ | |
| 15 | + | |
| 16 | +type Args = Record<string, unknown>; | |
| 17 | + | |
| 18 | +const MAX_MCP_ASSET_BYTES = 25 * 1024 * 1024; | |
| 19 | + | |
| 20 | +const str = (a: Args, k: string): string => { | |
| 21 | + const v = a[k]; | |
| 22 | + if (typeof v !== "string" || v === "") throw new HttpError(422, `${k} is required`); | |
| 23 | + return v; | |
| 24 | +}; | |
| 25 | +const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined); | |
| 26 | +const optBool = (a: Args, k: string) => { | |
| 27 | + if (a[k] === undefined || a[k] === null) return undefined; | |
| 28 | + if (typeof a[k] !== "boolean") throw new HttpError(422, `${k} must be a boolean`); | |
| 29 | + return a[k] as boolean; | |
| 30 | +}; | |
| 31 | +const id = (a: Args, k: string) => { | |
| 32 | + const n = Number(a[k]); | |
| 33 | + if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`); | |
| 34 | + return n; | |
| 35 | +}; | |
| 36 | +const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min); | |
| 37 | +const OWNER_REPO = { | |
| 38 | + owner: { type: "string", description: "Repository owner (user or org login)" }, | |
| 39 | + repo: { type: "string", description: "Repository name" }, | |
| 40 | +}; | |
| 41 | + | |
| 42 | +function out(c: AppContext, a: Args, r: Release) { | |
| 43 | + const html = `${repoHtml(baseOf(c), str(a, "owner"), str(a, "repo"))}/releases/tag/${encodeURIComponent(r.tagName)}`; | |
| 44 | + return { | |
| 45 | + id: r.id, | |
| 46 | + tag_name: r.tagName, | |
| 47 | + target_commitish: r.targetCommitish, | |
| 48 | + name: r.name, | |
| 49 | + body: r.body, | |
| 50 | + draft: r.draft, | |
| 51 | + prerelease: r.prerelease, | |
| 52 | + latest: r.latest, | |
| 53 | + author: r.author.login, | |
| 54 | + created_at: r.createdAt, | |
| 55 | + published_at: r.publishedAt, | |
| 56 | + html_url: html, | |
| 57 | + assets: r.assets.map((x) => ({ id: x.id, name: x.name, label: x.label, content_type: x.contentType, size: x.size, download_count: x.downloadCount, download_url: `${repoHtml(baseOf(c), str(a, "owner"), str(a, "repo"))}/releases/download/${encodeURIComponent(r.tagName)}/${encodeURIComponent(x.name)}` })), | |
| 58 | + }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +const RELEASE_FIELDS = { | |
| 62 | + name: { type: "string", description: "Release title" }, | |
| 63 | + body: { type: "string", description: "Release notes (Markdown)" }, | |
| 64 | + draft: { type: "boolean", description: "Keep it as a draft, visible to writers only" }, | |
| 65 | + prerelease: { type: "boolean" }, | |
| 66 | + make_latest: { type: "string", enum: ["true", "false", "legacy"], description: "Whether this becomes the latest release (default true)" }, | |
| 67 | +}; | |
| 68 | + | |
| 69 | +const makeLatest = (a: Args) => { | |
| 70 | + const v = optStr(a, "make_latest"); | |
| 71 | + if (v === undefined) return undefined; | |
| 72 | + if (v !== "true" && v !== "false" && v !== "legacy") throw new HttpError(422, "make_latest must be true, false or legacy"); | |
| 73 | + return v; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +export const RELEASE_TOOLS: Record<string, Tool> = { | |
| 77 | + list_releases: { | |
| 78 | + description: "List a repository's releases, newest first. Drafts are included only for writers.", | |
| 79 | + input: { ...OWNER_REPO, limit: { type: "number", description: "At most this many (default 30, max 100)" } }, | |
| 80 | + required: ["owner", "repo"], | |
| 81 | + readOnly: true, | |
| 82 | + run: async (c, a) => { | |
| 83 | + const { stub, caller } = await repoOf(c, a); | |
| 84 | + const limit = Math.min(Math.max(Number(a.limit) || 30, 1), 100); | |
| 85 | + const res = await stub.listReleases({ limit, offset: 0 }, caller); | |
| 86 | + return { total: res.total, releases: res.items.map((r) => out(c, a, r)) }; | |
| 87 | + }, | |
| 88 | + }, | |
| 89 | + get_release: { | |
| 90 | + description: "Get a release by tag, by id, or the latest release when neither is given.", | |
| 91 | + input: { ...OWNER_REPO, tag: { type: "string" }, id: { type: "number" } }, | |
| 92 | + required: ["owner", "repo"], | |
| 93 | + readOnly: true, | |
| 94 | + run: async (c, a) => { | |
| 95 | + const { stub, caller } = await repoOf(c, a); | |
| 96 | + const tag = optStr(a, "tag"); | |
| 97 | + const r = tag ? await stub.releaseByTag(tag, caller, true) : a.id !== undefined ? await stub.getRelease(id(a, "id"), caller) : await stub.latestRelease(); | |
| 98 | + return out(c, a, r); | |
| 99 | + }, | |
| 100 | + }, | |
| 101 | + create_release: { | |
| 102 | + description: "Create a release. Creates the tag at target_commitish (default branch by default) when it doesn't exist yet.", | |
| 103 | + input: { ...OWNER_REPO, tag_name: { type: "string" }, target_commitish: { type: "string", description: "Branch or commit for a new tag" }, ...RELEASE_FIELDS, generate_release_notes: { type: "boolean", description: "Fill in notes from merged pull requests since the previous release" } }, | |
| 104 | + required: ["owner", "repo", "tag_name"], | |
| 105 | + run: async (c, a) => { | |
| 106 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 107 | + const r = await stub.createRelease( | |
| 108 | + { | |
| 109 | + tag_name: str(a, "tag_name"), | |
| 110 | + target_commitish: optStr(a, "target_commitish"), | |
| 111 | + name: optStr(a, "name"), | |
| 112 | + body: optStr(a, "body"), | |
| 113 | + draft: optBool(a, "draft"), | |
| 114 | + prerelease: optBool(a, "prerelease"), | |
| 115 | + make_latest: makeLatest(a), | |
| 116 | + generate_release_notes: optBool(a, "generate_release_notes"), | |
| 117 | + }, | |
| 118 | + caller, | |
| 119 | + ); | |
| 120 | + return out(c, a, r); | |
| 121 | + }, | |
| 122 | + }, | |
| 123 | + update_release: { | |
| 124 | + description: "Edit a release: title, notes, tag, draft or prerelease state. Publishing a draft sends the release event.", | |
| 125 | + input: { ...OWNER_REPO, id: { type: "number" }, tag_name: { type: "string" }, target_commitish: { type: "string" }, ...RELEASE_FIELDS }, | |
| 126 | + required: ["owner", "repo", "id"], | |
| 127 | + run: async (c, a) => { | |
| 128 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 129 | + const r = await stub.updateRelease( | |
| 130 | + id(a, "id"), | |
| 131 | + { | |
| 132 | + tag_name: optStr(a, "tag_name"), | |
| 133 | + target_commitish: optStr(a, "target_commitish"), | |
| 134 | + name: typeof a.name === "string" ? a.name : undefined, | |
| 135 | + body: typeof a.body === "string" ? a.body : undefined, | |
| 136 | + draft: optBool(a, "draft"), | |
| 137 | + prerelease: optBool(a, "prerelease"), | |
| 138 | + make_latest: makeLatest(a), | |
| 139 | + }, | |
| 140 | + caller, | |
| 141 | + ); | |
| 142 | + return out(c, a, r); | |
| 143 | + }, | |
| 144 | + }, | |
| 145 | + upload_release_asset: { | |
| 146 | + description: "Attach a file (up to 25 MB) to a release. Pass text as content, or bytes as content_base64.", | |
| 147 | + input: { ...OWNER_REPO, release_id: { type: "number" }, name: { type: "string", description: "File name" }, label: { type: "string" }, content_type: { type: "string" }, content: { type: "string" }, content_base64: { type: "string" } }, | |
| 148 | + required: ["owner", "repo", "release_id", "name"], | |
| 149 | + run: async (c, a) => { | |
| 150 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 151 | + const text = typeof a.content === "string" ? a.content : undefined; | |
| 152 | + const b64 = optStr(a, "content_base64"); | |
| 153 | + if ((text === undefined) === (b64 === undefined)) throw new HttpError(422, "pass exactly one of content or content_base64"); | |
| 154 | + let bytes: Uint8Array; | |
| 155 | + try { | |
| 156 | + bytes = b64 !== undefined ? fromB64(b64) : new TextEncoder().encode(text); | |
| 157 | + } catch { | |
| 158 | + throw new HttpError(422, "content_base64 is not valid base64"); | |
| 159 | + } | |
| 160 | + if (bytes.byteLength > MAX_MCP_ASSET_BYTES) throw new HttpError(413, "MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files"); | |
| 161 | + const asset = await storeAsset(c.env, stub, id(a, "release_id"), { name: str(a, "name"), label: optStr(a, "label"), contentType: optStr(a, "content_type") ?? (text !== undefined ? "text/plain" : "application/octet-stream") }, { stream: new Blob([bytes]).stream(), length: bytes.byteLength }, caller); | |
| 162 | + return { id: asset.id, name: asset.name, size: asset.size, content_type: asset.contentType }; | |
| 163 | + }, | |
| 164 | + }, | |
| 165 | + generate_release_notes: { | |
| 166 | + description: "Draft release notes for a tag from the pull requests merged since the previous release. Doesn't create anything.", | |
| 167 | + input: { ...OWNER_REPO, tag_name: { type: "string" }, target_commitish: { type: "string" }, previous_tag_name: { type: "string" } }, | |
| 168 | + required: ["owner", "repo", "tag_name"], | |
| 169 | + readOnly: true, | |
| 170 | + run: async (c, a) => { | |
| 171 | + const { stub } = await repoOf(c, a, "write"); | |
| 172 | + return await stub.generateReleaseNotes({ tag_name: str(a, "tag_name"), target_commitish: optStr(a, "target_commitish"), previous_tag_name: optStr(a, "previous_tag_name") }); | |
| 173 | + }, | |
| 174 | + }, | |
| 175 | +}; |
modifiedapps/diffinery/src/mcp/server.ts+3−1View file
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 5 | 5 | import type { Role } from "../repo/types.js"; |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | +import { RELEASE_TOOLS } from "./releases.js"; | |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * Model Context Protocol server (Streamable HTTP transport, JSON responses). |
| @@ -17,7 +18,7 @@ | ||
| 17 | 18 | const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; |
| 18 | 19 | |
| 19 | 20 | type Args = Record<string, unknown>; |
| 20 | -interface Tool { | |
| 21 | +export interface Tool { | |
| 21 | 22 | description: string; |
| 22 | 23 | input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>; |
| 23 | 24 | required?: string[]; |
| @@ -369,6 +370,7 @@ | ||
| 369 | 370 | return { closed: true }; |
| 370 | 371 | }, |
| 371 | 372 | }, |
| 373 | + ...RELEASE_TOOLS, | |
| 372 | 374 | }; |
| 373 | 375 | |
| 374 | 376 | async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) { |
modifiedapps/diffinery/src/repo/RepoDO.ts+358−3View file
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | import { DurableObject } from "cloudflare:workers"; |
| 2 | +import { normalizeCheckSources } from "./required.js"; | |
| 3 | +import { unsafeTreePath } from "../lib/tree-names.js"; | |
| 2 | 4 | import { |
| 3 | 5 | EMPTY_TREE_OID, |
| 4 | 6 | ZERO_OID, |
| @@ -39,9 +41,16 @@ | ||
| 39 | 41 | import { HttpError, isValidRefName, now } from "../lib/util.js"; |
| 40 | 42 | import { Actions } from "./actions.js"; |
| 41 | 43 | import { Collab } from "./collab.js"; |
| 44 | +import { Deployments } from "./deployments.js"; | |
| 45 | +import { triggerWorkflows } from "./extra-triggers.js"; | |
| 46 | +import { GitData } from "./gitdata.js"; | |
| 42 | 47 | import { RepoObjectStore } from "./objects.js"; |
| 43 | 48 | import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js"; |
| 49 | +import { Releases } from "./releases.js"; | |
| 44 | 50 | import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js"; |
| 51 | +import { searchIssues } from "./search.js"; | |
| 52 | +import { Social } from "./social.js"; | |
| 53 | +import { Statuses } from "./statuses.js"; | |
| 45 | 54 | import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; |
| 46 | 55 | |
| 47 | 56 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| @@ -147,9 +156,10 @@ | ||
| 147 | 156 | await this.queue.process(); |
| 148 | 157 | } |
| 149 | 158 | |
| 150 | - emit(event: DiffineryEvent): void { | |
| 159 | + /** Queue an event (webhooks, notifications) and show it live to open pages; `live: false` skips the pages (e.g. draft releases, which readers can't see). */ | |
| 160 | + emit(event: DiffineryEvent, opts?: { live?: boolean }): void { | |
| 151 | 161 | this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e))); |
| 152 | - this.broadcast("repo", { kind: "event", event }); | |
| 162 | + if (opts?.live !== false) this.broadcast("repo", { kind: "event", event }); | |
| 153 | 163 | } |
| 154 | 164 | |
| 155 | 165 | // ---------------------------------------------------------------- refs |
| @@ -355,6 +365,7 @@ | ||
| 355 | 365 | at, |
| 356 | 366 | }; |
| 357 | 367 | this.emit(ev); |
| 368 | + this.deploys.noteRefUpdate(ref, newOid, caller); | |
| 358 | 369 | if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) { |
| 359 | 370 | const branch = ref.slice(11); |
| 360 | 371 | const synced = await this.collab.syncPullsForBranch(branch, newOid, caller); |
| @@ -476,6 +487,7 @@ | ||
| 476 | 487 | expectedHeadSha?: string; |
| 477 | 488 | createBranchFrom?: string; |
| 478 | 489 | author?: { name: string; email: string }; |
| 490 | + committer?: { name: string; email: string }; | |
| 479 | 491 | }, caller: Caller): Promise<{ sha: string; branch: string }> { |
| 480 | 492 | const ref = `refs/heads/${input.branch}`; |
| 481 | 493 | let parent = this.getRef(ref); |
| @@ -484,10 +496,16 @@ | ||
| 484 | 496 | else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`); |
| 485 | 497 | } |
| 486 | 498 | if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent }); |
| 499 | + for (const ch of input.changes) { | |
| 500 | + const why = ch.delete ? null : unsafeTreePath(ch.path); | |
| 501 | + if (why) throw new HttpError(422, `invalid path ${JSON.stringify(ch.path)}: ${why}`); | |
| 502 | + } | |
| 487 | 503 | const baseTree = parent ? (await readCommit(this.store, parent)).tree : null; |
| 488 | 504 | const tree = await applyChanges(this.store, baseTree, input.changes); |
| 489 | 505 | const ts = Math.floor(Date.now() / 1000); |
| 490 | - const committer = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 506 | + const committer = input.committer | |
| 507 | + ? { ...input.committer, timestamp: ts, timezone: "+0000" } | |
| 508 | + : { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 491 | 509 | const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer; |
| 492 | 510 | const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; |
| 493 | 511 | const sha = await this.store.write( |
| @@ -537,6 +555,7 @@ | ||
| 537 | 555 | |
| 538 | 556 | setProtection(pattern: string, rules: ProtectionRules | null): void { |
| 539 | 557 | if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) }; |
| 558 | + if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) }; | |
| 540 | 559 | if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules)); |
| 541 | 560 | else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern); |
| 542 | 561 | // Turning a queue off (or changing its checks) affects what is queued. |
| @@ -751,6 +770,342 @@ | ||
| 751 | 770 | return this.actions.artifactR2Key(runId, name); |
| 752 | 771 | } |
| 753 | 772 | |
| 773 | + // ---------------------------------------------------------------- RPC passthroughs (GitHub-compatible extras) | |
| 774 | + // Releases, commit statuses, deployments and environments, issue sub-resources | |
| 775 | + // (labels, reactions, events, locking), the Git data API, search and misc. | |
| 776 | + // The modules are created on first use. | |
| 777 | + | |
| 778 | + private extras: { releases?: Releases; statuses?: Statuses; deploys?: Deployments; social?: Social; gitdata?: GitData } = {}; | |
| 779 | + get releases(): Releases { | |
| 780 | + return (this.extras.releases ??= new Releases(this, this.env)); | |
| 781 | + } | |
| 782 | + get statuses(): Statuses { | |
| 783 | + return (this.extras.statuses ??= new Statuses(this)); | |
| 784 | + } | |
| 785 | + get deploys(): Deployments { | |
| 786 | + return (this.extras.deploys ??= new Deployments(this, this.env)); | |
| 787 | + } | |
| 788 | + get social(): Social { | |
| 789 | + return (this.extras.social ??= new Social(this)); | |
| 790 | + } | |
| 791 | + get gitdata(): GitData { | |
| 792 | + return (this.extras.gitdata ??= new GitData(this, this.env)); | |
| 793 | + } | |
| 794 | + | |
| 795 | + listReleases(...a: Parameters<Releases["list"]>) { | |
| 796 | + return this.releases.list(...a); | |
| 797 | + } | |
| 798 | + getRelease(...a: Parameters<Releases["get"]>) { | |
| 799 | + return this.releases.get(...a); | |
| 800 | + } | |
| 801 | + releaseByTag(...a: Parameters<Releases["byTag"]>) { | |
| 802 | + return this.releases.byTag(...a); | |
| 803 | + } | |
| 804 | + latestRelease() { | |
| 805 | + return this.releases.latest(); | |
| 806 | + } | |
| 807 | + releaseSummary(...a: Parameters<Releases["summary"]>) { | |
| 808 | + return this.releases.summary(...a); | |
| 809 | + } | |
| 810 | + createRelease(...a: Parameters<Releases["create"]>) { | |
| 811 | + return this.releases.create(...a); | |
| 812 | + } | |
| 813 | + updateRelease(...a: Parameters<Releases["update"]>) { | |
| 814 | + return this.releases.update(...a); | |
| 815 | + } | |
| 816 | + deleteRelease(...a: Parameters<Releases["remove"]>) { | |
| 817 | + return this.releases.remove(...a); | |
| 818 | + } | |
| 819 | + generateReleaseNotes(...a: Parameters<Releases["generateNotes"]>) { | |
| 820 | + return this.releases.generateNotes(...a); | |
| 821 | + } | |
| 822 | + releaseAssets(...a: Parameters<Releases["assets"]>) { | |
| 823 | + return this.releases.assets(...a); | |
| 824 | + } | |
| 825 | + releaseAsset(...a: Parameters<Releases["asset"]>) { | |
| 826 | + return this.releases.asset(...a); | |
| 827 | + } | |
| 828 | + updateReleaseAsset(...a: Parameters<Releases["updateAsset"]>) { | |
| 829 | + return this.releases.updateAsset(...a); | |
| 830 | + } | |
| 831 | + deleteReleaseAsset(...a: Parameters<Releases["deleteAsset"]>) { | |
| 832 | + return this.releases.deleteAsset(...a); | |
| 833 | + } | |
| 834 | + startReleaseAsset(...a: Parameters<Releases["startAsset"]>) { | |
| 835 | + return this.releases.startAsset(...a); | |
| 836 | + } | |
| 837 | + finishReleaseAsset(...a: Parameters<Releases["finishAsset"]>) { | |
| 838 | + return this.releases.finishAsset(...a); | |
| 839 | + } | |
| 840 | + abortReleaseAsset(...a: Parameters<Releases["abortAsset"]>) { | |
| 841 | + return this.releases.abortAsset(...a); | |
| 842 | + } | |
| 843 | + releaseDownload(...a: Parameters<Releases["download"]>) { | |
| 844 | + return this.releases.download(...a); | |
| 845 | + } | |
| 846 | + /** For the GitHub importer: a release with its original dates and author (no tag, events or workflows). */ | |
| 847 | + importRelease(...a: Parameters<Releases["importRelease"]>) { | |
| 848 | + return this.releases.importRelease(...a); | |
| 849 | + } | |
| 850 | + /** For the GitHub importer: an asset copied from a public https URL, or from an R2 key under this repository's prefix. */ | |
| 851 | + importReleaseAsset(...a: Parameters<Releases["importAsset"]>) { | |
| 852 | + return this.releases.importAsset(...a); | |
| 853 | + } | |
| 854 | + | |
| 855 | + createStatus(...a: Parameters<Statuses["create"]>) { | |
| 856 | + return this.statuses.create(...a); | |
| 857 | + } | |
| 858 | + commitStatuses(...a: Parameters<Statuses["list"]>) { | |
| 859 | + return this.statuses.list(...a); | |
| 860 | + } | |
| 861 | + combinedStatus(...a: Parameters<Statuses["combined"]>) { | |
| 862 | + return this.statuses.combined(...a); | |
| 863 | + } | |
| 864 | + /** Check runs and commit statuses together, as pull request and commit pages show them. */ | |
| 865 | + checksAndStatuses(sha: string) { | |
| 866 | + return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)]; | |
| 867 | + } | |
| 868 | + | |
| 869 | + createDeployment(...a: Parameters<Deployments["create"]>) { | |
| 870 | + return this.deploys.create(...a); | |
| 871 | + } | |
| 872 | + deployments(...a: Parameters<Deployments["list"]>) { | |
| 873 | + return this.deploys.list(...a); | |
| 874 | + } | |
| 875 | + deployment(...a: Parameters<Deployments["get"]>) { | |
| 876 | + return this.deploys.get(...a); | |
| 877 | + } | |
| 878 | + deleteDeployment(...a: Parameters<Deployments["remove"]>) { | |
| 879 | + return this.deploys.remove(...a); | |
| 880 | + } | |
| 881 | + createDeploymentStatus(...a: Parameters<Deployments["createStatus"]>) { | |
| 882 | + return this.deploys.createStatus(...a); | |
| 883 | + } | |
| 884 | + deploymentStatuses(...a: Parameters<Deployments["statuses"]>) { | |
| 885 | + return this.deploys.statuses(...a); | |
| 886 | + } | |
| 887 | + deploymentStatus(...a: Parameters<Deployments["status"]>) { | |
| 888 | + return this.deploys.status(...a); | |
| 889 | + } | |
| 890 | + environments() { | |
| 891 | + return this.deploys.environments(); | |
| 892 | + } | |
| 893 | + environment(...a: Parameters<Deployments["environment"]>) { | |
| 894 | + return this.deploys.environment(...a); | |
| 895 | + } | |
| 896 | + upsertEnvironment(...a: Parameters<Deployments["upsertEnvironment"]>) { | |
| 897 | + return this.deploys.upsertEnvironment(...a); | |
| 898 | + } | |
| 899 | + deleteEnvironment(...a: Parameters<Deployments["deleteEnvironment"]>) { | |
| 900 | + return this.deploys.deleteEnvironment(...a); | |
| 901 | + } | |
| 902 | + addEnvironmentBranchPattern(...a: Parameters<Deployments["addBranchPattern"]>) { | |
| 903 | + return this.deploys.addBranchPattern(...a); | |
| 904 | + } | |
| 905 | + removeEnvironmentBranchPattern(...a: Parameters<Deployments["removeBranchPattern"]>) { | |
| 906 | + return this.deploys.removeBranchPattern(...a); | |
| 907 | + } | |
| 908 | + setEnvironmentSecret(...a: Parameters<Deployments["setSecret"]>) { | |
| 909 | + return this.deploys.setSecret(...a); | |
| 910 | + } | |
| 911 | + deleteEnvironmentSecret(...a: Parameters<Deployments["deleteSecret"]>) { | |
| 912 | + return this.deploys.deleteSecret(...a); | |
| 913 | + } | |
| 914 | + environmentSecretNames(...a: Parameters<Deployments["secretNames"]>) { | |
| 915 | + return this.deploys.secretNames(...a); | |
| 916 | + } | |
| 917 | + setEnvironmentVariable(...a: Parameters<Deployments["setVariable"]>) { | |
| 918 | + return this.deploys.setVariable(...a); | |
| 919 | + } | |
| 920 | + environmentVariables(...a: Parameters<Deployments["variables"]>) { | |
| 921 | + return this.deploys.variables(...a); | |
| 922 | + } | |
| 923 | + | |
| 924 | + /** What the GitHub JSON builders need for a set of issues: label rows, reaction counts and lock reasons. */ | |
| 925 | + issueExtras(numbers: number[], subjects: string[] = []) { | |
| 926 | + const lockReasons: Record<number, string | null> = {}; | |
| 927 | + for (const n of numbers) lockReasons[n] = this.social.lockReason(n); | |
| 928 | + return { labels: this.social.labelRows(), reactions: this.social.reactionCounts([...numbers.map((n) => `issue:${n}`), ...subjects]), lockReasons }; | |
| 929 | + } | |
| 930 | + issueEvents(...a: Parameters<Social["events"]>) { | |
| 931 | + return this.social.events(...a); | |
| 932 | + } | |
| 933 | + repoIssueEvents(...a: Parameters<Social["repoEvents"]>) { | |
| 934 | + return this.social.repoEvents(...a); | |
| 935 | + } | |
| 936 | + issueEvent(...a: Parameters<Social["event"]>) { | |
| 937 | + return this.social.event(...a); | |
| 938 | + } | |
| 939 | + issueTimeline(...a: Parameters<Social["timeline"]>) { | |
| 940 | + return this.social.timeline(...a); | |
| 941 | + } | |
| 942 | + labelRows() { | |
| 943 | + return this.social.labelRows(); | |
| 944 | + } | |
| 945 | + label(...a: Parameters<Social["label"]>) { | |
| 946 | + return this.social.label(...a); | |
| 947 | + } | |
| 948 | + createLabel(...a: Parameters<Social["createLabel"]>) { | |
| 949 | + return this.social.createLabel(...a); | |
| 950 | + } | |
| 951 | + updateLabel(...a: Parameters<Social["updateLabel"]>) { | |
| 952 | + return this.social.updateLabel(...a); | |
| 953 | + } | |
| 954 | + deleteLabel(...a: Parameters<Social["deleteLabel"]>) { | |
| 955 | + return this.social.deleteLabel(...a); | |
| 956 | + } | |
| 957 | + issueLabels(...a: Parameters<Social["issueLabels"]>) { | |
| 958 | + return this.social.issueLabels(...a); | |
| 959 | + } | |
| 960 | + addIssueLabels(...a: Parameters<Social["addLabels"]>) { | |
| 961 | + return this.social.addLabels(...a); | |
| 962 | + } | |
| 963 | + setIssueLabels(...a: Parameters<Social["setLabels"]>) { | |
| 964 | + return this.social.setLabels(...a); | |
| 965 | + } | |
| 966 | + removeIssueLabel(...a: Parameters<Social["removeLabel"]>) { | |
| 967 | + return this.social.removeLabel(...a); | |
| 968 | + } | |
| 969 | + assignIssue(...a: Parameters<Social["assign"]>) { | |
| 970 | + return this.social.assign(...a); | |
| 971 | + } | |
| 972 | + lockIssue(...a: Parameters<Social["lock"]>) { | |
| 973 | + return this.social.lock(...a); | |
| 974 | + } | |
| 975 | + unlockIssue(...a: Parameters<Social["unlock"]>) { | |
| 976 | + return this.social.unlock(...a); | |
| 977 | + } | |
| 978 | + comment(...a: Parameters<Social["comment"]>) { | |
| 979 | + return this.social.comment(...a); | |
| 980 | + } | |
| 981 | + deleteComment(...a: Parameters<Social["deleteComment"]>) { | |
| 982 | + return this.social.deleteComment(...a); | |
| 983 | + } | |
| 984 | + repoComments(...a: Parameters<Social["repoComments"]>) { | |
| 985 | + return this.social.repoComments(...a); | |
| 986 | + } | |
| 987 | + createReviewComment(...a: Parameters<Social["createReviewComment"]>) { | |
| 988 | + return this.social.createReviewComment(...a); | |
| 989 | + } | |
| 990 | + review(...a: Parameters<Social["review"]>) { | |
| 991 | + return this.social.review(...a); | |
| 992 | + } | |
| 993 | + dismissReview(...a: Parameters<Social["dismissReview"]>) { | |
| 994 | + return this.social.dismissReview(...a); | |
| 995 | + } | |
| 996 | + removeReviewers(...a: Parameters<Social["removeReviewers"]>) { | |
| 997 | + return this.social.removeReviewers(...a); | |
| 998 | + } | |
| 999 | + updatePullBranch(...a: Parameters<Social["updateBranch"]>) { | |
| 1000 | + return this.social.updateBranch(...a); | |
| 1001 | + } | |
| 1002 | + pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) { | |
| 1003 | + return this.social.pullsForCommit(...a); | |
| 1004 | + } | |
| 1005 | + reactions(...a: Parameters<Social["reactions"]>) { | |
| 1006 | + return this.social.reactions(...a); | |
| 1007 | + } | |
| 1008 | + react(...a: Parameters<Social["react"]>) { | |
| 1009 | + return this.social.react(...a); | |
| 1010 | + } | |
| 1011 | + unreact(...a: Parameters<Social["unreact"]>) { | |
| 1012 | + return this.social.unreact(...a); | |
| 1013 | + } | |
| 1014 | + searchIssues(query: Parameters<typeof searchIssues>[1], limit: number) { | |
| 1015 | + return searchIssues(this, query, limit); | |
| 1016 | + } | |
| 1017 | + /** The issues for one page of search hits, in the order given (missing numbers are skipped). */ | |
| 1018 | + issuesByNumber(numbers: number[]) { | |
| 1019 | + return numbers.slice(0, 100).flatMap((n) => { | |
| 1020 | + try { | |
| 1021 | + return [this.collab.get(n).issue]; | |
| 1022 | + } catch { | |
| 1023 | + return []; | |
| 1024 | + } | |
| 1025 | + }); | |
| 1026 | + } | |
| 1027 | + | |
| 1028 | + objectType(...a: Parameters<GitData["objectType"]>) { | |
| 1029 | + return this.gitdata.objectType(...a); | |
| 1030 | + } | |
| 1031 | + refsWithTypes(...a: Parameters<GitData["refsWithTypes"]>) { | |
| 1032 | + return this.gitdata.refsWithTypes(...a); | |
| 1033 | + } | |
| 1034 | + createBlob(...a: Parameters<GitData["createBlob"]>) { | |
| 1035 | + return this.gitdata.createBlob(...a); | |
| 1036 | + } | |
| 1037 | + getBlob(...a: Parameters<GitData["getBlob"]>) { | |
| 1038 | + return this.gitdata.getBlob(...a); | |
| 1039 | + } | |
| 1040 | + createTree(...a: Parameters<GitData["createTree"]>) { | |
| 1041 | + return this.gitdata.createTree(...a); | |
| 1042 | + } | |
| 1043 | + getTree(...a: Parameters<GitData["getTree"]>) { | |
| 1044 | + return this.gitdata.getTree(...a); | |
| 1045 | + } | |
| 1046 | + createGitCommit(...a: Parameters<GitData["createCommit"]>) { | |
| 1047 | + return this.gitdata.createCommit(...a); | |
| 1048 | + } | |
| 1049 | + gitCommit(...a: Parameters<GitData["gitCommit"]>) { | |
| 1050 | + return this.gitdata.gitCommit(...a); | |
| 1051 | + } | |
| 1052 | + createGitTag(...a: Parameters<GitData["createTag"]>) { | |
| 1053 | + return this.gitdata.createTag(...a); | |
| 1054 | + } | |
| 1055 | + gitTag(...a: Parameters<GitData["getTag"]>) { | |
| 1056 | + return this.gitdata.getTag(...a); | |
| 1057 | + } | |
| 1058 | + putContents(...a: Parameters<GitData["putContents"]>) { | |
| 1059 | + return this.gitdata.putContents(...a); | |
| 1060 | + } | |
| 1061 | + deleteContents(...a: Parameters<GitData["deleteContents"]>) { | |
| 1062 | + return this.gitdata.deleteContents(...a); | |
| 1063 | + } | |
| 1064 | + readme(...a: Parameters<GitData["readme"]>) { | |
| 1065 | + return this.gitdata.readme(...a); | |
| 1066 | + } | |
| 1067 | + mergeBranches(...a: Parameters<GitData["merge"]>) { | |
| 1068 | + return this.gitdata.merge(...a); | |
| 1069 | + } | |
| 1070 | + languages() { | |
| 1071 | + return this.gitdata.languages(); | |
| 1072 | + } | |
| 1073 | + archiveKey(...a: Parameters<GitData["archiveKey"]>) { | |
| 1074 | + return this.gitdata.archiveKey(...a); | |
| 1075 | + } | |
| 1076 | + buildArchive(...a: Parameters<GitData["buildArchive"]>) { | |
| 1077 | + return this.gitdata.buildArchive(...a); | |
| 1078 | + } | |
| 1079 | + runLogsZip(...a: Parameters<GitData["runLogsZip"]>) { | |
| 1080 | + return this.gitdata.runLogsZip(...a); | |
| 1081 | + } | |
| 1082 | + runAttemptId(...a: Parameters<GitData["runAttemptId"]>) { | |
| 1083 | + return this.gitdata.runAttemptId(...a); | |
| 1084 | + } | |
| 1085 | + checkRun(...a: Parameters<GitData["check"]>) { | |
| 1086 | + return this.gitdata.check(...a); | |
| 1087 | + } | |
| 1088 | + updateCheckRun(...a: Parameters<GitData["updateCheck"]>) { | |
| 1089 | + return this.gitdata.updateCheck(...a); | |
| 1090 | + } | |
| 1091 | + checkSuites(...a: Parameters<GitData["checkSuites"]>) { | |
| 1092 | + return this.gitdata.checkSuites(...a); | |
| 1093 | + } | |
| 1094 | + checkSuite(...a: Parameters<GitData["checkSuite"]>) { | |
| 1095 | + return this.gitdata.checkSuite(...a); | |
| 1096 | + } | |
| 1097 | + | |
| 1098 | + /** repository_dispatch: an external event that starts workflows listening for its `event_type`. */ | |
| 1099 | + async repositoryDispatch(eventType: string, clientPayload: Record<string, unknown>, caller: Caller): Promise<void> { | |
| 1100 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events"); | |
| 1101 | + const sha = this.getRef(this.headRef); | |
| 1102 | + if (!sha) throw new HttpError(422, "the repository is empty"); | |
| 1103 | + const m = this.meta; | |
| 1104 | + const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload }; | |
| 1105 | + this.emit({ type: "repository_dispatch", action: eventType, repoId: m.repoId, owner: m.owner, repo: m.name, payload: JSON.parse(JSON.stringify(payload)), actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() }); | |
| 1106 | + await triggerWorkflows(this, { name: "repository_dispatch", action: eventType, ref: this.headRef, sha, payload, title: eventType }, caller); | |
| 1107 | + } | |
| 1108 | + | |
| 754 | 1109 | // ---------------------------------------------------------------- live updates (WebSockets with hibernation) |
| 755 | 1110 | |
| 756 | 1111 | override async fetch(request: Request): Promise<Response> { |
modifiedapps/diffinery/src/repo/actions.ts+17−2View file
| @@ -1061,11 +1061,25 @@ | ||
| 1061 | 1061 | throw new HttpError(409, (e as Error).message); |
| 1062 | 1062 | } |
| 1063 | 1063 | const m = this.repo.meta; |
| 1064 | + // Deployment environment (`environment:`): its branch policy, secrets and variables, and a deployment record. | |
| 1065 | + let environment: { secrets: Record<string, string>; vars: Record<string, string> } = { secrets: {}, vars: {} }; | |
| 1066 | + if (def.environment) { | |
| 1067 | + try { | |
| 1068 | + const ctx = { github: this.githubContext(run, short, ""), inputs: scope.inputs, vars: this.variables(), matrix: JSON.parse(job.matrix) }; | |
| 1069 | + const name = interpolate(def.environment.name, ctx); | |
| 1070 | + const url = def.environment.url && !def.environment.url.includes("steps.") ? interpolate(def.environment.url, ctx) : undefined; | |
| 1071 | + const logUrl = `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`; | |
| 1072 | + environment = await this.repo.deploys.jobStarted({ id: jobId, ref: run.ref, sha: run.sha, logUrl, actor: JSON.parse(run.actor) as Actor }, { name, url }); | |
| 1073 | + } catch (e) { | |
| 1074 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: (e as Error).message } }); | |
| 1075 | + throw new HttpError(409, (e as Error).message); | |
| 1076 | + } | |
| 1077 | + } | |
| 1064 | 1078 | const t = now(); |
| 1065 | 1079 | this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId); |
| 1066 | 1080 | this.repo.upsertCheck({ sha: run.head_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}` }); |
| 1067 | 1081 | this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); |
| 1068 | - const secrets: Record<string, string> = { ...scope.secrets, GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; | |
| 1082 | + const secrets: Record<string, string> = { ...scope.secrets, ...environment.secrets, GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; | |
| 1069 | 1083 | 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"; |
| 1070 | 1084 | return { |
| 1071 | 1085 | runId: run.id, |
| @@ -1083,7 +1097,7 @@ | ||
| 1083 | 1097 | strategy: JSON.parse(job.strategy), |
| 1084 | 1098 | needs: await this.needsResults(run, exp, exp.needs.get(job.job_key) ?? [], parent), |
| 1085 | 1099 | inputs: scope.inputs, |
| 1086 | - vars: this.variables(), | |
| 1100 | + vars: { ...this.variables(), ...environment.vars }, | |
| 1087 | 1101 | secrets, |
| 1088 | 1102 | outputs: def.outputs, |
| 1089 | 1103 | steps: def.steps.map((s) => ({ ...s, "timeout-minutes": s["timeout-minutes"] === undefined ? undefined : Number(s["timeout-minutes"]) })), |
| @@ -1144,6 +1158,7 @@ | ||
| 1144 | 1158 | t, |
| 1145 | 1159 | jobId, |
| 1146 | 1160 | ); |
| 1161 | + this.repo.deploys.jobCompleted(jobId, conclusion); | |
| 1147 | 1162 | const m = this.repo.meta; |
| 1148 | 1163 | this.repo.upsertCheck({ |
| 1149 | 1164 | sha: run.head_sha ?? run.sha, |
modifiedapps/diffinery/src/repo/collab.ts+95−12View file
| @@ -4,6 +4,8 @@ | ||
| 4 | 4 | import type { RepoDO } from "./RepoDO.js"; |
| 5 | 5 | import { toInfo } from "./RepoDO.js"; |
| 6 | 6 | import { atLeast, type Caller } from "./types.js"; |
| 7 | +import { COMMENT_SELECT } from "./social.js"; | |
| 8 | +import { requiredCheck } from "./required.js"; | |
| 7 | 9 | |
| 8 | 10 | export type IssueRow = { |
| 9 | 11 | number: number; |
| @@ -51,6 +53,7 @@ | ||
| 51 | 53 | updatedAt: string; |
| 52 | 54 | closedAt: string | null; |
| 53 | 55 | comments: number; |
| 56 | + locked: boolean; | |
| 54 | 57 | pull?: { |
| 55 | 58 | head: string; |
| 56 | 59 | headSha: string; |
| @@ -78,6 +81,10 @@ | ||
| 78 | 81 | commitId: string | null; |
| 79 | 82 | inReplyTo: string | null; |
| 80 | 83 | resolved: boolean; |
| 84 | + /** GitHub-style numeric ids: this comment's, its review's and its parent's. */ | |
| 85 | + seq?: number; | |
| 86 | + reviewSeq?: number | null; | |
| 87 | + inReplyToSeq?: number | null; | |
| 81 | 88 | }; |
| 82 | 89 | |
| 83 | 90 | export type Review = { |
| @@ -87,6 +94,8 @@ | ||
| 87 | 94 | body: string; |
| 88 | 95 | commitId: string; |
| 89 | 96 | submittedAt: string; |
| 97 | + /** GitHub-style numeric id. */ | |
| 98 | + seq?: number; | |
| 90 | 99 | }; |
| 91 | 100 | |
| 92 | 101 | export type Check = { |
| @@ -102,6 +111,12 @@ | ||
| 102 | 111 | app: string; |
| 103 | 112 | startedAt: string | null; |
| 104 | 113 | completedAt: string | null; |
| 114 | + /** Who reported it over the REST API (none for Diffinery Actions). */ | |
| 115 | + creatorId?: string | null; | |
| 116 | + creatorLogin?: string | null; | |
| 117 | + creatorKind?: string | null; | |
| 118 | + /** GitHub-style numeric id. */ | |
| 119 | + seq?: number | null; | |
| 105 | 120 | }; |
| 106 | 121 | |
| 107 | 122 | export type Mergeability = { |
| @@ -160,6 +175,7 @@ | ||
| 160 | 175 | updatedAt: r.updated_at, |
| 161 | 176 | closedAt: r.closed_at, |
| 162 | 177 | comments, |
| 178 | + locked: !!r.locked, | |
| 163 | 179 | }; |
| 164 | 180 | if (r.kind === "pull") { |
| 165 | 181 | const p = this.pullRow(r.number); |
| @@ -181,7 +197,25 @@ | ||
| 181 | 197 | |
| 182 | 198 | // ---------------------------------------------------------------- issues |
| 183 | 199 | |
| 184 | - list(opts: { kind?: "issue" | "pull"; state?: "open" | "closed" | "all"; label?: string; author?: string; limit?: number; offset?: number; q?: string } = {}): { items: Issue[]; total: number } { | |
| 200 | + list( | |
| 201 | + opts: { | |
| 202 | + kind?: "issue" | "pull"; | |
| 203 | + state?: "open" | "closed" | "all"; | |
| 204 | + label?: string; | |
| 205 | + author?: string; | |
| 206 | + limit?: number; | |
| 207 | + offset?: number; | |
| 208 | + q?: string; | |
| 209 | + // GitHub list filters (REST `issues` and `pulls` query parameters). | |
| 210 | + labels?: string[]; | |
| 211 | + assignee?: string; | |
| 212 | + since?: string; | |
| 213 | + head?: string; | |
| 214 | + base?: string; | |
| 215 | + sort?: "created" | "updated" | "comments"; | |
| 216 | + direction?: "asc" | "desc"; | |
| 217 | + } = {}, | |
| 218 | + ): { items: Issue[]; total: number } { | |
| 185 | 219 | const where: string[] = []; |
| 186 | 220 | const args: unknown[] = []; |
| 187 | 221 | if (opts.kind) { |
| @@ -204,16 +238,45 @@ | ||
| 204 | 238 | where.push("(title LIKE ? OR body LIKE ?)"); |
| 205 | 239 | args.push(`%${opts.q}%`, `%${opts.q}%`); |
| 206 | 240 | } |
| 241 | + for (const l of opts.labels ?? []) { | |
| 242 | + where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)"); | |
| 243 | + args.push(l); | |
| 244 | + } | |
| 245 | + if (opts.assignee === "none") where.push("assignees = '[]'"); | |
| 246 | + else if (opts.assignee === "*") where.push("assignees != '[]'"); | |
| 247 | + else if (opts.assignee) { | |
| 248 | + where.push("EXISTS (SELECT 1 FROM json_each(assignees) WHERE lower(value) = lower(?))"); | |
| 249 | + args.push(opts.assignee); | |
| 250 | + } | |
| 251 | + if (opts.since) { | |
| 252 | + where.push("updated_at >= ?"); | |
| 253 | + args.push(opts.since); | |
| 254 | + } | |
| 255 | + if (opts.head) { | |
| 256 | + where.push("number IN (SELECT number FROM pulls WHERE head_ref = ?)"); | |
| 257 | + args.push(opts.head); | |
| 258 | + } | |
| 259 | + if (opts.base) { | |
| 260 | + where.push("number IN (SELECT number FROM pulls WHERE base_ref = ?)"); | |
| 261 | + args.push(opts.base); | |
| 262 | + } | |
| 207 | 263 | const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; |
| 264 | + const dir = opts.direction === "asc" ? "ASC" : "DESC"; | |
| 265 | + const order = | |
| 266 | + opts.sort === "updated" | |
| 267 | + ? `updated_at ${dir}, number ${dir}` | |
| 268 | + : opts.sort === "comments" | |
| 269 | + ? `(SELECT count(*) FROM comments c WHERE c.number = issues.number AND c.review_id IS NULL) ${dir}, number ${dir}` | |
| 270 | + : `number ${dir}`; | |
| 208 | 271 | const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM issues ${w}`, ...args).one().n; |
| 209 | - 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(); | |
| 272 | + const rows = this.sql.exec<IssueRow>(`SELECT * FROM issues ${w} ORDER BY ${order} LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 30, 100), opts.offset ?? 0).toArray(); | |
| 210 | 273 | return { items: rows.map((r) => this.toIssue(r)), total }; |
| 211 | 274 | } |
| 212 | 275 | |
| 213 | 276 | get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } { |
| 214 | 277 | const issue = this.toIssue(this.row(number)); |
| 215 | 278 | const comments = this.sql |
| 216 | - .exec<Record<string, SqlStorageValue>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number) | |
| 279 | + .exec<Record<string, SqlStorageValue>>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.number = ? ORDER BY c.created_at`, number) | |
| 217 | 280 | .toArray() |
| 218 | 281 | .map( |
| 219 | 282 | (c): Comment => ({ |
| @@ -229,6 +292,9 @@ | ||
| 229 | 292 | commitId: (c.commit_id as string) ?? null, |
| 230 | 293 | inReplyTo: (c.in_reply_to as string) ?? null, |
| 231 | 294 | resolved: !!c.resolved, |
| 295 | + seq: c.seq as number, | |
| 296 | + reviewSeq: (c.review_seq as number) ?? null, | |
| 297 | + inReplyToSeq: (c.reply_seq as number) ?? null, | |
| 232 | 298 | }), |
| 233 | 299 | ); |
| 234 | 300 | const reviews = this.reviews(number); |
| @@ -237,7 +303,7 @@ | ||
| 237 | 303 | |
| 238 | 304 | private reviews(number: number): Review[] { |
| 239 | 305 | return this.sql |
| 240 | - .exec<Record<string, string>>("SELECT * FROM reviews WHERE number = ? ORDER BY submitted_at", number) | |
| 306 | + .exec<Record<string, string>>("SELECT rowid AS seq, * FROM reviews WHERE number = ? ORDER BY submitted_at", number) | |
| 241 | 307 | .toArray() |
| 242 | 308 | .map((r) => ({ |
| 243 | 309 | id: r.id!, |
| @@ -246,6 +312,7 @@ | ||
| 246 | 312 | body: r.body!, |
| 247 | 313 | commitId: r.commit_id!, |
| 248 | 314 | submittedAt: r.submitted_at!, |
| 315 | + seq: Number(r.seq), | |
| 249 | 316 | })); |
| 250 | 317 | } |
| 251 | 318 | |
| @@ -291,6 +358,7 @@ | ||
| 291 | 358 | state === "closed" ? (r.closed_at ?? t) : null, |
| 292 | 359 | number, |
| 293 | 360 | ); |
| 361 | + this.repo.social.recordUpdate(r, patch, caller); | |
| 294 | 362 | const m = this.repo.meta; |
| 295 | 363 | if (patch.state && patch.state !== r.state) { |
| 296 | 364 | const action = patch.state === "closed" ? "closed" : "reopened"; |
| @@ -328,6 +396,7 @@ | ||
| 328 | 396 | } |
| 329 | 397 | |
| 330 | 398 | editComment(id: string, body: string, caller: Caller): void { |
| 399 | + id = this.repo.social.commentKey(id); | |
| 331 | 400 | const c = this.sql.exec<{ author_id: string }>("SELECT author_id FROM comments WHERE id = ?", id).toArray()[0]; |
| 332 | 401 | if (!c) throw new HttpError(404, "comment not found"); |
| 333 | 402 | if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied"); |
| @@ -427,6 +496,7 @@ | ||
| 427 | 496 | const m = this.repo.meta; |
| 428 | 497 | this.repo.emit({ type: "pull_request", action: "review_requested", 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: now(), reviewers: added }); |
| 429 | 498 | } |
| 499 | + for (const l of added) this.repo.social.record(number, "review_requested", caller, { requested_reviewer: l }); | |
| 430 | 500 | } |
| 431 | 501 | |
| 432 | 502 | /** Called after a branch moves: refresh open pull requests whose head is that branch. */ |
| @@ -474,6 +544,7 @@ | ||
| 474 | 544 | const r = this.row(number); |
| 475 | 545 | const p = this.pullRow(number); |
| 476 | 546 | if (r.state !== "open") throw new HttpError(422, "pull request is closed"); |
| 547 | + if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked"); | |
| 477 | 548 | if (input.event !== "COMMENT" && r.author_id === caller.id) throw new HttpError(422, "you cannot approve or request changes on your own pull request"); |
| 478 | 549 | if (input.event !== "COMMENT" && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to approve or request changes"); |
| 479 | 550 | if (input.event === "COMMENT" && !input.body?.trim() && !input.comments?.length) throw new HttpError(422, "a comment review needs a body or line comments"); |
| @@ -544,17 +615,22 @@ | ||
| 544 | 615 | app: c.app!, |
| 545 | 616 | startedAt: c.started_at ?? null, |
| 546 | 617 | completedAt: c.completed_at ?? null, |
| 618 | + creatorId: c.creator_id ?? null, | |
| 619 | + creatorLogin: c.creator_login ?? null, | |
| 620 | + creatorKind: c.creator_kind ?? null, | |
| 621 | + seq: c.seq === null || c.seq === undefined ? null : Number(c.seq), | |
| 547 | 622 | })); |
| 548 | 623 | } |
| 549 | 624 | |
| 550 | 625 | /** Create or update a check run, keyed by (sha, name, app). */ |
| 551 | - upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string }): Check { | |
| 626 | + upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string; creator?: { id: string; login: string; kind: string } }): Check { | |
| 552 | 627 | const app = input.app ?? "diffinery-actions"; |
| 553 | - 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]; | |
| 628 | + const existing = this.sql.exec<{ id: string; started_at: string | null; seq: number | null }>("SELECT id, started_at, seq FROM checks WHERE sha = ? AND name = ? AND app = ?", input.sha, input.name, app).toArray()[0]; | |
| 554 | 629 | const t = now(); |
| 555 | 630 | const id = existing?.id ?? newId(); |
| 631 | + const seq = existing?.seq ?? this.sql.exec<{ n: number }>("SELECT coalesce(max(seq), 0) + 1 AS n FROM checks").one().n; | |
| 556 | 632 | this.sql.exec( |
| 557 | - "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 633 | + "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at, creator_id, creator_login, creator_kind, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 558 | 634 | id, |
| 559 | 635 | input.sha, |
| 560 | 636 | input.name, |
| @@ -567,6 +643,10 @@ | ||
| 567 | 643 | app, |
| 568 | 644 | existing?.started_at ?? (input.status !== "queued" ? t : null), |
| 569 | 645 | input.status === "completed" ? t : null, |
| 646 | + input.creator?.id ?? null, | |
| 647 | + input.creator?.login ?? null, | |
| 648 | + input.creator?.kind ?? null, | |
| 649 | + seq, | |
| 570 | 650 | ); |
| 571 | 651 | 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" }); |
| 572 | 652 | if (input.status === "completed") { |
| @@ -601,7 +681,8 @@ | ||
| 601 | 681 | if (rv.state === "APPROVED" && rv.author.kind !== "agent" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) result.approvals++; |
| 602 | 682 | if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login); |
| 603 | 683 | } |
| 604 | - const checks = this.checks(p.head_sha); | |
| 684 | + // Commit statuses count as checks too: a required check matches a status context. | |
| 685 | + const checks = [...this.checks(p.head_sha), ...this.repo.statuses.asChecks(p.head_sha)]; | |
| 605 | 686 | result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status })); |
| 606 | 687 | if (r.author_kind === "agent" && result.requiredApprovals < 1) { |
| 607 | 688 | // Agent-authored changes always need one human approval unless a rule says otherwise. |
| @@ -612,10 +693,10 @@ | ||
| 612 | 693 | if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)`); |
| 613 | 694 | if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`); |
| 614 | 695 | for (const name of rules.requiredChecks ?? []) { |
| 615 | - const c = checks.find((x) => x.name === name); | |
| 616 | - if (!c) result.blockers.push(`required check '${name}' has not run`); | |
| 617 | - else if (c.status !== "completed") result.blockers.push(`required check '${name}' is ${c.status}`); | |
| 618 | - else if (c.conclusion !== "success" && c.conclusion !== "skipped" && c.conclusion !== "neutral") result.blockers.push(`required check '${name}' ${c.conclusion}`); | |
| 696 | + const c = requiredCheck(name, checks, rules.requiredCheckSources?.[name]); | |
| 697 | + if (c.state === "missing") result.blockers.push(`required check '${name}' has not run`); | |
| 698 | + else if (c.state === "pending") result.blockers.push(`required check '${name}' is ${c.status}`); | |
| 699 | + else if (c.state === "failure") result.blockers.push(`required check '${name}' ${c.conclusion}`); | |
| 619 | 700 | } |
| 620 | 701 | if (rules.requireLinearHistory && mb !== baseSha) result.blockers.push("branch is behind its base; update it (linear history required)"); |
| 621 | 702 | if (caller) { |
| @@ -675,6 +756,8 @@ | ||
| 675 | 756 | this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number); |
| 676 | 757 | if (this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref); |
| 677 | 758 | this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number); |
| 759 | + this.repo.social.record(number, "merged", caller, { commit_id: newSha }); | |
| 760 | + this.repo.social.record(number, "closed", caller, { commit_id: newSha, state_reason: "merged" }); | |
| 678 | 761 | const meta = this.repo.meta; |
| 679 | 762 | 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 }); |
| 680 | 763 | // A merge is a push to the base branch from the server's point of view. |
addedapps/diffinery/src/repo/deployments.ts+512−0View file
| @@ -1,0 +1,512 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; | |
| 3 | +import { HttpError, now } from "../lib/util.js"; | |
| 4 | +import type { RepoDO } from "./RepoDO.js"; | |
| 5 | +import { requiredCheck } from "./required.js"; | |
| 6 | +import { atLeast, globToRegExp, type Caller } from "./types.js"; | |
| 7 | + | |
| 8 | +type DeploymentRow = { | |
| 9 | + id: number; | |
| 10 | + sha: string; | |
| 11 | + ref: string; | |
| 12 | + task: string; | |
| 13 | + environment: string; | |
| 14 | + original_environment: string; | |
| 15 | + description: string | null; | |
| 16 | + payload: string; | |
| 17 | + transient: number; | |
| 18 | + production: number; | |
| 19 | + creator_id: string; | |
| 20 | + creator_login: string; | |
| 21 | + creator_kind: string; | |
| 22 | + job_id: string | null; | |
| 23 | + created_at: string; | |
| 24 | + updated_at: string; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +type StatusRow = { | |
| 28 | + id: number; | |
| 29 | + deployment_id: number; | |
| 30 | + state: string; | |
| 31 | + description: string; | |
| 32 | + environment: string; | |
| 33 | + environment_url: string; | |
| 34 | + log_url: string; | |
| 35 | + creator_id: string; | |
| 36 | + creator_login: string; | |
| 37 | + creator_kind: string; | |
| 38 | + created_at: string; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +type EnvironmentRow = { id: number; name: string; branch_policy: string | null; created_at: string; updated_at: string }; | |
| 42 | + | |
| 43 | +export type Person = { id: string; login: string; kind: string }; | |
| 44 | + | |
| 45 | +export type Deployment = { | |
| 46 | + id: number; | |
| 47 | + sha: string; | |
| 48 | + ref: string; | |
| 49 | + task: string; | |
| 50 | + environment: string; | |
| 51 | + originalEnvironment: string; | |
| 52 | + description: string | null; | |
| 53 | + /** The payload as JSON text (see parsePayload). */ | |
| 54 | + payload: string; | |
| 55 | + transient: boolean; | |
| 56 | + production: boolean; | |
| 57 | + creator: Person; | |
| 58 | + createdAt: string; | |
| 59 | + updatedAt: string; | |
| 60 | + /** State of the newest status, if any. */ | |
| 61 | + state: string | null; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +export type DeploymentStatus = { | |
| 65 | + id: number; | |
| 66 | + deploymentId: number; | |
| 67 | + state: string; | |
| 68 | + description: string; | |
| 69 | + environment: string; | |
| 70 | + environmentUrl: string; | |
| 71 | + logUrl: string; | |
| 72 | + creator: Person; | |
| 73 | + createdAt: string; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +/** Which refs may deploy to an environment (GitHub's deployment branch policy). */ | |
| 77 | +export type BranchPolicy = { protected_branches: boolean; custom_branch_policies: boolean; patterns: Array<{ id: number; name: string; type: "branch" | "tag" }> }; | |
| 78 | + | |
| 79 | +/** | |
| 80 | + * Whether a job on `ref`, started by an actor of `actorKind`, may receive an | |
| 81 | + * environment's secrets. Pull request refs never do: their code is whatever the | |
| 82 | + * pull request says. Runs started by agents only do when the environment is | |
| 83 | + * limited to protected branches, where changes need review. | |
| 84 | + */ | |
| 85 | +export function secretsAllowedFor(policy: BranchPolicy | null, ref: string, actorKind: string): boolean { | |
| 86 | + if (ref.startsWith("refs/pull/")) return false; | |
| 87 | + if (actorKind === "agent") return !!policy?.protected_branches; | |
| 88 | + return true; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export type Environment = { id: number; name: string; policy: BranchPolicy | null; createdAt: string; updatedAt: string }; | |
| 92 | + | |
| 93 | +const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]); | |
| 94 | +const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/; | |
| 95 | +const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 96 | + | |
| 97 | +const person = (id: string, login: string, kind: string): Person => ({ id, login, kind }); | |
| 98 | + | |
| 99 | +/** A deployment's payload: the JSON it was created with, or the raw string when it isn't JSON. */ | |
| 100 | +export function parsePayload(text: string): unknown { | |
| 101 | + try { | |
| 102 | + return JSON.parse(text); | |
| 103 | + } catch { | |
| 104 | + return text; | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * Deployments, deployment statuses and environments (with their own secrets | |
| 110 | + * and variables). Workflow jobs with `environment:` record a deployment and | |
| 111 | + * get that environment's secrets once its branch policy allows the ref. | |
| 112 | + */ | |
| 113 | +export class Deployments { | |
| 114 | + constructor( | |
| 115 | + private repo: RepoDO, | |
| 116 | + private env: Env, | |
| 117 | + ) {} | |
| 118 | + | |
| 119 | + private get sql() { | |
| 120 | + return this.repo.sql; | |
| 121 | + } | |
| 122 | + | |
| 123 | + private toDeployment(r: DeploymentRow): Deployment { | |
| 124 | + const latest = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", r.id).toArray()[0]; | |
| 125 | + return { | |
| 126 | + id: r.id, | |
| 127 | + sha: r.sha, | |
| 128 | + ref: r.ref, | |
| 129 | + task: r.task, | |
| 130 | + environment: r.environment, | |
| 131 | + originalEnvironment: r.original_environment, | |
| 132 | + description: r.description, | |
| 133 | + payload: r.payload, | |
| 134 | + transient: !!r.transient, | |
| 135 | + production: !!r.production, | |
| 136 | + creator: person(r.creator_id, r.creator_login, r.creator_kind), | |
| 137 | + createdAt: r.created_at, | |
| 138 | + updatedAt: r.updated_at, | |
| 139 | + state: latest?.state ?? null, | |
| 140 | + }; | |
| 141 | + } | |
| 142 | + | |
| 143 | + private toStatus = (s: StatusRow): DeploymentStatus => ({ | |
| 144 | + id: s.id, | |
| 145 | + deploymentId: s.deployment_id, | |
| 146 | + state: s.state, | |
| 147 | + description: s.description, | |
| 148 | + environment: s.environment, | |
| 149 | + environmentUrl: s.environment_url, | |
| 150 | + logUrl: s.log_url, | |
| 151 | + creator: person(s.creator_id, s.creator_login, s.creator_kind), | |
| 152 | + createdAt: s.created_at, | |
| 153 | + }); | |
| 154 | + | |
| 155 | + private row(id: number): DeploymentRow { | |
| 156 | + const r = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = ?", id).toArray()[0]; | |
| 157 | + if (!r) throw new HttpError(404, "deployment not found"); | |
| 158 | + return r; | |
| 159 | + } | |
| 160 | + | |
| 161 | + private emit(type: "deployment" | "deployment_status", action: string, payload: Record<string, unknown>, caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf">): void { | |
| 162 | + const m = this.repo.meta; | |
| 163 | + this.repo.emit({ type, action, repoId: m.repoId, owner: m.owner, repo: m.name, payload, actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() }); | |
| 164 | + } | |
| 165 | + | |
| 166 | + // ---------------------------------------------------------------- deployments | |
| 167 | + | |
| 168 | + async create( | |
| 169 | + input: { ref?: string; task?: string; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean; required_contexts?: string[] }, | |
| 170 | + caller: Caller, | |
| 171 | + jobId: string | null = null, | |
| 172 | + ): Promise<Deployment> { | |
| 173 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployments"); | |
| 174 | + const ref = (input.ref ?? "").trim(); | |
| 175 | + if (!ref) throw new HttpError(422, "ref is required", { errors: [{ resource: "Deployment", code: "missing_field", field: "ref" }] }); | |
| 176 | + const sha = await this.repo.resolve(ref); | |
| 177 | + if (!sha) throw new HttpError(422, `no ref found for: ${ref}`, { errors: [{ resource: "Deployment", code: "invalid", field: "ref" }] }); | |
| 178 | + const environment = (input.environment ?? "production").trim(); | |
| 179 | + if (!ENV_NAME.test(environment)) throw new HttpError(422, "invalid environment name"); | |
| 180 | + // Like GitHub: without required_contexts every context on the commit must have passed; [] skips the check. | |
| 181 | + // Deployments a workflow job makes for its `environment:` are not checked (the job's own run is still going). | |
| 182 | + if (jobId === null && (input.required_contexts === undefined || input.required_contexts.length)) { | |
| 183 | + const entries = [...this.repo.statuses.asChecks(sha), ...this.repo.collab.checks(sha)]; | |
| 184 | + const contexts = input.required_contexts ?? [...new Set(entries.filter((e) => e.creatorKind !== "agent").map((e) => e.name))]; | |
| 185 | + const states = contexts.map((ctx) => ({ context: ctx, state: requiredCheck(ctx, entries).state })); | |
| 186 | + const failing = states.filter((x) => x.state !== "success"); | |
| 187 | + if (failing.length) throw new HttpError(409, `Conflict: Commit status checks failed for ${ref}.`, { errors: failing.map((x) => ({ contexts: [x], resource: "Deployment", field: "required_contexts" })) }); | |
| 188 | + } | |
| 189 | + this.ensureEnvironment(environment); | |
| 190 | + const t = now(); | |
| 191 | + this.sql.exec( | |
| 192 | + "INSERT INTO deployments (sha, ref, task, environment, original_environment, description, payload, transient, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 193 | + sha, | |
| 194 | + ref, | |
| 195 | + (input.task || "deploy").slice(0, 100), | |
| 196 | + environment, | |
| 197 | + environment, | |
| 198 | + input.description ? String(input.description).slice(0, 1000) : null, | |
| 199 | + typeof input.payload === "string" ? input.payload : JSON.stringify(input.payload ?? {}), | |
| 200 | + input.transient_environment ? 1 : 0, | |
| 201 | + (input.production_environment ?? environment === "production") ? 1 : 0, | |
| 202 | + caller.id, | |
| 203 | + caller.login, | |
| 204 | + caller.kind, | |
| 205 | + jobId, | |
| 206 | + t, | |
| 207 | + t, | |
| 208 | + ); | |
| 209 | + const d = this.toDeployment(this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = last_insert_rowid()").one()); | |
| 210 | + this.emit("deployment", "created", { deployment: { id: d.id, sha, ref, task: d.task, environment, description: d.description, payload: parsePayload(d.payload), created_at: t } }, caller); | |
| 211 | + return d; | |
| 212 | + } | |
| 213 | + | |
| 214 | + list(filter: { sha?: string; ref?: string; task?: string; environment?: string; limit: number; offset: number }): { items: Deployment[]; total: number } { | |
| 215 | + const where: string[] = []; | |
| 216 | + const args: unknown[] = []; | |
| 217 | + for (const [col, v] of [["sha", filter.sha], ["ref", filter.ref], ["task", filter.task], ["environment", filter.environment]] as const) { | |
| 218 | + if (v) { | |
| 219 | + where.push(`${col} = ?`); | |
| 220 | + args.push(v); | |
| 221 | + } | |
| 222 | + } | |
| 223 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 224 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM deployments ${w}`, ...args).one().n; | |
| 225 | + const rows = this.sql.exec<DeploymentRow>(`SELECT * FROM deployments ${w} ORDER BY id DESC LIMIT ? OFFSET ?`, ...args, Math.min(filter.limit, 100), filter.offset).toArray(); | |
| 226 | + return { items: rows.map((r) => this.toDeployment(r)), total }; | |
| 227 | + } | |
| 228 | + | |
| 229 | + get(id: number): Deployment { | |
| 230 | + return this.toDeployment(this.row(id)); | |
| 231 | + } | |
| 232 | + | |
| 233 | + remove(id: number, caller: Caller): void { | |
| 234 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to delete deployments"); | |
| 235 | + const d = this.get(id); | |
| 236 | + const others = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM deployments WHERE environment = ? AND id != ?", d.environment, id).one().n; | |
| 237 | + if (d.state === "success" && others > 0) throw new HttpError(422, "We cannot delete an active deployment unless it is the only deployment in a given environment."); | |
| 238 | + this.sql.exec("DELETE FROM deployment_statuses WHERE deployment_id = ?", id); | |
| 239 | + this.sql.exec("DELETE FROM deployments WHERE id = ?", id); | |
| 240 | + } | |
| 241 | + | |
| 242 | + createStatus( | |
| 243 | + id: number, | |
| 244 | + input: { state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }, | |
| 245 | + caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf" | "role">, | |
| 246 | + ): DeploymentStatus { | |
| 247 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployment statuses"); | |
| 248 | + const d = this.row(id); | |
| 249 | + const state = String(input.state ?? ""); | |
| 250 | + if (!STATES.has(state)) throw new HttpError(422, "state must be one of error, failure, inactive, in_progress, queued, pending, success", { errors: [{ resource: "DeploymentStatus", code: "invalid", field: "state" }] }); | |
| 251 | + const urls = [input.log_url ?? input.target_url ?? "", input.environment_url ?? ""]; | |
| 252 | + for (const u of urls) if (u && !/^https?:\/\//i.test(u)) throw new HttpError(422, "URLs must be http(s)"); | |
| 253 | + const environment = input.environment?.trim() || d.environment; | |
| 254 | + if (environment !== d.environment) { | |
| 255 | + if (!ENV_NAME.test(environment)) throw new HttpError(422, "invalid environment name"); | |
| 256 | + this.ensureEnvironment(environment); | |
| 257 | + this.sql.exec("UPDATE deployments SET environment = ? WHERE id = ?", environment, id); | |
| 258 | + } | |
| 259 | + const t = now(); | |
| 260 | + this.sql.exec( | |
| 261 | + "INSERT INTO deployment_statuses (deployment_id, state, description, environment, environment_url, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 262 | + id, | |
| 263 | + state, | |
| 264 | + (input.description ?? "").slice(0, 140), | |
| 265 | + environment, | |
| 266 | + urls[1], | |
| 267 | + urls[0], | |
| 268 | + caller.id, | |
| 269 | + caller.login, | |
| 270 | + caller.kind, | |
| 271 | + t, | |
| 272 | + ); | |
| 273 | + const statusId = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 274 | + this.sql.exec("UPDATE deployments SET updated_at = ? WHERE id = ?", t, id); | |
| 275 | + if (state === "success" && input.auto_inactive !== false) { | |
| 276 | + // Older non-transient, non-production deployments to the same environment become inactive. | |
| 277 | + const older = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE environment = ? AND id != ? AND transient = 0 AND production = 0", environment, id).toArray(); | |
| 278 | + for (const o of older) { | |
| 279 | + const last = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", o.id).toArray()[0]; | |
| 280 | + if (last?.state === "success") { | |
| 281 | + this.sql.exec( | |
| 282 | + "INSERT INTO deployment_statuses (deployment_id, state, description, environment, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'inactive', '', ?, ?, ?, ?, ?)", | |
| 283 | + o.id, | |
| 284 | + environment, | |
| 285 | + caller.id, | |
| 286 | + caller.login, | |
| 287 | + caller.kind, | |
| 288 | + t, | |
| 289 | + ); | |
| 290 | + } | |
| 291 | + } | |
| 292 | + } | |
| 293 | + const status = this.status(id, statusId); | |
| 294 | + this.emit("deployment_status", "created", { deployment_status: { id: statusId, state, description: status.description, environment, environment_url: status.environmentUrl, log_url: status.logUrl, created_at: t }, deployment: { id, sha: d.sha, ref: d.ref, task: d.task, environment } }, caller); | |
| 295 | + return status; | |
| 296 | + } | |
| 297 | + | |
| 298 | + statuses(id: number): DeploymentStatus[] { | |
| 299 | + this.row(id); | |
| 300 | + return this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC", id).toArray().map(this.toStatus); | |
| 301 | + } | |
| 302 | + | |
| 303 | + status(id: number, statusId: number): DeploymentStatus { | |
| 304 | + const s = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? AND id = ?", id, statusId).toArray()[0]; | |
| 305 | + if (!s) throw new HttpError(404, "deployment status not found"); | |
| 306 | + return this.toStatus(s); | |
| 307 | + } | |
| 308 | + | |
| 309 | + // ---------------------------------------------------------------- environments | |
| 310 | + | |
| 311 | + /** Environments guard deployment secrets, so only people with admin access change them, never agents. */ | |
| 312 | + private requireHumanAdmin(caller: Caller): void { | |
| 313 | + if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments"); | |
| 314 | + } | |
| 315 | + | |
| 316 | + /** Remember who last moved a tag: tags created by agents never satisfy environment tag patterns. */ | |
| 317 | + noteRefUpdate(ref: string, newOid: string, caller: Pick<Caller, "id" | "kind">): void { | |
| 318 | + if (!ref.startsWith("refs/tags/")) return; | |
| 319 | + if (/^0+$/.test(newOid)) this.sql.exec("DELETE FROM ref_actors WHERE ref = ?", ref); | |
| 320 | + else this.sql.exec("INSERT OR REPLACE INTO ref_actors (ref, actor_id, actor_kind, updated_at) VALUES (?, ?, ?, ?)", ref, caller.id, caller.kind, now()); | |
| 321 | + } | |
| 322 | + | |
| 323 | + private tagByAgent(tag: string): boolean { | |
| 324 | + return this.sql.exec<{ actor_kind: string }>("SELECT actor_kind FROM ref_actors WHERE ref = ?", `refs/tags/${tag}`).toArray()[0]?.actor_kind === "agent"; | |
| 325 | + } | |
| 326 | + | |
| 327 | + private envRow(name: string): EnvironmentRow | undefined { | |
| 328 | + return this.sql.exec<EnvironmentRow>("SELECT * FROM environments WHERE name = ?", name).toArray()[0]; | |
| 329 | + } | |
| 330 | + | |
| 331 | + private toEnvironment = (r: EnvironmentRow): Environment => ({ id: r.id, name: r.name, policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, createdAt: r.created_at, updatedAt: r.updated_at }); | |
| 332 | + | |
| 333 | + private ensureEnvironment(name: string): EnvironmentRow { | |
| 334 | + const existing = this.envRow(name); | |
| 335 | + if (existing) return existing; | |
| 336 | + const t = now(); | |
| 337 | + this.sql.exec("INSERT INTO environments (name, created_at, updated_at) VALUES (?, ?, ?)", name, t, t); | |
| 338 | + return this.envRow(name)!; | |
| 339 | + } | |
| 340 | + | |
| 341 | + environments(): Environment[] { | |
| 342 | + return this.sql.exec<EnvironmentRow>("SELECT * FROM environments ORDER BY name").toArray().map(this.toEnvironment); | |
| 343 | + } | |
| 344 | + | |
| 345 | + environment(name: string): Environment { | |
| 346 | + const r = this.envRow(name); | |
| 347 | + if (!r) throw new HttpError(404, "environment not found"); | |
| 348 | + return this.toEnvironment(r); | |
| 349 | + } | |
| 350 | + | |
| 351 | + /** | |
| 352 | + * Create or update an environment. Required reviewers and wait timers are not | |
| 353 | + * enforced yet, so they are refused rather than silently ignored. | |
| 354 | + */ | |
| 355 | + upsertEnvironment(name: string, input: { wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null }, caller: Caller): Environment { | |
| 356 | + this.requireHumanAdmin(caller); | |
| 357 | + if (!ENV_NAME.test(name)) throw new HttpError(422, "invalid environment name"); | |
| 358 | + if ((input.wait_timer ?? 0) > 0 || (input.reviewers?.length ?? 0) > 0) throw new HttpError(422, "required reviewers and wait timers are not supported yet"); | |
| 359 | + const row = this.ensureEnvironment(name); | |
| 360 | + if (input.deployment_branch_policy !== undefined) { | |
| 361 | + const p = input.deployment_branch_policy; | |
| 362 | + if (p && !!p.protected_branches === !!p.custom_branch_policies) throw new HttpError(422, "set exactly one of protected_branches and custom_branch_policies"); | |
| 363 | + const old = row.branch_policy ? (JSON.parse(row.branch_policy) as BranchPolicy) : null; | |
| 364 | + const policy: BranchPolicy | null = p ? { protected_branches: !!p.protected_branches, custom_branch_policies: !!p.custom_branch_policies, patterns: p.custom_branch_policies ? (old?.patterns ?? []) : [] } : null; | |
| 365 | + this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", policy ? JSON.stringify(policy) : null, now(), row.id); | |
| 366 | + } | |
| 367 | + return this.environment(name); | |
| 368 | + } | |
| 369 | + | |
| 370 | + deleteEnvironment(name: string, caller: Caller): void { | |
| 371 | + this.requireHumanAdmin(caller); | |
| 372 | + this.environment(name); | |
| 373 | + this.sql.exec("DELETE FROM environments WHERE name = ?", name); | |
| 374 | + this.sql.exec("DELETE FROM environment_secrets WHERE environment = ?", name); | |
| 375 | + this.sql.exec("DELETE FROM environment_variables WHERE environment = ?", name); | |
| 376 | + } | |
| 377 | + | |
| 378 | + addBranchPattern(name: string, pattern: { name?: string; type?: string }, caller: Caller): { id: number; name: string; type: "branch" | "tag" } { | |
| 379 | + this.requireHumanAdmin(caller); | |
| 380 | + const env = this.environment(name); | |
| 381 | + if (!env.policy?.custom_branch_policies) throw new HttpError(422, "enable custom_branch_policies on the environment first"); | |
| 382 | + const p = { id: Math.max(0, ...env.policy.patterns.map((x) => x.id)) + 1, name: String(pattern.name ?? "").trim(), type: pattern.type === "tag" ? ("tag" as const) : ("branch" as const) }; | |
| 383 | + if (!p.name) throw new HttpError(422, "name is required"); | |
| 384 | + env.policy.patterns.push(p); | |
| 385 | + this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); | |
| 386 | + return p; | |
| 387 | + } | |
| 388 | + | |
| 389 | + removeBranchPattern(name: string, id: number, caller: Caller): void { | |
| 390 | + this.requireHumanAdmin(caller); | |
| 391 | + const env = this.environment(name); | |
| 392 | + if (!env.policy) throw new HttpError(404, "branch policy not found"); | |
| 393 | + env.policy.patterns = env.policy.patterns.filter((x) => x.id !== id); | |
| 394 | + this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); | |
| 395 | + } | |
| 396 | + | |
| 397 | + /** Whether a run on `ref` may use the environment (and see its secrets). */ | |
| 398 | + refAllowed(name: string, ref: string): boolean { | |
| 399 | + const r = this.envRow(name); | |
| 400 | + const policy = r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null; | |
| 401 | + if (!policy) return true; | |
| 402 | + const branch = ref.startsWith("refs/heads/") ? ref.slice(11) : null; | |
| 403 | + const tag = ref.startsWith("refs/tags/") ? ref.slice(10) : null; | |
| 404 | + if (policy.protected_branches) return !!branch && !!this.repo.protectionFor(branch); | |
| 405 | + return policy.patterns.some((p) => (p.type === "tag" ? tag !== null && !this.tagByAgent(tag) && globToRegExp(p.name).test(tag) : branch !== null && globToRegExp(p.name).test(branch))); | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** Whether a job may receive the environment's secrets (it already passed refAllowed); see secretsAllowedFor. */ | |
| 409 | + secretsAllowed(name: string, ref: string, actorKind: string): boolean { | |
| 410 | + const r = this.envRow(name); | |
| 411 | + return secretsAllowedFor(r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, ref, actorKind); | |
| 412 | + } | |
| 413 | + | |
| 414 | + // ---------------------------------------------------------------- environment secrets and variables | |
| 415 | + | |
| 416 | + private scope(name: string): string { | |
| 417 | + return `repo:${this.repo.meta.repoId}:env:${name.toLowerCase()}`; | |
| 418 | + } | |
| 419 | + | |
| 420 | + async setSecret(envName: string, name: string, value: string, caller: Caller): Promise<void> { | |
| 421 | + if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); | |
| 422 | + if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); | |
| 423 | + const env = this.environment(envName); | |
| 424 | + // An environment holding secrets defaults to protected branches only, like a fresh GitHub environment should be. | |
| 425 | + if (!env.policy) this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify({ protected_branches: true, custom_branch_policies: false, patterns: [] } satisfies BranchPolicy), now(), env.id); | |
| 426 | + this.sql.exec("INSERT OR REPLACE INTO environment_secrets (environment, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)", env.name, name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, this.scope(env.name), value), now()); | |
| 427 | + } | |
| 428 | + | |
| 429 | + deleteSecret(envName: string, name: string, caller: Caller): void { | |
| 430 | + if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); | |
| 431 | + this.sql.exec("DELETE FROM environment_secrets WHERE environment = ? AND name = ?", envName, name.toUpperCase()); | |
| 432 | + } | |
| 433 | + | |
| 434 | + secretNames(envName: string): Array<{ name: string; updatedAt: string }> { | |
| 435 | + this.environment(envName); | |
| 436 | + return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM environment_secrets WHERE environment = ? ORDER BY name", envName).toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); | |
| 437 | + } | |
| 438 | + | |
| 439 | + setVariable(envName: string, name: string, value: string | null, caller: Caller, mustExist = false): void { | |
| 440 | + if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to manage variables"); | |
| 441 | + const env = this.environment(envName); | |
| 442 | + const key = name.toUpperCase(); | |
| 443 | + if (value === null) { | |
| 444 | + this.sql.exec("DELETE FROM environment_variables WHERE environment = ? AND name = ?", env.name, key); | |
| 445 | + return; | |
| 446 | + } | |
| 447 | + if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name"); | |
| 448 | + const existing = this.sql.exec("SELECT 1 FROM environment_variables WHERE environment = ? AND name = ?", env.name, key).toArray().length > 0; | |
| 449 | + if (mustExist && !existing) throw new HttpError(404, "variable not found"); | |
| 450 | + const t = now(); | |
| 451 | + if (existing) this.sql.exec("UPDATE environment_variables SET value = ?, updated_at = ? WHERE environment = ? AND name = ?", value, t, env.name, key); | |
| 452 | + else this.sql.exec("INSERT INTO environment_variables (environment, name, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", env.name, key, value, t, t); | |
| 453 | + } | |
| 454 | + | |
| 455 | + variables(envName: string): Array<{ name: string; value: string; createdAt: string; updatedAt: string }> { | |
| 456 | + this.environment(envName); | |
| 457 | + return this.sql | |
| 458 | + .exec<{ name: string; value: string; created_at: string; updated_at: string }>("SELECT * FROM environment_variables WHERE environment = ? ORDER BY name", envName) | |
| 459 | + .toArray() | |
| 460 | + .map((r) => ({ name: r.name, value: r.value, createdAt: r.created_at, updatedAt: r.updated_at })); | |
| 461 | + } | |
| 462 | + | |
| 463 | + // ---------------------------------------------------------------- workflow jobs with `environment:` | |
| 464 | + | |
| 465 | + /** | |
| 466 | + * Called when a job with `environment:` starts: checks the environment's | |
| 467 | + * branch policy, records a deployment with an in_progress status, and | |
| 468 | + * returns the environment's secrets and variables for the job. | |
| 469 | + */ | |
| 470 | + async jobStarted(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, environment: { name: string; url?: string }): Promise<{ secrets: Record<string, string>; vars: Record<string, string> }> { | |
| 471 | + const name = environment.name.trim(); | |
| 472 | + if (!ENV_NAME.test(name)) throw new HttpError(422, `invalid environment name '${name}'`); | |
| 473 | + this.ensureEnvironment(name); | |
| 474 | + if (!this.refAllowed(name, job.ref)) throw new HttpError(403, `${job.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${name} due to environment protection rules.`); | |
| 475 | + const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; | |
| 476 | + if (!this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) { | |
| 477 | + const t = now(); | |
| 478 | + this.sql.exec( | |
| 479 | + "INSERT INTO deployments (sha, ref, task, environment, original_environment, payload, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, 'deploy', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)", | |
| 480 | + job.sha, | |
| 481 | + job.ref.replace(/^refs\/(heads|tags)\//, ""), | |
| 482 | + name, | |
| 483 | + name, | |
| 484 | + name === "production" ? 1 : 0, | |
| 485 | + job.actor.id, | |
| 486 | + job.actor.login, | |
| 487 | + job.actor.kind, | |
| 488 | + job.id, | |
| 489 | + t, | |
| 490 | + t, | |
| 491 | + ); | |
| 492 | + const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 493 | + this.createStatus(id, { state: "in_progress", log_url: job.logUrl, environment_url: environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined }, system); | |
| 494 | + } | |
| 495 | + const secrets: Record<string, string> = {}; | |
| 496 | + const rows = this.secretsAllowed(name, job.ref, job.actor.kind) ? this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM environment_secrets WHERE environment = ?", name).toArray() : []; | |
| 497 | + for (const r of rows) { | |
| 498 | + secrets[r.name] = await decryptSecret(this.env.MASTER_KEY, this.scope(name), r.ciphertext); | |
| 499 | + } | |
| 500 | + const vars = Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM environment_variables WHERE environment = ?", name).toArray().map((r) => [r.name, r.value])); | |
| 501 | + return { secrets, vars }; | |
| 502 | + } | |
| 503 | + | |
| 504 | + /** Called when a job finishes: the deployment it recorded gets a final status. */ | |
| 505 | + jobCompleted(jobId: string, conclusion: string | null): void { | |
| 506 | + const d = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE job_id = ?", jobId).toArray()[0]; | |
| 507 | + if (!d) return; | |
| 508 | + const last = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", d.id).toArray()[0]; | |
| 509 | + const state = conclusion === "success" ? "success" : conclusion === "cancelled" || conclusion === "skipped" ? "inactive" : "failure"; | |
| 510 | + this.createStatus(d.id, { state, log_url: last?.log_url || undefined, environment_url: last?.environment_url || undefined }, { id: d.creator_id, login: d.creator_login, kind: d.creator_kind as Caller["kind"], role: "write" }); | |
| 511 | + } | |
| 512 | +} |
addedapps/diffinery/src/repo/extra-triggers.ts+29−0View file
| @@ -1,0 +1,29 @@ | ||
| 1 | +import { matchesTrigger } from "@diffinery/workflow-yaml"; | |
| 2 | +import type { RepoDO } from "./RepoDO.js"; | |
| 3 | +import type { Caller } from "./types.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Start the workflows that listen for an activity event other than push and | |
| 7 | + * pull_request (`release`, `repository_dispatch`), reading the workflow files | |
| 8 | + * at `sha`. `types:` filters match `action`. Like GitHub, events caused by a | |
| 9 | + * job's own token (GITHUB_TOKEN) start no new runs, so workflows can't loop; | |
| 10 | + * repository_dispatch is the exception, as on GitHub. | |
| 11 | + */ | |
| 12 | +export async function triggerWorkflows( | |
| 13 | + repo: RepoDO, | |
| 14 | + ev: { name: string; action?: string; ref: string; sha: string; payload: Record<string, unknown>; title: string }, | |
| 15 | + caller: Caller, | |
| 16 | +): Promise<number> { | |
| 17 | + if (caller.kind === "system" && caller.id.startsWith("job:") && ev.name !== "repository_dispatch") return 0; | |
| 18 | + let started = 0; | |
| 19 | + try { | |
| 20 | + for (const w of await repo.actions.loadWorkflows(ev.sha)) { | |
| 21 | + if (!w.workflow || !matchesTrigger(w.workflow, { name: ev.name, action: ev.action })) continue; | |
| 22 | + await repo.actions.createRun(w.workflow, { event: ev.name, ref: ev.ref, sha: ev.sha, payload: ev.payload, caller, title: ev.title }); | |
| 23 | + started++; | |
| 24 | + } | |
| 25 | + } catch (e) { | |
| 26 | + console.warn(`${ev.name} trigger failed`, e); | |
| 27 | + } | |
| 28 | + return started; | |
| 29 | +} |
addedapps/diffinery/src/repo/gitdata.ts+614−0View file
| @@ -1,0 +1,614 @@ | ||
| 1 | +import { ACTIONS_APP } from "./required.js"; | |
| 2 | +import { | |
| 3 | + CODE_TYPES, | |
| 4 | + EMPTY_TREE_OID, | |
| 5 | + hashObject, | |
| 6 | + isAncestor, | |
| 7 | + isOid, | |
| 8 | + isTreeMode, | |
| 9 | + mergeBase, | |
| 10 | + mergeTrees, | |
| 11 | + parseTag, | |
| 12 | + readCommit, | |
| 13 | + readTree, | |
| 14 | + resolvePath, | |
| 15 | + serializeCommit, | |
| 16 | + serializeTag, | |
| 17 | + serializeTree, | |
| 18 | + type ObjectType, | |
| 19 | + type Signature, | |
| 20 | + type TreeEntry, | |
| 21 | +} from "@diffinery/git-core"; | |
| 22 | +import type { Env } from "../env.js"; | |
| 23 | +import { scanForSecrets } from "../git/secret-scan.js"; | |
| 24 | +import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js"; | |
| 25 | +import { R2Sink } from "../lib/r2-sink.js"; | |
| 26 | +import { unsafeTreeName, unsafeTreePath } from "../lib/tree-names.js"; | |
| 27 | +import { HttpError, now } from "../lib/util.js"; | |
| 28 | +import type { Check } from "./collab.js"; | |
| 29 | +import { toInfo, type CommitInfo, type RepoDO } from "./RepoDO.js"; | |
| 30 | +import { atLeast, type Caller } from "./types.js"; | |
| 31 | + | |
| 32 | +export type SigInput = { name?: string; email?: string; date?: string }; | |
| 33 | +export type TreeInput = { path?: string; mode?: string; type?: string; sha?: string | null; content?: string }; | |
| 34 | +export type GitTreeEntry = { path: string; mode: string; type: "blob" | "tree" | "commit"; sha: string; size?: number }; | |
| 35 | + | |
| 36 | +const MODES = new Set(["100644", "100755", "040000", "160000", "120000"]); | |
| 37 | +/** Uncompressed file content an archive may hold; bigger repositories should be cloned. */ | |
| 38 | +const MAX_ARCHIVE_BYTES = 24 * 1024 * 1024; | |
| 39 | +/** Tree entries a walk may visit (repeated subtrees count every time), against tree bombs. */ | |
| 40 | +const MAX_TREE_ENTRIES = 100_000; | |
| 41 | +const MAX_TREE_DEPTH = 128; | |
| 42 | +/** Archive builds per repository per hour (cached archives don't count). */ | |
| 43 | +const ARCHIVE_BUILDS_PER_HOUR = 20; | |
| 44 | + | |
| 45 | +/** Language by file extension, for GET /languages (programming and markup languages, like linguist). */ | |
| 46 | +const LANGUAGES: Record<string, string> = { | |
| 47 | + ts: "TypeScript", tsx: "TypeScript", mts: "TypeScript", cts: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript", | |
| 48 | + py: "Python", rb: "Ruby", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", kts: "Kotlin", scala: "Scala", swift: "Swift", m: "Objective-C", | |
| 49 | + c: "C", h: "C", cc: "C++", cpp: "C++", cxx: "C++", hpp: "C++", hh: "C++", cs: "C#", fs: "F#", php: "PHP", pl: "Perl", lua: "Lua", r: "R", | |
| 50 | + dart: "Dart", ex: "Elixir", exs: "Elixir", erl: "Erlang", hs: "Haskell", clj: "Clojure", elm: "Elm", ml: "OCaml", zig: "Zig", nim: "Nim", | |
| 51 | + sh: "Shell", bash: "Shell", zsh: "Shell", ps1: "PowerShell", sql: "PLpgSQL", html: "HTML", htm: "HTML", css: "CSS", scss: "SCSS", sass: "Sass", | |
| 52 | + less: "Less", vue: "Vue", svelte: "Svelte", astro: "Astro", tf: "HCL", hcl: "HCL", nix: "Nix", sol: "Solidity", vb: "Visual Basic .NET", | |
| 53 | + groovy: "Groovy", jl: "Julia", cr: "Crystal", v: "V", d: "D", asm: "Assembly", s: "Assembly", mk: "Makefile", cmake: "CMake", proto: "Protocol Buffer", | |
| 54 | +}; | |
| 55 | +const LANGUAGE_FILES: Record<string, string> = { Dockerfile: "Dockerfile", Makefile: "Makefile", "CMakeLists.txt": "CMake", Rakefile: "Ruby", Gemfile: "Ruby" }; | |
| 56 | +const VENDORED = /(^|\/)(node_modules|vendor|third_party|dist|build|\.yarn)\//; | |
| 57 | + | |
| 58 | +function parseSig(input: SigInput | undefined, fallback: Signature, field: string): Signature { | |
| 59 | + if (!input) return fallback; | |
| 60 | + if (!input.name || !input.email) throw new HttpError(422, `${field} needs a name and an email`, { errors: [{ resource: "Commit", code: "missing_field", field }] }); | |
| 61 | + let timestamp = fallback.timestamp; | |
| 62 | + let timezone = "+0000"; | |
| 63 | + if (input.date) { | |
| 64 | + const t = Date.parse(input.date); | |
| 65 | + if (Number.isNaN(t)) throw new HttpError(422, `${field}.date must be an ISO 8601 date`); | |
| 66 | + timestamp = Math.floor(t / 1000); | |
| 67 | + const m = /([+-])(\d{2}):?(\d{2})$/.exec(input.date); | |
| 68 | + if (m) timezone = `${m[1]}${m[2]}${m[3]}`; | |
| 69 | + } | |
| 70 | + return { name: input.name.replace(/[<>\n]/g, ""), email: input.email.replace(/[<>\n]/g, ""), timestamp, timezone }; | |
| 71 | +} | |
| 72 | + | |
| 73 | +const sixDigit = (mode: string) => mode.padStart(6, "0"); | |
| 74 | +const entryType = (mode: string): GitTreeEntry["type"] => (isTreeMode(mode) ? "tree" : mode === "160000" ? "commit" : "blob"); | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * Git data API (blobs, trees, commits, annotated tags), contents writes with | |
| 78 | + * optimistic concurrency, the merges API, archives, languages and a few | |
| 79 | + * Actions and checks extras that need direct access to the repository. | |
| 80 | + */ | |
| 81 | +export class GitData { | |
| 82 | + constructor( | |
| 83 | + private repo: RepoDO, | |
| 84 | + private env: Env, | |
| 85 | + ) {} | |
| 86 | + | |
| 87 | + private get sql() { | |
| 88 | + return this.repo.sql; | |
| 89 | + } | |
| 90 | + | |
| 91 | + private get store() { | |
| 92 | + return this.repo.store; | |
| 93 | + } | |
| 94 | + | |
| 95 | + private requireWrite(caller: Caller): void { | |
| 96 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required"); | |
| 97 | + } | |
| 98 | + | |
| 99 | + private signature(caller: Caller): Signature { | |
| 100 | + return { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: Math.floor(Date.now() / 1000), timezone: "+0000" }; | |
| 101 | + } | |
| 102 | + | |
| 103 | + private agentHeaders(caller: Caller): [string, string][] { | |
| 104 | + return caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; | |
| 105 | + } | |
| 106 | + | |
| 107 | + /** Same push protection as git pushes: blobs with credentials are refused. */ | |
| 108 | + private scan(oid: string, content: Uint8Array): void { | |
| 109 | + const hits = scanForSecrets([{ oid, content }]); | |
| 110 | + if (hits.length) { | |
| 111 | + const m = this.repo.meta; | |
| 112 | + this.repo.emit({ type: "audit", action: "api.blocked_secret", actor: { id: "api", login: "api", kind: "system" }, target: `${m.owner}/${m.name}`, detail: { kinds: hits.map((h) => h.kind) }, at: now() }); | |
| 113 | + throw new HttpError(422, `push protection: possible secret detected (${hits.map((h) => h.kind).join(", ")})`); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + objectType(oid: string): ObjectType | null { | |
| 118 | + const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0]; | |
| 119 | + return row ? (CODE_TYPES[row.type] ?? null) : null; | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** Refs under a prefix with the type of the object each points at. */ | |
| 123 | + refsWithTypes(prefix: string): Array<{ ref: string; sha: string; type: ObjectType | "commit" }> { | |
| 124 | + return this.repo.refs(prefix).map((r) => ({ ref: r.name, sha: r.oid, type: this.objectType(r.oid) ?? "commit" })); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // ---------------------------------------------------------------- blobs | |
| 128 | + | |
| 129 | + async createBlob(content: Uint8Array, caller: Caller): Promise<{ sha: string; size: number }> { | |
| 130 | + this.requireWrite(caller); | |
| 131 | + const oid = await hashObject("blob", content); | |
| 132 | + this.scan(oid, content); | |
| 133 | + await this.store.writeKnown(oid, "blob", content); | |
| 134 | + return { sha: oid, size: content.length }; | |
| 135 | + } | |
| 136 | + | |
| 137 | + async getBlob(sha: string): Promise<{ sha: string; size: number; content: Uint8Array }> { | |
| 138 | + if (!isOid(sha)) throw new HttpError(404, "blob not found"); | |
| 139 | + const o = await this.store.read(sha); | |
| 140 | + if (!o || o.type !== "blob") throw new HttpError(404, "blob not found"); | |
| 141 | + return { sha, size: o.content.length, content: o.content }; | |
| 142 | + } | |
| 143 | + | |
| 144 | + // ---------------------------------------------------------------- trees | |
| 145 | + | |
| 146 | + async createTree(baseTree: string | undefined, entries: TreeInput[], caller: Caller): Promise<string> { | |
| 147 | + this.requireWrite(caller); | |
| 148 | + if (!Array.isArray(entries)) throw new HttpError(422, "tree must be an array", { errors: [{ resource: "Tree", code: "invalid", field: "tree" }] }); | |
| 149 | + if (baseTree && this.objectType(baseTree) !== "tree") throw new HttpError(422, "base_tree is not a valid tree oid", { errors: [{ resource: "Tree", code: "invalid", field: "base_tree" }] }); | |
| 150 | + type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node> }; | |
| 151 | + const load = async (oid: string | null): Promise<Node> => ({ entries: new Map(oid ? (await readTree(this.store, oid)).map((e) => [e.name, e]) : []), children: new Map() }); | |
| 152 | + const root = await load(baseTree ?? null); | |
| 153 | + const dirFor = async (parts: string[]): Promise<Node> => { | |
| 154 | + let node = root; | |
| 155 | + for (const part of parts) { | |
| 156 | + let child = node.children.get(part); | |
| 157 | + if (!child) { | |
| 158 | + const existing = node.entries.get(part); | |
| 159 | + child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null); | |
| 160 | + node.children.set(part, child); | |
| 161 | + } | |
| 162 | + node = child; | |
| 163 | + } | |
| 164 | + return node; | |
| 165 | + }; | |
| 166 | + for (const e of entries) { | |
| 167 | + const parts = String(e.path ?? "").split("/").filter(Boolean); | |
| 168 | + const name = parts.pop(); | |
| 169 | + const why = unsafeTreePath(String(e.path ?? "")); | |
| 170 | + if (!name || why) throw new HttpError(422, `invalid tree path ${JSON.stringify(String(e.path ?? ""))}: ${why ?? "empty path"}`, { errors: [{ resource: "Tree", code: "invalid", field: "path" }] }); | |
| 171 | + const mode = String(e.mode ?? "100644"); | |
| 172 | + if (!MODES.has(mode)) throw new HttpError(422, `invalid mode ${mode}`, { errors: [{ resource: "Tree", code: "invalid", field: "mode" }] }); | |
| 173 | + const dir = await dirFor(parts); | |
| 174 | + if (e.sha === null && e.content === undefined) { | |
| 175 | + dir.entries.delete(name); | |
| 176 | + dir.children.delete(name); | |
| 177 | + continue; | |
| 178 | + } | |
| 179 | + let oid: string; | |
| 180 | + if (e.content !== undefined) { | |
| 181 | + if (mode === "040000" || mode === "160000") throw new HttpError(422, "content can only be used with blob modes"); | |
| 182 | + oid = (await this.createBlob(new TextEncoder().encode(String(e.content)), caller)).sha; | |
| 183 | + } else { | |
| 184 | + oid = String(e.sha ?? ""); | |
| 185 | + if (!isOid(oid)) throw new HttpError(422, `invalid sha for ${e.path}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] }); | |
| 186 | + const want = mode === "040000" ? "tree" : mode === "160000" ? null : "blob"; | |
| 187 | + if (want && this.objectType(oid) !== want) throw new HttpError(422, `${oid} is not a ${want}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] }); | |
| 188 | + } | |
| 189 | + dir.children.delete(name); | |
| 190 | + dir.entries.set(name, { mode: mode === "040000" ? "40000" : mode, name, oid }); | |
| 191 | + } | |
| 192 | + const finish = async (node: Node): Promise<string | null> => { | |
| 193 | + for (const [name, child] of node.children) { | |
| 194 | + const oid = await finish(child); | |
| 195 | + if (oid) node.entries.set(name, { mode: "40000", name, oid }); | |
| 196 | + else node.entries.delete(name); | |
| 197 | + } | |
| 198 | + if (node.entries.size === 0) return null; | |
| 199 | + return this.store.write("tree", serializeTree([...node.entries.values()])); | |
| 200 | + }; | |
| 201 | + return (await finish(root)) ?? (await this.store.write("tree", new Uint8Array(0))); | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** A tree by tree SHA, commit SHA or ref; `recursive` lists every entry (truncated after 100k). */ | |
| 205 | + async getTree(rev: string, recursive: boolean): Promise<{ sha: string; tree: GitTreeEntry[]; truncated: boolean }> { | |
| 206 | + let sha = isOid(rev) && this.objectType(rev) === "tree" ? rev : null; | |
| 207 | + if (!sha) { | |
| 208 | + const commit = await this.repo.resolve(rev); | |
| 209 | + if (!commit) throw new HttpError(404, "tree not found"); | |
| 210 | + sha = (await readCommit(this.store, commit)).tree; | |
| 211 | + } | |
| 212 | + const out: GitTreeEntry[] = []; | |
| 213 | + let truncated = false; | |
| 214 | + const sizes = (oids: string[]) => { | |
| 215 | + const m = new Map<string, number>(); | |
| 216 | + for (let i = 0; i < oids.length; i += 100) { | |
| 217 | + const chunk = oids.slice(i, i + 100); | |
| 218 | + for (const r of this.sql.exec<{ oid: string; size: number }>(`SELECT oid, size FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk).toArray()) m.set(r.oid, r.size); | |
| 219 | + } | |
| 220 | + return m; | |
| 221 | + }; | |
| 222 | + const walk = async (oid: string, prefix: string) => { | |
| 223 | + const entries = await readTree(this.store, oid); | |
| 224 | + const size = sizes(entries.filter((e) => !isTreeMode(e.mode) && e.mode !== "160000").map((e) => e.oid)); | |
| 225 | + for (const e of entries) { | |
| 226 | + if (out.length >= 100_000) { | |
| 227 | + truncated = true; | |
| 228 | + return; | |
| 229 | + } | |
| 230 | + const path = prefix ? `${prefix}/${e.name}` : e.name; | |
| 231 | + const type = entryType(e.mode); | |
| 232 | + out.push({ path, mode: sixDigit(e.mode), type, sha: e.oid, ...(type === "blob" ? { size: size.get(e.oid) ?? 0 } : {}) }); | |
| 233 | + if (recursive && type === "tree") await walk(e.oid, path); | |
| 234 | + } | |
| 235 | + }; | |
| 236 | + if (sha !== EMPTY_TREE_OID || this.store.has(sha)) await walk(sha, ""); | |
| 237 | + return { sha, tree: out, truncated }; | |
| 238 | + } | |
| 239 | + | |
| 240 | + // ---------------------------------------------------------------- commits and tags | |
| 241 | + | |
| 242 | + async createCommit(input: { message?: string; tree?: string; parents?: string[]; author?: SigInput; committer?: SigInput; signature?: string }, caller: Caller): Promise<CommitInfo> { | |
| 243 | + this.requireWrite(caller); | |
| 244 | + if (typeof input.message !== "string") throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] }); | |
| 245 | + if (!input.tree || this.objectType(input.tree) !== "tree") throw new HttpError(422, "tree is not a valid tree oid", { errors: [{ resource: "Commit", code: "invalid", field: "tree" }] }); | |
| 246 | + const parents = input.parents ?? []; | |
| 247 | + if (!Array.isArray(parents)) throw new HttpError(422, "parents must be an array"); | |
| 248 | + for (const p of parents) if (!isOid(String(p)) || this.objectType(String(p)) !== "commit") throw new HttpError(422, `parent ${p} is not a valid commit`, { errors: [{ resource: "Commit", code: "invalid", field: "parents" }] }); | |
| 249 | + const me = this.signature(caller); | |
| 250 | + const author = parseSig(input.author, me, "author"); | |
| 251 | + const committer = parseSig(input.committer, input.author ? author : me, "committer"); | |
| 252 | + const sha = await this.store.write( | |
| 253 | + "commit", | |
| 254 | + serializeCommit({ tree: input.tree, parents: parents.map(String), author, committer, message: input.message, extraHeaders: this.agentHeaders(caller), gpgsig: input.signature || undefined }), | |
| 255 | + ); | |
| 256 | + return this.repo.commitInfo(sha); | |
| 257 | + } | |
| 258 | + | |
| 259 | + async gitCommit(sha: string): Promise<CommitInfo> { | |
| 260 | + if (!isOid(sha) || this.objectType(sha) !== "commit") throw new HttpError(404, "commit not found"); | |
| 261 | + return toInfo(sha, await readCommit(this.store, sha)); | |
| 262 | + } | |
| 263 | + | |
| 264 | + async createTag(input: { tag?: string; message?: string; object?: string; type?: string; tagger?: SigInput }, caller: Caller) { | |
| 265 | + this.requireWrite(caller); | |
| 266 | + const tag = String(input.tag ?? "").trim(); | |
| 267 | + if (!tag || /[\s~^:?*[\\]|\.\.|^\.|\/$|\.lock$/.test(tag)) throw new HttpError(422, "invalid tag name", { errors: [{ resource: "Tag", code: "invalid", field: "tag" }] }); | |
| 268 | + const object = String(input.object ?? ""); | |
| 269 | + const actual = isOid(object) ? this.objectType(object) : null; | |
| 270 | + if (!actual) throw new HttpError(422, "object does not exist", { errors: [{ resource: "Tag", code: "invalid", field: "object" }] }); | |
| 271 | + const type = (input.type ?? actual) as ObjectType; | |
| 272 | + if (type !== actual) throw new HttpError(422, `object is a ${actual}, not a ${type}`, { errors: [{ resource: "Tag", code: "invalid", field: "type" }] }); | |
| 273 | + const tagger = parseSig(input.tagger, this.signature(caller), "tagger"); | |
| 274 | + const message = String(input.message ?? ""); | |
| 275 | + const sha = await this.store.write("tag", serializeTag({ object, type, tag, tagger, message: message.endsWith("\n") || !message ? message : `${message}\n` })); | |
| 276 | + return this.getTag(sha); | |
| 277 | + } | |
| 278 | + | |
| 279 | + async getTag(sha: string) { | |
| 280 | + if (!isOid(sha) || this.objectType(sha) !== "tag") throw new HttpError(404, "tag not found"); | |
| 281 | + const o = await this.store.read(sha); | |
| 282 | + const t = parseTag(o!.content); | |
| 283 | + return { sha, tag: t.tag, message: t.message, tagger: t.tagger ?? null, object: { sha: t.object, type: t.type } }; | |
| 284 | + } | |
| 285 | + | |
| 286 | + // ---------------------------------------------------------------- contents | |
| 287 | + | |
| 288 | + /** Create or update one file; `sha` must name the file's current blob when it exists (GitHub's rule). */ | |
| 289 | + async putContents( | |
| 290 | + input: { path: string; content: Uint8Array; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } }, | |
| 291 | + caller: Caller, | |
| 292 | + ): Promise<{ created: boolean; commit: CommitInfo; blob: { sha: string; size: number } }> { | |
| 293 | + this.requireWrite(caller); | |
| 294 | + if (!input.message) throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] }); | |
| 295 | + const branch = input.branch || this.repo.meta.defaultBranch; | |
| 296 | + const head = this.repo.getRef(`refs/heads/${branch}`); | |
| 297 | + const existing = head ? await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path) : null; | |
| 298 | + if (existing && isTreeMode(existing.mode)) throw new HttpError(422, `${input.path} is a directory`); | |
| 299 | + if (existing && !input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] }); | |
| 300 | + if (existing && input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`); | |
| 301 | + if (!existing && input.sha) throw new HttpError(409, `${input.path} does not exist at ${branch}`); | |
| 302 | + const oid = await hashObject("blob", input.content); | |
| 303 | + this.scan(oid, input.content); | |
| 304 | + const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, content: input.content }], expectedHeadSha: head ?? undefined, author: input.author, committer: input.committer }, caller); | |
| 305 | + return { created: !existing, commit: await this.repo.commitInfo(res.sha), blob: { sha: oid, size: input.content.length } }; | |
| 306 | + } | |
| 307 | + | |
| 308 | + async deleteContents(input: { path: string; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } }, caller: Caller): Promise<CommitInfo> { | |
| 309 | + this.requireWrite(caller); | |
| 310 | + if (!input.message) throw new HttpError(422, "message is required"); | |
| 311 | + const branch = input.branch || this.repo.meta.defaultBranch; | |
| 312 | + const head = this.repo.getRef(`refs/heads/${branch}`); | |
| 313 | + if (!head) throw new HttpError(404, `branch ${branch} not found`); | |
| 314 | + const existing = await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path); | |
| 315 | + if (!existing || isTreeMode(existing.mode)) throw new HttpError(404, "file not found"); | |
| 316 | + if (!input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] }); | |
| 317 | + if (input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`); | |
| 318 | + const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, delete: true }], expectedHeadSha: head, author: input.author, committer: input.committer }, caller); | |
| 319 | + return this.repo.commitInfo(res.sha); | |
| 320 | + } | |
| 321 | + | |
| 322 | + /** The README in a directory (GET /readme). */ | |
| 323 | + async readme(rev: string, dir = ""): Promise<{ path: string; sha: string; size: number; content: Uint8Array; commit: string } | null> { | |
| 324 | + const t = await this.repo.tree(rev, dir); | |
| 325 | + if (!t) return null; | |
| 326 | + const candidates = t.items.filter((i) => i.type === "blob" && /^readme(\.[A-Za-z0-9]+)?$/i.test(i.name)); | |
| 327 | + const pick = candidates.find((i) => /\.(md|markdown)$/i.test(i.name)) ?? candidates[0]; | |
| 328 | + if (!pick) return null; | |
| 329 | + const b = await this.repo.blob(t.sha, pick.path); | |
| 330 | + return b ? { path: pick.path, sha: b.oid, size: b.size, content: b.content, commit: b.sha } : null; | |
| 331 | + } | |
| 332 | + | |
| 333 | + // ---------------------------------------------------------------- merges API | |
| 334 | + | |
| 335 | + /** Merge `head` into the `base` branch. Returns null when there is nothing to merge. */ | |
| 336 | + async merge(input: { base?: string; head?: string; commit_message?: string }, caller: Caller): Promise<CommitInfo | null> { | |
| 337 | + this.requireWrite(caller); | |
| 338 | + const base = String(input.base ?? ""); | |
| 339 | + const ref = `refs/heads/${base}`; | |
| 340 | + const baseSha = this.repo.getRef(ref); | |
| 341 | + if (!baseSha) throw new HttpError(404, "Base does not exist"); | |
| 342 | + const headSha = await this.repo.resolve(String(input.head ?? "")); | |
| 343 | + if (!headSha) throw new HttpError(404, "Head does not exist"); | |
| 344 | + if (await isAncestor(this.store, headSha, baseSha)) return null; | |
| 345 | + const mb = await mergeBase(this.store, baseSha, headSha); | |
| 346 | + const [bt, ht, mt] = await Promise.all([readCommit(this.store, baseSha), readCommit(this.store, headSha), mb ? readCommit(this.store, mb) : null]); | |
| 347 | + const merged = await mergeTrees(this.store, mt?.tree ?? null, bt.tree, ht.tree); | |
| 348 | + if (merged.conflicts.length || !merged.tree) throw new HttpError(409, "Merge conflict"); | |
| 349 | + const sig = this.signature(caller); | |
| 350 | + const message = input.commit_message || `Merge ${input.head} into ${base}`; | |
| 351 | + const sha = await this.store.write("commit", serializeCommit({ tree: merged.tree, parents: [baseSha, headSha], author: sig, committer: sig, message: message.endsWith("\n") ? message : `${message}\n`, extraHeaders: this.agentHeaders(caller) })); | |
| 352 | + const check = await this.repo.checkRefUpdate(ref, baseSha, sha, caller); | |
| 353 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 354 | + if (!this.repo.casRef(ref, baseSha, sha)) throw new HttpError(409, "base branch moved; try again"); | |
| 355 | + await this.repo.afterRefUpdate(ref, baseSha, sha, caller); | |
| 356 | + return this.repo.commitInfo(sha); | |
| 357 | + } | |
| 358 | + | |
| 359 | + // ---------------------------------------------------------------- languages and archives | |
| 360 | + | |
| 361 | + /** Bytes per language at the default branch, cached per commit. */ | |
| 362 | + async languages(): Promise<Record<string, number>> { | |
| 363 | + const sha = await this.repo.resolve("HEAD"); | |
| 364 | + if (!sha) return {}; | |
| 365 | + const cached = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'languages'").toArray()[0]; | |
| 366 | + if (cached) { | |
| 367 | + const c = JSON.parse(cached.value) as { sha: string; data: Record<string, number> }; | |
| 368 | + if (c.sha === sha) return c.data; | |
| 369 | + } | |
| 370 | + const out: Record<string, number> = {}; | |
| 371 | + try { | |
| 372 | + await this.walkTree((await readCommit(this.store, sha)).tree, "", (e, path) => { | |
| 373 | + if (isTreeMode(e.mode)) return !VENDORED.test(`${path}/`); | |
| 374 | + if (e.mode === "160000" || e.mode === "120000" || VENDORED.test(path)) return false; | |
| 375 | + const lang = LANGUAGE_FILES[e.name] ?? LANGUAGES[e.name.includes(".") ? e.name.split(".").pop()!.toLowerCase() : ""]; | |
| 376 | + if (!lang) return false; | |
| 377 | + const size = this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0]?.size ?? 0; | |
| 378 | + out[lang] = (out[lang] ?? 0) + size; | |
| 379 | + return false; | |
| 380 | + }); | |
| 381 | + } catch (e) { | |
| 382 | + // A huge (or hostile) tree: report what was counted before the budget ran out. | |
| 383 | + if (!(e instanceof HttpError && e.status === 413)) throw e; | |
| 384 | + } | |
| 385 | + const sorted = Object.fromEntries(Object.entries(out).sort((a, b) => b[1] - a[1])); | |
| 386 | + this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('languages', ?)", JSON.stringify({ sha, data: sorted })); | |
| 387 | + return sorted; | |
| 388 | + } | |
| 389 | + | |
| 390 | + archiveKey(sha: string, format: "zip" | "tar.gz"): string { | |
| 391 | + return `repos/${this.repo.meta.repoId}/archives/${sha}.${format}`; | |
| 392 | + } | |
| 393 | + | |
| 394 | + /** | |
| 395 | + * Visit every entry under a tree, depth first. `visit` returns whether to | |
| 396 | + * descend into a subtree. Every entry visited counts against one budget | |
| 397 | + * (a subtree reused a thousand times costs a thousand times), so a small | |
| 398 | + * tree of shared subtrees can't make a walk explode. | |
| 399 | + */ | |
| 400 | + private async walkTree(tree: string, prefix: string, visit: (e: TreeEntry, path: string) => boolean | Promise<boolean>, budget = { left: MAX_TREE_ENTRIES }, depth = 0): Promise<void> { | |
| 401 | + if (depth > MAX_TREE_DEPTH) throw new HttpError(413, "the tree is nested too deeply"); | |
| 402 | + for (const e of await readTree(this.store, tree)) { | |
| 403 | + if (--budget.left < 0) throw new HttpError(413, `the tree has more than ${MAX_TREE_ENTRIES.toLocaleString("en-US")} entries; clone it with git instead`); | |
| 404 | + const path = prefix ? `${prefix}/${e.name}` : e.name; | |
| 405 | + if ((await visit(e, path)) && isTreeMode(e.mode)) await this.walkTree(e.oid, path, visit, budget, depth + 1); | |
| 406 | + } | |
| 407 | + } | |
| 408 | + | |
| 409 | + /** Allow at most ARCHIVE_BUILDS_PER_HOUR fresh archive builds per repository. */ | |
| 410 | + private countArchiveBuild(): void { | |
| 411 | + const hourAgo = Date.now() - 3600_000; | |
| 412 | + const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'archive_builds'").toArray()[0]; | |
| 413 | + const recent = (row ? (JSON.parse(row.value) as number[]) : []).filter((t) => t > hourAgo); | |
| 414 | + if (recent.length >= ARCHIVE_BUILDS_PER_HOUR) throw new HttpError(429, "too many archives were built for this repository in the last hour; try again later or clone it with git"); | |
| 415 | + recent.push(Date.now()); | |
| 416 | + this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('archive_builds', ?)", JSON.stringify(recent)); | |
| 417 | + } | |
| 418 | + | |
| 419 | + /** | |
| 420 | + * Build a source archive of a commit into R2 (top directory `<owner>-<repo>-<sha7>/`, | |
| 421 | + * like GitHub). Entries stream through the compressor into R2, so memory stays | |
| 422 | + * around one file plus one upload part. Archives are a cache: R2 may evict them | |
| 423 | + * and they are rebuilt on the next download. | |
| 424 | + */ | |
| 425 | + async buildArchive(sha: string, format: "zip" | "tar.gz"): Promise<{ key: string; size: number }> { | |
| 426 | + const commit = await readCommit(this.store, sha); | |
| 427 | + this.countArchiveBuild(); | |
| 428 | + const m = this.repo.meta; | |
| 429 | + const prefix = `${m.owner}-${m.name}-${sha.slice(0, 7)}`; | |
| 430 | + const mtime = commit.committer.timestamp; | |
| 431 | + const key = this.archiveKey(sha, format); | |
| 432 | + const sink = new R2Sink(this.env.BUCKET, key, format === "zip" ? "application/zip" : "application/gzip", MAX_ARCHIVE_BYTES + 16 * 1024 * 1024, "repository is too large for an on-the-fly archive; clone it with git instead"); | |
| 433 | + let total = 0; | |
| 434 | + // Each entry, in tree order; unsafe names (and anything under them) are left out. | |
| 435 | + const entries = async (emit: (e: ArchiveEntry) => Promise<void>) => { | |
| 436 | + await emit({ path: `${prefix}/`, dir: true }); | |
| 437 | + await this.walkTree(commit.tree, prefix, async (e, path) => { | |
| 438 | + if (unsafeTreeName(e.name)) return false; | |
| 439 | + if (isTreeMode(e.mode)) { | |
| 440 | + await emit({ path: `${path}/`, dir: true }); | |
| 441 | + return true; | |
| 442 | + } | |
| 443 | + if (e.mode === "160000") { | |
| 444 | + await emit({ path: `${path}/`, dir: true }); | |
| 445 | + return false; | |
| 446 | + } | |
| 447 | + const o = await this.store.read(e.oid); | |
| 448 | + if (!o) return false; | |
| 449 | + total += o.content.length; | |
| 450 | + if (total > MAX_ARCHIVE_BYTES) throw new HttpError(413, "repository is too large for an on-the-fly archive; clone it with git instead"); | |
| 451 | + await emit({ path, mode: e.mode, content: o.content }); | |
| 452 | + return false; | |
| 453 | + }); | |
| 454 | + }; | |
| 455 | + let writer: WritableStreamDefaultWriter<Uint8Array> | null = null; | |
| 456 | + try { | |
| 457 | + if (format === "zip") { | |
| 458 | + const zw = new ZipWriter(mtime); | |
| 459 | + await entries(async (e) => { | |
| 460 | + let parts: Uint8Array[]; | |
| 461 | + try { | |
| 462 | + parts = await zw.entry(e); | |
| 463 | + } catch (err) { | |
| 464 | + if (err instanceof RangeError) throw new HttpError(413, "this repository has too many files for a zip archive (more than 65,535); download the .tar.gz archive or clone it with git"); | |
| 465 | + throw err; | |
| 466 | + } | |
| 467 | + for (const part of parts) await sink.write(part); | |
| 468 | + }); | |
| 469 | + for (const part of zw.end(sha)) await sink.write(part); | |
| 470 | + } else { | |
| 471 | + const gz = new CompressionStream("gzip"); | |
| 472 | + const w = gz.writable.getWriter(); | |
| 473 | + writer = w; | |
| 474 | + const reader = gz.readable.getReader(); | |
| 475 | + const pump = (async () => { | |
| 476 | + for (;;) { | |
| 477 | + const { done, value } = await reader.read(); | |
| 478 | + if (done) return; | |
| 479 | + await sink.write(value); | |
| 480 | + } | |
| 481 | + })(); | |
| 482 | + // If R2 or the size limit stops the pump, fail the writes instead of waiting on backpressure. | |
| 483 | + const stopped = pump.catch(async (err) => { | |
| 484 | + await w.abort(err).catch(() => undefined); | |
| 485 | + throw err; | |
| 486 | + }); | |
| 487 | + const put = async (parts: Uint8Array[]) => { | |
| 488 | + for (const part of parts) if (part.length) await w.write(part); | |
| 489 | + }; | |
| 490 | + await put(tarStart(mtime, sha)); | |
| 491 | + await entries((e) => put(tarEntry(e, mtime))); | |
| 492 | + await put([tarEnd()]); | |
| 493 | + await w.close(); | |
| 494 | + await stopped; | |
| 495 | + } | |
| 496 | + return { key, size: await sink.close() }; | |
| 497 | + } catch (e) { | |
| 498 | + await writer?.abort(e).catch(() => undefined); | |
| 499 | + await sink.abort(); | |
| 500 | + throw e; | |
| 501 | + } | |
| 502 | + } | |
| 503 | + | |
| 504 | + // ---------------------------------------------------------------- Actions extras | |
| 505 | + | |
| 506 | + /** A run's logs as a zip with one text file per job (GET /actions/runs/:id/logs). */ | |
| 507 | + async runLogsZip(runId: string): Promise<Uint8Array> { | |
| 508 | + const run = this.repo.actions.getRun(runId); | |
| 509 | + const entries: ArchiveEntry[] = []; | |
| 510 | + let total = 0; | |
| 511 | + for (const [i, j] of run.jobs.entries()) { | |
| 512 | + const { lines } = await this.repo.actions.logs(j.id); | |
| 513 | + const text = new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : "")); | |
| 514 | + total += text.length; | |
| 515 | + if (total > 24 * 1024 * 1024) break; | |
| 516 | + entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: text }); | |
| 517 | + } | |
| 518 | + return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000)); | |
| 519 | + } | |
| 520 | + | |
| 521 | + /** The run id of attempt `n` of a run (re-runs are stored as new runs with the same number). */ | |
| 522 | + runAttemptId(runId: string, attempt: number): string { | |
| 523 | + const r = this.repo.actions.getRun(runId); | |
| 524 | + const row = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE workflow_path = ? AND run_number = ? AND attempt = ? ORDER BY created_at DESC LIMIT 1", r.workflowPath, r.number, attempt).toArray()[0]; | |
| 525 | + if (!row) throw new HttpError(404, "run attempt not found"); | |
| 526 | + return row.id; | |
| 527 | + } | |
| 528 | + | |
| 529 | + // ---------------------------------------------------------------- check runs and suites | |
| 530 | + | |
| 531 | + /** A check run by GitHub's numeric id or Diffinery's own. */ | |
| 532 | + check(raw: string): Check { | |
| 533 | + const row = /^\d{1,15}$/.test(raw) | |
| 534 | + ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0] | |
| 535 | + : this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0]; | |
| 536 | + const c = row ? this.repo.collab.checks(row.sha).find((x) => x.id === row.id) : undefined; | |
| 537 | + if (!c) throw new HttpError(404, "check run not found"); | |
| 538 | + return c; | |
| 539 | + } | |
| 540 | + | |
| 541 | + /** PATCH /check-runs/:id: status, conclusion, output (annotations accumulate, like GitHub). */ | |
| 542 | + updateCheck( | |
| 543 | + id: string, | |
| 544 | + input: { | |
| 545 | + name?: string; | |
| 546 | + details_url?: string; | |
| 547 | + external_id?: string; | |
| 548 | + status?: string; | |
| 549 | + conclusion?: string; | |
| 550 | + started_at?: string; | |
| 551 | + completed_at?: string; | |
| 552 | + output?: { title?: string; summary?: string; text?: string; annotations?: unknown[] }; | |
| 553 | + }, | |
| 554 | + caller: Caller, | |
| 555 | + ): Check { | |
| 556 | + this.requireWrite(caller); | |
| 557 | + const c = this.check(id); | |
| 558 | + if (c.app === ACTIONS_APP) throw new HttpError(403, "check runs from Diffinery Actions can't be changed over the API"); | |
| 559 | + const own = c.creatorId ? c.creatorId === caller.id : c.app.toLowerCase() === caller.login.toLowerCase(); | |
| 560 | + if (!own) throw new HttpError(403, "only the app that created a check run can update it"); | |
| 561 | + if (input.status && !["queued", "in_progress", "completed", "waiting", "requested", "pending"].includes(input.status)) throw new HttpError(422, "invalid status", { errors: [{ resource: "CheckRun", code: "invalid", field: "status" }] }); | |
| 562 | + const conclusions = ["action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"]; | |
| 563 | + if (input.conclusion && !conclusions.includes(input.conclusion)) throw new HttpError(422, "invalid conclusion", { errors: [{ resource: "CheckRun", code: "invalid", field: "conclusion" }] }); | |
| 564 | + if (input.details_url && !/^https?:\/\//i.test(input.details_url)) throw new HttpError(422, "details_url must be an http(s) URL"); | |
| 565 | + const status = input.conclusion ? "completed" : (input.status ?? c.status); | |
| 566 | + if (status === "completed" && !(input.conclusion ?? c.conclusion)) throw new HttpError(422, "conclusion is required when status is completed", { errors: [{ resource: "CheckRun", code: "missing_field", field: "conclusion" }] }); | |
| 567 | + const output = { ...(c.output ?? {}) } as Record<string, unknown>; | |
| 568 | + if (input.output) { | |
| 569 | + for (const k of ["title", "summary", "text"] as const) if (input.output[k] !== undefined) output[k] = String(input.output[k]).slice(0, 65535); | |
| 570 | + if (Array.isArray(input.output.annotations)) { | |
| 571 | + const all = [...((output.annotations as unknown[]) ?? []), ...input.output.annotations.slice(0, 50)]; | |
| 572 | + output.annotations = all.slice(0, 1000); | |
| 573 | + } | |
| 574 | + } | |
| 575 | + if (input.external_id !== undefined) output.external_id = String(input.external_id); | |
| 576 | + const t = now(); | |
| 577 | + this.sql.exec( | |
| 578 | + "UPDATE checks SET name = ?, status = ?, conclusion = ?, details_url = ?, output = ?, started_at = ?, completed_at = ? WHERE id = ?", | |
| 579 | + input.name ?? c.name, | |
| 580 | + status, | |
| 581 | + status === "completed" ? (input.conclusion ?? c.conclusion) : null, | |
| 582 | + input.details_url ?? c.detailsUrl, | |
| 583 | + JSON.stringify(output), | |
| 584 | + input.started_at ?? c.startedAt ?? (status !== "queued" ? t : null), | |
| 585 | + status === "completed" ? (input.completed_at ?? c.completedAt ?? t) : null, | |
| 586 | + c.id, | |
| 587 | + ); | |
| 588 | + for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", c.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" }); | |
| 589 | + return this.check(c.id); | |
| 590 | + } | |
| 591 | + | |
| 592 | + /** Check suites, synthesised: one per workflow run, or per app for external check runs. */ | |
| 593 | + checkSuites(sha: string): Array<{ id: string; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> { | |
| 594 | + const groups = new Map<string, Check[]>(); | |
| 595 | + for (const c of this.repo.collab.checks(sha)) { | |
| 596 | + const id = c.runId ?? `app-${c.app}-${sha}`; | |
| 597 | + groups.set(id, [...(groups.get(id) ?? []), c]); | |
| 598 | + } | |
| 599 | + return [...groups].map(([id, runs]) => { | |
| 600 | + const done = runs.every((r) => r.status === "completed"); | |
| 601 | + const failed = runs.some((r) => ["failure", "timed_out", "cancelled", "action_required"].includes(r.conclusion ?? "")); | |
| 602 | + return { id, app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs }; | |
| 603 | + }); | |
| 604 | + } | |
| 605 | + | |
| 606 | + checkSuite(id: string): ReturnType<GitData["checkSuites"]>[number] { | |
| 607 | + const row = id.startsWith("app-") | |
| 608 | + ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0] | |
| 609 | + : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0]; | |
| 610 | + const suite = row ? this.checkSuites(row.sha).find((s) => s.id === id) : undefined; | |
| 611 | + if (!suite) throw new HttpError(404, "check suite not found"); | |
| 612 | + return suite; | |
| 613 | + } | |
| 614 | +} |
modifiedapps/diffinery/src/repo/queue.ts+13−13View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { ZERO_OID, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core"; |
| 2 | +import { requiredCheck } from "./required.js"; | |
| 2 | 3 | import type { Actor } from "@diffinery/shared"; |
| 3 | 4 | import { HttpError, now } from "../lib/util.js"; |
| 4 | 5 | import type { RepoDO } from "./RepoDO.js"; |
| @@ -115,8 +116,9 @@ | ||
| 115 | 116 | const pos = (position.get(r.base_ref) ?? 0) + 1; |
| 116 | 117 | position.set(r.base_ref, pos); |
| 117 | 118 | const title = this.sql.exec<{ title: string }>("SELECT title FROM issues WHERE number = ?", r.number).toArray()[0]?.title ?? ""; |
| 118 | - const required = this.repo.protectionFor(r.base_ref)?.requiredChecks ?? []; | |
| 119 | - const checks = r.group_sha ? this.repo.collab.checks(r.group_sha) : []; | |
| 119 | + const rules = this.repo.protectionFor(r.base_ref); | |
| 120 | + const required = rules?.requiredChecks ?? []; | |
| 121 | + const checks = r.group_sha ? this.repo.checksAndStatuses(r.group_sha) : []; | |
| 120 | 122 | out.push({ |
| 121 | 123 | number: r.number, |
| 122 | 124 | title, |
| @@ -132,9 +134,9 @@ | ||
| 132 | 134 | groupSha: r.group_sha, |
| 133 | 135 | parentSha: r.parent_sha, |
| 134 | 136 | builtAt: r.built_at, |
| 135 | - checks: (required.length ? required : checks.map((c) => c.name)).map((name) => { | |
| 136 | - const c = checks.find((x) => x.name === name); | |
| 137 | - return { name, state: !c ? "expected" : c.status === "completed" ? (c.conclusion ?? "neutral") : c.status }; | |
| 137 | + checks: (required.length ? required : [...new Set(checks.map((c) => c.name))]).map((name) => { | |
| 138 | + const c = requiredCheck(name, checks, rules?.requiredCheckSources?.[name]); | |
| 139 | + return { name, state: c.state === "missing" ? "expected" : c.state === "pending" ? (c.status ?? "queued") : (c.conclusion ?? "neutral") }; | |
| 138 | 140 | }), |
| 139 | 141 | }); |
| 140 | 142 | } |
| @@ -289,19 +291,17 @@ | ||
| 289 | 291 | |
| 290 | 292 | /** "pass", "pending", or why the group failed. */ |
| 291 | 293 | private verdict(r: QueueRow, base: string, settings: MergeQueueSettings): string { |
| 292 | - const required = this.repo.protectionFor(base)?.requiredChecks ?? []; | |
| 293 | - const checks = this.repo.collab.checks(r.group_sha!); | |
| 294 | + const rules = this.repo.protectionFor(base); | |
| 295 | + const required = rules?.requiredChecks ?? []; | |
| 296 | + const checks = this.repo.checksAndStatuses(r.group_sha!); | |
| 294 | 297 | for (const name of required) { |
| 295 | - const c = checks.find((x) => x.name === name); | |
| 296 | - if (c?.status === "completed" && !OK.has(c.conclusion ?? "")) { | |
| 298 | + const c = requiredCheck(name, checks, rules?.requiredCheckSources?.[name]); | |
| 299 | + if (c.state === "failure") { | |
| 297 | 300 | const what = c.conclusion === "timed_out" ? "timed out" : c.conclusion === "cancelled" ? "was cancelled" : c.conclusion === "action_required" ? "needs action" : "failed"; |
| 298 | 301 | return `the required check '${name}' ${what} on its merge group`; |
| 299 | 302 | } |
| 300 | 303 | } |
| 301 | - const pending = required.filter((name) => { | |
| 302 | - const c = checks.find((x) => x.name === name); | |
| 303 | - return !c || c.status !== "completed"; | |
| 304 | - }); | |
| 304 | + const pending = required.filter((name) => requiredCheck(name, checks, rules?.requiredCheckSources?.[name]).state !== "success"); | |
| 305 | 305 | if (!pending.length) return "pass"; |
| 306 | 306 | const timeout = (settings.checkTimeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES) * 60_000; |
| 307 | 307 | if (r.built_at && Date.parse(r.built_at) + timeout <= Date.now()) { |
addedapps/diffinery/src/repo/release-notes.ts+82−0View file
| @@ -1,0 +1,82 @@ | ||
| 1 | +/** | |
| 2 | + * Release notes like GitHub's "Generate release notes": merged pull requests | |
| 3 | + * between two tags, grouped by the categories in `.github/release.yml`, then | |
| 4 | + * first-time contributors and a full changelog link. Pure, so it is unit-tested. | |
| 5 | + * https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes | |
| 6 | + */ | |
| 7 | +import { parse } from "yaml"; | |
| 8 | + | |
| 9 | +export interface NotesPull { | |
| 10 | + number: number; | |
| 11 | + title: string; | |
| 12 | + author: string; | |
| 13 | + labels: string[]; | |
| 14 | + url: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export interface NotesCategory { | |
| 18 | + title: string; | |
| 19 | + labels: string[]; | |
| 20 | + exclude?: { labels?: string[]; authors?: string[] }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export interface NotesConfig { | |
| 24 | + exclude?: { labels?: string[]; authors?: string[] }; | |
| 25 | + categories?: NotesCategory[]; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : typeof v === "string" ? [v] : undefined); | |
| 29 | + | |
| 30 | +/** Parse `.github/release.yml`; returns an empty config for anything malformed. */ | |
| 31 | +export function parseReleaseConfig(text: string): NotesConfig { | |
| 32 | + let doc: unknown; | |
| 33 | + try { | |
| 34 | + doc = parse(text); | |
| 35 | + } catch { | |
| 36 | + return {}; | |
| 37 | + } | |
| 38 | + const changelog = (doc as { changelog?: Record<string, unknown> } | null)?.changelog; | |
| 39 | + if (!changelog || typeof changelog !== "object") return {}; | |
| 40 | + const ex = changelog.exclude as Record<string, unknown> | undefined; | |
| 41 | + const out: NotesConfig = {}; | |
| 42 | + if (ex && typeof ex === "object") out.exclude = { labels: strings(ex.labels), authors: strings(ex.authors) }; | |
| 43 | + if (Array.isArray(changelog.categories)) { | |
| 44 | + out.categories = []; | |
| 45 | + for (const raw of changelog.categories as Array<Record<string, unknown>>) { | |
| 46 | + if (!raw || typeof raw !== "object" || typeof raw.title !== "string") continue; | |
| 47 | + const cex = raw.exclude as Record<string, unknown> | undefined; | |
| 48 | + out.categories.push({ title: raw.title, labels: strings(raw.labels) ?? [], exclude: cex && typeof cex === "object" ? { labels: strings(cex.labels), authors: strings(cex.authors) } : undefined }); | |
| 49 | + } | |
| 50 | + } | |
| 51 | + return out; | |
| 52 | +} | |
| 53 | + | |
| 54 | +const hasAny = (labels: string[], wanted: string[] | undefined) => !!wanted?.length && (wanted.includes("*") || labels.some((l) => wanted.includes(l))); | |
| 55 | +const byAuthor = (author: string, authors: string[] | undefined) => !!authors?.some((a) => a === "*" || a.toLowerCase() === author.toLowerCase()); | |
| 56 | + | |
| 57 | +const line = (p: NotesPull) => `* ${p.title} by @${p.author} in ${p.url}`; | |
| 58 | + | |
| 59 | +export function renderReleaseNotes(input: { pulls: NotesPull[]; newContributors: NotesPull[]; changelogUrl: string; config?: NotesConfig }): string { | |
| 60 | + const cfg = input.config ?? {}; | |
| 61 | + const pulls = input.pulls.filter((p) => !hasAny(p.labels, cfg.exclude?.labels) && !byAuthor(p.author, cfg.exclude?.authors)); | |
| 62 | + const parts: string[] = []; | |
| 63 | + if (pulls.length) { | |
| 64 | + const sections: string[] = []; | |
| 65 | + if (cfg.categories?.length) { | |
| 66 | + const placed = new Set<number>(); | |
| 67 | + for (const cat of cfg.categories) { | |
| 68 | + const items = pulls.filter((p) => !placed.has(p.number) && hasAny(p.labels, cat.labels) && !hasAny(p.labels, cat.exclude?.labels) && !byAuthor(p.author, cat.exclude?.authors)); | |
| 69 | + for (const p of items) placed.add(p.number); | |
| 70 | + if (items.length) sections.push(`### ${cat.title}\n${items.map(line).join("\n")}`); | |
| 71 | + } | |
| 72 | + const other = pulls.filter((p) => !placed.has(p.number)); | |
| 73 | + if (other.length) sections.push(`### Other Changes\n${other.map(line).join("\n")}`); | |
| 74 | + } else sections.push(pulls.map(line).join("\n")); | |
| 75 | + parts.push(`## What's Changed\n${sections.join("\n\n")}`); | |
| 76 | + } | |
| 77 | + if (input.newContributors.length) { | |
| 78 | + parts.push(`## New Contributors\n${input.newContributors.map((p) => `* @${p.author} made their first contribution in ${p.url}`).join("\n")}`); | |
| 79 | + } | |
| 80 | + parts.push(`**Full Changelog**: ${input.changelogUrl}`); | |
| 81 | + return parts.join("\n\n"); | |
| 82 | +} |
addedapps/diffinery/src/repo/releases.ts+713−0View file
| @@ -1,0 +1,713 @@ | ||
| 1 | +import { ZERO_OID, fromUtf8, isAncestor, peelToCommit, walkCommits } from "@diffinery/git-core"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { ghRelease, nodeId, numericId } from "../http/gh.js"; | |
| 4 | +import { importHostAllowed, withoutCredentials } from "../lib/import-hosts.js"; | |
| 5 | +import { R2Sink } from "../lib/r2-sink.js"; | |
| 6 | +import { HttpError, isValidRefName, now } from "../lib/util.js"; | |
| 7 | +import { triggerWorkflows } from "./extra-triggers.js"; | |
| 8 | +import { parseReleaseConfig, renderReleaseNotes, type NotesPull } from "./release-notes.js"; | |
| 9 | +import type { RepoDO } from "./RepoDO.js"; | |
| 10 | +import { atLeast, type Caller } from "./types.js"; | |
| 11 | + | |
| 12 | +type ReleaseRow = { | |
| 13 | + id: number; | |
| 14 | + tag_name: string; | |
| 15 | + target_commitish: string; | |
| 16 | + name: string; | |
| 17 | + body: string; | |
| 18 | + draft: number; | |
| 19 | + prerelease: number; | |
| 20 | + author_id: string; | |
| 21 | + author_login: string; | |
| 22 | + author_kind: string; | |
| 23 | + created_at: string; | |
| 24 | + published_at: string | null; | |
| 25 | + updated_at: string; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +type AssetRow = { | |
| 29 | + id: number; | |
| 30 | + release_id: number; | |
| 31 | + name: string; | |
| 32 | + label: string; | |
| 33 | + content_type: string; | |
| 34 | + size: number; | |
| 35 | + state: string; | |
| 36 | + download_count: number; | |
| 37 | + uploader_id: string | null; | |
| 38 | + uploader_login: string | null; | |
| 39 | + uploader_kind: string | null; | |
| 40 | + r2_key: string; | |
| 41 | + created_at: string; | |
| 42 | + updated_at: string; | |
| 43 | +}; | |
| 44 | + | |
| 45 | +export type ReleaseAsset = { | |
| 46 | + id: number; | |
| 47 | + releaseId: number; | |
| 48 | + name: string; | |
| 49 | + label: string; | |
| 50 | + contentType: string; | |
| 51 | + size: number; | |
| 52 | + state: string; | |
| 53 | + downloadCount: number; | |
| 54 | + uploader: { id: string; login: string; kind: string } | null; | |
| 55 | + createdAt: string; | |
| 56 | + updatedAt: string; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +export type Release = { | |
| 60 | + id: number; | |
| 61 | + tagName: string; | |
| 62 | + targetCommitish: string; | |
| 63 | + name: string; | |
| 64 | + body: string; | |
| 65 | + draft: boolean; | |
| 66 | + prerelease: boolean; | |
| 67 | + author: { id: string; login: string; kind: string }; | |
| 68 | + createdAt: string; | |
| 69 | + publishedAt: string | null; | |
| 70 | + updatedAt: string; | |
| 71 | + /** Whether this is the repository's latest release. */ | |
| 72 | + latest: boolean; | |
| 73 | + assets: ReleaseAsset[]; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +export type MakeLatest = "true" | "false" | "legacy"; | |
| 77 | + | |
| 78 | +export type ReleaseInput = { | |
| 79 | + tag_name?: string; | |
| 80 | + target_commitish?: string; | |
| 81 | + name?: string | null; | |
| 82 | + body?: string | null; | |
| 83 | + draft?: boolean; | |
| 84 | + prerelease?: boolean; | |
| 85 | + make_latest?: MakeLatest | boolean; | |
| 86 | + generate_release_notes?: boolean; | |
| 87 | +}; | |
| 88 | + | |
| 89 | +/** What the importer knows about a release on the source forge. */ | |
| 90 | +export type ImportedRelease = { | |
| 91 | + tag_name: string; | |
| 92 | + target_commitish?: string; | |
| 93 | + name?: string | null; | |
| 94 | + body?: string | null; | |
| 95 | + draft?: boolean; | |
| 96 | + prerelease?: boolean; | |
| 97 | + created_at: string; | |
| 98 | + published_at?: string | null; | |
| 99 | + author: { id?: string; login: string; kind?: string }; | |
| 100 | + /** Mark as the repository's latest release (GitHub's `make_latest`). */ | |
| 101 | + latest?: boolean; | |
| 102 | +}; | |
| 103 | + | |
| 104 | +export type ImportedAsset = { | |
| 105 | + name: string; | |
| 106 | + label?: string; | |
| 107 | + content_type?: string; | |
| 108 | + download_count?: number; | |
| 109 | + created_at?: string; | |
| 110 | + updated_at?: string; | |
| 111 | + uploader?: { id?: string; login: string; kind?: string }; | |
| 112 | +}; | |
| 113 | + | |
| 114 | +const MAX_IMPORT_BYTES = 2 * 1024 ** 3; | |
| 115 | + | |
| 116 | +/** GitHub renames asset files with unusual characters; do the same. */ | |
| 117 | +export function assetName(raw: string): string { | |
| 118 | + const name = raw | |
| 119 | + .split(/[\\/]/) | |
| 120 | + .pop()! | |
| 121 | + .replace(/[^A-Za-z0-9._+@~-]+/g, ".") | |
| 122 | + .replace(/^\.+|\.+$/g, ""); | |
| 123 | + if (!name || name.length > 255) throw new HttpError(422, "asset name must be 1 to 255 characters", { errors: [{ resource: "ReleaseAsset", code: "invalid", field: "name" }] }); | |
| 124 | + return name; | |
| 125 | +} | |
| 126 | + | |
| 127 | +const toAsset = (a: AssetRow): ReleaseAsset => ({ | |
| 128 | + id: a.id, | |
| 129 | + releaseId: a.release_id, | |
| 130 | + name: a.name, | |
| 131 | + label: a.label, | |
| 132 | + contentType: a.content_type, | |
| 133 | + size: a.size, | |
| 134 | + state: a.state, | |
| 135 | + downloadCount: a.download_count, | |
| 136 | + uploader: a.uploader_id ? { id: a.uploader_id, login: a.uploader_login ?? "", kind: a.uploader_kind ?? "user" } : null, | |
| 137 | + createdAt: a.created_at, | |
| 138 | + updatedAt: a.updated_at, | |
| 139 | +}); | |
| 140 | + | |
| 141 | +/** Blocks fetches to loopback, private and link-local hosts (the importer's asset URLs come from users). */ | |
| 142 | +export function isPublicHttpsUrl(raw: string): boolean { | |
| 143 | + let u: URL; | |
| 144 | + try { | |
| 145 | + u = new URL(raw); | |
| 146 | + } catch { | |
| 147 | + return false; | |
| 148 | + } | |
| 149 | + if (u.protocol !== "https:" || u.username || u.password) return false; | |
| 150 | + const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 151 | + if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local") || (!h.includes(".") && !h.includes(":"))) return false; | |
| 152 | + if (/^(127\.|10\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(h)) return false; | |
| 153 | + if (h.includes(":") && (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80") || h.startsWith("::ffff:"))) return false; | |
| 154 | + return true; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** | |
| 158 | + * GitHub-compatible releases: rows in the repository's SQLite, asset bytes in | |
| 159 | + * R2 under `repos/<id>/releases/`. Drafts are visible only to writers, and | |
| 160 | + * publishing creates the tag from `target_commitish` when it doesn't exist. | |
| 161 | + */ | |
| 162 | +export class Releases { | |
| 163 | + constructor( | |
| 164 | + private repo: RepoDO, | |
| 165 | + private env: Env, | |
| 166 | + ) {} | |
| 167 | + | |
| 168 | + private get sql() { | |
| 169 | + return this.repo.sql; | |
| 170 | + } | |
| 171 | + | |
| 172 | + private row(id: number): ReleaseRow { | |
| 173 | + const r = this.sql.exec<ReleaseRow>("SELECT * FROM releases WHERE id = ?", id).toArray()[0]; | |
| 174 | + if (!r) throw new HttpError(404, "release not found"); | |
| 175 | + return r; | |
| 176 | + } | |
| 177 | + | |
| 178 | + private visibleRow(id: number, caller: Caller): ReleaseRow { | |
| 179 | + const r = this.row(id); | |
| 180 | + if (r.draft && !atLeast(caller.role, "write")) throw new HttpError(404, "release not found"); | |
| 181 | + return r; | |
| 182 | + } | |
| 183 | + | |
| 184 | + private assetsOf(releaseId: number): ReleaseAsset[] { | |
| 185 | + return this.sql.exec<AssetRow>("SELECT * FROM release_assets WHERE release_id = ? AND state = 'uploaded' ORDER BY id", releaseId).toArray().map(toAsset); | |
| 186 | + } | |
| 187 | + | |
| 188 | + private toRelease(r: ReleaseRow, latestId = this.latestId()): Release { | |
| 189 | + return { | |
| 190 | + id: r.id, | |
| 191 | + tagName: r.tag_name, | |
| 192 | + targetCommitish: r.target_commitish, | |
| 193 | + name: r.name, | |
| 194 | + body: r.body, | |
| 195 | + draft: !!r.draft, | |
| 196 | + prerelease: !!r.prerelease, | |
| 197 | + author: { id: r.author_id, login: r.author_login, kind: r.author_kind }, | |
| 198 | + createdAt: r.created_at, | |
| 199 | + publishedAt: r.published_at, | |
| 200 | + updatedAt: r.updated_at, | |
| 201 | + latest: r.id === latestId, | |
| 202 | + assets: this.assetsOf(r.id), | |
| 203 | + }; | |
| 204 | + } | |
| 205 | + | |
| 206 | + // ---------------------------------------------------------------- latest | |
| 207 | + | |
| 208 | + private pinned(): number | null { | |
| 209 | + const v = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'latest_release'").toArray()[0]?.value; | |
| 210 | + return v ? Number(v) : null; | |
| 211 | + } | |
| 212 | + | |
| 213 | + private pin(id: number | null): void { | |
| 214 | + if (id === null) this.sql.exec("DELETE FROM meta WHERE key = 'latest_release'"); | |
| 215 | + else this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('latest_release', ?)", String(id)); | |
| 216 | + } | |
| 217 | + | |
| 218 | + /** The release marked latest, else the newest published full release (GitHub's rule). */ | |
| 219 | + latestId(): number | null { | |
| 220 | + const p = this.pinned(); | |
| 221 | + if (p !== null && this.sql.exec("SELECT 1 FROM releases WHERE id = ? AND draft = 0 AND prerelease = 0", p).toArray().length) return p; | |
| 222 | + return this.sql.exec<{ id: number }>("SELECT id FROM releases WHERE draft = 0 AND prerelease = 0 ORDER BY created_at DESC, id DESC LIMIT 1").toArray()[0]?.id ?? null; | |
| 223 | + } | |
| 224 | + | |
| 225 | + /** Apply `make_latest` after a release was created or changed. */ | |
| 226 | + private applyLatest(r: ReleaseRow, make: MakeLatest | undefined, previousLatest: number | null): void { | |
| 227 | + const eligible = !r.draft && !r.prerelease; | |
| 228 | + if (make === "legacy") return this.pin(null); | |
| 229 | + if (make === "false") { | |
| 230 | + if (this.pinned() === r.id) this.pin(null); | |
| 231 | + // Keep whatever was latest before, rather than letting this newer release win by date. | |
| 232 | + else if (this.pinned() === null && previousLatest !== null && previousLatest !== r.id) this.pin(previousLatest); | |
| 233 | + return; | |
| 234 | + } | |
| 235 | + if (make === "true" && eligible) this.pin(r.id); | |
| 236 | + } | |
| 237 | + | |
| 238 | + // ---------------------------------------------------------------- reading | |
| 239 | + | |
| 240 | + list(opts: { limit: number; offset: number }, caller: Caller): { items: Release[]; total: number } { | |
| 241 | + const drafts = atLeast(caller.role, "write") ? "" : "WHERE draft = 0"; | |
| 242 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM releases ${drafts}`).one().n; | |
| 243 | + const latest = this.latestId(); | |
| 244 | + const rows = this.sql.exec<ReleaseRow>(`SELECT * FROM releases ${drafts} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, Math.min(opts.limit, 100), opts.offset).toArray(); | |
| 245 | + return { items: rows.map((r) => this.toRelease(r, latest)), total }; | |
| 246 | + } | |
| 247 | + | |
| 248 | + get(id: number, caller: Caller): Release { | |
| 249 | + return this.toRelease(this.visibleRow(id, caller)); | |
| 250 | + } | |
| 251 | + | |
| 252 | + /** A published release by tag; drafts only when `includeDrafts` and the caller can write (web UI). */ | |
| 253 | + byTag(tag: string, caller: Caller, includeDrafts = false): Release { | |
| 254 | + const drafts = includeDrafts && atLeast(caller.role, "write"); | |
| 255 | + const r = this.sql.exec<ReleaseRow>(`SELECT * FROM releases WHERE tag_name = ? ${drafts ? "" : "AND draft = 0"} ORDER BY draft, created_at DESC LIMIT 1`, tag).toArray()[0]; | |
| 256 | + if (!r) throw new HttpError(404, "release not found"); | |
| 257 | + return this.toRelease(r); | |
| 258 | + } | |
| 259 | + | |
| 260 | + latest(): Release { | |
| 261 | + const id = this.latestId(); | |
| 262 | + if (id === null) throw new HttpError(404, "no published releases"); | |
| 263 | + return this.toRelease(this.row(id), id); | |
| 264 | + } | |
| 265 | + | |
| 266 | + /** Release count and the latest release, for the repository home page. */ | |
| 267 | + summary(caller: Caller): { count: number; latest: Release | null } { | |
| 268 | + const drafts = atLeast(caller.role, "write") ? "" : "WHERE draft = 0"; | |
| 269 | + const count = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM releases ${drafts}`).one().n; | |
| 270 | + const id = this.latestId(); | |
| 271 | + return { count, latest: id === null ? null : this.toRelease(this.row(id), id) }; | |
| 272 | + } | |
| 273 | + | |
| 274 | + // ---------------------------------------------------------------- writing | |
| 275 | + | |
| 276 | + private requireWrite(caller: Caller): void { | |
| 277 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to manage releases"); | |
| 278 | + } | |
| 279 | + | |
| 280 | + /** Make sure `refs/tags/<tag>` exists, creating it at `target` (a branch, tag or SHA) like GitHub does on publish. */ | |
| 281 | + private async ensureTag(tag: string, target: string, caller: Caller): Promise<string> { | |
| 282 | + const ref = `refs/tags/${tag}`; | |
| 283 | + const existing = this.repo.getRef(ref); | |
| 284 | + if (existing) return peelToCommit(this.repo.store, existing); | |
| 285 | + if (!isValidRefName(ref)) throw new HttpError(422, `'${tag}' is not a valid tag name`, { errors: [{ resource: "Release", code: "invalid", field: "tag_name" }] }); | |
| 286 | + const sha = await this.repo.resolve(target); | |
| 287 | + if (!sha) throw new HttpError(422, `target_commitish '${target}' does not exist`, { errors: [{ resource: "Release", code: "invalid", field: "target_commitish" }] }); | |
| 288 | + const check = await this.repo.checkRefUpdate(ref, ZERO_OID, sha, caller); | |
| 289 | + if (!check.ok) throw new HttpError(403, check.reason ?? "tag creation rejected"); | |
| 290 | + if (!this.repo.casRef(ref, ZERO_OID, sha)) throw new HttpError(409, "tag was created concurrently; retry"); | |
| 291 | + await this.repo.afterRefUpdate(ref, ZERO_OID, sha, caller); | |
| 292 | + return sha; | |
| 293 | + } | |
| 294 | + | |
| 295 | + async create(input: ReleaseInput, caller: Caller): Promise<Release> { | |
| 296 | + this.requireWrite(caller); | |
| 297 | + const tag = (input.tag_name ?? "").trim(); | |
| 298 | + if (!tag) throw new HttpError(422, "tag_name is required", { errors: [{ resource: "Release", code: "missing_field", field: "tag_name" }] }); | |
| 299 | + const draft = !!input.draft; | |
| 300 | + if (!draft && this.sql.exec("SELECT 1 FROM releases WHERE tag_name = ? AND draft = 0", tag).toArray().length) { | |
| 301 | + throw new HttpError(422, "a release for this tag already exists", { errors: [{ resource: "Release", code: "already_exists", field: "tag_name" }] }); | |
| 302 | + } | |
| 303 | + const target = input.target_commitish?.trim() || this.repo.meta.defaultBranch; | |
| 304 | + if (!draft) await this.ensureTag(tag, target, caller); | |
| 305 | + else if (!this.repo.getRef(`refs/tags/${tag}`) && !(await this.repo.resolve(target))) throw new HttpError(422, `target_commitish '${target}' does not exist`); | |
| 306 | + let body = input.body ?? ""; | |
| 307 | + let name = input.name ?? ""; | |
| 308 | + if (input.generate_release_notes) { | |
| 309 | + const notes = await this.generateNotes({ tag_name: tag, target_commitish: target }); | |
| 310 | + body = body ? `${body}\n\n${notes.body}` : notes.body; | |
| 311 | + name ||= notes.name; | |
| 312 | + } | |
| 313 | + const before = this.latestId(); | |
| 314 | + const t = now(); | |
| 315 | + this.sql.exec( | |
| 316 | + "INSERT INTO releases (tag_name, target_commitish, name, body, draft, prerelease, author_id, author_login, author_kind, created_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 317 | + tag, | |
| 318 | + target, | |
| 319 | + name, | |
| 320 | + body, | |
| 321 | + draft ? 1 : 0, | |
| 322 | + input.prerelease ? 1 : 0, | |
| 323 | + caller.id, | |
| 324 | + caller.login, | |
| 325 | + caller.kind, | |
| 326 | + t, | |
| 327 | + draft ? null : t, | |
| 328 | + t, | |
| 329 | + ); | |
| 330 | + const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 331 | + const row = this.row(id); | |
| 332 | + this.applyLatest(row, makeLatest(input.make_latest) ?? "true", before); | |
| 333 | + const rel = this.toRelease(row); | |
| 334 | + await this.announce(rel, draft ? ["created"] : ["created", "published", rel.prerelease ? "prereleased" : "released"], caller); | |
| 335 | + return this.toRelease(this.row(id)); | |
| 336 | + } | |
| 337 | + | |
| 338 | + async update(id: number, input: ReleaseInput, caller: Caller): Promise<Release> { | |
| 339 | + this.requireWrite(caller); | |
| 340 | + const r = this.row(id); | |
| 341 | + const tag = input.tag_name?.trim() || r.tag_name; | |
| 342 | + const target = input.target_commitish?.trim() || r.target_commitish; | |
| 343 | + const draft = input.draft === undefined ? !!r.draft : !!input.draft; | |
| 344 | + const prerelease = input.prerelease === undefined ? !!r.prerelease : !!input.prerelease; | |
| 345 | + if (!draft && this.sql.exec("SELECT 1 FROM releases WHERE tag_name = ? AND draft = 0 AND id != ?", tag, id).toArray().length) { | |
| 346 | + throw new HttpError(422, "a release for this tag already exists", { errors: [{ resource: "Release", code: "already_exists", field: "tag_name" }] }); | |
| 347 | + } | |
| 348 | + const publishing = !!r.draft && !draft; | |
| 349 | + if (!draft) await this.ensureTag(tag, target, caller); | |
| 350 | + const before = this.latestId(); | |
| 351 | + const t = now(); | |
| 352 | + this.sql.exec( | |
| 353 | + "UPDATE releases SET tag_name = ?, target_commitish = ?, name = ?, body = ?, draft = ?, prerelease = ?, published_at = ?, updated_at = ? WHERE id = ?", | |
| 354 | + tag, | |
| 355 | + target, | |
| 356 | + input.name ?? r.name, | |
| 357 | + input.body ?? r.body, | |
| 358 | + draft ? 1 : 0, | |
| 359 | + prerelease ? 1 : 0, | |
| 360 | + draft ? null : (r.published_at ?? t), | |
| 361 | + t, | |
| 362 | + id, | |
| 363 | + ); | |
| 364 | + const row = this.row(id); | |
| 365 | + this.applyLatest(row, makeLatest(input.make_latest) ?? (publishing ? "true" : undefined), before); | |
| 366 | + const rel = this.toRelease(row); | |
| 367 | + const actions: string[] = []; | |
| 368 | + if (publishing) actions.push("published", prerelease ? "prereleased" : "released"); | |
| 369 | + else if (!r.draft && draft) actions.push("unpublished"); | |
| 370 | + else { | |
| 371 | + actions.push("edited"); | |
| 372 | + if (!draft && !!r.prerelease !== prerelease) actions.push(prerelease ? "prereleased" : "released"); | |
| 373 | + } | |
| 374 | + await this.announce(rel, actions, caller); | |
| 375 | + return rel; | |
| 376 | + } | |
| 377 | + | |
| 378 | + async remove(id: number, caller: Caller): Promise<void> { | |
| 379 | + this.requireWrite(caller); | |
| 380 | + const rel = this.toRelease(this.row(id)); | |
| 381 | + const keys = this.sql.exec<{ r2_key: string }>("SELECT r2_key FROM release_assets WHERE release_id = ?", id).toArray().map((a) => a.r2_key); | |
| 382 | + this.sql.exec("DELETE FROM release_assets WHERE release_id = ?", id); | |
| 383 | + this.sql.exec("DELETE FROM releases WHERE id = ?", id); | |
| 384 | + this.sql.exec("DELETE FROM reactions WHERE subject = ?", `release:${id}`); | |
| 385 | + if (this.pinned() === id) this.pin(null); | |
| 386 | + await this.deleteObjects(keys); | |
| 387 | + await this.announce(rel, ["deleted"], caller); | |
| 388 | + } | |
| 389 | + | |
| 390 | + /** Webhooks for every action; workflows (`on: release`) only for published releases, like GitHub. */ | |
| 391 | + private async announce(rel: Release, actions: string[], caller: Caller): Promise<void> { | |
| 392 | + const m = this.repo.meta; | |
| 393 | + const base = this.env.PUBLIC_URL.replace(/\/$/, ""); | |
| 394 | + const release = ghRelease(base, m.owner, m.name, rel); | |
| 395 | + const repository = { id: numericId(m.repoId), node_id: nodeId("Repository", m.repoId), name: m.name, full_name: `${m.owner}/${m.name}`, owner: { login: m.owner }, default_branch: m.defaultBranch, html_url: `${base}/${m.owner}/${m.name}`, clone_url: `${base}/${m.owner}/${m.name}.git` }; | |
| 396 | + const actor = { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }; | |
| 397 | + const t = now(); | |
| 398 | + // Drafts are visible to writers only: their events go to webhooks (set up by admins), never to readers' open pages. | |
| 399 | + const live = !rel.draft; | |
| 400 | + for (const action of actions) this.repo.emit({ type: "release", action, repoId: m.repoId, owner: m.owner, repo: m.name, payload: { release }, actor, at: t }, { live }); | |
| 401 | + if (live) this.repo.broadcast("releases", { action: actions[0], id: rel.id }); | |
| 402 | + if (rel.draft && actions.every((a) => a !== "unpublished")) return; | |
| 403 | + const ref = `refs/tags/${rel.tagName}`; | |
| 404 | + const tagged = this.repo.getRef(ref); | |
| 405 | + if (!tagged) return; | |
| 406 | + const sha = await peelToCommit(this.repo.store, tagged).catch(() => null); | |
| 407 | + if (!sha) return; | |
| 408 | + for (const action of actions) { | |
| 409 | + await triggerWorkflows(this.repo, { name: "release", action, ref, sha, payload: { action, release, repository, sender: { login: caller.login, type: caller.kind === "user" ? "User" : "Bot" } }, title: rel.name || rel.tagName }, caller); | |
| 410 | + } | |
| 411 | + } | |
| 412 | + | |
| 413 | + // ---------------------------------------------------------------- release notes | |
| 414 | + | |
| 415 | + async generateNotes(input: { tag_name: string; target_commitish?: string; previous_tag_name?: string; configuration_file_path?: string }): Promise<{ name: string; body: string }> { | |
| 416 | + const tag = input.tag_name?.trim(); | |
| 417 | + if (!tag) throw new HttpError(422, "tag_name is required", { errors: [{ resource: "Release", code: "missing_field", field: "tag_name" }] }); | |
| 418 | + const tagged = this.repo.getRef(`refs/tags/${tag}`); | |
| 419 | + const head = tagged ? await peelToCommit(this.repo.store, tagged) : await this.repo.resolve(input.target_commitish || this.repo.meta.defaultBranch); | |
| 420 | + if (!head) throw new HttpError(422, "target_commitish does not exist", { errors: [{ resource: "Release", code: "invalid", field: "target_commitish" }] }); | |
| 421 | + let prev = input.previous_tag_name?.trim() || null; | |
| 422 | + let prevSha: string | null = null; | |
| 423 | + if (prev) { | |
| 424 | + const ref = this.repo.getRef(`refs/tags/${prev}`); | |
| 425 | + if (!ref) throw new HttpError(422, `previous tag '${prev}' does not exist`, { errors: [{ resource: "Release", code: "invalid", field: "previous_tag_name" }] }); | |
| 426 | + prevSha = await peelToCommit(this.repo.store, ref); | |
| 427 | + } else { | |
| 428 | + // The newest published release whose tag is behind this one. | |
| 429 | + for (const r of this.sql.exec<{ tag_name: string }>("SELECT tag_name FROM releases WHERE draft = 0 AND tag_name != ? ORDER BY created_at DESC, id DESC LIMIT 50", tag).toArray()) { | |
| 430 | + const ref = this.repo.getRef(`refs/tags/${r.tag_name}`); | |
| 431 | + const sha = ref ? await peelToCommit(this.repo.store, ref).catch(() => null) : null; | |
| 432 | + if (sha && sha !== head && (await isAncestor(this.repo.store, sha, head))) { | |
| 433 | + prev = r.tag_name; | |
| 434 | + prevSha = sha; | |
| 435 | + break; | |
| 436 | + } | |
| 437 | + } | |
| 438 | + } | |
| 439 | + const stop = new Set<string>(); | |
| 440 | + if (prevSha) for (const c of await walkCommits(this.repo.store, [prevSha], { limit: 100_000 })) stop.add(c.oid); | |
| 441 | + const range = new Set((await walkCommits(this.repo.store, [head], { stop, limit: 10_000 })).map((c) => c.oid)); | |
| 442 | + const m = this.repo.meta; | |
| 443 | + const html = `${this.env.PUBLIC_URL.replace(/\/$/, "")}/${m.owner}/${m.name}`; | |
| 444 | + type Merged = { number: number; merge_commit: string | null; head_sha: string; merged_at: string; title: string; author_login: string; labels: string }; | |
| 445 | + const merged = this.sql.exec<Merged>("SELECT p.number, p.merge_commit, p.head_sha, p.merged_at, i.title, i.author_login, i.labels FROM pulls p JOIN issues i ON i.number = p.number WHERE p.merged = 1 ORDER BY p.merged_at, p.number").toArray(); | |
| 446 | + const toPull = (p: Merged): NotesPull => ({ number: p.number, title: p.title, author: p.author_login, labels: JSON.parse(p.labels) as string[], url: `${html}/pull/${p.number}` }); | |
| 447 | + const inRange = merged.filter((p) => (p.merge_commit && range.has(p.merge_commit)) || range.has(p.head_sha)); | |
| 448 | + const first = new Map<string, number>(); | |
| 449 | + for (const p of merged) if (!first.has(p.author_login.toLowerCase())) first.set(p.author_login.toLowerCase(), p.number); | |
| 450 | + const newContributors = inRange.filter((p) => first.get(p.author_login.toLowerCase()) === p.number); | |
| 451 | + const paths = input.configuration_file_path ? [input.configuration_file_path] : [".github/release.yml", ".github/release.yaml"]; | |
| 452 | + let config; | |
| 453 | + for (const path of paths) { | |
| 454 | + const b = await this.repo.blob(head, path).catch(() => null); | |
| 455 | + if (b && !b.binary) { | |
| 456 | + config = parseReleaseConfig(fromUtf8(b.content)); | |
| 457 | + break; | |
| 458 | + } | |
| 459 | + } | |
| 460 | + const changelogUrl = prev ? `${html}/compare/${encodeURIComponent(prev)}...${encodeURIComponent(tag)}` : `${html}/commits/${encodeURIComponent(tag)}`; | |
| 461 | + return { name: tag, body: renderReleaseNotes({ pulls: inRange.map(toPull), newContributors: newContributors.map(toPull), changelogUrl, config }) }; | |
| 462 | + } | |
| 463 | + | |
| 464 | + // ---------------------------------------------------------------- assets | |
| 465 | + | |
| 466 | + assets(releaseId: number, caller: Caller): ReleaseAsset[] { | |
| 467 | + this.visibleRow(releaseId, caller); | |
| 468 | + return this.assetsOf(releaseId); | |
| 469 | + } | |
| 470 | + | |
| 471 | + private assetRow(id: number, caller: Caller): AssetRow { | |
| 472 | + const a = this.sql.exec<AssetRow>("SELECT * FROM release_assets WHERE id = ? AND state = 'uploaded'", id).toArray()[0]; | |
| 473 | + if (!a) throw new HttpError(404, "asset not found"); | |
| 474 | + this.visibleRow(a.release_id, caller); | |
| 475 | + return a; | |
| 476 | + } | |
| 477 | + | |
| 478 | + asset(id: number, caller: Caller): ReleaseAsset { | |
| 479 | + return toAsset(this.assetRow(id, caller)); | |
| 480 | + } | |
| 481 | + | |
| 482 | + updateAsset(id: number, patch: { name?: string; label?: string }, caller: Caller): ReleaseAsset { | |
| 483 | + this.requireWrite(caller); | |
| 484 | + const a = this.assetRow(id, caller); | |
| 485 | + const name = patch.name ? assetName(patch.name) : a.name; | |
| 486 | + if (name !== a.name && this.sql.exec("SELECT 1 FROM release_assets WHERE release_id = ? AND name = ? AND id != ?", a.release_id, name, id).toArray().length) { | |
| 487 | + throw new HttpError(422, `an asset named ${name} already exists`, { errors: [{ resource: "ReleaseAsset", code: "already_exists", field: "name" }] }); | |
| 488 | + } | |
| 489 | + this.sql.exec("UPDATE release_assets SET name = ?, label = ?, updated_at = ? WHERE id = ?", name, patch.label ?? a.label, now(), id); | |
| 490 | + return this.asset(id, caller); | |
| 491 | + } | |
| 492 | + | |
| 493 | + async deleteAsset(id: number, caller: Caller): Promise<void> { | |
| 494 | + this.requireWrite(caller); | |
| 495 | + const a = this.assetRow(id, caller); | |
| 496 | + this.sql.exec("DELETE FROM release_assets WHERE id = ?", id); | |
| 497 | + await this.deleteObjects([a.r2_key]); | |
| 498 | + } | |
| 499 | + | |
| 500 | + /** Where this repository's asset bytes live; nothing outside it is ever deleted as an asset. */ | |
| 501 | + private get assetPrefix(): string { | |
| 502 | + return `repos/${this.repo.meta.repoId}/releases/`; | |
| 503 | + } | |
| 504 | + | |
| 505 | + /** Delete asset objects, but only keys under the asset prefix (never git objects, LFS or caches). */ | |
| 506 | + private async deleteObjects(keys: string[]): Promise<void> { | |
| 507 | + const mine = keys.filter((k) => k.startsWith(this.assetPrefix) && !k.includes("..")); | |
| 508 | + if (mine.length) await this.env.BUCKET.delete(mine).catch((e) => console.warn("release asset cleanup failed", e)); | |
| 509 | + } | |
| 510 | + | |
| 511 | + /** Reserve an asset row; the Worker streams the bytes to the returned R2 key, then calls finishAsset. */ | |
| 512 | + async startAsset(releaseId: number, input: { name: string; label?: string; contentType?: string }, caller: Caller, imported?: { createdAt?: string; uploader?: { id: string; login: string; kind: string } }): Promise<{ id: number; r2Key: string }> { | |
| 513 | + this.requireWrite(caller); | |
| 514 | + this.row(releaseId); | |
| 515 | + const name = assetName(input.name ?? ""); | |
| 516 | + const clash = this.sql.exec<{ id: number; state: string; r2_key: string }>("SELECT id, state, r2_key FROM release_assets WHERE release_id = ? AND name = ?", releaseId, name).toArray()[0]; | |
| 517 | + if (clash?.state === "uploaded") throw new HttpError(422, `an asset named ${name} already exists`, { errors: [{ resource: "ReleaseAsset", code: "already_exists", field: "name" }] }); | |
| 518 | + if (clash) { | |
| 519 | + // An upload that never finished: drop its row and whatever bytes it left behind. | |
| 520 | + this.sql.exec("DELETE FROM release_assets WHERE id = ?", clash.id); | |
| 521 | + await this.deleteObjects([clash.r2_key]); | |
| 522 | + } | |
| 523 | + const t = imported?.createdAt ?? now(); | |
| 524 | + const who = imported?.uploader ?? { id: caller.id, login: caller.login, kind: caller.kind }; | |
| 525 | + this.sql.exec( | |
| 526 | + "INSERT INTO release_assets (release_id, name, label, content_type, uploader_id, uploader_login, uploader_kind, r2_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?)", | |
| 527 | + releaseId, | |
| 528 | + name, | |
| 529 | + input.label ?? "", | |
| 530 | + (input.contentType || "application/octet-stream").slice(0, 200), | |
| 531 | + who.id, | |
| 532 | + who.login, | |
| 533 | + who.kind, | |
| 534 | + t, | |
| 535 | + t, | |
| 536 | + ); | |
| 537 | + const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 538 | + const r2Key = `repos/${this.repo.meta.repoId}/releases/${releaseId}/${id}`; | |
| 539 | + this.sql.exec("UPDATE release_assets SET r2_key = ? WHERE id = ?", r2Key, id); | |
| 540 | + return { id, r2Key }; | |
| 541 | + } | |
| 542 | + | |
| 543 | + finishAsset(id: number, size: number, extra: { downloadCount?: number; updatedAt?: string } = {}): ReleaseAsset { | |
| 544 | + const a = this.sql.exec<AssetRow>("SELECT * FROM release_assets WHERE id = ?", id).toArray()[0]; | |
| 545 | + if (!a) throw new HttpError(404, "asset not found"); | |
| 546 | + this.sql.exec("UPDATE release_assets SET state = 'uploaded', size = ?, download_count = ?, updated_at = ? WHERE id = ?", size, extra.downloadCount ?? 0, extra.updatedAt ?? now(), id); | |
| 547 | + return toAsset(this.sql.exec<AssetRow>("SELECT * FROM release_assets WHERE id = ?", id).one()); | |
| 548 | + } | |
| 549 | + | |
| 550 | + abortAsset(id: number): void { | |
| 551 | + this.sql.exec("DELETE FROM release_assets WHERE id = ? AND state != 'uploaded'", id); | |
| 552 | + } | |
| 553 | + | |
| 554 | + /** Where to read an asset's bytes from; counts the download. */ | |
| 555 | + download(spec: { assetId: number } | { tag: string; name: string }, caller: Caller): { r2Key: string; name: string; contentType: string; size: number } { | |
| 556 | + let a: AssetRow | undefined; | |
| 557 | + if ("assetId" in spec) a = this.assetRow(spec.assetId, caller); | |
| 558 | + else { | |
| 559 | + const drafts = atLeast(caller.role, "write") ? "" : "AND r.draft = 0"; | |
| 560 | + a = this.sql | |
| 561 | + .exec<AssetRow>(`SELECT a.* FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE r.tag_name = ? AND a.name = ? AND a.state = 'uploaded' ${drafts} ORDER BY r.draft, r.id DESC LIMIT 1`, spec.tag, spec.name) | |
| 562 | + .toArray()[0]; | |
| 563 | + if (!a) throw new HttpError(404, "asset not found"); | |
| 564 | + } | |
| 565 | + this.sql.exec("UPDATE release_assets SET download_count = download_count + 1 WHERE id = ?", a.id); | |
| 566 | + return { r2Key: a.r2_key, name: a.name, contentType: a.content_type, size: a.size }; | |
| 567 | + } | |
| 568 | + | |
| 569 | + // ---------------------------------------------------------------- import (used by the GitHub importer) | |
| 570 | + | |
| 571 | + /** | |
| 572 | + * Insert a release with its original dates and author, without creating | |
| 573 | + * tags, firing webhooks or starting workflows. Returns the new release id. | |
| 574 | + */ | |
| 575 | + importRelease(input: ImportedRelease, caller: Caller): { id: number } { | |
| 576 | + this.requireWrite(caller); | |
| 577 | + const tag = input.tag_name?.trim(); | |
| 578 | + if (!tag) throw new HttpError(422, "tag_name is required"); | |
| 579 | + const created = new Date(input.created_at); | |
| 580 | + if (Number.isNaN(created.getTime())) throw new HttpError(422, "created_at must be an ISO 8601 date"); | |
| 581 | + const draft = !!input.draft; | |
| 582 | + const published = draft ? null : (input.published_at ?? input.created_at); | |
| 583 | + const author = input.author ?? { login: caller.login }; | |
| 584 | + this.sql.exec( | |
| 585 | + "INSERT INTO releases (tag_name, target_commitish, name, body, draft, prerelease, author_id, author_login, author_kind, created_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 586 | + tag, | |
| 587 | + input.target_commitish || this.repo.meta.defaultBranch, | |
| 588 | + input.name ?? "", | |
| 589 | + input.body ?? "", | |
| 590 | + draft ? 1 : 0, | |
| 591 | + input.prerelease ? 1 : 0, | |
| 592 | + author.id ?? `imported:${author.login}`, | |
| 593 | + author.login, | |
| 594 | + author.kind ?? "user", | |
| 595 | + created.toISOString(), | |
| 596 | + published, | |
| 597 | + published ?? created.toISOString(), | |
| 598 | + ); | |
| 599 | + const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 600 | + if (input.latest && !draft && !input.prerelease) this.pin(id); | |
| 601 | + return { id }; | |
| 602 | + } | |
| 603 | + | |
| 604 | + /** | |
| 605 | + * Attach an asset to an imported release, from bytes staged in R2 or from a URL. | |
| 606 | + * | |
| 607 | + * A staged object must be under `repos/<id>/import/`. Its bytes are copied to | |
| 608 | + * the asset's own key and the staged copy is removed, so deleting the asset | |
| 609 | + * later can never delete anything else. | |
| 610 | + * | |
| 611 | + * A URL must be https on GitHub's hosts (or IMPORT_GHE_HOST). Redirects are | |
| 612 | + * followed by hand and checked the same way; credential headers are dropped | |
| 613 | + * as soon as a redirect leaves the original host. The body is streamed to R2 | |
| 614 | + * and counted, up to 2 GiB. | |
| 615 | + */ | |
| 616 | + async importAsset(releaseId: number, meta: ImportedAsset, source: { r2Key: string } | { url: string; headers?: Record<string, string> }, caller: Caller): Promise<ReleaseAsset> { | |
| 617 | + const uploader = meta.uploader ? { id: meta.uploader.id ?? `imported:${meta.uploader.login}`, login: meta.uploader.login, kind: meta.uploader.kind ?? "user" } : undefined; | |
| 618 | + if ("r2Key" in source) { | |
| 619 | + const staging = `repos/${this.repo.meta.repoId}/import/`; | |
| 620 | + if (typeof source.r2Key !== "string" || !source.r2Key.startsWith(staging) || source.r2Key.includes("..")) throw new HttpError(422, `r2Key must be under ${staging}`); | |
| 621 | + const obj = await this.env.BUCKET.get(source.r2Key); | |
| 622 | + if (!obj) throw new HttpError(422, "r2Key does not exist"); | |
| 623 | + if (obj.size > MAX_IMPORT_BYTES) { | |
| 624 | + await obj.body.cancel(); | |
| 625 | + throw new HttpError(413, "asset is too large to import"); | |
| 626 | + } | |
| 627 | + const { id, r2Key } = await this.startAsset(releaseId, { name: meta.name, label: meta.label, contentType: meta.content_type }, caller, { createdAt: meta.created_at, uploader }); | |
| 628 | + try { | |
| 629 | + const fixed = new FixedLengthStream(obj.size); | |
| 630 | + await Promise.all([obj.body.pipeTo(fixed.writable), this.env.BUCKET.put(r2Key, fixed.readable, { httpMetadata: obj.httpMetadata })]); | |
| 631 | + } catch (e) { | |
| 632 | + this.abortAsset(id); | |
| 633 | + await this.deleteObjects([r2Key]); | |
| 634 | + throw e; | |
| 635 | + } | |
| 636 | + await this.env.BUCKET.delete(source.r2Key).catch(() => undefined); | |
| 637 | + return this.finishAsset(id, obj.size, { downloadCount: meta.download_count, updatedAt: meta.updated_at }); | |
| 638 | + } | |
| 639 | + const allowed = (raw: string) => isPublicHttpsUrl(raw) && importHostAllowed(new URL(raw).hostname, this.env.IMPORT_GHE_HOST); | |
| 640 | + const startHost = (() => { | |
| 641 | + try { | |
| 642 | + return new URL(source.url).host; | |
| 643 | + } catch { | |
| 644 | + return ""; | |
| 645 | + } | |
| 646 | + })(); | |
| 647 | + let url = source.url; | |
| 648 | + let headers = { ...(source.headers ?? {}) }; | |
| 649 | + let res: Response | null = null; | |
| 650 | + for (let hop = 0; hop < 5; hop++) { | |
| 651 | + if (!allowed(url)) throw new HttpError(422, "asset URL must be an https URL on github.com, its asset hosts, or the configured GitHub Enterprise host"); | |
| 652 | + res = await fetch(url, { headers, redirect: "manual" }); | |
| 653 | + const loc = res.headers.get("location"); | |
| 654 | + if (res.status >= 300 && res.status < 400 && loc) { | |
| 655 | + await res.body?.cancel(); | |
| 656 | + const next = new URL(loc, url); | |
| 657 | + if (next.host !== startHost) headers = withoutCredentials(headers); | |
| 658 | + url = next.toString(); | |
| 659 | + res = null; | |
| 660 | + continue; | |
| 661 | + } | |
| 662 | + break; | |
| 663 | + } | |
| 664 | + if (!res || !res.ok || !res.body) { | |
| 665 | + await res?.body?.cancel(); | |
| 666 | + throw new HttpError(502, `could not download asset: HTTP ${res?.status ?? "redirect loop"}`); | |
| 667 | + } | |
| 668 | + const body = res.body; | |
| 669 | + const length = Number(res.headers.get("content-length") ?? NaN); | |
| 670 | + if (Number.isFinite(length) && length > MAX_IMPORT_BYTES) { | |
| 671 | + await body.cancel(); | |
| 672 | + throw new HttpError(413, "asset is too large to import"); | |
| 673 | + } | |
| 674 | + const { id, r2Key } = await this.startAsset(releaseId, { name: meta.name, label: meta.label, contentType: meta.content_type ?? res.headers.get("content-type") ?? undefined }, caller, { createdAt: meta.created_at, uploader }); | |
| 675 | + try { | |
| 676 | + let size: number; | |
| 677 | + if (Number.isFinite(length) && length >= 0) { | |
| 678 | + // FixedLengthStream fails the upload if the body is longer or shorter than announced. | |
| 679 | + const fixed = new FixedLengthStream(length); | |
| 680 | + await Promise.all([body.pipeTo(fixed.writable), this.env.BUCKET.put(r2Key, fixed.readable)]); | |
| 681 | + size = length; | |
| 682 | + } else { | |
| 683 | + const sink = new R2Sink(this.env.BUCKET, r2Key, "application/octet-stream", MAX_IMPORT_BYTES, "asset is too large to import"); | |
| 684 | + const reader = body.getReader(); | |
| 685 | + try { | |
| 686 | + for (;;) { | |
| 687 | + const { done, value } = await reader.read(); | |
| 688 | + if (done) break; | |
| 689 | + await sink.write(value); | |
| 690 | + } | |
| 691 | + size = await sink.close(); | |
| 692 | + } catch (e) { | |
| 693 | + await reader.cancel().catch(() => undefined); | |
| 694 | + await sink.abort(); | |
| 695 | + throw e; | |
| 696 | + } | |
| 697 | + } | |
| 698 | + return this.finishAsset(id, size, { downloadCount: meta.download_count, updatedAt: meta.updated_at }); | |
| 699 | + } catch (e) { | |
| 700 | + this.abortAsset(id); | |
| 701 | + await this.deleteObjects([r2Key]); | |
| 702 | + throw e; | |
| 703 | + } | |
| 704 | + } | |
| 705 | +} | |
| 706 | + | |
| 707 | +function makeLatest(v: ReleaseInput["make_latest"]): MakeLatest | undefined { | |
| 708 | + if (v === undefined || v === null) return undefined; | |
| 709 | + if (v === true || v === "true") return "true"; | |
| 710 | + if (v === false || v === "false") return "false"; | |
| 711 | + if (v === "legacy") return "legacy"; | |
| 712 | + throw new HttpError(422, "make_latest must be true, false or legacy", { errors: [{ resource: "Release", code: "invalid", field: "make_latest" }] }); | |
| 713 | +} |
addedapps/diffinery/src/repo/required.ts+49−0View file
| @@ -1,0 +1,49 @@ | ||
| 1 | +import { HttpError } from "../lib/util.js"; | |
| 2 | +import type { Check } from "./collab.js"; | |
| 3 | + | |
| 4 | +/** The app name of checks reported by Diffinery Actions. */ | |
| 5 | +export const ACTIONS_APP = "diffinery-actions"; | |
| 6 | + | |
| 7 | +const OK = new Set(["success", "skipped", "neutral"]); | |
| 8 | + | |
| 9 | +/** Who reported a check: "diffinery-actions", or the login that created a check run or commit status. */ | |
| 10 | +export function sourceOf(c: Pick<Check, "app"> & { creatorLogin?: string | null }): string { | |
| 11 | + if (c.app === ACTIONS_APP) return ACTIONS_APP; | |
| 12 | + return (c.creatorLogin ?? c.app).toLowerCase(); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export type RequiredState = { state: "success" | "failure" | "pending" | "missing"; conclusion: string | null; status: string | null }; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Whether a required check passes on a commit, given every check run and | |
| 19 | + * commit status reported there. The rules stop a weaker reporter from | |
| 20 | + * outvoting the real one: | |
| 21 | + * | |
| 22 | + * - With a bound source (`requiredCheckSources[name]`), only that reporter counts. | |
| 23 | + * - Otherwise, when Diffinery Actions reported a check with that name, it is the authority. | |
| 24 | + * - Reports from agents never satisfy a required check. | |
| 25 | + * - Of what is left, any failure fails and anything unfinished is pending. | |
| 26 | + */ | |
| 27 | +export function requiredCheck(name: string, entries: Check[], source?: string): RequiredState { | |
| 28 | + let named = entries.filter((e) => e.name === name); | |
| 29 | + if (source) named = named.filter((e) => sourceOf(e) === source.toLowerCase()); | |
| 30 | + else if (named.some((e) => e.app === ACTIONS_APP)) named = named.filter((e) => e.app === ACTIONS_APP); | |
| 31 | + named = named.filter((e) => e.creatorKind !== "agent"); | |
| 32 | + if (!named.length) return { state: "missing", conclusion: null, status: null }; | |
| 33 | + const failed = named.find((e) => e.status === "completed" && !OK.has(e.conclusion ?? "")); | |
| 34 | + if (failed) return { state: "failure", conclusion: failed.conclusion ?? "failure", status: "completed" }; | |
| 35 | + const open = named.find((e) => e.status !== "completed"); | |
| 36 | + if (open) return { state: "pending", conclusion: null, status: open.status }; | |
| 37 | + return { state: "success", conclusion: named[0]!.conclusion ?? "success", status: "completed" }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Validate a branch protection's `requiredCheckSources`: check name → reporter (a login, or "diffinery-actions"). */ | |
| 41 | +export function normalizeCheckSources(v: unknown): Record<string, string> { | |
| 42 | + if (!v || typeof v !== "object" || Array.isArray(v)) throw new HttpError(422, "requiredCheckSources must map check names to a reporter login or diffinery-actions"); | |
| 43 | + const out: Record<string, string> = {}; | |
| 44 | + for (const [k, s] of Object.entries(v as Record<string, unknown>)) { | |
| 45 | + if (!k || k.length > 255 || typeof s !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/.test(s)) throw new HttpError(422, `invalid source for required check '${k}'`); | |
| 46 | + out[k] = s.toLowerCase(); | |
| 47 | + } | |
| 48 | + return out; | |
| 49 | +} |
modifiedapps/diffinery/src/repo/schema.ts+53−0View file
| @@ -68,6 +68,54 @@ | ||
| 68 | 68 | parent_sha TEXT, group_sha TEXT, group_ref TEXT, built_at TEXT |
| 69 | 69 | ); |
| 70 | 70 | 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); |
| 71 | + | |
| 72 | +-- GitHub-compatible extras (see ./releases.ts, ./statuses.ts, ./deployments.ts, ./social.ts). | |
| 73 | +CREATE TABLE IF NOT EXISTS releases ( | |
| 74 | + id INTEGER PRIMARY KEY AUTOINCREMENT, tag_name TEXT NOT NULL, target_commitish TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', | |
| 75 | + body TEXT NOT NULL DEFAULT '', draft INTEGER NOT NULL DEFAULT 0, prerelease INTEGER NOT NULL DEFAULT 0, | |
| 76 | + author_id TEXT NOT NULL, author_login TEXT NOT NULL, author_kind TEXT NOT NULL DEFAULT 'user', | |
| 77 | + created_at TEXT NOT NULL, published_at TEXT, updated_at TEXT NOT NULL | |
| 78 | +); | |
| 79 | +CREATE INDEX IF NOT EXISTS releases_tag ON releases(tag_name); | |
| 80 | +CREATE TABLE IF NOT EXISTS release_assets ( | |
| 81 | + id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', | |
| 82 | + content_type TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'starter', download_count INTEGER NOT NULL DEFAULT 0, | |
| 83 | + uploader_id TEXT, uploader_login TEXT, uploader_kind TEXT, r2_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 84 | + UNIQUE (release_id, name) | |
| 85 | +); | |
| 86 | +CREATE TABLE IF NOT EXISTS statuses ( | |
| 87 | + id INTEGER PRIMARY KEY AUTOINCREMENT, sha TEXT NOT NULL, state TEXT NOT NULL, context TEXT NOT NULL, description TEXT, target_url TEXT, | |
| 88 | + creator_id TEXT NOT NULL, creator_login TEXT NOT NULL, creator_kind TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL | |
| 89 | +); | |
| 90 | +CREATE INDEX IF NOT EXISTS statuses_sha ON statuses(sha, context); | |
| 91 | +CREATE TABLE IF NOT EXISTS deployments ( | |
| 92 | + id INTEGER PRIMARY KEY AUTOINCREMENT, sha TEXT NOT NULL, ref TEXT NOT NULL, task TEXT NOT NULL DEFAULT 'deploy', environment TEXT NOT NULL, | |
| 93 | + original_environment TEXT NOT NULL, description TEXT, payload TEXT NOT NULL DEFAULT '{}', transient INTEGER NOT NULL DEFAULT 0, | |
| 94 | + production INTEGER NOT NULL DEFAULT 0, creator_id TEXT NOT NULL, creator_login TEXT NOT NULL, creator_kind TEXT NOT NULL, | |
| 95 | + job_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL | |
| 96 | +); | |
| 97 | +CREATE INDEX IF NOT EXISTS deployments_env ON deployments(environment); | |
| 98 | +CREATE TABLE IF NOT EXISTS deployment_statuses ( | |
| 99 | + id INTEGER PRIMARY KEY AUTOINCREMENT, deployment_id INTEGER NOT NULL, state TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', | |
| 100 | + environment TEXT NOT NULL, environment_url TEXT NOT NULL DEFAULT '', log_url TEXT NOT NULL DEFAULT '', | |
| 101 | + creator_id TEXT NOT NULL, creator_login TEXT NOT NULL, creator_kind TEXT NOT NULL, created_at TEXT NOT NULL | |
| 102 | +); | |
| 103 | +CREATE INDEX IF NOT EXISTS deployment_statuses_dep ON deployment_statuses(deployment_id); | |
| 104 | +CREATE TABLE IF NOT EXISTS environments ( | |
| 105 | + id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, branch_policy TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL | |
| 106 | +); | |
| 107 | +CREATE TABLE IF NOT EXISTS environment_secrets (environment TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, ciphertext TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (environment, name)); | |
| 108 | +CREATE TABLE IF NOT EXISTS environment_variables (environment TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, value TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (environment, name)); | |
| 109 | +CREATE TABLE IF NOT EXISTS reactions ( | |
| 110 | + id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT NOT NULL, content TEXT NOT NULL, user_id TEXT NOT NULL, user_login TEXT NOT NULL, | |
| 111 | + user_kind TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (subject, content, user_id) | |
| 112 | +); | |
| 113 | +CREATE TABLE IF NOT EXISTS issue_events ( | |
| 114 | + id INTEGER PRIMARY KEY AUTOINCREMENT, number INTEGER NOT NULL, event TEXT NOT NULL, actor_id TEXT NOT NULL, actor_login TEXT NOT NULL, | |
| 115 | + actor_kind TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL | |
| 116 | +); | |
| 117 | +CREATE INDEX IF NOT EXISTS issue_events_number ON issue_events(number); | |
| 118 | +CREATE TABLE IF NOT EXISTS ref_actors (ref TEXT PRIMARY KEY, actor_id TEXT NOT NULL, actor_kind TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 71 | 119 | `; |
| 72 | 120 | |
| 73 | 121 | /** Columns added after a table was first created; added with ALTER TABLE when missing. */ |
| @@ -75,6 +123,11 @@ | ||
| 75 | 123 | // The commit a run reports its checks on: the PR head for pull_request runs, whose |
| 76 | 124 | // GITHUB_SHA is the test merge commit. Null means the run's own sha. |
| 77 | 125 | ["runs", "head_sha", "TEXT"], |
| 126 | + // Who created a check run over the REST API (null for Diffinery Actions), and GitHub's numeric id. | |
| 127 | + ["checks", "creator_id", "TEXT"], | |
| 128 | + ["checks", "creator_login", "TEXT"], | |
| 129 | + ["checks", "creator_kind", "TEXT"], | |
| 130 | + ["checks", "seq", "INTEGER"], | |
| 78 | 131 | ]; |
| 79 | 132 | |
| 80 | 133 | export function migrateRepoSchema(sql: SqlStorage): void { |
addedapps/diffinery/src/repo/search.ts+117−0View file
| @@ -1,0 +1,117 @@ | ||
| 1 | +import { inRange, matchesText, type IssueQuery } from "../lib/search-query.js"; | |
| 2 | +import type { RepoDO } from "./RepoDO.js"; | |
| 3 | + | |
| 4 | +type Row = { | |
| 5 | + number: number; | |
| 6 | + kind: string; | |
| 7 | + title: string; | |
| 8 | + body: string; | |
| 9 | + state: string; | |
| 10 | + author_login: string; | |
| 11 | + assignees: string; | |
| 12 | + labels: string; | |
| 13 | + created_at: string; | |
| 14 | + updated_at: string; | |
| 15 | + closed_at: string | null; | |
| 16 | + locked: number; | |
| 17 | + head_ref: string | null; | |
| 18 | + base_ref: string | null; | |
| 19 | + draft: number | null; | |
| 20 | + merged: number | null; | |
| 21 | + comments: number; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +/** Rows scanned per repository, and comment bodies read for `in:comments`. */ | |
| 25 | +const MAX_ROWS = 5000; | |
| 26 | +const MAX_COMMENT_ROWS = 2000; | |
| 27 | + | |
| 28 | +/** One hit: the issue number and the value it sorts by. The Worker merges hits across repositories. */ | |
| 29 | +export type SearchHit = { number: number; key: string | number }; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Issue and pull request search within one repository. Returns only numbers | |
| 33 | + * and sort keys (at most `limit`); the Worker merges the hits of every | |
| 34 | + * repository the caller can read and then loads just the page it returns. | |
| 35 | + */ | |
| 36 | +export function searchIssues(repo: RepoDO, q: IssueQuery, limit: number): { hits: SearchHit[]; total: number } { | |
| 37 | + const where: string[] = []; | |
| 38 | + const args: unknown[] = []; | |
| 39 | + if (q.kind) { | |
| 40 | + where.push("i.kind = ?"); | |
| 41 | + args.push(q.kind); | |
| 42 | + } | |
| 43 | + if (q.state) { | |
| 44 | + where.push("i.state = ?"); | |
| 45 | + args.push(q.state); | |
| 46 | + } | |
| 47 | + if (q.merged !== undefined) where.push(q.merged ? "p.merged = 1" : "coalesce(p.merged, 0) = 0"); | |
| 48 | + if (q.draft !== undefined) where.push(q.draft ? "p.draft = 1" : "coalesce(p.draft, 0) = 0"); | |
| 49 | + if (q.locked !== undefined) where.push(q.locked ? "i.locked = 1" : "i.locked = 0"); | |
| 50 | + if (q.author) { | |
| 51 | + where.push("i.author_login = ? COLLATE NOCASE"); | |
| 52 | + args.push(q.author); | |
| 53 | + } | |
| 54 | + if (q.notAuthor) { | |
| 55 | + where.push("i.author_login != ? COLLATE NOCASE"); | |
| 56 | + args.push(q.notAuthor); | |
| 57 | + } | |
| 58 | + if (q.head) { | |
| 59 | + where.push("p.head_ref = ?"); | |
| 60 | + args.push(q.head); | |
| 61 | + } | |
| 62 | + if (q.base) { | |
| 63 | + where.push("p.base_ref = ?"); | |
| 64 | + args.push(q.base); | |
| 65 | + } | |
| 66 | + for (const l of q.labels) { | |
| 67 | + where.push("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE lower(value) = lower(?))"); | |
| 68 | + args.push(l); | |
| 69 | + } | |
| 70 | + for (const l of q.notLabels) { | |
| 71 | + where.push("NOT EXISTS (SELECT 1 FROM json_each(i.labels) WHERE lower(value) = lower(?))"); | |
| 72 | + args.push(l); | |
| 73 | + } | |
| 74 | + if (q.noLabel) where.push("i.labels = '[]'"); | |
| 75 | + if (q.noAssignee) where.push("i.assignees = '[]'"); | |
| 76 | + if (q.assignee) { | |
| 77 | + where.push("EXISTS (SELECT 1 FROM json_each(i.assignees) WHERE lower(value) = lower(?))"); | |
| 78 | + args.push(q.assignee); | |
| 79 | + } | |
| 80 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 81 | + const rows = repo.sql | |
| 82 | + .exec<Row>( | |
| 83 | + `SELECT i.*, p.head_ref, p.base_ref, p.draft, p.merged, (SELECT count(*) FROM comments c WHERE c.number = i.number AND c.review_id IS NULL) AS comments | |
| 84 | + FROM issues i LEFT JOIN pulls p ON p.number = i.number ${w} ORDER BY i.number DESC LIMIT ?`, | |
| 85 | + ...args, | |
| 86 | + MAX_ROWS, | |
| 87 | + ) | |
| 88 | + .toArray(); | |
| 89 | + const fields = q.in.length ? q.in : ["title", "body"]; | |
| 90 | + // in:comments reads a bounded number of the newest comment bodies, once, instead of a query per issue. | |
| 91 | + const commentText = new Map<number, string[]>(); | |
| 92 | + if (fields.includes("comments") && q.text.length) { | |
| 93 | + for (const c of repo.sql.exec<{ number: number; body: string }>("SELECT number, body FROM comments ORDER BY rowid DESC LIMIT ?", MAX_COMMENT_ROWS).toArray()) { | |
| 94 | + const list = commentText.get(c.number) ?? []; | |
| 95 | + list.push(c.body); | |
| 96 | + commentText.set(c.number, list); | |
| 97 | + } | |
| 98 | + } | |
| 99 | + const matched = rows.filter((r) => { | |
| 100 | + if (!inRange(r.created_at, q.created) || !inRange(r.updated_at, q.updated) || !inRange(r.closed_at, q.closed)) return false; | |
| 101 | + if (!q.text.length) return true; | |
| 102 | + const hay: string[] = []; | |
| 103 | + if (fields.includes("title")) hay.push(r.title); | |
| 104 | + if (fields.includes("body")) hay.push(r.body); | |
| 105 | + if (fields.includes("comments")) hay.push(...(commentText.get(r.number) ?? [])); | |
| 106 | + return matchesText(q.text, hay); | |
| 107 | + }); | |
| 108 | + const sort = q.sort ?? { field: "created", dir: "desc" }; | |
| 109 | + const key = (r: Row) => (sort.field === "comments" ? r.comments : sort.field === "updated" ? r.updated_at : r.created_at); | |
| 110 | + matched.sort((a, b) => { | |
| 111 | + const x = key(a); | |
| 112 | + const y = key(b); | |
| 113 | + const c = x < y ? -1 : x > y ? 1 : b.number - a.number; | |
| 114 | + return sort.dir === "asc" ? c : -c; | |
| 115 | + }); | |
| 116 | + return { hits: matched.slice(0, Math.max(0, Math.min(limit, 1000))).map((r) => ({ number: r.number, key: key(r) })), total: matched.length }; | |
| 117 | +} |
addedapps/diffinery/src/repo/social.ts+471−0View file
| @@ -1,0 +1,471 @@ | ||
| 1 | +import { isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core"; | |
| 2 | +import { HttpError, now } from "../lib/util.js"; | |
| 3 | +import type { Comment, IssueRow, Review } from "./collab.js"; | |
| 4 | +import type { RepoDO } from "./RepoDO.js"; | |
| 5 | +import { atLeast, type Caller } from "./types.js"; | |
| 6 | + | |
| 7 | +export const REACTION_CONTENT = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]; | |
| 8 | + | |
| 9 | +export type Person = { id: string; login: string; kind: string }; | |
| 10 | +export type Reaction = { id: number; content: string; user: Person; createdAt: string }; | |
| 11 | +/** `detail` is JSON text: what the event is about (label, assignee, rename, lock reason, ...). */ | |
| 12 | +export type IssueEvent = { id: number; number: number; event: string; actor: Person; detail: string; createdAt: string }; | |
| 13 | +export type LabelRow = { id: number; name: string; color: string; description: string }; | |
| 14 | +export type TimelineItem = | |
| 15 | + | ({ kind: "event" } & IssueEvent) | |
| 16 | + | { kind: "comment"; comment: Comment; createdAt: string } | |
| 17 | + | { kind: "review"; review: Review; createdAt: string }; | |
| 18 | + | |
| 19 | +type CommentRow = { | |
| 20 | + id: string; | |
| 21 | + number: number; | |
| 22 | + author_id: string; | |
| 23 | + author_login: string; | |
| 24 | + author_kind: string; | |
| 25 | + body: string; | |
| 26 | + created_at: string; | |
| 27 | + updated_at: string; | |
| 28 | + review_id: string | null; | |
| 29 | + path: string | null; | |
| 30 | + line: number | null; | |
| 31 | + side: string | null; | |
| 32 | + commit_id: string | null; | |
| 33 | + in_reply_to: string | null; | |
| 34 | + resolved: number; | |
| 35 | + seq: number; | |
| 36 | + review_seq: number | null; | |
| 37 | + reply_seq: number | null; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Comment columns plus the numeric ids GitHub clients expect (row ids of the | |
| 42 | + * comment, its review and the comment it replies to). Use with `comments c`. | |
| 43 | + */ | |
| 44 | +export const COMMENT_SELECT = "c.rowid AS seq, (SELECT r.rowid FROM reviews r WHERE r.id = c.review_id) AS review_seq, (SELECT p.rowid FROM comments p WHERE p.id = c.in_reply_to) AS reply_seq, c.*"; | |
| 45 | + | |
| 46 | +const toComment = (c: CommentRow): Comment => ({ | |
| 47 | + id: c.id, | |
| 48 | + author: { id: c.author_id, login: c.author_login, kind: c.author_kind }, | |
| 49 | + body: c.body, | |
| 50 | + createdAt: c.created_at, | |
| 51 | + updatedAt: c.updated_at, | |
| 52 | + reviewId: c.review_id, | |
| 53 | + path: c.path, | |
| 54 | + line: c.line, | |
| 55 | + side: c.side, | |
| 56 | + commitId: c.commit_id, | |
| 57 | + inReplyTo: c.in_reply_to, | |
| 58 | + resolved: !!c.resolved, | |
| 59 | + seq: c.seq, | |
| 60 | + reviewSeq: c.review_seq, | |
| 61 | + inReplyToSeq: c.reply_seq, | |
| 62 | +}); | |
| 63 | + | |
| 64 | +const LABEL_COLOR = /^[0-9a-fA-F]{6}$/; | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * The issue and pull request sub-resources bots use: labels and assignees on | |
| 68 | + * one issue, locking, reactions, issue events and timelines, single comments, | |
| 69 | + * review comment replies, dismissing reviews and updating a PR branch. | |
| 70 | + */ | |
| 71 | +export class Social { | |
| 72 | + constructor(private repo: RepoDO) {} | |
| 73 | + | |
| 74 | + private get sql() { | |
| 75 | + return this.repo.sql; | |
| 76 | + } | |
| 77 | + | |
| 78 | + private issueRow(number: number): IssueRow { | |
| 79 | + const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0]; | |
| 80 | + if (!r) throw new HttpError(404, `#${number} not found`); | |
| 81 | + return r; | |
| 82 | + } | |
| 83 | + | |
| 84 | + private triage(caller: Caller): void { | |
| 85 | + if (!atLeast(caller.role, "triage")) throw new HttpError(403, "triage access is required"); | |
| 86 | + } | |
| 87 | + | |
| 88 | + // ---------------------------------------------------------------- issue events | |
| 89 | + | |
| 90 | + record(number: number, event: string, actor: Pick<Caller, "id" | "login" | "kind">, detail: Record<string, unknown> = {}): void { | |
| 91 | + this.sql.exec("INSERT INTO issue_events (number, event, actor_id, actor_login, actor_kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", number, event, actor.id, actor.login, actor.kind, JSON.stringify(detail), now()); | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** Called by Collab.update: turn a patch into labeled/unlabeled/assigned/... events. */ | |
| 95 | + recordUpdate(before: IssueRow, patch: { title?: string; state?: string; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Pick<Caller, "id" | "login" | "kind">): void { | |
| 96 | + if (patch.labels) { | |
| 97 | + const old = new Set(JSON.parse(before.labels) as string[]); | |
| 98 | + const next = new Set(patch.labels); | |
| 99 | + const colors = new Map(this.labelRows().map((l) => [l.name, l.color])); | |
| 100 | + for (const l of next) if (!old.has(l)) this.record(before.number, "labeled", caller, { label: { name: l, color: colors.get(l) ?? "ededed" } }); | |
| 101 | + for (const l of old) if (!next.has(l)) this.record(before.number, "unlabeled", caller, { label: { name: l, color: colors.get(l) ?? "ededed" } }); | |
| 102 | + } | |
| 103 | + if (patch.assignees) { | |
| 104 | + const old = new Set(JSON.parse(before.assignees) as string[]); | |
| 105 | + const next = new Set(patch.assignees); | |
| 106 | + for (const a of next) if (!old.has(a)) this.record(before.number, "assigned", caller, { assignee: a }); | |
| 107 | + for (const a of old) if (!next.has(a)) this.record(before.number, "unassigned", caller, { assignee: a }); | |
| 108 | + } | |
| 109 | + if (patch.title !== undefined && patch.title !== before.title) this.record(before.number, "renamed", caller, { rename: { from: before.title, to: patch.title } }); | |
| 110 | + if (patch.state && patch.state !== before.state) this.record(before.number, patch.state === "closed" ? "closed" : "reopened", caller, patch.state === "closed" ? { state_reason: patch.stateReason ?? "completed" } : {}); | |
| 111 | + } | |
| 112 | + | |
| 113 | + events(number: number): IssueEvent[] { | |
| 114 | + this.issueRow(number); | |
| 115 | + return this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events WHERE number = ? ORDER BY id", number).toArray().map(toEvent); | |
| 116 | + } | |
| 117 | + | |
| 118 | + repoEvents(limit: number, offset: number): { items: IssueEvent[]; total: number } { | |
| 119 | + const total = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issue_events").one().n; | |
| 120 | + return { items: this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events ORDER BY id DESC LIMIT ? OFFSET ?", Math.min(limit, 100), offset).toArray().map(toEvent), total }; | |
| 121 | + } | |
| 122 | + | |
| 123 | + event(id: number): IssueEvent { | |
| 124 | + const r = this.sql.exec<Record<string, string | number>>("SELECT * FROM issue_events WHERE id = ?", id).toArray()[0]; | |
| 125 | + if (!r) throw new HttpError(404, "event not found"); | |
| 126 | + return toEvent(r); | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** Comments, reviews and events of an issue in time order (GitHub's timeline, basic). */ | |
| 130 | + timeline(number: number): TimelineItem[] { | |
| 131 | + const { comments, reviews } = this.repo.collab.get(number); | |
| 132 | + const items: TimelineItem[] = [ | |
| 133 | + ...this.events(number).map((e) => ({ kind: "event" as const, ...e })), | |
| 134 | + ...comments.filter((c) => !c.reviewId).map((c) => ({ kind: "comment" as const, comment: c, createdAt: c.createdAt })), | |
| 135 | + ...reviews.map((r) => ({ kind: "review" as const, review: r, createdAt: r.submittedAt })), | |
| 136 | + ]; | |
| 137 | + return items.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0)); | |
| 138 | + } | |
| 139 | + | |
| 140 | + lockReason(number: number): string | null { | |
| 141 | + const e = this.sql.exec<{ detail: string }>("SELECT detail FROM issue_events WHERE number = ? AND event = 'locked' ORDER BY id DESC LIMIT 1", number).toArray()[0]; | |
| 142 | + return e ? ((JSON.parse(e.detail) as { lock_reason?: string }).lock_reason ?? null) : null; | |
| 143 | + } | |
| 144 | + | |
| 145 | + // ---------------------------------------------------------------- labels | |
| 146 | + | |
| 147 | + labelRows(): LabelRow[] { | |
| 148 | + return this.sql.exec<LabelRow>("SELECT rowid AS id, name, color, description FROM labels ORDER BY name").toArray(); | |
| 149 | + } | |
| 150 | + | |
| 151 | + label(name: string): LabelRow { | |
| 152 | + const l = this.sql.exec<LabelRow>("SELECT rowid AS id, name, color, description FROM labels WHERE name = ?", name).toArray()[0]; | |
| 153 | + if (!l) throw new HttpError(404, "label not found"); | |
| 154 | + return l; | |
| 155 | + } | |
| 156 | + | |
| 157 | + createLabel(input: { name?: string; color?: string; description?: string }, caller: Caller): LabelRow { | |
| 158 | + this.triage(caller); | |
| 159 | + const name = (input.name ?? "").trim(); | |
| 160 | + if (!name || name.length > 100) throw new HttpError(422, "label name must be 1 to 100 characters", { errors: [{ resource: "Label", code: "invalid", field: "name" }] }); | |
| 161 | + if (this.sql.exec("SELECT 1 FROM labels WHERE name = ?", name).toArray().length) throw new HttpError(422, "a label with this name already exists", { errors: [{ resource: "Label", code: "already_exists", field: "name" }] }); | |
| 162 | + const color = (input.color ?? "ededed").replace(/^#/, ""); | |
| 163 | + if (!LABEL_COLOR.test(color)) throw new HttpError(422, "color must be a 6-digit hex code", { errors: [{ resource: "Label", code: "invalid", field: "color" }] }); | |
| 164 | + this.sql.exec("INSERT INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.toLowerCase(), (input.description ?? "").slice(0, 100)); | |
| 165 | + return this.label(name); | |
| 166 | + } | |
| 167 | + | |
| 168 | + updateLabel(name: string, input: { new_name?: string; color?: string; description?: string }, caller: Caller): LabelRow { | |
| 169 | + this.triage(caller); | |
| 170 | + const old = this.label(name); | |
| 171 | + const next = input.new_name?.trim() || old.name; | |
| 172 | + const color = (input.color ?? old.color).replace(/^#/, ""); | |
| 173 | + if (!LABEL_COLOR.test(color)) throw new HttpError(422, "color must be a 6-digit hex code", { errors: [{ resource: "Label", code: "invalid", field: "color" }] }); | |
| 174 | + if (next !== old.name && this.sql.exec("SELECT 1 FROM labels WHERE name = ?", next).toArray().length) throw new HttpError(422, "a label with this name already exists", { errors: [{ resource: "Label", code: "already_exists", field: "name" }] }); | |
| 175 | + this.sql.exec("UPDATE labels SET name = ?, color = ?, description = ? WHERE name = ?", next, color.toLowerCase(), input.description ?? old.description, old.name); | |
| 176 | + if (next !== old.name) this.renameOnIssues(old.name, next); | |
| 177 | + return this.label(next); | |
| 178 | + } | |
| 179 | + | |
| 180 | + deleteLabel(name: string, caller: Caller): void { | |
| 181 | + this.triage(caller); | |
| 182 | + this.label(name); | |
| 183 | + this.sql.exec("DELETE FROM labels WHERE name = ?", name); | |
| 184 | + this.renameOnIssues(name, null); | |
| 185 | + } | |
| 186 | + | |
| 187 | + private renameOnIssues(from: string, to: string | null): void { | |
| 188 | + for (const r of this.sql.exec<{ number: number; labels: string }>("SELECT number, labels FROM issues WHERE EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)", from).toArray()) { | |
| 189 | + const labels = (JSON.parse(r.labels) as string[]).flatMap((l) => (l === from ? (to ? [to] : []) : [l])); | |
| 190 | + this.sql.exec("UPDATE issues SET labels = ? WHERE number = ?", JSON.stringify([...new Set(labels)]), r.number); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 194 | + /** Labels on an issue, creating any that don't exist yet (like GitHub). */ | |
| 195 | + private ensureLabels(names: string[]): string[] { | |
| 196 | + const out: string[] = []; | |
| 197 | + for (const raw of names) { | |
| 198 | + const name = String(raw).trim(); | |
| 199 | + if (!name) continue; | |
| 200 | + if (name.length > 100) throw new HttpError(422, "label names are at most 100 characters"); | |
| 201 | + if (!this.sql.exec("SELECT 1 FROM labels WHERE name = ?", name).toArray().length) this.sql.exec("INSERT INTO labels (name, color, description) VALUES (?, 'ededed', '')", name); | |
| 202 | + if (!out.includes(name)) out.push(name); | |
| 203 | + } | |
| 204 | + return out; | |
| 205 | + } | |
| 206 | + | |
| 207 | + issueLabels(number: number): LabelRow[] { | |
| 208 | + const names = JSON.parse(this.issueRow(number).labels) as string[]; | |
| 209 | + const rows = new Map(this.labelRows().map((l) => [l.name, l])); | |
| 210 | + return names.map((n) => rows.get(n) ?? { id: 0, name: n, color: "ededed", description: "" }); | |
| 211 | + } | |
| 212 | + | |
| 213 | + addLabels(number: number, names: string[], caller: Caller): LabelRow[] { | |
| 214 | + this.triage(caller); | |
| 215 | + const current = JSON.parse(this.issueRow(number).labels) as string[]; | |
| 216 | + const next = [...new Set([...current, ...this.ensureLabels(names)])]; | |
| 217 | + if (next.length > 100) throw new HttpError(422, "an issue can have at most 100 labels"); | |
| 218 | + this.repo.collab.update(number, { labels: next }, caller); | |
| 219 | + return this.issueLabels(number); | |
| 220 | + } | |
| 221 | + | |
| 222 | + setLabels(number: number, names: string[], caller: Caller): LabelRow[] { | |
| 223 | + this.triage(caller); | |
| 224 | + this.issueRow(number); | |
| 225 | + this.repo.collab.update(number, { labels: this.ensureLabels(names) }, caller); | |
| 226 | + return this.issueLabels(number); | |
| 227 | + } | |
| 228 | + | |
| 229 | + removeLabel(number: number, name: string, caller: Caller): LabelRow[] { | |
| 230 | + this.triage(caller); | |
| 231 | + const current = JSON.parse(this.issueRow(number).labels) as string[]; | |
| 232 | + if (!current.includes(name)) throw new HttpError(404, "Label does not exist"); | |
| 233 | + this.repo.collab.update(number, { labels: current.filter((l) => l !== name) }, caller); | |
| 234 | + return this.issueLabels(number); | |
| 235 | + } | |
| 236 | + | |
| 237 | + // ---------------------------------------------------------------- assignees and locking | |
| 238 | + | |
| 239 | + assign(number: number, logins: string[], add: boolean, caller: Caller): void { | |
| 240 | + this.triage(caller); | |
| 241 | + const current = JSON.parse(this.issueRow(number).assignees) as string[]; | |
| 242 | + const set = new Set(current); | |
| 243 | + for (const l of logins.map(String).filter(Boolean)) { | |
| 244 | + if (add) set.add(l); | |
| 245 | + else for (const x of [...set]) if (x.toLowerCase() === l.toLowerCase()) set.delete(x); | |
| 246 | + } | |
| 247 | + if (set.size > 10) throw new HttpError(422, "an issue can have at most 10 assignees"); | |
| 248 | + this.repo.collab.update(number, { assignees: [...set] }, caller); | |
| 249 | + } | |
| 250 | + | |
| 251 | + lock(number: number, reason: string | null, caller: Caller): void { | |
| 252 | + this.triage(caller); | |
| 253 | + if (reason && !["off-topic", "too heated", "resolved", "spam"].includes(reason)) throw new HttpError(422, "lock_reason must be off-topic, too heated, resolved or spam"); | |
| 254 | + this.issueRow(number); | |
| 255 | + this.sql.exec("UPDATE issues SET locked = 1, updated_at = ? WHERE number = ?", now(), number); | |
| 256 | + this.record(number, "locked", caller, { lock_reason: reason }); | |
| 257 | + } | |
| 258 | + | |
| 259 | + unlock(number: number, caller: Caller): void { | |
| 260 | + this.triage(caller); | |
| 261 | + this.issueRow(number); | |
| 262 | + this.sql.exec("UPDATE issues SET locked = 0, updated_at = ? WHERE number = ?", now(), number); | |
| 263 | + this.record(number, "unlocked", caller); | |
| 264 | + } | |
| 265 | + | |
| 266 | + // ---------------------------------------------------------------- comments | |
| 267 | + | |
| 268 | + /** A comment's internal id from either form clients send: GitHub's numeric id or Diffinery's own. */ | |
| 269 | + commentKey(raw: string): string { | |
| 270 | + if (!/^\d{1,15}$/.test(raw)) return raw; | |
| 271 | + return this.sql.exec<{ id: string }>("SELECT id FROM comments WHERE rowid = ?", Number(raw)).toArray()[0]?.id ?? raw; | |
| 272 | + } | |
| 273 | + | |
| 274 | + /** Reaction subjects keep comments under their internal id, whichever id the client used. */ | |
| 275 | + private subjectKey(subject: string): string { | |
| 276 | + const i = subject.indexOf(":"); | |
| 277 | + const kind = subject.slice(0, i); | |
| 278 | + return kind === "comment" || kind === "review_comment" ? `${kind}:${this.commentKey(subject.slice(i + 1))}` : subject; | |
| 279 | + } | |
| 280 | + | |
| 281 | + private commentRow(id: string, review: boolean): CommentRow { | |
| 282 | + const c = this.sql.exec<CommentRow>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.id = ? AND c.review_id IS ${review ? "NOT " : ""}NULL`, this.commentKey(id)).toArray()[0]; | |
| 283 | + if (!c) throw new HttpError(404, "comment not found"); | |
| 284 | + return c; | |
| 285 | + } | |
| 286 | + | |
| 287 | + /** One issue comment (or, with `review`, one review comment) and the issue it belongs to. */ | |
| 288 | + comment(id: string, review = false): { number: number; isPull: boolean; comment: Comment } { | |
| 289 | + const c = this.commentRow(id, review); | |
| 290 | + return { number: c.number, isPull: this.issueRow(c.number).kind === "pull", comment: toComment(c) }; | |
| 291 | + } | |
| 292 | + | |
| 293 | + deleteComment(id: string, review: boolean, caller: Caller): void { | |
| 294 | + const c = this.commentRow(id, review); | |
| 295 | + if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied"); | |
| 296 | + this.sql.exec("DELETE FROM comments WHERE id = ?", c.id); | |
| 297 | + this.sql.exec("DELETE FROM reactions WHERE subject = ?", `${review ? "review_comment" : "comment"}:${c.id}`); | |
| 298 | + this.repo.broadcast(`pr:${c.number}`, { action: "comment", id: c.id }); | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** Comments across the repository (issue comments, or review comments with `review`). */ | |
| 302 | + repoComments(opts: { review: boolean; since?: string; sort?: string; direction?: string; limit: number; offset: number }): { items: Array<{ number: number; isPull: boolean; comment: Comment }>; total: number } { | |
| 303 | + const where = [`c.review_id IS ${opts.review ? "NOT " : ""}NULL`]; | |
| 304 | + const args: unknown[] = []; | |
| 305 | + if (opts.since) { | |
| 306 | + where.push("c.updated_at >= ?"); | |
| 307 | + args.push(opts.since); | |
| 308 | + } | |
| 309 | + const col = opts.sort === "updated" ? "c.updated_at" : "c.created_at"; | |
| 310 | + const dir = opts.direction === "desc" ? "DESC" : "ASC"; | |
| 311 | + const w = `WHERE ${where.join(" AND ")}`; | |
| 312 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM comments c ${w}`, ...args).one().n; | |
| 313 | + const rows = this.sql.exec<CommentRow & { kind: string }>(`SELECT ${COMMENT_SELECT}, i.kind FROM comments c JOIN issues i ON i.number = c.number ${w} ORDER BY ${col} ${dir}, c.rowid ${dir} LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit, 100), opts.offset).toArray(); | |
| 314 | + return { items: rows.map((r) => ({ number: r.number, isPull: r.kind === "pull", comment: toComment(r) })), total }; | |
| 315 | + } | |
| 316 | + | |
| 317 | + /** A single review comment, or a reply to one (`in_reply_to`), as a one-comment COMMENTED review. */ | |
| 318 | + createReviewComment(number: number, input: { body?: string; commit_id?: string; path?: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; in_reply_to?: string }, caller: Caller): Comment { | |
| 319 | + if (!input.body?.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "body" }] }); | |
| 320 | + let path = input.path; | |
| 321 | + let line = input.line ?? input.position; | |
| 322 | + let side = input.side; | |
| 323 | + let replyTo: string | undefined; | |
| 324 | + if (input.in_reply_to) { | |
| 325 | + const parent = this.commentRow(String(input.in_reply_to), true); | |
| 326 | + replyTo = parent.id; | |
| 327 | + if (parent.number !== number) throw new HttpError(404, "comment not found"); | |
| 328 | + path = parent.path ?? undefined; | |
| 329 | + line = parent.line ?? undefined; | |
| 330 | + side = (parent.side as "LEFT" | "RIGHT" | null) ?? undefined; | |
| 331 | + } | |
| 332 | + if (!path || !line) throw new HttpError(422, "path and line are required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "line" }] }); | |
| 333 | + const review = this.repo.collab.submitReview(number, { event: "COMMENT", comments: [{ path, line: Number(line), side, body: input.body, inReplyTo: replyTo }] }, caller); | |
| 334 | + const row = this.sql.exec<CommentRow>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.review_id = ? ORDER BY c.rowid DESC LIMIT 1`, review.id).one(); | |
| 335 | + return toComment(row); | |
| 336 | + } | |
| 337 | + | |
| 338 | + // ---------------------------------------------------------------- reviews and reviewers | |
| 339 | + | |
| 340 | + /** A review by GitHub's numeric id or Diffinery's own. */ | |
| 341 | + review(number: number, id: string): Review { | |
| 342 | + const r = this.repo.collab.get(number).reviews.find((x) => x.id === id || String(x.seq) === id); | |
| 343 | + if (!r) throw new HttpError(404, "review not found"); | |
| 344 | + return r; | |
| 345 | + } | |
| 346 | + | |
| 347 | + /** Dismissing a review can unblock a merge, so it takes maintain access, and never the pull request's author or an agent. */ | |
| 348 | + dismissReview(number: number, id: string, message: string, caller: Caller): Review { | |
| 349 | + if (!atLeast(caller.role, "maintain")) throw new HttpError(403, "maintain access is required to dismiss reviews"); | |
| 350 | + if (caller.kind === "agent") throw new HttpError(403, "agents can't dismiss reviews"); | |
| 351 | + if (this.issueRow(number).author_id === caller.id) throw new HttpError(403, "you can't dismiss reviews on your own pull request"); | |
| 352 | + const r = this.review(number, id); | |
| 353 | + if (r.state !== "APPROVED" && r.state !== "CHANGES_REQUESTED") throw new HttpError(422, "only approving or change-requesting reviews can be dismissed"); | |
| 354 | + this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE id = ?", r.id); | |
| 355 | + this.record(number, "review_dismissed", caller, { dismissed_review: { state: r.state.toLowerCase(), review_id: r.seq ?? r.id, dismissal_message: message } }); | |
| 356 | + this.repo.broadcast(`pr:${number}`, { action: "review", state: "DISMISSED" }); | |
| 357 | + return this.review(number, r.id); | |
| 358 | + } | |
| 359 | + | |
| 360 | + removeReviewers(number: number, logins: string[], caller: Caller): void { | |
| 361 | + const r = this.issueRow(number); | |
| 362 | + if (!atLeast(caller.role, "triage") && r.author_id !== caller.id) throw new HttpError(403, "permission denied"); | |
| 363 | + const p = this.sql.exec<{ requested_reviewers: string }>("SELECT requested_reviewers FROM pulls WHERE number = ?", number).toArray()[0]; | |
| 364 | + if (!p) throw new HttpError(404, "pull request not found"); | |
| 365 | + const drop = new Set(logins.map((l) => String(l).toLowerCase())); | |
| 366 | + const left = (JSON.parse(p.requested_reviewers) as string[]).filter((l) => !drop.has(l.toLowerCase())); | |
| 367 | + this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(left), number); | |
| 368 | + for (const l of logins) this.record(number, "review_request_removed", caller, { requested_reviewer: l }); | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** Merge the base branch into the pull request's head branch (GitHub's "Update branch"). */ | |
| 372 | + async updateBranch(number: number, expectedHeadSha: string | undefined, caller: Caller): Promise<{ sha: string }> { | |
| 373 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to update the branch"); | |
| 374 | + const p = this.sql.exec<{ head_ref: string; head_sha: string; base_ref: string; merged: number }>("SELECT head_ref, head_sha, base_ref, merged FROM pulls WHERE number = ?", number).toArray()[0]; | |
| 375 | + if (!p) throw new HttpError(404, "pull request not found"); | |
| 376 | + if (this.issueRow(number).state !== "open") throw new HttpError(422, "pull request is closed"); | |
| 377 | + if (expectedHeadSha && expectedHeadSha !== p.head_sha) throw new HttpError(422, "expected head sha didn't match current head ref."); | |
| 378 | + const store = this.repo.store; | |
| 379 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); | |
| 380 | + if (!baseSha) throw new HttpError(422, `base branch ${p.base_ref} no longer exists`); | |
| 381 | + if (await isAncestor(store, baseSha, p.head_sha)) throw new HttpError(422, "There are no new commits on the base branch."); | |
| 382 | + const mb = await mergeBase(store, baseSha, p.head_sha); | |
| 383 | + const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), mb ? readCommit(store, mb) : null]); | |
| 384 | + const merged = await mergeTrees(store, mt?.tree ?? null, ht.tree, bt.tree); | |
| 385 | + if (merged.conflicts.length || !merged.tree) throw new HttpError(422, "merge conflict between base and head"); | |
| 386 | + const ts = Math.floor(Date.now() / 1000); | |
| 387 | + const sig = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 388 | + const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; | |
| 389 | + const sha = await store.write("commit", serializeCommit({ tree: merged.tree, parents: [p.head_sha, baseSha], author: sig, committer: sig, message: `Merge branch '${p.base_ref}' into ${p.head_ref}\n`, extraHeaders: extra })); | |
| 390 | + const ref = `refs/heads/${p.head_ref}`; | |
| 391 | + const check = await this.repo.checkRefUpdate(ref, p.head_sha, sha, caller); | |
| 392 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 393 | + if (!this.repo.casRef(ref, p.head_sha, sha)) throw new HttpError(409, "head branch moved; try again"); | |
| 394 | + await this.repo.afterRefUpdate(ref, p.head_sha, sha, caller); | |
| 395 | + return { sha }; | |
| 396 | + } | |
| 397 | + | |
| 398 | + /** Pull requests associated with a commit: merged by it or containing it as their head (basic). */ | |
| 399 | + async pullsForCommit(sha: string): Promise<number[]> { | |
| 400 | + const direct = this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE merge_commit = ? OR head_sha = ? ORDER BY number DESC", sha, sha).toArray().map((r) => r.number); | |
| 401 | + const open = this.sql.exec<{ number: number; head_sha: string }>("SELECT p.number, p.head_sha FROM pulls p JOIN issues i ON i.number = p.number WHERE i.state = 'open' ORDER BY p.number DESC LIMIT 50").toArray(); | |
| 402 | + const out = new Set(direct); | |
| 403 | + for (const p of open) if (!out.has(p.number) && this.repo.store.has(sha) && (await isAncestor(this.repo.store, sha, p.head_sha).catch(() => false))) out.add(p.number); | |
| 404 | + return [...out]; | |
| 405 | + } | |
| 406 | + | |
| 407 | + // ---------------------------------------------------------------- reactions | |
| 408 | + | |
| 409 | + private subjectExists(subject: string): boolean { | |
| 410 | + const [kind, id] = [subject.slice(0, subject.indexOf(":")), subject.slice(subject.indexOf(":") + 1)]; | |
| 411 | + if (kind === "issue") return this.sql.exec("SELECT 1 FROM issues WHERE number = ?", Number(id)).toArray().length > 0; | |
| 412 | + if (kind === "comment") return this.sql.exec("SELECT 1 FROM comments WHERE id = ? AND review_id IS NULL", id).toArray().length > 0; | |
| 413 | + if (kind === "review_comment") return this.sql.exec("SELECT 1 FROM comments WHERE id = ? AND review_id IS NOT NULL", id).toArray().length > 0; | |
| 414 | + if (kind === "release") return this.sql.exec("SELECT 1 FROM releases WHERE id = ?", Number(id)).toArray().length > 0; | |
| 415 | + return false; | |
| 416 | + } | |
| 417 | + | |
| 418 | + reactions(subject: string, content?: string): Reaction[] { | |
| 419 | + subject = this.subjectKey(subject); | |
| 420 | + if (!this.subjectExists(subject)) throw new HttpError(404, "not found"); | |
| 421 | + const rows = this.sql | |
| 422 | + .exec<Record<string, string | number>>(`SELECT * FROM reactions WHERE subject = ? ${content ? "AND content = ?" : ""} ORDER BY id`, ...(content ? [subject, content] : [subject])) | |
| 423 | + .toArray(); | |
| 424 | + return rows.map((r) => ({ id: r.id as number, content: r.content as string, user: { id: r.user_id as string, login: r.user_login as string, kind: r.user_kind as string }, createdAt: r.created_at as string })); | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** The issue or pull request a reaction subject belongs to (null for releases). */ | |
| 428 | + private subjectIssue(subject: string): number | null { | |
| 429 | + const [kind, id] = [subject.slice(0, subject.indexOf(":")), subject.slice(subject.indexOf(":") + 1)]; | |
| 430 | + if (kind === "issue") return Number(id); | |
| 431 | + if (kind === "comment" || kind === "review_comment") return this.sql.exec<{ number: number }>("SELECT number FROM comments WHERE id = ?", id).toArray()[0]?.number ?? null; | |
| 432 | + return null; | |
| 433 | + } | |
| 434 | + | |
| 435 | + react(subject: string, content: string, caller: Caller): { reaction: Reaction; created: boolean } { | |
| 436 | + subject = this.subjectKey(subject); | |
| 437 | + if (caller.id === "anonymous") throw new HttpError(401, "sign in to react"); | |
| 438 | + if (!REACTION_CONTENT.includes(content)) throw new HttpError(422, `content must be one of ${REACTION_CONTENT.join(", ")}`, { errors: [{ resource: "Reaction", code: "invalid", field: "content" }] }); | |
| 439 | + if (!this.subjectExists(subject)) throw new HttpError(404, "not found"); | |
| 440 | + const number = this.subjectIssue(subject); | |
| 441 | + if (number !== null && this.issueRow(number).locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked"); | |
| 442 | + const existing = this.sql.exec<{ id: number }>("SELECT id FROM reactions WHERE subject = ? AND content = ? AND user_id = ?", subject, content, caller.id).toArray()[0]; | |
| 443 | + if (!existing) this.sql.exec("INSERT INTO reactions (subject, content, user_id, user_login, user_kind, created_at) VALUES (?, ?, ?, ?, ?, ?)", subject, content, caller.id, caller.login, caller.kind, now()); | |
| 444 | + const id = existing?.id ?? this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 445 | + return { reaction: this.reactions(subject).find((r) => r.id === id)!, created: !existing }; | |
| 446 | + } | |
| 447 | + | |
| 448 | + unreact(subject: string, id: number, caller: Caller): void { | |
| 449 | + subject = this.subjectKey(subject); | |
| 450 | + const r = this.sql.exec<{ user_id: string }>("SELECT user_id FROM reactions WHERE subject = ? AND id = ?", subject, id).toArray()[0]; | |
| 451 | + if (!r) throw new HttpError(404, "reaction not found"); | |
| 452 | + if (r.user_id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "you can only delete your own reactions"); | |
| 453 | + this.sql.exec("DELETE FROM reactions WHERE id = ?", id); | |
| 454 | + } | |
| 455 | + | |
| 456 | + /** Reaction counts per subject, for embedding in issue, comment and release JSON. */ | |
| 457 | + reactionCounts(subjects: string[]): Record<string, Record<string, number>> { | |
| 458 | + const out: Record<string, Record<string, number>> = {}; | |
| 459 | + for (let i = 0; i < subjects.length; i += 50) { | |
| 460 | + const chunk = subjects.slice(i, i + 50); | |
| 461 | + const rows = this.sql.exec<{ subject: string; content: string; n: number }>(`SELECT subject, content, count(*) AS n FROM reactions WHERE subject IN (${chunk.map(() => "?").join(",")}) GROUP BY subject, content`, ...chunk).toArray(); | |
| 462 | + for (const r of rows) (out[r.subject] ??= {})[r.content] = r.n; | |
| 463 | + } | |
| 464 | + return out; | |
| 465 | + } | |
| 466 | +} | |
| 467 | + | |
| 468 | +function toEvent(r: Record<string, string | number>): IssueEvent { | |
| 469 | + return { id: r.id as number, number: r.number as number, event: r.event as string, actor: { id: r.actor_id as string, login: r.actor_login as string, kind: r.actor_kind as string }, detail: r.detail as string, createdAt: r.created_at as string }; | |
| 470 | +} | |
| 471 | + |
addedapps/diffinery/src/repo/statuses.ts+154−0View file
| @@ -1,0 +1,154 @@ | ||
| 1 | +import { HttpError, now } from "../lib/util.js"; | |
| 2 | +import type { Check } from "./collab.js"; | |
| 3 | +import type { RepoDO } from "./RepoDO.js"; | |
| 4 | +import { atLeast, type Caller } from "./types.js"; | |
| 5 | + | |
| 6 | +export type StatusState = "error" | "failure" | "pending" | "success"; | |
| 7 | +const STATES = new Set<StatusState>(["error", "failure", "pending", "success"]); | |
| 8 | + | |
| 9 | +type StatusRow = { | |
| 10 | + id: number; | |
| 11 | + sha: string; | |
| 12 | + state: StatusState; | |
| 13 | + context: string; | |
| 14 | + description: string | null; | |
| 15 | + target_url: string | null; | |
| 16 | + creator_id: string; | |
| 17 | + creator_login: string; | |
| 18 | + creator_kind: string; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export type CommitStatus = { | |
| 24 | + id: number; | |
| 25 | + sha: string; | |
| 26 | + state: StatusState; | |
| 27 | + context: string; | |
| 28 | + description: string | null; | |
| 29 | + targetUrl: string | null; | |
| 30 | + creator: { id: string; login: string; kind: string }; | |
| 31 | + createdAt: string; | |
| 32 | + updatedAt: string; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +const toStatus = (r: StatusRow): CommitStatus => ({ | |
| 36 | + id: r.id, | |
| 37 | + sha: r.sha, | |
| 38 | + state: r.state, | |
| 39 | + context: r.context, | |
| 40 | + description: r.description, | |
| 41 | + targetUrl: r.target_url, | |
| 42 | + creator: { id: r.creator_id, login: r.creator_login, kind: r.creator_kind }, | |
| 43 | + createdAt: r.created_at, | |
| 44 | + updatedAt: r.updated_at, | |
| 45 | +}); | |
| 46 | + | |
| 47 | +/** Combined state as GitHub computes it: any failure or error fails, any pending (or none) is pending. */ | |
| 48 | +export function combineStates(states: StatusState[]): StatusState | "pending" { | |
| 49 | + if (states.some((s) => s === "error" || s === "failure")) return "failure"; | |
| 50 | + if (!states.length || states.some((s) => s === "pending")) return "pending"; | |
| 51 | + return "success"; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Commit statuses (the older, simpler API next to check runs). External CI and | |
| 56 | + * bots post them; they show with the checks on pull requests and commits, and | |
| 57 | + * branch protection's required checks match a status `context` too. | |
| 58 | + */ | |
| 59 | +export class Statuses { | |
| 60 | + constructor(private repo: RepoDO) {} | |
| 61 | + | |
| 62 | + private get sql() { | |
| 63 | + return this.repo.sql; | |
| 64 | + } | |
| 65 | + | |
| 66 | + async create(rev: string, input: { state?: string; target_url?: string | null; description?: string | null; context?: string }, caller: Caller): Promise<CommitStatus> { | |
| 67 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create commit statuses"); | |
| 68 | + const sha = await this.repo.resolve(rev); | |
| 69 | + if (!sha) throw new HttpError(422, `no commit found for SHA: ${rev}`, { errors: [{ resource: "Status", code: "invalid", field: "sha" }] }); | |
| 70 | + const state = input.state as StatusState; | |
| 71 | + if (!STATES.has(state)) throw new HttpError(422, "state must be one of error, failure, pending, success", { errors: [{ resource: "Status", code: "invalid", field: "state" }] }); | |
| 72 | + const target = input.target_url || null; | |
| 73 | + if (target && !/^https?:\/\//i.test(target)) throw new HttpError(422, "target_url must be an http(s) URL", { errors: [{ resource: "Status", code: "invalid", field: "target_url" }] }); | |
| 74 | + const context = (input.context || "default").slice(0, 255); | |
| 75 | + if (this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM statuses WHERE sha = ? AND context = ?", sha, context).one().n >= 1000) { | |
| 76 | + throw new HttpError(422, "this SHA and context has reached the maximum number of statuses"); | |
| 77 | + } | |
| 78 | + const t = now(); | |
| 79 | + this.sql.exec( | |
| 80 | + "INSERT INTO statuses (sha, state, context, description, target_url, creator_id, creator_login, creator_kind, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 81 | + sha, | |
| 82 | + state, | |
| 83 | + context, | |
| 84 | + input.description ? input.description.slice(0, 1024) : null, | |
| 85 | + target, | |
| 86 | + caller.id, | |
| 87 | + caller.login, | |
| 88 | + caller.kind, | |
| 89 | + t, | |
| 90 | + t, | |
| 91 | + ); | |
| 92 | + const status = toStatus(this.sql.exec<StatusRow>("SELECT * FROM statuses WHERE id = last_insert_rowid()").one()); | |
| 93 | + const m = this.repo.meta; | |
| 94 | + this.repo.emit({ | |
| 95 | + type: "status", | |
| 96 | + repoId: m.repoId, | |
| 97 | + owner: m.owner, | |
| 98 | + repo: m.name, | |
| 99 | + payload: { id: status.id, sha, name: `${m.owner}/${m.name}`, state, context, description: status.description, target_url: target, commit: { sha }, created_at: t, updated_at: t }, | |
| 100 | + actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, | |
| 101 | + at: t, | |
| 102 | + }); | |
| 103 | + for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" }); | |
| 104 | + if (state !== "pending") { | |
| 105 | + const queueBase = this.repo.queue.baseForGroup(sha); | |
| 106 | + if (queueBase) this.repo.queue.schedule(queueBase); | |
| 107 | + } | |
| 108 | + return status; | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** Every status for a commit, newest first. */ | |
| 112 | + list(sha: string): CommitStatus[] { | |
| 113 | + return this.sql.exec<StatusRow>("SELECT * FROM statuses WHERE sha = ? ORDER BY id DESC", sha).toArray().map(toStatus); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** The latest status per context. */ | |
| 117 | + latest(sha: string): CommitStatus[] { | |
| 118 | + return this.sql | |
| 119 | + .exec<StatusRow>("SELECT s.* FROM statuses s WHERE s.sha = ? AND s.id = (SELECT max(id) FROM statuses x WHERE x.sha = s.sha AND x.context = s.context) ORDER BY s.context", sha) | |
| 120 | + .toArray() | |
| 121 | + .map(toStatus); | |
| 122 | + } | |
| 123 | + | |
| 124 | + combined(sha: string): { state: string; statuses: CommitStatus[] } { | |
| 125 | + const statuses = this.latest(sha); | |
| 126 | + return { state: combineStates(statuses.map((s) => s.state)), statuses }; | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** Latest statuses shaped like check runs, for the UI and merge requirements. */ | |
| 130 | + asChecks(sha: string): Check[] { | |
| 131 | + // Newest per context *and creator*: one reporter's later status can't overwrite another's for required checks. | |
| 132 | + const rows = this.sql | |
| 133 | + .exec<StatusRow>("SELECT s.* FROM statuses s WHERE s.sha = ? AND s.id = (SELECT max(id) FROM statuses x WHERE x.sha = s.sha AND x.context = s.context AND x.creator_id = s.creator_id) ORDER BY s.context, s.id", sha) | |
| 134 | + .toArray() | |
| 135 | + .map(toStatus); | |
| 136 | + return rows.map((s) => ({ | |
| 137 | + id: `status-${s.id}`, | |
| 138 | + sha, | |
| 139 | + name: s.context, | |
| 140 | + status: s.state === "pending" ? "in_progress" : "completed", | |
| 141 | + conclusion: s.state === "pending" ? null : s.state === "error" ? "failure" : s.state, | |
| 142 | + runId: null, | |
| 143 | + jobId: null, | |
| 144 | + detailsUrl: s.targetUrl, | |
| 145 | + output: s.description ? { title: s.description } : null, | |
| 146 | + app: "status", | |
| 147 | + startedAt: s.createdAt, | |
| 148 | + completedAt: s.state === "pending" ? null : s.updatedAt, | |
| 149 | + creatorId: s.creator.id, | |
| 150 | + creatorLogin: s.creator.login, | |
| 151 | + creatorKind: s.creator.kind, | |
| 152 | + })); | |
| 153 | + } | |
| 154 | +} |
modifiedapps/diffinery/src/repo/types.ts+2−0View file
| @@ -24,6 +24,8 @@ | ||
| 24 | 24 | requiredApprovals?: number; |
| 25 | 25 | dismissStaleReviews?: boolean; |
| 26 | 26 | requiredChecks?: string[]; |
| 27 | + /** Bind a required check to one reporter: check name → login, or "diffinery-actions". */ | |
| 28 | + requiredCheckSources?: Record<string, string>; | |
| 27 | 29 | allowForcePush?: boolean; |
| 28 | 30 | allowDeletion?: boolean; |
| 29 | 31 | requireLinearHistory?: boolean; |
addedapps/diffinery/src/web/checks.tsx+23−0View file
| @@ -1,0 +1,23 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import type { FC } from "hono/jsx"; | |
| 3 | +import type { Check } from "../repo/collab.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * A check's name, linked to its job for Diffinery checks, or to the | |
| 7 | + * `target_url` / `details_url` an external CI reported (http(s) only). | |
| 8 | + */ | |
| 9 | +export const CheckName: FC<{ base: string; ch: Pick<Check, "name" | "runId" | "jobId"> & { detailsUrl?: string | null }; class?: string }> = ({ base, ch, class: cls }) => { | |
| 10 | + if (ch.runId) | |
| 11 | + return ( | |
| 12 | + <a class={cls} href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}> | |
| 13 | + {ch.name} | |
| 14 | + </a> | |
| 15 | + ); | |
| 16 | + if (ch.detailsUrl && /^https?:\/\//i.test(ch.detailsUrl)) | |
| 17 | + return ( | |
| 18 | + <a class={cls} href={ch.detailsUrl} rel="noopener noreferrer nofollow" target="_blank"> | |
| 19 | + {ch.name} | |
| 20 | + </a> | |
| 21 | + ); | |
| 22 | + return <span class={cls}>{ch.name}</span>; | |
| 23 | +}; |
modifiedapps/diffinery/src/web/code.tsx+7−3View file
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | import { ICONS } from "./assets.js"; |
| 9 | 9 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 10 | 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | +import { CheckName } from "./checks.js"; | |
| 12 | +import { ReleasesSection } from "./releases.js"; | |
| 11 | 13 | import { |
| 12 | 14 | CommitRow, |
| 13 | 15 | CopyButton, |
| @@ -118,11 +120,12 @@ | ||
| 118 | 120 | async function treePage(c: AppContext, l: Loaded, ref: string, path: string, root: boolean): Promise<Response> { |
| 119 | 121 | const { repo, caller, stub } = l; |
| 120 | 122 | const base = repoUrl(repo); |
| 121 | - const [tree, names, stats, last] = await Promise.all([ | |
| 123 | + const [tree, names, stats, last, releases] = await Promise.all([ | |
| 122 | 124 | stub.tree(ref, path), |
| 123 | 125 | refNames(stub), |
| 124 | 126 | root && !path ? stub.stats() : Promise.resolve(null), |
| 125 | 127 | !path ? stub.log(ref, { limit: 1 }).catch(() => []) : Promise.resolve([]), |
| 128 | + root && !path ? stub.releaseSummary(caller).catch(() => null) : Promise.resolve(null), | |
| 126 | 129 | ]); |
| 127 | 130 | const counts = stats ? { issues: stats.openIssues, pulls: stats.openPulls } : undefined; |
| 128 | 131 | if (!tree) { |
| @@ -262,6 +265,7 @@ | ||
| 262 | 265 | </dl> |
| 263 | 266 | ) : null} |
| 264 | 267 | </section> |
| 268 | + <ReleasesSection base={base} summary={releases} canWrite={atLeast(caller.role, "write") && !repo.archived} topics={repo.topics} /> | |
| 265 | 269 | <section> |
| 266 | 270 | <h3>Shortcuts</h3> |
| 267 | 271 | <p class="small muted m0"> |
| @@ -847,7 +851,7 @@ | ||
| 847 | 851 | const { repo, caller, stub } = l; |
| 848 | 852 | const base = repoUrl(repo); |
| 849 | 853 | const cm = await stub.commitWithDiff(prm(c, "sha")); |
| 850 | - const checks = await stub.checks(cm.sha).catch(() => []); | |
| 854 | + const checks = await stub.checksAndStatuses(cm.sha).catch(() => []); | |
| 851 | 855 | const [title, ...rest] = cm.message.split("\n"); |
| 852 | 856 | const body = rest.join("\n").trim(); |
| 853 | 857 | const files = cm.files as DiffFile[]; |
| @@ -889,7 +893,7 @@ | ||
| 889 | 893 | {checks.map((ch) => ( |
| 890 | 894 | <li> |
| 891 | 895 | <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>} | |
| 896 | + <CheckName base={base} ch={ch} /> | |
| 893 | 897 | <span class="muted small">{ch.output?.title ?? ""}</span> |
| 894 | 898 | </li> |
| 895 | 899 | ))} |
modifiedapps/diffinery/src/web/collab.tsx+5−10View file
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { csrfToken } from "./csrf.js"; |
| 12 | 12 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 13 | 13 | import { mentions } from "../mail/notify.js"; |
| 14 | +import { CheckName } from "./checks.js"; | |
| 14 | 15 | import { |
| 15 | 16 | Actor, |
| 16 | 17 | CommitRow, |
| @@ -696,7 +697,7 @@ | ||
| 696 | 697 | {showChecks.map((ch) => ( |
| 697 | 698 | <li> |
| 698 | 699 | <Status status={ch.status} conclusion={ch.conclusion} /> |
| 699 | - {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>} | |
| 700 | + <CheckName base={base} ch={ch} /> | |
| 700 | 701 | <span class="muted small grow">{ch.output?.title && ch.output.title !== ch.conclusion ? ch.output.title : ""}</span> |
| 701 | 702 | <span class="muted small">{ch.startedAt ? duration(ch.startedAt, ch.completedAt) : ""}</span> |
| 702 | 703 | </li> |
| @@ -961,7 +962,7 @@ | ||
| 961 | 962 | ); |
| 962 | 963 | |
| 963 | 964 | if (tab === "conversation") { |
| 964 | - 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[])]); | |
| 965 | + const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[])]); | |
| 965 | 966 | return page( |
| 966 | 967 | <div class="grid"> |
| 967 | 968 | <div> |
| @@ -1007,7 +1008,7 @@ | ||
| 1007 | 1008 | } |
| 1008 | 1009 | |
| 1009 | 1010 | if (tab === "checks") { |
| 1010 | - const [checks, runs] = await Promise.all([stub.checks(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]); | |
| 1011 | + const [checks, runs] = await Promise.all([stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]); | |
| 1011 | 1012 | return page( |
| 1012 | 1013 | <div data-fragment="checks"> |
| 1013 | 1014 | <div class="box"> |
| @@ -1020,13 +1021,7 @@ | ||
| 1020 | 1021 | <li> |
| 1021 | 1022 | <Status status={ch.status} conclusion={ch.conclusion} /> |
| 1022 | 1023 | <div class="grow"> |
| 1023 | - {ch.runId ? ( | |
| 1024 | - <a class="title" href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}> | |
| 1025 | - {ch.name} | |
| 1026 | - </a> | |
| 1027 | - ) : ( | |
| 1028 | - <span class="title">{ch.name}</span> | |
| 1029 | - )} | |
| 1024 | + <CheckName base={base} ch={ch} class="title" /> | |
| 1030 | 1025 | <div class="meta"> |
| 1031 | 1026 | {ch.app} · {ch.status === "completed" ? (ch.conclusion ?? "neutral") : ch.status.replace("_", " ")} |
| 1032 | 1027 | {ch.output?.summary ? ` · ${ch.output.summary.slice(0, 200)}` : ""} |
modifiedapps/diffinery/src/web/csrf.ts+20−3View file
| @@ -37,8 +37,25 @@ | ||
| 37 | 37 | keys(): string[]; |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | +/** Form bodies bigger than this are refused before they are read (routes taking files pass a larger cap). */ | |
| 41 | +export const MAX_FORM_BYTES = 10 * 1024 * 1024; | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * Refuse a body over `maxBytes` before anything buffers it. Browsers always | |
| 45 | + * send Content-Length with a form, so a body without one is refused too. | |
| 46 | + */ | |
| 47 | +export function checkFormSize(c: AppContext, maxBytes = MAX_FORM_BYTES): void { | |
| 48 | + const len = c.req.header("content-length"); | |
| 49 | + if (len === undefined || !/^\d+$/.test(len)) { | |
| 50 | + if (c.req.raw.body === null) return; | |
| 51 | + throw new HttpError(411, "send the form with a Content-Length header"); | |
| 52 | + } | |
| 53 | + if (Number(len) > maxBytes) throw new HttpError(413, `the form is too large (the limit is ${Math.floor(maxBytes / 1024 / 1024)} MB)`); | |
| 54 | +} | |
| 55 | + | |
| 40 | 56 | /** Parse an urlencoded/multipart form once per request (Hono caches the body). */ |
| 41 | -export async function formData(c: AppContext): Promise<FormData> { | |
| 57 | +export async function formData(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> { | |
| 58 | + checkFormSize(c, maxBytes); | |
| 42 | 59 | const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>; |
| 43 | 60 | const all = (name: string): string[] => { |
| 44 | 61 | const v = raw[name]; |
| @@ -57,7 +74,7 @@ | ||
| 57 | 74 | } |
| 58 | 75 | |
| 59 | 76 | /** Reject state-changing requests without a matching token (and cross-origin requests). */ |
| 60 | -export async function verifyCsrf(c: AppContext): Promise<FormData> { | |
| 77 | +export async function verifyCsrf(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> { | |
| 61 | 78 | const origin = c.req.header("origin"); |
| 62 | 79 | if (origin && origin !== "null") { |
| 63 | 80 | const self = new URL(c.req.url).origin; |
| @@ -66,7 +83,7 @@ | ||
| 66 | 83 | } |
| 67 | 84 | const type = c.req.header("content-type") ?? ""; |
| 68 | 85 | 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); | |
| 86 | + const form = await formData(c, maxBytes); | |
| 70 | 87 | const cookie = getCookie(c, CSRF_COOKIE); |
| 71 | 88 | const sent = form.s(CSRF_FIELD); |
| 72 | 89 | if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again"); |
addedapps/diffinery/src/web/releases.tsx+478−0View file
| @@ -1,0 +1,478 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { FC } from "hono/jsx"; | |
| 4 | +import type { AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { storeAsset } from "../http/files.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import type { Release } from "../repo/releases.js"; | |
| 9 | +import { atLeast } from "../repo/types.js"; | |
| 10 | +import { ICONS } from "./assets.js"; | |
| 11 | +import { CSRF_FIELD, csrfToken, type FormData } from "./csrf.js"; | |
| 12 | +import { KCheckbox, KSelect } from "./kumo.js"; | |
| 13 | +import { Actor, Form, Markdown, Pager, Time, act, encPath, flash, fmtBytes, pageNum, post, prm, refNames, render, repoUrl, restOfPath, statusOf, type WebApp } from "./ui.js"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Releases: the list, a release page per tag, and the new/edit form with | |
| 17 | + * asset uploads. Bytes go to R2 through the same path as the REST upload_url. | |
| 18 | + */ | |
| 19 | + | |
| 20 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 21 | + | |
| 22 | +/** The largest upload the web form takes in one submission; bigger files go through the API's upload_url. */ | |
| 23 | +const MAX_FORM_UPLOAD = 100 * 1024 * 1024; | |
| 24 | +/** The whole form body, files plus the other fields; checked before the body is read. */ | |
| 25 | +const MAX_FORM_BODY = MAX_FORM_UPLOAD + 1024 * 1024; | |
| 26 | + | |
| 27 | +async function forAction(c: AppContext): Promise<Loaded> { | |
| 28 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 29 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 30 | + if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); | |
| 31 | + return l; | |
| 32 | +} | |
| 33 | + | |
| 34 | +function releaseId(c: AppContext, name = "id"): number { | |
| 35 | + const n = Number(prm(c, name)); | |
| 36 | + if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found"); | |
| 37 | + return n; | |
| 38 | +} | |
| 39 | + | |
| 40 | +const tagHref = (base: string, tag: string) => `${base}/releases/tag/${encPath(tag)}`; | |
| 41 | + | |
| 42 | +// ---------------------------------------------------------------- repo home sidebar | |
| 43 | + | |
| 44 | +/** The "Releases" section on the repository home page, plus topics when the repo has any. */ | |
| 45 | +export const ReleasesSection: FC<{ base: string; summary: { count: number; latest: Release | null } | null; canWrite: boolean; topics?: string }> = ({ base, summary, canWrite, topics }) => { | |
| 46 | + let names: string[] = []; | |
| 47 | + try { | |
| 48 | + const t = JSON.parse(topics ?? "[]"); | |
| 49 | + if (Array.isArray(t)) names = t.filter((x): x is string => typeof x === "string"); | |
| 50 | + } catch { | |
| 51 | + names = []; | |
| 52 | + } | |
| 53 | + return ( | |
| 54 | + <> | |
| 55 | + {names.length ? ( | |
| 56 | + <section> | |
| 57 | + <h3>Topics</h3> | |
| 58 | + <div class="row" style="gap:6px;flex-wrap:wrap"> | |
| 59 | + {names.map((n) => ( | |
| 60 | + <span class="badge blue">{n}</span> | |
| 61 | + ))} | |
| 62 | + </div> | |
| 63 | + </section> | |
| 64 | + ) : null} | |
| 65 | + <section> | |
| 66 | + <h3> | |
| 67 | + <a href={`${base}/releases`} style="color:inherit"> | |
| 68 | + Releases | |
| 69 | + </a>{" "} | |
| 70 | + {summary?.count ? <span class="badge">{summary.count}</span> : null} | |
| 71 | + </h3> | |
| 72 | + {summary?.latest ? ( | |
| 73 | + <div class="row" style="align-items:flex-start"> | |
| 74 | + <span class="st green">{raw(ICONS.tag)}</span> | |
| 75 | + <div> | |
| 76 | + <a href={tagHref(base, summary.latest.tagName)}> | |
| 77 | + <b>{summary.latest.name || summary.latest.tagName}</b> | |
| 78 | + </a>{" "} | |
| 79 | + <span class="badge green">Latest</span> | |
| 80 | + <div class="muted small"> | |
| 81 | + <Time at={summary.latest.publishedAt ?? summary.latest.createdAt} /> | |
| 82 | + </div> | |
| 83 | + </div> | |
| 84 | + </div> | |
| 85 | + ) : ( | |
| 86 | + <p class="small muted m0"> | |
| 87 | + No releases published.{" "} | |
| 88 | + {canWrite ? <a href={`${base}/releases/new`}>Create a new release</a> : null} | |
| 89 | + </p> | |
| 90 | + )} | |
| 91 | + </section> | |
| 92 | + </> | |
| 93 | + ); | |
| 94 | +}; | |
| 95 | + | |
| 96 | +// ---------------------------------------------------------------- list and release pages | |
| 97 | + | |
| 98 | +const Badges: FC<{ r: Release }> = ({ r }) => ( | |
| 99 | + <> | |
| 100 | + {r.draft ? <span class="badge amber">Draft</span> : null} | |
| 101 | + {r.prerelease ? <span class="badge amber">Pre-release</span> : null} | |
| 102 | + {r.latest ? <span class="badge green">Latest</span> : null} | |
| 103 | + </> | |
| 104 | +); | |
| 105 | + | |
| 106 | +const ReleaseCard: FC<{ c: AppContext; l: Loaded; r: Release; full?: boolean }> = ({ c, l, r, full }) => { | |
| 107 | + const base = repoUrl(l.repo); | |
| 108 | + const canWrite = atLeast(l.caller.role, "write") && !l.repo.archived; | |
| 109 | + const t = csrfToken(c); | |
| 110 | + return ( | |
| 111 | + <div class="box" id={`release-${r.id}`}> | |
| 112 | + <div class="head"> | |
| 113 | + <div class="grow"> | |
| 114 | + <h2 style="margin:0;font-size:20px"> | |
| 115 | + <a href={tagHref(base, r.tagName)} style="color:var(--fg)"> | |
| 116 | + {r.name || r.tagName} | |
| 117 | + </a>{" "} | |
| 118 | + <Badges r={r} /> | |
| 119 | + </h2> | |
| 120 | + <div class="muted small" style="margin-top:4px"> | |
| 121 | + <span class="st faint">{raw(ICONS.tag)}</span>{" "} | |
| 122 | + <a class="mono" href={`${base}/tree/${encPath(r.tagName)}`}> | |
| 123 | + {r.tagName} | |
| 124 | + </a>{" "} | |
| 125 | + · <Actor login={r.author.login} kind={r.author.kind} /> {r.draft ? "drafted this" : "released this"} <Time at={r.publishedAt ?? r.createdAt} /> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + {canWrite ? ( | |
| 129 | + <div class="row"> | |
| 130 | + <a class="btn sm" href={`${base}/releases/edit/${encPath(r.tagName)}?id=${r.id}`}> | |
| 131 | + {raw(ICONS.edit)} Edit | |
| 132 | + </a> | |
| 133 | + <Form action={`${base}/releases/${r.id}/delete`} t={t} confirm={`Delete the release ${r.name || r.tagName}? The tag stays.`}> | |
| 134 | + <button class="btn sm danger">Delete</button> | |
| 135 | + </Form> | |
| 136 | + </div> | |
| 137 | + ) : null} | |
| 138 | + </div> | |
| 139 | + <div class="body"> | |
| 140 | + <Markdown text={full || r.body.length < 4000 ? r.body : `${r.body.slice(0, 4000)}…`} base={{ owner: l.repo.owner_login, repo: l.repo.name, ref: r.tagName, dir: "" }} empty="No release notes." /> | |
| 141 | + <h3 class="mt"> | |
| 142 | + Assets <span class="badge">{r.assets.length + 2}</span> | |
| 143 | + </h3> | |
| 144 | + <ul class="list" style="border:1px solid var(--border);border-radius:6px"> | |
| 145 | + {r.assets.map((a) => ( | |
| 146 | + <li> | |
| 147 | + <span class="st faint">{raw(ICONS.file)}</span> | |
| 148 | + <div class="grow"> | |
| 149 | + <a class="title" href={`${base}/releases/download/${encPath(r.tagName)}/${encodeURIComponent(a.name)}`} rel="nofollow"> | |
| 150 | + {a.name} | |
| 151 | + </a> | |
| 152 | + {a.label && a.label !== a.name ? <span class="muted small"> {a.label}</span> : null} | |
| 153 | + </div> | |
| 154 | + <span class="muted small">{fmtBytes(a.size)}</span> | |
| 155 | + <span class="muted small" title="downloads"> | |
| 156 | + {raw(ICONS.download)} {a.downloadCount} | |
| 157 | + </span> | |
| 158 | + {canWrite && full ? ( | |
| 159 | + <Form action={`${base}/releases/${r.id}/assets/${a.id}/delete`} t={t} confirm={`Delete ${a.name}?`}> | |
| 160 | + <button class="btn sm" title="Delete asset" aria-label={`Delete ${a.name}`}> | |
| 161 | + {raw(ICONS.x)} | |
| 162 | + </button> | |
| 163 | + </Form> | |
| 164 | + ) : null} | |
| 165 | + </li> | |
| 166 | + ))} | |
| 167 | + {r.draft ? null : ( | |
| 168 | + <> | |
| 169 | + <li> | |
| 170 | + <span class="st faint">{raw(ICONS.code)}</span> | |
| 171 | + <a class="title grow" href={`${base}/archive/refs/tags/${encPath(r.tagName)}.zip`} rel="nofollow"> | |
| 172 | + Source code (zip) | |
| 173 | + </a> | |
| 174 | + </li> | |
| 175 | + <li> | |
| 176 | + <span class="st faint">{raw(ICONS.code)}</span> | |
| 177 | + <a class="title grow" href={`${base}/archive/refs/tags/${encPath(r.tagName)}.tar.gz`} rel="nofollow"> | |
| 178 | + Source code (tar.gz) | |
| 179 | + </a> | |
| 180 | + </li> | |
| 181 | + </> | |
| 182 | + )} | |
| 183 | + </ul> | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + ); | |
| 187 | +}; | |
| 188 | + | |
| 189 | +async function listPage(c: AppContext): Promise<Response> { | |
| 190 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 191 | + const page = pageNum(c); | |
| 192 | + const per = 10; | |
| 193 | + const res = await l.stub.listReleases({ limit: per, offset: (page - 1) * per }, l.caller); | |
| 194 | + const base = repoUrl(l.repo); | |
| 195 | + const canWrite = atLeast(l.caller.role, "write") && !l.repo.archived; | |
| 196 | + return render( | |
| 197 | + c, | |
| 198 | + { title: `Releases · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, description: `Releases of ${l.repo.owner_login}/${l.repo.name}` }, | |
| 199 | + <div style="max-width:980px"> | |
| 200 | + <div class="row between mb"> | |
| 201 | + <h1 class="m0">Releases</h1> | |
| 202 | + <div class="row"> | |
| 203 | + <a class="btn sm" href={`${base}/tags`}> | |
| 204 | + {raw(ICONS.tag)} Tags | |
| 205 | + </a> | |
| 206 | + {canWrite ? ( | |
| 207 | + <a class="btn sm green" href={`${base}/releases/new`}> | |
| 208 | + Draft a new release | |
| 209 | + </a> | |
| 210 | + ) : null} | |
| 211 | + </div> | |
| 212 | + </div> | |
| 213 | + {res.items.length ? ( | |
| 214 | + res.items.map((r) => <ReleaseCard c={c} l={l} r={r} />) | |
| 215 | + ) : ( | |
| 216 | + <div class="box"> | |
| 217 | + <div class="empty"> | |
| 218 | + There aren't any releases here yet. | |
| 219 | + {canWrite ? ( | |
| 220 | + <> | |
| 221 | + {" "} | |
| 222 | + <a href={`${base}/releases/new`}>Create the first release</a>, or publish one from a workflow with the REST API. | |
| 223 | + </> | |
| 224 | + ) : null} | |
| 225 | + </div> | |
| 226 | + </div> | |
| 227 | + )} | |
| 228 | + <Pager page={page} hasNext={page * per < res.total} href={(p) => `${base}/releases?page=${p}`} /> | |
| 229 | + </div>, | |
| 230 | + ); | |
| 231 | +} | |
| 232 | + | |
| 233 | +async function tagPage(c: AppContext): Promise<Response> { | |
| 234 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 235 | + const tag = restOfPath(c, 4); | |
| 236 | + if (!tag) return c.redirect(`${repoUrl(l.repo)}/releases`, 302); | |
| 237 | + const r = await l.stub.releaseByTag(tag, l.caller, true); | |
| 238 | + return render( | |
| 239 | + c, | |
| 240 | + { title: `${r.name || r.tagName} · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, description: `Release ${r.tagName} of ${l.repo.owner_login}/${l.repo.name}` }, | |
| 241 | + <div style="max-width:980px"> | |
| 242 | + <div class="crumbs mb"> | |
| 243 | + <a href={`${repoUrl(l.repo)}/releases`}>Releases</a> | |
| 244 | + <span class="faint">/</span> | |
| 245 | + <b>{r.tagName}</b> | |
| 246 | + </div> | |
| 247 | + <ReleaseCard c={c} l={l} r={r} full /> | |
| 248 | + </div>, | |
| 249 | + ); | |
| 250 | +} | |
| 251 | + | |
| 252 | +// ---------------------------------------------------------------- new and edit | |
| 253 | + | |
| 254 | +type FormValues = { tag: string; newTag: string; target: string; name: string; body: string; prerelease: boolean; latest: boolean; generate: boolean }; | |
| 255 | + | |
| 256 | +const ReleaseForm: FC<{ c: AppContext; l: Loaded; tags: string[]; branches: string[]; v: FormValues; r?: Release; error?: string }> = ({ c, l, tags, branches, v, r, error }) => { | |
| 257 | + const base = repoUrl(l.repo); | |
| 258 | + const action = r ? `${base}/releases/${r.id}/edit` : `${base}/releases`; | |
| 259 | + const targets = [...new Set([l.repo.default_branch, ...branches])]; | |
| 260 | + return ( | |
| 261 | + <div style="max-width:900px"> | |
| 262 | + <h1>{r ? `Edit release ${r.tagName}` : "New release"}</h1> | |
| 263 | + {error ? <div class="flash error">{error}</div> : null} | |
| 264 | + <form method="post" action={action} enctype="multipart/form-data" class="box"> | |
| 265 | + <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} /> | |
| 266 | + <div class="body"> | |
| 267 | + <div class="row" style="gap:12px;align-items:flex-start;flex-wrap:wrap"> | |
| 268 | + <div class="field grow" style="min-width:220px"> | |
| 269 | + <label>Tag</label> | |
| 270 | + <KSelect name="tag" label="Existing tag" placeholder="Choose an existing tag" value={tags.includes(v.tag) ? v.tag : undefined} options={tags.map((t) => ({ value: t, label: t }))} block /> | |
| 271 | + <input type="text" name="new_tag" value={tags.includes(v.tag) ? "" : v.newTag || v.tag} placeholder="or type a new tag, e.g. v1.2.0" aria-label="New tag name" style="margin-top:6px" /> | |
| 272 | + </div> | |
| 273 | + <div class="field" style="min-width:200px"> | |
| 274 | + <label>Target for a new tag</label> | |
| 275 | + <KSelect name="target" label="Target branch" value={v.target || l.repo.default_branch} options={targets.map((b) => ({ value: b, label: b }))} block /> | |
| 276 | + </div> | |
| 277 | + </div> | |
| 278 | + <div class="field"> | |
| 279 | + <label for="rn">Release title</label> | |
| 280 | + <input id="rn" name="name" type="text" value={v.name} placeholder="Defaults to the tag name" /> | |
| 281 | + </div> | |
| 282 | + <div class="field"> | |
| 283 | + <label for="rb">Release notes</label> | |
| 284 | + <textarea id="rb" name="body" style="min-height:240px" placeholder="Markdown is supported."> | |
| 285 | + {v.body} | |
| 286 | + </textarea> | |
| 287 | + </div> | |
| 288 | + <div class="field"> | |
| 289 | + <label for="ra">Attach files</label> | |
| 290 | + <input id="ra" name="assets" type="file" multiple /> | |
| 291 | + <p class="small muted m0">Up to 100 MB per submission. Use the REST API's upload_url for larger files.</p> | |
| 292 | + </div> | |
| 293 | + {r?.assets.length ? ( | |
| 294 | + <p class="small muted"> | |
| 295 | + Attached: {r.assets.map((a) => a.name).join(", ")}. Remove files from the <a href={tagHref(base, r.tagName)}>release page</a>. | |
| 296 | + </p> | |
| 297 | + ) : null} | |
| 298 | + <div class="field"> | |
| 299 | + <KCheckbox name="prerelease" checked={v.prerelease} label="Set as a pre-release (not production ready)" /> | |
| 300 | + <KCheckbox name="latest" checked={v.latest} label="Set as the latest release" /> | |
| 301 | + {r ? null : <KCheckbox name="generate" checked={v.generate} label="Generate release notes from merged pull requests (when the notes are empty)" />} | |
| 302 | + </div> | |
| 303 | + <div class="row"> | |
| 304 | + {r && !r.draft ? ( | |
| 305 | + <button class="btn green" name="action" value="publish"> | |
| 306 | + Update release | |
| 307 | + </button> | |
| 308 | + ) : ( | |
| 309 | + <> | |
| 310 | + <button class="btn green" name="action" value="publish"> | |
| 311 | + Publish release | |
| 312 | + </button> | |
| 313 | + <button class="btn" name="action" value="draft"> | |
| 314 | + Save draft | |
| 315 | + </button> | |
| 316 | + </> | |
| 317 | + )} | |
| 318 | + <a class="btn" href={r ? tagHref(base, r.tagName) : `${base}/releases`}> | |
| 319 | + Cancel | |
| 320 | + </a> | |
| 321 | + </div> | |
| 322 | + </div> | |
| 323 | + </form> | |
| 324 | + </div> | |
| 325 | + ); | |
| 326 | +}; | |
| 327 | + | |
| 328 | +function valuesOf(f: FormData): FormValues { | |
| 329 | + return { | |
| 330 | + tag: f.s("tag").trim(), | |
| 331 | + newTag: f.s("new_tag").trim(), | |
| 332 | + target: f.s("target").trim(), | |
| 333 | + name: f.s("name").trim(), | |
| 334 | + body: f.s("body").replace(/\r\n/g, "\n"), | |
| 335 | + prerelease: f.b("prerelease"), | |
| 336 | + latest: f.b("latest"), | |
| 337 | + generate: f.b("generate"), | |
| 338 | + }; | |
| 339 | +} | |
| 340 | + | |
| 341 | +/** The files chosen in the form's file input (empty file inputs submit a nameless, empty part). */ | |
| 342 | +async function uploadedFiles(c: AppContext): Promise<File[]> { | |
| 343 | + const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>; | |
| 344 | + const v = raw.assets; | |
| 345 | + const list = Array.isArray(v) ? v : v === undefined ? [] : [v]; | |
| 346 | + const files = list.filter((x): x is File => typeof x !== "string" && !!x.name && x.size > 0); | |
| 347 | + const total = files.reduce((n, x) => n + x.size, 0); | |
| 348 | + if (total > MAX_FORM_UPLOAD) throw new HttpError(413, "the web form takes up to 100 MB of files at once; upload larger files with the REST API"); | |
| 349 | + return files; | |
| 350 | +} | |
| 351 | + | |
| 352 | +async function attach(c: AppContext, l: Loaded, id: number, files: File[]): Promise<void> { | |
| 353 | + for (const file of files) { | |
| 354 | + await storeAsset(c.env, l.stub, id, { name: file.name, contentType: file.type || "application/octet-stream" }, { stream: file.stream(), length: file.size }, l.caller); | |
| 355 | + } | |
| 356 | +} | |
| 357 | + | |
| 358 | +async function formPage(c: AppContext, l: Loaded, v: FormValues, r?: Release, error?: string, status?: number): Promise<Response> { | |
| 359 | + const names = await refNames(l.stub); | |
| 360 | + return render(c, { title: `${r ? "Edit release" : "New release"} · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, status }, <ReleaseForm c={c} l={l} tags={[...names.tags].reverse()} branches={names.branches} v={v} r={r} error={error} />); | |
| 361 | +} | |
| 362 | + | |
| 363 | +// ---------------------------------------------------------------- routes | |
| 364 | + | |
| 365 | +export function registerReleases(web: WebApp): void { | |
| 366 | + web.get("/:owner/:repo/releases", (c) => listPage(c)); | |
| 367 | + | |
| 368 | + web.get("/:owner/:repo/releases/latest", async (c) => { | |
| 369 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 370 | + const r = await l.stub.latestRelease().catch(() => null); | |
| 371 | + return c.redirect(r ? tagHref(repoUrl(l.repo), r.tagName) : `${repoUrl(l.repo)}/releases`, 302); | |
| 372 | + }); | |
| 373 | + | |
| 374 | + web.get("/:owner/:repo/releases/tag/*", (c) => tagPage(c)); | |
| 375 | + | |
| 376 | + web.get("/:owner/:repo/releases/new", async (c) => { | |
| 377 | + const l = await forAction(c); | |
| 378 | + const tag = (c.req.query("tag") ?? "").trim(); | |
| 379 | + return formPage(c, l, { tag, newTag: tag, target: l.repo.default_branch, name: "", body: "", prerelease: false, latest: true, generate: true }); | |
| 380 | + }); | |
| 381 | + | |
| 382 | + web.get("/:owner/:repo/releases/edit/*", async (c) => { | |
| 383 | + const l = await forAction(c); | |
| 384 | + const idq = Number(c.req.query("id")); | |
| 385 | + const r = Number.isInteger(idq) && idq > 0 ? await l.stub.getRelease(idq, l.caller) : await l.stub.releaseByTag(restOfPath(c, 4), l.caller, true); | |
| 386 | + return formPage(c, l, { tag: r.tagName, newTag: r.tagName, target: r.targetCommitish, name: r.name, body: r.body, prerelease: r.prerelease, latest: r.latest, generate: false }, r); | |
| 387 | + }); | |
| 388 | + | |
| 389 | + post(web, "/:owner/:repo/releases", async (c, f) => { | |
| 390 | + const l = await forAction(c); | |
| 391 | + const v = valuesOf(f); | |
| 392 | + const draft = f.s("action") === "draft"; | |
| 393 | + try { | |
| 394 | + const files = await uploadedFiles(c); | |
| 395 | + const tag = v.newTag || v.tag; | |
| 396 | + if (!tag) throw new HttpError(422, "choose a tag or type a new one"); | |
| 397 | + const r = await l.stub.createRelease( | |
| 398 | + { | |
| 399 | + tag_name: tag, | |
| 400 | + target_commitish: v.target || undefined, | |
| 401 | + name: v.name || null, | |
| 402 | + body: v.body || null, | |
| 403 | + draft, | |
| 404 | + prerelease: v.prerelease, | |
| 405 | + make_latest: v.latest ? "true" : "false", | |
| 406 | + generate_release_notes: v.generate && !v.body.trim(), | |
| 407 | + }, | |
| 408 | + l.caller, | |
| 409 | + ); | |
| 410 | + try { | |
| 411 | + await attach(c, l, r.id, files); | |
| 412 | + } catch (e) { | |
| 413 | + const s = statusOf(e); | |
| 414 | + if (s.status >= 500) throw e; | |
| 415 | + flash(c, "error", `The release was saved, but a file couldn't be attached: ${s.message}`); | |
| 416 | + return c.redirect(`${repoUrl(l.repo)}/releases/edit/${encPath(r.tagName)}?id=${r.id}`, 303); | |
| 417 | + } | |
| 418 | + return c.redirect(tagHref(repoUrl(l.repo), r.tagName), 303); | |
| 419 | + } catch (e) { | |
| 420 | + const s = statusOf(e); | |
| 421 | + if (s.status >= 500) throw e; | |
| 422 | + return formPage(c, l, v, undefined, s.message, s.status); | |
| 423 | + } | |
| 424 | + }, { maxBytes: MAX_FORM_BODY }); | |
| 425 | + | |
| 426 | + post(web, "/:owner/:repo/releases/:id/edit", async (c, f) => { | |
| 427 | + const l = await forAction(c); | |
| 428 | + const id = releaseId(c); | |
| 429 | + const before = await l.stub.getRelease(id, l.caller); | |
| 430 | + const v = valuesOf(f); | |
| 431 | + const publish = f.s("action") !== "draft"; | |
| 432 | + try { | |
| 433 | + const files = await uploadedFiles(c); | |
| 434 | + const tag = v.newTag || v.tag || before.tagName; | |
| 435 | + const r = await l.stub.updateRelease( | |
| 436 | + id, | |
| 437 | + { | |
| 438 | + tag_name: tag, | |
| 439 | + target_commitish: v.target || undefined, | |
| 440 | + name: v.name, | |
| 441 | + body: v.body, | |
| 442 | + draft: before.draft ? !publish : false, | |
| 443 | + prerelease: v.prerelease, | |
| 444 | + make_latest: v.latest ? "true" : "false", | |
| 445 | + }, | |
| 446 | + l.caller, | |
| 447 | + ); | |
| 448 | + await attach(c, l, r.id, files); | |
| 449 | + return c.redirect(tagHref(repoUrl(l.repo), r.tagName), 303); | |
| 450 | + } catch (e) { | |
| 451 | + const s = statusOf(e); | |
| 452 | + if (s.status >= 500) throw e; | |
| 453 | + return formPage(c, l, v, before, s.message, s.status); | |
| 454 | + } | |
| 455 | + }, { maxBytes: MAX_FORM_BODY }); | |
| 456 | + | |
| 457 | + post(web, "/:owner/:repo/releases/:id/delete", async (c) => { | |
| 458 | + const l = await forAction(c); | |
| 459 | + const id = releaseId(c); | |
| 460 | + const base = repoUrl(l.repo); | |
| 461 | + return act(c, `${base}/releases`, async () => { | |
| 462 | + await l.stub.deleteRelease(id, l.caller); | |
| 463 | + return `${base}/releases`; | |
| 464 | + }, "Release deleted. The tag was kept."); | |
| 465 | + }); | |
| 466 | + | |
| 467 | + post(web, "/:owner/:repo/releases/:id/assets/:asset/delete", async (c) => { | |
| 468 | + const l = await forAction(c); | |
| 469 | + const id = releaseId(c); | |
| 470 | + const r = await l.stub.getRelease(id, l.caller); | |
| 471 | + const back = tagHref(repoUrl(l.repo), r.tagName); | |
| 472 | + const assetId = releaseId(c, "asset"); | |
| 473 | + return act(c, back, async () => { | |
| 474 | + if (!r.assets.some((a) => a.id === assetId)) throw new HttpError(404, "asset not found"); | |
| 475 | + await l.stub.deleteReleaseAsset(assetId, l.caller); | |
| 476 | + }, "Asset deleted."); | |
| 477 | + }); | |
| 478 | +} |
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { registerActions } from "./actions.js"; |
| 9 | 9 | import { registerCode } from "./code.js"; |
| 10 | 10 | import { registerCollab } from "./collab.js"; |
| 11 | +import { registerReleases } from "./releases.js"; | |
| 11 | 12 | import { registerRepoSettings } from "./repo-settings.js"; |
| 12 | 13 | import { registerSeo } from "./seo.js"; |
| 13 | 14 | import { errorPage } from "./ui.js"; |
| @@ -38,5 +39,6 @@ | ||
| 38 | 39 | registerAccount(web); |
| 39 | 40 | registerRepoSettings(web); |
| 40 | 41 | registerActions(web); |
| 42 | +registerReleases(web); | |
| 41 | 43 | registerCollab(web); |
| 42 | 44 | registerCode(web); |
modifiedapps/diffinery/src/web/ui.tsx+2−2View file
| @@ -227,8 +227,8 @@ | ||
| 227 | 227 | } |
| 228 | 228 | |
| 229 | 229 | /** Register a CSRF-checked POST route. */ |
| 230 | -export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>): void { | |
| 231 | - app.post(path, async (c) => handler(c, await verifyCsrf(c))); | |
| 230 | +export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>, opts?: { maxBytes?: number }): void { | |
| 231 | + app.post(path, async (c) => handler(c, await verifyCsrf(c, opts?.maxBytes))); | |
| 232 | 232 | } |
| 233 | 233 | |
| 234 | 234 | export function principalAccount(c: AppContext): Account | null { |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+291−0View file
| @@ -751,3 +751,294 @@ | ||
| 751 | 751 | const post = await fetch(`${base}/auth/logout`, { method: "POST", redirect: "manual", headers: { "sec-fetch-site": "same-origin" } }); |
| 752 | 752 | assert.match(post.headers.get("set-cookie") ?? "", /__Host-dfy_d1=[0-9a-f-]+; Max-Age=86400; Path=\/; HttpOnly; Secure; SameSite=Lax/); |
| 753 | 753 | }); |
| 754 | + | |
| 755 | +test("GitHub REST compatibility: Octokit against releases, statuses, git data, issues, deployments and search", async () => { | |
| 756 | + const { Octokit } = await import("@octokit/rest"); | |
| 757 | + const gh = new Octokit({ auth: token, baseUrl: `${base}/api/v3`, userAgent: "diffinery-e2e", request: { retries: 0 } }); | |
| 758 | + const owner = (await gh.rest.users.getAuthenticated()).data.login; | |
| 759 | + const repo = "compat"; | |
| 760 | + const R = { owner, repo }; | |
| 761 | + | |
| 762 | + // ------------------------------------------------------------ repository, pushed with git | |
| 763 | + const created = await gh.rest.repos.createForAuthenticatedUser({ name: repo, description: "Octokit compatibility" }); | |
| 764 | + assert.equal(created.status, 201); | |
| 765 | + assert.equal(created.data.full_name, `${owner}/${repo}`); | |
| 766 | + assert.match(created.data.node_id, /\S/); | |
| 767 | + assert.equal(created.data.owner.login, owner); | |
| 768 | + const src = join(work, "compat"); | |
| 769 | + mkdirSync(join(src, "src"), { recursive: true }); | |
| 770 | + writeFileSync(join(src, "README.md"), "# compat\n"); | |
| 771 | + writeFileSync(join(src, "src/main.ts"), "export const x = 1;\n"); | |
| 772 | + git(src, ["init", "-q", "-b", "main"]); | |
| 773 | + git(src, ["add", "-A"]); | |
| 774 | + git(src, ["commit", "-q", "-m", "initial"]); | |
| 775 | + git(src, ["remote", "add", "origin", `${base}/${owner}/${repo}.git`]); | |
| 776 | + git(src, ["push", "-q", "origin", "main"]); | |
| 777 | + const mainSha = git(src, ["rev-parse", "HEAD"]).trim(); | |
| 778 | + | |
| 779 | + const r = await gh.rest.repos.get(R); | |
| 780 | + assert.equal(r.data.default_branch, "main"); | |
| 781 | + assert.equal(r.data.permissions.admin, true); | |
| 782 | + assert.equal(r.headers["x-github-media-type"], "github.v3; format=json"); | |
| 783 | + const rate = await gh.rest.rateLimit.get(); | |
| 784 | + assert.ok(rate.data.resources.core.remaining > 0); | |
| 785 | + | |
| 786 | + // GitHub error bodies: message and documentation_url, surfaced by Octokit's RequestError. | |
| 787 | + await assert.rejects(gh.rest.repos.getContent({ ...R, path: "missing.txt" }), (e) => e.status === 404 && typeof e.response.data.message === "string" && typeof e.response.data.documentation_url === "string"); | |
| 788 | + | |
| 789 | + // ------------------------------------------------------------ contents | |
| 790 | + const readme = await gh.rest.repos.getContent({ ...R, path: "README.md" }); | |
| 791 | + assert.equal(readme.data.type, "file"); | |
| 792 | + assert.equal(Buffer.from(readme.data.content, "base64").toString(), "# compat\n"); | |
| 793 | + const rawReadme = await gh.rest.repos.getContent({ ...R, path: "README.md", mediaType: { format: "raw" } }); | |
| 794 | + assert.equal(rawReadme.data, "# compat\n"); | |
| 795 | + const dir = await gh.rest.repos.getContent({ ...R, path: "src" }); | |
| 796 | + assert.deepEqual(dir.data.map((e) => e.name), ["main.ts"]); | |
| 797 | + await assert.rejects(gh.rest.repos.createOrUpdateFileContents({ ...R, path: "README.md", message: "stale", content: Buffer.from("x").toString("base64"), sha: "0".repeat(40) }), (e) => e.status === 409); | |
| 798 | + const put = await gh.rest.repos.createOrUpdateFileContents({ ...R, path: "README.md", message: "Update README", content: Buffer.from("# compat\n\nUpdated.\n").toString("base64"), sha: readme.data.sha, committer: { name: "Release Bot", email: "bot@example.com" } }); | |
| 799 | + assert.equal(put.status, 200); | |
| 800 | + assert.equal(put.data.commit.committer.name, "Release Bot"); | |
| 801 | + const added = await gh.rest.repos.createOrUpdateFileContents({ ...R, path: "docs/guide.md", message: "Add guide", content: Buffer.from("guide\n").toString("base64") }); | |
| 802 | + assert.equal(added.status, 201); | |
| 803 | + const readmeApi = await gh.rest.repos.getReadme(R); | |
| 804 | + assert.equal(readmeApi.data.path, "README.md"); | |
| 805 | + | |
| 806 | + // ------------------------------------------------------------ git data: blob -> tree -> commit -> ref, then git sees it | |
| 807 | + const headSha = (await gh.rest.git.getRef({ ...R, ref: "heads/main" })).data.object.sha; | |
| 808 | + assert.equal(headSha, added.data.commit.sha); | |
| 809 | + const blob = await gh.rest.git.createBlob({ ...R, content: Buffer.from("made with the git data API\n").toString("base64"), encoding: "base64" }); | |
| 810 | + assert.equal(blob.status, 201); | |
| 811 | + assert.equal(Buffer.from((await gh.rest.git.getBlob({ ...R, file_sha: blob.data.sha })).data.content, "base64").toString(), "made with the git data API\n"); | |
| 812 | + const headCommit = await gh.rest.git.getCommit({ ...R, commit_sha: headSha }); | |
| 813 | + const tree = await gh.rest.git.createTree({ ...R, base_tree: headCommit.data.tree.sha, tree: [{ path: "gitdata/hello.txt", mode: "100644", type: "blob", sha: blob.data.sha }, { path: "inline.txt", mode: "100644", type: "blob", content: "inline\n" }] }); | |
| 814 | + const full = await gh.rest.git.getTree({ ...R, tree_sha: tree.data.sha, recursive: "true" }); | |
| 815 | + assert.ok(full.data.tree.some((e) => e.path === "gitdata/hello.txt" && e.type === "blob")); | |
| 816 | + assert.ok(full.data.tree.some((e) => e.path === "src/main.ts")); | |
| 817 | + const commit = await gh.rest.git.createCommit({ ...R, message: "Commit from the git data API", tree: tree.data.sha, parents: [headSha], author: { name: "Octo Cat", email: "octo@example.com", date: "2024-05-06T07:08:09Z" } }); | |
| 818 | + assert.equal(commit.data.author.name, "Octo Cat"); | |
| 819 | + assert.deepEqual(commit.data.parents.map((p) => p.sha), [headSha]); | |
| 820 | + const ref = await gh.rest.git.createRef({ ...R, ref: "refs/heads/feature", sha: commit.data.sha }); | |
| 821 | + assert.equal(ref.data.ref, "refs/heads/feature"); | |
| 822 | + assert.equal(ref.data.object.type, "commit"); | |
| 823 | + await assert.rejects(gh.rest.git.createRef({ ...R, ref: "refs/heads/feature", sha: commit.data.sha }), (e) => e.status === 422); | |
| 824 | + const matching = await gh.rest.git.listMatchingRefs({ ...R, ref: "heads/f" }); | |
| 825 | + assert.deepEqual(matching.data.map((x) => x.ref), ["refs/heads/feature"]); | |
| 826 | + const tag = await gh.rest.git.createTag({ ...R, tag: "v0.1.0", message: "Annotated tag", object: commit.data.sha, type: "commit", tagger: { name: "Octo Cat", email: "octo@example.com", date: "2024-05-06T07:08:09Z" } }); | |
| 827 | + await gh.rest.git.createRef({ ...R, ref: "refs/tags/v0.1.0", sha: tag.data.sha }); | |
| 828 | + assert.equal((await gh.rest.git.getTag({ ...R, tag_sha: tag.data.sha })).data.object.sha, commit.data.sha); | |
| 829 | + assert.equal((await gh.rest.git.getRef({ ...R, ref: "tags/v0.1.0" })).data.object.type, "tag"); | |
| 830 | + git(src, ["fetch", "-q", "origin", "feature", "--tags"]); | |
| 831 | + assert.equal(git(src, ["show", "FETCH_HEAD:gitdata/hello.txt"]), "made with the git data API\n"); | |
| 832 | + assert.equal(git(src, ["cat-file", "-t", "v0.1.0"]).trim(), "tag"); | |
| 833 | + | |
| 834 | + // ------------------------------------------------------------ commit statuses count as checks, also for required checks | |
| 835 | + await ok("PUT", `/repos/${owner}/${repo}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci/external"] }); | |
| 836 | + const pr = await gh.rest.pulls.create({ ...R, title: "Feature", head: "feature", base: "main", body: "Adds files" }); | |
| 837 | + assert.equal(pr.status, 201); | |
| 838 | + const pending = await until("mergeability", async () => { | |
| 839 | + const p = (await gh.rest.pulls.get({ ...R, pull_number: pr.data.number })).data; | |
| 840 | + return typeof p.mergeable === "boolean" ? p : null; | |
| 841 | + }); | |
| 842 | + assert.equal(pending.mergeable, true); | |
| 843 | + assert.equal(pending.mergeable_state, "blocked"); | |
| 844 | + assert.equal(pending.head.sha, commit.data.sha); | |
| 845 | + const st = await gh.rest.repos.createCommitStatus({ ...R, sha: commit.data.sha, state: "success", context: "ci/external", description: "External CI passed", target_url: "https://ci.example.com/build/1" }); | |
| 846 | + assert.equal(st.status, 201); | |
| 847 | + assert.equal(st.data.creator.login, owner); | |
| 848 | + await assert.rejects(gh.rest.repos.createCommitStatus({ ...R, sha: commit.data.sha, state: "great" }), (e) => e.status === 422); | |
| 849 | + const combined = await gh.rest.repos.getCombinedStatusForRef({ ...R, ref: "feature" }); | |
| 850 | + assert.equal(combined.data.state, "success"); | |
| 851 | + assert.equal(combined.data.total_count, 1); | |
| 852 | + assert.equal(combined.data.statuses[0].context, "ci/external"); | |
| 853 | + assert.equal((await gh.rest.repos.listCommitStatusesForRef({ ...R, ref: commit.data.sha })).data.length, 1); | |
| 854 | + const clean = (await gh.rest.pulls.get({ ...R, pull_number: pr.data.number })).data; | |
| 855 | + assert.equal(clean.mergeable_state, "clean"); | |
| 856 | + const prPage = await (await fetch(`${base}/${owner}/${repo}/pull/${pr.data.number}/checks`)).text(); | |
| 857 | + assert.match(prPage, /ci\/external/); | |
| 858 | + assert.match(prPage, /https:\/\/ci\.example\.com\/build\/1/); | |
| 859 | + | |
| 860 | + // ------------------------------------------------------------ pull request sub-resources | |
| 861 | + const byHead = await gh.rest.pulls.list({ ...R, head: `${owner}:feature`, state: "open" }); | |
| 862 | + assert.deepEqual(byHead.data.map((p) => p.number), [pr.data.number]); | |
| 863 | + assert.equal((await gh.rest.pulls.list({ ...R, base: "other" })).data.length, 0); | |
| 864 | + const assoc = await gh.rest.repos.listPullRequestsAssociatedWithCommit({ ...R, commit_sha: commit.data.sha }); | |
| 865 | + assert.deepEqual(assoc.data.map((p) => p.number), [pr.data.number]); | |
| 866 | + const rc = await gh.rest.pulls.createReviewComment({ ...R, pull_number: pr.data.number, body: "Why this file?", commit_id: commit.data.sha, path: "inline.txt", line: 1, side: "RIGHT" }); | |
| 867 | + assert.equal(rc.status, 201); | |
| 868 | + assert.equal(rc.data.path, "inline.txt"); | |
| 869 | + const reply = await gh.rest.pulls.createReplyForReviewComment({ ...R, pull_number: pr.data.number, comment_id: rc.data.id, body: "For the test." }); | |
| 870 | + assert.equal(reply.data.in_reply_to_id, rc.data.id); | |
| 871 | + // Numeric ids, like GitHub's: actions parse them with Number() and pass them back. | |
| 872 | + assert.equal(typeof rc.data.id, "number"); | |
| 873 | + assert.equal(typeof rc.data.pull_request_review_id, "number"); | |
| 874 | + assert.equal((await gh.rest.pulls.listReviewComments({ ...R, pull_number: pr.data.number })).data.length, 2); | |
| 875 | + await gh.rest.reactions.createForPullRequestReviewComment({ ...R, comment_id: rc.data.id, content: "eyes" }); | |
| 876 | + const merged = await gh.rest.pulls.merge({ ...R, pull_number: pr.data.number, merge_method: "squash" }); | |
| 877 | + assert.equal(merged.data.merged, true); | |
| 878 | + const merges = await gh.rest.issues.listEventsForTimeline({ ...R, issue_number: pr.data.number }); | |
| 879 | + assert.ok(merges.data.some((e) => e.event === "merged")); | |
| 880 | + | |
| 881 | + // ------------------------------------------------------------ releases and assets | |
| 882 | + const rel = await gh.rest.repos.createRelease({ ...R, tag_name: "v1.0.0", target_commitish: "main", name: "First", generate_release_notes: true }); | |
| 883 | + assert.equal(rel.status, 201); | |
| 884 | + assert.match(rel.data.body, /Full Changelog/); | |
| 885 | + assert.match(rel.data.body, /Feature by @/); | |
| 886 | + assert.equal(rel.data.author.login, owner); | |
| 887 | + assert.match(rel.data.upload_url, /\/api\/uploads\/repos\/.+\/releases\/\d+\/assets\{\?name,label\}$/); | |
| 888 | + assert.match(git(src, ["ls-remote", "--tags", "origin"]), /refs\/tags\/v1\.0\.0/); | |
| 889 | + const bytes = Buffer.from("hello from a release asset\n"); | |
| 890 | + const up = await gh.rest.repos.uploadReleaseAsset({ ...R, release_id: rel.data.id, name: "hello.txt", label: "Greeting", data: bytes, url: rel.data.upload_url, headers: { "content-type": "text/plain", "content-length": bytes.length } }); | |
| 891 | + assert.equal(up.status, 201); | |
| 892 | + assert.equal(up.data.name, "hello.txt"); | |
| 893 | + assert.equal(up.data.size, bytes.length); | |
| 894 | + assert.equal(up.data.state, "uploaded"); | |
| 895 | + const bin = Buffer.from(randomBytes(70_000)); | |
| 896 | + const up2 = await gh.rest.repos.uploadReleaseAsset({ ...R, release_id: rel.data.id, name: "tool linux.bin", data: bin, baseUrl: `${base}/api/uploads`, headers: { "content-type": "application/octet-stream", "content-length": bin.length } }); | |
| 897 | + assert.equal(up2.data.name, "tool.linux.bin"); | |
| 898 | + await assert.rejects(gh.rest.repos.uploadReleaseAsset({ ...R, release_id: rel.data.id, name: "hello.txt", data: bytes, url: rel.data.upload_url, headers: { "content-type": "text/plain", "content-length": bytes.length } }), (e) => e.status === 422); | |
| 899 | + const viaApi = await gh.rest.repos.getReleaseAsset({ ...R, asset_id: up2.data.id, headers: { accept: "application/octet-stream" } }); | |
| 900 | + assert.deepEqual(Buffer.from(viaApi.data), bin); | |
| 901 | + const browser = await fetch(up.data.browser_download_url); | |
| 902 | + assert.equal(browser.status, 200); | |
| 903 | + assert.equal(await browser.text(), bytes.toString()); | |
| 904 | + assert.match(browser.headers.get("content-disposition") ?? "", /attachment/); | |
| 905 | + assert.equal((await gh.rest.repos.getReleaseAsset({ ...R, asset_id: up.data.id })).data.download_count, 1); | |
| 906 | + assert.deepEqual((await gh.rest.repos.listReleaseAssets({ ...R, release_id: rel.data.id })).data.map((a) => a.name), ["hello.txt", "tool.linux.bin"]); | |
| 907 | + assert.equal((await gh.rest.repos.updateReleaseAsset({ ...R, asset_id: up.data.id, label: "Hello" })).data.label, "Hello"); | |
| 908 | + assert.equal((await gh.rest.repos.getLatestRelease(R)).data.id, rel.data.id); | |
| 909 | + assert.equal((await gh.rest.repos.getReleaseByTag({ ...R, tag: "v1.0.0" })).data.assets.length, 2); | |
| 910 | + const notes = await gh.rest.repos.generateReleaseNotes({ ...R, tag_name: "v1.1.0", previous_tag_name: "v1.0.0" }); | |
| 911 | + assert.match(notes.data.body, /compare\/v1\.0\.0\.\.\.v1\.1\.0/); | |
| 912 | + await gh.rest.reactions.createForRelease({ ...R, release_id: rel.data.id, content: "rocket" }); | |
| 913 | + assert.equal((await gh.rest.repos.getRelease({ ...R, release_id: rel.data.id })).data.reactions.rocket, 1); | |
| 914 | + | |
| 915 | + // Drafts are for writers only; a read-only token sees neither the draft nor its tag lookup. | |
| 916 | + const draft = await gh.rest.repos.createRelease({ ...R, tag_name: "v2.0.0-rc.1", name: "Next", draft: true, prerelease: true }); | |
| 917 | + assert.equal(draft.data.draft, true); | |
| 918 | + const reader = await ok("POST", "/diffinery/tokens", { name: "reader", scopes: ["read"] }); | |
| 919 | + const ro = new Octokit({ auth: reader.token, baseUrl: `${base}/api/v3`, request: { retries: 0 } }); | |
| 920 | + assert.deepEqual((await ro.rest.repos.listReleases(R)).data.map((x) => x.tag_name), ["v1.0.0"]); | |
| 921 | + await assert.rejects(ro.rest.repos.getRelease({ ...R, release_id: draft.data.id }), (e) => e.status === 404); | |
| 922 | + await assert.rejects(ro.rest.repos.createRelease({ ...R, tag_name: "v9" }), (e) => e.status === 403 || e.status === 404); | |
| 923 | + const published = await gh.rest.repos.updateRelease({ ...R, release_id: draft.data.id, draft: false, make_latest: "false" }); | |
| 924 | + assert.equal(published.data.draft, false); | |
| 925 | + assert.equal((await gh.rest.repos.getLatestRelease(R)).data.tag_name, "v1.0.0"); | |
| 926 | + await gh.rest.repos.deleteReleaseAsset({ ...R, asset_id: up2.data.id }); | |
| 927 | + assert.equal((await fetch(`${base}/${owner}/${repo}/releases/download/v1.0.0/tool.linux.bin`)).status, 404); | |
| 928 | + await gh.rest.repos.deleteRelease({ ...R, release_id: published.data.id }); | |
| 929 | + await assert.rejects(gh.rest.repos.getReleaseByTag({ ...R, tag: "v2.0.0-rc.1" }), (e) => e.status === 404); | |
| 930 | + | |
| 931 | + // Archives | |
| 932 | + const zipball = await gh.rest.repos.downloadZipballArchive({ ...R, ref: "v1.0.0" }); | |
| 933 | + assert.equal(Buffer.from(zipball.data).subarray(0, 2).toString(), "PK"); | |
| 934 | + const tarball = await gh.rest.repos.downloadTarballArchive({ ...R, ref: "main" }); | |
| 935 | + assert.deepEqual([...Buffer.from(tarball.data).subarray(0, 2)], [0x1f, 0x8b]); | |
| 936 | + const tgz = join(work, "compat.tar.gz"); | |
| 937 | + writeFileSync(tgz, Buffer.from(tarball.data)); | |
| 938 | + assert.match(execFileSync("tar", ["tzf", tgz], { encoding: "utf8" }), /\/gitdata\/hello\.txt/); | |
| 939 | + | |
| 940 | + // The web UI: releases list, the release page and the form (CSRF, Kumo controls, multipart upload). | |
| 941 | + const list = await (await fetch(`${base}/${owner}/${repo}/releases`)).text(); | |
| 942 | + assert.match(list, /First/); | |
| 943 | + assert.match(list, /hello\.txt/); | |
| 944 | + const form = await fetch(`${base}/${owner}/${repo}/releases/new`); | |
| 945 | + const html = await form.text(); | |
| 946 | + assert.doesNotMatch(html, /<select|type="checkbox"|type="radio"/); | |
| 947 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1]; | |
| 948 | + const cookie = /dfy_csrf=([^;]+)/.exec(form.headers.get("set-cookie") ?? "")?.[1]; | |
| 949 | + assert.ok(csrf && cookie); | |
| 950 | + const fd = new FormData(); | |
| 951 | + for (const [k, v] of Object.entries({ _csrf: csrf, tag: "", new_tag: "v3.0.0", target: "main", name: "From the web", body: "Notes from the form.", latest: "1", action: "publish" })) fd.append(k, v); | |
| 952 | + fd.append("assets", new Blob(["web upload\n"], { type: "text/plain" }), "web.txt"); | |
| 953 | + const submit = await fetch(`${base}/${owner}/${repo}/releases`, { method: "POST", body: fd, redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}` } }); | |
| 954 | + assert.equal(submit.status, 303, await submit.text()); | |
| 955 | + assert.equal(submit.headers.get("location"), `/${owner}/${repo}/releases/tag/v3.0.0`); | |
| 956 | + assert.equal(await (await fetch(`${base}/${owner}/${repo}/releases/download/v3.0.0/web.txt`)).text(), "web upload\n"); | |
| 957 | + assert.equal((await gh.rest.repos.getLatestRelease(R)).data.tag_name, "v3.0.0"); | |
| 958 | + assert.match(await (await fetch(`${base}/${owner}/${repo}`)).text(), /From the web/); | |
| 959 | + | |
| 960 | + // ------------------------------------------------------------ issues: labels, assignees, lock, reactions, events, pagination | |
| 961 | + await gh.rest.issues.createLabel({ ...R, name: "bug", color: "d73a4a", description: "Something is broken" }); | |
| 962 | + await assert.rejects(gh.rest.issues.createLabel({ ...R, name: "bug", color: "d73a4a" }), (e) => e.status === 422); | |
| 963 | + const issue = await gh.rest.issues.create({ ...R, title: "Crash on start", body: "It crashes.", labels: ["bug"], assignees: [owner] }); | |
| 964 | + assert.deepEqual(issue.data.labels.map((l) => [l.name, l.color]), [["bug", "d73a4a"]]); | |
| 965 | + assert.equal(issue.data.assignees[0].login, owner); | |
| 966 | + assert.equal(issue.data.user.login, owner); | |
| 967 | + assert.match(issue.data.html_url, new RegExp(`/${owner}/${repo}/issues/${issue.data.number}$`)); | |
| 968 | + const n = issue.data.number; | |
| 969 | + await gh.rest.issues.addLabels({ ...R, issue_number: n, labels: ["triage"] }); | |
| 970 | + assert.deepEqual((await gh.rest.issues.listLabelsOnIssue({ ...R, issue_number: n })).data.map((l) => l.name).sort(), ["bug", "triage"]); | |
| 971 | + await gh.rest.issues.removeLabel({ ...R, issue_number: n, name: "triage" }); | |
| 972 | + await gh.rest.issues.removeAssignees({ ...R, issue_number: n, assignees: [owner] }); | |
| 973 | + await gh.rest.issues.addAssignees({ ...R, issue_number: n, assignees: [owner] }); | |
| 974 | + assert.equal((await gh.rest.issues.checkUserCanBeAssigned({ ...R, assignee: owner })).status, 204); | |
| 975 | + const comment = await gh.rest.issues.createComment({ ...R, issue_number: n, body: "Same here." }); | |
| 976 | + assert.equal(typeof comment.data.id, "number"); | |
| 977 | + assert.equal((await gh.rest.issues.getComment({ ...R, comment_id: comment.data.id })).data.body, "Same here."); | |
| 978 | + await gh.rest.issues.updateComment({ ...R, comment_id: comment.data.id, body: "Same here!" }); | |
| 979 | + assert.equal((await gh.rest.issues.getComment({ ...R, comment_id: String(comment.data.id) })).data.body, "Same here!"); | |
| 980 | + await gh.rest.reactions.createForIssue({ ...R, issue_number: n, content: "+1" }); | |
| 981 | + const heart = await gh.rest.reactions.createForIssueComment({ ...R, comment_id: comment.data.id, content: "heart" }); | |
| 982 | + assert.equal(heart.status, 201); | |
| 983 | + assert.equal((await gh.rest.reactions.createForIssueComment({ ...R, comment_id: comment.data.id, content: "heart" })).status, 200); | |
| 984 | + assert.deepEqual((await gh.rest.reactions.listForIssue({ ...R, issue_number: n })).data.map((x) => x.content), ["+1"]); | |
| 985 | + assert.equal((await gh.rest.issues.get({ ...R, issue_number: n })).data.reactions["+1"], 1); | |
| 986 | + await gh.rest.issues.lock({ ...R, issue_number: n, lock_reason: "resolved" }); | |
| 987 | + const locked = await gh.rest.issues.get({ ...R, issue_number: n }); | |
| 988 | + assert.equal(locked.data.locked, true); | |
| 989 | + assert.equal(locked.data.active_lock_reason, "resolved"); | |
| 990 | + await gh.rest.issues.unlock({ ...R, issue_number: n }); | |
| 991 | + await gh.rest.issues.update({ ...R, issue_number: n, title: "Crash on startup", state: "closed", state_reason: "completed" }); | |
| 992 | + const events = (await gh.rest.issues.listEvents({ ...R, issue_number: n })).data.map((e) => e.event); | |
| 993 | + for (const e of ["labeled", "unlabeled", "unassigned", "assigned", "locked", "unlocked", "renamed", "closed"]) assert.ok(events.includes(e), `${e} in ${events}`); | |
| 994 | + const timeline = (await gh.rest.issues.listEventsForTimeline({ ...R, issue_number: n })).data; | |
| 995 | + assert.ok(timeline.some((e) => e.event === "commented" && e.body === "Same here!" && e.id === comment.data.id)); | |
| 996 | + assert.ok((await gh.rest.issues.listCommentsForRepo(R)).data.some((x) => x.id === comment.data.id)); | |
| 997 | + await gh.rest.issues.deleteComment({ ...R, comment_id: comment.data.id }); | |
| 998 | + await assert.rejects(gh.rest.issues.getComment({ ...R, comment_id: comment.data.id }), (e) => e.status === 404); | |
| 999 | + | |
| 1000 | + for (let i = 0; i < 3; i++) await gh.rest.issues.create({ ...R, title: `Paged ${i}` }); | |
| 1001 | + const page1 = await gh.rest.issues.listForRepo({ ...R, state: "all", per_page: 2 }); | |
| 1002 | + assert.equal(page1.data.length, 2); | |
| 1003 | + assert.match(page1.headers.link ?? "", /rel="next"/); | |
| 1004 | + const all = await gh.paginate(gh.rest.issues.listForRepo, { ...R, state: "all", per_page: 2 }); | |
| 1005 | + assert.equal(new Set(all.map((x) => x.number)).size, all.length); | |
| 1006 | + assert.ok(all.length >= 5, `paginated ${all.length} issues and pull requests`); | |
| 1007 | + | |
| 1008 | + // ------------------------------------------------------------ search, over readable repositories only | |
| 1009 | + const found = await until("issue search", async () => { | |
| 1010 | + const s = await gh.rest.search.issuesAndPullRequests({ q: `repo:${owner}/${repo} is:issue label:bug crash` }); | |
| 1011 | + return s.data.total_count ? s.data : null; | |
| 1012 | + }); | |
| 1013 | + assert.deepEqual(found.items.map((x) => x.number), [n]); | |
| 1014 | + assert.equal((await gh.rest.search.issuesAndPullRequests({ q: `repo:${owner}/${repo} is:pr is:merged` })).data.items[0].number, pr.data.number); | |
| 1015 | + assert.equal((await gh.rest.search.issuesAndPullRequests({ q: `repo:${owner}/${repo} is:issue -label:bug Paged`, sort: "created", order: "asc" })).data.items.map((x) => x.title).join(","), "Paged 0,Paged 1,Paged 2"); | |
| 1016 | + await gh.rest.repos.replaceAllTopics({ ...R, names: ["octokit", "e2e"] }); | |
| 1017 | + assert.deepEqual((await gh.rest.repos.getAllTopics(R)).data.names, ["octokit", "e2e"]); | |
| 1018 | + const repos = await gh.rest.search.repos({ q: `topic:octokit user:${owner}` }); | |
| 1019 | + assert.deepEqual(repos.data.items.map((x) => x.full_name), [`${owner}/${repo}`]); | |
| 1020 | + assert.ok("TypeScript" in (await gh.rest.repos.listLanguages(R)).data); | |
| 1021 | + | |
| 1022 | + // ------------------------------------------------------------ deployments and environments | |
| 1023 | + const env = await gh.rest.repos.createOrUpdateEnvironment({ ...R, environment_name: "staging" }); | |
| 1024 | + assert.equal(env.data.name, "staging"); | |
| 1025 | + const dep = await gh.rest.repos.createDeployment({ ...R, ref: "main", environment: "staging", auto_merge: false, required_contexts: [], payload: { region: "eu" }, description: "Deploy to staging" }); | |
| 1026 | + assert.equal(dep.status, 201); | |
| 1027 | + assert.equal(dep.data.sha, (await gh.rest.git.getRef({ ...R, ref: "heads/main" })).data.object.sha); | |
| 1028 | + assert.deepEqual(dep.data.payload, { region: "eu" }); | |
| 1029 | + await gh.rest.repos.createDeploymentStatus({ ...R, deployment_id: dep.data.id, state: "in_progress" }); | |
| 1030 | + const ds = await gh.rest.repos.createDeploymentStatus({ ...R, deployment_id: dep.data.id, state: "success", environment_url: "https://staging.example.com", log_url: "https://ci.example.com/deploy/1" }); | |
| 1031 | + assert.equal(ds.data.state, "success"); | |
| 1032 | + assert.deepEqual((await gh.rest.repos.listDeploymentStatuses({ ...R, deployment_id: dep.data.id })).data.map((x) => x.state), ["success", "in_progress"]); | |
| 1033 | + assert.equal((await gh.rest.repos.listDeployments({ ...R, environment: "staging" })).data.length, 1); | |
| 1034 | + assert.ok((await gh.rest.repos.getAllEnvironments(R)).data.environments.some((e) => e.name === "staging")); | |
| 1035 | + await gh.rest.actions.createEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION", value: "eu" }); | |
| 1036 | + assert.equal((await gh.rest.actions.getEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION" })).data.value, "eu"); | |
| 1037 | + await gh.rest.repos.deleteAnEnvironment({ ...R, environment_name: "staging" }); | |
| 1038 | + | |
| 1039 | + // ------------------------------------------------------------ repository_dispatch and repository variables | |
| 1040 | + assert.equal((await gh.rest.repos.createDispatchEvent({ ...R, event_type: "deploy", client_payload: { env: "prod" } })).status, 204); | |
| 1041 | + await gh.rest.actions.createRepoVariable({ ...R, name: "COLOR", value: "blue" }); | |
| 1042 | + await gh.rest.actions.updateRepoVariable({ ...R, name: "COLOR", value: "green" }); | |
| 1043 | + assert.equal((await gh.rest.actions.getRepoVariable({ ...R, name: "COLOR" })).data.value, "green"); | |
| 1044 | +}); |
addedapps/diffinery/test/e2e/security.test.mjs+301−0View file
| @@ -1,0 +1,301 @@ | ||
| 1 | +// End-to-end security checks of the GitHub-compatible REST API, against a real | |
| 2 | +// `wrangler dev` Worker with real (seeded) accounts, tokens and sessions. | |
| 3 | +// | |
| 4 | +// pnpm --filter @diffinery/app e2e | |
| 5 | +// | |
| 6 | +// Covers: search only shows what the caller may read (pending, disabled and | |
| 7 | +// orphaned-agent callers see nothing); agents can't turn a failing required | |
| 8 | +// check green; deployments verify every context; agents can't manage | |
| 9 | +// environments or dismiss reviews; secrets default environments to protected | |
| 10 | +// branches; locked conversations and archived repositories refuse changes; | |
| 11 | +// draft releases stay off readers' live sockets; unsafe tree names, tree bombs, | |
| 12 | +// long search queries and percent signs in label names. | |
| 13 | +import { spawn, execFileSync } from "node:child_process"; | |
| 14 | +import { createHash, randomBytes } from "node:crypto"; | |
| 15 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 16 | +import { tmpdir } from "node:os"; | |
| 17 | +import { join, dirname, resolve } from "node:path"; | |
| 18 | +import { fileURLToPath } from "node:url"; | |
| 19 | +import { createServer } from "node:net"; | |
| 20 | +import { test, before, after } from "node:test"; | |
| 21 | +import assert from "node:assert/strict"; | |
| 22 | + | |
| 23 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 24 | +const work = mkdtempSync(join(tmpdir(), "diffinery-security-")); | |
| 25 | +let base; | |
| 26 | +let dev; | |
| 27 | +let devLog = ""; | |
| 28 | + | |
| 29 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 30 | +const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; | |
| 31 | +const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan") }; | |
| 32 | +const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}` }; | |
| 33 | +const OWNER = "olive"; | |
| 34 | + | |
| 35 | +const freePort = () => | |
| 36 | + new Promise((res) => { | |
| 37 | + const s = createServer(); | |
| 38 | + s.listen(0, "127.0.0.1", () => { | |
| 39 | + const { port } = s.address(); | |
| 40 | + s.close(() => res(port)); | |
| 41 | + }); | |
| 42 | + }); | |
| 43 | + | |
| 44 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 45 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 46 | + const end = Date.now() + timeoutMs; | |
| 47 | + while (Date.now() < end) { | |
| 48 | + const v = await fn(); | |
| 49 | + if (v) return v; | |
| 50 | + await sleep(300); | |
| 51 | + } | |
| 52 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */ | |
| 56 | +async function api(method, path, body, as = T.olive) { | |
| 57 | + const headers = { "content-type": "application/json" }; | |
| 58 | + if (typeof as === "string") headers.authorization = `Bearer ${as}`; | |
| 59 | + else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; | |
| 60 | + const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 61 | + const text = await res.text(); | |
| 62 | + let json = null; | |
| 63 | + try { | |
| 64 | + json = text ? JSON.parse(text) : null; | |
| 65 | + } catch { | |
| 66 | + json = text; | |
| 67 | + } | |
| 68 | + return { status: res.status, json }; | |
| 69 | +} | |
| 70 | +async function ok(method, path, body, as) { | |
| 71 | + const r = await api(method, path, body, as); | |
| 72 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 73 | + return r.json; | |
| 74 | +} | |
| 75 | +async function status(expected, method, path, body, as) { | |
| 76 | + const r = await api(method, path, body, as); | |
| 77 | + assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 78 | + return r.json; | |
| 79 | +} | |
| 80 | +const b64 = (s) => Buffer.from(s).toString("base64"); | |
| 81 | + | |
| 82 | +before(async () => { | |
| 83 | + const port = await freePort(); | |
| 84 | + base = `http://127.0.0.1:${port}`; | |
| 85 | + const persist = join(work, "state"); | |
| 86 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 87 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 88 | + const at = new Date().toISOString(); | |
| 89 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 90 | + const account = (id, login, kind, status, owner = null) => `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', '${status}', ${owner ? `'${owner}'` : "NULL"}, '${at}')`; | |
| 91 | + const token = (id, account, t, scopes) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '${JSON.stringify(scopes)}', '${later}', '${at}')`; | |
| 92 | + const session = (id, account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 93 | + wrangler( | |
| 94 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 95 | + [ | |
| 96 | + account("u-olive", "olive", "user", "active"), | |
| 97 | + account("a-bot", "olive-bot", "agent", "active", "u-olive"), | |
| 98 | + account("u-rita", "rita", "user", "active"), | |
| 99 | + account("u-penny", "penny", "user", "pending"), | |
| 100 | + account("u-dora", "dora", "user", "disabled"), | |
| 101 | + account("u-gone", "gone", "user", "disabled"), | |
| 102 | + account("a-orphan", "gone-bot", "agent", "active", "u-gone"), | |
| 103 | + token("t1", "u-olive", T.olive, ["admin"]), | |
| 104 | + token("t2", "a-bot", T.bot, ["admin"]), | |
| 105 | + token("t3", "u-rita", T.rita, ["write"]), | |
| 106 | + token("t4", "u-dora", T.dora, ["write"]), | |
| 107 | + token("t5", "a-orphan", T.orphan, ["write"]), | |
| 108 | + session("s1", "u-penny", S.penny), | |
| 109 | + session("s2", "u-dora", S.dora), | |
| 110 | + ].join("; "), | |
| 111 | + ); | |
| 112 | + dev = spawn( | |
| 113 | + "pnpm", | |
| 114 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], | |
| 115 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 116 | + ); | |
| 117 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 118 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 119 | + await until( | |
| 120 | + "wrangler dev", | |
| 121 | + async () => { | |
| 122 | + try { | |
| 123 | + return (await fetch(`${base}/healthz`)).ok; | |
| 124 | + } catch { | |
| 125 | + return false; | |
| 126 | + } | |
| 127 | + }, | |
| 128 | + 120_000, | |
| 129 | + ); | |
| 130 | +}); | |
| 131 | + | |
| 132 | +after(() => { | |
| 133 | + if (dev?.pid) { | |
| 134 | + try { | |
| 135 | + process.kill(-dev.pid, "SIGTERM"); | |
| 136 | + } catch {} | |
| 137 | + } | |
| 138 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 139 | +}); | |
| 140 | + | |
| 141 | +/** A private repository owned by olive with one commit on main. */ | |
| 142 | +async function repoWithReadme(name, extra = {}) { | |
| 143 | + await ok("POST", "/user/repos", { name, ...extra }); | |
| 144 | + const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }); | |
| 145 | + return put.commit.sha; | |
| 146 | +} | |
| 147 | + | |
| 148 | +test("security: search, required checks, environments, reviews, locks, archives, drafts and tree names", async () => { | |
| 149 | + // ------------------------------------------------------------ setup: a private repo the agent and rita can use | |
| 150 | + const R = `/repos/${OWNER}/guarded`; | |
| 151 | + const mainSha = await repoWithReadme("guarded"); | |
| 152 | + await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" }); | |
| 153 | + await status(204, "PUT", `${R}/collaborators/rita`, { permission: "read" }); | |
| 154 | + await status(204, "PUT", `${R}/collaborators/gone-bot`, { permission: "write" }); | |
| 155 | + const issue = await ok("POST", `${R}/issues`, { title: "needle in a haystack" }); | |
| 156 | + | |
| 157 | + // ------------------------------------------------------------ search only shows what the caller may read | |
| 158 | + const found = await ok("GET", "/search/issues?q=needle"); | |
| 159 | + assert.equal(found.total_count, 1); | |
| 160 | + assert.equal(found.items[0].number, issue.number); | |
| 161 | + assert.equal((await ok("GET", "/search/issues?q=needle", undefined, T.rita)).total_count, 1); | |
| 162 | + for (const [who, as] of [ | |
| 163 | + ["pending account (session)", { cookie: S.penny }], | |
| 164 | + ["disabled account (session)", { cookie: S.dora }], | |
| 165 | + ["agent of a disabled owner", T.orphan], | |
| 166 | + ["anonymous", null], | |
| 167 | + ]) { | |
| 168 | + const r = await api("GET", "/search/issues?q=needle", undefined, as); | |
| 169 | + assert.equal(r.status, 200, `${who}: ${JSON.stringify(r.json)}`); | |
| 170 | + assert.equal(r.json.total_count, 0, `${who} must not find private issues`); | |
| 171 | + const repos = await ok("GET", "/search/repositories?q=guarded", undefined, as); | |
| 172 | + assert.equal(repos.total_count, 0, `${who} must not find private repos`); | |
| 173 | + } | |
| 174 | + await status(401, "GET", "/search/issues?q=needle", undefined, T.dora); | |
| 175 | + await status(404, "GET", R, undefined, T.orphan); | |
| 176 | + // Search input is bounded. | |
| 177 | + await status(422, "GET", `/search/issues?q=${"x".repeat(300)}`); | |
| 178 | + await status(422, "GET", "/search/issues"); | |
| 179 | + | |
| 180 | + // ------------------------------------------------------------ required checks: an agent can't turn its failing PR green | |
| 181 | + await ok("PUT", `${R}/branches/main/protection`, { requiredChecks: ["ci"] }); | |
| 182 | + await ok("POST", `${R}/git/refs`, { ref: "refs/heads/agent-change", sha: mainSha }, T.bot); | |
| 183 | + const change = await ok("PUT", `${R}/contents/change.txt`, { message: "agent change", content: b64("hi\n"), branch: "agent-change" }, T.bot); | |
| 184 | + const head = change.commit.sha; | |
| 185 | + const pr = await ok("POST", `${R}/pulls`, { title: "Agent change", head: "agent-change", base: "main" }, T.bot); | |
| 186 | + await ok("POST", `${R}/pulls/${pr.number}/reviews`, { event: "APPROVE", body: "fine by me" }); | |
| 187 | + const failing = await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: head, status: "completed", conclusion: "failure" }); | |
| 188 | + assert.equal(typeof failing.id, "number"); | |
| 189 | + const mergeState = async () => | |
| 190 | + until("mergeability", async () => { | |
| 191 | + const p = await ok("GET", `${R}/pulls/${pr.number}`); | |
| 192 | + return typeof p.mergeable === "boolean" ? p.mergeable_state : null; | |
| 193 | + }); | |
| 194 | + assert.equal(await mergeState(), "blocked"); | |
| 195 | + // 1. PATCH someone else's check run. | |
| 196 | + await status(403, "PATCH", `${R}/check-runs/${failing.id}`, { conclusion: "success" }, T.bot); | |
| 197 | + // 2. A green commit status with the same name. | |
| 198 | + await ok("POST", `${R}/statuses/${head}`, { state: "success", context: "ci" }, T.bot); | |
| 199 | + assert.equal(await mergeState(), "blocked"); | |
| 200 | + // 3. A green check run of its own with the same name. | |
| 201 | + await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: head, status: "completed", conclusion: "success" }, T.bot); | |
| 202 | + assert.equal(await mergeState(), "blocked"); | |
| 203 | + | |
| 204 | + // ------------------------------------------------------------ deployments verify every context unless told otherwise | |
| 205 | + await status(409, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging" }); | |
| 206 | + await status(201, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging", required_contexts: [] }); | |
| 207 | + await status(409, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging", required_contexts: ["ci"] }); | |
| 208 | + | |
| 209 | + // The real reporter fixes its check: now the PR is mergeable and deployable. | |
| 210 | + await ok("PATCH", `${R}/check-runs/${failing.id}`, { conclusion: "success" }); | |
| 211 | + assert.equal(await mergeState(), "clean"); | |
| 212 | + await status(201, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging" }); | |
| 213 | + | |
| 214 | + // ------------------------------------------------------------ review dismissal: maintainers only, never the author or an agent | |
| 215 | + const reviews = await ok("GET", `${R}/pulls/${pr.number}/reviews`); | |
| 216 | + const approval = reviews.find((r) => r.state === "APPROVED"); | |
| 217 | + await status(403, "PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "nope" }, T.bot); | |
| 218 | + await status(403, "PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "nope" }, T.rita); | |
| 219 | + const dismissed = await ok("PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "stale" }); | |
| 220 | + assert.equal(dismissed.state, "DISMISSED"); | |
| 221 | + | |
| 222 | + // ------------------------------------------------------------ environments: admins who are people, not agents | |
| 223 | + await status(403, "PUT", `${R}/environments/production`, {}, T.bot); | |
| 224 | + await ok("PUT", `${R}/environments/production`, { deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } }); | |
| 225 | + await status(403, "POST", `${R}/environments/production/deployment-branch-policies`, { name: "v*", type: "tag" }, T.bot); | |
| 226 | + const policy = await ok("POST", `${R}/environments/production/deployment-branch-policies`, { name: "release/*", type: "branch" }); | |
| 227 | + await status(403, "DELETE", `${R}/environments/production/deployment-branch-policies/${policy.id}`, undefined, T.bot); | |
| 228 | + await status(403, "DELETE", `${R}/environments/production`, undefined, T.bot); | |
| 229 | + await status(403, "PUT", `${R}/environments/production/secrets/TOKEN`, { value: "s3cret" }, T.bot); | |
| 230 | + // The first secret limits an environment without rules to protected branches. | |
| 231 | + await ok("PUT", `${R}/environments/vault`, {}); | |
| 232 | + assert.equal((await ok("GET", `${R}/environments/vault`)).deployment_branch_policy, null); | |
| 233 | + await status(201, "PUT", `${R}/environments/vault/secrets/API_KEY`, { value: "s3cret" }); | |
| 234 | + const vault = await ok("GET", `${R}/environments/vault`); | |
| 235 | + assert.equal(vault.deployment_branch_policy?.protected_branches, true); | |
| 236 | + assert.ok(!JSON.stringify(vault).includes("s3cret")); | |
| 237 | + | |
| 238 | + // ------------------------------------------------------------ locked conversations | |
| 239 | + await status(204, "PUT", `${R}/issues/${pr.number}/lock`, { lock_reason: "resolved" }); | |
| 240 | + await status(403, "POST", `${R}/pulls/${pr.number}/comments`, { body: "late", commit_id: head, path: "change.txt", line: 1, side: "RIGHT" }, T.rita); | |
| 241 | + await status(403, "POST", `${R}/issues/${pr.number}/reactions`, { content: "+1" }, T.rita); | |
| 242 | + await status(201, "POST", `${R}/issues/${issue.number}/reactions`, { content: "+1" }, T.rita); | |
| 243 | + | |
| 244 | + // ------------------------------------------------------------ unsafe tree entry names, on every write path | |
| 245 | + for (const path of [".git/config", ".GIT/hooks/pre-commit", "x/.git./config", "git~1/config", "a\\b", "x/../y"]) { | |
| 246 | + await status(422, "POST", `${R}/git/trees`, { tree: [{ path, mode: "100644", type: "blob", content: "x" }] }); | |
| 247 | + } | |
| 248 | + for (const path of [".Git/hooks/post-checkout", "git~1/HEAD", "sub/.git/config"]) { | |
| 249 | + await status(422, "PUT", `${R}/contents/${path}`, { message: "bad", content: b64("x") }); | |
| 250 | + } | |
| 251 | + | |
| 252 | + // ------------------------------------------------------------ a tree bomb can't blow up archives or languages | |
| 253 | + const leaf = await ok("POST", `${R}/git/trees`, { tree: [{ path: "a.js", mode: "100644", type: "blob", content: "x" }] }); | |
| 254 | + const wide = await ok("POST", `${R}/git/trees`, { tree: Array.from({ length: 400 }, (_, i) => ({ path: `d${i}`, mode: "040000", type: "tree", sha: leaf.sha })) }); | |
| 255 | + const bomb = await ok("POST", `${R}/git/trees`, { tree: Array.from({ length: 400 }, (_, i) => ({ path: `e${i}`, mode: "040000", type: "tree", sha: wide.sha })) }); | |
| 256 | + const bombCommit = await ok("POST", `${R}/git/commits`, { message: "bomb", tree: bomb.sha, parents: [mainSha] }); | |
| 257 | + await ok("POST", `${R}/git/refs`, { ref: "refs/heads/bomb", sha: bombCommit.sha }); | |
| 258 | + const zip = await fetch(`${base}/api/v3${R}/zipball/bomb`, { headers: { authorization: `Bearer ${T.olive}` }, redirect: "manual" }); | |
| 259 | + assert.equal(zip.status, 413, await zip.text()); | |
| 260 | + await status(200, "GET", `${R}/languages`); | |
| 261 | + | |
| 262 | + // ------------------------------------------------------------ labels with a percent sign (no double decoding) | |
| 263 | + await ok("POST", `${R}/labels`, { name: "50%", color: "ff0000" }); | |
| 264 | + assert.equal((await ok("GET", `${R}/labels/50%25`)).name, "50%"); | |
| 265 | + assert.equal((await ok("PATCH", `${R}/labels/50%25`, { description: "half" })).description, "half"); | |
| 266 | + await ok("POST", `${R}/issues/${issue.number}/labels`, { labels: ["50%"] }); | |
| 267 | + await ok("DELETE", `${R}/issues/${issue.number}/labels/50%25`); | |
| 268 | + await status(204, "DELETE", `${R}/labels/50%25`); | |
| 269 | + | |
| 270 | + // ------------------------------------------------------------ archived repositories are read-only for everyone | |
| 271 | + const A = `/repos/${OWNER}/attic`; | |
| 272 | + await repoWithReadme("attic"); | |
| 273 | + const old = await ok("POST", `${A}/issues`, { title: "old" }); | |
| 274 | + assert.equal((await ok("PATCH", A, { archived: true })).archived, true); | |
| 275 | + await status(403, "POST", `${A}/issues/${old.number}/reactions`, { content: "heart" }); | |
| 276 | + await status(403, "PUT", `${A}/environments/production`, {}); | |
| 277 | + await status(403, "POST", `${A}/releases`, { tag_name: "v1" }); | |
| 278 | + await status(200, "GET", `${A}/labels`); | |
| 279 | + | |
| 280 | + // ------------------------------------------------------------ private: false makes a repository public | |
| 281 | + await repoWithReadme("flip"); | |
| 282 | + const flipped = await ok("PATCH", `/repos/${OWNER}/flip`, { private: false }); | |
| 283 | + assert.equal(flipped.private, false); | |
| 284 | + assert.equal(flipped.visibility, "public"); | |
| 285 | + await status(200, "GET", `/repos/${OWNER}/flip`, undefined, null); | |
| 286 | + await status(422, "PATCH", `/repos/${OWNER}/flip`, { visibility: "secret" }); | |
| 287 | + | |
| 288 | + // ------------------------------------------------------------ draft releases never reach readers' live pages | |
| 289 | + const ws = new WebSocket(`${base.replace("http", "ws")}/${OWNER}/flip/live?topics=repo,releases`); | |
| 290 | + const seen = []; | |
| 291 | + ws.addEventListener("message", (e) => seen.push(String(e.data))); | |
| 292 | + await new Promise((res, rej) => { | |
| 293 | + ws.addEventListener("open", res); | |
| 294 | + ws.addEventListener("error", rej); | |
| 295 | + }); | |
| 296 | + const draft = await ok("POST", `/repos/${OWNER}/flip/releases`, { tag_name: "v0.9", name: "Secret plans", body: "draft notes", draft: true }); | |
| 297 | + await ok("POST", `/repos/${OWNER}/flip/releases`, { tag_name: "v1.0", name: "Public release" }); | |
| 298 | + await until("the published release event", async () => seen.some((m) => m.includes("Public release"))); | |
| 299 | + ws.close(); | |
| 300 | + assert.ok(!seen.some((m) => m.includes("Secret plans") || m.includes(`"id":${draft.id}`)), `draft leaked: ${seen.join("\n")}`); | |
| 301 | +}); |
addedapps/diffinery/test/unit/archive-stream.test.ts+33−0View file
| @@ -1,0 +1,33 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { ZIP_MAX_ENTRIES, ZipWriter, concat, tar, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../../src/lib/archive.js"; | |
| 3 | + | |
| 4 | +const enc = new TextEncoder(); | |
| 5 | +const MTIME = Date.UTC(2024, 0, 2, 3, 4, 6) / 1000; | |
| 6 | +const entries: ArchiveEntry[] = [ | |
| 7 | + { path: "r-abc/", dir: true }, | |
| 8 | + { path: "r-abc/a.txt", mode: "100644", content: enc.encode("hello\n".repeat(100)) }, | |
| 9 | + { path: `r-abc/${"long/".repeat(30)}b`, mode: "100755", content: enc.encode("#!/bin/sh\n") }, | |
| 10 | + { path: "r-abc/l", mode: "120000", content: enc.encode("a.txt") }, | |
| 11 | +]; | |
| 12 | + | |
| 13 | +describe("streaming archive writers", () => { | |
| 14 | + it("write the same tar piece by piece as in one go", () => { | |
| 15 | + const pieces = [...tarStart(MTIME, "abc"), ...entries.flatMap((e) => tarEntry(e, MTIME)), tarEnd()]; | |
| 16 | + expect(concat(pieces)).toEqual(tar(entries, MTIME, "abc")); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("write the same zip piece by piece as in one go", async () => { | |
| 20 | + const w = new ZipWriter(MTIME); | |
| 21 | + const parts: Uint8Array[] = []; | |
| 22 | + for (const e of entries) parts.push(...(await w.entry(e))); | |
| 23 | + parts.push(...w.end("abc")); | |
| 24 | + expect(concat(parts)).toEqual(await zip(entries, MTIME, "abc")); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("refuse more entries than classic zip can hold instead of writing a broken archive", async () => { | |
| 28 | + const w = new ZipWriter(MTIME); | |
| 29 | + const dir: ArchiveEntry = { path: "d/", dir: true }; | |
| 30 | + for (let i = 0; i < ZIP_MAX_ENTRIES; i++) await w.entry(dir); | |
| 31 | + await expect(w.entry(dir)).rejects.toBeInstanceOf(RangeError); | |
| 32 | + }); | |
| 33 | +}); |
addedapps/diffinery/test/unit/archive.test.ts+105−0View file
| @@ -1,0 +1,105 @@ | ||
| 1 | +import { gunzipSync, inflateRawSync } from "node:zlib"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { crc32, gzip, tar, zip, type ArchiveEntry } from "../../src/lib/archive.js"; | |
| 4 | + | |
| 5 | +const enc = new TextEncoder(); | |
| 6 | +const dec = new TextDecoder(); | |
| 7 | +const MTIME = Date.UTC(2024, 4, 6, 7, 8, 10) / 1000; | |
| 8 | + | |
| 9 | +const entries: ArchiveEntry[] = [ | |
| 10 | + { path: "repo-abc1234/", dir: true }, | |
| 11 | + { path: "repo-abc1234/README.md", mode: "100644", content: enc.encode("# hello\n".repeat(50)) }, | |
| 12 | + { path: "repo-abc1234/bin/run", mode: "100755", content: enc.encode("#!/bin/sh\necho hi\n") }, | |
| 13 | + { path: `repo-abc1234/${"deep/".repeat(30)}file.txt`, mode: "100644", content: enc.encode("long path") }, | |
| 14 | + { path: "repo-abc1234/link", mode: "120000", content: enc.encode("README.md") }, | |
| 15 | +]; | |
| 16 | + | |
| 17 | +/** Read a tar archive's entries (ustar plus pax `path` records). */ | |
| 18 | +function untar(data: Uint8Array): Array<{ name: string; type: string; mode: number; body: string; link: string }> { | |
| 19 | + const out: Array<{ name: string; type: string; mode: number; body: string; link: string }> = []; | |
| 20 | + let off = 0; | |
| 21 | + let paxPath: string | null = null; | |
| 22 | + while (off + 512 <= data.length) { | |
| 23 | + const h = data.subarray(off, off + 512); | |
| 24 | + if (h.every((b) => b === 0)) break; | |
| 25 | + const str = (a: number, n: number) => dec.decode(h.subarray(a, a + n)).replace(/\0.*$/s, ""); | |
| 26 | + const size = parseInt(str(124, 12), 8); | |
| 27 | + const type = str(156, 1); | |
| 28 | + const body = data.subarray(off + 512, off + 512 + size); | |
| 29 | + // Header checksum: the sum of all header bytes with the checksum field as spaces. | |
| 30 | + let sum = 0; | |
| 31 | + for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : h[i]!; | |
| 32 | + expect(parseInt(str(148, 8), 8)).toBe(sum); | |
| 33 | + if (type === "x") { | |
| 34 | + paxPath = /\d+ path=(.*)\n/.exec(dec.decode(body))?.[1] ?? null; | |
| 35 | + } else if (type !== "g") { | |
| 36 | + out.push({ name: paxPath ?? str(0, 100), type, mode: parseInt(str(100, 8), 8), body: dec.decode(body), link: str(157, 100) }); | |
| 37 | + paxPath = null; | |
| 38 | + } | |
| 39 | + off += 512 + Math.ceil(size / 512) * 512; | |
| 40 | + } | |
| 41 | + return out; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** Read a zip archive's central directory and inflate each entry. */ | |
| 45 | +function unzip(data: Uint8Array): Array<{ name: string; body: string; unixMode: number }> { | |
| 46 | + const v = new DataView(data.buffer, data.byteOffset, data.byteLength); | |
| 47 | + let eocd = data.length - 22; | |
| 48 | + while (eocd >= 0 && v.getUint32(eocd, true) !== 0x06054b50) eocd--; | |
| 49 | + expect(eocd).toBeGreaterThanOrEqual(0); | |
| 50 | + const count = v.getUint16(eocd + 10, true); | |
| 51 | + let p = v.getUint32(eocd + 16, true); | |
| 52 | + const out: Array<{ name: string; body: string; unixMode: number }> = []; | |
| 53 | + for (let i = 0; i < count; i++) { | |
| 54 | + expect(v.getUint32(p, true)).toBe(0x02014b50); | |
| 55 | + const method = v.getUint16(p + 10, true); | |
| 56 | + const crc = v.getUint32(p + 16, true); | |
| 57 | + const csize = v.getUint32(p + 20, true); | |
| 58 | + const nlen = v.getUint16(p + 28, true); | |
| 59 | + const xlen = v.getUint16(p + 30, true); | |
| 60 | + const clen = v.getUint16(p + 32, true); | |
| 61 | + const attrs = v.getUint32(p + 38, true); | |
| 62 | + const local = v.getUint32(p + 42, true); | |
| 63 | + const name = dec.decode(data.subarray(p + 46, p + 46 + nlen)); | |
| 64 | + const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true); | |
| 65 | + const raw = data.subarray(start, start + csize); | |
| 66 | + const body = method === 8 ? new Uint8Array(inflateRawSync(raw)) : raw; | |
| 67 | + expect(crc32(body)).toBe(crc); | |
| 68 | + out.push({ name, body: dec.decode(body), unixMode: attrs >>> 16 }); | |
| 69 | + p += 46 + nlen + xlen + clen; | |
| 70 | + } | |
| 71 | + return out; | |
| 72 | +} | |
| 73 | + | |
| 74 | +describe("archives", () => { | |
| 75 | + it("computes the standard CRC-32", () => { | |
| 76 | + expect(crc32(enc.encode("123456789"))).toBe(0xcbf43926); | |
| 77 | + expect(crc32(new Uint8Array(0))).toBe(0); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("writes a tar with modes, symlinks, long names and a commit comment", async () => { | |
| 81 | + const t = tar(entries, MTIME, "abc1234"); | |
| 82 | + expect(t.length % 512).toBe(0); | |
| 83 | + const files = untar(t); | |
| 84 | + expect(files.map((f) => f.name)).toEqual(entries.map((e) => ("dir" in e && !e.path.endsWith("/") ? `${e.path}/` : e.path))); | |
| 85 | + expect(files[1]!.body).toBe("# hello\n".repeat(50)); | |
| 86 | + // Like `git archive` (tar.umask 0002), which GitHub tarballs come from. | |
| 87 | + expect(files[2]!.mode).toBe(0o775); | |
| 88 | + expect(files[1]!.mode).toBe(0o664); | |
| 89 | + expect(files[3]!.body).toBe("long path"); | |
| 90 | + expect(files[4]!.type).toBe("2"); | |
| 91 | + expect(files[4]!.link).toBe("README.md"); | |
| 92 | + // gzip round trip | |
| 93 | + expect(new Uint8Array(gunzipSync(await gzip(t)))).toEqual(t); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + it("writes a zip that inflates back to the same files", async () => { | |
| 97 | + const z = await zip(entries, MTIME, "abc1234"); | |
| 98 | + const files = unzip(z); | |
| 99 | + expect(files.map((f) => f.name)).toEqual(entries.map((e) => e.path)); | |
| 100 | + expect(files[1]!.body).toBe("# hello\n".repeat(50)); | |
| 101 | + expect(files[2]!.unixMode & 0o777).toBe(0o775); | |
| 102 | + expect(files[4]!.unixMode & 0o170000).toBe(0o120000); | |
| 103 | + expect(dec.decode(z.subarray(z.length - "abc1234".length))).toBe("abc1234"); | |
| 104 | + }); | |
| 105 | +}); |
addedapps/diffinery/test/unit/environment-secrets.test.ts+24−0View file
| @@ -1,0 +1,24 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { secretsAllowedFor, type BranchPolicy } from "../../src/repo/deployments.js"; | |
| 3 | + | |
| 4 | +const protectedOnly: BranchPolicy = { protected_branches: true, custom_branch_policies: false, patterns: [] }; | |
| 5 | +const custom: BranchPolicy = { protected_branches: false, custom_branch_policies: true, patterns: [{ id: 1, name: "release/*", type: "branch" }] }; | |
| 6 | + | |
| 7 | +describe("environment secrets", () => { | |
| 8 | + it("never go to pull request refs", () => { | |
| 9 | + for (const p of [null, protectedOnly, custom]) { | |
| 10 | + for (const kind of ["user", "agent", "system"]) expect(secretsAllowedFor(p, "refs/pull/7/merge", kind)).toBe(false); | |
| 11 | + } | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("go to runs by people on allowed refs", () => { | |
| 15 | + expect(secretsAllowedFor(null, "refs/heads/main", "user")).toBe(true); | |
| 16 | + expect(secretsAllowedFor(custom, "refs/heads/release/1", "user")).toBe(true); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("go to runs by agents only when the environment is limited to protected branches", () => { | |
| 20 | + expect(secretsAllowedFor(null, "refs/heads/main", "agent")).toBe(false); | |
| 21 | + expect(secretsAllowedFor(custom, "refs/heads/release/1", "agent")).toBe(false); | |
| 22 | + expect(secretsAllowedFor(protectedOnly, "refs/heads/main", "agent")).toBe(true); | |
| 23 | + }); | |
| 24 | +}); |
addedapps/diffinery/test/unit/r2-sink.test.ts+102−0View file
| @@ -1,0 +1,102 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { PART_SIZE, R2Sink } from "../../src/lib/r2-sink.js"; | |
| 3 | +import { importHostAllowed, withoutCredentials } from "../../src/lib/import-hosts.js"; | |
| 4 | + | |
| 5 | +/** An in-memory stand-in for the bits of R2Bucket the sink uses; it enforces R2's part-size rules. */ | |
| 6 | +function fakeBucket() { | |
| 7 | + const objects = new Map<string, Uint8Array>(); | |
| 8 | + const log: string[] = []; | |
| 9 | + const bucket = { | |
| 10 | + async put(key: string, value: Uint8Array) { | |
| 11 | + log.push(`put ${value.length}`); | |
| 12 | + objects.set(key, value); | |
| 13 | + return {}; | |
| 14 | + }, | |
| 15 | + async createMultipartUpload(key: string) { | |
| 16 | + const parts = new Map<number, Uint8Array>(); | |
| 17 | + log.push("create"); | |
| 18 | + return { | |
| 19 | + async uploadPart(n: number, data: Uint8Array) { | |
| 20 | + log.push(`part ${n} ${data.length}`); | |
| 21 | + parts.set(n, data.slice()); | |
| 22 | + return { partNumber: n, etag: `e${n}` }; | |
| 23 | + }, | |
| 24 | + async complete(list: Array<{ partNumber: number }>) { | |
| 25 | + const sizes = list.map((p) => parts.get(p.partNumber)!.length); | |
| 26 | + // R2: every part but the last has the same size, at least 5 MiB. | |
| 27 | + for (const s of sizes.slice(0, -1)) { | |
| 28 | + if (s !== sizes[0] || s < 5 * 1024 * 1024) throw new Error("bad part sizes"); | |
| 29 | + } | |
| 30 | + const all = new Uint8Array(sizes.reduce((a, b) => a + b, 0)); | |
| 31 | + let o = 0; | |
| 32 | + for (const p of list) { | |
| 33 | + all.set(parts.get(p.partNumber)!, o); | |
| 34 | + o += parts.get(p.partNumber)!.length; | |
| 35 | + } | |
| 36 | + objects.set(key, all); | |
| 37 | + log.push("complete"); | |
| 38 | + return {}; | |
| 39 | + }, | |
| 40 | + async abort() { | |
| 41 | + log.push("abort"); | |
| 42 | + }, | |
| 43 | + }; | |
| 44 | + }, | |
| 45 | + }; | |
| 46 | + return { bucket: bucket as unknown as R2Bucket, objects, log }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +const chunk = (n: number, fill: number) => new Uint8Array(n).fill(fill); | |
| 50 | + | |
| 51 | +describe("R2Sink", () => { | |
| 52 | + it("uses a single put for small objects", async () => { | |
| 53 | + const { bucket, objects, log } = fakeBucket(); | |
| 54 | + const sink = new R2Sink(bucket, "k", "text/plain", 1024); | |
| 55 | + await sink.write(chunk(10, 1)); | |
| 56 | + await sink.write(chunk(20, 2)); | |
| 57 | + expect(await sink.close()).toBe(30); | |
| 58 | + expect(log).toEqual(["put 30"]); | |
| 59 | + expect(objects.get("k")!.length).toBe(30); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it("uploads big objects as equal parts, whatever the chunk sizes", async () => { | |
| 63 | + const { bucket, objects, log } = fakeBucket(); | |
| 64 | + const sink = new R2Sink(bucket, "k", "application/gzip", 64 * 1024 * 1024); | |
| 65 | + const sizes = [3_000_000, 7_777_777, 1, 5_000_000, 2_500_000]; | |
| 66 | + let i = 0; | |
| 67 | + for (const s of sizes) await sink.write(chunk(s, i++)); | |
| 68 | + const total = sizes.reduce((a, b) => a + b, 0); | |
| 69 | + expect(await sink.close()).toBe(total); | |
| 70 | + expect(log.filter((l) => l.startsWith("part")).slice(0, -1).every((l) => l.endsWith(` ${PART_SIZE}`))).toBe(true); | |
| 71 | + expect(log.at(-1)).toBe("complete"); | |
| 72 | + // The bytes come out in order. | |
| 73 | + const out = objects.get("k")!; | |
| 74 | + expect(out.length).toBe(total); | |
| 75 | + expect(out[0]).toBe(0); | |
| 76 | + expect(out[3_000_000]).toBe(1); | |
| 77 | + expect(out[total - 1]).toBe(4); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("stops with a 413 past the limit and aborts the upload", async () => { | |
| 81 | + const { bucket, log } = fakeBucket(); | |
| 82 | + const sink = new R2Sink(bucket, "k", "application/zip", PART_SIZE + 10, "too big"); | |
| 83 | + await sink.write(chunk(PART_SIZE, 0)); | |
| 84 | + await expect(sink.write(chunk(11, 0))).rejects.toMatchObject({ status: 413 }); | |
| 85 | + await sink.abort(); | |
| 86 | + expect(log).toContain("abort"); | |
| 87 | + }); | |
| 88 | +}); | |
| 89 | + | |
| 90 | +describe("importer hosts", () => { | |
| 91 | + it("allows GitHub's hosts and the configured enterprise host only", () => { | |
| 92 | + for (const h of ["github.com", "api.github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", "GitHub.com"]) expect(importHostAllowed(h)).toBe(true); | |
| 93 | + for (const h of ["example.com", "evil.github.com.example", "githubusercontent.com", "raw.githubusercontent.com", "localhost"]) expect(importHostAllowed(h)).toBe(false); | |
| 94 | + expect(importHostAllowed("ghe.example.com", "GHE.example.com")).toBe(true); | |
| 95 | + expect(importHostAllowed("ghe.example.com")).toBe(false); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("drops every credential header on a cross-host redirect", () => { | |
| 99 | + const h = withoutCredentials({ Authorization: "token x", Cookie: "a=b", "X-GitHub-Token": "y", "Proxy-Authorization": "z", Accept: "application/octet-stream", "User-Agent": "diffinery" }); | |
| 100 | + expect(h).toEqual({ Accept: "application/octet-stream", "User-Agent": "diffinery" }); | |
| 101 | + }); | |
| 102 | +}); |
addedapps/diffinery/test/unit/release-notes.test.ts+87−0View file
| @@ -1,0 +1,87 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { parseReleaseConfig, renderReleaseNotes, type NotesPull } from "../../src/repo/release-notes.js"; | |
| 3 | + | |
| 4 | +const pr = (number: number, title: string, author: string, labels: string[] = []): NotesPull => ({ number, title, author, labels, url: `https://example.test/o/r/pull/${number}` }); | |
| 5 | +const changelogUrl = "https://example.test/o/r/compare/v1.0.0...v1.1.0"; | |
| 6 | + | |
| 7 | +describe("generated release notes", () => { | |
| 8 | + it("lists merged pull requests, new contributors and the changelog link like GitHub", () => { | |
| 9 | + const pulls = [pr(1, "Add search", "alice"), pr(2, "Fix crash", "bob")]; | |
| 10 | + expect(renderReleaseNotes({ pulls, newContributors: [pulls[1]!], changelogUrl })).toBe( | |
| 11 | + [ | |
| 12 | + "## What's Changed", | |
| 13 | + "* Add search by @alice in https://example.test/o/r/pull/1", | |
| 14 | + "* Fix crash by @bob in https://example.test/o/r/pull/2", | |
| 15 | + "", | |
| 16 | + "## New Contributors", | |
| 17 | + "* @bob made their first contribution in https://example.test/o/r/pull/2", | |
| 18 | + "", | |
| 19 | + `**Full Changelog**: ${changelogUrl}`, | |
| 20 | + ].join("\n"), | |
| 21 | + ); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("has only the changelog link when nothing was merged", () => { | |
| 25 | + expect(renderReleaseNotes({ pulls: [], newContributors: [], changelogUrl })).toBe(`**Full Changelog**: ${changelogUrl}`); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("groups by the categories in .github/release.yml and applies exclusions", () => { | |
| 29 | + const config = parseReleaseConfig(` | |
| 30 | +changelog: | |
| 31 | + exclude: | |
| 32 | + labels: [ignore-for-release] | |
| 33 | + authors: [dependabot] | |
| 34 | + categories: | |
| 35 | + - title: Breaking Changes | |
| 36 | + labels: [breaking] | |
| 37 | + - title: Features | |
| 38 | + labels: [feature, enhancement] | |
| 39 | + exclude: | |
| 40 | + labels: [internal] | |
| 41 | + - title: Everything else | |
| 42 | + labels: ["*"] | |
| 43 | +`); | |
| 44 | + expect(config.exclude).toEqual({ labels: ["ignore-for-release"], authors: ["dependabot"] }); | |
| 45 | + expect(config.categories?.map((c) => c.title)).toEqual(["Breaking Changes", "Features", "Everything else"]); | |
| 46 | + const notes = renderReleaseNotes({ | |
| 47 | + pulls: [ | |
| 48 | + pr(1, "New API", "alice", ["breaking", "feature"]), | |
| 49 | + pr(2, "Dark mode", "bob", ["enhancement"]), | |
| 50 | + pr(3, "Bump deps", "Dependabot", ["deps"]), | |
| 51 | + pr(4, "Hidden", "carol", ["ignore-for-release"]), | |
| 52 | + pr(5, "Refactor", "dave", ["feature", "internal"]), | |
| 53 | + ], | |
| 54 | + newContributors: [], | |
| 55 | + config, | |
| 56 | + changelogUrl, | |
| 57 | + }); | |
| 58 | + expect(notes).toBe( | |
| 59 | + [ | |
| 60 | + "## What's Changed", | |
| 61 | + "### Breaking Changes", | |
| 62 | + "* New API by @alice in https://example.test/o/r/pull/1", | |
| 63 | + "", | |
| 64 | + "### Features", | |
| 65 | + "* Dark mode by @bob in https://example.test/o/r/pull/2", | |
| 66 | + "", | |
| 67 | + "### Everything else", | |
| 68 | + "* Refactor by @dave in https://example.test/o/r/pull/5", | |
| 69 | + "", | |
| 70 | + `**Full Changelog**: ${changelogUrl}`, | |
| 71 | + ].join("\n"), | |
| 72 | + ); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("puts uncategorised pull requests under Other Changes", () => { | |
| 76 | + const config = parseReleaseConfig("changelog:\n categories:\n - title: Fixes\n labels: bug\n"); | |
| 77 | + const notes = renderReleaseNotes({ pulls: [pr(1, "Fix", "a", ["bug"]), pr(2, "Docs", "b")], newContributors: [], config, changelogUrl }); | |
| 78 | + expect(notes).toContain("### Fixes\n* Fix by @a"); | |
| 79 | + expect(notes).toContain("### Other Changes\n* Docs by @b"); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("ignores malformed configuration", () => { | |
| 83 | + expect(parseReleaseConfig(": : :\n - [")).toEqual({}); | |
| 84 | + expect(parseReleaseConfig("just text")).toEqual({}); | |
| 85 | + expect(parseReleaseConfig("changelog:\n categories:\n - labels: [x]\n")).toEqual({ categories: [] }); | |
| 86 | + }); | |
| 87 | +}); |
addedapps/diffinery/test/unit/required.test.ts+50−0View file
| @@ -1,0 +1,50 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { ACTIONS_APP, normalizeCheckSources, requiredCheck } from "../../src/repo/required.js"; | |
| 3 | +import type { Check } from "../../src/repo/collab.js"; | |
| 4 | + | |
| 5 | +type Entry = Partial<Check> & Pick<Check, "name" | "app" | "status">; | |
| 6 | +const entry = (e: Entry): Check => ({ conclusion: null, creatorLogin: null, creatorKind: null, ...e }) as unknown as Check; | |
| 7 | +const actions = (status: string, conclusion: string | null = null) => entry({ name: "ci", app: ACTIONS_APP, status, conclusion } as Entry); | |
| 8 | +const by = (login: string, kind: string, conclusion: string) => entry({ name: "ci", app: login, status: "completed", conclusion, creatorLogin: login, creatorKind: kind } as Entry); | |
| 9 | + | |
| 10 | +describe("requiredCheck", () => { | |
| 11 | + it("is missing when nothing reported the name", () => { | |
| 12 | + expect(requiredCheck("ci", []).state).toBe("missing"); | |
| 13 | + expect(requiredCheck("ci", [entry({ name: "lint", app: ACTIONS_APP, status: "completed", conclusion: "success" } as Entry)]).state).toBe("missing"); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("passes on success, skipped and neutral", () => { | |
| 17 | + for (const c of ["success", "skipped", "neutral"]) expect(requiredCheck("ci", [actions("completed", c)]).state).toBe("success"); | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it("lets Diffinery Actions be the authority over anyone else", () => { | |
| 21 | + // A user posting a green status of the same name can't outvote a failing workflow. | |
| 22 | + expect(requiredCheck("ci", [actions("completed", "failure"), by("alice", "user", "success")]).state).toBe("failure"); | |
| 23 | + expect(requiredCheck("ci", [actions("in_progress"), by("alice", "user", "success")]).state).toBe("pending"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("fails when any counted report fails, so a newer green one can't hide it", () => { | |
| 27 | + expect(requiredCheck("ci", [by("alice", "user", "failure"), by("bob", "user", "success")]).state).toBe("failure"); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("never counts reports from agents", () => { | |
| 31 | + expect(requiredCheck("ci", [by("bot", "agent", "success")]).state).toBe("missing"); | |
| 32 | + expect(requiredCheck("ci", [by("alice", "user", "failure"), by("bot", "agent", "success")]).state).toBe("failure"); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("only counts the bound source when there is one", () => { | |
| 36 | + const entries = [actions("completed", "failure"), by("ci-bot", "user", "success")]; | |
| 37 | + expect(requiredCheck("ci", entries, "ci-bot").state).toBe("success"); | |
| 38 | + expect(requiredCheck("ci", [by("alice", "user", "success")], "ci-bot").state).toBe("missing"); | |
| 39 | + expect(requiredCheck("ci", entries, ACTIONS_APP).state).toBe("failure"); | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe("normalizeCheckSources", () => { | |
| 44 | + it("lowercases valid sources", () => { | |
| 45 | + expect(normalizeCheckSources({ ci: "Diffinery-Actions", deploy: "CI-Bot" })).toEqual({ ci: "diffinery-actions", deploy: "ci-bot" }); | |
| 46 | + }); | |
| 47 | + it("refuses anything else", () => { | |
| 48 | + for (const v of [null, [], "x", { ci: 1 }, { ci: "" }, { ci: "a b" }, { "": "x" }]) expect(() => normalizeCheckSources(v)).toThrow(); | |
| 49 | + }); | |
| 50 | +}); |
addedapps/diffinery/test/unit/search-query.test.ts+112−0View file
| @@ -1,0 +1,112 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { inRange, matchesText, parseIssueQuery, parseRange, parseRepoQuery, tokenize } from "../../src/lib/search-query.js"; | |
| 3 | + | |
| 4 | +describe("search query tokenizer", () => { | |
| 5 | + it("splits qualifiers, quoted values and negations", () => { | |
| 6 | + expect(tokenize('repo:o/r label:"good first issue" -label:wontfix crash')).toEqual([ | |
| 7 | + { key: "repo", value: "o/r", negated: false }, | |
| 8 | + { key: "label", value: "good first issue", negated: false }, | |
| 9 | + { key: "label", value: "wontfix", negated: true }, | |
| 10 | + { key: null, value: "crash", negated: false }, | |
| 11 | + ]); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("keeps a quoted phrase as one word", () => { | |
| 15 | + expect(tokenize('"null pointer" -flaky')).toEqual([ | |
| 16 | + { key: null, value: "null pointer", negated: false }, | |
| 17 | + { key: null, value: "flaky", negated: true }, | |
| 18 | + ]); | |
| 19 | + }); | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe("issue search queries", () => { | |
| 23 | + it("reads the qualifiers actions and scripts use", () => { | |
| 24 | + const q = parseIssueQuery("repo:acme/api is:pr is:open label:bug,ci -label:wip author:alice assignee:bob head:feature base:main in:title fix crash"); | |
| 25 | + expect(q.repos).toEqual(["acme/api"]); | |
| 26 | + expect(q.kind).toBe("pull"); | |
| 27 | + expect(q.state).toBe("open"); | |
| 28 | + expect(q.labels).toEqual(["bug", "ci"]); | |
| 29 | + expect(q.notLabels).toEqual(["wip"]); | |
| 30 | + expect(q.author).toBe("alice"); | |
| 31 | + expect(q.assignee).toBe("bob"); | |
| 32 | + expect(q.head).toBe("feature"); | |
| 33 | + expect(q.base).toBe("main"); | |
| 34 | + expect(q.in).toEqual(["title"]); | |
| 35 | + expect(q.text).toEqual(["fix", "crash"]); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("handles merged, draft, locked, no: and negated author", () => { | |
| 39 | + const q = parseIssueQuery("is:merged -is:draft is:locked no:label no:assignee -author:dependabot user:acme"); | |
| 40 | + expect(q.kind).toBe("pull"); | |
| 41 | + expect(q.merged).toBe(true); | |
| 42 | + expect(q.draft).toBe(false); | |
| 43 | + expect(q.locked).toBe(true); | |
| 44 | + expect(q.noLabel).toBe(true); | |
| 45 | + expect(q.noAssignee).toBe(true); | |
| 46 | + expect(q.notAuthor).toBe("dependabot"); | |
| 47 | + expect(q.owners).toEqual(["acme"]); | |
| 48 | + expect(parseIssueQuery("is:unmerged").merged).toBe(false); | |
| 49 | + expect(parseIssueQuery("-is:open").state).toBe("closed"); | |
| 50 | + expect(parseIssueQuery("type:issue state:closed").kind).toBe("issue"); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it("parses date ranges and sort", () => { | |
| 54 | + const q = parseIssueQuery("created:>=2024-01-01 updated:2024-01-01..2024-02-01 closed:<2023-06-01 sort:updated-asc"); | |
| 55 | + expect(q.created).toEqual({ gte: "2024-01-01" }); | |
| 56 | + expect(q.updated).toEqual({ gte: "2024-01-01", lte: "2024-02-01T23:59:59.999Z" }); | |
| 57 | + expect(q.closed).toEqual({ lt: "2023-06-01" }); | |
| 58 | + expect(q.sort).toEqual({ field: "updated", dir: "asc" }); | |
| 59 | + // The sort and order parameters win over a sort: qualifier. | |
| 60 | + expect(parseIssueQuery("sort:updated-asc", { sort: "comments", order: "desc" }).sort).toEqual({ field: "comments", dir: "desc" }); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("treats unknown qualifiers and bad repo names as text or ignores them", () => { | |
| 64 | + const q = parseIssueQuery("milestone:v2 repo:not-a-repo"); | |
| 65 | + expect(q.text).toEqual(["milestone:v2"]); | |
| 66 | + expect(q.repos).toEqual([]); | |
| 67 | + }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +describe("date ranges", () => { | |
| 71 | + it("covers whole days and open ends", () => { | |
| 72 | + expect(parseRange(">2024-01-01")).toEqual({ gt: "2024-01-01T23:59:59.999Z" }); | |
| 73 | + expect(parseRange("<=2024-01-01")).toEqual({ lte: "2024-01-01T23:59:59.999Z" }); | |
| 74 | + expect(parseRange("2024-03-05")).toEqual({ gte: "2024-03-05", lte: "2024-03-05T23:59:59.999Z" }); | |
| 75 | + expect(parseRange("2024-01-01..*")).toEqual({ gte: "2024-01-01" }); | |
| 76 | + expect(parseRange("*..2024-01-01")).toEqual({ lte: "2024-01-01T23:59:59.999Z" }); | |
| 77 | + expect(parseRange("yesterday")).toBeNull(); | |
| 78 | + expect(parseRange(">soon")).toBeNull(); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("matches ISO timestamps", () => { | |
| 82 | + const day = parseRange("2024-03-05")!; | |
| 83 | + expect(inRange("2024-03-05T10:00:00Z", day)).toBe(true); | |
| 84 | + expect(inRange("2024-03-06T00:00:00Z", day)).toBe(false); | |
| 85 | + expect(inRange(null, day)).toBe(false); | |
| 86 | + expect(inRange(null, undefined)).toBe(true); | |
| 87 | + expect(inRange("2024-01-02T00:00:00Z", parseRange(">2024-01-01")!)).toBe(true); | |
| 88 | + expect(inRange("2024-01-01T12:00:00Z", parseRange(">2024-01-01")!)).toBe(false); | |
| 89 | + }); | |
| 90 | +}); | |
| 91 | + | |
| 92 | +describe("repository search queries", () => { | |
| 93 | + it("reads owners, topics, visibility and archived", () => { | |
| 94 | + const q = parseRepoQuery("org:acme topic:CLI is:private archived:false in:name,description tool language:go"); | |
| 95 | + expect(q.owners).toEqual(["acme"]); | |
| 96 | + expect(q.topics).toEqual(["cli"]); | |
| 97 | + expect(q.visibility).toBe("private"); | |
| 98 | + expect(q.archived).toBe(false); | |
| 99 | + expect(q.in).toEqual(["name", "description"]); | |
| 100 | + expect(q.text).toEqual(["tool"]); | |
| 101 | + }); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +describe("text matching", () => { | |
| 105 | + it("needs every word and none of the excluded ones, ignoring case", () => { | |
| 106 | + expect(matchesText([], ["anything"])).toBe(true); | |
| 107 | + expect(matchesText(["Crash", "login"], ["App crashes on login"])).toBe(true); | |
| 108 | + expect(matchesText(["crash", "-login"], ["App crashes on login"])).toBe(false); | |
| 109 | + expect(matchesText(["crash"], ["title", "body mentions a CRASH"])).toBe(true); | |
| 110 | + expect(matchesText(["-"], ["a - b"])).toBe(true); | |
| 111 | + }); | |
| 112 | +}); |
addedapps/diffinery/test/unit/tree-names.test.ts+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { isDotGitName, unsafeTreeName, unsafeTreePath } from "../../src/lib/tree-names.js"; | |
| 3 | + | |
| 4 | +describe("tree entry names", () => { | |
| 5 | + it("accepts ordinary names", () => { | |
| 6 | + for (const n of ["README.md", ".gitignore", ".github", "git", "a b", "50%", ".git-blame-ignore-revs", "gitk~1"]) expect(unsafeTreeName(n)).toBeNull(); | |
| 7 | + }); | |
| 8 | + | |
| 9 | + it("refuses . and .. and separators", () => { | |
| 10 | + for (const n of ["", ".", "..", "a/b", "a\\b", "a\0b"]) expect(unsafeTreeName(n)).not.toBeNull(); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("refuses every spelling a checkout would treat as .git", () => { | |
| 14 | + for (const n of [".git", ".GIT", ".Git", ".git.", ".git ", ".git. .", "git~1", "GIT~1", ".git", ".git", ".git"]) { | |
| 15 | + expect(isDotGitName(n)).toBe(true); | |
| 16 | + expect(unsafeTreeName(n)).not.toBeNull(); | |
| 17 | + } | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it("refuses long names", () => { | |
| 21 | + expect(unsafeTreeName("a".repeat(255))).toBeNull(); | |
| 22 | + expect(unsafeTreeName("a".repeat(256))).not.toBeNull(); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("checks every segment of a path", () => { | |
| 26 | + expect(unsafeTreePath("src/lib/a.ts")).toBeNull(); | |
| 27 | + expect(unsafeTreePath("/src//lib/")).toBeNull(); | |
| 28 | + for (const p of ["", "/", "a/../b", "./a", ".git/config", "sub/.GIT/hooks/pre-commit", "x/git~1/y", "a\\b", "a\0", "sub/.git./x"]) expect(unsafeTreePath(p)).not.toBeNull(); | |
| 29 | + expect(unsafeTreePath("a/".repeat(2100))).not.toBeNull(); | |
| 30 | + }); | |
| 31 | +}); |
modifiedpnpm-lock.yaml+154−0View file
| @@ -54,6 +54,9 @@ | ||
| 54 | 54 | hono: |
| 55 | 55 | specifier: ^4.13.0 |
| 56 | 56 | version: 4.13.13 |
| 57 | + yaml: | |
| 58 | + specifier: ^2.9.1 | |
| 59 | + version: 2.9.1 | |
| 57 | 60 | devDependencies: |
| 58 | 61 | '@cloudflare/kumo': |
| 59 | 62 | specifier: 2.14.0 |
| @@ -64,6 +67,9 @@ | ||
| 64 | 67 | '@cloudflare/workers-types': |
| 65 | 68 | specifier: ^5.20261012.1 |
| 66 | 69 | version: 5.20261012.1 |
| 70 | + '@octokit/rest': | |
| 71 | + specifier: 22.0.1 | |
| 72 | + version: 22.0.1 | |
| 67 | 73 | '@phosphor-icons/react': |
| 68 | 74 | specifier: 2.1.10 |
| 69 | 75 | version: 2.1.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) |
| @@ -1257,6 +1263,64 @@ | ||
| 1257 | 1263 | '@levischuck/tiny-cbor@0.2.11': |
| 1258 | 1264 | resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} |
| 1259 | 1265 | |
| 1266 | + '@octokit/auth-token@6.0.0': | |
| 1267 | + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} | |
| 1268 | + engines: {node: '>= 20'} | |
| 1269 | + | |
| 1270 | + '@octokit/core@7.0.8': | |
| 1271 | + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} | |
| 1272 | + engines: {node: '>= 20'} | |
| 1273 | + | |
| 1274 | + '@octokit/endpoint@11.0.5': | |
| 1275 | + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} | |
| 1276 | + engines: {node: '>= 20'} | |
| 1277 | + | |
| 1278 | + '@octokit/graphql@9.0.5': | |
| 1279 | + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} | |
| 1280 | + engines: {node: '>= 20'} | |
| 1281 | + | |
| 1282 | + '@octokit/openapi-types@27.0.0': | |
| 1283 | + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} | |
| 1284 | + | |
| 1285 | + '@octokit/openapi-types@29.0.1': | |
| 1286 | + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} | |
| 1287 | + | |
| 1288 | + '@octokit/plugin-paginate-rest@14.0.0': | |
| 1289 | + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} | |
| 1290 | + engines: {node: '>= 20'} | |
| 1291 | + peerDependencies: | |
| 1292 | + '@octokit/core': '>=6' | |
| 1293 | + | |
| 1294 | + '@octokit/plugin-request-log@6.0.0': | |
| 1295 | + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} | |
| 1296 | + engines: {node: '>= 20'} | |
| 1297 | + peerDependencies: | |
| 1298 | + '@octokit/core': '>=6' | |
| 1299 | + | |
| 1300 | + '@octokit/plugin-rest-endpoint-methods@17.0.0': | |
| 1301 | + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} | |
| 1302 | + engines: {node: '>= 20'} | |
| 1303 | + peerDependencies: | |
| 1304 | + '@octokit/core': '>=6' | |
| 1305 | + | |
| 1306 | + '@octokit/request-error@7.1.2': | |
| 1307 | + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} | |
| 1308 | + engines: {node: '>= 20'} | |
| 1309 | + | |
| 1310 | + '@octokit/request@10.0.16': | |
| 1311 | + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} | |
| 1312 | + engines: {node: '>= 20'} | |
| 1313 | + | |
| 1314 | + '@octokit/rest@22.0.1': | |
| 1315 | + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} | |
| 1316 | + engines: {node: '>= 20'} | |
| 1317 | + | |
| 1318 | + '@octokit/types@16.0.0': | |
| 1319 | + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} | |
| 1320 | + | |
| 1321 | + '@octokit/types@18.0.0': | |
| 1322 | + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} | |
| 1323 | + | |
| 1260 | 1324 | '@oxc-project/types@0.153.0': |
| 1261 | 1325 | resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} |
| 1262 | 1326 | |
| @@ -1579,6 +1643,9 @@ | ||
| 1579 | 1643 | resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} |
| 1580 | 1644 | engines: {node: '>=12'} |
| 1581 | 1645 | |
| 1646 | + before-after-hook@4.0.0: | |
| 1647 | + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} | |
| 1648 | + | |
| 1582 | 1649 | blake3-wasm@2.1.5: |
| 1583 | 1650 | resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} |
| 1584 | 1651 | |
| @@ -1605,6 +1672,10 @@ | ||
| 1605 | 1672 | comma-separated-tokens@2.0.3: |
| 1606 | 1673 | resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} |
| 1607 | 1674 | |
| 1675 | + content-type@3.1.1: | |
| 1676 | + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} | |
| 1677 | + engines: {node: '>=22'} | |
| 1678 | + | |
| 1608 | 1679 | convert-source-map@2.0.0: |
| 1609 | 1680 | resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} |
| 1610 | 1681 | |
| @@ -1727,6 +1798,9 @@ | ||
| 1727 | 1798 | resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} |
| 1728 | 1799 | engines: {node: '>=12'} |
| 1729 | 1800 | |
| 1801 | + json-with-bigint@3.5.12: | |
| 1802 | + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} | |
| 1803 | + | |
| 1730 | 1804 | kleur@4.1.5: |
| 1731 | 1805 | resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} |
| 1732 | 1806 | engines: {node: '>=6'} |
| @@ -2058,6 +2132,9 @@ | ||
| 2058 | 2132 | unist-util-visit@5.1.0: |
| 2059 | 2133 | resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} |
| 2060 | 2134 | |
| 2135 | + universal-user-agent@7.0.3: | |
| 2136 | + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} | |
| 2137 | + | |
| 2061 | 2138 | use-sync-external-store@1.7.0: |
| 2062 | 2139 | resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} |
| 2063 | 2140 | peerDependencies: |
| @@ -2894,6 +2971,75 @@ | ||
| 2894 | 2971 | |
| 2895 | 2972 | '@levischuck/tiny-cbor@0.2.11': {} |
| 2896 | 2973 | |
| 2974 | + '@octokit/auth-token@6.0.0': {} | |
| 2975 | + | |
| 2976 | + '@octokit/core@7.0.8': | |
| 2977 | + dependencies: | |
| 2978 | + '@octokit/auth-token': 6.0.0 | |
| 2979 | + '@octokit/graphql': 9.0.5 | |
| 2980 | + '@octokit/request': 10.0.16 | |
| 2981 | + '@octokit/request-error': 7.1.2 | |
| 2982 | + '@octokit/types': 18.0.0 | |
| 2983 | + before-after-hook: 4.0.0 | |
| 2984 | + universal-user-agent: 7.0.3 | |
| 2985 | + | |
| 2986 | + '@octokit/endpoint@11.0.5': | |
| 2987 | + dependencies: | |
| 2988 | + '@octokit/types': 18.0.0 | |
| 2989 | + universal-user-agent: 7.0.3 | |
| 2990 | + | |
| 2991 | + '@octokit/graphql@9.0.5': | |
| 2992 | + dependencies: | |
| 2993 | + '@octokit/request': 10.0.16 | |
| 2994 | + '@octokit/types': 18.0.0 | |
| 2995 | + universal-user-agent: 7.0.3 | |
| 2996 | + | |
| 2997 | + '@octokit/openapi-types@27.0.0': {} | |
| 2998 | + | |
| 2999 | + '@octokit/openapi-types@29.0.1': {} | |
| 3000 | + | |
| 3001 | + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': | |
| 3002 | + dependencies: | |
| 3003 | + '@octokit/core': 7.0.8 | |
| 3004 | + '@octokit/types': 16.0.0 | |
| 3005 | + | |
| 3006 | + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.8)': | |
| 3007 | + dependencies: | |
| 3008 | + '@octokit/core': 7.0.8 | |
| 3009 | + | |
| 3010 | + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': | |
| 3011 | + dependencies: | |
| 3012 | + '@octokit/core': 7.0.8 | |
| 3013 | + '@octokit/types': 16.0.0 | |
| 3014 | + | |
| 3015 | + '@octokit/request-error@7.1.2': | |
| 3016 | + dependencies: | |
| 3017 | + '@octokit/types': 18.0.0 | |
| 3018 | + | |
| 3019 | + '@octokit/request@10.0.16': | |
| 3020 | + dependencies: | |
| 3021 | + '@octokit/endpoint': 11.0.5 | |
| 3022 | + '@octokit/request-error': 7.1.2 | |
| 3023 | + '@octokit/types': 18.0.0 | |
| 3024 | + content-type: 3.1.1 | |
| 3025 | + json-with-bigint: 3.5.12 | |
| 3026 | + universal-user-agent: 7.0.3 | |
| 3027 | + | |
| 3028 | + '@octokit/rest@22.0.1': | |
| 3029 | + dependencies: | |
| 3030 | + '@octokit/core': 7.0.8 | |
| 3031 | + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) | |
| 3032 | + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.8) | |
| 3033 | + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) | |
| 3034 | + | |
| 3035 | + '@octokit/types@16.0.0': | |
| 3036 | + dependencies: | |
| 3037 | + '@octokit/openapi-types': 27.0.0 | |
| 3038 | + | |
| 3039 | + '@octokit/types@18.0.0': | |
| 3040 | + dependencies: | |
| 3041 | + '@octokit/openapi-types': 29.0.1 | |
| 3042 | + | |
| 2897 | 3043 | '@oxc-project/types@0.153.0': {} |
| 2898 | 3044 | |
| 2899 | 3045 | '@peculiar/asn1-android@2.10.0': |
| @@ -3255,6 +3401,8 @@ | ||
| 3255 | 3401 | |
| 3256 | 3402 | assertion-error@2.0.1: {} |
| 3257 | 3403 | |
| 3404 | + before-after-hook@4.0.0: {} | |
| 3405 | + | |
| 3258 | 3406 | blake3-wasm@2.1.5: {} |
| 3259 | 3407 | |
| 3260 | 3408 | ccount@2.0.1: {} |
| @@ -3271,6 +3419,8 @@ | ||
| 3271 | 3419 | |
| 3272 | 3420 | comma-separated-tokens@2.0.3: {} |
| 3273 | 3421 | |
| 3422 | + content-type@3.1.1: {} | |
| 3423 | + | |
| 3274 | 3424 | convert-source-map@2.0.0: {} |
| 3275 | 3425 | |
| 3276 | 3426 | cookie@1.1.1: {} |
| @@ -3468,6 +3618,8 @@ | ||
| 3468 | 3618 | |
| 3469 | 3619 | internmap@2.0.3: {} |
| 3470 | 3620 | |
| 3621 | + json-with-bigint@3.5.12: {} | |
| 3622 | + | |
| 3471 | 3623 | kleur@4.1.5: {} |
| 3472 | 3624 | |
| 3473 | 3625 | lightningcss-android-arm64@1.33.0: |
| @@ -3853,6 +4005,8 @@ | ||
| 3853 | 4005 | unist-util-is: 6.0.1 |
| 3854 | 4006 | unist-util-visit-parents: 6.0.2 |
| 3855 | 4007 | |
| 4008 | + universal-user-agent@7.0.3: {} | |
| 4009 | + | |
| 3856 | 4010 | use-sync-external-store@1.7.0(react@19.2.0): |
| 3857 | 4011 | dependencies: |
| 3858 | 4012 | react: 19.2.0 |