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 5928b43b..00000000 Binary files a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.idx and /dev/null differ 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 b4868279..00000000 Binary files a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.pack and /dev/null differ diff --git a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.rev b/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.rev deleted file mode 100644 index e73ae87e..00000000 Binary files a/dev/fixtures/arcadeya/devops.git/objects/pack/pack-fd6409aa7a3ef918aa052071d097d7bbc3c982f9.rev and /dev/null differ diff --git a/dev/fixtures/arcadeya/devops.git/packed-refs b/dev/fixtures/arcadeya/devops.git/packed-refs deleted file mode 100644 index d56bac2a..00000000 --- a/dev/fixtures/arcadeya/devops.git/packed-refs +++ /dev/null @@ -1,24 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -00369028951caf895762ead7701cf422670753ed refs/heads/dev -1a9987d5804029c654b2bf841acda2f20dca7f7a refs/tags/v1 -62fad70b81e5bcfeced42b3adf9bfd2809434a4f refs/tags/v10 -^0f52ee57efa533a3dcf25cbafb16ad65784b17be -13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8 refs/tags/v11 -c67543659c59e5f42cbe5fc4cf8e922a878e467d refs/tags/v12 -8d219363f3d2f4df2c3fef6a50801d43091c87a7 refs/tags/v13 -ebbc1017587ff0d7ba02d6968ade70ca4351371d refs/tags/v14 -ed70f686b06194a5e06e6ef306f5bcb4c63975ef refs/tags/v15 -a5e7ab7b084e41035ac19121936dbf57106dac86 refs/tags/v16 -00369028951caf895762ead7701cf422670753ed refs/tags/v17 -a3e5ec4ffb596b947ab0617c10f5567ca224add2 refs/tags/v2 -^90785fb0d92870a3013ec18aa2ce483402a2bb9d -3d7f84c8442d0c3d6fefdd76e4bfa19007be86e9 refs/tags/v3 -^0b654d92968c9ea014ad54cd3463dfee4eddc4e6 -bdeb79a4ae8ca17f8b3df933119bb5ac01cdf765 refs/tags/v4 -^5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27 -5eafc535d37d51dcfde9bb05f9088b33f3f17048 refs/tags/v5 -39a541ccf517da61ae30d9a11b4010d76ca1bd67 refs/tags/v6 -156bfa5b751b2f38013f7e871df029cbc89d37f7 refs/tags/v7 -8a957c4c330701b2122ea694a895c04e4e3b2f0f refs/tags/v8 -d0382cdaa6a4f426961ff4516fd21c2a670b988a refs/tags/v9 -^a626e2908880414793118359f24b7ca586ed7696 diff --git a/dev/fixtures/arcadeya/devops.json b/dev/fixtures/arcadeya/devops.json deleted file mode 100644 index 9e7c3b2f..00000000 --- a/dev/fixtures/arcadeya/devops.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "actions": { - "action_run": [], - "action_task": [], - "action_run_job": [], - "action_artifact": [], - "action_task_step": [], - "action_task_output": [] - }, - "releases": [ - { - "body": "", - "name": "feat: rename deploy runner label", - "draft": false, - "tag_name": "v1", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "v2", - "draft": false, - "tag_name": "v2", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "v3", - "draft": false, - "tag_name": "v3", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "v4", - "draft": false, - "tag_name": "v4", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "fix(ci): simplify oidc token validation action", - "draft": false, - "tag_name": "v5", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "feat(ci): resolve tags from checked out sources", - "draft": false, - "tag_name": "v6", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "feat(ci): add optional git source checkout", - "draft": false, - "tag_name": "v7", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "feat(ci): add Artifact Keeper OIDC registry login", - "draft": false, - "tag_name": "v8", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "v9", - "draft": false, - "tag_name": "v9", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "fix(ci): support direct Artifact Keeper registry routing", - "draft": false, - "tag_name": "v10", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "fix(ci): derive images registry paths", - "draft": false, - "tag_name": "v11", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "feat(ci): support subdirectory Docker builds", - "draft": false, - "tag_name": "v12", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "fix(ci): enter configured image build directory", - "draft": false, - "tag_name": "v13", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "fix(ci): report OIDC exchange failures", - "draft": false, - "tag_name": "v14", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "refactor(ci): compose reusable deployment primitives", - "draft": false, - "tag_name": "v15", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "refactor(ci): derive repository deployment settings", - "draft": false, - "tag_name": "v16", - "prerelease": false, - "target_commitish": "" - }, - { - "body": "", - "name": "feat(ci): support workload image rollouts", - "draft": false, - "tag_name": "v17", - "prerelease": false, - "target_commitish": "" - } - ] -} diff --git a/dev/fixtures/home.json b/dev/fixtures/home.json deleted file mode 100644 index 55658778..00000000 --- a/dev/fixtures/home.json +++ /dev/null @@ -1,1006 +0,0 @@ -{ - "userCreatedUnix": 1782638521, - "commitEmails": [ - "me@whereareiam.me" - ], - "repositories": [ - { - "id": 3, - "name": "devops", - "created_unix": 1782807301, - "updated_unix": 1788461591 - }, - { - "id": 12, - "name": "identica-docs", - "created_unix": 1786711457, - "updated_unix": 1788460976 - } - ], - "actions": [ - { - "id": 22, - "user_id": 2, - "op_type": 1, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "", - "is_private": false, - "content": "", - "created_unix": 1782807301 - }, - { - "id": 25, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "", - "created_unix": 1782807561 - }, - { - "id": 28, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"fdee4139fec3874e2be9e7cccd477dcec5a519fd\",\"Message\":\"feat: add initial devops setup\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T10:18:15+02:00\"}],\"HeadCommit\":{\"Sha1\":\"fdee4139fec3874e2be9e7cccd477dcec5a519fd\",\"Message\":\"feat: add initial devops setup\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T10:18:15+02:00\"},\"CompareURL\":\"\",\"Len\":1}", - "created_unix": 1782807561 - }, - { - "id": 31, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v1", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"fdee4139fec3874e2be9e7cccd477dcec5a519fd\",\"Message\":\"feat: add initial devops setup\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T10:18:15+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...fdee4139fec3874e2be9e7cccd477dcec5a519fd\",\"Len\":0}", - "created_unix": 1782807561 - }, - { - "id": 34, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v1", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"72f8f1e1e2249ccf9554e1db83ba09d04f45b037\",\"Message\":\"feat: update secret names for registry credentials\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T11:33:36+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...72f8f1e1e2249ccf9554e1db83ba09d04f45b037\",\"Len\":0}", - "created_unix": 1782812040 - }, - { - "id": 37, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"72f8f1e1e2249ccf9554e1db83ba09d04f45b037\",\"Message\":\"feat: update secret names for registry credentials\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T11:33:36+02:00\"}],\"HeadCommit\":{\"Sha1\":\"72f8f1e1e2249ccf9554e1db83ba09d04f45b037\",\"Message\":\"feat: update secret names for registry credentials\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T11:33:36+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/fdee4139fec3874e2be9e7cccd477dcec5a519fd...72f8f1e1e2249ccf9554e1db83ba09d04f45b037\",\"Len\":1}", - "created_unix": 1782812071 - }, - { - "id": 43, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"1a26b44d49d2f3f980c62cc54259b216a6613355\",\"Message\":\"feat: rename deploy runner label\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T18:22:33+02:00\"}],\"HeadCommit\":{\"Sha1\":\"1a26b44d49d2f3f980c62cc54259b216a6613355\",\"Message\":\"feat: rename deploy runner label\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T18:22:33+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/72f8f1e1e2249ccf9554e1db83ba09d04f45b037...1a26b44d49d2f3f980c62cc54259b216a6613355\",\"Len\":1}", - "created_unix": 1782836565 - }, - { - "id": 46, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v1", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"1a26b44d49d2f3f980c62cc54259b216a6613355\",\"Message\":\"feat: rename deploy runner label\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-06-30T18:22:33+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...1a26b44d49d2f3f980c62cc54259b216a6613355\",\"Len\":0}", - "created_unix": 1782836576 - }, - { - "id": 49, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"90785fb0d92870a3013ec18aa2ce483402a2bb9d\",\"Message\":\"refactor: reorganize reusable CI actions into devops primitive and composite groups\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T16:32:23+02:00\"}],\"HeadCommit\":{\"Sha1\":\"90785fb0d92870a3013ec18aa2ce483402a2bb9d\",\"Message\":\"refactor: reorganize reusable CI actions into devops primitive and composite groups\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T16:32:23+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/1a9987d5804029c654b2bf841acda2f20dca7f7a...90785fb0d92870a3013ec18aa2ce483402a2bb9d\",\"Len\":1}", - "created_unix": 1783002772 - }, - { - "id": 52, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v2", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"90785fb0d92870a3013ec18aa2ce483402a2bb9d\",\"Message\":\"refactor: reorganize reusable CI actions into devops primitive and composite groups\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T16:32:23+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...9fe62817d6d7791811c7dad4d36eb160cef64ef9\",\"Len\":0}", - "created_unix": 1783002785 - }, - { - "id": 58, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v3", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"0b654d92968c9ea014ad54cd3463dfee4eddc4e6\",\"Message\":\"feat(ci): add registry-backed build cache support\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T17:41:00+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...e0b46ac661f695bbe86f1f46e2ce78533d1a95e7\",\"Len\":0}", - "created_unix": 1783006911 - }, - { - "id": 67, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/codex/registry-build-cache", - "is_private": false, - "content": "", - "created_unix": 1783006963 - }, - { - "id": 70, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/codex/registry-build-cache", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"0b654d92968c9ea014ad54cd3463dfee4eddc4e6\",\"Message\":\"feat(ci): add registry-backed build cache support\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T17:41:00+02:00\"}],\"HeadCommit\":{\"Sha1\":\"0b654d92968c9ea014ad54cd3463dfee4eddc4e6\",\"Message\":\"feat(ci): add registry-backed build cache support\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T17:41:00+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/90785fb0d92870a3013ec18aa2ce483402a2bb9d...0b654d92968c9ea014ad54cd3463dfee4eddc4e6\",\"Len\":1}", - "created_unix": 1783006963 - }, - { - "id": 79, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/codex/registry-build-cache", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Message\":\"fix(ci): restore plain docker build and push flow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:48:58+02:00\"}],\"HeadCommit\":{\"Sha1\":\"1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Message\":\"fix(ci): restore plain docker build and push flow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:48:58+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0b654d92968c9ea014ad54cd3463dfee4eddc4e6...1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Len\":1}", - "created_unix": 1783010976 - }, - { - "id": 82, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Message\":\"fix(ci): restore plain docker build and push flow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:48:58+02:00\"},{\"Sha1\":\"0b654d92968c9ea014ad54cd3463dfee4eddc4e6\",\"Message\":\"feat(ci): add registry-backed build cache support\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T17:41:00+02:00\"}],\"HeadCommit\":{\"Sha1\":\"1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Message\":\"fix(ci): restore plain docker build and push flow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:48:58+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/90785fb0d92870a3013ec18aa2ce483402a2bb9d...1ed2485b3a243b72447e2132214f645dc322fbc4\",\"Len\":2}", - "created_unix": 1783011129 - }, - { - "id": 88, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v4", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Message\":\"chore(ci): align composite action refs to v4\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:59:46+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...2b625adf1f5bf70c89330c7416696ac1b15d5492\",\"Len\":0}", - "created_unix": 1783011606 - }, - { - "id": 91, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/codex/registry-build-cache", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Message\":\"chore(ci): align composite action refs to v4\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:59:46+02:00\"}],\"HeadCommit\":{\"Sha1\":\"5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Message\":\"chore(ci): align composite action refs to v4\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:59:46+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/1ed2485b3a243b72447e2132214f645dc322fbc4...5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Len\":1}", - "created_unix": 1783011623 - }, - { - "id": 97, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Message\":\"chore(ci): align composite action refs to v4\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:59:46+02:00\"}],\"HeadCommit\":{\"Sha1\":\"5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Message\":\"chore(ci): align composite action refs to v4\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-02T18:59:46+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/1ed2485b3a243b72447e2132214f645dc322fbc4...5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27\",\"Len\":1}", - "created_unix": 1783011695 - }, - { - "id": 106, - "user_id": 2, - "op_type": 17, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/codex/registry-build-cache", - "is_private": false, - "content": "", - "created_unix": 1783011726 - }, - { - "id": 139, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Message\":\"fix(ci): simplify oidc token validation action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:10:28+02:00\"}],\"HeadCommit\":{\"Sha1\":\"5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Message\":\"fix(ci): simplify oidc token validation action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:10:28+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/5c9fc92a6e0548bd0d5b38daf97f9eacb113ef27...5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Len\":1}", - "created_unix": 1783444255 - }, - { - "id": 142, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v5", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Message\":\"fix(ci): simplify oidc token validation action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:10:28+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Len\":0}", - "created_unix": 1783444264 - }, - { - "id": 151, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"645fb601344e613b39a5255d13a20756351d7bdb\",\"Message\":\"feat(ci): add optional kubernetes image pre-pull action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:54:12+02:00\"}],\"HeadCommit\":{\"Sha1\":\"645fb601344e613b39a5255d13a20756351d7bdb\",\"Message\":\"feat(ci): add optional kubernetes image pre-pull action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:54:12+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/5eafc535d37d51dcfde9bb05f9088b33f3f17048...645fb601344e613b39a5255d13a20756351d7bdb\",\"Len\":1}", - "created_unix": 1783447287 - }, - { - "id": 154, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v6", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"645fb601344e613b39a5255d13a20756351d7bdb\",\"Message\":\"feat(ci): add optional kubernetes image pre-pull action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:54:12+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...645fb601344e613b39a5255d13a20756351d7bdb\",\"Len\":0}", - "created_unix": 1783447296 - }, - { - "id": 157, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Message\":\"fix(ci): simplify oidc token validation action\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-07-07T19:10:28+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/645fb601344e613b39a5255d13a20756351d7bdb...5eafc535d37d51dcfde9bb05f9088b33f3f17048\",\"Len\":0}", - "created_unix": 1783448312 - }, - { - "id": 163, - "user_id": 2, - "op_type": 16, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v6", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":null,\"CompareURL\":\"\",\"Len\":0}", - "created_unix": 1783448322 - }, - { - "id": 673, - "user_id": 2, - "op_type": 1, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "", - "is_private": true, - "content": "", - "created_unix": 1786711457 - }, - { - "id": 676, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"39a541ccf517da61ae30d9a11b4010d76ca1bd67\",\"Message\":\"feat(ci): resolve tags from checked out sources\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:10:54+02:00\"}],\"HeadCommit\":{\"Sha1\":\"39a541ccf517da61ae30d9a11b4010d76ca1bd67\",\"Message\":\"feat(ci): resolve tags from checked out sources\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:10:54+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/5eafc535d37d51dcfde9bb05f9088b33f3f17048...39a541ccf517da61ae30d9a11b4010d76ca1bd67\",\"Len\":1}", - "created_unix": 1786713056 - }, - { - "id": 679, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v6", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"39a541ccf517da61ae30d9a11b4010d76ca1bd67\",\"Message\":\"feat(ci): resolve tags from checked out sources\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:10:54+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...39a541ccf517da61ae30d9a11b4010d76ca1bd67\",\"Len\":0}", - "created_unix": 1786713056 - }, - { - "id": 682, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "", - "created_unix": 1786713073 - }, - { - "id": 683, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"fa15bbbc053d1337aab7ca5c84ae7f1efccb3fc9\",\"Message\":\"feat(ci): migrate docs deployment to forgejo\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:11:12+02:00\"},{\"Sha1\":\"88221a0d27c3b5c588bc13f6e242e20681340ce7\",\"Message\":\"chore: migrate build setup from Node to Bun in build-and-deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-04-30T12:06:02+02:00\"},{\"Sha1\":\"b1e824fb06a41bcf22e3e4b8b9c939b4a7915452\",\"Message\":\"chore: update build-and-deploy workflow with new action references\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-04-30T01:11:17+02:00\"},{\"Sha1\":\"914d287f1221f071abaa3bcb9aba126aa7d12702\",\"Message\":\"fix: update action references to use full repository URLs\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-04-30T00:19:17+02:00\"},{\"Sha1\":\"0a33d24174dacc5f4b7f1686ee571be1b3963805\",\"Message\":\"feat: add deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-04-30T00:03:29+02:00\"}],\"HeadCommit\":{\"Sha1\":\"fa15bbbc053d1337aab7ca5c84ae7f1efccb3fc9\",\"Message\":\"feat(ci): migrate docs deployment to forgejo\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:11:12+02:00\"},\"CompareURL\":\"\",\"Len\":5}", - "created_unix": 1786713073 - }, - { - "id": 686, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"d609927ad6f9314d83f36ae31a584c39cd580bf9\",\"Message\":\"fix(ci): provide checkout token\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:16:48+02:00\"}],\"HeadCommit\":{\"Sha1\":\"d609927ad6f9314d83f36ae31a584c39cd580bf9\",\"Message\":\"fix(ci): provide checkout token\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:16:48+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/fa15bbbc053d1337aab7ca5c84ae7f1efccb3fc9...d609927ad6f9314d83f36ae31a584c39cd580bf9\",\"Len\":1}", - "created_unix": 1786713409 - }, - { - "id": 688, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"93250ad76f0d3217a0fa0a052054ae856fff2978\",\"Message\":\"fix(ci): use public checkout credentials\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:19:12+02:00\"}],\"HeadCommit\":{\"Sha1\":\"93250ad76f0d3217a0fa0a052054ae856fff2978\",\"Message\":\"fix(ci): use public checkout credentials\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:19:12+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/d609927ad6f9314d83f36ae31a584c39cd580bf9...93250ad76f0d3217a0fa0a052054ae856fff2978\",\"Len\":1}", - "created_unix": 1786713553 - }, - { - "id": 690, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"32d3a904e23d49f182cd996abb72f0f2eb78aba4\",\"Message\":\"fix(ci): authenticate scriptorium checkout\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:23:05+02:00\"}],\"HeadCommit\":{\"Sha1\":\"32d3a904e23d49f182cd996abb72f0f2eb78aba4\",\"Message\":\"fix(ci): authenticate scriptorium checkout\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:23:05+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/93250ad76f0d3217a0fa0a052054ae856fff2978...32d3a904e23d49f182cd996abb72f0f2eb78aba4\",\"Len\":1}", - "created_unix": 1786713787 - }, - { - "id": 698, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"156bfa5b751b2f38013f7e871df029cbc89d37f7\",\"Message\":\"feat(ci): add optional git source checkout\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:32:51+02:00\"}],\"HeadCommit\":{\"Sha1\":\"156bfa5b751b2f38013f7e871df029cbc89d37f7\",\"Message\":\"feat(ci): add optional git source checkout\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:32:51+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/39a541ccf517da61ae30d9a11b4010d76ca1bd67...156bfa5b751b2f38013f7e871df029cbc89d37f7\",\"Len\":1}", - "created_unix": 1786714372 - }, - { - "id": 701, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v7", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"156bfa5b751b2f38013f7e871df029cbc89d37f7\",\"Message\":\"feat(ci): add optional git source checkout\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:32:51+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...156bfa5b751b2f38013f7e871df029cbc89d37f7\",\"Len\":0}", - "created_unix": 1786714372 - }, - { - "id": 704, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"ad057cc56cd961a20633728d070cab874d9f1e7f\",\"Message\":\"fix(ci): checkout public scriptorium source\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:32:51+02:00\"}],\"HeadCommit\":{\"Sha1\":\"ad057cc56cd961a20633728d070cab874d9f1e7f\",\"Message\":\"fix(ci): checkout public scriptorium source\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:32:51+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/32d3a904e23d49f182cd996abb72f0f2eb78aba4...ad057cc56cd961a20633728d070cab874d9f1e7f\",\"Len\":1}", - "created_unix": 1786714372 - }, - { - "id": 706, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"c53966a6c38ba4d0a48ddc2884cbe37b115072d7\",\"Message\":\"fix(ci): resolve scriptorium dockerfile\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:46:05+02:00\"}],\"HeadCommit\":{\"Sha1\":\"c53966a6c38ba4d0a48ddc2884cbe37b115072d7\",\"Message\":\"fix(ci): resolve scriptorium dockerfile\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T15:46:05+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/ad057cc56cd961a20633728d070cab874d9f1e7f...c53966a6c38ba4d0a48ddc2884cbe37b115072d7\",\"Len\":1}", - "created_unix": 1786715166 - }, - { - "id": 708, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"fccd7ce17e1aada1289e02af80b46f89ee76f0ca\",\"Message\":\"fix(ci): push images through registry proxy\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T16:26:45+02:00\"}],\"HeadCommit\":{\"Sha1\":\"fccd7ce17e1aada1289e02af80b46f89ee76f0ca\",\"Message\":\"fix(ci): push images through registry proxy\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T16:26:45+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/c53966a6c38ba4d0a48ddc2884cbe37b115072d7...fccd7ce17e1aada1289e02af80b46f89ee76f0ca\",\"Len\":1}", - "created_unix": 1786717606 - }, - { - "id": 711, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"ee5eb5f49f85b0ea6b79e8b2ccc8a547b01d2d92\",\"Message\":\"fix(ci): use repository hostname\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T22:15:31+02:00\"}],\"HeadCommit\":{\"Sha1\":\"ee5eb5f49f85b0ea6b79e8b2ccc8a547b01d2d92\",\"Message\":\"fix(ci): use repository hostname\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-14T22:15:31+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/fccd7ce17e1aada1289e02af80b46f89ee76f0ca...ee5eb5f49f85b0ea6b79e8b2ccc8a547b01d2d92\",\"Len\":1}", - "created_unix": 1786738542 - }, - { - "id": 868, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"8a957c4c330701b2122ea694a895c04e4e3b2f0f\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"}],\"HeadCommit\":{\"Sha1\":\"8a957c4c330701b2122ea694a895c04e4e3b2f0f\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/156bfa5b751b2f38013f7e871df029cbc89d37f7...8a957c4c330701b2122ea694a895c04e4e3b2f0f\",\"Len\":1}", - "created_unix": 1787663963 - }, - { - "id": 871, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v8", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"8a957c4c330701b2122ea694a895c04e4e3b2f0f\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...8a957c4c330701b2122ea694a895c04e4e3b2f0f\",\"Len\":0}", - "created_unix": 1787663963 - }, - { - "id": 874, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"9651a14f8ebf907fa9c98aa003dfdb3413c7ecfa\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"}],\"HeadCommit\":{\"Sha1\":\"9651a14f8ebf907fa9c98aa003dfdb3413c7ecfa\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/ee5eb5f49f85b0ea6b79e8b2ccc8a547b01d2d92...9651a14f8ebf907fa9c98aa003dfdb3413c7ecfa\",\"Len\":1}", - "created_unix": 1787664026 - }, - { - "id": 879, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"e4e274d8b7f69296425297024f030b999f410e9f\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"}],\"HeadCommit\":{\"Sha1\":\"e4e274d8b7f69296425297024f030b999f410e9f\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/8a957c4c330701b2122ea694a895c04e4e3b2f0f...e4e274d8b7f69296425297024f030b999f410e9f\",\"Len\":1}", - "created_unix": 1787666648 - }, - { - "id": 882, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"5e120d8eac54b58bdfd4f019f56b6b19dc6b3425\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"}],\"HeadCommit\":{\"Sha1\":\"5e120d8eac54b58bdfd4f019f56b6b19dc6b3425\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/e4e274d8b7f69296425297024f030b999f410e9f...5e120d8eac54b58bdfd4f019f56b6b19dc6b3425\",\"Len\":1}", - "created_unix": 1787666840 - }, - { - "id": 885, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v9", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"5e120d8eac54b58bdfd4f019f56b6b19dc6b3425\",\"Message\":\"feat(ci): add Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T15:19:21+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...239196836f7c4f3f9063754fdec3a595ddfe7402\",\"Len\":0}", - "created_unix": 1787666840 - }, - { - "id": 888, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"0f62cca98aa94f94dbd9ffe4da592ca1ffec872a\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"}],\"HeadCommit\":{\"Sha1\":\"0f62cca98aa94f94dbd9ffe4da592ca1ffec872a\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/9651a14f8ebf907fa9c98aa003dfdb3413c7ecfa...0f62cca98aa94f94dbd9ffe4da592ca1ffec872a\",\"Len\":1}", - "created_unix": 1787666840 - }, - { - "id": 893, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"17d62bab70c2dec30e2a468b6cf648276bded1c4\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"}],\"HeadCommit\":{\"Sha1\":\"17d62bab70c2dec30e2a468b6cf648276bded1c4\",\"Message\":\"ci: use Artifact Keeper OIDC registry login\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T14:17:48+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/0f62cca98aa94f94dbd9ffe4da592ca1ffec872a...17d62bab70c2dec30e2a468b6cf648276bded1c4\",\"Len\":1}", - "created_unix": 1787666948 - }, - { - "id": 898, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"a626e2908880414793118359f24b7ca586ed7696\",\"Message\":\"feat(registry): derive default Docker destination\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T16:44:18+02:00\"}],\"HeadCommit\":{\"Sha1\":\"a626e2908880414793118359f24b7ca586ed7696\",\"Message\":\"feat(registry): derive default Docker destination\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T16:44:18+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/5e120d8eac54b58bdfd4f019f56b6b19dc6b3425...a626e2908880414793118359f24b7ca586ed7696\",\"Len\":1}", - "created_unix": 1787669066 - }, - { - "id": 901, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v9", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"a626e2908880414793118359f24b7ca586ed7696\",\"Message\":\"feat(registry): derive default Docker destination\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-25T16:44:18+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...850b0707aea0baa6e0ec218302c3e549f9693c95\",\"Len\":0}", - "created_unix": 1787669066 - }, - { - "id": 994, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"701866ee502b48ca7959a2bacdcd52fb6859015f\",\"Message\":\"fix(ci): follow registry login redirects\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T21:53:13+02:00\"}],\"HeadCommit\":{\"Sha1\":\"701866ee502b48ca7959a2bacdcd52fb6859015f\",\"Message\":\"fix(ci): follow registry login redirects\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T21:53:13+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/a626e2908880414793118359f24b7ca586ed7696...701866ee502b48ca7959a2bacdcd52fb6859015f\",\"Len\":1}", - "created_unix": 1787860538 - }, - { - "id": 997, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v10", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"701866ee502b48ca7959a2bacdcd52fb6859015f\",\"Message\":\"fix(ci): follow registry login redirects\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T21:53:13+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...aa92c36090109c5a841f285d419164ffc5a00765\",\"Len\":0}", - "created_unix": 1787860538 - }, - { - "id": 1006, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"0f52ee57efa533a3dcf25cbafb16ad65784b17be\",\"Message\":\"fix(ci): support direct Artifact Keeper registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:05:59+02:00\"}],\"HeadCommit\":{\"Sha1\":\"0f52ee57efa533a3dcf25cbafb16ad65784b17be\",\"Message\":\"fix(ci): support direct Artifact Keeper registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:05:59+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/701866ee502b48ca7959a2bacdcd52fb6859015f...0f52ee57efa533a3dcf25cbafb16ad65784b17be\",\"Len\":1}", - "created_unix": 1787861260 - }, - { - "id": 1009, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v10", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"0f52ee57efa533a3dcf25cbafb16ad65784b17be\",\"Message\":\"fix(ci): support direct Artifact Keeper registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:05:59+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...f3cee24312a1c8f373a71f9bd1f8406861b9f221\",\"Len\":0}", - "created_unix": 1787861260 - }, - { - "id": 1024, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Message\":\"fix(ci): use hostname-based registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:28:53+02:00\"}],\"HeadCommit\":{\"Sha1\":\"594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Message\":\"fix(ci): use hostname-based registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:28:53+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0f52ee57efa533a3dcf25cbafb16ad65784b17be...594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Len\":1}", - "created_unix": 1787862546 - }, - { - "id": 1027, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Message\":\"fix(ci): use hostname-based registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:28:53+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...563cd85bf911f62b5c58c68f5652c72d745662f4\",\"Len\":0}", - "created_unix": 1787862546 - }, - { - "id": 1036, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v10", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Message\":\"fix(ci): use hostname-based registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:28:53+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...74eaea4713153e5d898c0d0e878bea9ee2ce7f97\",\"Len\":0}", - "created_unix": 1787862612 - }, - { - "id": 1039, - "user_id": 2, - "op_type": 16, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":null,\"CompareURL\":\"\",\"Len\":0}", - "created_unix": 1787862612 - }, - { - "id": 1048, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v10", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"23c9fd09597b63dd8f9370879e7ed73c09c378f7\",\"Message\":\"fix(ci): support direct Artifact Keeper registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:05:59+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...96fa5006a6f52f80e77911531fc69632a831c70c\",\"Len\":0}", - "created_unix": 1787862722 - }, - { - "id": 1063, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v10", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"0f52ee57efa533a3dcf25cbafb16ad65784b17be\",\"Message\":\"fix(ci): support direct Artifact Keeper registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:05:59+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...f949e8ae73021bcf734acc3954c020c5060e4142\",\"Len\":0}", - "created_unix": 1787863935 - }, - { - "id": 1066, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"594cb92fe9d73e5e9e4824152d45a6e093ec76cb\",\"Message\":\"fix(ci): use hostname-based registry routing\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-27T22:28:53+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...ec3a1a33c5688e74784d952d1ba59d7609203e4a\",\"Len\":0}", - "created_unix": 1787863935 - }, - { - "id": 1114, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3\",\"Message\":\"fix(ci): authenticate registry after image build\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T14:47:03+02:00\"}],\"HeadCommit\":{\"Sha1\":\"0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3\",\"Message\":\"fix(ci): authenticate registry after image build\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T14:47:03+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/594cb92fe9d73e5e9e4824152d45a6e093ec76cb...0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3\",\"Len\":1}", - "created_unix": 1788007633 - }, - { - "id": 1117, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3\",\"Message\":\"fix(ci): authenticate registry after image build\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T14:47:03+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3\",\"Len\":0}", - "created_unix": 1788007633 - }, - { - "id": 1129, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"142a0c00edc5488639e03623ca9cef8269460875\",\"Message\":\"fix(ci): authenticate registry after image build\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T14:47:03+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...142a0c00edc5488639e03623ca9cef8269460875\",\"Len\":0}", - "created_unix": 1788015517 - }, - { - "id": 1138, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8\",\"Message\":\"fix(ci): derive images registry paths\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T17:06:40+02:00\"}],\"HeadCommit\":{\"Sha1\":\"13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8\",\"Message\":\"fix(ci): derive images registry paths\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T17:06:40+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0ccd0c1bec81f97a2506e8f059c01f31f7f00ca3...13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8\",\"Len\":1}", - "created_unix": 1788016002 - }, - { - "id": 1141, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v11", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8\",\"Message\":\"fix(ci): derive images registry paths\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-29T17:06:40+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8\",\"Len\":0}", - "created_unix": 1788016002 - }, - { - "id": 1204, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"c67543659c59e5f42cbe5fc4cf8e922a878e467d\",\"Message\":\"feat(ci): support subdirectory Docker builds\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:35:33+02:00\"}],\"HeadCommit\":{\"Sha1\":\"c67543659c59e5f42cbe5fc4cf8e922a878e467d\",\"Message\":\"feat(ci): support subdirectory Docker builds\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:35:33+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/13c0201e5df48b7e5eb3ba292d5c86f8b6d802e8...c67543659c59e5f42cbe5fc4cf8e922a878e467d\",\"Len\":1}", - "created_unix": 1788201336 - }, - { - "id": 1207, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v12", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"c67543659c59e5f42cbe5fc4cf8e922a878e467d\",\"Message\":\"feat(ci): support subdirectory Docker builds\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:35:33+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...c67543659c59e5f42cbe5fc4cf8e922a878e467d\",\"Len\":0}", - "created_unix": 1788201336 - }, - { - "id": 1213, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"8d219363f3d2f4df2c3fef6a50801d43091c87a7\",\"Message\":\"fix(ci): enter configured image build directory\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:37:13+02:00\"}],\"HeadCommit\":{\"Sha1\":\"8d219363f3d2f4df2c3fef6a50801d43091c87a7\",\"Message\":\"fix(ci): enter configured image build directory\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:37:13+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/c67543659c59e5f42cbe5fc4cf8e922a878e467d...8d219363f3d2f4df2c3fef6a50801d43091c87a7\",\"Len\":1}", - "created_unix": 1788201434 - }, - { - "id": 1216, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v13", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"8d219363f3d2f4df2c3fef6a50801d43091c87a7\",\"Message\":\"fix(ci): enter configured image build directory\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:37:13+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...8d219363f3d2f4df2c3fef6a50801d43091c87a7\",\"Len\":0}", - "created_unix": 1788201434 - }, - { - "id": 1225, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"ebbc1017587ff0d7ba02d6968ade70ca4351371d\",\"Message\":\"fix(ci): report OIDC exchange failures\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:48:22+02:00\"}],\"HeadCommit\":{\"Sha1\":\"ebbc1017587ff0d7ba02d6968ade70ca4351371d\",\"Message\":\"fix(ci): report OIDC exchange failures\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:48:22+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/8d219363f3d2f4df2c3fef6a50801d43091c87a7...ebbc1017587ff0d7ba02d6968ade70ca4351371d\",\"Len\":1}", - "created_unix": 1788202104 - }, - { - "id": 1228, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v14", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"ebbc1017587ff0d7ba02d6968ade70ca4351371d\",\"Message\":\"fix(ci): report OIDC exchange failures\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-08-31T20:48:22+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...ebbc1017587ff0d7ba02d6968ade70ca4351371d\",\"Len\":0}", - "created_unix": 1788202104 - }, - { - "id": 1268, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"ed70f686b06194a5e06e6ef306f5bcb4c63975ef\",\"Message\":\"refactor(ci): compose reusable deployment primitives\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:38:16+02:00\"}],\"HeadCommit\":{\"Sha1\":\"ed70f686b06194a5e06e6ef306f5bcb4c63975ef\",\"Message\":\"refactor(ci): compose reusable deployment primitives\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:38:16+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/ebbc1017587ff0d7ba02d6968ade70ca4351371d...ed70f686b06194a5e06e6ef306f5bcb4c63975ef\",\"Len\":1}", - "created_unix": 1788457112 - }, - { - "id": 1271, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v15", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"ed70f686b06194a5e06e6ef306f5bcb4c63975ef\",\"Message\":\"refactor(ci): compose reusable deployment primitives\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:38:16+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...ed70f686b06194a5e06e6ef306f5bcb4c63975ef\",\"Len\":0}", - "created_unix": 1788457112 - }, - { - "id": 1289, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"a5e7ab7b084e41035ac19121936dbf57106dac86\",\"Message\":\"refactor(ci): derive repository deployment settings\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:47:01+02:00\"}],\"HeadCommit\":{\"Sha1\":\"a5e7ab7b084e41035ac19121936dbf57106dac86\",\"Message\":\"refactor(ci): derive repository deployment settings\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:47:01+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/ed70f686b06194a5e06e6ef306f5bcb4c63975ef...a5e7ab7b084e41035ac19121936dbf57106dac86\",\"Len\":1}", - "created_unix": 1788457623 - }, - { - "id": 1292, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v16", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"a5e7ab7b084e41035ac19121936dbf57106dac86\",\"Message\":\"refactor(ci): derive repository deployment settings\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T19:47:01+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...a5e7ab7b084e41035ac19121936dbf57106dac86\",\"Len\":0}", - "created_unix": 1788457623 - }, - { - "id": 1332, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"02a490852d965a4c825fb24c0a21ed22a8ee6fdf\",\"Message\":\"feat(ci): use shared release-aware deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:40:12+02:00\"}],\"HeadCommit\":{\"Sha1\":\"02a490852d965a4c825fb24c0a21ed22a8ee6fdf\",\"Message\":\"feat(ci): use shared release-aware deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:40:12+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/17d62bab70c2dec30e2a468b6cf648276bded1c4...02a490852d965a4c825fb24c0a21ed22a8ee6fdf\",\"Len\":1}", - "created_unix": 1788460813 - }, - { - "id": 1346, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": true, - "content": "{\"Commits\":[{\"Sha1\":\"cd06ddfcab145c32e477fd123da4a97ba2c2ce64\",\"Message\":\"feat(ci): use shared release-aware deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:40:12+02:00\"}],\"HeadCommit\":{\"Sha1\":\"cd06ddfcab145c32e477fd123da4a97ba2c2ce64\",\"Message\":\"feat(ci): use shared release-aware deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:40:12+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/02a490852d965a4c825fb24c0a21ed22a8ee6fdf...cd06ddfcab145c32e477fd123da4a97ba2c2ce64\",\"Len\":1}", - "created_unix": 1788460976 - }, - { - "id": 1356, - "user_id": 2, - "op_type": 5, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/heads/dev", - "is_private": false, - "content": "{\"Commits\":[{\"Sha1\":\"00369028951caf895762ead7701cf422670753ed\",\"Message\":\"feat(ci): support workload image rollouts\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:53:10+02:00\"}],\"HeadCommit\":{\"Sha1\":\"00369028951caf895762ead7701cf422670753ed\",\"Message\":\"feat(ci): support workload image rollouts\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:53:10+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/a5e7ab7b084e41035ac19121936dbf57106dac86...00369028951caf895762ead7701cf422670753ed\",\"Len\":1}", - "created_unix": 1788461591 - }, - { - "id": 1359, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 3, - "comment_id": 0, - "ref_name": "refs/tags/v17", - "is_private": false, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"00369028951caf895762ead7701cf422670753ed\",\"Message\":\"feat(ci): support workload image rollouts\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:53:10+02:00\"},\"CompareURL\":\"arcadeya/devops/compare/0000000000000000000000000000000000000000...00369028951caf895762ead7701cf422670753ed\",\"Len\":0}", - "created_unix": 1788461591 - }, - { - "id": 1383, - "user_id": 2, - "op_type": 9, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/tags/1.0.0", - "is_private": true, - "content": "{\"Commits\":[],\"HeadCommit\":{\"Sha1\":\"cd06ddfcab145c32e477fd123da4a97ba2c2ce64\",\"Message\":\"feat(ci): use shared release-aware deploy workflow\",\"AuthorEmail\":\"me@whereareiam.me\",\"AuthorName\":\"whereareiam\",\"CommitterEmail\":\"me@whereareiam.me\",\"CommitterName\":\"whereareiam\",\"Signature\":null,\"Verification\":null,\"Timestamp\":\"2026-09-03T20:40:12+02:00\"},\"CompareURL\":\"whereareiam/identica-docs/compare/0000000000000000000000000000000000000000...cd06ddfcab145c32e477fd123da4a97ba2c2ce64\",\"Len\":0}", - "created_unix": 1788462794 - }, - { - "id": 1384, - "user_id": 2, - "op_type": 24, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "1.0.0", - "is_private": true, - "content": "1.0.0", - "created_unix": 1788462795 - }, - { - "id": 1385, - "user_id": 2, - "op_type": 16, - "act_user_id": 2, - "repo_id": 12, - "comment_id": 0, - "ref_name": "refs/tags/1.0.0", - "is_private": true, - "content": "{\"Commits\":[],\"HeadCommit\":null,\"CompareURL\":\"\",\"Len\":0}", - "created_unix": 1788463045 - } - ] -} diff --git a/dev/fixtures/logs/whereareiam/identica-docs/95/661.log.zst b/dev/fixtures/logs/whereareiam/identica-docs/95/661.log.zst deleted file mode 100644 index 5d091784..00000000 Binary files a/dev/fixtures/logs/whereareiam/identica-docs/95/661.log.zst and /dev/null differ 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 5d091784..00000000 Binary files a/dev/fixtures/logs/whereareiam/identica-docs/96/662.log.zst and /dev/null differ 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 9dfabb0c..00000000 Binary files a/dev/fixtures/logs/whereareiam/identica-docs/97/663.log.zst and /dev/null differ 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 9dfabb0c..00000000 Binary files a/dev/fixtures/logs/whereareiam/identica-docs/98/664.log.zst and /dev/null differ 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 eb171263..00000000 Binary files a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.idx and /dev/null differ 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 f6dec571..00000000 Binary files a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.pack and /dev/null differ diff --git a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.rev b/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.rev deleted file mode 100644 index 793f62b9..00000000 Binary files a/dev/fixtures/whereareiam/identica-docs.git/objects/pack/pack-a3055ea70f2e699b561083fadb4b2b705d2f9e79.rev and /dev/null differ 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,