From 18752631fb893aef3774ab071a75e3bfefe0f344 Mon Sep 17 00:00:00 2001 From: whereareiam Date: Sat, 3 Oct 2026 14:02:18 +0200 Subject: [PATCH] Preview: Replace instance exports with synthetic fixtures The preview's fixtures were exports from a real Forgejo instance mixed with hand-made additions. The exported repositories were also unusable in a fresh clone: Git does not keep their empty refs directories, so the seeder silently pushed this repository's own history instead. Fixtures are now invented and declared under dev/fixtures: owner.json for users and the organization, and one folder per repository holding its description, history, commit files and workflow runs. The seeder builds each history as real commits and creates issues, pull requests, releases and packages through the API. What the API cannot create is written to the database from the same declarations: past timestamps, the activity feed, finished workflow runs with logs, and their commit statuses. Those rows are checked against the live schema, so a Forgejo upgrade that changes a column fails by naming it. Times are relative to the day of seeding. The preview user is now alex, and dev:forgejo:reset empties the volume for a fresh seed. Co-Authored-By: Claude Opus 5.5 --- .prettierignore | 2 + README.md | 30 +- dev/fixtures/README.md | 84 ++ dev/fixtures/arcadeya/devops.git/HEAD | 1 - dev/fixtures/arcadeya/devops.git/config | 4 - ...6409aa7a3ef918aa052071d097d7bbc3c982f9.idx | Bin 8436 -> 0 bytes ...409aa7a3ef918aa052071d097d7bbc3c982f9.pack | Bin 31414 -> 0 bytes ...6409aa7a3ef918aa052071d097d7bbc3c982f9.rev | Bin 1104 -> 0 bytes dev/fixtures/arcadeya/devops.git/packed-refs | 24 - dev/fixtures/arcadeya/devops.json | 148 --- dev/fixtures/home.json | 1006 ----------------- .../whereareiam/identica-docs/95/661.log.zst | Bin 150 -> 0 bytes .../whereareiam/identica-docs/96/662.log.zst | Bin 150 -> 0 bytes .../whereareiam/identica-docs/97/663.log.zst | Bin 146 -> 0 bytes .../whereareiam/identica-docs/98/664.log.zst | Bin 146 -> 0 bytes dev/fixtures/owner.json | 16 + .../acme-webapp/action/ci-failed.json | 35 + .../acme-webapp/action/ci-failed.log | 39 + .../acme-webapp/action/ci-passed.json | 35 + .../acme-webapp/action/ci-passed.log | 37 + .../acme-webapp/action/release-passed.json | 25 + .../acme-webapp/action/release-passed.log | 19 + .../acme-webapp/commit/01-initial/.gitignore | 2 + .../acme-webapp/commit/01-initial/LICENSE | 6 + .../acme-webapp/commit/01-initial/README.md | 11 + .../commit/01-initial/package.json | 6 + .../commit/02-app-shell/index.html | 12 + .../commit/02-app-shell/src/app.css | 10 + .../commit/02-app-shell/src/main.ts | 3 + .../03-add-ci/.forgejo/workflows/ci.yml | 20 + .../04-api-client/src/api/client.test.ts | 7 + .../commit/04-api-client/src/api/client.ts | 13 + .../05-settings-page/docs/configuration.md | 6 + .../src/settings/SettingsPage.ts | 18 + .../commit/06-fix-retry/src/api/client.ts | 23 + .../.forgejo/workflows/release.yml | 13 + .../commit/07-release-notes/CHANGELOG.md | 14 + .../commit/08-dark-mode-toggle/src/app.css | 14 + .../08-dark-mode-toggle/src/theme/toggle.ts | 5 + .../commit/09-dark-mode-docs/docs/theming.md | 3 + .../10-login-redirect/src/auth/redirect.ts | 4 + .../commit/11-remove-legacy/docs/legacy.md | 3 + .../commit/12-prepare-v2/package.json | 6 + .../commit/13-contributing/CONTRIBUTING.md | 3 + .../repository/acme-webapp/history.json | 115 ++ .../repository/acme-webapp/repository.json | 214 ++++ .../alex-notes/commit/01-initial/README.md | 4 + .../alex-notes/commit/02-ideas/ideas.md | 4 + .../repository/alex-notes/history.json | 22 + .../repository/alex-notes/repository.json | 23 + .../whereareiam/identica-docs.git/HEAD | 1 - .../whereareiam/identica-docs.git/config | 4 - ...055ea70f2e699b561083fadb4b2b705d2f9e79.idx | Bin 1884 -> 0 bytes ...55ea70f2e699b561083fadb4b2b705d2f9e79.pack | Bin 4070 -> 0 bytes ...055ea70f2e699b561083fadb4b2b705d2f9e79.rev | Bin 168 -> 0 bytes .../whereareiam/identica-docs.git/packed-refs | 2 - dev/fixtures/whereareiam/identica-docs.json | 508 --------- dev/preview/CommandRunner.ts | 25 + dev/preview/PreviewApplication.ts | 12 +- dev/preview/config/constants.ts | 7 +- dev/preview/fixtures/FixtureActionImporter.ts | 304 ----- dev/preview/fixtures/FixtureArchive.test.ts | 76 ++ dev/preview/fixtures/FixtureArchive.ts | 92 +- dev/preview/fixtures/FixtureClock.test.ts | 15 + dev/preview/fixtures/FixtureClock.ts | 21 + dev/preview/fixtures/FixtureDefinition.ts | 24 - dev/preview/fixtures/FixtureOwnerSeeder.ts | 59 + .../fixtures/FixtureRepositoryDirectory.ts | 59 + dev/preview/fixtures/FixtureSeeder.ts | 145 +-- dev/preview/fixtures/ForgejoApi.ts | 79 +- .../database/FixtureActionLog.test.ts | 28 + .../fixtures/database/FixtureActionLog.ts | 53 + .../fixtures/database/FixtureActionWriter.ts | 353 ++++++ .../fixtures/database/FixtureDatabase.ts | 101 ++ .../database/FixtureDatabaseImporter.ts | 71 ++ .../database/FixtureTimelineWriter.ts | 209 ++++ .../fixtures/model/FixtureActionRun.ts | 30 + dev/preview/fixtures/model/FixtureHistory.ts | 20 + dev/preview/fixtures/model/FixtureOwners.ts | 26 + .../model/FixtureRepositoryDescription.ts | 81 ++ .../fixtures/repository/BuiltHistory.ts | 31 + .../repository/FixtureHistoryBuilder.ts | 91 ++ .../repository/FixtureRepositorySeeder.ts | 246 ++++ .../fixtures/repository/SeededRepository.ts | 55 + dev/preview/forgejo/ForgejoCompose.ts | 11 + package.json | 1 + tsconfig.json | 3 +- vitest.config.ts | 2 +- 88 files changed, 2683 insertions(+), 2251 deletions(-) create mode 100644 .prettierignore create mode 100644 dev/fixtures/README.md delete mode 100644 dev/fixtures/arcadeya/devops.git/HEAD delete mode 100644 dev/fixtures/arcadeya/devops.git/config delete mode 100644 dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.idx delete mode 100644 dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.pack delete mode 100644 dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.rev delete mode 100644 dev/fixtures/arcadeya/devops.git/packed-refs delete mode 100644 dev/fixtures/arcadeya/devops.json delete mode 100644 dev/fixtures/home.json delete mode 100644 dev/fixtures/logs/whereareiam/identica-docs/95/661.log.zst delete mode 100644 dev/fixtures/logs/whereareiam/identica-docs/96/662.log.zst delete mode 100644 dev/fixtures/logs/whereareiam/identica-docs/97/663.log.zst delete mode 100644 dev/fixtures/logs/whereareiam/identica-docs/98/664.log.zst create mode 100644 dev/fixtures/owner.json create mode 100644 dev/fixtures/repository/acme-webapp/action/ci-failed.json create mode 100644 dev/fixtures/repository/acme-webapp/action/ci-failed.log create mode 100644 dev/fixtures/repository/acme-webapp/action/ci-passed.json create mode 100644 dev/fixtures/repository/acme-webapp/action/ci-passed.log create mode 100644 dev/fixtures/repository/acme-webapp/action/release-passed.json create mode 100644 dev/fixtures/repository/acme-webapp/action/release-passed.log create mode 100644 dev/fixtures/repository/acme-webapp/commit/01-initial/.gitignore create mode 100644 dev/fixtures/repository/acme-webapp/commit/01-initial/LICENSE create mode 100644 dev/fixtures/repository/acme-webapp/commit/01-initial/README.md create mode 100644 dev/fixtures/repository/acme-webapp/commit/01-initial/package.json create mode 100644 dev/fixtures/repository/acme-webapp/commit/02-app-shell/index.html create mode 100644 dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/app.css create mode 100644 dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/main.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/03-add-ci/.forgejo/workflows/ci.yml create mode 100644 dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.test.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/05-settings-page/docs/configuration.md create mode 100644 dev/fixtures/repository/acme-webapp/commit/05-settings-page/src/settings/SettingsPage.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/06-fix-retry/src/api/client.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/07-release-notes/.forgejo/workflows/release.yml create mode 100644 dev/fixtures/repository/acme-webapp/commit/07-release-notes/CHANGELOG.md create mode 100644 dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/app.css create mode 100644 dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/theme/toggle.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/09-dark-mode-docs/docs/theming.md create mode 100644 dev/fixtures/repository/acme-webapp/commit/10-login-redirect/src/auth/redirect.ts create mode 100644 dev/fixtures/repository/acme-webapp/commit/11-remove-legacy/docs/legacy.md create mode 100644 dev/fixtures/repository/acme-webapp/commit/12-prepare-v2/package.json create mode 100644 dev/fixtures/repository/acme-webapp/commit/13-contributing/CONTRIBUTING.md create mode 100644 dev/fixtures/repository/acme-webapp/history.json create mode 100644 dev/fixtures/repository/acme-webapp/repository.json create mode 100644 dev/fixtures/repository/alex-notes/commit/01-initial/README.md create mode 100644 dev/fixtures/repository/alex-notes/commit/02-ideas/ideas.md create mode 100644 dev/fixtures/repository/alex-notes/history.json create mode 100644 dev/fixtures/repository/alex-notes/repository.json delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/HEAD delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/config delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.idx delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.pack delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.rev delete mode 100644 dev/fixtures/whereareiam/identica-docs.git/packed-refs delete mode 100644 dev/fixtures/whereareiam/identica-docs.json delete mode 100644 dev/preview/fixtures/FixtureActionImporter.ts create mode 100644 dev/preview/fixtures/FixtureArchive.test.ts create mode 100644 dev/preview/fixtures/FixtureClock.test.ts create mode 100644 dev/preview/fixtures/FixtureClock.ts delete mode 100644 dev/preview/fixtures/FixtureDefinition.ts create mode 100644 dev/preview/fixtures/FixtureOwnerSeeder.ts create mode 100644 dev/preview/fixtures/FixtureRepositoryDirectory.ts create mode 100644 dev/preview/fixtures/database/FixtureActionLog.test.ts create mode 100644 dev/preview/fixtures/database/FixtureActionLog.ts create mode 100644 dev/preview/fixtures/database/FixtureActionWriter.ts create mode 100644 dev/preview/fixtures/database/FixtureDatabase.ts create mode 100644 dev/preview/fixtures/database/FixtureDatabaseImporter.ts create mode 100644 dev/preview/fixtures/database/FixtureTimelineWriter.ts create mode 100644 dev/preview/fixtures/model/FixtureActionRun.ts create mode 100644 dev/preview/fixtures/model/FixtureHistory.ts create mode 100644 dev/preview/fixtures/model/FixtureOwners.ts create mode 100644 dev/preview/fixtures/model/FixtureRepositoryDescription.ts create mode 100644 dev/preview/fixtures/repository/BuiltHistory.ts create mode 100644 dev/preview/fixtures/repository/FixtureHistoryBuilder.ts create mode 100644 dev/preview/fixtures/repository/FixtureRepositorySeeder.ts create mode 100644 dev/preview/fixtures/repository/SeededRepository.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..bc30aaca --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Sample source files of the preview's fixture repositories; they are content, not project code. +dev/fixtures/repository/*/commit/ diff --git a/README.md b/README.md index 46e14b13..961b500e 100644 --- a/README.md +++ b/README.md @@ -269,17 +269,25 @@ bun dev:forgejo:watch ``` Open [http://localhost:3000](http://localhost:3000). The preview skips Forgejo's first-run setup, selects `github-auto` -by default, and signs in automatically as `whereareiam`, including when a public page is opened first. Set -`FORGEJO_PREVIEW_AUTO_LOGIN=false` to keep the normal sign-in page; the default credentials are `whereareiam` / `preview`. -The watcher rebuilds the theme and restarts Forgejo -after source changes so CSS and template updates are visible. The local Forgejo data is kept in the Docker volume -`forgejo-dev-data`. - -The checked-in fixture snapshot under `dev/fixtures` restores `whereareiam/identica-docs` and the `arcadeya/devops` -organization repository. It includes the recorded `identica-docs` Actions history as display-only data; no runner is -configured and no workflow executes during seeding. It also restores the repositories' original creation times and -their recorded public activity, including pushes, tags, and releases, so the profile activity timeline is useful for -visual checks. +by default, and signs in automatically as `alex`, including when a public page is opened first. Set +`FORGEJO_PREVIEW_AUTO_LOGIN=false` to keep the normal sign-in page; the default credentials are `alex` / `preview`. The +watcher rebuilds the theme and restarts Forgejo after source changes so CSS and template updates are visible. + +The first start fills the preview from the fixtures under `dev/fixtures`: the users `alex` and `sam`, the organization +`acme`, and two repositories. `acme/webapp` has branches, tags, issues, pull requests in every state, releases including +a draft and a prerelease, packages, and finished workflow runs with logs. `alex/notes` is nearly empty, for checking +empty states. All of it is invented, and dates are written relative to the day of seeding, so the activity feed and +contribution heatmap always look recent. No runner is configured and no workflow executes. + +The data is kept in the Docker volume `forgejo-dev-data`, and a repository that already exists is not seeded again. +After changing a fixture, start from an empty volume: + +```bash +bun dev:forgejo:reset +bun dev:forgejo +``` + +See [`dev/fixtures/README.md`](dev/fixtures/README.md) for the fixture format. Override the local login before starting when needed: diff --git a/dev/fixtures/README.md b/dev/fixtures/README.md new file mode 100644 index 00000000..b14b828e --- /dev/null +++ b/dev/fixtures/README.md @@ -0,0 +1,84 @@ +# Preview fixtures + +Everything the local Forgejo preview shows comes from this folder. The content is invented; do not add exports from a +real instance. + +```text +dev/fixtures/ + owner.json users, organizations and teams + repository/ + -/ + repository.json description, labels, milestones, issues, pull requests, releases, packages + history.json commits, branches and tags + commit// the files a commit adds or changes + action/.json one finished workflow run + action/.log that run's log +``` + +## Change what the preview shows + +Edit the files, then seed an empty volume. A repository that already exists in the preview is left as it is. + +```bash +bun dev:forgejo:reset +bun dev:forgejo +``` + +Seeding stops with a message naming the fixture when something does not line up, for example a pull request from a +branch that `history.json` never creates. `bun vitest` runs the same checks without Docker. + +## Times + +Every time is written as `daysAgo`, optionally with a `time` of day in UTC, and is resolved when the preview is seeded. +A time later than the moment of seeding is clamped to it. + +## Add a repository + +Create a folder under `repository/` with `repository.json` and `history.json`. Use an empty list for anything the +repository should not have; `alex-notes` is the smallest example. The owner must be a user or organization in +`owner.json`. + +## History + +Each entry in `history.json` becomes one commit. Its `id` names a folder under `commit/` whose files are copied over the +working tree, so a commit only contains what it adds or changes. Commits are applied in list order. A `branch` seen for +the first time starts at the default branch's tip at that point in the list; later commits on the same branch continue +it. Tags name a commit `id`. + +Commit hashes differ between seeds because the dates do. Refer to commits by `id`, never by hash. + +## Issues, pull requests and releases + +- `author`, `assignees` and comment authors are user logins from `owner.json`. +- `labels` and `milestone` must be declared in the same `repository.json`. +- A pull request's `head` is a branch from `history.json`. Its `state` is `open`, `closed` or `merged`; a title starting + with `WIP:` makes it a draft. +- A release's `tag` may be one from `history.json` or a new one, which a draft usually wants. Each entry in `assets` is + uploaded as a file with the given text content. +- Packages are generic packages owned by the repository's owner. + +## Workflow runs + +Forgejo's API cannot create a finished run, so runs are written into the database. Each `action/.json` lists its +jobs and their steps with a status of `success`, `failure`, `cancelled` or `skipped` and a duration in seconds. The +run's `commit` is a commit `id`, and for a `pull_request` event its `ref` is the pull request's branch. Each job also +reports a commit status, which is what shows as the check mark beside the commit. + +The log beside it supplies the lines for every step: + +```text +::job build +::step Set up job +docker(version:v9.1.0) received task of job build +::step bun run build +built in 412ms +``` + +`Set up job` and `Complete job` are steps Forgejo adds to every job by itself. List them to give them log lines and a +duration; they are not stored as steps. + +## After a Forgejo upgrade + +The database rows for timestamps, the activity feed, workflow runs and commit statuses follow the schema of the Forgejo +version pinned in `dev/docker-compose.yml`. When a column they write is missing or a new required column appears, +seeding stops and names the table and column. Adjust the writers under `dev/preview/fixtures/database/`. diff --git a/dev/fixtures/arcadeya/devops.git/HEAD b/dev/fixtures/arcadeya/devops.git/HEAD deleted file mode 100644 index a334635f..00000000 --- a/dev/fixtures/arcadeya/devops.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/dev diff --git a/dev/fixtures/arcadeya/devops.git/config b/dev/fixtures/arcadeya/devops.git/config deleted file mode 100644 index 07d359d0..00000000 --- a/dev/fixtures/arcadeya/devops.git/config +++ /dev/null @@ -1,4 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true diff --git a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.idx b/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.idx deleted file mode 100644 index 5928b43bc03aa44368f37f15c60cb47471ee56be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8436 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 Q*grikud-KjNDWl{ALV+U{Qv*} diff --git a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.pack b/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.pack deleted file mode 100644 index b4868279219f5cc6b9cc69a6b33946827a503178..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31414 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 CEI2j* diff --git a/dev/fixtures/logs/whereareiam/identica-docs/96/662.log.zst b/dev/fixtures/logs/whereareiam/identica-docs/96/662.log.zst deleted file mode 100644 index 5d091784cca6ef6844e00fd889f8144c1fb741a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 150 zcmdPcs{fZEVkt9&6T75zKz1pI2a9vwb2e9Fow-~_hB@BS#bRkjlRXcr*NT5J`uBGB z+p@UK`qLW^sz)90tMV4#ekLyS>sCplwCmp=cwVfzIdRtk>1n;Qj`ga_Y%E^r)6y;- z5~ZE!-Xz1iOkj~f!b(vV20ou>4)M>M+Sw1V#A*3T2r@7*)B^ExAZ7$&hK;W#^#cG~ CEI2j* diff --git a/dev/fixtures/logs/whereareiam/identica-docs/97/663.log.zst b/dev/fixtures/logs/whereareiam/identica-docs/97/663.log.zst deleted file mode 100644 index 9dfabb0ccd1697a7762779df8c4984696f1b6c4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 146 zcmdPcs{fZEqLrC}>9~YULTBllO@=4tJ7jrHw!POL)NFP6pWbgSk%P^x%x$yIT->{T z)yKJ!JO1cI^}ld17TIj@%I){|6Q|RbH$?4Y4exwreb<4tX4BTrgruNEOA=C5`qj243$7U2Z$Mgm|^3qN&NtIUOi6$ diff --git a/dev/fixtures/logs/whereareiam/identica-docs/98/664.log.zst b/dev/fixtures/logs/whereareiam/identica-docs/98/664.log.zst deleted file mode 100644 index 9dfabb0ccd1697a7762779df8c4984696f1b6c4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 146 zcmdPcs{fZEqLrC}>9~YULTBllO@=4tJ7jrHw!POL)NFP6pWbgSk%P^x%x$yIT->{T z)yKJ!JO1cI^}ld17TIj@%I){|6Q|RbH$?4Y4exwreb<4tX4BTrgruNEOA=C5`qj243$7U2Z$Mgm|^3qN&NtIUOi6$ diff --git a/dev/fixtures/owner.json b/dev/fixtures/owner.json new file mode 100644 index 00000000..f53d46e1 --- /dev/null +++ b/dev/fixtures/owner.json @@ -0,0 +1,16 @@ +{ + "users": [ + { "login": "alex", "fullName": "Alex Rivera", "email": "alex@fixtures.local", "joinedDaysAgo": 400 }, + { "login": "sam", "fullName": "Sam Chen", "email": "sam@fixtures.local", "joinedDaysAgo": 210 } + ], + "organizations": [ + { + "name": "acme", + "fullName": "Acme", + "description": "Tools for people who ship web applications.", + "website": "https://acme.example", + "location": "Remote", + "teams": [{ "name": "maintainers", "permission": "write", "members": ["sam"] }] + } + ] +} diff --git a/dev/fixtures/repository/acme-webapp/action/ci-failed.json b/dev/fixtures/repository/acme-webapp/action/ci-failed.json new file mode 100644 index 00000000..97aae514 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/ci-failed.json @@ -0,0 +1,35 @@ +{ + "name": "CI", + "workflow": "ci.yml", + "title": "docs: describe theming", + "event": "pull_request", + "ref": "refs/heads/feature/dark-mode", + "commit": "09-dark-mode-docs", + "actor": "sam", + "daysAgo": 3, + "time": "17:31", + "jobs": [ + { + "id": "build", + "status": "success", + "steps": [ + { "name": "Set up job", "status": "success", "seconds": 2 }, + { "name": "actions/checkout@v4", "status": "success", "seconds": 3 }, + { "name": "bun install --frozen-lockfile", "status": "success", "seconds": 6 }, + { "name": "bun run build", "status": "success", "seconds": 8 }, + { "name": "Complete job", "status": "success", "seconds": 1 } + ] + }, + { + "id": "test", + "status": "failure", + "steps": [ + { "name": "Set up job", "status": "success", "seconds": 2 }, + { "name": "actions/checkout@v4", "status": "success", "seconds": 3 }, + { "name": "bun install --frozen-lockfile", "status": "success", "seconds": 5 }, + { "name": "bun run test", "status": "failure", "seconds": 4 }, + { "name": "Complete job", "status": "success", "seconds": 1 } + ] + } + ] +} diff --git a/dev/fixtures/repository/acme-webapp/action/ci-failed.log b/dev/fixtures/repository/acme-webapp/action/ci-failed.log new file mode 100644 index 00000000..b1d41b17 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/ci-failed.log @@ -0,0 +1,39 @@ +::job build +::step Set up job +docker(version:v9.1.0) received task of job build, triggered by event: pull_request +Start image=node:20-bookworm +::step actions/checkout@v4 +Syncing repository: acme/webapp +Checking out the ref refs/heads/feature/dark-mode +::step bun install --frozen-lockfile +bun install v1.4.0 +Checked 12 installs across 14 packages (no changes) +::step bun run build +vite v8.3.2 building for production... +dist/index.html 0.42 kB +dist/assets/index.js 1.31 kB +built in 398ms +::step Complete job +Cleaning up container for job build +Job succeeded +::job test +::step Set up job +docker(version:v9.1.0) received task of job test, triggered by event: pull_request +Start image=node:20-bookworm +::step actions/checkout@v4 +Syncing repository: acme/webapp +Checking out the ref refs/heads/feature/dark-mode +::step bun install --frozen-lockfile +bun install v1.4.0 +Checked 12 installs across 14 packages (no changes) +::step bun run test +RUN v4.1.10 /workspace/acme/webapp +src/api/client.test.ts (1 test) 3ms +src/theme/toggle.test.ts (0 test) +Error: No test suite found in file src/theme/toggle.test.ts +Test Files 1 failed | 1 passed (2) +Tests 1 passed (1) +error: script "test" exited with code 1 +::step Complete job +Cleaning up container for job test +Job failed diff --git a/dev/fixtures/repository/acme-webapp/action/ci-passed.json b/dev/fixtures/repository/acme-webapp/action/ci-passed.json new file mode 100644 index 00000000..d5838c71 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/ci-passed.json @@ -0,0 +1,35 @@ +{ + "name": "CI", + "workflow": "ci.yml", + "title": "chore: prepare 2.0.0-rc.1", + "event": "push", + "ref": "refs/heads/main", + "commit": "12-prepare-v2", + "actor": "alex", + "daysAgo": 2, + "time": "09:01", + "jobs": [ + { + "id": "build", + "status": "success", + "steps": [ + { "name": "Set up job", "status": "success", "seconds": 2 }, + { "name": "actions/checkout@v4", "status": "success", "seconds": 3 }, + { "name": "bun install --frozen-lockfile", "status": "success", "seconds": 6 }, + { "name": "bun run build", "status": "success", "seconds": 9 }, + { "name": "Complete job", "status": "success", "seconds": 1 } + ] + }, + { + "id": "test", + "status": "success", + "steps": [ + { "name": "Set up job", "status": "success", "seconds": 2 }, + { "name": "actions/checkout@v4", "status": "success", "seconds": 3 }, + { "name": "bun install --frozen-lockfile", "status": "success", "seconds": 5 }, + { "name": "bun run test", "status": "success", "seconds": 7 }, + { "name": "Complete job", "status": "success", "seconds": 1 } + ] + } + ] +} diff --git a/dev/fixtures/repository/acme-webapp/action/ci-passed.log b/dev/fixtures/repository/acme-webapp/action/ci-passed.log new file mode 100644 index 00000000..fb9bffe9 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/ci-passed.log @@ -0,0 +1,37 @@ +::job build +::step Set up job +docker(version:v9.1.0) received task of job build, triggered by event: push +Start image=node:20-bookworm +::step actions/checkout@v4 +Syncing repository: acme/webapp +Checking out the ref refs/heads/main +::step bun install --frozen-lockfile +bun install v1.4.0 +Checked 12 installs across 14 packages (no changes) +::step bun run build +vite v8.3.2 building for production... +transforming... +dist/index.html 0.42 kB +dist/assets/index.js 1.18 kB +built in 412ms +::step Complete job +Cleaning up container for job build +Job succeeded +::job test +::step Set up job +docker(version:v9.1.0) received task of job test, triggered by event: push +Start image=node:20-bookworm +::step actions/checkout@v4 +Syncing repository: acme/webapp +Checking out the ref refs/heads/main +::step bun install --frozen-lockfile +bun install v1.4.0 +Checked 12 installs across 14 packages (no changes) +::step bun run test +RUN v4.1.10 /workspace/acme/webapp +src/api/client.test.ts (1 test) 3ms +Test Files 1 passed (1) +Tests 1 passed (1) +::step Complete job +Cleaning up container for job test +Job succeeded diff --git a/dev/fixtures/repository/acme-webapp/action/release-passed.json b/dev/fixtures/repository/acme-webapp/action/release-passed.json new file mode 100644 index 00000000..e61d3e4c --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/release-passed.json @@ -0,0 +1,25 @@ +{ + "name": "Release", + "workflow": "release.yml", + "title": "v1.2.0", + "event": "push", + "ref": "refs/tags/v1.2.0", + "commit": "07-release-notes", + "actor": "alex", + "daysAgo": 11, + "time": "15:20", + "jobs": [ + { + "id": "publish", + "status": "success", + "steps": [ + { "name": "Set up job", "status": "success", "seconds": 2 }, + { "name": "actions/checkout@v4", "status": "success", "seconds": 3 }, + { "name": "bun install --frozen-lockfile", "status": "success", "seconds": 6 }, + { "name": "bun run build", "status": "success", "seconds": 9 }, + { "name": "tar -czf webapp.tar.gz dist", "status": "success", "seconds": 1 }, + { "name": "Complete job", "status": "success", "seconds": 1 } + ] + } + ] +} diff --git a/dev/fixtures/repository/acme-webapp/action/release-passed.log b/dev/fixtures/repository/acme-webapp/action/release-passed.log new file mode 100644 index 00000000..5fc214e9 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/action/release-passed.log @@ -0,0 +1,19 @@ +::job publish +::step Set up job +docker(version:v9.1.0) received task of job publish, triggered by event: push +Start image=node:20-bookworm +::step actions/checkout@v4 +Syncing repository: acme/webapp +Checking out the ref refs/tags/v1.2.0 +::step bun install --frozen-lockfile +bun install v1.4.0 +Checked 12 installs across 14 packages (no changes) +::step bun run build +vite v8.3.2 building for production... +dist/index.html 0.42 kB +dist/assets/index.js 1.18 kB +built in 405ms +::step tar -czf webapp.tar.gz dist +::step Complete job +Cleaning up container for job publish +Job succeeded diff --git a/dev/fixtures/repository/acme-webapp/commit/01-initial/.gitignore b/dev/fixtures/repository/acme-webapp/commit/01-initial/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/01-initial/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/dev/fixtures/repository/acme-webapp/commit/01-initial/LICENSE b/dev/fixtures/repository/acme-webapp/commit/01-initial/LICENSE new file mode 100644 index 00000000..88a84583 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/01-initial/LICENSE @@ -0,0 +1,6 @@ +MIT License + +Copyright (c) Acme + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction. diff --git a/dev/fixtures/repository/acme-webapp/commit/01-initial/README.md b/dev/fixtures/repository/acme-webapp/commit/01-initial/README.md new file mode 100644 index 00000000..03156935 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/01-initial/README.md @@ -0,0 +1,11 @@ +# Webapp + +A small web application used as a preview fixture. It has enough history, issues and releases to exercise +repository pages without depending on a real project. + +## Development + +```sh +bun install +bun run dev +``` diff --git a/dev/fixtures/repository/acme-webapp/commit/01-initial/package.json b/dev/fixtures/repository/acme-webapp/commit/01-initial/package.json new file mode 100644 index 00000000..ade3e756 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/01-initial/package.json @@ -0,0 +1,6 @@ +{ + "name": "webapp", + "version": "1.0.0", + "private": true, + "scripts": { "dev": "vite", "build": "vite build", "test": "vitest run" } +} diff --git a/dev/fixtures/repository/acme-webapp/commit/02-app-shell/index.html b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/index.html new file mode 100644 index 00000000..7c0a9162 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/index.html @@ -0,0 +1,12 @@ + + + + + Webapp + + + +

+ + + diff --git a/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/app.css b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/app.css new file mode 100644 index 00000000..f84c1e5b --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/app.css @@ -0,0 +1,10 @@ +:root { + color-scheme: light; + font-family: system-ui, sans-serif; +} + +main { + max-width: 960px; + margin: 0 auto; + padding: 24px; +} diff --git a/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/main.ts b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/main.ts new file mode 100644 index 00000000..3e9c1f5a --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/02-app-shell/src/main.ts @@ -0,0 +1,3 @@ +const root = document.querySelector("#app"); + +if (root) root.textContent = "Webapp is running."; diff --git a/dev/fixtures/repository/acme-webapp/commit/03-add-ci/.forgejo/workflows/ci.yml b/dev/fixtures/repository/acme-webapp/commit/03-add-ci/.forgejo/workflows/ci.yml new file mode 100644 index 00000000..470fb56f --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/03-add-ci/.forgejo/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - run: bun install --frozen-lockfile + - run: bun run build + test: + runs-on: docker + needs: build + steps: + - uses: actions/checkout@v4 + - run: bun install --frozen-lockfile + - run: bun run test diff --git a/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.test.ts b/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.test.ts new file mode 100644 index 00000000..479e1323 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from "vitest"; + +import { ApiClient } from "./client"; + +it("keeps the base URL", () => { + expect(new ApiClient("https://api.acme.example")).toBeInstanceOf(ApiClient); +}); diff --git a/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.ts b/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.ts new file mode 100644 index 00000000..938a003b --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/04-api-client/src/api/client.ts @@ -0,0 +1,13 @@ +export class ApiClient { + private readonly baseUrl: string; + + public constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + public async get(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`); + if (!response.ok) throw new Error(`Request failed: ${response.status}`); + return (await response.json()) as T; + } +} diff --git a/dev/fixtures/repository/acme-webapp/commit/05-settings-page/docs/configuration.md b/dev/fixtures/repository/acme-webapp/commit/05-settings-page/docs/configuration.md new file mode 100644 index 00000000..8dfaee42 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/05-settings-page/docs/configuration.md @@ -0,0 +1,6 @@ +# Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `API_URL` | `https://api.acme.example` | Base URL of the API | +| `REQUEST_TIMEOUT` | `10s` | Time before a request is abandoned | diff --git a/dev/fixtures/repository/acme-webapp/commit/05-settings-page/src/settings/SettingsPage.ts b/dev/fixtures/repository/acme-webapp/commit/05-settings-page/src/settings/SettingsPage.ts new file mode 100644 index 00000000..8add382c --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/05-settings-page/src/settings/SettingsPage.ts @@ -0,0 +1,18 @@ +import { ApiClient } from "../api/client"; + +export interface Settings { + readonly displayName: string; + readonly notifications: boolean; +} + +export class SettingsPage { + private readonly client: ApiClient; + + public constructor(client: ApiClient) { + this.client = client; + } + + public load(): Promise { + return this.client.get("/settings"); + } +} diff --git a/dev/fixtures/repository/acme-webapp/commit/06-fix-retry/src/api/client.ts b/dev/fixtures/repository/acme-webapp/commit/06-fix-retry/src/api/client.ts new file mode 100644 index 00000000..1218c7b5 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/06-fix-retry/src/api/client.ts @@ -0,0 +1,23 @@ +export class ApiClient { + private readonly baseUrl: string; + private readonly attempts: number; + + public constructor(baseUrl: string, attempts = 3) { + this.baseUrl = baseUrl; + this.attempts = attempts; + } + + public async get(path: string): Promise { + let failure: unknown; + for (let attempt = 1; attempt <= this.attempts; attempt++) { + try { + const response = await fetch(`${this.baseUrl}${path}`); + if (response.ok) return (await response.json()) as T; + failure = new Error(`Request failed: ${response.status}`); + } catch (error) { + failure = error; + } + } + throw failure; + } +} diff --git a/dev/fixtures/repository/acme-webapp/commit/07-release-notes/.forgejo/workflows/release.yml b/dev/fixtures/repository/acme-webapp/commit/07-release-notes/.forgejo/workflows/release.yml new file mode 100644 index 00000000..12930072 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/07-release-notes/.forgejo/workflows/release.yml @@ -0,0 +1,13 @@ +name: Release +on: + push: + tags: ["v*"] + +jobs: + publish: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - run: bun install --frozen-lockfile + - run: bun run build + - run: tar -czf webapp.tar.gz dist diff --git a/dev/fixtures/repository/acme-webapp/commit/07-release-notes/CHANGELOG.md b/dev/fixtures/repository/acme-webapp/commit/07-release-notes/CHANGELOG.md new file mode 100644 index 00000000..09c7467e --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/07-release-notes/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 1.2.0 + +- Retry failed API requests up to three times. +- Add the settings page. + +## 1.1.0 + +- Add the API client. + +## 1.0.0 + +- First release. diff --git a/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/app.css b/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/app.css new file mode 100644 index 00000000..7c3a9cfb --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/app.css @@ -0,0 +1,14 @@ +:root { + color-scheme: light; + font-family: system-ui, sans-serif; +} + +:root[data-color-scheme="dark"] { + color-scheme: dark; +} + +main { + max-width: 960px; + margin: 0 auto; + padding: 24px; +} diff --git a/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/theme/toggle.ts b/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/theme/toggle.ts new file mode 100644 index 00000000..6014a5dc --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/08-dark-mode-toggle/src/theme/toggle.ts @@ -0,0 +1,5 @@ +export function toggleColorScheme(root: HTMLElement): "light" | "dark" { + const next = root.dataset.colorScheme === "dark" ? "light" : "dark"; + root.dataset.colorScheme = next; + return next; +} diff --git a/dev/fixtures/repository/acme-webapp/commit/09-dark-mode-docs/docs/theming.md b/dev/fixtures/repository/acme-webapp/commit/09-dark-mode-docs/docs/theming.md new file mode 100644 index 00000000..1f68ffc4 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/09-dark-mode-docs/docs/theming.md @@ -0,0 +1,3 @@ +# Theming + +The color scheme follows `data-color-scheme` on the root element. Call `toggleColorScheme` to switch it. diff --git a/dev/fixtures/repository/acme-webapp/commit/10-login-redirect/src/auth/redirect.ts b/dev/fixtures/repository/acme-webapp/commit/10-login-redirect/src/auth/redirect.ts new file mode 100644 index 00000000..541f3bc4 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/10-login-redirect/src/auth/redirect.ts @@ -0,0 +1,4 @@ +export function loginRedirect(location: URL): string { + const target = location.searchParams.get("next"); + return target && target.startsWith("/") ? target : "/"; +} diff --git a/dev/fixtures/repository/acme-webapp/commit/11-remove-legacy/docs/legacy.md b/dev/fixtures/repository/acme-webapp/commit/11-remove-legacy/docs/legacy.md new file mode 100644 index 00000000..6f347ef7 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/11-remove-legacy/docs/legacy.md @@ -0,0 +1,3 @@ +# Legacy endpoints + +The `/v0` endpoints are scheduled for removal. diff --git a/dev/fixtures/repository/acme-webapp/commit/12-prepare-v2/package.json b/dev/fixtures/repository/acme-webapp/commit/12-prepare-v2/package.json new file mode 100644 index 00000000..e8696180 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/12-prepare-v2/package.json @@ -0,0 +1,6 @@ +{ + "name": "webapp", + "version": "2.0.0-rc.1", + "private": true, + "scripts": { "dev": "vite", "build": "vite build", "test": "vitest run" } +} diff --git a/dev/fixtures/repository/acme-webapp/commit/13-contributing/CONTRIBUTING.md b/dev/fixtures/repository/acme-webapp/commit/13-contributing/CONTRIBUTING.md new file mode 100644 index 00000000..23f83e83 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/commit/13-contributing/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Open an issue before starting larger changes, and keep pull requests focused on one topic. diff --git a/dev/fixtures/repository/acme-webapp/history.json b/dev/fixtures/repository/acme-webapp/history.json new file mode 100644 index 00000000..abdc712c --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/history.json @@ -0,0 +1,115 @@ +{ + "defaultBranch": "main", + "commits": [ + { + "id": "01-initial", + "branch": "main", + "author": "alex", + "daysAgo": 120, + "time": "09:14", + "message": "Initial commit" + }, + { + "id": "02-app-shell", + "branch": "main", + "author": "alex", + "daysAgo": 118, + "time": "14:02", + "message": "Add the application shell" + }, + { + "id": "03-add-ci", + "branch": "main", + "author": "alex", + "daysAgo": 110, + "time": "11:30", + "message": "ci: build and test on every push" + }, + { + "id": "04-api-client", + "branch": "main", + "author": "sam", + "daysAgo": 95, + "time": "16:45", + "message": "feat(api): add a typed API client" + }, + { + "id": "05-settings-page", + "branch": "main", + "author": "alex", + "daysAgo": 60, + "time": "10:20", + "message": "feat(settings): add the settings page\n\nLoads the current settings through the API client and documents the\nconfiguration variables." + }, + { + "id": "06-fix-retry", + "branch": "main", + "author": "sam", + "daysAgo": 30, + "time": "13:05", + "message": "fix(api): retry failed requests" + }, + { + "id": "10-login-redirect", + "branch": "fix/login-redirect", + "author": "sam", + "daysAgo": 25, + "time": "09:40", + "message": "fix(auth): only follow local login redirects" + }, + { + "id": "07-release-notes", + "branch": "main", + "author": "alex", + "daysAgo": 12, + "time": "15:10", + "message": "docs: add the changelog and release workflow" + }, + { + "id": "11-remove-legacy", + "branch": "chore/remove-legacy", + "author": "alex", + "daysAgo": 8, + "time": "11:00", + "message": "docs: announce removal of legacy endpoints" + }, + { + "id": "08-dark-mode-toggle", + "branch": "feature/dark-mode", + "author": "sam", + "daysAgo": 4, + "time": "10:15", + "message": "feat(theme): add a dark mode toggle" + }, + { + "id": "09-dark-mode-docs", + "branch": "feature/dark-mode", + "author": "sam", + "daysAgo": 3, + "time": "17:30", + "message": "docs: describe theming" + }, + { + "id": "13-contributing", + "branch": "docs/contributing", + "author": "sam", + "daysAgo": 2, + "time": "12:00", + "message": "docs: add contributing notes" + }, + { + "id": "12-prepare-v2", + "branch": "main", + "author": "alex", + "daysAgo": 2, + "time": "09:00", + "message": "chore: prepare 2.0.0-rc.1" + } + ], + "tags": [ + { "name": "v1.0.0", "commit": "03-add-ci" }, + { "name": "v1.1.0", "commit": "05-settings-page" }, + { "name": "v1.2.0", "commit": "07-release-notes" }, + { "name": "v2.0.0-rc.1", "commit": "12-prepare-v2" } + ] +} diff --git a/dev/fixtures/repository/acme-webapp/repository.json b/dev/fixtures/repository/acme-webapp/repository.json new file mode 100644 index 00000000..8c788536 --- /dev/null +++ b/dev/fixtures/repository/acme-webapp/repository.json @@ -0,0 +1,214 @@ +{ + "owner": "acme", + "name": "webapp", + "description": "A small web application with enough history to exercise every repository page.", + "website": "https://webapp.acme.example", + "topics": ["typescript", "web", "fixture"], + "createdDaysAgo": 120, + "stars": ["alex", "sam"], + "labels": [ + { "name": "bug", "color": "d73a4a", "description": "Something is not working" }, + { "name": "enhancement", "color": "a2eeef", "description": "New feature or request" }, + { "name": "documentation", "color": "0075ca", "description": "Improvements to documentation" }, + { "name": "good first issue", "color": "7057ff", "description": "Good for newcomers" } + ], + "milestones": [ + { "title": "2.0", "description": "Theming and the removal of legacy endpoints.", "state": "open", "dueInDays": 30 }, + { "title": "1.2", "description": "Reliability fixes.", "state": "closed", "dueInDays": -11 } + ], + "issues": [ + { + "title": "Requests fail permanently after one network error", + "body": "A single dropped connection makes the settings page show an error until it is reloaded.\n\n### Steps\n\n1. Open the settings page.\n2. Disconnect the network for a moment.\n\n### Expected\n\nThe request is retried.", + "author": "sam", + "labels": ["bug"], + "milestone": "1.2", + "assignees": ["sam"], + "state": "closed", + "daysAgo": 34, + "comments": [ + { + "author": "alex", + "daysAgo": 33, + "body": "Reproduced. Three attempts with no delay should be enough for now." + }, + { "author": "sam", "daysAgo": 30, "body": "Fixed on `main`." } + ] + }, + { + "title": "Add a dark mode", + "body": "The application only has a light color scheme. It should follow a toggle and remember the choice.", + "author": "alex", + "labels": ["enhancement"], + "milestone": "2.0", + "assignees": ["sam"], + "state": "open", + "daysAgo": 21, + "comments": [{ "author": "sam", "daysAgo": 4, "body": "A first version is up as a pull request." }] + }, + { + "title": "Login redirect accepts external URLs", + "body": "`?next=https://example.com` is followed after sign-in. Only local paths should be accepted.", + "author": "sam", + "labels": ["bug"], + "milestone": "1.2", + "assignees": [], + "state": "closed", + "daysAgo": 26, + "comments": [] + }, + { + "title": "Document the configuration variables", + "body": "The README does not say which environment variables exist.", + "author": "sam", + "labels": ["documentation", "good first issue"], + "milestone": null, + "assignees": [], + "state": "open", + "daysAgo": 15, + "comments": [] + }, + { + "title": "Remove the legacy `/v0` endpoints", + "body": "They have been deprecated since 1.0 and should go away with 2.0.\n\n- [x] Announce the removal\n- [ ] Delete the handlers\n- [ ] Update the documentation", + "author": "alex", + "labels": ["enhancement"], + "milestone": "2.0", + "assignees": ["alex"], + "state": "open", + "daysAgo": 9, + "comments": [ + { "author": "sam", "daysAgo": 8, "body": "Is anyone still calling them?" }, + { "author": "alex", "daysAgo": 8, "body": "Not according to the access logs of the last month." } + ] + }, + { + "title": "Settings page flickers while loading", + "body": "The page renders empty fields for a moment before the settings arrive.", + "author": "sam", + "labels": [], + "milestone": null, + "assignees": [], + "state": "open", + "daysAgo": 1, + "comments": [] + } + ], + "pulls": [ + { + "title": "Only follow local login redirects", + "body": "Rejects any `next` target that does not start with `/`.", + "author": "sam", + "head": "fix/login-redirect", + "labels": ["bug"], + "milestone": "1.2", + "state": "merged", + "daysAgo": 25, + "comments": [{ "author": "alex", "daysAgo": 24, "body": "Looks good, thanks." }] + }, + { + "title": "Announce removal of legacy endpoints", + "body": "Adds a short notice to the documentation.", + "author": "alex", + "head": "chore/remove-legacy", + "labels": ["documentation"], + "milestone": null, + "state": "closed", + "daysAgo": 8, + "comments": [{ "author": "alex", "daysAgo": 7, "body": "Closing: this belongs in the changelog instead." }] + }, + { + "title": "Add a dark mode toggle", + "body": "Adds `toggleColorScheme` and the matching styles.\n\nStill missing: remembering the choice between visits.", + "author": "sam", + "head": "feature/dark-mode", + "labels": ["enhancement"], + "milestone": "2.0", + "state": "open", + "daysAgo": 4, + "comments": [ + { "author": "alex", "daysAgo": 3, "body": "Could the toggle also respect the system preference on first load?" } + ] + }, + { + "title": "WIP: Add contributing notes", + "body": "Draft of the contributing guide.", + "author": "sam", + "head": "docs/contributing", + "labels": ["documentation"], + "milestone": null, + "state": "open", + "daysAgo": 2, + "comments": [] + } + ], + "releases": [ + { + "tag": "v1.0.0", + "name": "1.0.0", + "body": "First release.", + "draft": false, + "prerelease": false, + "daysAgo": 108, + "assets": [] + }, + { + "tag": "v1.1.0", + "name": "1.1.0", + "body": "## Features\n\n- Add a typed API client.\n- Add the settings page.", + "draft": false, + "prerelease": false, + "daysAgo": 58, + "assets": [] + }, + { + "tag": "v1.2.0", + "name": "1.2.0", + "body": "## Fixes\n\n- Retry failed API requests up to three times.\n- Only follow local login redirects.\n\n## Documentation\n\n- Add the changelog.\n\n**Full changelog**: `v1.1.0...v1.2.0`", + "draft": false, + "prerelease": false, + "daysAgo": 11, + "assets": [ + { "name": "webapp-1.2.0.tar.gz", "content": "Fixture release asset for webapp 1.2.0.\n" }, + { + "name": "checksums.txt", + "content": "0000000000000000000000000000000000000000000000000000000000000000 webapp-1.2.0.tar.gz\n" + } + ] + }, + { + "tag": "v2.0.0-rc.1", + "name": "2.0.0 release candidate 1", + "body": "Preview of 2.0. Not recommended for production use.", + "draft": false, + "prerelease": true, + "daysAgo": 2, + "assets": [] + }, + { + "tag": "v2.0.0", + "name": "2.0.0", + "body": "Release notes in progress.", + "draft": true, + "prerelease": false, + "daysAgo": 1, + "assets": [] + } + ], + "packages": [ + { + "type": "generic", + "name": "webapp-cli", + "version": "1.1.0", + "file": "webapp-cli.txt", + "content": "Fixture package webapp-cli 1.1.0.\n" + }, + { + "type": "generic", + "name": "webapp-cli", + "version": "1.2.0", + "file": "webapp-cli.txt", + "content": "Fixture package webapp-cli 1.2.0.\n" + } + ] +} diff --git a/dev/fixtures/repository/alex-notes/commit/01-initial/README.md b/dev/fixtures/repository/alex-notes/commit/01-initial/README.md new file mode 100644 index 00000000..c36e407b --- /dev/null +++ b/dev/fixtures/repository/alex-notes/commit/01-initial/README.md @@ -0,0 +1,4 @@ +# Notes + +Personal notes. This fixture repository stays small on purpose: it has no issues, releases or workflows, so +the empty states of those pages can be checked. diff --git a/dev/fixtures/repository/alex-notes/commit/02-ideas/ideas.md b/dev/fixtures/repository/alex-notes/commit/02-ideas/ideas.md new file mode 100644 index 00000000..f2d9b97e --- /dev/null +++ b/dev/fixtures/repository/alex-notes/commit/02-ideas/ideas.md @@ -0,0 +1,4 @@ +# Ideas + +- Export notes as a single file. +- Search across all notes. diff --git a/dev/fixtures/repository/alex-notes/history.json b/dev/fixtures/repository/alex-notes/history.json new file mode 100644 index 00000000..812bf9b0 --- /dev/null +++ b/dev/fixtures/repository/alex-notes/history.json @@ -0,0 +1,22 @@ +{ + "defaultBranch": "main", + "commits": [ + { + "id": "01-initial", + "branch": "main", + "author": "alex", + "daysAgo": 45, + "time": "20:10", + "message": "Initial commit" + }, + { + "id": "02-ideas", + "branch": "main", + "author": "alex", + "daysAgo": 6, + "time": "21:35", + "message": "Add a list of ideas" + } + ], + "tags": [] +} diff --git a/dev/fixtures/repository/alex-notes/repository.json b/dev/fixtures/repository/alex-notes/repository.json new file mode 100644 index 00000000..b1d4d7a9 --- /dev/null +++ b/dev/fixtures/repository/alex-notes/repository.json @@ -0,0 +1,23 @@ +{ + "owner": "alex", + "name": "notes", + "description": "Personal notes, kept small to show empty states.", + "website": "", + "topics": [], + "createdDaysAgo": 45, + "stars": [], + "labels": [], + "milestones": [], + "issues": [], + "pulls": [], + "releases": [], + "packages": [ + { + "type": "generic", + "name": "notes-export", + "version": "0.1.0", + "file": "notes.txt", + "content": "Fixture package notes-export 0.1.0.\n" + } + ] +} diff --git a/dev/fixtures/whereareiam/identica-docs.git/HEAD b/dev/fixtures/whereareiam/identica-docs.git/HEAD deleted file mode 100644 index a334635f..00000000 --- a/dev/fixtures/whereareiam/identica-docs.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/dev diff --git a/dev/fixtures/whereareiam/identica-docs.git/config b/dev/fixtures/whereareiam/identica-docs.git/config deleted file mode 100644 index 07d359d0..00000000 --- a/dev/fixtures/whereareiam/identica-docs.git/config +++ /dev/null @@ -1,4 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true diff --git a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.idx b/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.idx deleted file mode 100644 index eb171263133345c81d4ca1feb129c72c941ef723..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1884 zcmexg;-AdGz`z8==ztL@fDMDxQi554`jIg!kdF_u0p&rMokYw5R6m$8C(wPkFc(k` zALa(i6N`C(YSA$tP>fV80MvsG3j)PqSO~}lVPQO21Sp4&MS)^sc(6E7ju2dGq;-TWDMo<+qz}SC(Bs7Engp}ed*GR=~CGJfvxm2*O!*bZJn9f zTewywCTGl8G28Hz;TjIcY3|3oYOk%2dy(BCGLPrZ7cS5CM;X7aJ~{p|WRpl)aIF5( z$-$1cEAPI$9=bZ<5 z&I=4|bx+yO535}3liA8=_T=O8vVf3FTUMTS;&JYN=v-WRxR~L|#uqBT*_KG3S9<(p z$Ig?MTb54@3ED6C=lJK^UY08PD3w)){~Ve3bXyzP+a)Wj7acm6@^~%F(Lh7P8137t8@2xw&3FYr8+rX-0JCNT4R0~oAl;`;a%qRPS)sQPtO&w6bF|a7^0P=zL1^aTKx}`vI zU>U_&3l#SOVtJr?U>V9^0u*;z%o?|xUoUfZm_YNd+uqs*vHJ5Wl@6vihfcQE6}>!- NS^fOfN4(C7y8&x0gHHee diff --git a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.pack b/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.pack deleted file mode 100644 index f6dec571b674cc6437bcb7caf11aa3738fe80913..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4070 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 diff --git a/dev/fixtures/whereareiam/identica-docs.git/packed-refs b/dev/fixtures/whereareiam/identica-docs.git/packed-refs deleted file mode 100644 index 27ac30b0..00000000 --- a/dev/fixtures/whereareiam/identica-docs.git/packed-refs +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -cd06ddfcab145c32e477fd123da4a97ba2c2ce64 refs/heads/dev diff --git a/dev/fixtures/whereareiam/identica-docs.json b/dev/fixtures/whereareiam/identica-docs.json deleted file mode 100644 index 78c40e36..00000000 --- a/dev/fixtures/whereareiam/identica-docs.json +++ /dev/null @@ -1,508 +0,0 @@ -{ - "actions": { - "action_run": [ - { - "id": 373, - "ref": "refs/heads/dev", - "event": "workflow_dispatch", - "index": 17, - "title": "Manual Build Deploy", - "status": 1, - "created": 1788463057, - "repo_id": 12, - "started": 1788463058, - "stopped": 1788463111, - "updated": 1788463111, - "version": 6, - "owner_id": 2, - "priority": 0, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "prioritize": false, - "approved_by": 0, - "schedule_id": 0, - "workflow_id": "manual-build-deploy.yml", - "notify_email": false, - "event_payload": "{\"inputs\":{\"deploy\":\"true\",\"source_ref\":\"dev\"},\"ref\":\"refs/heads/dev\",\"repository\":{\"id\":12,\"owner\":{\"id\":2,\"login\":\"whereareiam\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"whereareiam\",\"email\":\"whereareiam@noreply.repository.arcadeya.com\",\"avatar_url\":\"https://repository.arcadeya.com/avatars/cdb228665b4dc7a75ebbd5ba367a3439eb160aae66141f8c50fbff118fe69354\",\"html_url\":\"https://repository.arcadeya.com/whereareiam\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-06-28T09:22:01Z\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"pronouns\":\"\",\"website\":\"\",\"description\":\"\",\"visibility\":\"public\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0,\"username\":\"whereareiam\"},\"name\":\"identica-docs\",\"full_name\":\"whereareiam/identica-docs\",\"description\":\"\",\"empty\":false,\"private\":true,\"fork\":false,\"template\":false,\"parent\":null,\"mirror\":false,\"size\":48,\"language\":\"\",\"languages_url\":\"https://repository.arcadeya.com/api/v1/repos/whereareiam/identica-docs/languages\",\"html_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs\",\"url\":\"https://repository.arcadeya.com/api/v1/repos/whereareiam/identica-docs\",\"link\":\"\",\"ssh_url\":\"ssh://git@repository.arcadeya.com/whereareiam/identica-docs.git\",\"clone_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs.git\",\"original_url\":\"\",\"website\":\"\",\"stars_count\":0,\"forks_count\":0,\"watchers_count\":1,\"open_issues_count\":0,\"open_pr_counter\":0,\"release_counter\":0,\"default_branch\":\"dev\",\"archived\":false,\"created_at\":\"2026-08-14T12:44:17Z\",\"updated_at\":\"2026-09-03T18:42:56Z\",\"archived_at\":\"1970-01-01T00:00:00Z\",\"permissions\":{\"admin\":false,\"push\":false,\"pull\":false},\"has_issues\":false,\"has_wiki\":false,\"has_wiki_contents\":false,\"wiki_branch\":\"main\",\"wiki_ssh_url\":\"ssh://git@repository.arcadeya.com/whereareiam/identica-docs.wiki.git\",\"wiki_clone_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs.wiki.git\",\"globally_editable_wiki\":false,\"has_pull_requests\":true,\"has_projects\":false,\"has_releases\":false,\"has_packages\":false,\"has_actions\":true,\"ignore_whitespace_conflicts\":false,\"allow_merge_commits\":true,\"allow_rebase\":true,\"allow_rebase_explicit\":true,\"allow_squash_merge\":true,\"allow_fast_forward_only_merge\":true,\"allow_rebase_update\":true,\"default_delete_branch_after_merge\":false,\"default_merge_style\":\"merge\",\"default_allow_maintainer_edit\":false,\"default_update_style\":\"merge\",\"avatar_url\":\"\",\"internal\":false,\"mirror_interval\":\"\",\"object_format_name\":\"sha1\",\"mirror_updated\":\"0001-01-01T00:00:00Z\",\"repo_transfer\":null,\"topics\":[]},\"sender\":{\"id\":2,\"login\":\"whereareiam\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"whereareiam\",\"email\":\"whereareiam@noreply.repository.arcadeya.com\",\"avatar_url\":\"https://repository.arcadeya.com/avatars/cdb228665b4dc7a75ebbd5ba367a3439eb160aae66141f8c50fbff118fe69354\",\"html_url\":\"https://repository.arcadeya.com/whereareiam\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-06-28T09:22:01Z\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"pronouns\":\"\",\"website\":\"\",\"description\":\"\",\"visibility\":\"public\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0,\"username\":\"whereareiam\"},\"workflow\":\".forgejo/workflows/manual-build-deploy.yml\"}", - "need_approval": false, - "trigger_event": "workflow_dispatch", - "pull_request_id": 0, - "trigger_user_id": 2, - "concurrency_type": 0, - "concurrency_group": "refs/heads/dev_manual-build-deploy.yml_workflow_dispatch__auto", - "previous_duration": 0, - "workflow_directory": ".forgejo/workflows", - "pre_execution_error": "", - "is_fork_pull_request": false, - "pull_request_poster_id": 0, - "workflow_source_commit": null, - "pre_execution_error_code": 0, - "pre_execution_error_details": "null", - "pre_execution_warning_codes": "[]", - "pre_execution_warning_details": "[]" - }, - { - "id": 372, - "ref": "refs/heads/dev", - "event": "workflow_dispatch", - "index": 16, - "title": "Manual Build Deploy", - "status": 2, - "created": 1788462801, - "repo_id": 12, - "started": 1788462802, - "stopped": 1788462811, - "updated": 1788462812, - "version": 6, - "owner_id": 2, - "priority": 0, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "prioritize": false, - "approved_by": 0, - "schedule_id": 0, - "workflow_id": "manual-build-deploy.yml", - "notify_email": false, - "event_payload": "{\"inputs\":{\"deploy\":\"true\",\"source_ref\":\"1.0.0\"},\"ref\":\"refs/heads/dev\",\"repository\":{\"id\":12,\"owner\":{\"id\":2,\"login\":\"whereareiam\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"whereareiam\",\"email\":\"whereareiam@noreply.repository.arcadeya.com\",\"avatar_url\":\"https://repository.arcadeya.com/avatars/cdb228665b4dc7a75ebbd5ba367a3439eb160aae66141f8c50fbff118fe69354\",\"html_url\":\"https://repository.arcadeya.com/whereareiam\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-06-28T09:22:01Z\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"pronouns\":\"\",\"website\":\"\",\"description\":\"\",\"visibility\":\"public\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0,\"username\":\"whereareiam\"},\"name\":\"identica-docs\",\"full_name\":\"whereareiam/identica-docs\",\"description\":\"\",\"empty\":false,\"private\":true,\"fork\":false,\"template\":false,\"parent\":null,\"mirror\":false,\"size\":48,\"language\":\"\",\"languages_url\":\"https://repository.arcadeya.com/api/v1/repos/whereareiam/identica-docs/languages\",\"html_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs\",\"url\":\"https://repository.arcadeya.com/api/v1/repos/whereareiam/identica-docs\",\"link\":\"\",\"ssh_url\":\"ssh://git@repository.arcadeya.com/whereareiam/identica-docs.git\",\"clone_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs.git\",\"original_url\":\"\",\"website\":\"\",\"stars_count\":0,\"forks_count\":0,\"watchers_count\":1,\"open_issues_count\":0,\"open_pr_counter\":0,\"release_counter\":1,\"default_branch\":\"dev\",\"archived\":false,\"created_at\":\"2026-08-14T12:44:17Z\",\"updated_at\":\"2026-09-03T18:42:56Z\",\"archived_at\":\"1970-01-01T00:00:00Z\",\"permissions\":{\"admin\":false,\"push\":false,\"pull\":false},\"has_issues\":false,\"has_wiki\":false,\"has_wiki_contents\":false,\"wiki_branch\":\"main\",\"wiki_ssh_url\":\"ssh://git@repository.arcadeya.com/whereareiam/identica-docs.wiki.git\",\"wiki_clone_url\":\"https://repository.arcadeya.com/whereareiam/identica-docs.wiki.git\",\"globally_editable_wiki\":false,\"has_pull_requests\":true,\"has_projects\":false,\"has_releases\":true,\"has_packages\":false,\"has_actions\":true,\"ignore_whitespace_conflicts\":false,\"allow_merge_commits\":true,\"allow_rebase\":true,\"allow_rebase_explicit\":true,\"allow_squash_merge\":true,\"allow_fast_forward_only_merge\":true,\"allow_rebase_update\":true,\"default_delete_branch_after_merge\":false,\"default_merge_style\":\"merge\",\"default_allow_maintainer_edit\":false,\"default_update_style\":\"merge\",\"avatar_url\":\"\",\"internal\":false,\"mirror_interval\":\"\",\"object_format_name\":\"sha1\",\"mirror_updated\":\"0001-01-01T00:00:00Z\",\"repo_transfer\":null,\"topics\":[]},\"sender\":{\"id\":2,\"login\":\"whereareiam\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"whereareiam\",\"email\":\"whereareiam@noreply.repository.arcadeya.com\",\"avatar_url\":\"https://repository.arcadeya.com/avatars/cdb228665b4dc7a75ebbd5ba367a3439eb160aae66141f8c50fbff118fe69354\",\"html_url\":\"https://repository.arcadeya.com/whereareiam\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-06-28T09:22:01Z\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"pronouns\":\"\",\"website\":\"\",\"description\":\"\",\"visibility\":\"public\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0,\"username\":\"whereareiam\"},\"workflow\":\".forgejo/workflows/manual-build-deploy.yml\"}", - "need_approval": false, - "trigger_event": "workflow_dispatch", - "pull_request_id": 0, - "trigger_user_id": 2, - "concurrency_type": 0, - "concurrency_group": "refs/heads/dev_manual-build-deploy.yml_workflow_dispatch__auto", - "previous_duration": 0, - "workflow_directory": ".forgejo/workflows", - "pre_execution_error": "", - "is_fork_pull_request": false, - "pull_request_poster_id": 0, - "workflow_source_commit": null, - "pre_execution_error_code": 0, - "pre_execution_error_details": "null", - "pre_execution_warning_codes": "[]", - "pre_execution_warning_details": "[]" - } - ], - "action_task": [ - { - "id": 664, - "job_id": 1304, - "status": 1, - "attempt": 1, - "created": 1788463104, - "repo_id": 12, - "started": 1788463104, - "stopped": 1788463110, - "updated": 1788463111, - "log_size": 146, - "owner_id": 2, - "runner_id": 1, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "log_length": 2, - "token_hash": "73221fb8f340cf0d4af93af3fa1b629b8fd2fa40df3279cf8f2892d94f078306c957a3abe598c0b654caafaa82da135af86c", - "token_salt": "oFkUzDnRnnFGRO452T6OTF", - "log_expired": false, - "log_filename": "whereareiam/identica-docs/98/664.log.zst", - "log_in_storage": true, - "token_last_eight": "01fe6e43", - "runner_request_key": "c92a3eac-78dd-4e39-ba6b-a059bd5d419e", - "is_fork_pull_request": false, - "log_indexes_base64": "ALIB" - }, - { - "id": 662, - "job_id": 1302, - "status": 4, - "attempt": 1, - "created": 1788462812, - "repo_id": 12, - "started": 1788462812, - "stopped": 1788462812, - "updated": 1788462812, - "log_size": 150, - "owner_id": 2, - "runner_id": 1, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "log_length": 2, - "token_hash": "3d4130f766c6ba390185b9cb4dd0b89e5a21173d827a65eaef289878754ee7569072812078ee3962d9b571229e9985d9dd7d", - "token_salt": "EguBY47thFaAiHi-9NUDyO", - "log_expired": false, - "log_filename": "whereareiam/identica-docs/96/662.log.zst", - "log_in_storage": true, - "token_last_eight": "ce92c1ca", - "runner_request_key": "7d361a98-7908-43aa-b918-eaaba45a7247", - "is_fork_pull_request": false, - "log_indexes_base64": "ALIB" - }, - { - "id": 663, - "job_id": 1303, - "status": 1, - "attempt": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463058, - "stopped": 1788463102, - "updated": 1788463102, - "log_size": 146, - "owner_id": 2, - "runner_id": 1, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "log_length": 2, - "token_hash": "e7156b6edc843412130cd0d9024425160071d7284b72b4391a585f1b92cc55592eee8ced215219e9a16fe5942294c05f6c0f", - "token_salt": "fCJ5-Kifdu4yLpEEg_u7_f", - "log_expired": false, - "log_filename": "whereareiam/identica-docs/97/663.log.zst", - "log_in_storage": true, - "token_last_eight": "0d61a289", - "runner_request_key": "60a8f532-4836-4088-b41e-be9afca3eaba", - "is_fork_pull_request": false, - "log_indexes_base64": "ALIB" - }, - { - "id": 661, - "job_id": 1301, - "status": 2, - "attempt": 1, - "created": 1788462802, - "repo_id": 12, - "started": 1788462802, - "stopped": 1788462805, - "updated": 1788462811, - "log_size": 150, - "owner_id": 2, - "runner_id": 1, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "log_length": 2, - "token_hash": "f5e6baf9e9930a37b0d748112e9957e8c498da64b8f3bbef66f3d14e259cd346b8cb7cd851b8f6fa3225156fc0c331817f09", - "token_salt": "mLzp7BSLfCpTdjxT9vtM96", - "log_expired": false, - "log_filename": "whereareiam/identica-docs/95/661.log.zst", - "log_in_storage": true, - "token_last_eight": "d46eb41d", - "runner_request_key": "822a4d8e-b6dc-40bd-8239-57039549e4f8", - "is_fork_pull_request": false, - "log_indexes_base64": "ALIB" - } - ], - "action_run_job": [ - { - "id": 1301, - "name": "build", - "needs": "null", - "handle": "e8581c58-1c65-436b-a5a3-41f28f2ac6b0", - "job_id": "build", - "run_id": 372, - "status": 2, - "attempt": 1, - "created": 1788462801, - "repo_id": 12, - "runs_on": "[\"deploy\"]", - "started": 1788462802, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "owner_id": 2, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "workflow_payload": "\\x6e616d653a204d616e75616c204275696c64204465706c6f790a226f6e223a0a20202020776f726b666c6f775f64697370617463683a0a2020202020202020696e707574733a0a202020202020202020202020736f757263655f7265663a0a202020202020202020202020202020206465736372697074696f6e3a205363726970746f7269756d206272616e63682c207461672c206f7220636f6d6d697420746f206275696c642e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a206465760a202020202020202020202020696d6167655f7461673a0a202020202020202020202020202020206465736372697074696f6e3a204f7074696f6e616c20696d6d757461626c6520696d61676520746167206f766572726964652e0a2020202020202020202020202020202072657175697265643a2066616c73650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a2022220a2020202020202020202020206465706c6f793a0a202020202020202020202020202020206465736372697074696f6e3a204465706c6f7920696d61676520746f204b756265726e657465732e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20626f6f6c65616e0a2020202020202020202020202020202064656661756c743a2066616c73650a6a6f62733a0a202020206275696c643a0a20202020202020206e616d653a206275696c640a202020202020202072756e732d6f6e3a206465706c6f790a202020202020202073746570733a0a2020202020202020202020202d2069643a20736f757263650a20202020202020202020202020206e616d653a20436865636b6f7574205363726970746f7269756d20736f757263650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f736f757263652f6769742f636865636b6f75742d726566407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020736f757263655f7265663a20247b7b20696e707574732e736f757263655f726566207d7d0a20202020202020202020202020202020736f757263655f7265706f3a2068747470733a2f2f6769746875622e636f6d2f776865726561726569616d2f7363726970746f7269756d2e6769740a20202020202020202020202020202020776f726b73706163655f6469723a207363726970746f7269756d0a2020202020202020202020202d206e616d653a205365742075702042756e0a2020202020202020202020202020757365733a20616374696f6e732f73657475702d62756e4076320a2020202020202020202020202020776974683a0a2020202020202020202020202020202062756e2d76657273696f6e3a2022312e33220a2020202020202020202020202d206e616d653a204275696c64205363726970746f7269756d0a202020202020202020202020202072756e3a207c0a20202020202020202020202020202020736574202d65756f20706970656661696c0a2020202020202020202020202020202062756e2063690a2020202020202020202020202020202062756e2072756e206275696c640a2020202020202020202020202020776f726b696e672d6469726563746f72793a207363726970746f7269756d0a20202020202020202020202020207368656c6c3a20626173680a2020202020202020202020202d2069643a20696d6167652d7461670a20202020202020202020202020206e616d653a205265736f6c766520696d616765207461670a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f6d657461646174612f696d6167652f7265736f6c76652d746167407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020696d6167655f7461673a20247b7b20696e707574732e696d6167655f746167207d7d0a202020202020202020202020202020207265665f6e616d653a20247b7b20696e707574732e736f757263655f726566207d7d0a202020202020202020202020202020207368613a20247b7b2073746570732e736f757263652e6f7574707574732e636f6d6d6974207d7d0a20202020202020202020202020202020736f757263655f6469726563746f72793a207363726970746f7269756d0a2020202020202020202020202d2069643a206275696c642d707573680a20202020202020202020202020206e616d653a204275696c6420616e64207075736820696d6167650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f7061636b6167652f646f636b65722f6275696c642d70757368407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020646f636b65725f636f6e746578743a207363726970746f7269756d0a20202020202020202020202020202020646f636b657266696c653a207363726970746f7269756d2f446f636b657266696c650a20202020202020202020202020202020696d6167655f7265706f7369746f72793a2072656769737472792e776865726561726569616d2e6d652f646f636b65722d707269766174652f776865726561726569616d2f6964656e746963612d646f63730a20202020202020202020202020202020696d6167655f7461673a20247b7b2073746570732e696d6167652d7461672e6f7574707574732e696d6167655f746167207d7d0a20202020202020206f7574707574733a0a202020202020202020202020696d6167655f7265663a20247b7b2073746570732e6275696c642d707573682e6f7574707574732e696d6167655f726566207d7d0a656e61626c652d6f70656e69642d636f6e6e6563743a20747275650a", - "is_fork_pull_request": false - }, - { - "id": 1302, - "name": "deploy", - "needs": "[\"build\"]", - "handle": "b0635920-ff0b-409f-9905-ca8ff27c5313", - "job_id": "deploy", - "run_id": 372, - "status": 4, - "attempt": 1, - "created": 1788462801, - "repo_id": 12, - "runs_on": "[\"deploy\"]", - "started": 1788462812, - "stopped": 1788462812, - "task_id": 662, - "updated": 1788462812, - "owner_id": 2, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "workflow_payload": "\\x6e616d653a204d616e75616c204275696c64204465706c6f790a226f6e223a0a20202020776f726b666c6f775f64697370617463683a0a2020202020202020696e707574733a0a202020202020202020202020736f757263655f7265663a0a202020202020202020202020202020206465736372697074696f6e3a205363726970746f7269756d206272616e63682c207461672c206f7220636f6d6d697420746f206275696c642e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a206465760a202020202020202020202020696d6167655f7461673a0a202020202020202020202020202020206465736372697074696f6e3a204f7074696f6e616c20696d6d757461626c6520696d61676520746167206f766572726964652e0a2020202020202020202020202020202072657175697265643a2066616c73650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a2022220a2020202020202020202020206465706c6f793a0a202020202020202020202020202020206465736372697074696f6e3a204465706c6f7920696d61676520746f204b756265726e657465732e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20626f6f6c65616e0a2020202020202020202020202020202064656661756c743a2066616c73650a6a6f62733a0a202020206465706c6f793a0a20202020202020206e616d653a206465706c6f790a202020202020202072756e732d6f6e3a206465706c6f790a202020202020202069663a20696e707574732e6465706c6f790a202020202020202073746570733a0a2020202020202020202020202d2069643a206f6964632d746f6b656e0a20202020202020202020202020206e616d653a2052657175657374204b756265726e65746573204f49444320746f6b656e0a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f617574682f6f6964632f726571756573742d746f6b656e407631360a2020202020202020202020202020776974683a0a2020202020202020202020202020202061756469656e63653a206b756265726e657465730a2020202020202020202020202d206e616d653a2056616c6964617465204b756265726e6574657320746f6b656e20636c61696d730a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f617574682f6f6964632f76616c69646174652d636c61696d73407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020746f6b656e3a20247b7b2073746570732e6f6964632d746f6b656e2e6f7574707574732e746f6b656e207d7d0a2020202020202020202020202d206e616d653a20536574206465706c6f796d656e7420696d6167650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f636f6d706f736974652f6b756265726e657465732f6465706c6f796d656e742f7365742d696d616765407631360a2020202020202020202020202020776974683a0a202020202020202020202020202020206265617265725f746f6b656e3a20247b7b2073746570732e6f6964632d746f6b656e2e6f7574707574732e746f6b656e207d7d0a20202020202020202020202020202020636f6e7461696e65723a206964656e746963612d646f63730a202020202020202020202020202020206465706c6f796d656e743a206964656e746963612d646f63730a20202020202020202020202020202020696d6167653a20247b7b206e656564732e6275696c642e6f7574707574732e696d6167655f726566207d7d0a202020202020202020202020202020206e616d6573706163653a20776865726561726569616d0a656e61626c652d6f70656e69642d636f6e6e6563743a20747275650a", - "is_fork_pull_request": false - }, - { - "id": 1303, - "name": "build", - "needs": "null", - "handle": "e062a120-bdbd-4d1a-93f8-55f7a9e91f98", - "job_id": "build", - "run_id": 373, - "status": 1, - "attempt": 1, - "created": 1788463057, - "repo_id": 12, - "runs_on": "[\"deploy\"]", - "started": 1788463058, - "stopped": 1788463102, - "task_id": 663, - "updated": 1788463102, - "owner_id": 2, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "workflow_payload": "\\x6e616d653a204d616e75616c204275696c64204465706c6f790a226f6e223a0a20202020776f726b666c6f775f64697370617463683a0a2020202020202020696e707574733a0a202020202020202020202020736f757263655f7265663a0a202020202020202020202020202020206465736372697074696f6e3a205363726970746f7269756d206272616e63682c207461672c206f7220636f6d6d697420746f206275696c642e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a206465760a202020202020202020202020696d6167655f7461673a0a202020202020202020202020202020206465736372697074696f6e3a204f7074696f6e616c20696d6d757461626c6520696d61676520746167206f766572726964652e0a2020202020202020202020202020202072657175697265643a2066616c73650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a2022220a2020202020202020202020206465706c6f793a0a202020202020202020202020202020206465736372697074696f6e3a204465706c6f7920696d61676520746f204b756265726e657465732e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20626f6f6c65616e0a2020202020202020202020202020202064656661756c743a2066616c73650a6a6f62733a0a202020206275696c643a0a20202020202020206e616d653a206275696c640a202020202020202072756e732d6f6e3a206465706c6f790a202020202020202073746570733a0a2020202020202020202020202d2069643a20736f757263650a20202020202020202020202020206e616d653a20436865636b6f7574205363726970746f7269756d20736f757263650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f736f757263652f6769742f636865636b6f75742d726566407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020736f757263655f7265663a20247b7b20696e707574732e736f757263655f726566207d7d0a20202020202020202020202020202020736f757263655f7265706f3a2068747470733a2f2f6769746875622e636f6d2f776865726561726569616d2f7363726970746f7269756d2e6769740a20202020202020202020202020202020776f726b73706163655f6469723a207363726970746f7269756d0a2020202020202020202020202d206e616d653a205365742075702042756e0a2020202020202020202020202020757365733a20616374696f6e732f73657475702d62756e4076320a2020202020202020202020202020776974683a0a2020202020202020202020202020202062756e2d76657273696f6e3a2022312e33220a2020202020202020202020202d206e616d653a204275696c64205363726970746f7269756d0a202020202020202020202020202072756e3a207c0a20202020202020202020202020202020736574202d65756f20706970656661696c0a2020202020202020202020202020202062756e2063690a2020202020202020202020202020202062756e2072756e206275696c640a2020202020202020202020202020776f726b696e672d6469726563746f72793a207363726970746f7269756d0a20202020202020202020202020207368656c6c3a20626173680a2020202020202020202020202d2069643a20696d6167652d7461670a20202020202020202020202020206e616d653a205265736f6c766520696d616765207461670a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f6d657461646174612f696d6167652f7265736f6c76652d746167407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020696d6167655f7461673a20247b7b20696e707574732e696d6167655f746167207d7d0a202020202020202020202020202020207265665f6e616d653a20247b7b20696e707574732e736f757263655f726566207d7d0a202020202020202020202020202020207368613a20247b7b2073746570732e736f757263652e6f7574707574732e636f6d6d6974207d7d0a20202020202020202020202020202020736f757263655f6469726563746f72793a207363726970746f7269756d0a2020202020202020202020202d2069643a206275696c642d707573680a20202020202020202020202020206e616d653a204275696c6420616e64207075736820696d6167650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f7061636b6167652f646f636b65722f6275696c642d70757368407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020646f636b65725f636f6e746578743a207363726970746f7269756d0a20202020202020202020202020202020646f636b657266696c653a207363726970746f7269756d2f446f636b657266696c650a20202020202020202020202020202020696d6167655f7265706f7369746f72793a2072656769737472792e776865726561726569616d2e6d652f646f636b65722d707269766174652f776865726561726569616d2f6964656e746963612d646f63730a20202020202020202020202020202020696d6167655f7461673a20247b7b2073746570732e696d6167652d7461672e6f7574707574732e696d6167655f746167207d7d0a20202020202020206f7574707574733a0a202020202020202020202020696d6167655f7265663a20247b7b2073746570732e6275696c642d707573682e6f7574707574732e696d6167655f726566207d7d0a656e61626c652d6f70656e69642d636f6e6e6563743a20747275650a", - "is_fork_pull_request": false - }, - { - "id": 1304, - "name": "deploy", - "needs": "[\"build\"]", - "handle": "d6ea23d3-9ac0-454b-bf83-b17372179a3f", - "job_id": "deploy", - "run_id": 373, - "status": 1, - "attempt": 1, - "created": 1788463057, - "repo_id": 12, - "runs_on": "[\"deploy\"]", - "started": 1788463104, - "stopped": 1788463110, - "task_id": 664, - "updated": 1788463111, - "owner_id": 2, - "commit_sha": "cd06ddfcab145c32e477fd123da4a97ba2c2ce64", - "workflow_payload": "\\x6e616d653a204d616e75616c204275696c64204465706c6f790a226f6e223a0a20202020776f726b666c6f775f64697370617463683a0a2020202020202020696e707574733a0a202020202020202020202020736f757263655f7265663a0a202020202020202020202020202020206465736372697074696f6e3a205363726970746f7269756d206272616e63682c207461672c206f7220636f6d6d697420746f206275696c642e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a206465760a202020202020202020202020696d6167655f7461673a0a202020202020202020202020202020206465736372697074696f6e3a204f7074696f6e616c20696d6d757461626c6520696d61676520746167206f766572726964652e0a2020202020202020202020202020202072657175697265643a2066616c73650a20202020202020202020202020202020747970653a20737472696e670a2020202020202020202020202020202064656661756c743a2022220a2020202020202020202020206465706c6f793a0a202020202020202020202020202020206465736372697074696f6e3a204465706c6f7920696d61676520746f204b756265726e657465732e0a2020202020202020202020202020202072657175697265643a20747275650a20202020202020202020202020202020747970653a20626f6f6c65616e0a2020202020202020202020202020202064656661756c743a2066616c73650a6a6f62733a0a202020206465706c6f793a0a20202020202020206e616d653a206465706c6f790a202020202020202072756e732d6f6e3a206465706c6f790a202020202020202069663a20696e707574732e6465706c6f790a202020202020202073746570733a0a2020202020202020202020202d2069643a206f6964632d746f6b656e0a20202020202020202020202020206e616d653a2052657175657374204b756265726e65746573204f49444320746f6b656e0a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f617574682f6f6964632f726571756573742d746f6b656e407631360a2020202020202020202020202020776974683a0a2020202020202020202020202020202061756469656e63653a206b756265726e657465730a2020202020202020202020202d206e616d653a2056616c6964617465204b756265726e6574657320746f6b656e20636c61696d730a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f7072696d69746976652f617574682f6f6964632f76616c69646174652d636c61696d73407631360a2020202020202020202020202020776974683a0a20202020202020202020202020202020746f6b656e3a20247b7b2073746570732e6f6964632d746f6b656e2e6f7574707574732e746f6b656e207d7d0a2020202020202020202020202d206e616d653a20536574206465706c6f796d656e7420696d6167650a2020202020202020202020202020757365733a2061726361646579612f6465766f70732f636f6d706f736974652f6b756265726e657465732f6465706c6f796d656e742f7365742d696d616765407631360a2020202020202020202020202020776974683a0a202020202020202020202020202020206265617265725f746f6b656e3a20247b7b2073746570732e6f6964632d746f6b656e2e6f7574707574732e746f6b656e207d7d0a20202020202020202020202020202020636f6e7461696e65723a206964656e746963612d646f63730a202020202020202020202020202020206465706c6f796d656e743a206964656e746963612d646f63730a20202020202020202020202020202020696d6167653a20247b7b206e656564732e6275696c642e6f7574707574732e696d6167655f726566207d7d0a202020202020202020202020202020206e616d6573706163653a20776865726561726569616d0a656e61626c652d6f70656e69642d636f6e6e6563743a20747275650a", - "is_fork_pull_request": false - } - ], - "action_artifact": [], - "action_task_step": [ - { - "id": 2453, - "name": "Validate Kubernetes token claims", - "index": 1, - "status": 1, - "created": 1788463104, - "repo_id": 12, - "started": 1788463107, - "stopped": 1788463107, - "task_id": 664, - "updated": 1788463111, - "log_index": 1, - "log_length": 1 - }, - { - "id": 2454, - "name": "Set deployment image", - "index": 2, - "status": 1, - "created": 1788463104, - "repo_id": 12, - "started": 1788463107, - "stopped": 1788463110, - "task_id": 664, - "updated": 1788463111, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2452, - "name": "Request Kubernetes OIDC token", - "index": 0, - "status": 1, - "created": 1788463104, - "repo_id": 12, - "started": 1788463106, - "stopped": 1788463107, - "task_id": 664, - "updated": 1788463111, - "log_index": 0, - "log_length": 1 - }, - { - "id": 2451, - "name": "Build and push image", - "index": 4, - "status": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463083, - "stopped": 1788463100, - "task_id": 663, - "updated": 1788463102, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2449, - "name": "Build Scriptorium", - "index": 2, - "status": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463063, - "stopped": 1788463083, - "task_id": 663, - "updated": 1788463102, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2447, - "name": "Checkout Scriptorium source", - "index": 0, - "status": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463060, - "stopped": 1788463061, - "task_id": 663, - "updated": 1788463102, - "log_index": 0, - "log_length": 1 - }, - { - "id": 2448, - "name": "Set up Bun", - "index": 1, - "status": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463061, - "stopped": 1788463063, - "task_id": 663, - "updated": 1788463102, - "log_index": 1, - "log_length": 1 - }, - { - "id": 2450, - "name": "Resolve image tag", - "index": 3, - "status": 1, - "created": 1788463058, - "repo_id": 12, - "started": 1788463083, - "stopped": 1788463083, - "task_id": 663, - "updated": 1788463102, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2439, - "name": "Checkout Scriptorium source", - "index": 0, - "status": 2, - "created": 1788462802, - "repo_id": 12, - "started": 1788462804, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "log_index": 0, - "log_length": 1 - }, - { - "id": 2440, - "name": "Set up Bun", - "index": 1, - "status": 4, - "created": 1788462802, - "repo_id": 12, - "started": 1788462805, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "log_index": 1, - "log_length": 1 - }, - { - "id": 2441, - "name": "Build Scriptorium", - "index": 2, - "status": 4, - "created": 1788462802, - "repo_id": 12, - "started": 1788462805, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2442, - "name": "Resolve image tag", - "index": 3, - "status": 4, - "created": 1788462802, - "repo_id": 12, - "started": 1788462805, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2443, - "name": "Build and push image", - "index": 4, - "status": 4, - "created": 1788462802, - "repo_id": 12, - "started": 1788462805, - "stopped": 1788462805, - "task_id": 661, - "updated": 1788462811, - "log_index": 2, - "log_length": 0 - }, - { - "id": 2444, - "name": "Request Kubernetes OIDC token", - "index": 0, - "status": 4, - "created": 1788462812, - "repo_id": 12, - "started": 0, - "stopped": 0, - "task_id": 662, - "updated": 1788462812, - "log_index": 0, - "log_length": 1 - }, - { - "id": 2445, - "name": "Validate Kubernetes token claims", - "index": 1, - "status": 4, - "created": 1788462812, - "repo_id": 12, - "started": 0, - "stopped": 0, - "task_id": 662, - "updated": 1788462812, - "log_index": 1, - "log_length": 1 - }, - { - "id": 2446, - "name": "Set deployment image", - "index": 2, - "status": 4, - "created": 1788462812, - "repo_id": 12, - "started": 0, - "stopped": 0, - "task_id": 662, - "updated": 1788462812, - "log_index": 2, - "log_length": 0 - } - ], - "action_task_output": [ - { - "id": 105, - "task_id": 663, - "output_key": "image_ref", - "output_value": "registry.whereareiam.me/docker-private/whereareiam/identica-docs:dev-cc8ca14" - } - ] - }, - "releases": [] -} diff --git a/dev/preview/CommandRunner.ts b/dev/preview/CommandRunner.ts index 1dd5cb41..8ffa0a2d 100644 --- a/dev/preview/CommandRunner.ts +++ b/dev/preview/CommandRunner.ts @@ -14,4 +14,29 @@ export class CommandRunner { public capture(command: string, args: readonly string[]): string { return execFileSync(command, [...args], { cwd: this.workingDirectory, encoding: "utf8" }); } + + /** Whether the command exits successfully; its output is discarded. */ + public succeeds(command: string, args: readonly string[]): boolean { + try { + execFileSync(command, [...args], { cwd: this.workingDirectory, stdio: "ignore" }); + return true; + } catch { + return false; + } + } + + /** Runs outside the project directory, for work on a temporary checkout. */ + public captureIn( + directory: string, + command: string, + args: readonly string[], + environment: NodeJS.ProcessEnv = {} + ): string { + return execFileSync(command, [...args], { + cwd: directory, + encoding: "utf8", + env: { ...process.env, ...environment }, + stdio: ["ignore", "pipe", "pipe"], + }); + } } diff --git a/dev/preview/PreviewApplication.ts b/dev/preview/PreviewApplication.ts index 920a2f77..6cbbb2ee 100644 --- a/dev/preview/PreviewApplication.ts +++ b/dev/preview/PreviewApplication.ts @@ -57,6 +57,9 @@ export class PreviewApplication { case PREVIEW_COMMANDS.seedFixtures: await this.seed(); return; + case PREVIEW_COMMANDS.reset: + this.compose.reset(); + return; default: throw new Error(`Unknown preview command: ${command}`); } @@ -70,7 +73,7 @@ export class PreviewApplication { await this.readiness.wait(); this.reloader.publish(); this.admin.ensure(); - await this.fixtureSeeder.seed(); + await this.seedFixtures(); console.log( `Forgejo development preview: ${this.config.previewUrl} (login: ${this.config.previewUser} / ${this.config.previewPassword})` ); @@ -92,6 +95,11 @@ export class PreviewApplication { private async seed(): Promise { await this.readiness.wait(); this.admin.ensure(); - await this.fixtureSeeder.seed(); + await this.seedFixtures(); + } + + /** Seeding restarts Forgejo to edit its database, so wait until it answers again. */ + private async seedFixtures(): Promise { + if (await this.fixtureSeeder.seed()) await this.readiness.wait(); } } diff --git a/dev/preview/config/constants.ts b/dev/preview/config/constants.ts index a70cace0..9d2bc261 100644 --- a/dev/preview/config/constants.ts +++ b/dev/preview/config/constants.ts @@ -17,9 +17,9 @@ export const PREVIEW = { host: "127.0.0.1", port: 3000, healthPath: "/api/healthz", - defaultUser: "whereareiam", + defaultUser: "alex", defaultPassword: "preview", - defaultEmail: "whereareiam@fixtures.local", + defaultEmail: "alex@fixtures.local", defaultTheme: "github-auto", pollIntervalMs: 1_000, maxHealthAttempts: 60, @@ -66,6 +66,7 @@ export const PREVIEW_COMMANDS = { sync: "sync", watch: "watch", seedFixtures: "seed", + reset: "reset", } as const; export const ADMIN_CREATE_ARGS = ["admin", "user", "create"] as const; @@ -75,4 +76,6 @@ export const FIXTURES = { databaseFile: "/var/lib/gitea/data/gitea.db", actionLogDirectory: "/var/lib/gitea/actions_log", localDatabaseFile: "dev/.generated/fixture-gitea.db", + /** For fixture users other than the preview user; Forgejo's API requires at least eight characters. */ + userPassword: "preview-fixture", } as const; diff --git a/dev/preview/fixtures/FixtureActionImporter.ts b/dev/preview/fixtures/FixtureActionImporter.ts deleted file mode 100644 index 769dbf87..00000000 --- a/dev/preview/fixtures/FixtureActionImporter.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { Buffer } from "node:buffer"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { dirname } from "node:path"; - -import { FIXTURES } from "../config/constants.ts"; -import { PreviewConfig } from "../config/PreviewConfig.ts"; -import { ForgejoCompose } from "../forgejo/ForgejoCompose.ts"; -import type { FixtureActions, FixtureHome, FixtureRow } from "./FixtureArchive.ts"; - -type TableName = keyof FixtureActions; - -interface TablePlan { - readonly table: TableName; - readonly parent?: TableName; - readonly parentColumn?: string; -} - -export interface FixtureRepositoryMapping { - readonly sourceId: number; - readonly localId: number; -} - -const TABLE_PLANS: readonly TablePlan[] = [ - { table: "action_run" }, - { table: "action_run_job", parent: "action_run", parentColumn: "run_id" }, - { table: "action_task", parent: "action_run_job", parentColumn: "job_id" }, - { table: "action_task_step", parent: "action_task", parentColumn: "task_id" }, - { table: "action_task_output", parent: "action_task", parentColumn: "task_id" }, - { table: "action_artifact", parent: "action_run", parentColumn: "run_id" }, -]; - -export class FixtureActionImporter { - private readonly config: PreviewConfig; - private readonly compose: ForgejoCompose; - - public constructor(config: PreviewConfig, compose: ForgejoCompose) { - this.config = config; - this.compose = compose; - } - - public import( - actions: FixtureActions, - home: FixtureHome, - repositories: readonly FixtureRepositoryMapping[], - repositoryId: number, - ownerId: number, - logDirectory: string - ): void { - this.compose.stopService(); - try { - this.compose.removeDatabaseSidecars(FIXTURES.databaseFile); - this.copyDatabaseFromContainer(); - this.importDatabase(actions, home, repositories, repositoryId, ownerId); - this.compose.removeDatabaseSidecars(FIXTURES.databaseFile); - this.compose.copyToContainer(this.config.fixtureDatabaseFile, FIXTURES.databaseFile); - this.compose.repairDatabaseOwnership(FIXTURES.databaseFile); - this.copyLogs(logDirectory); - } finally { - this.compose.startService(); - } - } - - private copyDatabaseFromContainer(): void { - mkdirSync(dirname(this.config.fixtureDatabaseFile), { recursive: true }); - rmSync(this.config.fixtureDatabaseFile, { force: true }); - this.compose.copyFromContainer(FIXTURES.databaseFile, this.config.fixtureDatabaseFile); - } - - private importDatabase( - actions: FixtureActions, - home: FixtureHome, - repositories: readonly FixtureRepositoryMapping[], - repositoryId: number, - ownerId: number - ): void { - const database = new SqliteFixtureDatabase(this.config.fixtureDatabaseFile); - database.transaction(() => { - this.deleteExisting(database, repositoryId); - this.deleteGeneratedActivity(database, repositories); - this.restoreUserTimeline(database, home.userCreatedUnix, ownerId); - this.restoreRepositoryTimeline(database, home, repositories); - this.restoreActivity(database, home.actions, repositories, ownerId); - this.restoreCommitEmails(database, home.commitEmails, ownerId); - const identifierMaps = new Map>(); - for (const plan of TABLE_PLANS) - this.insertRows(database, plan, actions[plan.table], identifierMaps, repositoryId, ownerId); - }); - } - - private copyLogs(logDirectory: string): void { - if (!existsSync(logDirectory)) throw new Error(`Missing fixture log directory: ${logDirectory}`); - this.compose.copyToContainer(`${logDirectory}/.`, FIXTURES.actionLogDirectory); - this.compose.repairOwnership(FIXTURES.actionLogDirectory); - } - - private deleteExisting(database: SqliteFixtureDatabase, repositoryId: number): void { - const taskIds = "SELECT id FROM action_task WHERE repo_id = ?"; - database.run(`DELETE FROM action_task_step WHERE task_id IN (${taskIds})`, [repositoryId]); - database.run(`DELETE FROM action_task_output WHERE task_id IN (${taskIds})`, [repositoryId]); - database.run("DELETE FROM action_artifact WHERE repo_id = ?", [repositoryId]); - database.run("DELETE FROM action_task WHERE repo_id = ?", [repositoryId]); - database.run("DELETE FROM action_run_job WHERE repo_id = ?", [repositoryId]); - database.run("DELETE FROM action_run WHERE repo_id = ?", [repositoryId]); - } - - private deleteGeneratedActivity(database: SqliteFixtureDatabase, repositories: readonly FixtureRepositoryMapping[]): void { - database.run( - `DELETE FROM action WHERE repo_id IN (${repositories.map(() => "?").join(", ")})`, - repositories.map(repository => repository.localId) - ); - } - - private restoreRepositoryTimeline( - database: SqliteFixtureDatabase, - home: FixtureHome, - repositories: readonly FixtureRepositoryMapping[] - ): void { - for (const sourceRepository of home.repositories) { - const localRepository = repositories.find(repository => repository.sourceId === sourceRepository.id); - if (!localRepository) throw new Error(`Missing local repository mapping for source repository ${sourceRepository.id}.`); - database.run( - "UPDATE repository SET created_unix = ?, updated_unix = ? WHERE id = ?", - [sourceRepository.created_unix, sourceRepository.updated_unix, localRepository.localId] - ); - } - } - - private restoreUserTimeline(database: SqliteFixtureDatabase, createdUnix: number, ownerId: number): void { - database.run("UPDATE user SET created_unix = ? WHERE id = ?", [createdUnix, ownerId]); - } - - private restoreActivity( - database: SqliteFixtureDatabase, - actions: readonly FixtureRow[], - repositories: readonly FixtureRepositoryMapping[], - ownerId: number - ): void { - const supportedColumns = new Set(database.columnNames("action")); - const identifierOffset = database.maximumIdentifier("action"); - for (const [index, action] of actions.entries()) { - const normalized = { ...action }; - normalized.id = identifierOffset + index + 1; - normalized.user_id = ownerId; - normalized.act_user_id = ownerId; - normalized.repo_id = this.localRepositoryId(action.repo_id, repositories); - const columns = Object.keys(normalized).filter(column => supportedColumns.has(column)); - database.insert( - "action", - columns, - columns.map(column => normalized[column]) - ); - } - } - - private restoreCommitEmails(database: SqliteFixtureDatabase, emails: readonly string[], ownerId: number): void { - for (const email of emails) - database.run( - "INSERT OR IGNORE INTO email_address (uid, email, lower_email, is_activated, is_primary) VALUES (?, ?, ?, ?, ?)", - [ownerId, email, email.toLowerCase(), true, false] - ); - } - - private localRepositoryId(sourceId: unknown, repositories: readonly FixtureRepositoryMapping[]): number { - if (typeof sourceId !== "number") throw new Error("Fixture activity is missing a source repository ID."); - const repository = repositories.find(candidate => candidate.sourceId === sourceId); - if (!repository) throw new Error(`Missing local repository mapping for source repository ${sourceId}.`); - return repository.localId; - } - - private insertRows( - database: SqliteFixtureDatabase, - plan: TablePlan, - rows: readonly FixtureRow[], - identifierMaps: Map>, - repositoryId: number, - ownerId: number - ): void { - const supportedColumns = this.supportedColumns(database, plan.table); - const identifierMap = this.createIdentifierMap(database, plan.table, rows); - identifierMaps.set(plan.table, identifierMap); - - for (const row of rows) { - const normalized = this.normalizeRow(row, plan, identifierMaps, identifierMap, repositoryId, ownerId); - const columns = Object.keys(normalized).filter(column => supportedColumns.has(column)); - if (columns.length === 0) continue; - database.insert( - plan.table, - columns, - columns.map(column => normalized[column]) - ); - } - } - - private supportedColumns(database: SqliteFixtureDatabase, table: TableName): Set { - return new Set(database.columnNames(table)); - } - - private createIdentifierMap( - database: SqliteFixtureDatabase, - table: TableName, - rows: readonly FixtureRow[] - ): Map { - const currentMaximum = database.maximumIdentifier(table); - const identifiers = rows - .map(row => this.number(row.id)) - .filter((identifier): identifier is number => identifier !== undefined); - return new Map(identifiers.map((identifier, index) => [identifier, currentMaximum + index + 1])); - } - - private normalizeRow( - row: FixtureRow, - plan: TablePlan, - identifierMaps: ReadonlyMap>, - identifierMap: ReadonlyMap, - repositoryId: number, - ownerId: number - ): Record { - const normalized: Record = { ...row }; - const logIndexesBase64 = normalized.log_indexes_base64; - if (typeof logIndexesBase64 === "string") { - normalized.log_indexes = Buffer.from(logIndexesBase64, "base64"); - delete normalized.log_indexes_base64; - } - const sourceId = this.number(row.id); - if (sourceId !== undefined) normalized.id = identifierMap.get(sourceId); - if ("repo_id" in normalized) normalized.repo_id = repositoryId; - if ("owner_id" in normalized) normalized.owner_id = ownerId; - for (const column of ["trigger_user_id", "pull_request_poster_id", "approved_by"]) - if (this.number(normalized[column]) !== undefined) normalized[column] = ownerId; - if ("runner_id" in normalized) normalized.runner_id = 0; - if (plan.parent && plan.parentColumn) { - const sourceParentId = this.number(normalized[plan.parentColumn]); - const parentId = sourceParentId === undefined ? undefined : identifierMaps.get(plan.parent)?.get(sourceParentId); - if (parentId === undefined) throw new Error(`Fixture ${plan.table} row has no matching ${plan.parent} parent.`); - normalized[plan.parentColumn] = parentId; - } - return normalized; - } - - private number(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; - } -} - -class SqliteFixtureDatabase { - private readonly file: string; - private readonly statements: string[] = []; - - public constructor(file: string) { - this.file = file; - } - - public transaction(callback: () => void): void { - this.statements.push("PRAGMA foreign_keys = OFF", "BEGIN IMMEDIATE"); - callback(); - this.statements.push("COMMIT"); - execFileSync("sqlite3", [this.file], { input: this.statements.join(";\n"), stdio: ["pipe", "inherit", "inherit"] }); - } - - public run(statement: string, values: readonly unknown[]): void { - this.statements.push(this.bindValues(statement, values)); - } - - public columnNames(table: string): readonly string[] { - const output = execFileSync("sqlite3", [this.file, `SELECT name FROM pragma_table_info('${table}')`], { - encoding: "utf8", - }); - return output.trim().split("\n").filter(Boolean); - } - - public maximumIdentifier(table: string): number { - const output = execFileSync("sqlite3", [this.file, `SELECT coalesce(max(id), 0) FROM ${table}`], { - encoding: "utf8", - }); - return Number(output.trim()); - } - - public insert(table: string, columns: readonly string[], values: readonly unknown[]): void { - this.statements.push( - `INSERT INTO ${this.identifier(table)} (${columns.map(column => this.identifier(column)).join(", ")}) VALUES (${values.map(value => this.literal(value)).join(", ")})` - ); - } - - private bindValues(statement: string, values: readonly unknown[]): string { - let valueIndex = 0; - const bound = statement.replaceAll("?", () => this.literal(values[valueIndex++])); - if (valueIndex !== values.length) throw new Error("Fixture database statement has an unexpected number of values."); - return bound; - } - - private literal(value: unknown): string { - if (value === null || value === undefined) return "NULL"; - if (typeof value === "number") return String(value); - if (typeof value === "boolean") return value ? "1" : "0"; - if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`; - const text = typeof value === "string" ? value : JSON.stringify(value); - return `'${text.replaceAll("'", "''")}'`; - } - - private identifier(value: string): string { - return `"${value.replaceAll('"', '""')}"`; - } -} diff --git a/dev/preview/fixtures/FixtureArchive.test.ts b/dev/preview/fixtures/FixtureArchive.test.ts new file mode 100644 index 00000000..14a885ae --- /dev/null +++ b/dev/preview/fixtures/FixtureArchive.test.ts @@ -0,0 +1,76 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { FixtureActionLog } from "./database/FixtureActionLog.ts"; +import { FixtureArchive } from "./FixtureArchive.ts"; + +const archive = new FixtureArchive(fileURLToPath(new URL("../../fixtures", import.meta.url))); +const owners = archive.owners(); +const users = new Set(owners.users.map(user => user.login)); +const ownerNames = new Set([...users, ...owners.organizations.map(organization => organization.name)]); + +it("declares team members as users", () => { + for (const organization of owners.organizations) + for (const team of organization.teams) for (const member of team.members) expect(users, member).toContain(member); +}); + +describe.each(archive.repositories().map(fixture => [fixture.name, fixture] as const))( + "fixture %s", + (_name, fixture) => { + const description = fixture.description(); + const history = fixture.history(); + const commits = new Set(history.commits.map(commit => commit.id)); + const branches = new Set(history.commits.map(commit => commit.branch)); + + it("is named after its owner and repository", () => { + expect(fixture.name).toBe(`${description.owner}-${description.name}`); + expect(ownerNames).toContain(description.owner); + }); + + it("has files for every commit and known authors", () => { + expect(commits.size).toBe(history.commits.length); + expect(branches).toContain(history.defaultBranch); + for (const commit of history.commits) { + expect(existsSync(fixture.commitDirectory(commit.id))).toBe(true); + expect(users, commit.id).toContain(commit.author); + } + for (const tag of history.tags) expect(commits, tag.name).toContain(tag.commit); + }); + + it("only refers to declared users, labels, milestones and branches", () => { + const labels = new Set(description.labels.map(label => label.name)); + const milestones = new Set(description.milestones.map(milestone => milestone.title)); + for (const user of description.stars) expect(users).toContain(user); + for (const entry of [...description.issues, ...description.pulls]) { + expect(users, entry.title).toContain(entry.author); + for (const label of entry.labels) expect(labels, entry.title).toContain(label); + if (entry.milestone !== null) expect(milestones, entry.title).toContain(entry.milestone); + for (const comment of entry.comments) expect(users, entry.title).toContain(comment.author); + } + for (const issue of description.issues) for (const assignee of issue.assignees) expect(users).toContain(assignee); + for (const pull of description.pulls) { + expect(branches, pull.title).toContain(pull.head); + expect(pull.head, pull.title).not.toBe(history.defaultBranch); + } + }); + + it("gives every workflow run a commit, an actor and log sections for its jobs", () => { + for (const { name, run, log } of fixture.actionRuns()) { + expect(commits, name).toContain(run.commit); + expect(users, name).toContain(run.actor); + if (run.event === "pull_request") + expect( + description.pulls.map(pull => `refs/heads/${pull.head}`), + name + ).toContain(run.ref); + const sections = new FixtureActionLog(log, name); + for (const job of run.jobs) { + const steps = job.steps.map(step => step.name); + for (const section of sections.steps(job.id)) expect(steps, `${name} ${job.id}`).toContain(section.name); + expect(sections.steps(job.id).length, `${name} ${job.id}`).toBeGreaterThan(0); + } + } + }); + } +); diff --git a/dev/preview/fixtures/FixtureArchive.ts b/dev/preview/fixtures/FixtureArchive.ts index 58fa9598..d2e0da21 100644 --- a/dev/preview/fixtures/FixtureArchive.ts +++ b/dev/preview/fixtures/FixtureArchive.ts @@ -1,47 +1,10 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { FixtureDefinition } from "./FixtureDefinition.ts"; - -export interface FixtureSnapshot { - readonly releases: readonly FixtureRelease[]; - readonly actions: FixtureActions; -} - -export interface FixtureHome { - readonly userCreatedUnix: number; - readonly repositories: readonly FixtureRepositoryActivity[]; - readonly actions: readonly FixtureRow[]; - readonly commitEmails: readonly string[]; -} - -export interface FixtureRepositoryActivity { - readonly id: number; - readonly name: string; - readonly created_unix: number; - readonly updated_unix: number; -} - -export interface FixtureRelease { - readonly tag_name: string; - readonly target_commitish: string; - readonly name: string; - readonly body: string; - readonly draft: boolean; - readonly prerelease: boolean; -} - -export interface FixtureActions { - readonly action_run: readonly FixtureRow[]; - readonly action_run_job: readonly FixtureRow[]; - readonly action_task: readonly FixtureRow[]; - readonly action_task_step: readonly FixtureRow[]; - readonly action_task_output: readonly FixtureRow[]; - readonly action_artifact: readonly FixtureRow[]; -} - -export type FixtureRow = Readonly>; +import { FixtureRepositoryDirectory } from "./FixtureRepositoryDirectory.ts"; +import type { FixtureOwners } from "./model/FixtureOwners.ts"; +/** Reads `dev/fixtures`: `owner.json` and one folder per repository under `repository/`. */ export class FixtureArchive { private readonly fixturesDirectory: string; @@ -49,42 +12,19 @@ export class FixtureArchive { this.fixturesDirectory = fixturesDirectory; } - public repositoryDirectory(definition: FixtureDefinition): string { - return join(this.fixturesDirectory, definition.owner, `${definition.repository}.git`); - } - - public snapshotFile(definition: FixtureDefinition): string { - return join(this.fixturesDirectory, definition.owner, `${definition.repository}.json`); - } - - public logDirectory(): string { - return join(this.fixturesDirectory, "logs"); - } - - public homeFile(): string { - return join(this.fixturesDirectory, "home.json"); - } - - public readSnapshot(definition: FixtureDefinition): FixtureSnapshot { - const file = this.snapshotFile(definition); - if (!existsSync(file)) throw new Error(`Missing fixture snapshot: ${file}. Restore the checked-in fixtures first.`); - return JSON.parse(readFileSync(file, "utf8")) as FixtureSnapshot; - } - - public readHome(): FixtureHome { - const file = this.homeFile(); - if (!existsSync(file)) throw new Error(`Missing fixture home activity: ${file}. Restore the checked-in fixtures first.`); - return JSON.parse(readFileSync(file, "utf8")) as FixtureHome; - } - - public assertRepository(definition: FixtureDefinition): void { - const directory = this.repositoryDirectory(definition); - if (!existsSync(directory)) - throw new Error(`Missing fixture repository: ${directory}. Restore the checked-in fixtures first.`); + public owners(): FixtureOwners { + const file = join(this.fixturesDirectory, "owner.json"); + if (!existsSync(file)) throw new Error(`Missing fixture owners: ${file}`); + return JSON.parse(readFileSync(file, "utf8")) as FixtureOwners; } - public assertLogs(): void { - const directory = this.logDirectory(); - if (!existsSync(directory)) throw new Error(`Missing fixture logs: ${directory}. Restore the checked-in fixtures first.`); + public repositories(): readonly FixtureRepositoryDirectory[] { + const directory = join(this.fixturesDirectory, "repository"); + if (!existsSync(directory)) throw new Error(`Missing fixture repositories: ${directory}`); + return readdirSync(directory, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + .map(name => new FixtureRepositoryDirectory(join(directory, name))); } } diff --git a/dev/preview/fixtures/FixtureClock.test.ts b/dev/preview/fixtures/FixtureClock.test.ts new file mode 100644 index 00000000..cc8a29aa --- /dev/null +++ b/dev/preview/fixtures/FixtureClock.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from "vitest"; + +import { FixtureClock } from "./FixtureClock.ts"; + +const clock = new FixtureClock(new Date("2026-10-03T12:30:00Z")); + +it("counts days back from the day of seeding", () => { + expect(clock.at(2, "09:00").toISOString()).toBe("2026-10-01T09:00:00.000Z"); + expect(clock.at(120).toISOString()).toBe("2026-06-05T12:00:00.000Z"); +}); + +it("never returns a time after the moment of seeding", () => { + expect(clock.at(0, "23:00").toISOString()).toBe("2026-10-03T12:30:00.000Z"); + expect(clock.unix(0, "23:00")).toBe(Date.parse("2026-10-03T12:30:00Z") / 1000); +}); diff --git a/dev/preview/fixtures/FixtureClock.ts b/dev/preview/fixtures/FixtureClock.ts new file mode 100644 index 00000000..37cf39bd --- /dev/null +++ b/dev/preview/fixtures/FixtureClock.ts @@ -0,0 +1,21 @@ +const DAY_MS = 86_400_000; + +/** Fixture times are written as days before the seed, so a preview always looks recently used. */ +export class FixtureClock { + private readonly now: Date; + + public constructor(now: Date = new Date()) { + this.now = now; + } + + public at(daysAgo: number, time = "12:00"): Date { + const [hours = 0, minutes = 0] = time.split(":").map(Number); + const midnight = Date.UTC(this.now.getUTCFullYear(), this.now.getUTCMonth(), this.now.getUTCDate()); + const moment = midnight - daysAgo * DAY_MS + (hours * 60 + minutes) * 60_000; + return new Date(Math.min(moment, this.now.getTime())); + } + + public unix(daysAgo: number, time?: string): number { + return Math.floor(this.at(daysAgo, time).getTime() / 1000); + } +} diff --git a/dev/preview/fixtures/FixtureDefinition.ts b/dev/preview/fixtures/FixtureDefinition.ts deleted file mode 100644 index 76b74585..00000000 --- a/dev/preview/fixtures/FixtureDefinition.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type FixtureOwnerType = "organization" | "user"; - -export class FixtureDefinition { - public readonly owner: string; - public readonly repository: string; - public readonly ownerType: FixtureOwnerType; - public readonly displayName: string; - - public constructor(owner: string, repository: string, ownerType: FixtureOwnerType, displayName: string) { - this.owner = owner; - this.repository = repository; - this.ownerType = ownerType; - this.displayName = displayName; - } - - public get identifier(): string { - return `${this.owner}/${this.repository}`; - } -} - -export const FIXTURE_DEFINITIONS = [ - new FixtureDefinition("whereareiam", "identica-docs", "user", "whereareiam"), - new FixtureDefinition("arcadeya", "devops", "organization", "Arcadeya"), -] as const; diff --git a/dev/preview/fixtures/FixtureOwnerSeeder.ts b/dev/preview/fixtures/FixtureOwnerSeeder.ts new file mode 100644 index 00000000..e1095a61 --- /dev/null +++ b/dev/preview/fixtures/FixtureOwnerSeeder.ts @@ -0,0 +1,59 @@ +import { FIXTURES } from "../config/constants.ts"; +import { ForgejoApi } from "./ForgejoApi.ts"; +import type { FixtureOrganization, FixtureOwners, FixtureUser } from "./model/FixtureOwners.ts"; + +interface Identified { + readonly id: number; +} + +const TEAM_UNITS = ["repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.packages", "repo.actions"]; + +/** Creates the fixture users, organizations and teams that do not exist yet. */ +export class FixtureOwnerSeeder { + private readonly api: ForgejoApi; + + public constructor(api: ForgejoApi) { + this.api = api; + } + + public async seed(owners: FixtureOwners): Promise { + for (const user of owners.users) await this.seedUser(user); + for (const organization of owners.organizations) await this.seedOrganization(organization); + } + + private async seedUser(user: FixtureUser): Promise { + if (!(await this.api.find(`/users/${user.login}`))) + await this.api.post("/admin/users", { + username: user.login, + email: user.email, + password: FIXTURES.userPassword, + must_change_password: false, + }); + await this.api.patch(`/admin/users/${user.login}`, { + login_name: user.login, + source_id: 0, + full_name: user.fullName, + }); + } + + private async seedOrganization(organization: FixtureOrganization): Promise { + if (await this.api.find(`/orgs/${organization.name}`)) return; + await this.api.post("/orgs", { + username: organization.name, + full_name: organization.fullName, + description: organization.description, + website: organization.website, + location: organization.location, + visibility: "public", + }); + for (const team of organization.teams) { + const created = await this.api.post(`/orgs/${organization.name}/teams`, { + name: team.name, + permission: team.permission, + includes_all_repositories: true, + units: TEAM_UNITS, + }); + for (const member of team.members) await this.api.put(`/teams/${created.id}/members/${member}`); + } + } +} diff --git a/dev/preview/fixtures/FixtureRepositoryDirectory.ts b/dev/preview/fixtures/FixtureRepositoryDirectory.ts new file mode 100644 index 00000000..46031f94 --- /dev/null +++ b/dev/preview/fixtures/FixtureRepositoryDirectory.ts @@ -0,0 +1,59 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; + +import type { FixtureActionRun } from "./model/FixtureActionRun.ts"; +import type { FixtureHistory } from "./model/FixtureHistory.ts"; +import type { FixtureRepositoryDescription } from "./model/FixtureRepositoryDescription.ts"; + +export interface FixtureActionRunFile { + readonly name: string; + readonly run: FixtureActionRun; + readonly log: string; +} + +/** One `dev/fixtures/repository/`: everything that describes a single fixture repository. */ +export class FixtureRepositoryDirectory { + private readonly directory: string; + + public constructor(directory: string) { + this.directory = directory; + } + + public get name(): string { + return basename(this.directory); + } + + public description(): FixtureRepositoryDescription { + return this.readJson("repository.json"); + } + + public history(): FixtureHistory { + return this.readJson("history.json"); + } + + public commitDirectory(commit: string): string { + const directory = join(this.directory, "commit", commit); + if (!existsSync(directory)) throw new Error(`Fixture ${this.name} has no files for commit ${commit}: ${directory}`); + return directory; + } + + public actionRuns(): readonly FixtureActionRunFile[] { + const directory = join(this.directory, "action"); + if (!existsSync(directory)) return []; + return readdirSync(directory) + .filter(file => file.endsWith(".json")) + .sort() + .map(file => { + const name = file.slice(0, -".json".length); + const log = join(directory, `${name}.log`); + if (!existsSync(log)) throw new Error(`Fixture ${this.name} action ${name} has no log: ${log}`); + return { name, run: this.readJson(join("action", file)), log: readFileSync(log, "utf8") }; + }); + } + + private readJson(file: string): T { + const path = join(this.directory, file); + if (!existsSync(path)) throw new Error(`Missing fixture file: ${path}`); + return JSON.parse(readFileSync(path, "utf8")) as T; + } +} diff --git a/dev/preview/fixtures/FixtureSeeder.ts b/dev/preview/fixtures/FixtureSeeder.ts index 7eeb9cd2..dea5db1b 100644 --- a/dev/preview/fixtures/FixtureSeeder.ts +++ b/dev/preview/fixtures/FixtureSeeder.ts @@ -1,133 +1,62 @@ -import { COMMANDS } from "../config/constants.ts"; +import { PreviewConfig } from "../config/PreviewConfig.ts"; import { CommandRunner } from "../CommandRunner.ts"; import { ForgejoCompose } from "../forgejo/ForgejoCompose.ts"; -import { PreviewConfig } from "../config/PreviewConfig.ts"; -import { FixtureActionImporter } from "./FixtureActionImporter.ts"; -import type { FixtureRepositoryMapping } from "./FixtureActionImporter.ts"; +import { FixtureDatabaseImporter } from "./database/FixtureDatabaseImporter.ts"; import { FixtureArchive } from "./FixtureArchive.ts"; -import type { FixtureHome, FixtureRelease } from "./FixtureArchive.ts"; -import { FIXTURE_DEFINITIONS, FixtureDefinition } from "./FixtureDefinition.ts"; +import { FixtureClock } from "./FixtureClock.ts"; +import { FixtureOwnerSeeder } from "./FixtureOwnerSeeder.ts"; import { ForgejoApi } from "./ForgejoApi.ts"; +import type { FixtureOwners } from "./model/FixtureOwners.ts"; +import { FixtureHistoryBuilder } from "./repository/FixtureHistoryBuilder.ts"; +import { FixtureRepositorySeeder } from "./repository/FixtureRepositorySeeder.ts"; +import { SeededRepository } from "./repository/SeededRepository.ts"; -interface ForgejoUser { - readonly id: number; -} - -interface ForgejoRepository { +interface Identified { readonly id: number; } +/** Fills a fresh preview from `dev/fixtures`: owners, then each repository, then the database-only parts. */ export class FixtureSeeder { - private readonly config: PreviewConfig; - private readonly runner: CommandRunner; private readonly archive: FixtureArchive; private readonly api: ForgejoApi; - private readonly actionImporter: FixtureActionImporter; + private readonly ownerSeeder: FixtureOwnerSeeder; + private readonly repositorySeeder: FixtureRepositorySeeder; + private readonly databaseImporter: FixtureDatabaseImporter; public constructor(config: PreviewConfig, runner: CommandRunner, compose: ForgejoCompose) { - this.config = config; - this.runner = runner; + const clock = new FixtureClock(); this.archive = new FixtureArchive(config.fixturesDirectory); this.api = new ForgejoApi(config); - this.actionImporter = new FixtureActionImporter(config, compose); + this.ownerSeeder = new FixtureOwnerSeeder(this.api); + this.repositorySeeder = new FixtureRepositorySeeder(config, this.api, new FixtureHistoryBuilder(runner, clock)); + this.databaseImporter = new FixtureDatabaseImporter(config, compose, clock); } - public async seed(): Promise { - const home = this.archive.readHome(); - const repositories = [] as FixtureRepositoryMapping[]; - for (const definition of FIXTURE_DEFINITIONS) repositories.push(await this.seedRepository(definition, home)); - this.archive.assertLogs(); - await this.importActions(home, repositories); - } - - private async seedRepository(definition: FixtureDefinition, home: FixtureHome): Promise { - this.archive.assertRepository(definition); - await this.ensureOwner(definition); - const repository = await this.ensureRepository(definition); - this.pushMirror(definition); - await this.seedReleases(definition); - console.log(`Seeded fixture ${definition.identifier}.`); - return { sourceId: this.sourceRepositoryId(definition, home), localId: repository.id }; - } + /** Returns whether anything was seeded; repositories that already exist are left untouched. */ + public async seed(): Promise { + const owners = this.archive.owners(); + await this.ownerSeeder.seed(owners); - private async ensureOwner(definition: FixtureDefinition): Promise { - if (definition.ownerType === "organization") { - if (await this.api.getJson(`/orgs/${definition.owner}`)) return; - await this.api.postJson("/orgs", { username: definition.owner, full_name: definition.displayName }); - return; + const seeded: SeededRepository[] = []; + for (const fixture of this.archive.repositories()) { + const repository = await this.repositorySeeder.seed(fixture, owners); + if (repository) seeded.push(repository); + console.log(`${repository ? "Seeded" : "Kept existing"} fixture ${fixture.name}.`); } + if (seeded.length === 0) return false; + for (const repository of seeded) await this.repositorySeeder.settle(repository); - if (definition.owner === this.config.previewUser) return; - throw new Error(`Fixture user ${definition.owner} must be the configured preview user.`); + this.databaseImporter.import(owners, seeded, await this.userIds(owners)); + return true; } - private async ensureRepository(definition: FixtureDefinition): Promise { - const existing = await this.api.getJson(`/repos/${definition.identifier}`); - if (existing) return existing; - - const path = - definition.ownerType === "organization" - ? `/orgs/${definition.owner}/repos` - : `/admin/users/${definition.owner}/repos`; - const created = await this.api.postJson(path, { - name: definition.repository, - description: `Local ${definition.identifier} fixture`, - private: false, - auto_init: false, - }); - if (!created) { - const repository = await this.api.getJson(`/repos/${definition.identifier}`); - if (repository) return repository; - throw new Error(`Unable to create local fixture repository ${definition.identifier}.`); + private async userIds(owners: FixtureOwners): Promise> { + const identifiers = new Map(); + for (const user of owners.users) { + const found = await this.api.find(`/users/${user.login}`); + if (!found) throw new Error(`Fixture user ${user.login} was not created.`); + identifiers.set(user.login, found.id); } - return created; - } - - private pushMirror(definition: FixtureDefinition): void { - this.runner.run(COMMANDS.git, [ - "-C", - this.archive.repositoryDirectory(definition), - "push", - "--mirror", - `${this.config.previewGitUrl}/${definition.identifier}.git`, - ]); - } - - private async seedReleases(definition: FixtureDefinition): Promise { - for (const release of this.archive.readSnapshot(definition).releases) await this.seedRelease(definition, release); - } - - private async seedRelease(definition: FixtureDefinition, release: FixtureRelease): Promise { - const path = `/repos/${definition.identifier}/releases/tags/${encodeURIComponent(release.tag_name)}`; - if (await this.api.getJson(path)) return; - await this.api.postJson(`/repos/${definition.identifier}/releases`, { - tag_name: release.tag_name, - target_commitish: release.target_commitish, - name: release.name, - body: release.body, - draft: release.draft, - prerelease: release.prerelease, - }); - } - - private async importActions(home: FixtureHome, repositories: readonly FixtureRepositoryMapping[]): Promise { - const definition = FIXTURE_DEFINITIONS[0]; - const repository = await this.api.getJson(`/repos/${definition.identifier}`); - const owner = await this.api.getJson(`/users/${definition.owner}`); - if (!repository || !owner) throw new Error(`Unable to resolve local fixture IDs for ${definition.identifier}.`); - this.actionImporter.import( - this.archive.readSnapshot(definition).actions, - home, - repositories, - repository.id, - owner.id, - this.archive.logDirectory() - ); - } - - private sourceRepositoryId(definition: FixtureDefinition, home: FixtureHome): number { - const repository = home.repositories.find(candidate => candidate.name === definition.repository); - if (!repository) throw new Error(`Missing source repository activity for fixture ${definition.identifier}.`); - return repository.id; + return identifiers; } } diff --git a/dev/preview/fixtures/ForgejoApi.ts b/dev/preview/fixtures/ForgejoApi.ts index ea3a3fb1..ad2a2369 100644 --- a/dev/preview/fixtures/ForgejoApi.ts +++ b/dev/preview/fixtures/ForgejoApi.ts @@ -1,5 +1,6 @@ import { PreviewConfig } from "../config/PreviewConfig.ts"; +/** Calls the preview's API as its administrator; `sudo` performs a request as another fixture user. */ export class ForgejoApi { private readonly config: PreviewConfig; @@ -7,38 +8,68 @@ export class ForgejoApi { this.config = config; } - public async getJson(path: string): Promise { - const response = await this.request(path, "GET"); + public async find(path: string): Promise { + const response = await this.request("GET", `/api/v1${path}`); if (response.status === 404) return undefined; - await this.assertSuccess(response); - return (await response.json()) as T; + return this.json(response); } - public async postJson(path: string, body: unknown): Promise { - const response = await this.request(path, "POST", body); - if (response.status === 409 || response.status === 422) return undefined; - if (response.status === 500) { - const message = await response.text(); - if (message.includes("UNIQUE constraint failed: release.repo_id, release.tag_name")) return undefined; - throw new Error(`Forgejo API request failed (${response.status}): ${message}`); - } - await this.assertSuccess(response); - return (await response.json()) as T; + public async post(path: string, body: unknown, sudo?: string): Promise { + return this.json(await this.request("POST", `/api/v1${path}`, JSON.stringify(body), "application/json", sudo)); + } + + /** A POST whose response has no body worth reading, such as a merge. */ + public async submit(path: string, body: unknown, sudo?: string): Promise { + await this.assertSuccess( + await this.request("POST", `/api/v1${path}`, JSON.stringify(body), "application/json", sudo) + ); + } + + public async patch(path: string, body: unknown, sudo?: string): Promise { + return this.json(await this.request("PATCH", `/api/v1${path}`, JSON.stringify(body), "application/json", sudo)); + } + + public async put(path: string, body?: unknown, sudo?: string): Promise { + const payload = body === undefined ? undefined : JSON.stringify(body); + await this.assertSuccess(await this.request("PUT", `/api/v1${path}`, payload, "application/json", sudo)); } - private async request(path: string, method: string, body?: unknown): Promise { - return fetch(`${this.config.previewUrl}/api/v1${path}`, { - method, - headers: { - authorization: `Basic ${Buffer.from(`${this.config.previewUser}:${this.config.previewPassword}`).toString("base64")}`, - "content-type": "application/json", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); + public async delete(path: string): Promise { + const response = await this.request("DELETE", `/api/v1${path}`); + if (response.status !== 404) await this.assertSuccess(response); + } + + public async attach(path: string, name: string, content: string): Promise { + const form = new FormData(); + form.append("attachment", new Blob([content]), name); + return this.json(await this.request("POST", `/api/v1${path}`, form)); + } + + public async uploadPackage(path: string, content: string): Promise { + await this.assertSuccess(await this.request("PUT", `/api/packages${path}`, content, "application/octet-stream")); + } + + private async request( + method: string, + path: string, + body?: string | FormData, + contentType?: string, + sudo?: string + ): Promise { + const credentials = Buffer.from(`${this.config.previewUser}:${this.config.previewPassword}`).toString("base64"); + const headers: Record = { authorization: `Basic ${credentials}` }; + if (contentType && typeof body === "string") headers["content-type"] = contentType; + if (sudo && sudo !== this.config.previewUser) headers.sudo = sudo; + return fetch(`${this.config.previewUrl}${path}`, { method, headers, body }); + } + + private async json(response: Response): Promise { + await this.assertSuccess(response); + return (await response.json()) as T; } private async assertSuccess(response: Response): Promise { if (response.ok) return; - throw new Error(`Forgejo API request failed (${response.status}): ${await response.text()}`); + throw new Error(`Forgejo ${response.url} failed (${response.status}): ${await response.text()}`); } } diff --git a/dev/preview/fixtures/database/FixtureActionLog.test.ts b/dev/preview/fixtures/database/FixtureActionLog.test.ts new file mode 100644 index 00000000..ffd47cf2 --- /dev/null +++ b/dev/preview/fixtures/database/FixtureActionLog.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "vitest"; + +import { FixtureActionLog } from "./FixtureActionLog.ts"; + +it("splits a log into jobs and steps", () => { + const log = new FixtureActionLog( + "::job build\n::step Set up job\nfirst\nsecond\n::step Complete job\n::job test\n::step run\nthird\n", + "test" + ); + expect(log.steps("build")).toEqual([ + { name: "Set up job", lines: ["first", "second"] }, + { name: "Complete job", lines: [] }, + ]); + expect(log.steps("test")).toEqual([{ name: "run", lines: ["third"] }]); + expect(log.steps("missing")).toEqual([]); +}); + +it("rejects lines outside a job or step", () => { + expect(() => new FixtureActionLog("::step orphan\n", "test")).toThrow("before any ::job"); + expect(() => new FixtureActionLog("::job build\nstray\n", "test")).toThrow("before any ::step"); +}); + +it("encodes line offsets the way Forgejo reads them", () => { + // Two lines of 89 and 57 bytes, as recorded by a real Forgejo 16 task: offsets 0 and 89. + const first = `${"a".repeat(88)}\n`; + const second = `${"b".repeat(56)}\n`; + expect([...FixtureActionLog.indexes([first, second])]).toEqual([0, 178, 1]); +}); diff --git a/dev/preview/fixtures/database/FixtureActionLog.ts b/dev/preview/fixtures/database/FixtureActionLog.ts new file mode 100644 index 00000000..de25f54e --- /dev/null +++ b/dev/preview/fixtures/database/FixtureActionLog.ts @@ -0,0 +1,53 @@ +import { Buffer } from "node:buffer"; + +const JOB_MARKER = "::job "; +const STEP_MARKER = "::step "; + +export interface FixtureActionLogStep { + readonly name: string; + readonly lines: readonly string[]; +} + +/** A run's `.log` file split into its `::job` and `::step` sections. */ +export class FixtureActionLog { + private readonly jobs = new Map(); + + public constructor(text: string, source: string) { + let steps: FixtureActionLogStep[] | undefined; + let lines: string[] | undefined; + for (const line of text.split("\n")) { + if (line.startsWith(JOB_MARKER)) { + steps = []; + lines = undefined; + this.jobs.set(line.slice(JOB_MARKER.length).trim(), steps); + } else if (line.startsWith(STEP_MARKER)) { + if (!steps) throw new Error(`${source}: a ::step line appears before any ::job line.`); + lines = []; + steps.push({ name: line.slice(STEP_MARKER.length).trim(), lines }); + } else if (line !== "") { + if (!lines) throw new Error(`${source}: a log line appears before any ::step line.`); + lines.push(line); + } + } + } + + public steps(job: string): readonly FixtureActionLogStep[] { + return this.jobs.get(job) ?? []; + } + + /** Forgejo stores the byte offset of every log line as a signed varint. */ + public static indexes(lines: readonly string[]): Uint8Array { + const bytes: number[] = []; + let offset = 0; + for (const line of lines) { + let value = offset * 2; + while (value >= 0x80) { + bytes.push((value % 0x80) + 0x80); + value = Math.floor(value / 0x80); + } + bytes.push(value); + offset += Buffer.byteLength(line); + } + return Uint8Array.from(bytes); + } +} diff --git a/dev/preview/fixtures/database/FixtureActionWriter.ts b/dev/preview/fixtures/database/FixtureActionWriter.ts new file mode 100644 index 00000000..6bf39fc9 --- /dev/null +++ b/dev/preview/fixtures/database/FixtureActionWriter.ts @@ -0,0 +1,353 @@ +import { Buffer } from "node:buffer"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { FixtureClock } from "../FixtureClock.ts"; +import type { FixtureActionRunFile } from "../FixtureRepositoryDirectory.ts"; +import type { FixtureActionJob, FixtureActionStatus } from "../model/FixtureActionRun.ts"; +import { SeededRepository } from "../repository/SeededRepository.ts"; +import { FixtureActionLog } from "./FixtureActionLog.ts"; +import { FixtureDatabase } from "./FixtureDatabase.ts"; + +const STATUS: Record = { success: 1, failure: 2, cancelled: 3, skipped: 4 }; +/** Forgejo adds these two steps itself from the log lines before the first and after the last recorded step. */ +const IMPLICIT_STEPS = ["Set up job", "Complete job"]; +const WORKFLOW_DIRECTORY = ".forgejo/workflows"; +/** Forgejo's built-in user that reports workflow results as commit statuses. */ +const ACTIONS_USER_ID = -2; + +interface CommitChecks { + count: number; + failure?: string; + last: string; +} + +/** Replaces a repository's workflow runs with the declared ones and writes their log files. */ +export class FixtureActionWriter { + private readonly database: FixtureDatabase; + private readonly clock: FixtureClock; + private readonly logDirectory: string; + private readonly userIds: ReadonlyMap; + private checks = new Map(); + + public constructor( + database: FixtureDatabase, + clock: FixtureClock, + logDirectory: string, + userIds: ReadonlyMap + ) { + this.database = database; + this.clock = clock; + this.logDirectory = logDirectory; + this.userIds = userIds; + } + + public write(repository: SeededRepository): void { + this.deleteExisting(repository.id); + this.checks = new Map(); + const runs = [...repository.actionRuns].sort( + (a, b) => this.clock.unix(a.run.daysAgo, a.run.time) - this.clock.unix(b.run.daysAgo, b.run.time) + ); + runs.forEach((file, position) => this.writeRun(repository, file, position + 1)); + this.database.run("DELETE FROM action_run_index WHERE group_id = ?", [repository.id]); + if (runs.length > 0) this.database.insert("action_run_index", { group_id: repository.id, max_index: runs.length }); + this.writeCheckSummaries(repository.id); + } + + private writeCheckSummaries(repositoryId: number): void { + for (const [sha, checks] of this.checks) { + this.database.insert("commit_status_summary", { + id: this.database.nextIdentifier("commit_status_summary"), + repo_id: repositoryId, + sha, + state: checks.failure ? "failure" : "success", + target_url: checks.failure ?? checks.last, + }); + this.database.insert("commit_status_index", { + id: this.database.nextIdentifier("commit_status_index"), + repo_id: repositoryId, + sha, + max_index: checks.count, + }); + } + } + + /** Pushing workflow files makes Forgejo queue runs of its own; they would wait for a runner forever. */ + private deleteExisting(repositoryId: number): void { + const tasks = "SELECT id FROM action_task WHERE repo_id = ?"; + this.database.run(`DELETE FROM action_task_step WHERE task_id IN (${tasks})`, [repositoryId]); + this.database.run(`DELETE FROM action_task_output WHERE task_id IN (${tasks})`, [repositoryId]); + for (const table of [ + "action_artifact", + "action_task", + "action_run_job", + "action_run", + "commit_status", + "commit_status_summary", + "commit_status_index", + ]) + this.database.run(`DELETE FROM ${table} WHERE repo_id = ?`, [repositoryId]); + } + + private writeRun(repository: SeededRepository, file: FixtureActionRunFile, index: number): void { + const { run } = file; + const commit = repository.history.commit(run.commit); + const actor = this.userId(run.actor); + const pull = run.event === "pull_request" ? this.pullFor(repository, run.ref) : undefined; + const ref = pull ? `refs/pull/${pull.index}/head` : run.ref; + const created = this.clock.unix(run.daysAgo, run.time); + const log = new FixtureActionLog(file.log, `${repository.identifier} action ${file.name}`); + const runId = this.database.nextIdentifier("action_run"); + + let cursor = created + 1; + for (const [position, job] of run.jobs.entries()) { + const needs = position === 0 ? null : [run.jobs[position - 1]?.id]; + const started = cursor; + cursor = this.writeJob(repository, file, job, log, runId, commit.sha, needs, started); + this.writeCheck( + repository, + file, + job, + commit.sha, + `/${repository.identifier}/actions/runs/${index}/jobs/${position}`, + started, + cursor + ); + cursor += 2; + } + const stopped = cursor - 2; + + this.database.insert("action_run", { + id: runId, + title: run.title, + repo_id: repository.id, + owner_id: repository.ownerId, + workflow_id: run.workflow, + workflow_directory: WORKFLOW_DIRECTORY, + index, + trigger_user_id: actor, + schedule_id: 0, + ref, + commit_sha: commit.sha, + event: run.event, + event_payload: JSON.stringify(this.payload(run.event, ref, commit.sha, run.title, run.actor, pull, repository)), + trigger_event: run.event, + status: this.runStatus(run.jobs), + version: 1, + started: created + 1, + stopped, + previous_duration: 0, + created, + updated: stopped, + notify_email: false, + is_fork_pull_request: false, + pull_request_poster_id: pull ? this.userId(pull.author) : 0, + pull_request_id: 0, + need_approval: false, + approved_by: 0, + concurrency_group: `${ref}_${run.workflow}_${run.event}__auto`, + concurrency_type: 0, + pre_execution_error: "", + pre_execution_error_code: 0, + pre_execution_error_details: "null", + pre_execution_warning_codes: "[]", + pre_execution_warning_details: "[]", + priority: 0, + prioritize: false, + }); + } + + /** Returns the time the job stopped. */ + private writeJob( + repository: SeededRepository, + file: FixtureActionRunFile, + job: FixtureActionJob, + log: FixtureActionLog, + runId: number, + sha: string, + needs: readonly (string | undefined)[] | null, + started: number + ): number { + const jobId = this.database.nextIdentifier("action_run_job"); + const taskId = this.database.nextIdentifier("action_task"); + const sections = log.steps(job.id); + const lines: string[] = []; + let cursor = started; + let stepIndex = 0; + + for (const step of job.steps) { + const section = sections.find(candidate => candidate.name === step.name); + const stepStarted = cursor; + const stepLines = (section?.lines ?? []).map(line => `${this.timestamp(stepStarted)} ${line}\n`); + if (!IMPLICIT_STEPS.includes(step.name)) + this.database.insert("action_task_step", { + id: this.database.nextIdentifier("action_task_step"), + name: step.name, + task_id: taskId, + index: stepIndex++, + repo_id: repository.id, + status: STATUS[step.status], + log_index: lines.length, + log_length: stepLines.length, + started: stepStarted, + stopped: stepStarted + step.seconds, + created: started, + updated: stepStarted + step.seconds, + }); + lines.push(...stepLines); + cursor += step.seconds; + } + + const logFile = `${repository.identifier}/${(taskId % 256).toString(16).padStart(2, "0")}/${taskId}.log`; + const target = join(this.logDirectory, logFile); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, lines.join("")); + const token = createHash("sha256") + .update(`fixture-task-${repository.identifier}-${file.name}-${job.id}`) + .digest("hex"); + const shared = { + repo_id: repository.id, + owner_id: repository.ownerId, + commit_sha: sha, + is_fork_pull_request: false, + }; + + this.database.insert("action_task", { + id: taskId, + job_id: jobId, + attempt: 1, + runner_id: 0, + status: STATUS[job.status], + started, + stopped: cursor, + ...shared, + token_hash: token, + token_salt: "fixture", + token_last_eight: token.slice(-8), + log_filename: logFile, + log_in_storage: true, + log_length: lines.length, + log_size: Buffer.byteLength(lines.join("")), + log_indexes: FixtureActionLog.indexes(lines), + log_expired: false, + runner_request_key: "", + created: started, + updated: cursor, + }); + this.database.insert("action_run_job", { + id: jobId, + run_id: runId, + ...shared, + name: job.id, + attempt: 1, + handle: randomUUID(), + workflow_payload: Buffer.from(this.jobWorkflow(file.run.name, job.id)), + job_id: job.id, + needs: JSON.stringify(needs), + runs_on: JSON.stringify(["docker"]), + task_id: taskId, + status: STATUS[job.status], + started, + stopped: cursor, + created: started, + updated: cursor, + }); + return cursor; + } + + /** The commit status a finished job reports, shown as the check mark beside its commit. */ + private writeCheck( + repository: SeededRepository, + file: FixtureActionRunFile, + job: FixtureActionJob, + sha: string, + url: string, + started: number, + stopped: number + ): void { + const failed = job.status !== "success"; + const checks = this.checks.get(sha) ?? { count: 0, last: url }; + checks.count += 1; + checks.last = url; + if (failed) checks.failure ??= url; + this.checks.set(sha, checks); + const context = `${file.run.name} / ${job.id} (${file.run.event})`; + this.database.insert("commit_status", { + id: this.database.nextIdentifier("commit_status"), + index: checks.count, + repo_id: repository.id, + state: failed ? "failure" : "success", + sha, + target_url: url, + description: `${failed ? "Failing after" : "Successful in"} ${stopped - started}s`, + context_hash: createHash("sha1").update(context).digest("hex"), + context, + creator_id: ACTIONS_USER_ID, + created_unix: started, + updated_unix: stopped, + }); + } + + private runStatus(jobs: readonly FixtureActionJob[]): number { + for (const status of ["failure", "cancelled"] as const) + if (jobs.some(job => job.status === status)) return STATUS[status]; + return STATUS.success; + } + + private payload( + event: string, + ref: string, + sha: string, + title: string, + actor: string, + pull: SeededRepository["issues"][number] | undefined, + repository: SeededRepository + ): unknown { + const sender = { login: actor }; + if (event === "pull_request" && pull) + return { + action: "opened", + number: pull.index, + pull_request: { + number: pull.index, + title: pull.title, + head: { ref: ref, sha }, + base: { ref: repository.history.defaultBranch }, + }, + sender, + }; + return { + ref, + before: "0".repeat(40), + after: sha, + head_commit: { id: sha, message: title }, + pusher: sender, + sender, + }; + } + + private pullFor(repository: SeededRepository, ref: string): SeededRepository["issues"][number] { + const branch = ref.replace("refs/heads/", ""); + const position = repository.description.pulls.findIndex(pull => pull.head === branch); + const pull = repository.issues.filter(issue => issue.kind === "pull")[position]; + if (!pull) + throw new Error(`Fixture ${repository.identifier} has no pull request from ${branch} for its workflow run.`); + return pull; + } + + private jobWorkflow(name: string, job: string): string { + return `name: ${name}\n"on": push\njobs:\n ${job}:\n name: ${job}\n runs-on: docker\n steps:\n - run: "true"\n`; + } + + /** The timestamp layout Forgejo writes in front of every log line. */ + private timestamp(unix: number): string { + return new Date(unix * 1000).toISOString().replace(/\.\d+Z$/, ".0000000Z"); + } + + private userId(login: string): number { + const identifier = this.userIds.get(login); + if (identifier === undefined) throw new Error(`Fixture workflow run refers to unknown user ${login}.`); + return identifier; + } +} diff --git a/dev/preview/fixtures/database/FixtureDatabase.ts b/dev/preview/fixtures/database/FixtureDatabase.ts new file mode 100644 index 00000000..f86b15cf --- /dev/null +++ b/dev/preview/fixtures/database/FixtureDatabase.ts @@ -0,0 +1,101 @@ +import { Buffer } from "node:buffer"; +import { execFileSync } from "node:child_process"; + +interface Column { + readonly name: string; + readonly required: boolean; +} + +export type FixtureDatabaseRow = Readonly>; + +/** + * Collects statements for a copy of the preview's SQLite database and applies them in one transaction. + * Rows are checked against the live schema, so a Forgejo upgrade that renames a column fails here by name. + */ +export class FixtureDatabase { + private readonly file: string; + private readonly statements: string[] = []; + private readonly columns = new Map(); + private readonly identifiers = new Map(); + + public constructor(file: string) { + this.file = file; + } + + public apply(): void { + const script = [ + "PRAGMA foreign_keys = OFF", + "BEGIN IMMEDIATE", + ...this.statements, + "COMMIT", + "PRAGMA wal_checkpoint(TRUNCATE)", + ].join(";\n"); + execFileSync("sqlite3", [this.file], { input: `${script};\n`, stdio: ["pipe", "ignore", "inherit"] }); + } + + public run(statement: string, values: readonly unknown[]): void { + let index = 0; + const bound = statement.replaceAll("?", () => this.literal(values[index++])); + if (index !== values.length) + throw new Error(`Fixture statement expects ${index} values, got ${values.length}: ${statement}`); + this.statements.push(bound); + } + + public insert(table: string, row: FixtureDatabaseRow): void { + const columns = this.tableColumns(table); + const unknown = Object.keys(row).filter(name => !columns.some(column => column.name === name)); + if (unknown.length > 0) + throw new Error( + `Forgejo's ${table} table has no column ${unknown.join(", ")}; update the fixture database writers.` + ); + const missing = columns.filter(column => column.required && !(column.name in row)).map(column => column.name); + if (missing.length > 0) + throw new Error(`Forgejo's ${table} table requires ${missing.join(", ")}; update the fixture database writers.`); + const names = Object.keys(row); + this.statements.push( + `INSERT INTO ${this.quote(table)} (${names.map(name => this.quote(name)).join(", ")}) VALUES (${names.map(name => this.literal(row[name])).join(", ")})` + ); + } + + /** The next free primary key of a table, counting rows queued in this batch. */ + public nextIdentifier(table: string): number { + const current = + this.identifiers.get(table) ?? Number(this.query(`SELECT coalesce(max(id), 0) FROM ${this.quote(table)}`)); + this.identifiers.set(table, current + 1); + return current + 1; + } + + private tableColumns(table: string): readonly Column[] { + const cached = this.columns.get(table); + if (cached) return cached; + const columns = this.query( + `SELECT name, "notnull" AND dflt_value IS NULL AND NOT pk FROM pragma_table_info('${table}')` + ) + .split("\n") + .filter(Boolean) + .map(line => { + const [name = "", required] = line.split("|"); + return { name, required: required === "1" }; + }); + if (columns.length === 0) throw new Error(`Forgejo has no ${table} table; update the fixture database writers.`); + this.columns.set(table, columns); + return columns; + } + + private query(statement: string): string { + return execFileSync("sqlite3", [this.file, statement], { encoding: "utf8" }).trim(); + } + + private literal(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "1" : "0"; + if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`; + const text = typeof value === "string" ? value : JSON.stringify(value); + return `'${text.replaceAll("'", "''")}'`; + } + + private quote(name: string): string { + return `"${name.replaceAll('"', '""')}"`; + } +} diff --git a/dev/preview/fixtures/database/FixtureDatabaseImporter.ts b/dev/preview/fixtures/database/FixtureDatabaseImporter.ts new file mode 100644 index 00000000..4c234070 --- /dev/null +++ b/dev/preview/fixtures/database/FixtureDatabaseImporter.ts @@ -0,0 +1,71 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { FIXTURES } from "../../config/constants.ts"; +import { PreviewConfig } from "../../config/PreviewConfig.ts"; +import { ForgejoCompose } from "../../forgejo/ForgejoCompose.ts"; +import { FixtureClock } from "../FixtureClock.ts"; +import type { FixtureOwners } from "../model/FixtureOwners.ts"; +import { SeededRepository } from "../repository/SeededRepository.ts"; +import { FixtureActionWriter } from "./FixtureActionWriter.ts"; +import { FixtureDatabase } from "./FixtureDatabase.ts"; +import { FixtureTimelineWriter } from "./FixtureTimelineWriter.ts"; + +/** + * Writes what Forgejo's API cannot create: past timestamps, the activity feed and finished workflow runs. + * Forgejo is stopped while its SQLite database is edited on a local copy. + */ +export class FixtureDatabaseImporter { + private readonly config: PreviewConfig; + private readonly compose: ForgejoCompose; + private readonly clock: FixtureClock; + + public constructor(config: PreviewConfig, compose: ForgejoCompose, clock: FixtureClock) { + this.config = config; + this.compose = compose; + this.clock = clock; + } + + public import( + owners: FixtureOwners, + repositories: readonly SeededRepository[], + userIds: ReadonlyMap + ): void { + const databaseCopy = this.config.fixtureDatabaseFile; + const logs = join(dirname(databaseCopy), "fixture-logs"); + this.compose.stopService(); + try { + mkdirSync(dirname(databaseCopy), { recursive: true }); + rmSync(databaseCopy, { force: true }); + rmSync(logs, { recursive: true, force: true }); + mkdirSync(logs, { recursive: true }); + this.copyDatabase(databaseCopy); + + const database = new FixtureDatabase(databaseCopy); + const timeline = new FixtureTimelineWriter(database, this.clock, userIds); + const actions = new FixtureActionWriter(database, this.clock, logs, userIds); + timeline.writeOwners(owners); + for (const repository of repositories) { + timeline.writeRepository(repository); + actions.write(repository); + } + database.apply(); + + this.compose.removeDatabaseSidecars(FIXTURES.databaseFile); + this.compose.copyToContainer(databaseCopy, FIXTURES.databaseFile); + this.compose.repairOwnership(FIXTURES.databaseFile); + this.compose.copyToContainer(`${logs}/.`, FIXTURES.actionLogDirectory); + this.compose.repairOwnership(FIXTURES.actionLogDirectory); + } finally { + this.compose.startService(); + } + } + + /** Forgejo's latest writes may still sit in SQLite's write-ahead log, so that file is copied along. */ + private copyDatabase(databaseCopy: string): void { + for (const suffix of ["-wal", "-shm"]) rmSync(`${databaseCopy}${suffix}`, { force: true }); + this.compose.copyFromContainer(FIXTURES.databaseFile, databaseCopy); + for (const suffix of ["-wal", "-shm"]) + this.compose.copyFromContainerIfPresent(`${FIXTURES.databaseFile}${suffix}`, `${databaseCopy}${suffix}`); + } +} diff --git a/dev/preview/fixtures/database/FixtureTimelineWriter.ts b/dev/preview/fixtures/database/FixtureTimelineWriter.ts new file mode 100644 index 00000000..81b7bdc3 --- /dev/null +++ b/dev/preview/fixtures/database/FixtureTimelineWriter.ts @@ -0,0 +1,209 @@ +import { FixtureClock } from "../FixtureClock.ts"; +import type { FixtureOwners } from "../model/FixtureOwners.ts"; +import type { BuiltCommit } from "../repository/BuiltHistory.ts"; +import { SeededRepository } from "../repository/SeededRepository.ts"; +import { FixtureDatabase } from "./FixtureDatabase.ts"; + +/** Forgejo's activity operation types (models/activities/action.go). */ +const OPERATION = { + createRepository: 1, + commit: 5, + createIssue: 6, + createPull: 7, + pushTag: 9, + mergePull: 11, + closeIssue: 12, + closePull: 14, + publishRelease: 24, +} as const; + +/** Forgejo's comment types for the events that end an issue or pull request (models/issues/comment.go). */ +const COMMENT_TYPE = { close: 2, mergePull: 28 } as const; + +/** + * The API stamps everything it creates with the current time. This moves the declared `daysAgo` values into + * the database and rebuilds each repository's activity feed from them, so lists, feeds and the contribution + * heatmap look like months of use. + */ +export class FixtureTimelineWriter { + private readonly database: FixtureDatabase; + private readonly clock: FixtureClock; + private readonly userIds: ReadonlyMap; + + public constructor(database: FixtureDatabase, clock: FixtureClock, userIds: ReadonlyMap) { + this.database = database; + this.clock = clock; + this.userIds = userIds; + } + + public writeOwners(owners: FixtureOwners): void { + for (const user of owners.users) + this.database.run("UPDATE user SET created_unix = ? WHERE lower_name = ?", [ + this.clock.unix(user.joinedDaysAgo), + user.login.toLowerCase(), + ]); + } + + public writeRepository(repository: SeededRepository): void { + const created = this.clock.unix(repository.description.createdDaysAgo, "08:00"); + const updated = Math.floor(repository.history.latest().date.getTime() / 1000); + this.database.run("UPDATE repository SET created_unix = ?, updated_unix = ? WHERE id = ?", [ + created, + updated, + repository.id, + ]); + + for (const issue of repository.issues) { + const opened = this.clock.unix(issue.daysAgo); + const closed = this.clock.unix(Math.max(issue.daysAgo - 1, 0)); + this.database.run( + 'UPDATE issue SET created_unix = ?, updated_unix = ?, closed_unix = CASE WHEN is_closed THEN ? ELSE closed_unix END WHERE repo_id = ? AND "index" = ?', + [opened, issue.state === "open" ? opened : closed, closed, repository.id, issue.index] + ); + } + this.writeTimelineEvents(repository); + for (const comment of repository.comments) { + const time = this.clock.unix(comment.daysAgo, "15:00"); + this.database.run("UPDATE comment SET created_unix = ?, updated_unix = ? WHERE id = ?", [time, time, comment.id]); + } + for (const release of repository.releases) + this.database.run("UPDATE release SET created_unix = ? WHERE id = ?", [ + this.clock.unix(release.daysAgo, "16:00"), + release.id, + ]); + + this.writeActivity(repository, created); + } + + /** Forgejo records label, milestone, push, close and merge events as comments stamped with the seed time. */ + private writeTimelineEvents(repository: SeededRepository): void { + const issue = 'SELECT id FROM issue WHERE repo_id = ? AND "index" = ?'; + for (const entry of repository.issues) { + const opened = this.clock.unix(entry.daysAgo); + const closed = this.clock.unix(Math.max(entry.daysAgo - 1, 0)); + this.database.run(`UPDATE comment SET created_unix = ?, updated_unix = ? WHERE issue_id IN (${issue})`, [ + opened, + opened, + repository.id, + entry.index, + ]); + this.database.run( + `UPDATE comment SET created_unix = ?, updated_unix = ? WHERE type IN (${COMMENT_TYPE.close}, ${COMMENT_TYPE.mergePull}) AND issue_id IN (${issue})`, + [closed, closed, repository.id, entry.index] + ); + } + } + + private writeActivity(repository: SeededRepository, created: number): void { + this.database.run("DELETE FROM action WHERE repo_id = ?", [repository.id]); + const author = (login: string): number => this.userId(login); + const firstAuthor = repository.history.commits[0]?.author ?? ""; + this.record(repository, OPERATION.createRepository, author(firstAuthor), created, "", ""); + + for (const commit of repository.history.commits) + this.record( + repository, + OPERATION.commit, + author(commit.author), + this.unix(commit), + `refs/heads/${commit.branch}`, + this.pushContent([commit], commit) + ); + for (const [tag, commit] of repository.history.tags) + this.record( + repository, + OPERATION.pushTag, + author(commit.author), + this.unix(commit) + 60, + `refs/tags/${tag}`, + this.pushContent([], commit) + ); + + for (const issue of repository.issues) { + const content = `${issue.index}|${issue.title}`; + const pull = issue.kind === "pull"; + const opened = this.clock.unix(issue.daysAgo); + const closed = this.clock.unix(Math.max(issue.daysAgo - 1, 0)); + this.record( + repository, + pull ? OPERATION.createPull : OPERATION.createIssue, + author(issue.author), + opened, + "", + content + ); + if (issue.state === "merged") + this.record(repository, OPERATION.mergePull, author(issue.author), closed, "", content); + if (issue.state === "closed") + this.record( + repository, + pull ? OPERATION.closePull : OPERATION.closeIssue, + author(issue.author), + closed, + "", + content + ); + } + for (const release of repository.releases.filter(candidate => !candidate.draft)) + this.record( + repository, + OPERATION.publishRelease, + author(firstAuthor), + this.clock.unix(release.daysAgo, "16:00"), + release.tag, + release.name + ); + } + + /** Forgejo keeps one activity row per feed it appears in: the actor's, the owner's and each fixture user's. */ + private record( + repository: SeededRepository, + operation: number, + actor: number, + time: number, + ref: string, + content: string + ): void { + for (const feed of new Set([actor, repository.ownerId, ...this.userIds.values()])) + this.database.insert("action", { + id: this.database.nextIdentifier("action"), + user_id: feed, + op_type: operation, + act_user_id: actor, + repo_id: repository.id, + comment_id: 0, + ref_name: ref, + is_private: false, + content, + created_unix: time, + }); + } + + private pushContent(commits: readonly BuiltCommit[], head: BuiltCommit): string { + const entry = (commit: BuiltCommit) => ({ + Sha1: commit.sha, + Message: commit.message, + AuthorEmail: `${commit.author}@fixtures.local`, + AuthorName: commit.author, + CommitterEmail: `${commit.author}@fixtures.local`, + CommitterName: commit.author, + Timestamp: commit.date.toISOString(), + }); + return JSON.stringify({ + Commits: commits.map(entry), + HeadCommit: entry(head), + CompareURL: "", + Len: commits.length, + }); + } + + private unix(commit: BuiltCommit): number { + return Math.floor(commit.date.getTime() / 1000); + } + + private userId(login: string): number { + const identifier = this.userIds.get(login); + if (identifier === undefined) throw new Error(`Fixture activity refers to unknown user ${login}.`); + return identifier; + } +} diff --git a/dev/preview/fixtures/model/FixtureActionRun.ts b/dev/preview/fixtures/model/FixtureActionRun.ts new file mode 100644 index 00000000..1150fe24 --- /dev/null +++ b/dev/preview/fixtures/model/FixtureActionRun.ts @@ -0,0 +1,30 @@ +export type FixtureActionStatus = "success" | "failure" | "cancelled" | "skipped"; + +/** One workflow run. Its log sits beside it under the same name, split by `::job ` and `::step ` lines. */ +export interface FixtureActionRun { + /** The workflow's display name, as in its `name:` key. */ + readonly name: string; + /** The workflow's file name under `.forgejo/workflows`. */ + readonly workflow: string; + readonly title: string; + readonly event: "push" | "pull_request"; + readonly ref: string; + readonly commit: string; + readonly actor: string; + readonly daysAgo: number; + readonly time: string; + readonly jobs: readonly FixtureActionJob[]; +} + +export interface FixtureActionJob { + readonly id: string; + readonly status: FixtureActionStatus; + readonly steps: readonly FixtureActionStep[]; +} + +/** `Set up job` and `Complete job` are the steps Forgejo adds itself; list them to give them log lines and a duration. */ +export interface FixtureActionStep { + readonly name: string; + readonly status: FixtureActionStatus; + readonly seconds: number; +} diff --git a/dev/preview/fixtures/model/FixtureHistory.ts b/dev/preview/fixtures/model/FixtureHistory.ts new file mode 100644 index 00000000..e60b6efc --- /dev/null +++ b/dev/preview/fixtures/model/FixtureHistory.ts @@ -0,0 +1,20 @@ +export interface FixtureHistory { + readonly defaultBranch: string; + readonly commits: readonly FixtureCommit[]; + readonly tags: readonly FixtureTag[]; +} + +/** A commit applies the files under `commit//`. A branch seen for the first time starts at the default branch's tip. */ +export interface FixtureCommit { + readonly id: string; + readonly branch: string; + readonly author: string; + readonly daysAgo: number; + readonly time: string; + readonly message: string; +} + +export interface FixtureTag { + readonly name: string; + readonly commit: string; +} diff --git a/dev/preview/fixtures/model/FixtureOwners.ts b/dev/preview/fixtures/model/FixtureOwners.ts new file mode 100644 index 00000000..b843c588 --- /dev/null +++ b/dev/preview/fixtures/model/FixtureOwners.ts @@ -0,0 +1,26 @@ +export interface FixtureOwners { + readonly users: readonly FixtureUser[]; + readonly organizations: readonly FixtureOrganization[]; +} + +export interface FixtureUser { + readonly login: string; + readonly fullName: string; + readonly email: string; + readonly joinedDaysAgo: number; +} + +export interface FixtureOrganization { + readonly name: string; + readonly fullName: string; + readonly description: string; + readonly website: string; + readonly location: string; + readonly teams: readonly FixtureTeam[]; +} + +export interface FixtureTeam { + readonly name: string; + readonly permission: "read" | "write" | "admin"; + readonly members: readonly string[]; +} diff --git a/dev/preview/fixtures/model/FixtureRepositoryDescription.ts b/dev/preview/fixtures/model/FixtureRepositoryDescription.ts new file mode 100644 index 00000000..a2f2cb9c --- /dev/null +++ b/dev/preview/fixtures/model/FixtureRepositoryDescription.ts @@ -0,0 +1,81 @@ +export interface FixtureRepositoryDescription { + readonly owner: string; + readonly name: string; + readonly description: string; + readonly website: string; + readonly topics: readonly string[]; + readonly createdDaysAgo: number; + readonly stars: readonly string[]; + readonly labels: readonly FixtureLabel[]; + readonly milestones: readonly FixtureMilestone[]; + readonly issues: readonly FixtureIssue[]; + readonly pulls: readonly FixturePull[]; + readonly releases: readonly FixtureRelease[]; + readonly packages: readonly FixturePackage[]; +} + +export interface FixtureLabel { + readonly name: string; + readonly color: string; + readonly description: string; +} + +export interface FixtureMilestone { + readonly title: string; + readonly description: string; + readonly state: "open" | "closed"; + readonly dueInDays: number; +} + +export interface FixtureComment { + readonly author: string; + readonly daysAgo: number; + readonly body: string; +} + +export interface FixtureIssue { + readonly title: string; + readonly body: string; + readonly author: string; + readonly labels: readonly string[]; + readonly milestone: string | null; + readonly assignees: readonly string[]; + readonly state: "open" | "closed"; + readonly daysAgo: number; + readonly comments: readonly FixtureComment[]; +} + +export interface FixturePull { + readonly title: string; + readonly body: string; + readonly author: string; + readonly head: string; + readonly labels: readonly string[]; + readonly milestone: string | null; + readonly state: "open" | "closed" | "merged"; + readonly daysAgo: number; + readonly comments: readonly FixtureComment[]; +} + +export interface FixtureRelease { + readonly tag: string; + readonly name: string; + readonly body: string; + readonly draft: boolean; + readonly prerelease: boolean; + readonly daysAgo: number; + readonly assets: readonly FixtureFile[]; +} + +export interface FixtureFile { + readonly name: string; + readonly content: string; +} + +export interface FixturePackage { + readonly type: "generic"; + readonly name: string; + readonly version: string; + readonly file: string; + readonly content: string; +} diff --git a/dev/preview/fixtures/repository/BuiltHistory.ts b/dev/preview/fixtures/repository/BuiltHistory.ts new file mode 100644 index 00000000..f830930c --- /dev/null +++ b/dev/preview/fixtures/repository/BuiltHistory.ts @@ -0,0 +1,31 @@ +export interface BuiltCommit { + readonly id: string; + readonly sha: string; + readonly branch: string; + readonly author: string; + readonly message: string; + readonly date: Date; +} + +/** A fixture history after it has been turned into real commits. */ +export class BuiltHistory { + public readonly defaultBranch: string; + public readonly commits: readonly BuiltCommit[]; + public readonly tags: ReadonlyMap; + + public constructor(defaultBranch: string, commits: readonly BuiltCommit[], tags: ReadonlyMap) { + this.defaultBranch = defaultBranch; + this.commits = commits; + this.tags = tags; + } + + public commit(id: string): BuiltCommit { + const commit = this.commits.find(candidate => candidate.id === id); + if (!commit) throw new Error(`Fixture history has no commit ${id}.`); + return commit; + } + + public latest(): BuiltCommit { + return this.commits.reduce((latest, commit) => (commit.date > latest.date ? commit : latest)); + } +} diff --git a/dev/preview/fixtures/repository/FixtureHistoryBuilder.ts b/dev/preview/fixtures/repository/FixtureHistoryBuilder.ts new file mode 100644 index 00000000..9c5d943d --- /dev/null +++ b/dev/preview/fixtures/repository/FixtureHistoryBuilder.ts @@ -0,0 +1,91 @@ +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { COMMANDS } from "../../config/constants.ts"; +import { CommandRunner } from "../../CommandRunner.ts"; +import { FixtureClock } from "../FixtureClock.ts"; +import { FixtureRepositoryDirectory } from "../FixtureRepositoryDirectory.ts"; +import type { FixtureUser } from "../model/FixtureOwners.ts"; +import { BuiltHistory } from "./BuiltHistory.ts"; +import type { BuiltCommit } from "./BuiltHistory.ts"; + +/** Replays a fixture's commit folders into a temporary Git repository and pushes it to the preview. */ +export class FixtureHistoryBuilder { + private readonly runner: CommandRunner; + private readonly clock: FixtureClock; + + public constructor(runner: CommandRunner, clock: FixtureClock) { + this.runner = runner; + this.clock = clock; + } + + public push( + fixture: FixtureRepositoryDirectory, + users: ReadonlyMap, + remote: string + ): BuiltHistory { + const checkout = mkdtempSync(join(tmpdir(), "forgejo-fixture-")); + try { + const history = this.build(fixture, users, checkout); + this.git(checkout, ["push", "--quiet", "--mirror", remote]); + return history; + } finally { + rmSync(checkout, { recursive: true, force: true }); + } + } + + private build( + fixture: FixtureRepositoryDirectory, + users: ReadonlyMap, + checkout: string + ): BuiltHistory { + const source = fixture.history(); + this.git(checkout, ["init", "--quiet", "--initial-branch", source.defaultBranch]); + const branches = new Set([source.defaultBranch]); + const commits: BuiltCommit[] = []; + + for (const commit of source.commits) { + const author = users.get(commit.author); + if (!author) throw new Error(`Fixture ${fixture.name} commit ${commit.id} has unknown author ${commit.author}.`); + if (commits.length > 0) this.checkout(checkout, commit.branch, branches, source.defaultBranch); + cpSync(fixture.commitDirectory(commit.id), checkout, { recursive: true }); + const date = this.clock.at(commit.daysAgo, commit.time); + const identity = { + GIT_AUTHOR_NAME: author.fullName, + GIT_AUTHOR_EMAIL: author.email, + GIT_AUTHOR_DATE: date.toISOString(), + GIT_COMMITTER_NAME: author.fullName, + GIT_COMMITTER_EMAIL: author.email, + GIT_COMMITTER_DATE: date.toISOString(), + }; + this.git(checkout, ["add", "--all"]); + this.git(checkout, ["commit", "--quiet", "--no-gpg-sign", "--message", commit.message], identity); + const sha = this.git(checkout, ["rev-parse", "HEAD"]).trim(); + commits.push({ id: commit.id, sha, branch: commit.branch, author: commit.author, message: commit.message, date }); + } + + const tags = new Map(); + for (const tag of source.tags) { + const commit = commits.find(candidate => candidate.id === tag.commit); + if (!commit) throw new Error(`Fixture ${fixture.name} tag ${tag.name} points at unknown commit ${tag.commit}.`); + this.git(checkout, ["tag", tag.name, commit.sha]); + tags.set(tag.name, commit); + } + this.git(checkout, ["checkout", "--quiet", source.defaultBranch]); + return new BuiltHistory(source.defaultBranch, commits, tags); + } + + private checkout(checkout: string, branch: string, branches: Set, defaultBranch: string): void { + if (branches.has(branch)) { + this.git(checkout, ["checkout", "--quiet", branch]); + return; + } + this.git(checkout, ["checkout", "--quiet", "-b", branch, defaultBranch]); + branches.add(branch); + } + + private git(checkout: string, args: readonly string[], environment: NodeJS.ProcessEnv = {}): string { + return this.runner.captureIn(checkout, COMMANDS.git, args, environment); + } +} diff --git a/dev/preview/fixtures/repository/FixtureRepositorySeeder.ts b/dev/preview/fixtures/repository/FixtureRepositorySeeder.ts new file mode 100644 index 00000000..fa38e665 --- /dev/null +++ b/dev/preview/fixtures/repository/FixtureRepositorySeeder.ts @@ -0,0 +1,246 @@ +import { PreviewConfig } from "../../config/PreviewConfig.ts"; +import { FixtureRepositoryDirectory } from "../FixtureRepositoryDirectory.ts"; +import { ForgejoApi } from "../ForgejoApi.ts"; +import type { FixtureOwners, FixtureUser } from "../model/FixtureOwners.ts"; +import type { + FixtureComment, + FixtureIssue, + FixturePull, + FixtureRepositoryDescription, +} from "../model/FixtureRepositoryDescription.ts"; +import { FixtureHistoryBuilder } from "./FixtureHistoryBuilder.ts"; +import { SeededRepository } from "./SeededRepository.ts"; + +interface Identified { + readonly id: number; +} + +interface Numbered extends Identified { + readonly number: number; +} + +const DAY_MS = 86_400_000; +const MERGE_ATTEMPTS = 20; +const SETTLE_ATTEMPTS = 60; + +/** Creates one fixture repository and everything declared for it through the preview's API. */ +export class FixtureRepositorySeeder { + private readonly config: PreviewConfig; + private readonly api: ForgejoApi; + private readonly historyBuilder: FixtureHistoryBuilder; + + public constructor(config: PreviewConfig, api: ForgejoApi, historyBuilder: FixtureHistoryBuilder) { + this.config = config; + this.api = api; + this.historyBuilder = historyBuilder; + } + + /** Returns nothing when the repository already exists: a fixture is seeded once per preview volume. */ + public async seed(fixture: FixtureRepositoryDirectory, owners: FixtureOwners): Promise { + const description = fixture.description(); + const path = `/repos/${description.owner}/${description.name}`; + if (await this.api.find(path)) return undefined; + + const owner = await this.api.find(`/users/${description.owner}`); + if (!owner) throw new Error(`Fixture ${fixture.name} belongs to unknown owner ${description.owner}.`); + const created = await this.create(description, owners); + try { + const users = new Map(owners.users.map(user => [user.login, user])); + const remote = `${this.config.previewGitUrl}/${description.owner}/${description.name}.git`; + const history = this.historyBuilder.push(fixture, users, remote); + const seeded = new SeededRepository(created.id, owner.id, description, history, fixture.actionRuns()); + await this.api.patch(path, { website: description.website, default_branch: history.defaultBranch }); + await this.api.put(`${path}/topics`, { topics: description.topics }); + for (const user of description.stars) + await this.api.put(`/user/starred/${description.owner}/${description.name}`, undefined, user); + const labels = await this.seedLabels(path, description); + const milestones = await this.seedMilestones(path, description); + for (const issue of description.issues) await this.seedIssue(path, issue, labels, milestones, seeded); + for (const pull of description.pulls) await this.seedPull(path, pull, labels, milestones, seeded); + await this.seedReleases(path, seeded); + await this.seedPackages(description); + return seeded; + } catch (error) { + await this.api.delete(path); + throw error; + } + } + + /** + * Forgejo handles pushes and merges in a background queue. Wait until a repository's activity stops growing, + * or that work would run after the database step and stamp everything with the current time again. + */ + public async settle(repository: SeededRepository): Promise { + let previous = -1; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt++) { + const feed = await this.api.find(`/repos/${repository.identifier}/activities/feeds?limit=50`); + const current = feed?.length ?? 0; + if (current > 0 && current === previous) return; + previous = current; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + throw new Error(`Fixture ${repository.identifier} was still being processed by Forgejo after ${SETTLE_ATTEMPTS}s.`); + } + + private create(description: FixtureRepositoryDescription, owners: FixtureOwners): Promise { + const organization = owners.organizations.some(candidate => candidate.name === description.owner); + const path = organization ? `/orgs/${description.owner}/repos` : `/admin/users/${description.owner}/repos`; + return this.api.post(path, { + name: description.name, + description: description.description, + private: false, + auto_init: false, + }); + } + + private async seedLabels(path: string, description: FixtureRepositoryDescription): Promise> { + const labels = new Map(); + for (const label of description.labels) { + const created = await this.api.post(`${path}/labels`, { + name: label.name, + color: `#${label.color}`, + description: label.description, + }); + labels.set(label.name, created.id); + } + return labels; + } + + private async seedMilestones(path: string, description: FixtureRepositoryDescription): Promise> { + const milestones = new Map(); + for (const milestone of description.milestones) { + const created = await this.api.post(`${path}/milestones`, { + title: milestone.title, + description: milestone.description, + due_on: new Date(Date.now() + milestone.dueInDays * DAY_MS).toISOString(), + state: milestone.state, + }); + milestones.set(milestone.title, created.id); + } + return milestones; + } + + private async seedIssue( + path: string, + issue: FixtureIssue, + labels: ReadonlyMap, + milestones: ReadonlyMap, + seeded: SeededRepository + ): Promise { + const created = await this.api.post( + `${path}/issues`, + { + title: issue.title, + body: issue.body, + labels: this.identifiers(issue.labels, labels, "label"), + milestone: issue.milestone === null ? 0 : this.identifier(issue.milestone, milestones, "milestone"), + assignees: issue.assignees, + }, + issue.author + ); + await this.seedComments(path, created.number, issue.comments, seeded); + if (issue.state === "closed") await this.api.patch(`${path}/issues/${created.number}`, { state: "closed" }); + seeded.issues.push({ ...this.summary(created.number, issue), kind: "issue" }); + } + + private async seedPull( + path: string, + pull: FixturePull, + labels: ReadonlyMap, + milestones: ReadonlyMap, + seeded: SeededRepository + ): Promise { + const created = await this.api.post( + `${path}/pulls`, + { + title: pull.title, + body: pull.body, + head: pull.head, + base: seeded.history.defaultBranch, + labels: this.identifiers(pull.labels, labels, "label"), + milestone: pull.milestone === null ? 0 : this.identifier(pull.milestone, milestones, "milestone"), + }, + pull.author + ); + await this.seedComments(path, created.number, pull.comments, seeded); + if (pull.state === "merged") await this.merge(`${path}/pulls/${created.number}/merge`); + if (pull.state === "closed") await this.api.patch(`${path}/pulls/${created.number}`, { state: "closed" }); + seeded.issues.push({ ...this.summary(created.number, pull), kind: "pull" }); + } + + /** Forgejo checks mergeability in the background, so a new pull request is not mergeable at once. */ + private async merge(path: string): Promise { + for (let attempt = 1; ; attempt++) { + try { + await this.api.submit(path, { Do: "merge" }); + return; + } catch (error) { + if (attempt === MERGE_ATTEMPTS) throw error; + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + } + + private async seedComments( + path: string, + index: number, + comments: readonly FixtureComment[], + seeded: SeededRepository + ): Promise { + for (const comment of comments) { + const created = await this.api.post( + `${path}/issues/${index}/comments`, + { body: comment.body }, + comment.author + ); + seeded.comments.push({ id: created.id, daysAgo: comment.daysAgo }); + } + } + + private async seedReleases(path: string, seeded: SeededRepository): Promise { + for (const release of seeded.description.releases) { + const created = await this.api.post(`${path}/releases`, { + tag_name: release.tag, + target_commitish: seeded.history.defaultBranch, + name: release.name, + body: release.body, + draft: release.draft, + prerelease: release.prerelease, + }); + for (const asset of release.assets) + await this.api.attach( + `${path}/releases/${created.id}/assets?name=${encodeURIComponent(asset.name)}`, + asset.name, + asset.content + ); + seeded.releases.push({ + id: created.id, + tag: release.tag, + name: release.name, + draft: release.draft, + daysAgo: release.daysAgo, + }); + } + } + + private async seedPackages(description: FixtureRepositoryDescription): Promise { + for (const entry of description.packages) { + const segments = [description.owner, entry.type, entry.name, entry.version, entry.file].map(encodeURIComponent); + await this.api.uploadPackage(`/${segments.join("/")}`, entry.content); + } + } + + private summary(index: number, source: FixtureIssue | FixturePull): Omit { + return { index, title: source.title, author: source.author, state: source.state, daysAgo: source.daysAgo }; + } + + private identifiers(names: readonly string[], known: ReadonlyMap, kind: string): number[] { + return names.map(name => this.identifier(name, known, kind)); + } + + private identifier(name: string, known: ReadonlyMap, kind: string): number { + const identifier = known.get(name); + if (identifier === undefined) throw new Error(`Fixture refers to undeclared ${kind} "${name}".`); + return identifier; + } +} diff --git a/dev/preview/fixtures/repository/SeededRepository.ts b/dev/preview/fixtures/repository/SeededRepository.ts new file mode 100644 index 00000000..4441609f --- /dev/null +++ b/dev/preview/fixtures/repository/SeededRepository.ts @@ -0,0 +1,55 @@ +import type { FixtureActionRunFile } from "../FixtureRepositoryDirectory.ts"; +import type { FixtureRepositoryDescription } from "../model/FixtureRepositoryDescription.ts"; +import { BuiltHistory } from "./BuiltHistory.ts"; + +export interface SeededIssue { + readonly index: number; + readonly title: string; + readonly author: string; + readonly kind: "issue" | "pull"; + readonly state: "open" | "closed" | "merged"; + readonly daysAgo: number; +} + +export interface SeededComment { + readonly id: number; + readonly daysAgo: number; +} + +export interface SeededRelease { + readonly id: number; + readonly tag: string; + readonly name: string; + readonly draft: boolean; + readonly daysAgo: number; +} + +/** What the API created for one fixture repository; the database step adjusts times and adds Actions from it. */ +export class SeededRepository { + public readonly id: number; + public readonly ownerId: number; + public readonly description: FixtureRepositoryDescription; + public readonly history: BuiltHistory; + public readonly actionRuns: readonly FixtureActionRunFile[]; + public readonly issues: SeededIssue[] = []; + public readonly comments: SeededComment[] = []; + public readonly releases: SeededRelease[] = []; + + public constructor( + id: number, + ownerId: number, + description: FixtureRepositoryDescription, + history: BuiltHistory, + actionRuns: readonly FixtureActionRunFile[] + ) { + this.id = id; + this.ownerId = ownerId; + this.description = description; + this.history = history; + this.actionRuns = actionRuns; + } + + public get identifier(): string { + return `${this.description.owner}/${this.description.name}`; + } +} diff --git a/dev/preview/forgejo/ForgejoCompose.ts b/dev/preview/forgejo/ForgejoCompose.ts index 9548bb56..84fac2c2 100644 --- a/dev/preview/forgejo/ForgejoCompose.ts +++ b/dev/preview/forgejo/ForgejoCompose.ts @@ -19,6 +19,11 @@ export class ForgejoCompose { this.run(["down"]); } + /** Stops the preview and deletes its data volume, so the next start seeds the fixtures again. */ + public reset(): void { + this.run(["down", "--volumes"]); + } + public stopService(): void { this.run(["stop", this.config.composeService]); } @@ -43,6 +48,11 @@ export class ForgejoCompose { this.runner.run(COMMANDS.docker, ["cp", source, `${this.containerName()}:${destination}`]); } + /** Copies a file that may not exist; returns whether it did. */ + public copyFromContainerIfPresent(source: string, destination: string): boolean { + return this.runner.succeeds(COMMANDS.docker, ["cp", `${this.containerName()}:${source}`, destination]); + } + public startService(): void { this.run(["start", this.config.composeService]); } @@ -62,6 +72,7 @@ export class ForgejoCompose { "--entrypoint", "chown", COMPOSE.image, + "-R", "1000:1000", path, ]); diff --git a/package.json b/package.json index 108414ff..80dfc27f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dev:forgejo:sync": "bun dev/dev.ts sync", "dev:forgejo:watch": "bun dev/dev.ts watch", "dev:forgejo:seed": "bun dev/dev.ts seed", + "dev:forgejo:reset": "bun dev/dev.ts reset", "commit": "bun fmt && bun bundle && bun vitest", "version": "bun scripts/version.ts", "release": "bun bundle", diff --git a/tsconfig.json b/tsconfig.json index dfb4f526..ca14c512 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,5 +47,6 @@ "vite.config.ts", "theme.config.ts", "eslint.config.ts" - ] + ], + "exclude": ["dev/fixtures"] } diff --git a/vitest.config.ts b/vitest.config.ts index 8232599b..fe2fe29c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "components/**/*.test.ts"], + include: ["src/**/*.test.ts", "components/**/*.test.ts", "dev/preview/**/*.test.ts"], testTimeout: 20000, open: false, allowOnly: true,