From 787da477495aa14a6e75b29796d761e414eb0efb Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Mon, 22 Jun 2026 22:24:42 +0000 Subject: [PATCH 1/5] huge refactor --- .changeset/fix-clone-remote-did.md | 10 + PLAN.md | 58 +- bun.lock | 304 +- package.json | 16 +- schemas/ci/check-suite.json | 19 + schemas/issues/comment.json | 7 + schemas/issues/issue-dependency.json | 15 + schemas/issues/issue-field-value.json | 29 + schemas/issues/issue-sub-issue.json | 19 + schemas/org/org-blocked-user.json | 20 + schemas/org/org-custom-property.json | 46 + schemas/org/org-issue-field.json | 53 + schemas/org/org-issue-type.json | 24 + schemas/org/org-member.json | 3 + schemas/org/org-webhook.json | 102 + schemas/org/team-member.json | 8 + schemas/org/team.json | 25 + schemas/releases/release.json | 42 +- schemas/repo/collaborator.json | 2 +- schemas/repo/repo.json | 52 + schemas/repo/settings.json | 1688 ++- schemas/repo/submission-decision.json | 37 + schemas/repo/webhook.json | 76 + schemas/social/activity.json | 31 + schemas/social/block.json | 17 + schemas/social/email.json | 35 + schemas/social/gist-comment.json | 26 + schemas/social/gist-star.json | 20 + schemas/social/gist.json | 56 + schemas/social/gpg-key.json | 132 + schemas/social/profile.json | 66 + schemas/social/social-account.json | 22 + schemas/social/ssh-key.json | 27 + schemas/social/ssh-signing-key.json | 21 + src/ci.ts | 30 +- src/cli/agent.ts | 21 +- src/cli/commands/ci.ts | 53 +- src/cli/commands/clone.ts | 90 +- src/cli/commands/github-api.ts | 7 +- src/cli/commands/issue.ts | 199 +- src/cli/commands/pr.ts | 292 +- src/cli/commands/repo.ts | 13 +- src/cli/commands/serve.ts | 21 +- src/cli/dwn-sqlite.ts | 14 +- src/cli/main.ts | 6 +- src/cli/repo-context.ts | 4 +- src/cli/submission-decisions.ts | 53 + src/daemon/lockfile.ts | 8 +- src/git-remote/resolve.ts | 9 + src/git-server/push-authorizer.ts | 25 +- src/git-server/verify.ts | 68 +- src/github-shim/actions.ts | 2940 ++++++ src/github-shim/activity.ts | 386 + src/github-shim/body-media.ts | 42 + src/github-shim/checks.ts | 1025 ++ src/github-shim/commit-comments.ts | 508 + src/github-shim/contents.ts | 319 + src/github-shim/dependency-graph.ts | 152 + src/github-shim/deployments.ts | 877 ++ src/github-shim/emails.ts | 239 + src/github-shim/follows.ts | 329 + src/github-shim/gists.ts | 1048 ++ src/github-shim/git-objects.ts | 2629 +++++ src/github-shim/git-refs.ts | 1724 ++++ src/github-shim/gpg-keys.ts | 328 + src/github-shim/helpers.ts | 99 +- src/github-shim/index.ts | 383 +- src/github-shim/issues.ts | 3834 ++++++- src/github-shim/meta.ts | 364 + src/github-shim/metrics.ts | 595 ++ src/github-shim/notifications.ts | 575 ++ src/github-shim/orgs.ts | 3590 +++++++ src/github-shim/pages.ts | 668 ++ src/github-shim/pulls.ts | 2244 +++- src/github-shim/releases.ts | 1185 ++- src/github-shim/repo-metadata.ts | 3919 +++++++ src/github-shim/repos.ts | 1142 ++- src/github-shim/search.ts | 956 ++ src/github-shim/server.ts | 5258 +++++++++- src/github-shim/social-accounts.ts | 216 + src/github-shim/stars.ts | 528 + src/github-shim/user-attestations.ts | 294 + src/github-shim/user-keys.ts | 423 + src/github-shim/users.ts | 496 +- src/github-shim/webhooks.ts | 1241 +++ src/indexer/api.ts | 202 +- src/indexer/crawler.ts | 247 +- src/indexer/explore.ts | 769 ++ src/indexer/index.ts | 8 + src/indexer/store.ts | 462 +- src/issues.ts | 104 +- src/org.ts | 163 + src/patches.ts | 18 +- src/releases.ts | 9 + src/repo.ts | 524 +- src/social.ts | 280 + src/web/routes.ts | 11 +- tests/bundle-restore.spec.ts | 37 +- tests/bundle-sync.spec.ts | 20 +- tests/cli.spec.ts | 439 +- tests/daemon-lifecycle.spec.ts | 16 + tests/daemon.spec.ts | 10 +- tests/e2e-collaboration.spec.ts | 70 +- tests/e2e.spec.ts | 130 +- tests/git-remote.spec.ts | 47 +- tests/github-shim.spec.ts | 13024 +++++++++++++++++++++++- tests/helpers/identity.ts | 54 + tests/helpers/test-daemon.ts | 15 +- tests/indexer.spec.ts | 822 +- tests/integration.spec.ts | 59 +- tests/profiles.spec.ts | 8 +- tests/protocols.spec.ts | 155 +- tests/push-authorizer.spec.ts | 25 +- tests/resolver.spec.ts | 10 +- tests/schemas.spec.ts | 358 + tests/shims.spec.ts | 10 +- tests/verify.spec.ts | 22 + tests/web.spec.ts | 10 +- 118 files changed, 61309 insertions(+), 1406 deletions(-) create mode 100644 .changeset/fix-clone-remote-did.md create mode 100644 schemas/issues/issue-dependency.json create mode 100644 schemas/issues/issue-field-value.json create mode 100644 schemas/issues/issue-sub-issue.json create mode 100644 schemas/org/org-blocked-user.json create mode 100644 schemas/org/org-custom-property.json create mode 100644 schemas/org/org-issue-field.json create mode 100644 schemas/org/org-issue-type.json create mode 100644 schemas/org/org-webhook.json create mode 100644 schemas/repo/submission-decision.json create mode 100644 schemas/social/block.json create mode 100644 schemas/social/email.json create mode 100644 schemas/social/gist-comment.json create mode 100644 schemas/social/gist-star.json create mode 100644 schemas/social/gist.json create mode 100644 schemas/social/gpg-key.json create mode 100644 schemas/social/profile.json create mode 100644 schemas/social/social-account.json create mode 100644 schemas/social/ssh-key.json create mode 100644 schemas/social/ssh-signing-key.json create mode 100644 src/cli/submission-decisions.ts create mode 100644 src/github-shim/actions.ts create mode 100644 src/github-shim/activity.ts create mode 100644 src/github-shim/body-media.ts create mode 100644 src/github-shim/checks.ts create mode 100644 src/github-shim/commit-comments.ts create mode 100644 src/github-shim/contents.ts create mode 100644 src/github-shim/dependency-graph.ts create mode 100644 src/github-shim/deployments.ts create mode 100644 src/github-shim/emails.ts create mode 100644 src/github-shim/follows.ts create mode 100644 src/github-shim/gists.ts create mode 100644 src/github-shim/git-objects.ts create mode 100644 src/github-shim/git-refs.ts create mode 100644 src/github-shim/gpg-keys.ts create mode 100644 src/github-shim/meta.ts create mode 100644 src/github-shim/metrics.ts create mode 100644 src/github-shim/notifications.ts create mode 100644 src/github-shim/orgs.ts create mode 100644 src/github-shim/pages.ts create mode 100644 src/github-shim/repo-metadata.ts create mode 100644 src/github-shim/search.ts create mode 100644 src/github-shim/social-accounts.ts create mode 100644 src/github-shim/stars.ts create mode 100644 src/github-shim/user-attestations.ts create mode 100644 src/github-shim/user-keys.ts create mode 100644 src/github-shim/webhooks.ts create mode 100644 src/indexer/explore.ts create mode 100644 tests/helpers/identity.ts diff --git a/.changeset/fix-clone-remote-did.md b/.changeset/fix-clone-remote-did.md new file mode 100644 index 0000000..6718e76 --- /dev/null +++ b/.changeset/fix-clone-remote-did.md @@ -0,0 +1,10 @@ +--- +'@enbox/gitd': patch +--- + +fix: skip local daemon when cloning repos owned by a different DID + +The local daemon resolver now checks `ownerDid` in the lockfile and +only routes to `localhost` when the requested DID matches the daemon +owner. Previously, cloning any DID would hit the local daemon — which +does not have the remote user's repos — and fail with 404. diff --git a/PLAN.md b/PLAN.md index ab206ca..2a38260 100644 --- a/PLAN.md +++ b/PLAN.md @@ -278,6 +278,18 @@ export const ForgeIssuesDefinition = { milestone : { type: 'string' }, }, + reaction: { + $actions: [ + { role: 'repo:repo/contributor', can: ['create', 'read', 'delete'] }, + { role: 'repo:repo/maintainer', can: ['create', 'read', 'delete'] }, + ], + $tags: { + $requiredTags : ['emoji'], + $allowUndefinedTags : false, + emoji : { type: 'string', maxLength: 10 }, + }, + }, + comment: { $actions: [ { role: 'repo:repo/contributor', can: ['create', 'read'] }, @@ -436,7 +448,8 @@ export const ForgePatchesDefinition = { reviewComment: { $actions: [ { role: 'repo:repo/contributor', can: ['create', 'read'] }, - { role: 'repo:repo/maintainer', can: ['create', 'read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, + { who: 'author', of: 'repo/patch/review/reviewComment', can: ['create', 'update', 'delete'] }, ], $tags: { $allowUndefinedTags : true, @@ -517,7 +530,7 @@ export const ForgeCiDefinition = { types : { checkSuite : { schema: 'https://enbox.org/schemas/forge/check-suite', dataFormats: ['application/json'] }, checkRun : { schema: 'https://enbox.org/schemas/forge/check-run', dataFormats: ['application/json'] }, - artifact : { dataFormats: ['application/octet-stream', 'application/gzip'] }, + artifact : { dataFormats: ['application/octet-stream', 'application/gzip', 'application/zip'] }, }, structure: { repo: { @@ -526,7 +539,7 @@ export const ForgeCiDefinition = { checkSuite: { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, - { role: 'repo:repo/maintainer', can: ['create', 'update'] }, + { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, ], $tags: { $requiredTags : ['commitSha', 'status'], @@ -540,7 +553,7 @@ export const ForgeCiDefinition = { checkRun: { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, - { who: 'author', of: 'repo/checkSuite', can: ['create', 'update'] }, + { who: 'author', of: 'repo/checkSuite', can: ['create', 'update', 'delete'] }, ], $tags: { $requiredTags : ['name', 'status'], @@ -553,8 +566,18 @@ export const ForgeCiDefinition = { artifact: { $actions : [ { role: 'repo:repo/contributor', can: ['read'] }, - { who: 'author', of: 'repo/checkSuite', can: ['create'] }, + { who: 'author', of: 'repo/checkSuite', can: ['create', 'delete'] }, ], + $tags: { + $requiredTags : ['name'], + $allowUndefinedTags : false, + name : { type: 'string' }, + size : { type: 'integer' }, + contentType : { type: 'string' }, + digest : { type: 'string' }, + expired : { type: 'boolean' }, + expiresAt : { type: 'string' }, + }, $size: { max: 104857600 }, // 100MB per artifact }, }, @@ -1368,7 +1391,7 @@ Registry CLI with 5 subcommands and 20 CLI tests — 525 total tests, 1282 asser - [x] **Web UI**: read-only repo/issue/PR viewer - [ ] **VS Code extension**: native IDE integration (separate repo, post-stabilization) - [x] **GitHub migration tool**: import repos, issues, PRs from GitHub -- [x] **GitHub API compatibility shim**: read + write (18 endpoints — 10 GET, 8 POST/PATCH/PUT) +- [x] **GitHub API compatibility shim**: read + write (583 endpoints — 327 GET, 256 POST/PATCH/PUT/DELETE) - [x] **Package manager shims**: npm registry proxy, Go module proxy, OCI/Docker registry proxy — 56 tests, 122 assertions - [x] **Unified daemon**: `ShimAdapter` interface, config-driven multi-shim process, `gitd daemon` command — 34 tests, 85 assertions @@ -1435,9 +1458,22 @@ gitd/ │ ├── helpers.ts # numericId, fromOpt, pagination, response builders │ ├── server.ts # HTTP server, router, handleShimRequest │ ├── repos.ts # GET /repos/:did/:repo -│ ├── issues.ts # GET /repos/:did/:repo/issues{/:number{/comments}} -│ ├── pulls.ts # GET /repos/:did/:repo/pulls{/:number{/reviews}} -│ ├── releases.ts # GET /repos/:did/:repo/releases{/tags/:tag} +│ ├── issues.ts # Issues, labels, milestones, assignees, reactions +│ ├── pulls.ts # Pull requests, reviews, review comments, merge +│ ├── releases.ts # Releases and release assets +│ ├── commit-comments.ts # Commit comments +│ ├── deployments.ts # Deployments and deployment statuses +│ ├── repo-metadata.ts # Topics, languages, deploy keys, collaborators +│ ├── git-refs.ts # Branches, tags, refs, branch protection +│ ├── git-objects.ts # Git blobs, trees, commits, archives, contributors +│ ├── checks.ts # Statuses, check suites, check runs +│ ├── actions.ts # Actions workflows, workflow runs, jobs, logs, and artifacts +│ ├── webhooks.ts # Repository webhooks +│ ├── notifications.ts # Notifications and thread subscriptions +│ ├── stars.ts # Stars and stargazers +│ ├── follows.ts # Followers and following +│ ├── orgs.ts # Organizations, org members, teams, team members +│ ├── search.ts # GitHub-compatible search endpoints │ └── users.ts # GET /users/:did │ └── resolver/ # Package resolver + trust chain │ ├── index.ts # Barrel re-export @@ -1481,7 +1517,7 @@ gitd/ │ ├── wiki/ │ └── org/ └── tests/ - ├── protocols.spec.ts # Structural validation tests (148 tests) + ├── protocols.spec.ts # Structural validation tests (159 tests) ├── schemas.spec.ts # JSON schema validation tests ├── integration.spec.ts # DWN integration tests (15 tests) ├── cli.spec.ts # CLI command tests (48 tests) @@ -1495,7 +1531,7 @@ gitd/ ├── ref-sync.spec.ts # Ref sync tests ├── verify.spec.ts # Signature verification tests ├── credential-helper.spec.ts # Credential helper tests - ├── github-shim.spec.ts # GitHub API shim tests (92 tests) + ├── github-shim.spec.ts # GitHub API shim tests (394 tests) ├── resolver.spec.ts # Resolver, attestation, trust chain tests (41 tests) ├── shims.spec.ts # Package manager shim tests (56 tests) ├── daemon.spec.ts # Unified daemon tests (34 tests) diff --git a/bun.lock b/bun.lock index b96cb57..6993d02 100644 --- a/bun.lock +++ b/bun.lock @@ -6,14 +6,14 @@ "name": "@enbox/dwn-git", "dependencies": { "@clack/prompts": "^1.0.1", - "@enbox/agent": "^0.3.1", - "@enbox/api": "^0.4.2", - "@enbox/auth": "^0.3.1", - "@enbox/crypto": "0.0.6", - "@enbox/dids": "0.0.7", - "@enbox/dwn-sdk-js": "0.1.0", - "@enbox/dwn-sql-store": "0.0.9", - "kysely": "0.26.3", + "@enbox/agent": "0.8.3", + "@enbox/api": "0.6.41", + "@enbox/auth": "0.6.49", + "@enbox/crypto": "0.1.1", + "@enbox/dids": "0.1.1", + "@enbox/dwn-sdk-js": "0.4.2", + "@enbox/dwn-sql-store": "0.0.27", + "kysely": "0.28.14", }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -49,73 +49,71 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.999.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/credential-provider-node": "^3.972.14", "@aws-sdk/middleware-bucket-endpoint": "^3.972.6", "@aws-sdk/middleware-expect-continue": "^3.972.6", "@aws-sdk/middleware-flexible-checksums": "^3.973.1", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-location-constraint": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-sdk-s3": "^3.972.15", "@aws-sdk/middleware-ssec": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/signature-v4-multi-region": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-blob-browser": "^4.2.11", "@smithy/hash-node": "^4.2.10", "@smithy/hash-stream-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/md5-js": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "@smithy/util-waiter": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-6ML2ls4nnOxm1kKzy2RgM+i8aS/9wgw6V91iqSibBYU/isYs8BvC2xcv8AsaWG5mOQjytjRzsBO5COxfWVPg3A=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.700.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-sso-oidc": "3.699.0", "@aws-sdk/client-sts": "3.699.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/credential-provider-node": "3.699.0", "@aws-sdk/middleware-bucket-endpoint": "3.696.0", "@aws-sdk/middleware-expect-continue": "3.696.0", "@aws-sdk/middleware-flexible-checksums": "3.697.0", "@aws-sdk/middleware-host-header": "3.696.0", "@aws-sdk/middleware-location-constraint": "3.696.0", "@aws-sdk/middleware-logger": "3.696.0", "@aws-sdk/middleware-recursion-detection": "3.696.0", "@aws-sdk/middleware-sdk-s3": "3.696.0", "@aws-sdk/middleware-ssec": "3.696.0", "@aws-sdk/middleware-user-agent": "3.696.0", "@aws-sdk/region-config-resolver": "3.696.0", "@aws-sdk/signature-v4-multi-region": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-endpoints": "3.696.0", "@aws-sdk/util-user-agent-browser": "3.696.0", "@aws-sdk/util-user-agent-node": "3.696.0", "@aws-sdk/xml-builder": "3.696.0", "@smithy/config-resolver": "^3.0.12", "@smithy/core": "^2.5.3", "@smithy/eventstream-serde-browser": "^3.0.13", "@smithy/eventstream-serde-config-resolver": "^3.0.10", "@smithy/eventstream-serde-node": "^3.0.12", "@smithy/fetch-http-handler": "^4.1.1", "@smithy/hash-blob-browser": "^3.1.9", "@smithy/hash-node": "^3.0.10", "@smithy/hash-stream-node": "^3.1.9", "@smithy/invalid-dependency": "^3.0.10", "@smithy/md5-js": "^3.0.10", "@smithy/middleware-content-length": "^3.0.12", "@smithy/middleware-endpoint": "^3.2.3", "@smithy/middleware-retry": "^3.0.27", "@smithy/middleware-serde": "^3.0.10", "@smithy/middleware-stack": "^3.0.10", "@smithy/node-config-provider": "^3.1.11", "@smithy/node-http-handler": "^3.3.1", "@smithy/protocol-http": "^4.1.7", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/url-parser": "^3.0.10", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.27", "@smithy/util-defaults-mode-node": "^3.0.27", "@smithy/util-endpoints": "^2.1.6", "@smithy/util-middleware": "^3.0.10", "@smithy/util-retry": "^3.0.10", "@smithy/util-stream": "^3.3.1", "@smithy/util-utf8": "^3.0.0", "@smithy/util-waiter": "^3.1.9", "tslib": "^2.6.2" } }, "sha512-TZTc8OZ873VodJNcsQ4Y/60f0Y0Ws5Z3Xm5QBgPCvhqQS7/+V28pXM3fAomJsKDWTxzH0nP9csX5cPvLybgdZQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.8", "@smithy/core": "^3.23.6", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A=="], + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.696.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/middleware-host-header": "3.696.0", "@aws-sdk/middleware-logger": "3.696.0", "@aws-sdk/middleware-recursion-detection": "3.696.0", "@aws-sdk/middleware-user-agent": "3.696.0", "@aws-sdk/region-config-resolver": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-endpoints": "3.696.0", "@aws-sdk/util-user-agent-browser": "3.696.0", "@aws-sdk/util-user-agent-node": "3.696.0", "@smithy/config-resolver": "^3.0.12", "@smithy/core": "^2.5.3", "@smithy/fetch-http-handler": "^4.1.1", "@smithy/hash-node": "^3.0.10", "@smithy/invalid-dependency": "^3.0.10", "@smithy/middleware-content-length": "^3.0.12", "@smithy/middleware-endpoint": "^3.2.3", "@smithy/middleware-retry": "^3.0.27", "@smithy/middleware-serde": "^3.0.10", "@smithy/middleware-stack": "^3.0.10", "@smithy/node-config-provider": "^3.1.11", "@smithy/node-http-handler": "^3.3.1", "@smithy/protocol-http": "^4.1.7", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/url-parser": "^3.0.10", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.27", "@smithy/util-defaults-mode-node": "^3.0.27", "@smithy/util-endpoints": "^2.1.6", "@smithy/util-middleware": "^3.0.10", "@smithy/util-retry": "^3.0.10", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-q5TTkd08JS0DOkHfUL853tuArf7NrPeqoS5UOvqJho8ibV9Ak/a/HO4kNvy9Nj3cib/toHYHsQIEtecUPSUUrQ=="], - "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg=="], + "@aws-sdk/client-sso-oidc": ["@aws-sdk/client-sso-oidc@3.699.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/credential-provider-node": "3.699.0", "@aws-sdk/middleware-host-header": "3.696.0", "@aws-sdk/middleware-logger": "3.696.0", "@aws-sdk/middleware-recursion-detection": "3.696.0", "@aws-sdk/middleware-user-agent": "3.696.0", "@aws-sdk/region-config-resolver": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-endpoints": "3.696.0", "@aws-sdk/util-user-agent-browser": "3.696.0", "@aws-sdk/util-user-agent-node": "3.696.0", "@smithy/config-resolver": "^3.0.12", "@smithy/core": "^2.5.3", "@smithy/fetch-http-handler": "^4.1.1", "@smithy/hash-node": "^3.0.10", "@smithy/invalid-dependency": "^3.0.10", "@smithy/middleware-content-length": "^3.0.12", "@smithy/middleware-endpoint": "^3.2.3", "@smithy/middleware-retry": "^3.0.27", "@smithy/middleware-serde": "^3.0.10", "@smithy/middleware-stack": "^3.0.10", "@smithy/node-config-provider": "^3.1.11", "@smithy/node-http-handler": "^3.3.1", "@smithy/protocol-http": "^4.1.7", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/url-parser": "^3.0.10", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.27", "@smithy/util-defaults-mode-node": "^3.0.27", "@smithy/util-endpoints": "^2.1.6", "@smithy/util-middleware": "^3.0.10", "@smithy/util-retry": "^3.0.10", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-sts": "^3.699.0" } }, "sha512-u8a1GorY5D1l+4FQAf4XBUC1T10/t7neuwT21r0ymrtMFSK2a9QqVHKMoLkvavAwyhJnARSBM9/UQC797PFOFw=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q=="], + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.699.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-sso-oidc": "3.699.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/credential-provider-node": "3.699.0", "@aws-sdk/middleware-host-header": "3.696.0", "@aws-sdk/middleware-logger": "3.696.0", "@aws-sdk/middleware-recursion-detection": "3.696.0", "@aws-sdk/middleware-user-agent": "3.696.0", "@aws-sdk/region-config-resolver": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-endpoints": "3.696.0", "@aws-sdk/util-user-agent-browser": "3.696.0", "@aws-sdk/util-user-agent-node": "3.696.0", "@smithy/config-resolver": "^3.0.12", "@smithy/core": "^2.5.3", "@smithy/fetch-http-handler": "^4.1.1", "@smithy/hash-node": "^3.0.10", "@smithy/invalid-dependency": "^3.0.10", "@smithy/middleware-content-length": "^3.0.12", "@smithy/middleware-endpoint": "^3.2.3", "@smithy/middleware-retry": "^3.0.27", "@smithy/middleware-serde": "^3.0.10", "@smithy/middleware-stack": "^3.0.10", "@smithy/node-config-provider": "^3.1.11", "@smithy/node-http-handler": "^3.3.1", "@smithy/protocol-http": "^4.1.7", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/url-parser": "^3.0.10", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.27", "@smithy/util-defaults-mode-node": "^3.0.27", "@smithy/util-endpoints": "^2.1.6", "@smithy/util-middleware": "^3.0.10", "@smithy/util-retry": "^3.0.10", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-++lsn4x2YXsZPIzFVwv3fSUVM55ZT0WRFmPeNilYIhZClxHLmVAWKH4I55cY9ry60/aTKYjzOXkWwyBKGsGvQg=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" } }, "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A=="], + "@aws-sdk/core": ["@aws-sdk/core@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/core": "^2.5.3", "@smithy/node-config-provider": "^3.1.11", "@smithy/property-provider": "^3.1.9", "@smithy/protocol-http": "^4.1.7", "@smithy/signature-v4": "^4.2.2", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/util-middleware": "^3.0.10", "fast-xml-parser": "4.4.1", "tslib": "^2.6.2" } }, "sha512-3c9III1k03DgvRZWg8vhVmfIXPG6hAciN9MzQTzqGngzWAELZF/WONRTRQuDFixVtarQatmLHYVw/atGeA2Byw=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-login": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/property-provider": "^3.1.9", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-T9iMFnJL7YTlESLpVFT3fg1Lkb1lD+oiaIC8KMpepb01gDUBIpj9+Y+pA/cgRWW0yRxmkDXNazAE2qQTVFGJzA=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/fetch-http-handler": "^4.1.1", "@smithy/node-http-handler": "^3.3.1", "@smithy/property-provider": "^3.1.9", "@smithy/protocol-http": "^4.1.7", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/util-stream": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-GV6EbvPi2eq1+WgY/o2RFA3P7HGmnkIzCNmhwtALFlqMroLYWKE7PSeHw66Uh1dFQeVESn0/+hiUNhu1mB0emA=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.14", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-ini": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.699.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/credential-provider-env": "3.696.0", "@aws-sdk/credential-provider-http": "3.696.0", "@aws-sdk/credential-provider-process": "3.696.0", "@aws-sdk/credential-provider-sso": "3.699.0", "@aws-sdk/credential-provider-web-identity": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/credential-provider-imds": "^3.2.6", "@smithy/property-provider": "^3.1.9", "@smithy/shared-ini-file-loader": "^3.1.10", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-sts": "^3.699.0" } }, "sha512-dXmCqjJnKmG37Q+nLjPVu22mNkrGHY8hYoOt3Jo9R2zr5MYV7s/NHsCHr+7E+BZ+tfZYLRPeB1wkpTeHiEcdRw=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.699.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.696.0", "@aws-sdk/credential-provider-http": "3.696.0", "@aws-sdk/credential-provider-ini": "3.699.0", "@aws-sdk/credential-provider-process": "3.696.0", "@aws-sdk/credential-provider-sso": "3.699.0", "@aws-sdk/credential-provider-web-identity": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/credential-provider-imds": "^3.2.6", "@smithy/property-provider": "^3.1.9", "@smithy/shared-ini-file-loader": "^3.1.10", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-MmEmNDo1bBtTgRmdNfdQksXu4uXe66s0p1hi1YPrn1h59Q605eq/xiWbGL6/3KdkViH6eGUuABeV2ODld86ylg=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/token-providers": "3.999.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/property-provider": "^3.1.9", "@smithy/shared-ini-file-loader": "^3.1.10", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-mL1RcFDe9sfmyU5K1nuFkO8UiJXXxLX4JO1gVaDIOvPqwStpUAwi3A1BoeZhWZZNQsiKI810RnYGo0E0WB/hUA=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.699.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.696.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/token-providers": "3.699.0", "@aws-sdk/types": "3.696.0", "@smithy/property-provider": "^3.1.9", "@smithy/shared-ini-file-loader": "^3.1.10", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-Ekp2cZG4pl9D8+uKWm4qO1xcm8/MeiI8f+dnlZm8aQzizeC+aXYy9GyoclSf6daK8KfRPiRfM7ZHBBL5dAfdMA=="], - "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.999.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/smithy-client": "^4.12.0", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.999.0" } }, "sha512-qoTdw8/LWZelGabV9biLUGMseIHU38Bji52mnslV/uHfCLeZNjmCzO6K/4H/v3OHMlydMCPRF0L+k/U8W83I6Q=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/property-provider": "^3.1.9", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-sts": "^3.696.0" } }, "sha512-XJ/CVlWChM0VCoc259vWguFUjJDn/QwDqHwbx+K9cg3v6yrqXfK5ai+p/6lx0nQpnk4JzPVeYYxWRpaTsGC9rg=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ=="], + "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.700.0", "", { "dependencies": { "@smithy/abort-controller": "^3.1.7", "@smithy/middleware-endpoint": "^3.2.3", "@smithy/smithy-client": "^3.4.4", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.700.0" } }, "sha512-bcPaWwihmfaVSJnrKb6WnpL2f7TTKAVbIwUUqXpau6/A7WonCI1oiYswCAmMQFJ3ETRVWz/3f8wiAoBKitTTyQ=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QMdffpU+GkSGC+bz6WdqlclqIeCsOfgX8JFZ5xvwDtX+UTj4mIXm3uXu7Ko6dBseRcJz1FA6T9OmlAAY6JgJUg=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@aws-sdk/util-arn-parser": "3.693.0", "@smithy/node-config-provider": "^3.1.11", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "@smithy/util-config-provider": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-V07jishKHUS5heRNGFpCWCSTjRJyQLynS/ncUeE8ZYtG66StOOQWftTwDfFOSoXlIqrXgb4oT9atryzXq7Z4LQ=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.973.1", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/crc64-nvme": "^3.972.3", "@aws-sdk/types": "^3.973.4", "@smithy/is-array-buffer": "^4.2.1", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-QLXsxsI6VW8LuGK+/yx699wzqP/NMCGk/hSGP+qtB+Lcff+23UlbahyouLlk+nfT7Iu021SkXBhnAuVd6IZcPw=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-vpVukqY3U2pb+ULeX0shs6L0aadNep6kKzjme/MyulPjtUDJpD3AekHsXRrCCGLmOqSKqRgQn5zhV9pQhHsb6Q=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.697.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/is-array-buffer": "^3.0.0", "@smithy/node-config-provider": "^3.1.11", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "@smithy/util-middleware": "^3.0.10", "@smithy/util-stream": "^3.3.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-K/y43P+NuHu5+21/29BoJSltcPekvcCU8i74KlGGHbW2Z105e5QVZlFjxivcPOjOA3gdC0W4SoFSIWam5RBhzw=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XdZ2TLwyj3Am6kvUc67vquQvs6+D8npXvXgyEUJAdkUDx5oMFJKOqpK+UpJhVDsEL068WAJl2NEGzbSik7dGJQ=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-zELJp9Ta2zkX7ELggMN9qMCgekqZhFC5V2rOr4hJDEb/Tte7gpfKSObAnw/3AYiVqt36sjHKfdkoTsuwGdEoDg=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="], + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-FgH12OB0q+DtTrP2aiDBddDKwL4BPOrm7w3VV9BJrSdkqQCNBPz8S1lb0y5eVH4tBG+2j7gKPlOv1wde4jF/iw=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-KhkHt+8AjCxcR/5Zp3++YPJPpFQzxpr+jmONiT/Jw2yqnSngZ0Yspm5wGoRx2hS1HJbyZNuaOWEGuJoxLeBKfA=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/core": "^3.23.6", "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-WDLgssevOU5BFx1s8jA7jj6cE5HuImz28sy9jKOaVtz0AW1lYqSzotzdyiybFaBcQTs5zxXOb2pUfyMxgEKY3Q=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-si/maV3Z0hH7qa99f9ru2xpS5HlfSVcasRlNUXKSDm611i7jFMWwGNLUOXFAOLhXotPX5G3Z6BLwL34oDeBMug=="], - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-acvMUX9jF4I2Ew+Z/EA6gfaFaz9ehci5wxBmXCZeulLuv8m+iGf6pY9uKz8TPjg39bdAz3hxoE0eLP8Qz+IYlA=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-arn-parser": "3.693.0", "@smithy/core": "^2.5.3", "@smithy/node-config-provider": "^3.1.11", "@smithy/protocol-http": "^4.1.7", "@smithy/signature-v4": "^4.2.2", "@smithy/smithy-client": "^3.4.4", "@smithy/types": "^3.7.1", "@smithy/util-config-provider": "^3.0.0", "@smithy/util-middleware": "^3.0.10", "@smithy/util-stream": "^3.3.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-M7fEiAiN7DBMHflzOFzh1I2MNSlLpbiH2ubs87bdRc2wZsDPSbs4l3v6h3WLhxoQK0bq6vcfroudrLBgvCuX3Q=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.6", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ=="], + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-w/d6O7AOZ7Pg3w2d3BxnX5RmGNWb5X4RNxF19rJqcgu/xqxxE/QwZTNd5a7eTsqLXAUIfbbR8hh0czVfC1pJLA=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.3", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.696.0", "", { "dependencies": { "@aws-sdk/core": "3.696.0", "@aws-sdk/types": "3.696.0", "@aws-sdk/util-endpoints": "3.696.0", "@smithy/core": "^2.5.3", "@smithy/protocol-http": "^4.1.7", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-Lvyj8CTyxrHI6GHd2YVZKIRI5Fmnugt3cpJo0VrKKEgK5zMySwEZ1n4dqPK6czYRWKd5+WnYHYAuU+Wdk6Jsjw=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/node-config-provider": "^3.1.11", "@smithy/types": "^3.7.1", "@smithy/util-config-provider": "^3.0.0", "@smithy/util-middleware": "^3.0.10", "tslib": "^2.6.2" } }, "sha512-7EuH142lBXjI8yH6dVS/CZeiK/WZsmb/8zP6bQbVYpMrppSTgB3MzZZdxVZGzL5r8zPQOU10wLC4kIMy0qdBVQ=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.3", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.696.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/protocol-http": "^4.1.7", "@smithy/signature-v4": "^4.2.2", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-ijPkoLjXuPtgxAYlDoYls8UaG/VKigROn9ebbvPL/orEY5umedd3iZTcS9T+uAf4Ur3GELLxMQiERZpfDKaz3g=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.999.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.699.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/property-provider": "^3.1.9", "@smithy/shared-ini-file-loader": "^3.1.10", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-sso-oidc": "^3.699.0" } }, "sha512-kuiEW9DWs7fNos/SM+y58HCPhcIzm1nEZLhe2/7/6+TvAYLuEWURYsbK48gzsxXlaJ2k/jGY3nIsA7RptbMOwA=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + "@aws-sdk/types": ["@aws-sdk/types@3.696.0", "", { "dependencies": { "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-9rTvUJIAj5d3//U5FDPWGJ1nFJLuWb30vugGOrWk7aNZ6y9tuA3PI7Cc9dP8WEXKVyK1vuuk8rSFP2iqXnlgrw=="], - "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg=="], + "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.693.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WC8x6ca+NRrtpAH64rWu+ryDZI3HuLwlEr8EU6/dbC/pt+r/zC0PBoC15VEygUaBA+isppCikQpGyEDu0Yj7gQ=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/types": "^3.7.1", "@smithy/util-endpoints": "^2.1.6", "tslib": "^2.6.2" } }, "sha512-T5s0IlBVX+gkb9g/I6CLt4yAZVzMSiGnbUqWihWsHvQR1WOoIcndQy/Oz/IJXT9T2ipoy7a80gzV6a5mglrioA=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.696.0", "", { "dependencies": { "@aws-sdk/types": "3.696.0", "@smithy/types": "^3.7.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Z5rVNDdmPOe6ELoM5AhF/ja5tSjbe6ctSctDPb0JdDf4dT0v2MfwhJKzXju2RzX8Es/77Glh7MlaXLE0kCB9+Q=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.696.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.696.0", "@aws-sdk/types": "3.696.0", "@smithy/node-config-provider": "^3.1.11", "@smithy/types": "^3.7.1", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-KhKqcfyXIB0SCCt+qsu4eJjsfiOrNzK5dCV7RAW2YIpp+msxGUUX0NdRE9rkzjiv+3EMktgJm3eEIS+yxtlVdQ=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.8", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.3.6", "tslib": "^2.6.2" } }, "sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg=="], - - "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.696.0", "", { "dependencies": { "@smithy/types": "^3.7.1", "tslib": "^2.6.2" } }, "sha512-dn1mX+EeqivoLYnY7p2qLrir0waPnCgS/0YdRCAVU2x14FgfUYCH6Im3w3oi2dMwhxfKY5lYVB5NKvZu7uI9lQ=="], "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -165,23 +163,23 @@ "@dnsquery/dns-packet": ["@dnsquery/dns-packet@6.1.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.4", "utf8-codec": "^1.0.0" } }, "sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew=="], - "@enbox/agent": ["@enbox/agent@0.3.1", "", { "dependencies": { "@enbox/common": "0.0.7", "@enbox/crypto": "0.0.8", "@enbox/dids": "0.0.9", "@enbox/dwn-clients": "0.1.0", "@enbox/dwn-sdk-js": "0.1.2", "@scure/bip39": "1.2.2", "abstract-level": "1.0.4", "ed25519-keygen": "0.4.11", "level": "8.0.0", "ms": "2.1.3", "ulidx": "2.1.0" } }, "sha512-sNAZQSOA/0g74rdKpUNg+UeH1CpYWPAEatj4B238oVr52BVeb3/k5iOnBswCfLYXS9nMqnEwqCMJtuw+Q7j9DQ=="], + "@enbox/agent": ["@enbox/agent@0.8.3", "", { "dependencies": { "@enbox/common": "0.1.1", "@enbox/crypto": "0.1.1", "@enbox/dids": "0.1.1", "@enbox/dwn-clients": "0.4.7", "@enbox/dwn-sdk-js": "0.4.2", "@scure/bip39": "1.2.2", "abstract-level": "1.0.4", "ed25519-keygen": "0.4.11", "level": "8.0.1", "ms": "2.1.3", "ulidx": "2.1.0" } }, "sha512-LMYUrKpy1fFJJa4MEnQ0BYh5GQ1e016R6Jc82ZWnyMNTeXF6PZzFoZgt+nzZ3ACue9TMIl21lFFN6m44WUYghg=="], - "@enbox/api": ["@enbox/api@0.4.2", "", { "dependencies": { "@enbox/agent": "0.3.1", "@enbox/auth": "0.3.1", "@enbox/common": "0.0.7", "@enbox/dwn-clients": "0.1.0" } }, "sha512-kyhH+HAH4Lvc7gOb3GaELGGymDTSuEIPILR8eDrrLq8XmCp4Gf3oIEBl/JSNKVU8o131PNXaF5z5vdkxV32Vdw=="], + "@enbox/api": ["@enbox/api@0.6.41", "", { "dependencies": { "@enbox/agent": "0.8.3", "@enbox/auth": "0.6.49", "@enbox/common": "0.1.1", "@enbox/dwn-clients": "0.4.7" } }, "sha512-Uasj59s5quoatO6RTnQSLMhvBXf8/THnEfwSVaRdUiRXpO7Z+FwqzD8evMl6fHB5rQSQXCnk1mp9JYqUP8IEIQ=="], - "@enbox/auth": ["@enbox/auth@0.3.1", "", { "dependencies": { "@enbox/agent": "0.3.1", "@enbox/common": "0.0.7", "@enbox/dids": "0.0.9", "@enbox/dwn-clients": "0.1.0", "level": "8.0.0" } }, "sha512-6j7CoYIoluJzEinMGwrKgH+JH32l2WFa7d7skL0ux6qehbW7a05q2TPO7fDKnPH12Km7BjVvM/O2+Ta+HBpdHg=="], + "@enbox/auth": ["@enbox/auth@0.6.49", "", { "dependencies": { "@enbox/agent": "0.8.3", "@enbox/common": "0.1.1", "@enbox/crypto": "0.1.1", "@enbox/dids": "0.1.1", "@enbox/dwn-clients": "0.4.7", "@enbox/dwn-sdk-js": "0.4.2", "level": "8.0.1" } }, "sha512-FQfn7rWVpM29w2X/lbcogc7OHj4s/Hg4n+0UkrWJQHNphZLcZIg4y+jad0uvCh/f2bj/Ldbgcklnh3AUVT42ng=="], - "@enbox/common": ["@enbox/common@0.0.7", "", { "dependencies": { "@isaacs/ttlcache": "1.4.1", "level": "8.0.1", "multiformats": "13.1.0" } }, "sha512-LJBg7RnJxg0KezaPjMPrW/LfzU0QstXrZ+MrdL8C2a+0vLF184NAGqQha59ye87S14dR4kKvshFiJnAkyZxCsw=="], + "@enbox/common": ["@enbox/common@0.1.1", "", { "dependencies": { "@isaacs/ttlcache": "1.4.1", "level": "8.0.1", "multiformats": "11.0.2" } }, "sha512-QsrjbYklh05nvCmn6a33iqCei1PGyNDOhnbB6bNgdnLyzsf0TKwzZgnx1qlpoiCwO6GJI4I2qXIfK4aP/ngeug=="], - "@enbox/crypto": ["@enbox/crypto@0.0.6", "", { "dependencies": { "@enbox/common": "0.0.5", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/hashes": "1.4.0", "cborg": "^4.5.8" } }, "sha512-FEXM1VCcvrd1yxIHPbc7H2nTwdwl9PnfiIz0x8Y4oCg0AOEC/2kH9SFmPAGYPJInZqLgrAarMQkS+Dn8LPKdTQ=="], + "@enbox/crypto": ["@enbox/crypto@0.1.1", "", { "dependencies": { "@enbox/common": "0.1.1", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/hashes": "1.4.0", "cborg": "4.5.8" } }, "sha512-Jd4pYGYG7SmmEQ7Gy2rFb0dfb9hEIsNM7AGptTI1KM+Lp8bMgPF93H+x3s8dPuThfjGh2285i5I5+Aott6xZ2w=="], - "@enbox/dids": ["@enbox/dids@0.0.7", "", { "dependencies": { "@decentralized-identity/ion-sdk": "1.0.4", "@dnsquery/dns-packet": "6.1.1", "@enbox/common": "0.0.5", "@enbox/crypto": "0.0.6", "abstract-level": "1.0.4", "bencode": "4.0.0", "level": "8.0.1", "ms": "2.1.3" } }, "sha512-/adZ+WTTRlThxMCr+oFja154PMRXSnEVocMxdP607wSKwTw3UkXBQ8Jz7wbX0b2iR46MreiEtLD0ecoDYiqeGw=="], + "@enbox/dids": ["@enbox/dids@0.1.1", "", { "dependencies": { "@decentralized-identity/ion-sdk": "1.0.4", "@dnsquery/dns-packet": "6.1.1", "@enbox/common": "0.1.1", "@enbox/crypto": "0.1.1", "abstract-level": "1.0.4", "bencode": "4.0.0", "level": "8.0.1", "ms": "2.1.3" } }, "sha512-t2mNm8IKkZ2c2ouKnVvbPUNFD+n1Neu+gt+RYncovH1ARkIv6Azf1zTezubU8snXefeEPQBfS/sia/65paXoCA=="], - "@enbox/dwn-clients": ["@enbox/dwn-clients@0.1.0", "", { "dependencies": { "@enbox/common": "0.0.7", "@enbox/crypto": "0.0.8", "@enbox/dwn-sdk-js": "0.1.2", "ms": "2.1.3" } }, "sha512-t+KJfguzLuJdp2dLC8kO+7wZqroui/WmsLtJUpWSjCpdo4kUqbWGnor8lEBq/HN4BHrWLiKqL4H7vCwNbc1XdA=="], + "@enbox/dwn-clients": ["@enbox/dwn-clients@0.4.7", "", { "dependencies": { "@enbox/common": "0.1.1", "@enbox/crypto": "0.1.1", "@enbox/dwn-sdk-js": "0.4.2", "ms": "2.1.3" } }, "sha512-AP0GO6ORHlmoZ/c7UMXdtGvMxahgJgkB2s2r9eVDwRkBdkqYYoQx7+UpldlCP9NVu6rzJ8hDWba9HkL7Hm6XIQ=="], - "@enbox/dwn-sdk-js": ["@enbox/dwn-sdk-js@0.1.0", "", { "dependencies": { "@enbox/crypto": "0.0.6", "@enbox/dids": "0.0.7", "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", "@noble/ciphers": "0.5.3", "@noble/curves": "1.4.2", "@noble/ed25519": "2.0.0", "@noble/secp256k1": "2.0.0", "abstract-level": "1.0.3", "ajv": "8.18.0", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "level": "8.0.0", "lodash": "4.17.21", "lru-cache": "9.1.2", "mitt": "^3.0.1", "multiformats": "11.0.2", "uint8arrays": "5.1.0", "ulidx": "2.1.0" } }, "sha512-iW10d+fRCeoYZ0mKeaaob25Z615xIT34QO8jPgCObzBheilFH3bUnDkCDDuVBeef4jt9VRVlU1nFC9Kn4+I9+g=="], + "@enbox/dwn-sdk-js": ["@enbox/dwn-sdk-js@0.4.2", "", { "dependencies": { "@enbox/common": "0.1.1", "@enbox/crypto": "0.1.1", "@enbox/dids": "0.1.1", "@ipld/dag-cbor": "9.0.5", "@js-temporal/polyfill": "0.4.4", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/ed25519": "2.0.0", "@noble/secp256k1": "2.0.0", "abstract-level": "1.0.4", "ajv": "8.18.0", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "level": "8.0.1", "lodash": "4.17.21", "lru-cache": "9.1.2", "mitt": "3.0.1", "multiformats": "11.0.2", "uint8arrays": "5.1.0", "ulidx": "2.1.0" } }, "sha512-3Cq2ua2fvRJzLFvx1IXW4ChIMAMKz4QlyImqu+am+LwHYMCuRup2Wvd73wT+e1tPij8lm/YN2Yk77wjubK9/Uw=="], - "@enbox/dwn-sql-store": ["@enbox/dwn-sql-store@0.0.9", "", { "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/lib-storage": "^3.700.0", "@enbox/dwn-sdk-js": "0.1.0", "@ipld/dag-cbor": "9.0.5", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "kysely": "0.26.3", "multiformats": "12.0.1" } }, "sha512-OONdvDPnIcs0vwDCDPUEhpf6wmnb0Z4XN8/06iqoLqbzVOTmxYE7CxZq7j5ZgDvfql/GEeMK220zG42HdsoW9w=="], + "@enbox/dwn-sql-store": ["@enbox/dwn-sql-store@0.0.27", "", { "dependencies": { "@aws-sdk/client-s3": "3.700.0", "@aws-sdk/lib-storage": "3.700.0", "@enbox/dwn-sdk-js": "0.4.2", "@ipld/dag-cbor": "9.0.5", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "kysely": "0.28.14", "multiformats": "11.0.2" } }, "sha512-Fo42rDikp6erT979wOu+W83Cg7bOO0fPhLa2wbuxaC0lz1LKB7AzjhVW0ZmX0qq6dflM1Q3l08z3Q9led/y6DQ=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -201,7 +199,7 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@ipld/dag-cbor": ["@ipld/dag-cbor@9.0.3", "", { "dependencies": { "cborg": "^2.0.1", "multiformats": "^12.0.1" } }, "sha512-A2UFccS0+sARK9xwXiVZIaWbLbPxLGP3UZOjBeOMWfDY04SXi8h1+t4rHBzOlKYF/yWNm3RbFLyclWO7hZcy4g=="], + "@ipld/dag-cbor": ["@ipld/dag-cbor@9.0.5", "", { "dependencies": { "cborg": "^4.0.0", "multiformats": "^12.0.1" } }, "sha512-TyqgtxEojc98rvxg4NGM+73JzQeM4+tK2VQes/in2mdyhO+1wbGuBijh1tvi9BErQ/dEblxs9v4vEQSX8mFCIw=="], "@ipld/dag-pb": ["@ipld/dag-pb@4.1.5", "", { "dependencies": { "multiformats": "^13.1.0" } }, "sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA=="], @@ -241,107 +239,105 @@ "@scure/bip39": ["@scure/bip39@1.2.2", "", { "dependencies": { "@noble/hashes": "~1.3.2", "@scure/base": "~1.1.4" } }, "sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA=="], - "@smithy/abort-controller": ["@smithy/abort-controller@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g=="], - - "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA=="], + "@smithy/abort-controller": ["@smithy/abort-controller@3.1.9", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw=="], - "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.2", "", { "dependencies": { "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg=="], + "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jSqRnZvkT4egkq/7b6/QRCNXmmYVcHwnJldqJ3IhVpQE2atObVJ137xmGeuGFhjFUr8gCEVAOKwSY79OvpbDaQ=="], - "@smithy/config-resolver": ["@smithy/config-resolver@4.4.9", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q=="], + "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@3.0.1", "", { "dependencies": { "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-VEYtPvh5rs/xlyqpm5NRnfYLZn+q0SRPELbvBV+C/G7IQ+ouTuo+NKKa3ShG5OaFR8NYVMXls9hPYLTvIKKDrQ=="], - "@smithy/core": ["@smithy/core@3.23.6", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.11", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" } }, "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg=="], + "@smithy/config-resolver": ["@smithy/config-resolver@3.0.13", "", { "dependencies": { "@smithy/node-config-provider": "^3.1.12", "@smithy/types": "^3.7.2", "@smithy/util-config-provider": "^3.0.0", "@smithy/util-middleware": "^3.0.11", "tslib": "^2.6.2" } }, "sha512-Gr/qwzyPaTL1tZcq8WQyHhTZREER5R1Wytmz4WnVGL4onA3dNk6Btll55c8Vr58pLdvWZmtG8oZxJTw3t3q7Jg=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.10", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ=="], + "@smithy/core": ["@smithy/core@2.5.7", "", { "dependencies": { "@smithy/middleware-serde": "^3.0.11", "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-middleware": "^3.0.11", "@smithy/util-stream": "^3.3.4", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-8olpW6mKCa0v+ibCjoCzgZHQx1SQmZuW/WkrdZo73wiTprTH6qhmskT60QLFdT9DRa5mXxjz89kQPZ7ZSsoqqg=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.10", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^3.1.12", "@smithy/property-provider": "^3.1.11", "@smithy/types": "^3.7.2", "@smithy/url-parser": "^3.0.11", "tslib": "^2.6.2" } }, "sha512-ZCY2yD0BY+K9iMXkkbnjo+08T2h8/34oHd0Jmh6BZUSZwaaGlGCyBT/3wnS7u7Xl33/EEfN4B6nQr3Gx5bYxgw=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.10", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@3.1.10", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^3.7.2", "@smithy/util-hex-encoding": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-323B8YckSbUH0nMIpXn7HZsAVKHYHFUODa8gG9cHo0ySvA1fr5iWaNT+iIL0UCqUzG6QPHA3BSsBtRQou4mMqQ=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA=="], + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@3.0.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^3.0.13", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-kbrt0vjOIihW3V7Cqj1SXQvAI5BR8SnyQYsandva0AOR307cXAc+IhPngxIPslxTLfxwDpNu0HzCAq6g42kCPg=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.10", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ=="], + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-P2pnEp4n75O+QHjyO7cbw/vsw5l93K/8EWyjNCAAybYwUmj3M+hjSQZ9P5TVdUgEG08ueMAP5R4FkuSkElZ5tQ=="], - "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.10", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw=="], + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@3.0.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^3.0.13", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-zqy/9iwbj8Wysmvi7Lq7XFLeDgjRpTbCfwBhJa8WbrylTAHiAu6oQTwdY7iu2lxigbc9YYr9vPv5SzYny5tCXQ=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g=="], + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@3.0.13", "", { "dependencies": { "@smithy/eventstream-codec": "^3.1.10", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-L1Ib66+gg9uTnqp/18Gz4MDpJPKRE44geOjOQ2SVc0eiaO5l255ADziATZgjQjqumC7yPtp1XnjHlF1srcwjKw=="], - "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.11", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.1", "@smithy/chunked-blob-reader-native": "^4.2.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="], - "@smithy/hash-node": ["@smithy/hash-node@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w=="], + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@3.1.10", "", { "dependencies": { "@smithy/chunked-blob-reader": "^4.0.0", "@smithy/chunked-blob-reader-native": "^3.0.1", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-elwslXOoNunmfS0fh55jHggyhccobFkexLYC1ZeZ1xP2BTSrcIBaHV2b4xUQOdctrSNOpMqOZH1r2XzWTEhyfA=="], - "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA=="], + "@smithy/hash-node": ["@smithy/hash-node@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-emP23rwYyZhQBvklqTtwetkQlqbNYirDiEEwXl2v0GYWMnCzxst7ZaRAnWuy28njp5kAH54lvkdG37MblZzaHA=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org=="], + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@3.1.10", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-olomK/jZQ93OMayW1zfTHwcbwBdhcZOHsyWyiZ9h9IXvc1mCD/VuvzbLb3Gy/qNJwI4MANPLctTp2BucV2oU/Q=="], - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-NuQmVPEJjUX6c+UELyVz8kUx8Q539EDeNwbRyu4IIF8MeV7hUtq1FB3SHVyki2u++5XLMFqngeMKk7ccspnNyQ=="], - "@smithy/md5-js": ["@smithy/md5-js@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.10", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w=="], + "@smithy/md5-js": ["@smithy/md5-js@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-3NM0L3i2Zm4bbgG6Ymi9NBcxXhryi3uE8fIfHJZIOfZVxOkGdjdgjR9A06SFIZCfnEIWKXZdm6Yq5/aPXFFhsQ=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.20", "", { "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-serde": "^4.2.11", "@smithy/node-config-provider": "^4.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" } }, "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw=="], + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@3.0.13", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-zfMhzojhFpIX3P5ug7jxTjfUcIPcGjcQYzB9t+rv0g1TX7B0QdwONW+ATouaLoD7h7LOw/ZlXfkq4xJ/g2TrIw=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.37", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/service-error-classification": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" } }, "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@3.2.8", "", { "dependencies": { "@smithy/core": "^2.5.7", "@smithy/middleware-serde": "^3.0.11", "@smithy/node-config-provider": "^3.1.12", "@smithy/shared-ini-file-loader": "^3.1.12", "@smithy/types": "^3.7.2", "@smithy/url-parser": "^3.0.11", "@smithy/util-middleware": "^3.0.11", "tslib": "^2.6.2" } }, "sha512-OEJZKVUEhMOqMs3ktrTWp7UvvluMJEvD5XgQwRePSbDg1VvBaL8pX8mwPltFn6wk1GySbcVwwyldL8S+iqnrEQ=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@3.0.34", "", { "dependencies": { "@smithy/node-config-provider": "^3.1.12", "@smithy/protocol-http": "^4.1.8", "@smithy/service-error-classification": "^3.0.11", "@smithy/smithy-client": "^3.7.0", "@smithy/types": "^3.7.2", "@smithy/util-middleware": "^3.0.11", "@smithy/util-retry": "^3.0.11", "tslib": "^2.6.2", "uuid": "^9.0.1" } }, "sha512-yVRr/AAtPZlUvwEkrq7S3x7Z8/xCd97m2hLDaqdz6ucP2RKHsBjEqaUA2ebNv2SsZoPEi+ZD0dZbOB1u37tGCA=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA=="], + "@smithy/middleware-serde": ["@smithy/middleware-serde@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-KzPAeySp/fOoQA82TpnwItvX8BBURecpx6ZMu75EZDkAcnPtO6vf7q4aH5QHs/F1s3/snQaSFbbUMcFFZ086Mw=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.10", "", { "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-1HGo9a6/ikgOMrTrWL/WiN9N8GSVYpuRQO5kjstAq4CvV59bjqnh7TbdXGQ4vxLD3xlSjfBjq5t1SOELePsLnA=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.12", "", { "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w=="], + "@smithy/node-config-provider": ["@smithy/node-config-provider@3.1.12", "", { "dependencies": { "@smithy/property-provider": "^3.1.11", "@smithy/shared-ini-file-loader": "^3.1.12", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-O9LVEu5J/u/FuNlZs+L7Ikn3lz7VB9hb0GtPT9MQeiBmtK8RSY3ULmsZgXhe6VAlgTw0YO+paQx4p8xdbs43vQ=="], - "@smithy/property-provider": ["@smithy/property-provider@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@3.3.3", "", { "dependencies": { "@smithy/abort-controller": "^3.1.9", "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-BrpZOaZ4RCbcJ2igiSNG16S+kgAc65l/2hmxWdmhyoGWHTLlzQzr06PXavJp9OBlPEG/sHlqdxjWmjzV66+BSQ=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A=="], + "@smithy/property-provider": ["@smithy/property-provider@3.1.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A=="], - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA=="], + "@smithy/protocol-http": ["@smithy/protocol-http@4.1.8", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-hmgIAVyxw1LySOwkgMIUN0kjN8TG9Nc85LJeEmEE/cNEe2rkHDUWhnJf2gxcSRFLWsyqWsrZGw40ROjUogg+Iw=="], - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q=="], + "@smithy/querystring-builder": ["@smithy/querystring-builder@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-uri-escape": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg=="], - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg=="], + "@smithy/querystring-parser": ["@smithy/querystring-parser@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-Je3kFvCsFMnso1ilPwA7GtlbPaTixa3WwC+K21kmMZHsBEOZYQaqxcMqeFFoU7/slFjKDIpiiPydvdJm8Q/MCw=="], - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug=="], + "@smithy/service-error-classification": ["@smithy/service-error-classification@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2" } }, "sha512-QnYDPkyewrJzCyaeI2Rmp7pDwbUETe+hU8ADkXmgNusO1bgHBH7ovXJiYmba8t0fNfJx75fE8dlM6SEmZxheog=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.10", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-uri-escape": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA=="], + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@3.1.12", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-1xKSGI+U9KKdbG2qDvIR9dGrw3CNx+baqJfyr0igKEpjbHL5stsqAesYBzHChYHlelWtb87VnLWlhvfCz13H8Q=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.0", "", { "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-stack": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" } }, "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ=="], + "@smithy/signature-v4": ["@smithy/signature-v4@4.2.4", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "@smithy/util-hex-encoding": "^3.0.0", "@smithy/util-middleware": "^3.0.11", "@smithy/util-uri-escape": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-5JWeMQYg81TgU4cG+OexAWdvDTs5JDdbEZx+Qr1iPbvo91QFGzjy0IkXAKaXUHqmKUJgSHK0ZxnCkgZpzkeNTA=="], - "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@smithy/smithy-client": ["@smithy/smithy-client@3.7.0", "", { "dependencies": { "@smithy/core": "^2.5.7", "@smithy/middleware-endpoint": "^3.2.8", "@smithy/middleware-stack": "^3.0.11", "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "@smithy/util-stream": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-9wYrjAZFlqWhgVo3C4y/9kpc68jgiSsKUnsFPzr/MSiRL93+QRDafGTfhhKAb2wsr69Ru87WTiqSfQusSmWipA=="], - "@smithy/url-parser": ["@smithy/url-parser@4.2.10", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A=="], + "@smithy/types": ["@smithy/types@3.7.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg=="], - "@smithy/util-base64": ["@smithy/util-base64@4.3.1", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w=="], + "@smithy/url-parser": ["@smithy/url-parser@3.0.11", "", { "dependencies": { "@smithy/querystring-parser": "^3.0.11", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-TmlqXkSk8ZPhfc+SQutjmFr5FjC0av3GZP4B/10caK1SbRwe/v+Wzu/R6xEKxoNqL+8nY18s1byiy6HqPG37Aw=="], - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g=="], + "@smithy/util-base64": ["@smithy/util-base64@3.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ=="], - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw=="], + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ=="], - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.1", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig=="], + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA=="], - "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.36", "", { "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew=="], + "@smithy/util-config-provider": ["@smithy/util-config-provider@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.39", "", { "dependencies": { "@smithy/config-resolver": "^4.4.9", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@3.0.34", "", { "dependencies": { "@smithy/property-provider": "^3.1.11", "@smithy/smithy-client": "^3.7.0", "@smithy/types": "^3.7.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FumjjF631lR521cX+svMLBj3SwSDh9VdtyynTYDAiBDEf8YPP5xORNXKQ9j0105o5+ARAGnOOP/RqSl40uXddA=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@3.0.34", "", { "dependencies": { "@smithy/config-resolver": "^3.0.13", "@smithy/credential-provider-imds": "^3.2.8", "@smithy/node-config-provider": "^3.1.12", "@smithy/property-provider": "^3.1.11", "@smithy/smithy-client": "^3.7.0", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-vN6aHfzW9dVVzkI0wcZoUXvfjkl4CSbM9nE//08lmUMyf00S75uuCpTrqF9uD4bD9eldIXlt53colrlwKAT8Gw=="], - "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA=="], + "@smithy/util-endpoints": ["@smithy/util-endpoints@2.1.7", "", { "dependencies": { "@smithy/node-config-provider": "^3.1.12", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-tSfcqKcN/Oo2STEYCABVuKgJ76nyyr6skGl9t15hs+YaiU06sgMkN7QYjo0BbVw+KT26zok3IzbdSOksQ4YzVw=="], - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA=="], + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ=="], - "@smithy/util-retry": ["@smithy/util-retry@4.2.10", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A=="], + "@smithy/util-middleware": ["@smithy/util-middleware@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow=="], - "@smithy/util-stream": ["@smithy/util-stream@4.5.15", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw=="], + "@smithy/util-retry": ["@smithy/util-retry@3.0.11", "", { "dependencies": { "@smithy/service-error-classification": "^3.0.11", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-hJUC6W7A3DQgaee3Hp9ZFcOxVDZzmBIRBPlUAk8/fSOEl7pE/aX7Dci0JycNOnm9Mfr0KV2XjIlUOcGWXQUdVQ=="], - "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q=="], + "@smithy/util-stream": ["@smithy/util-stream@3.3.4", "", { "dependencies": { "@smithy/fetch-http-handler": "^4.1.3", "@smithy/node-http-handler": "^3.3.3", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-hex-encoding": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-SGhGBG/KupieJvJSZp/rfHHka8BFgj56eek9px4pp7lZbOF+fRiVr4U7A3y3zJD8uGhxq32C5D96HxsTC9BckQ=="], - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.1", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g=="], + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg=="], - "@smithy/util-waiter": ["@smithy/util-waiter@4.2.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg=="], + "@smithy/util-utf8": ["@smithy/util-utf8@3.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA=="], - "@smithy/uuid": ["@smithy/uuid@1.1.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw=="], + "@smithy/util-waiter": ["@smithy/util-waiter@3.2.0", "", { "dependencies": { "@smithy/abort-controller": "^3.1.9", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-PpjSboaDUE6yl+1qlg3Si57++e84oXdWGbuFUSAciXsVfEZJJJupR2Nb0QuXHiunt2vGR+1PTizOMvnUPaG2Qg=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="], @@ -489,7 +485,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@5.3.6", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA=="], + "fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -609,11 +605,11 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kysely": ["kysely@0.26.3", "", {}, "sha512-yWSgGi9bY13b/W06DD2OCDDHQmq1kwTGYlQ4wpZkMOJqMGCstVCFIvxCCVG4KfY1/3G0MhDAcZsip/Lw8/vJWw=="], + "kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="], "layerr": ["layerr@2.1.0", "", {}, "sha512-xDD9suWxfBYeXgqffRVH/Wqh+mqZrQcqPRn0I0ijl7iJQ7vu8gMGPt1Qop59pEW/jaIDNUN7+PX1Qk40+vuflg=="], - "level": ["level@8.0.0", "", { "dependencies": { "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-ypf0jjAk2BWI33yzEaaotpq7fkOPALKAgDBxggO6Q9HGX2MRXn0wbP1Jn/tJv1gtL867+YOjOB49WaUF3UoJNQ=="], + "level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], "level-supports": ["level-supports@4.0.1", "", {}, "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA=="], @@ -773,7 +769,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -813,6 +809,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -827,54 +825,34 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "@aws-sdk/lib-storage/buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], - - "@decentralized-identity/ion-sdk/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], - - "@enbox/agent/@enbox/crypto": ["@enbox/crypto@0.0.8", "", { "dependencies": { "@enbox/common": "0.0.7", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/hashes": "1.4.0", "cborg": "^4.5.8" } }, "sha512-aLuIZ/EuzsAE0ywhyXWuJsb96tuKvRPRCigkJ8psDFMtzSqNPPCu1OZLVaH6I/JnuIn/377UgNCpxpACZH1/Tg=="], - - "@enbox/agent/@enbox/dids": ["@enbox/dids@0.0.9", "", { "dependencies": { "@decentralized-identity/ion-sdk": "1.0.4", "@dnsquery/dns-packet": "6.1.1", "@enbox/common": "0.0.7", "@enbox/crypto": "0.0.8", "abstract-level": "1.0.4", "bencode": "4.0.0", "level": "8.0.1", "ms": "2.1.3" } }, "sha512-G02XX/2OJd55lrFzaLfMMPqLdg4Frl3XRRUs4m2U7lk8Y5seXw2E3GGNprlL69UxGtEisOd9rtu91FKm504QzQ=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/agent/@enbox/dwn-sdk-js": ["@enbox/dwn-sdk-js@0.1.2", "", { "dependencies": { "@enbox/crypto": "0.0.8", "@enbox/dids": "0.0.9", "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", "@noble/ciphers": "0.5.3", "@noble/curves": "1.4.2", "@noble/ed25519": "2.0.0", "@noble/secp256k1": "2.0.0", "abstract-level": "1.0.3", "ajv": "8.18.0", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "level": "8.0.0", "lodash": "4.17.21", "lru-cache": "9.1.2", "mitt": "^3.0.1", "multiformats": "11.0.2", "uint8arrays": "5.1.0", "ulidx": "2.1.0" } }, "sha512-vNTQ/8cnZM1rRSXwSzLfPnVAsiQH9EKI2FT4CXwUADNut0Y9nJBAW62RJ4ORoQ3dguRmCooYSYO+Crk3deBYpg=="], + "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/auth/@enbox/dids": ["@enbox/dids@0.0.9", "", { "dependencies": { "@decentralized-identity/ion-sdk": "1.0.4", "@dnsquery/dns-packet": "6.1.1", "@enbox/common": "0.0.7", "@enbox/crypto": "0.0.8", "abstract-level": "1.0.4", "bencode": "4.0.0", "level": "8.0.1", "ms": "2.1.3" } }, "sha512-G02XX/2OJd55lrFzaLfMMPqLdg4Frl3XRRUs4m2U7lk8Y5seXw2E3GGNprlL69UxGtEisOd9rtu91FKm504QzQ=="], + "@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/common/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], - - "@enbox/common/multiformats": ["multiformats@13.1.0", "", {}, "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@enbox/crypto/@enbox/common": ["@enbox/common@0.0.5", "", { "dependencies": { "@isaacs/ttlcache": "1.4.1", "level": "8.0.1", "multiformats": "13.1.0" } }, "sha512-Zwb7s4JTzfXtHrFDA0MM6gfFcN65qVey6vNTG6gAM1xXwO0WDlovZxvKa2wLEXPW7Zf/ojwuPpCPCpJ4+Y7byA=="], + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/dids/@enbox/common": ["@enbox/common@0.0.5", "", { "dependencies": { "@isaacs/ttlcache": "1.4.1", "level": "8.0.1", "multiformats": "13.1.0" } }, "sha512-Zwb7s4JTzfXtHrFDA0MM6gfFcN65qVey6vNTG6gAM1xXwO0WDlovZxvKa2wLEXPW7Zf/ojwuPpCPCpJ4+Y7byA=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@enbox/dids/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/dwn-clients/@enbox/crypto": ["@enbox/crypto@0.0.8", "", { "dependencies": { "@enbox/common": "0.0.7", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/hashes": "1.4.0", "cborg": "^4.5.8" } }, "sha512-aLuIZ/EuzsAE0ywhyXWuJsb96tuKvRPRCigkJ8psDFMtzSqNPPCu1OZLVaH6I/JnuIn/377UgNCpxpACZH1/Tg=="], + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], - "@enbox/dwn-clients/@enbox/dwn-sdk-js": ["@enbox/dwn-sdk-js@0.1.2", "", { "dependencies": { "@enbox/crypto": "0.0.8", "@enbox/dids": "0.0.9", "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", "@noble/ciphers": "0.5.3", "@noble/curves": "1.4.2", "@noble/ed25519": "2.0.0", "@noble/secp256k1": "2.0.0", "abstract-level": "1.0.3", "ajv": "8.18.0", "interface-blockstore": "5.2.3", "interface-store": "5.1.2", "ipfs-unixfs-exporter": "13.1.5", "ipfs-unixfs-importer": "15.1.5", "level": "8.0.0", "lodash": "4.17.21", "lru-cache": "9.1.2", "mitt": "^3.0.1", "multiformats": "11.0.2", "uint8arrays": "5.1.0", "ulidx": "2.1.0" } }, "sha512-vNTQ/8cnZM1rRSXwSzLfPnVAsiQH9EKI2FT4CXwUADNut0Y9nJBAW62RJ4ORoQ3dguRmCooYSYO+Crk3deBYpg=="], + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@enbox/dwn-sdk-js/@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="], + "@aws-sdk/lib-storage/buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], - "@enbox/dwn-sdk-js/abstract-level": ["abstract-level@1.0.3", "", { "dependencies": { "buffer": "^6.0.3", "catering": "^2.1.0", "is-buffer": "^2.0.5", "level-supports": "^4.0.0", "level-transcoder": "^1.0.1", "module-error": "^1.0.1", "queue-microtask": "^1.2.3" } }, "sha512-t6jv+xHy+VYwc4xqZMn2Pa9DjcdzvzZmQGRjTFc8spIbRGHgBrEKbPq+rYXc7CCo0lxgYvSgKVg9qZAhpVQSjA=="], + "@decentralized-identity/ion-sdk/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], "@enbox/dwn-sdk-js/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@enbox/dwn-sql-store/@ipld/dag-cbor": ["@ipld/dag-cbor@9.0.5", "", { "dependencies": { "cborg": "^4.0.0", "multiformats": "^12.0.1" } }, "sha512-TyqgtxEojc98rvxg4NGM+73JzQeM4+tK2VQes/in2mdyhO+1wbGuBijh1tvi9BErQ/dEblxs9v4vEQSX8mFCIw=="], - - "@enbox/dwn-sql-store/multiformats": ["multiformats@12.0.1", "", {}, "sha512-s01wijBJoDUqESWSzePY0lvTw7J3PVO9x2Cc6ASI5AMZM2Gnhh7BC17+nlFhHKU7dDzaCaRfb+NiqNzOsgPUoQ=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "@ipld/dag-cbor/cborg": ["cborg@2.0.5", "", { "bin": { "cborg": "cli.js" } }, "sha512-xVW1rSIw1ZXbkwl2XhJ7o/jAv0vnVoQv/QlfQxV8a7V5PlA4UU/AcIiXqmpyybwNWy/GPQU1m/aBVNIWr7/T0w=="], - "@ipld/dag-cbor/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], "@ipld/dag-pb/multiformats": ["multiformats@13.1.0", "", {}, "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ=="], @@ -921,6 +899,8 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "ipfs-unixfs-exporter/@ipld/dag-cbor": ["@ipld/dag-cbor@9.0.3", "", { "dependencies": { "cborg": "^2.0.1", "multiformats": "^12.0.1" } }, "sha512-A2UFccS0+sARK9xwXiVZIaWbLbPxLGP3UZOjBeOMWfDY04SXi8h1+t4rHBzOlKYF/yWNm3RbFLyclWO7hZcy4g=="], + "ipfs-unixfs-exporter/uint8arrays": ["uint8arrays@4.0.10", "", { "dependencies": { "multiformats": "^12.0.1" } }, "sha512-AnJNUGGDJAgFw/eWu/Xb9zrVKEGlwJJCaeInlf3BkecE/zcTobk5YXYIPNQJO1q5Hh1QZrQQHf0JvcHqz2hqoA=="], "ipfs-unixfs-importer/uint8arrays": ["uint8arrays@4.0.10", "", { "dependencies": { "multiformats": "^12.0.1" } }, "sha512-AnJNUGGDJAgFw/eWu/Xb9zrVKEGlwJJCaeInlf3BkecE/zcTobk5YXYIPNQJO1q5Hh1QZrQQHf0JvcHqz2hqoA=="], @@ -945,42 +925,26 @@ "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@enbox/agent/@enbox/dids/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], - - "@enbox/agent/@enbox/dwn-sdk-js/@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="], - - "@enbox/agent/@enbox/dwn-sdk-js/abstract-level": ["abstract-level@1.0.3", "", { "dependencies": { "buffer": "^6.0.3", "catering": "^2.1.0", "is-buffer": "^2.0.5", "level-supports": "^4.0.0", "level-transcoder": "^1.0.1", "module-error": "^1.0.1", "queue-microtask": "^1.2.3" } }, "sha512-t6jv+xHy+VYwc4xqZMn2Pa9DjcdzvzZmQGRjTFc8spIbRGHgBrEKbPq+rYXc7CCo0lxgYvSgKVg9qZAhpVQSjA=="], - - "@enbox/agent/@enbox/dwn-sdk-js/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/auth/@enbox/dids/@enbox/crypto": ["@enbox/crypto@0.0.8", "", { "dependencies": { "@enbox/common": "0.0.7", "@noble/ciphers": "0.5.3", "@noble/curves": "1.3.0", "@noble/hashes": "1.4.0", "cborg": "^4.5.8" } }, "sha512-aLuIZ/EuzsAE0ywhyXWuJsb96tuKvRPRCigkJ8psDFMtzSqNPPCu1OZLVaH6I/JnuIn/377UgNCpxpACZH1/Tg=="], + "@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/auth/@enbox/dids/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], + "@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/crypto/@enbox/common/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], - - "@enbox/crypto/@enbox/common/multiformats": ["multiformats@13.1.0", "", {}, "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@enbox/dids/@enbox/common/multiformats": ["multiformats@13.1.0", "", {}, "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ=="], + "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/dwn-clients/@enbox/dwn-sdk-js/@enbox/dids": ["@enbox/dids@0.0.9", "", { "dependencies": { "@decentralized-identity/ion-sdk": "1.0.4", "@dnsquery/dns-packet": "6.1.1", "@enbox/common": "0.0.7", "@enbox/crypto": "0.0.8", "abstract-level": "1.0.4", "bencode": "4.0.0", "level": "8.0.1", "ms": "2.1.3" } }, "sha512-G02XX/2OJd55lrFzaLfMMPqLdg4Frl3XRRUs4m2U7lk8Y5seXw2E3GGNprlL69UxGtEisOd9rtu91FKm504QzQ=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@enbox/dwn-clients/@enbox/dwn-sdk-js/@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="], + "@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/dwn-clients/@enbox/dwn-sdk-js/abstract-level": ["abstract-level@1.0.3", "", { "dependencies": { "buffer": "^6.0.3", "catering": "^2.1.0", "is-buffer": "^2.0.5", "level-supports": "^4.0.0", "level-transcoder": "^1.0.1", "module-error": "^1.0.1", "queue-microtask": "^1.2.3" } }, "sha512-t6jv+xHy+VYwc4xqZMn2Pa9DjcdzvzZmQGRjTFc8spIbRGHgBrEKbPq+rYXc7CCo0lxgYvSgKVg9qZAhpVQSjA=="], + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], - "@enbox/dwn-clients/@enbox/dwn-sdk-js/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@enbox/dwn-sdk-js/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@enbox/dwn-sql-store/@ipld/dag-cbor/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -989,6 +953,10 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "ipfs-unixfs-exporter/@ipld/dag-cbor/cborg": ["cborg@2.0.5", "", { "bin": { "cborg": "cli.js" } }, "sha512-xVW1rSIw1ZXbkwl2XhJ7o/jAv0vnVoQv/QlfQxV8a7V5PlA4UU/AcIiXqmpyybwNWy/GPQU1m/aBVNIWr7/T0w=="], + + "ipfs-unixfs-exporter/@ipld/dag-cbor/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], + "ipfs-unixfs-exporter/uint8arrays/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], "ipfs-unixfs-importer/uint8arrays/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], @@ -1007,14 +975,6 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@enbox/agent/@enbox/dwn-sdk-js/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@enbox/dwn-clients/@enbox/dwn-sdk-js/@enbox/dids/abstract-level": ["abstract-level@1.0.4", "", { "dependencies": { "buffer": "^6.0.3", "catering": "^2.1.0", "is-buffer": "^2.0.5", "level-supports": "^4.0.0", "level-transcoder": "^1.0.1", "module-error": "^1.0.1", "queue-microtask": "^1.2.3" } }, "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg=="], - - "@enbox/dwn-clients/@enbox/dwn-sdk-js/@enbox/dids/level": ["level@8.0.1", "", { "dependencies": { "abstract-level": "^1.0.4", "browser-level": "^1.0.1", "classic-level": "^1.2.0" } }, "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ=="], - - "@enbox/dwn-clients/@enbox/dwn-sdk-js/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/package.json b/package.json index 2914b28..29a9319 100644 --- a/package.json +++ b/package.json @@ -96,14 +96,14 @@ }, "dependencies": { "@clack/prompts": "^1.0.1", - "@enbox/agent": "^0.3.1", - "@enbox/api": "^0.4.2", - "@enbox/auth": "^0.3.1", - "@enbox/crypto": "0.0.6", - "@enbox/dids": "0.0.7", - "@enbox/dwn-sdk-js": "0.1.0", - "@enbox/dwn-sql-store": "0.0.9", - "kysely": "0.26.3" + "@enbox/agent": "0.8.3", + "@enbox/api": "0.6.41", + "@enbox/auth": "0.6.49", + "@enbox/crypto": "0.1.1", + "@enbox/dids": "0.1.1", + "@enbox/dwn-sdk-js": "0.4.2", + "@enbox/dwn-sql-store": "0.0.27", + "kysely": "0.28.14" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", diff --git a/schemas/ci/check-suite.json b/schemas/ci/check-suite.json index e483fba..31c0423 100644 --- a/schemas/ci/check-suite.json +++ b/schemas/ci/check-suite.json @@ -5,9 +5,28 @@ "description": "A CI check suite associated with a branch", "type": "object", "properties": { + "app": { + "type": "string" + }, "headBranch": { "type": "string" }, + "event": { + "type": "string" + }, + "displayTitle": { + "type": "string" + }, + "message": { + "type": "string" + }, + "inputs": { + "type": "object", + "maxProperties": 25, + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + } + }, "conclusion": { "type": "string" }, diff --git a/schemas/issues/comment.json b/schemas/issues/comment.json index 44d071e..3fe0639 100644 --- a/schemas/issues/comment.json +++ b/schemas/issues/comment.json @@ -7,6 +7,13 @@ "properties": { "body": { "type": "string" + }, + "pinnedAt": { + "type": "string", + "format": "date-time" + }, + "pinnedBy": { + "type": "string" } }, "required": ["body"], diff --git a/schemas/issues/issue-dependency.json b/schemas/issues/issue-dependency.json new file mode 100644 index 0000000..d76d5fc --- /dev/null +++ b/schemas/issues/issue-dependency.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/issue-dependency", + "title": "IssueDependencyData", + "description": "A dependency edge from an issue to a blocking issue", + "type": "object", + "properties": { + "issueId": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["issueId"], + "additionalProperties": false +} diff --git a/schemas/issues/issue-field-value.json b/schemas/issues/issue-field-value.json new file mode 100644 index 0000000..0488bc6 --- /dev/null +++ b/schemas/issues/issue-field-value.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/issue-field-value", + "title": "IssueFieldValueData", + "description": "A custom field value attached to an issue", + "type": "object", + "properties": { + "fieldId": { + "type": "integer", + "minimum": 1 + }, + "dataType": { + "type": "string", + "enum": ["text", "single_select", "number", "date", "multi_select"] + }, + "value": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + } + }, + "required": ["fieldId", "dataType", "value"], + "additionalProperties": false +} diff --git a/schemas/issues/issue-sub-issue.json b/schemas/issues/issue-sub-issue.json new file mode 100644 index 0000000..ac9d0ac --- /dev/null +++ b/schemas/issues/issue-sub-issue.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/issue-sub-issue", + "title": "IssueSubIssueData", + "description": "An ordered sub-issue edge from a parent issue to a child issue", + "type": "object", + "properties": { + "issueId": { + "type": "integer", + "minimum": 1 + }, + "priority": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["issueId", "priority"], + "additionalProperties": false +} diff --git a/schemas/org/org-blocked-user.json b/schemas/org/org-blocked-user.json new file mode 100644 index 0000000..b2c9780 --- /dev/null +++ b/schemas/org/org-blocked-user.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/org-blocked-user", + "title": "OrgBlockedUserData", + "description": "A user blocked from an organization", + "type": "object", + "properties": { + "did": { + "type": "string" + }, + "blockedAt": { + "type": "string" + }, + "blockedBy": { + "type": "string" + } + }, + "required": ["did"], + "additionalProperties": false +} diff --git a/schemas/org/org-custom-property.json b/schemas/org/org-custom-property.json new file mode 100644 index 0000000..bd32607 --- /dev/null +++ b/schemas/org/org-custom-property.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/org-custom-property", + "title": "OrgCustomPropertyData", + "description": "An organization-level repository custom property definition", + "type": "object", + "properties": { + "propertyName": { + "type": "string", + "minLength": 1, + "maxLength": 75 + }, + "valueType": { + "type": "string", + "enum": ["string", "single_select", "multi_select", "true_false", "url"] + }, + "required": { + "type": "boolean" + }, + "defaultValue": { + "type": ["string", "array", "null"], + "items": { + "type": "string" + } + }, + "description": { + "type": ["string", "null"] + }, + "allowedValues": { + "type": ["array", "null"], + "maxItems": 200, + "items": { + "type": "string" + } + }, + "valuesEditableBy": { + "type": ["string", "null"], + "enum": ["org_actors", "org_and_repo_actors", null] + }, + "requireExplicitValues": { + "type": "boolean" + } + }, + "required": ["propertyName", "valueType"], + "additionalProperties": false +} diff --git a/schemas/org/org-issue-field.json b/schemas/org/org-issue-field.json new file mode 100644 index 0000000..66f2cab --- /dev/null +++ b/schemas/org/org-issue-field.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/org-issue-field", + "title": "OrgIssueFieldData", + "description": "An organization-level custom issue field", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "dataType": { + "type": "string", + "enum": ["text", "single_select", "number", "date", "multi_select"] + }, + "visibility": { + "type": "string", + "enum": ["organization_members_only", "all"] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "color": { + "type": "string", + "enum": ["gray", "blue", "green", "yellow", "orange", "red", "pink", "purple"] + }, + "priority": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["name", "color", "priority"], + "additionalProperties": false + } + } + }, + "required": ["name", "dataType"], + "additionalProperties": false +} diff --git a/schemas/org/org-issue-type.json b/schemas/org/org-issue-type.json new file mode 100644 index 0000000..0606acf --- /dev/null +++ b/schemas/org/org-issue-type.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/org-issue-type", + "title": "OrgIssueTypeData", + "description": "An organization-level issue type definition", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "color": { + "type": ["string", "null"], + "enum": ["gray", "blue", "green", "yellow", "orange", "red", "pink", "purple", null] + }, + "isEnabled": { + "type": "boolean" + } + }, + "required": ["name", "isEnabled"], + "additionalProperties": false +} diff --git a/schemas/org/org-member.json b/schemas/org/org-member.json index ccc8964..f975ef4 100644 --- a/schemas/org/org-member.json +++ b/schemas/org/org-member.json @@ -10,6 +10,9 @@ }, "alias": { "type": "string" + }, + "public": { + "type": "boolean" } }, "required": ["did"], diff --git a/schemas/org/org-webhook.json b/schemas/org/org-webhook.json new file mode 100644 index 0000000..137af6b --- /dev/null +++ b/schemas/org/org-webhook.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/org-webhook", + "title": "OrgWebhookData", + "description": "An organization webhook configuration specifying a callback URL, shared secret, subscribed events, and active status.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean" + }, + "deliveries": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "guid": { + "type": "string", + "minLength": 1 + }, + "deliveredAt": { + "type": "string" + }, + "redelivery": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "event": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": ["string", "null"] + }, + "installationId": { + "type": ["integer", "null"] + }, + "repositoryId": { + "type": ["integer", "null"] + }, + "throttledAt": { + "type": ["string", "null"] + }, + "request": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "payload": {} + }, + "additionalProperties": false + }, + "response": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "payload": {} + }, + "additionalProperties": false + } + }, + "required": ["id", "guid", "deliveredAt", "redelivery", "duration", "status", "statusCode", "event"], + "additionalProperties": false + } + } + }, + "required": ["url", "secret", "events", "active"], + "additionalProperties": false +} diff --git a/schemas/org/team-member.json b/schemas/org/team-member.json index 694931b..6efbc16 100644 --- a/schemas/org/team-member.json +++ b/schemas/org/team-member.json @@ -10,6 +10,14 @@ }, "alias": { "type": "string" + }, + "role": { + "type": "string", + "enum": ["member", "maintainer"] + }, + "state": { + "type": "string", + "enum": ["active", "pending"] } }, "required": ["did"], diff --git a/schemas/org/team.json b/schemas/org/team.json index bb2adec..3c429ca 100644 --- a/schemas/org/team.json +++ b/schemas/org/team.json @@ -10,6 +10,31 @@ }, "description": { "type": "string" + }, + "privacy": { + "type": "string", + "enum": ["visible", "secret"] + }, + "repositories": { + "type": "object", + "description": "GitHub-compatible team repository grants keyed by owner/repo", + "additionalProperties": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "permission": { + "type": "string", + "enum": ["pull", "triage", "push", "maintain", "admin"] + } + }, + "required": ["owner", "repo", "permission"], + "additionalProperties": false + } } }, "required": ["name"], diff --git a/schemas/releases/release.json b/schemas/releases/release.json index 9a913a3..0c6daeb 100644 --- a/schemas/releases/release.json +++ b/schemas/releases/release.json @@ -10,8 +10,48 @@ }, "body": { "type": "string" + }, + "discussionCategoryName": { + "type": "string" + }, + "publishedAt": { + "type": "string" + }, + "makeLatest": { + "type": "string", + "enum": ["true", "false", "legacy"] + }, + "reactions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/releaseReaction" + } } }, "required": ["name"], - "additionalProperties": false + "additionalProperties": false, + "definitions": { + "releaseReaction": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "userDid": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string", + "enum": ["+1", "laugh", "heart", "hooray", "rocket", "eyes"] + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "userDid", "content", "createdAt"], + "additionalProperties": false + } + } } diff --git a/schemas/repo/collaborator.json b/schemas/repo/collaborator.json index 756b87a..db633ae 100644 --- a/schemas/repo/collaborator.json +++ b/schemas/repo/collaborator.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://enbox.org/schemas/forge/collaborator", "title": "CollaboratorData", - "description": "A collaborator entry identifying a DID and optional alias, shared by maintainer, triager, and contributor roles.", + "description": "A collaborator entry identifying a DID and optional alias, shared by maintainer, triager, contributor, and viewer roles.", "type": "object", "properties": { "did": { diff --git a/schemas/repo/repo.json b/schemas/repo/repo.json index 66cd2f8..6ef4da5 100644 --- a/schemas/repo/repo.json +++ b/schemas/repo/repo.json @@ -28,6 +28,58 @@ "items": { "type": "string" } + }, + "hasIssues": { + "type": "boolean" + }, + "hasProjects": { + "type": "boolean" + }, + "hasWiki": { + "type": "boolean" + }, + "hasDownloads": { + "type": "boolean" + }, + "hasPullRequests": { + "type": "boolean" + }, + "isTemplate": { + "type": "boolean" + }, + "allowSquashMerge": { + "type": "boolean" + }, + "allowMergeCommit": { + "type": "boolean" + }, + "allowRebaseMerge": { + "type": "boolean" + }, + "allowAutoMerge": { + "type": "boolean" + }, + "allowForking": { + "type": "boolean" + }, + "deleteBranchOnMerge": { + "type": "boolean" + }, + "webCommitSignoffRequired": { + "type": "boolean" + }, + "pullRequestCreationPolicy": { + "type": "string", + "enum": ["all", "collaborators_only"] + }, + "forkedFromDid": { + "type": "string" + }, + "forkedFromRepoName": { + "type": "string" + }, + "forkedFromRecordId": { + "type": "string" } }, "required": ["name", "defaultBranch", "dwnEndpoints"], diff --git a/schemas/repo/settings.json b/schemas/repo/settings.json index 587d641..130eda8 100644 --- a/schemas/repo/settings.json +++ b/schemas/repo/settings.json @@ -10,19 +10,1569 @@ "additionalProperties": { "type": "object", "properties": { + "allowDeletions": { + "type": "boolean" + }, + "allowForcePushes": { + "type": "boolean" + }, + "allowForkSyncing": { + "type": "boolean" + }, + "blockCreations": { + "type": "boolean" + }, + "dismissStaleReviews": { + "type": "boolean" + }, + "reviewBypassAllowances": { + "$ref": "#/definitions/branchProtectionRestrictions" + }, + "reviewDismissalRestrictions": { + "$ref": "#/definitions/branchProtectionRestrictions" + }, + "enforceAdmins": { + "type": "boolean" + }, + "lockBranch": { + "type": "boolean" + }, + "restrictions": { + "$ref": "#/definitions/branchProtectionRestrictions" + }, + "requiredConversationResolution": { + "type": "boolean" + }, + "requiredChecksStrict": { + "type": "boolean" + }, + "requiredLinearHistory": { + "type": "boolean" + }, + "requireCodeOwnerReviews": { + "type": "boolean" + }, + "requireLastPushApproval": { + "type": "boolean" + }, + "requiredSignatures": { + "type": "boolean" + }, "requiredReviews": { "type": "integer" }, - "requiredChecks": { + "requiredCheckApps": { + "type": "object", + "additionalProperties": { + "type": ["integer", "null"], + "minimum": -1 + } + }, + "requiredChecks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "color": { + "type": "string", + "pattern": "^[0-9a-fA-F]{6}$" + }, + "description": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "color"], + "additionalProperties": false + } + }, + "milestones": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "number": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": ["open", "closed"] + }, + "description": { + "type": ["string", "null"] + }, + "dueOn": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "closedAt": { + "type": ["string", "null"] + } + }, + "required": ["title", "number"], + "additionalProperties": false + } + }, + "commitComments": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "body": { + "type": "string", + "minLength": 1 + }, + "commitId": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": ["string", "null"] + }, + "position": { + "type": ["integer", "null"] + }, + "line": { + "type": ["integer", "null"] + }, + "userDid": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "reactions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/githubReaction" + } + } + }, + "required": ["id", "body", "commitId", "userDid", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "pullReviewCommentReactions": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/githubReaction" + } + } + }, + "deployments": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "sha": { + "type": "string" + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "task": { + "type": "string", + "minLength": 1 + }, + "payload": {}, + "originalEnvironment": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "creatorDid": { + "type": "string" + }, + "transientEnvironment": { + "type": "boolean" + }, + "productionEnvironment": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "statuses": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": ["error", "failure", "inactive", "in_progress", "queued", "pending", "success"] + }, + "targetUrl": { + "type": "string" + }, + "logUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "environmentUrl": { + "type": "string" + }, + "creatorDid": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "state", "environment", "creatorDid", "createdAt", "updatedAt"], + "additionalProperties": false + } + } + }, + "required": ["id", "sha", "ref", "task", "originalEnvironment", "environment", "creatorDid", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "environments": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "waitTimer": { + "type": "integer", + "minimum": 0, + "maximum": 43200 + }, + "preventSelfReview": { + "type": "boolean" + }, + "reviewers": { + "type": "array", + "maxItems": 6, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["User", "Team"] + }, + "id": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["type", "id"], + "additionalProperties": false + } + }, + "deploymentBranchPolicy": { + "type": ["object", "null"], + "properties": { + "protectedBranches": { + "type": "boolean" + }, + "customBranchPolicies": { + "type": "boolean" + } + }, + "required": ["protectedBranches", "customBranchPolicies"], + "additionalProperties": false + }, + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "value", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "secrets": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "encryptedValue": { + "type": "string", + "minLength": 1 + }, + "keyId": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "encryptedValue", "keyId", "createdAt", "updatedAt"], + "additionalProperties": false + } + } + }, + "required": ["id", "name", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "deployKeys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "key": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "verified": { + "type": "boolean" + }, + "addedBy": { + "type": ["string", "null"] + }, + "lastUsed": { + "type": ["string", "null"] + }, + "enabled": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "key", "title", "readOnly", "createdAt"], + "additionalProperties": false + } + }, + "autolinks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "keyPrefix": { + "type": "string", + "minLength": 1 + }, + "urlTemplate": { + "type": "string", + "minLength": 1 + }, + "isAlphanumeric": { + "type": "boolean" + } + }, + "required": ["id", "keyPrefix", "urlTemplate", "isAlphanumeric"], + "additionalProperties": false + } + }, + "interactionLimit": { + "type": "object", + "properties": { + "limit": { + "type": "string", + "enum": ["existing_users", "contributors_only", "collaborators_only"] + }, + "expiresAt": { + "type": "string" + } + }, + "required": ["limit", "expiresAt"], + "additionalProperties": false + }, + "vulnerabilityAlertsEnabled": { + "type": "boolean" + }, + "automatedSecurityFixesEnabled": { + "type": "boolean" + }, + "automatedSecurityFixesPaused": { + "type": "boolean" + }, + "codeScanningAlerts": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": ["open", "closed", "dismissed", "fixed"] + }, + "rule": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": true + }, + "tool": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "guid": { + "type": ["string", "null"] + }, + "version": { + "type": ["string", "null"] + } + }, + "required": ["name"], + "additionalProperties": true + }, + "mostRecentInstance": { + "$ref": "#/definitions/codeScanningInstance" + }, + "instances": { + "type": "array", + "items": { + "$ref": "#/definitions/codeScanningInstance" + } + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "dismissedAt": { + "type": ["string", "null"] + }, + "dismissedBy": { + "type": ["string", "null"] + }, + "dismissedReason": { + "type": ["string", "null"], + "enum": ["false positive", "won't fix", "used in tests", null] + }, + "dismissedComment": { + "type": ["string", "null"] + }, + "fixedAt": { + "type": ["string", "null"] + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["number", "state", "rule", "tool", "mostRecentInstance", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "dependabotAlerts": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": ["auto_dismissed", "dismissed", "fixed", "open"] + }, + "dependency": { + "type": "object", + "properties": { + "package": { + "type": "object", + "properties": { + "ecosystem": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": ["ecosystem", "name"], + "additionalProperties": false + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "string" + } + }, + "required": ["package", "manifestPath"], + "additionalProperties": false + }, + "securityAdvisory": { + "type": "object", + "additionalProperties": true + }, + "securityVulnerability": { + "type": "object", + "additionalProperties": true + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "dismissedAt": { + "type": ["string", "null"] + }, + "dismissedBy": { + "type": ["string", "null"] + }, + "dismissedReason": { + "type": ["string", "null"], + "enum": ["fix_started", "inaccurate", "no_bandwidth", "not_used", "tolerable_risk", null] + }, + "dismissedComment": { + "type": ["string", "null"] + }, + "fixedAt": { + "type": ["string", "null"] + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["number", "state", "dependency", "securityAdvisory", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "secretScanningAlerts": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": ["open", "resolved"] + }, + "secretType": { + "type": "string", + "minLength": 1 + }, + "secretTypeDisplayName": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "providerSlug": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "resolution": { + "type": ["string", "null"], + "enum": ["false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests", null] + }, + "resolvedAt": { + "type": ["string", "null"] + }, + "resolvedBy": { + "type": ["string", "null"] + }, + "resolutionComment": { + "type": ["string", "null"] + }, + "validity": { + "type": "string", + "enum": ["active", "inactive", "unknown"] + }, + "publiclyLeaked": { + "type": "boolean" + }, + "multiRepo": { + "type": "boolean" + }, + "isBase64Encoded": { + "type": "boolean" + }, + "pushProtectionBypassed": { + "type": "boolean" + }, + "pushProtectionBypassedBy": { + "type": ["string", "null"] + }, + "pushProtectionBypassedAt": { + "type": ["string", "null"] + }, + "pushProtectionBypassRequestReviewer": { + "type": ["string", "null"] + }, + "pushProtectionBypassRequestReviewerComment": { + "type": ["string", "null"] + }, + "pushProtectionBypassRequestComment": { + "type": ["string", "null"] + }, + "pushProtectionBypassRequestHtmlUrl": { + "type": ["string", "null"] + }, + "firstLocationDetected": { + "type": ["object", "null"], + "additionalProperties": true + }, + "hasMoreLocations": { + "type": "boolean" + }, + "assignedTo": { + "type": ["string", "null"] + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningLocation" + } + } + }, + "required": ["number", "state", "secretType", "secret", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "secretScanningScanHistory": { + "type": "object", + "properties": { + "incrementalScans": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningScan" + } + }, + "backfillScans": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningScan" + } + }, + "patternUpdateScans": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningScan" + } + }, + "customPatternBackfillScans": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningScan" + } + }, + "genericSecretsBackfillScans": { + "type": "array", + "items": { + "$ref": "#/definitions/secretScanningScan" + } + } + }, + "additionalProperties": false + }, + "secretScanningPushProtectionBypasses": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "placeholderId": { + "type": "string", + "minLength": 1 + }, + "tokenType": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "enum": ["false_positive", "used_in_tests", "will_fix_later"] + }, + "expireAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "alertNumber": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["placeholderId", "tokenType"], + "additionalProperties": false + } + }, + "securityAdvisories": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "ghsaId": { + "type": "string", + "minLength": 1 + }, + "cveId": { + "type": ["string", "null"] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "unknown"] + }, + "state": { + "type": "string", + "enum": ["triage", "draft", "published", "closed"] + }, + "authorDid": { + "type": "string", + "minLength": 1 + }, + "publisherDid": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "publishedAt": { + "type": ["string", "null"] + }, + "closedAt": { + "type": ["string", "null"] + }, + "withdrawnAt": { + "type": ["string", "null"] + }, + "submission": { + "type": ["object", "null"], + "additionalProperties": true + }, + "vulnerabilities": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "cvssSeverities": { + "type": "object", + "additionalProperties": true + }, + "cweIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "credits": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "creditsDetailed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "collaboratingUsers": { + "type": "array", + "items": { + "type": "string" + } + }, + "collaboratingTeams": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "privateFork": { + "type": ["object", "null"], + "additionalProperties": true + }, + "cveRequestedAt": { + "type": ["string", "null"] + } + }, + "required": ["ghsaId", "summary", "description", "state", "authorDid", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "privateVulnerabilityReportingEnabled": { + "type": "boolean" + }, + "immutableReleasesEnabled": { + "type": "boolean" + }, + "customProperties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { "type": "array", "items": { "type": "string" } } + ] + } + }, + "attestations": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "subjectDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-fA-F]{64}$" + }, + "predicateType": { + "type": "string" + }, + "bundle": { + "type": "object", + "additionalProperties": true + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "subjectDigest", "bundle", "createdAt"], + "additionalProperties": false + } + }, + "issueTypes": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": ["string", "null"] + }, + "color": { + "type": ["string", "null"] + }, + "isEnabled": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "name", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "transferRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "newOwner": { + "type": "string", + "minLength": 1 + }, + "newName": { + "type": "string", + "minLength": 1 + }, + "teamIds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } + }, + "requestedBy": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "newOwner", "requestedBy", "createdAt"], + "additionalProperties": false + }, + "rulesets": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "target": { + "type": "string", + "enum": ["branch", "tag", "push"] + }, + "enforcement": { + "type": "string", + "enum": ["disabled", "active", "evaluate"] + }, + "bypassActors": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "conditions": { + "type": "object", + "additionalProperties": true + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1 + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "versionId": { + "type": "integer", + "minimum": 1 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "history": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "versionId": { + "type": "integer", + "minimum": 1 + }, + "actorDid": { + "type": "string", + "minLength": 1 + }, + "updatedAt": { + "type": "string" + }, + "state": { + "type": "object", + "additionalProperties": true + } + }, + "required": ["versionId", "actorDid", "updatedAt", "state"], + "additionalProperties": false + } + } + }, + "required": ["id", "name", "target", "enforcement", "rules", "versionId", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "ruleSuites": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "actorId": { + "type": "integer", + "minimum": 1 + }, + "actorName": { + "type": "string", + "minLength": 1 + }, + "beforeSha": { + "type": "string" + }, + "afterSha": { + "type": "string" + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "repositoryId": { + "type": "integer", + "minimum": 1 + }, + "repositoryName": { + "type": "string", + "minLength": 1 + }, + "pushedAt": { + "type": "string" + }, + "result": { + "type": "string", + "enum": ["pass", "fail", "bypass"] + }, + "evaluationResult": { + "type": ["string", "null"], + "enum": ["pass", "fail", "bypass", null] + }, + "ruleEvaluations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "enforcement": { + "type": "string", + "enum": ["disabled", "active", "evaluate"] + }, + "result": { + "type": "string", + "enum": ["pass", "fail", "bypass"] + }, + "ruleType": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "string" + } + }, + "required": ["ruleSource", "enforcement", "result", "ruleType"], + "additionalProperties": false + } + } + }, + "required": ["id", "actorName", "beforeSha", "afterSha", "ref", "pushedAt", "result"], + "additionalProperties": false + } + }, + "actionsWorkflows": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "state": { + "type": "string", + "enum": ["active", "disabled_manually"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "name", "path", "state", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "actionsVariables": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "value", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "actionsSecrets": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "encryptedValue": { + "type": "string", + "minLength": 1 + }, + "keyId": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "encryptedValue", "keyId", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "actionsCaches": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "key": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "lastAccessedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "sizeInBytes": { + "type": "integer", + "minimum": 0 + } }, + "required": ["id", "ref", "key", "version", "lastAccessedAt", "createdAt", "sizeInBytes"], "additionalProperties": false } }, + "actionsCacheRetentionLimitDays": { + "type": "integer", + "minimum": 1 + }, + "actionsCacheStorageLimitGb": { + "type": "integer", + "minimum": 1 + }, + "actionsPermissions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "allowedActions": { + "type": "string", + "enum": ["all", "local_only", "selected"] + }, + "shaPinningRequired": { + "type": "boolean" + }, + "selectedActions": { + "type": "object", + "properties": { + "githubOwnedAllowed": { + "type": "boolean" + }, + "verifiedAllowed": { + "type": "boolean" + }, + "patternsAllowed": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["githubOwnedAllowed", "verifiedAllowed", "patternsAllowed"], + "additionalProperties": false + }, + "defaultWorkflowPermissions": { + "type": "string", + "enum": ["read", "write"] + }, + "canApprovePullRequestReviews": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "allowedActions", + "shaPinningRequired", + "selectedActions", + "defaultWorkflowPermissions", + "canApprovePullRequestReviews" + ], + "additionalProperties": false + }, + "pages": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["queued", "building", "built", "errored"] + }, + "cname": { + "type": ["string", "null"] + }, + "custom404": { + "type": "boolean" + }, + "source": { + "type": ["object", "null"], + "properties": { + "branch": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "enum": ["/", "/docs"] + } + }, + "required": ["branch", "path"], + "additionalProperties": false + }, + "buildType": { + "type": "string", + "enum": ["legacy", "workflow"] + }, + "public": { + "type": "boolean" + }, + "httpsEnforced": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "builds": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["queued", "building", "built", "errored"] + }, + "errorMessage": { + "type": ["string", "null"] + }, + "pusherDid": { + "type": "string", + "minLength": 1 + }, + "commit": { + "type": "string", + "minLength": 1 + }, + "duration": { + "type": "integer", + "minimum": 0 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "status", "errorMessage", "pusherDid", "commit", "duration", "createdAt", "updatedAt"], + "additionalProperties": false + } + }, + "deployments": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "artifactId": { + "type": "integer", + "minimum": 1 + }, + "artifactUrl": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "string", + "minLength": 1 + }, + "pagesBuildVersion": { + "type": "string", + "minLength": 1 + }, + "oidcTokenHash": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["pending", "succeed", "failed", "cancelled"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "environment", "pagesBuildVersion", "oidcTokenHash", "status", "createdAt", "updatedAt"], + "additionalProperties": false + } + } + }, + "required": [ + "status", + "cname", + "custom404", + "source", + "buildType", + "public", + "httpsEnforced", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, "mergeStrategies": { "type": "array", "items": { @@ -35,5 +1585,139 @@ } }, "required": [], - "additionalProperties": false + "additionalProperties": false, + "definitions": { + "branchProtectionRestrictions": { + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "teams": { + "type": "array", + "items": { + "type": "string" + } + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["apps", "teams", "users"], + "additionalProperties": false + }, + "githubReaction": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "userDid": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string", + "enum": ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "userDid", "content", "createdAt"], + "additionalProperties": false + }, + "codeScanningInstance": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "minLength": 1 + }, + "analysisKey": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["open", "closed", "dismissed", "fixed"] + }, + "commitSha": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "object", + "additionalProperties": true + }, + "location": { + "type": "object", + "additionalProperties": true + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["ref", "analysisKey", "category", "state", "commitSha"], + "additionalProperties": false + }, + "secretScanningLocation": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "details": { + "type": "object", + "additionalProperties": true + } + }, + "required": ["type", "details"], + "additionalProperties": false + }, + "secretScanningScan": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "startedAt": { + "type": "string" + }, + "completedAt": { + "type": "string" + }, + "patternSlug": { + "type": "string" + }, + "patternScope": { + "type": "string" + } + }, + "required": ["type", "status"], + "additionalProperties": false + } + } } diff --git a/schemas/repo/submission-decision.json b/schemas/repo/submission-decision.json new file mode 100644 index 0000000..53d26a5 --- /dev/null +++ b/schemas/repo/submission-decision.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/submission-decision", + "title": "SubmissionDecisionData", + "description": "A repo-owner decision for an external issue or patch submission.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["issue", "patch"] + }, + "decision": { + "type": "string", + "enum": ["ignored"] + }, + "submitterDid": { + "type": "string" + }, + "submissionRecordId": { + "type": "string" + }, + "submissionContextId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "decidedBy": { + "type": "string" + }, + "decidedAt": { + "type": "string" + } + }, + "required": ["kind", "decision", "submitterDid", "submissionRecordId", "decidedBy", "decidedAt"], + "additionalProperties": false +} diff --git a/schemas/repo/webhook.json b/schemas/repo/webhook.json index fbbe0c9..f6198a1 100644 --- a/schemas/repo/webhook.json +++ b/schemas/repo/webhook.json @@ -19,6 +19,82 @@ }, "active": { "type": "boolean" + }, + "deliveries": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "guid": { + "type": "string", + "minLength": 1 + }, + "deliveredAt": { + "type": "string" + }, + "redelivery": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "event": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": ["string", "null"] + }, + "installationId": { + "type": ["integer", "null"] + }, + "repositoryId": { + "type": ["integer", "null"] + }, + "throttledAt": { + "type": ["string", "null"] + }, + "request": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "payload": {} + }, + "additionalProperties": false + }, + "response": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "payload": {} + }, + "additionalProperties": false + } + }, + "required": ["id", "guid", "deliveredAt", "redelivery", "duration", "status", "statusCode", "event"], + "additionalProperties": false + } } }, "required": ["url", "secret", "events", "active"], diff --git a/schemas/social/activity.json b/schemas/social/activity.json index f792949..1282143 100644 --- a/schemas/social/activity.json +++ b/schemas/social/activity.json @@ -11,11 +11,42 @@ "summary": { "type": "string" }, + "repoDid": { + "type": "string" + }, + "repoRecordId": { + "type": "string" + }, + "repoName": { + "type": "string" + }, + "recordId": { + "type": "string" + }, "targetDid": { "type": "string" }, "targetRecordId": { "type": "string" + }, + "ref": { + "type": "string" + }, + "head": { + "type": "string" + }, + "before": { + "type": "string" + }, + "tagName": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "payload": { + "type": "object", + "additionalProperties": true } }, "required": ["type", "summary"], diff --git a/schemas/social/block.json b/schemas/social/block.json new file mode 100644 index 0000000..b1133e7 --- /dev/null +++ b/schemas/social/block.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/block", + "title": "BlockData", + "description": "A user blocked by the local actor", + "type": "object", + "properties": { + "targetDid": { + "type": "string" + }, + "blockedAt": { + "type": "string" + } + }, + "required": ["targetDid"], + "additionalProperties": false +} diff --git a/schemas/social/email.json b/schemas/social/email.json new file mode 100644 index 0000000..e13566b --- /dev/null +++ b/schemas/social/email.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/email", + "title": "EmailData", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "primary": { + "type": "boolean" + }, + "verified": { + "type": "boolean" + }, + "visibility": { + "type": [ + "string", + "null" + ], + "enum": [ + "public", + "private", + null + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "email" + ], + "additionalProperties": false +} diff --git a/schemas/social/gist-comment.json b/schemas/social/gist-comment.json new file mode 100644 index 0000000..ad13c6d --- /dev/null +++ b/schemas/social/gist-comment.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/gist-comment", + "title": "GistCommentData", + "description": "A comment on a GitHub-compatible gist", + "type": "object", + "properties": { + "gistId": { + "type": "string" + }, + "body": { + "type": "string" + }, + "userDid": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["gistId", "body"], + "additionalProperties": false +} diff --git a/schemas/social/gist-star.json b/schemas/social/gist-star.json new file mode 100644 index 0000000..e693e99 --- /dev/null +++ b/schemas/social/gist-star.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/gist-star", + "title": "GistStarData", + "description": "A gist starred by a DID", + "type": "object", + "properties": { + "ownerDid": { + "type": "string" + }, + "gistId": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["ownerDid", "gistId"], + "additionalProperties": false +} diff --git a/schemas/social/gist.json b/schemas/social/gist.json new file mode 100644 index 0000000..844e9be --- /dev/null +++ b/schemas/social/gist.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/gist", + "title": "GistData", + "description": "A GitHub-compatible gist owned by a DID", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "files": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string" + }, + "type": { + "type": "string" + }, + "language": { + "type": ["string", "null"] + } + }, + "required": ["filename", "content"], + "additionalProperties": false + }, + "minProperties": 1 + }, + "forkOfOwnerDid": { + "type": "string" + }, + "forkOfGistId": { + "type": "string" + }, + "forkedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["files"], + "additionalProperties": false +} diff --git a/schemas/social/gpg-key.json b/schemas/social/gpg-key.json new file mode 100644 index 0000000..5deb5cf --- /dev/null +++ b/schemas/social/gpg-key.json @@ -0,0 +1,132 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/gpg-key", + "title": "GpgKeyData", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "armoredPublicKey": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, + "keyId": { + "type": "string" + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + }, + "subkeys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "primaryKeyId": { + "type": "integer" + }, + "keyId": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + }, + "canSign": { + "type": "boolean" + }, + "canEncryptComms": { + "type": "boolean" + }, + "canEncryptStorage": { + "type": "boolean" + }, + "canCertify": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": [ + "string", + "null" + ] + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "publicKey" + ], + "additionalProperties": false + } + }, + "canSign": { + "type": "boolean" + }, + "canEncryptComms": { + "type": "boolean" + }, + "canEncryptStorage": { + "type": "boolean" + }, + "canCertify": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": [ + "string", + "null" + ] + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "armoredPublicKey" + ], + "additionalProperties": false +} diff --git a/schemas/social/profile.json b/schemas/social/profile.json new file mode 100644 index 0000000..64b30d1 --- /dev/null +++ b/schemas/social/profile.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/profile", + "title": "ProfileData", + "type": "object", + "properties": { + "did": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "blog": { + "type": "string" + }, + "twitterUsername": { + "type": [ + "string", + "null" + ] + }, + "company": { + "type": [ + "string", + "null" + ] + }, + "location": { + "type": [ + "string", + "null" + ] + }, + "hireable": { + "type": [ + "boolean", + "null" + ] + }, + "bio": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "did" + ], + "additionalProperties": false +} diff --git a/schemas/social/social-account.json b/schemas/social/social-account.json new file mode 100644 index 0000000..e031dd8 --- /dev/null +++ b/schemas/social/social-account.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/social-account", + "title": "SocialAccountData", + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "provider", + "url" + ], + "additionalProperties": false +} diff --git a/schemas/social/ssh-key.json b/schemas/social/ssh-key.json new file mode 100644 index 0000000..bd733d8 --- /dev/null +++ b/schemas/social/ssh-key.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/ssh-key", + "title": "SshKeyData", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "key": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "verified": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "key" + ], + "additionalProperties": false +} diff --git a/schemas/social/ssh-signing-key.json b/schemas/social/ssh-signing-key.json new file mode 100644 index 0000000..287eff0 --- /dev/null +++ b/schemas/social/ssh-signing-key.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/ssh-signing-key", + "title": "SshSigningKeyData", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "key": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "key" + ], + "additionalProperties": false +} diff --git a/src/ci.ts b/src/ci.ts index 53b4227..cadfdf4 100644 --- a/src/ci.ts +++ b/src/ci.ts @@ -19,6 +19,13 @@ import { defineProtocol } from '@enbox/api'; export type CheckSuiteData = { app : string; headBranch? : string; + event? : string; + displayTitle? : string; + message? : string; + inputs? : Record; + conclusion? : string; + startedAt? : string; + completedAt? : string; }; /** Data shape for an individual check run within a suite. */ @@ -27,7 +34,11 @@ export type CheckRunData = { title : string; summary : string; text? : string; + steps? : Array>; + annotations? : Array>; }; + startedAt? : string; + completedAt? : string; }; // --------------------------------------------------------------------------- @@ -61,7 +72,7 @@ export const ForgeCiDefinition = { dataFormats : ['application/json'], }, artifact: { - dataFormats: ['application/octet-stream', 'application/gzip'], + dataFormats: ['application/octet-stream', 'application/gzip', 'application/zip'], }, }, structure: { @@ -71,7 +82,7 @@ export const ForgeCiDefinition = { checkSuite: { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, - { role: 'repo:repo/maintainer', can: ['create', 'update'] }, + { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, ], $tags: { $requiredTags : ['commitSha', 'status'], @@ -85,7 +96,7 @@ export const ForgeCiDefinition = { checkRun: { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, - { who: 'author', of: 'repo/checkSuite', can: ['create', 'update'] }, + { who: 'author', of: 'repo/checkSuite', can: ['create', 'update', 'delete'] }, ], $tags: { $requiredTags : ['name', 'status'], @@ -98,8 +109,19 @@ export const ForgeCiDefinition = { artifact: { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, - { who: 'author', of: 'repo/checkSuite', can: ['create'] }, + { who: 'author', of: 'repo/checkSuite', can: ['create', 'delete'] }, ], + $tags: { + $requiredTags : ['name'], + $allowUndefinedTags : false, + name : { type: 'string' }, + size : { type: 'integer' }, + contentType : { type: 'string' }, + digest : { type: 'string' }, + expired : { type: 'boolean' }, + expiresAt : { type: 'string' }, + }, + $size: { max: 104857600 }, }, }, }, diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 5d6a514..29e4690 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -236,8 +236,10 @@ export async function connectAgent(options: ConnectOptions): Promise { + const agentDid = agent.agentDid; + const localDid = did === agentDid?.uri + ? agentDid + : await agent.did.get({ didUri: did, tenant: agentDid?.uri }); + if (!localDid) { return; } + + const portableDid = await localDid.export(); + await (agent.did as any).cache.set(portableDid.uri, { + didDocument : portableDid.document, + didDocumentMetadata : portableDid.metadata, + didResolutionMetadata : {}, + }); +} + /** Bind typed protocol handles and configure all protocols. */ async function bindProtocols( enbox: Enbox, diff --git a/src/cli/commands/ci.ts b/src/cli/commands/ci.ts index c6091c7..843a773 100644 --- a/src/cli/commands/ci.ts +++ b/src/cli/commands/ci.ts @@ -126,16 +126,12 @@ async function ciShow(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const { records: suites } = await ctx.ci.records.query('repo/checkSuite' as any, { - filter: { recordId: suiteId }, - }); - - if (suites.length === 0) { + const suite = await findCheckSuite(ctx, args, suiteId); + if (!suite) { console.error(`Check suite ${suiteId} not found.`); process.exit(1); } - const suite = suites[0]; const data = await suite.data.json(); const tags = suite.tags as Record | undefined; const status = tags?.status ?? 'unknown'; @@ -227,12 +223,8 @@ async function ciRun(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - // Look up the suite to get its contextId. - const { records: suites } = await ctx.ci.records.query('repo/checkSuite' as any, { - filter: { recordId: suiteId }, - }); - - if (suites.length === 0) { + const suite = await findCheckSuite(ctx, args, suiteId); + if (!suite) { console.error(`Check suite ${suiteId} not found.`); process.exit(1); } @@ -240,7 +232,7 @@ async function ciRun(ctx: AgentContext, args: string[]): Promise { const { status, record } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, { data : {}, tags : { name, status: 'queued' }, - parentContextId : suites[0].contextId, + parentContextId : suite.contextId, } as any); if (status.code >= 300) { @@ -273,17 +265,12 @@ async function ciUpdate(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - // Find the run record. - const { records: runs } = await ctx.ci.records.query('repo/checkSuite/checkRun' as any, { - filter: { recordId: runId }, - }); - - if (runs.length === 0) { + const run = await findCheckRun(ctx, args, runId); + if (!run) { console.error(`Check run ${runId} not found.`); process.exit(1); } - const run = runs[0]; const existingData = await run.data.json(); const existingTags = run.tags as Record | undefined; @@ -302,3 +289,29 @@ async function ciUpdate(ctx: AgentContext, args: string[]): Promise { console.log(`Updated check run ${runId.slice(0, 8)}... → ${newStatus}${conclusion ? ` (${conclusion})` : ''}`); } + +async function findCheckSuite(ctx: AgentContext, args: string[], suiteId: string): Promise { + const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const { records } = await ctx.ci.records.query('repo/checkSuite' as any, { + filter: { contextId: repoContextId, recordId: suiteId }, + }); + return records[0]; +} + +async function findCheckRun(ctx: AgentContext, args: string[], runId: string): Promise { + const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const { records: suites } = await ctx.ci.records.query('repo/checkSuite' as any, { + filter: { contextId: repoContextId }, + }); + + for (const suite of suites) { + const { records: runs } = await ctx.ci.records.query('repo/checkSuite/checkRun' as any, { + filter: { contextId: suite.contextId, recordId: runId }, + }); + if (runs[0]) { + return runs[0]; + } + } + + return undefined; +} diff --git a/src/cli/commands/clone.ts b/src/cli/commands/clone.ts index 43e3d4c..0e2cc0f 100644 --- a/src/cli/commands/clone.ts +++ b/src/cli/commands/clone.ts @@ -5,13 +5,88 @@ * 1. Validates the DID/repo argument * 2. Spawns `git clone did::/` with inherited stdio * - * Usage: gitd clone / [-- ] + * Usage: gitd clone / [git-clone-args...] * * @module */ import { spawn, spawnSync } from 'node:child_process'; +// --------------------------------------------------------------------------- +// Argument helpers +// --------------------------------------------------------------------------- + +const CLONE_OPTIONS_WITH_VALUE = new Set([ + '--also-filter-submodules', + '--branch', + '--config', + '--depth', + '--filter', + '--jobs', + '--origin', + '--reference', + '--reference-if-able', + '--recurse-submodules', + '--separate-git-dir', + '--server-option', + '--shallow-exclude', + '--shallow-since', + '--template', + '--upload-pack', + '-b', + '-c', + '-j', + '-o', + '-u', +]); + +/** Drop an optional separator kept for backwards-compatible usage. */ +export function gitCloneArgs(args: string[]): string[] { + return args[0] === '--' ? args.slice(1) : args; +} + +/** Infer the working-tree path that `git clone` will create. */ +export function inferCloneDirectory(repoName: string, args: string[]): string { + const positionals: string[] = []; + let consumeNext = false; + let onlyPositionals = false; + + for (const arg of args) { + if (consumeNext) { + consumeNext = false; + continue; + } + + if (onlyPositionals) { + positionals.push(arg); + continue; + } + + if (arg === '--') { + onlyPositionals = true; + continue; + } + + if (arg.startsWith('--')) { + if (!arg.includes('=') && CLONE_OPTIONS_WITH_VALUE.has(arg)) { + consumeNext = true; + } + continue; + } + + if (arg.startsWith('-') && arg !== '-') { + if (CLONE_OPTIONS_WITH_VALUE.has(arg)) { + consumeNext = true; + } + continue; + } + + positionals.push(arg); + } + + return positionals.at(-1) ?? repoName; +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -20,11 +95,12 @@ export async function cloneCommand(args: string[]): Promise { const target = args[0]; if (!target) { - console.error('Usage: gitd clone / [-- ]'); + console.error('Usage: gitd clone / [git-clone-args...]'); console.error(''); console.error('Examples:'); console.error(' gitd clone did:dht:abc123/my-repo'); - console.error(' gitd clone did:dht:abc123/my-repo -- --depth 1'); + console.error(' gitd clone did:dht:abc123/my-repo --depth 1'); + console.error(' gitd clone did:dht:abc123/my-repo my-local-dir'); process.exit(1); } @@ -52,9 +128,9 @@ export async function cloneCommand(args: string[]): Promise { process.exit(1); } - // Collect any extra git args after `--`. - const dashDashIdx = args.indexOf('--'); - const extraArgs = dashDashIdx !== -1 ? args.slice(dashDashIdx + 1) : []; + // Collect extra git args. A leading `--` remains supported from the + // original command shape, but it is no longer required. + const extraArgs = gitCloneArgs(args.slice(1)); // Build the DID transport URL: `did::/` const didUrl = `did::${didPart}/${repoPart}`; @@ -78,7 +154,7 @@ export async function cloneCommand(args: string[]): Promise { // Determine the clone directory (git uses the repo name by default, // unless the user specified a destination in extraArgs). - const cloneDir = extraArgs.find(a => !a.startsWith('-')) ?? repoPart; + const cloneDir = inferCloneDirectory(repoPart, extraArgs); // Store the repo name in git config so subsequent commands can auto-detect it. spawnSync('git', ['config', 'enbox.repo', repoPart], { diff --git a/src/cli/commands/github-api.ts b/src/cli/commands/github-api.ts index d17de16..49efffa 100644 --- a/src/cli/commands/github-api.ts +++ b/src/cli/commands/github-api.ts @@ -2,7 +2,7 @@ * `gitd github-api` — start the GitHub API compatibility shim. * * Usage: - * gitd github-api [--port ] + * gitd github-api [--port ] [--repos ] * * Starts a read-only HTTP server that translates GitHub REST API v3 * requests into DWN queries. Default port: 8181. @@ -13,7 +13,7 @@ import type { AgentContext } from '../agent.js'; import { startShimServer } from '../../github-shim/server.js'; -import { flagValue, parsePort } from '../flags.js'; +import { flagValue, parsePort, resolveReposPath } from '../flags.js'; // --------------------------------------------------------------------------- // Command @@ -21,9 +21,10 @@ import { flagValue, parsePort } from '../flags.js'; export async function githubApiCommand(ctx: AgentContext, args: string[]): Promise { const port = parsePort(flagValue(args, '--port') ?? process.env.GITD_GITHUB_API_PORT ?? '8181'); + const reposPath = resolveReposPath(args, ctx.profileName ?? null); console.log('Starting GitHub API compatibility shim...'); - startShimServer({ ctx, port }); + startShimServer({ ctx, port, reposPath }); // Keep the process alive — the server runs until Ctrl+C. await new Promise(() => {}); diff --git a/src/cli/commands/issue.ts b/src/cli/commands/issue.ts index d25f363..4c74c2e 100644 --- a/src/cli/commands/issue.ts +++ b/src/cli/commands/issue.ts @@ -7,6 +7,8 @@ * gitd issue comment * gitd issue close [--reason ] * gitd issue reopen + * gitd issue accept + * gitd issue ignore [--reason ] * gitd issue list [--status ] * * @module @@ -14,9 +16,10 @@ import type { AgentContext } from '../agent.js'; -import { getRepoContextId } from '../repo-context.js'; +import { recordIgnoredSubmission } from '../submission-decisions.js'; import { findByShortId, shortId } from '../../github-shim/helpers.js'; import { flagValue, resolveRepoName } from '../flags.js'; +import { getRepoContext, getRepoContextId } from '../repo-context.js'; // --------------------------------------------------------------------------- // Sub-command dispatch @@ -32,10 +35,12 @@ export async function issueCommand(ctx: AgentContext, args: string[]): Promise'); + console.error('Usage: gitd issue '); process.exit(1); } } @@ -171,6 +176,7 @@ async function issueClose(ctx: AgentContext, args: string[]): Promise { console.error('Usage: gitd issue close '); process.exit(1); } + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); const issue = await findById(ctx, repoContextId, idStr); @@ -197,6 +203,16 @@ async function issueClose(ctx: AgentContext, args: string[]): Promise { process.exit(1); } + const event = await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : reason ? { reason } : {}, + tags : { from: tags?.status ?? 'open', to: 'closed' }, + parentContextId : issue.contextId, + } as any); + if (event.status.code >= 300) { + console.error(`Failed to record issue status change: ${event.status.code} ${event.status.detail}`); + process.exit(1); + } + console.log(`Closed issue ${idStr}: "${data.title}"`); } @@ -236,9 +252,135 @@ async function issueReopen(ctx: AgentContext, args: string[]): Promise { process.exit(1); } + const event = await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : {}, + tags : { from: tags?.status ?? 'closed', to: 'open' }, + parentContextId : issue.contextId, + } as any); + if (event.status.code >= 300) { + console.error(`Failed to record issue status change: ${event.status.code} ${event.status.detail}`); + process.exit(1); + } + console.log(`Reopened issue ${idStr}: "${data.title}"`); } +// --------------------------------------------------------------------------- +// issue accept +// --------------------------------------------------------------------------- + +async function issueAccept(ctx: AgentContext, args: string[]): Promise { + const submitterDid = args[0]; + const idStr = args[1]; + + if (!submitterDid || !idStr) { + console.error('Usage: gitd issue accept [--repo ]'); + process.exit(1); + } + + const repo = await getRepoContext(ctx, resolveRepoName(args)); + const { records } = await ctx.issues.records.query('repo/issue', { + from : submitterDid, + filter : { tags: { repoDid: ctx.did, repoRecordId: repo.recordId } }, + }); + + const externalIssue = findExternalRecord(records, idStr); + if (!externalIssue) { + console.error(`External issue ${idStr} from ${submitterDid} not found for ${repo.name}.`); + process.exit(1); + } + + const externalTags = externalIssue.tags as Record | undefined; + if (externalTags?.repoDid !== ctx.did || externalTags?.repoRecordId !== repo.recordId) { + console.error(`External issue ${idStr} does not target ${ctx.did}/${repo.name}.`); + process.exit(1); + } + + const data = await externalIssue.data.json(); + const title = typeof data.title === 'string' ? data.title : 'Untitled issue'; + const body = typeof data.body === 'string' ? data.body : ''; + const statusTag = externalTags?.status === 'closed' ? 'closed' : 'open'; + const tags: Record = { + status : statusTag, + submitterDid, + submissionRecordId : externalIssue.id, + submissionContextId : externalIssue.contextId ?? '', + }; + + const { status, record } = await ctx.issues.records.create('repo/issue', { + data : { title, body }, + tags, + parentContextId : repo.contextId, + }); + + if (status.code >= 300) { + console.error(`Failed to accept issue: ${status.code} ${status.detail}`); + process.exit(1); + } + if (!record) {throw new Error('Failed to create accepted issue record');} + + const copied = await copyExternalIssueThread(ctx, submitterDid, externalIssue, record); + + console.log(`Accepted external issue ${shortId(externalIssue.id)} as ${shortId(record.id)}: "${title}"`); + console.log(` Submitter: ${submitterDid}`); + console.log(` Source record: ${externalIssue.id}`); + console.log(` Record ID: ${record.id}`); + const copiedParts = [ + copied.comments > 0 ? `${copied.comments} comment${copied.comments !== 1 ? 's' : ''}` : '', + copied.statusChanges > 0 ? `${copied.statusChanges} status change${copied.statusChanges !== 1 ? 's' : ''}` : '', + ].filter(Boolean); + if (copiedParts.length > 0) { + console.log(` Copied: ${copiedParts.join(', ')}`); + } +} + +// --------------------------------------------------------------------------- +// issue ignore +// --------------------------------------------------------------------------- + +async function issueIgnore(ctx: AgentContext, args: string[]): Promise { + const submitterDid = args[0]; + const idStr = args[1]; + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + + if (!submitterDid || !idStr) { + console.error('Usage: gitd issue ignore [--repo ] [--reason ]'); + process.exit(1); + } + + const repo = await getRepoContext(ctx, resolveRepoName(args)); + const { records } = await ctx.issues.records.query('repo/issue', { + from : submitterDid, + filter : { tags: { repoDid: ctx.did, repoRecordId: repo.recordId } }, + }); + + const externalIssue = findExternalRecord(records, idStr); + if (!externalIssue) { + console.error(`External issue ${idStr} from ${submitterDid} not found for ${repo.name}.`); + process.exit(1); + } + + const externalTags = externalIssue.tags as Record | undefined; + if (externalTags?.repoDid !== ctx.did || externalTags?.repoRecordId !== repo.recordId) { + console.error(`External issue ${idStr} does not target ${ctx.did}/${repo.name}.`); + process.exit(1); + } + + const decision = await recordIgnoredSubmission(ctx, repo, 'issue', submitterDid, externalIssue, reason); + if (decision.status && decision.status.code >= 300) { + console.error(`Failed to ignore issue: ${decision.status.code} ${decision.status.detail}`); + process.exit(1); + } + + if (!decision.created) { + console.log(`External issue ${shortId(externalIssue.id)} is already ignored.`); + return; + } + + console.log(`Ignored external issue ${shortId(externalIssue.id)} from ${submitterDid}.`); + console.log(` Decision record: ${decision.record?.id ?? 'unknown'}`); +} + // --------------------------------------------------------------------------- // issue list // --------------------------------------------------------------------------- @@ -300,3 +442,56 @@ async function findById( return findByShortId(records, idStr); } + +function findExternalRecord(records: any[], idStr: string): any | undefined { + return records.find(record => record.id === idStr || shortId(record.id).startsWith(idStr.toLowerCase())); +} + +async function copyExternalIssueThread( + ctx: AgentContext, + submitterDid: string, + externalIssue: any, + acceptedIssue: any, +): Promise<{ comments: number; statusChanges: number }> { + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + from : submitterDid, + filter : { contextId: externalIssue.contextId }, + }); + + let copiedComments = 0; + for (const comment of comments) { + const commentData = await comment.data.json(); + const { status } = await ctx.issues.records.create('repo/issue/comment' as any, { + data : commentData, + parentContextId : acceptedIssue.contextId, + } as any); + if (status.code >= 300) { + console.error(` Warning: failed to copy issue comment ${comment.id}: ${status.code} ${status.detail}`); + continue; + } + copiedComments++; + } + + const { records: statusChanges } = await ctx.issues.records.query('repo/issue/statusChange' as any, { + from : submitterDid, + filter : { contextId: externalIssue.contextId }, + }); + + let copiedStatusChanges = 0; + for (const statusChange of statusChanges) { + const statusData = await statusChange.data.json(); + const statusTags = (statusChange.tags ?? {}) as Record; + const { status } = await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : statusData, + tags : statusTags, + parentContextId : acceptedIssue.contextId, + } as any); + if (status.code >= 300) { + console.error(` Warning: failed to copy issue status change ${statusChange.id}: ${status.code} ${status.detail}`); + continue; + } + copiedStatusChanges++; + } + + return { comments: copiedComments, statusChanges: copiedStatusChanges }; +} diff --git a/src/cli/commands/pr.ts b/src/cli/commands/pr.ts index 73ae06d..1303967 100644 --- a/src/cli/commands/pr.ts +++ b/src/cli/commands/pr.ts @@ -9,6 +9,8 @@ * gitd pr merge [--squash | --rebase] [--no-delete-branch] * gitd pr close * gitd pr reopen + * gitd pr accept + * gitd pr ignore [--reason ] * gitd pr list [--status ] * * `gitd patch` is accepted as an alias for `gitd pr`. @@ -23,9 +25,10 @@ import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; -import { getRepoContextId } from '../repo-context.js'; +import { recordIgnoredSubmission } from '../submission-decisions.js'; import { findByShortId, shortId } from '../../github-shim/helpers.js'; import { flagValue, hasFlag, resolveRepoName } from '../flags.js'; +import { getRepoContext, getRepoContextId } from '../repo-context.js'; // --------------------------------------------------------------------------- // Sub-command dispatch @@ -44,10 +47,12 @@ export async function prCommand(ctx: AgentContext, args: string[]): Promise'); + console.error('Usage: gitd pr '); process.exit(1); } } @@ -628,6 +633,138 @@ async function prReopen(ctx: AgentContext, args: string[]): Promise { console.log(`Reopened PR ${idStr}: "${data.title}"`); } +// --------------------------------------------------------------------------- +// pr accept +// --------------------------------------------------------------------------- + +async function prAccept(ctx: AgentContext, args: string[]): Promise { + const submitterDid = args[0]; + const idStr = args[1]; + + if (!submitterDid || !idStr) { + console.error('Usage: gitd pr accept [--repo ]'); + process.exit(1); + } + + const repo = await getRepoContext(ctx, resolveRepoName(args)); + const { records } = await ctx.patches.records.query('repo/patch', { + from : submitterDid, + filter : { tags: { repoDid: ctx.did, repoRecordId: repo.recordId } }, + }); + + const externalPatch = findExternalRecord(records, idStr); + if (!externalPatch) { + console.error(`External PR ${idStr} from ${submitterDid} not found for ${repo.name}.`); + process.exit(1); + } + + const externalTags = externalPatch.tags as Record | undefined; + if (externalTags?.repoDid !== ctx.did || externalTags?.repoRecordId !== repo.recordId) { + console.error(`External PR ${idStr} does not target ${ctx.did}/${repo.name}.`); + process.exit(1); + } + + const data = await externalPatch.data.json(); + const title = typeof data.title === 'string' ? data.title : 'Untitled PR'; + const body = typeof data.body === 'string' ? data.body : ''; + const statusTag = validPatchStatus(externalTags?.status) ? externalTags.status : 'open'; + const baseBranch = externalTags?.baseBranch ?? 'main'; + const sourceDid = externalTags?.sourceDid ?? submitterDid; + + const tags: Record = { + status : statusTag, + baseBranch, + sourceDid, + submitterDid, + submissionRecordId : externalPatch.id, + submissionContextId : externalPatch.contextId ?? '', + }; + if (externalTags?.headBranch) { tags.headBranch = externalTags.headBranch; } + + const { status, record } = await ctx.patches.records.create('repo/patch', { + data : { title, body }, + tags, + parentContextId : repo.contextId, + }); + + if (status.code >= 300) { + console.error(`Failed to accept PR: ${status.code} ${status.detail}`); + process.exit(1); + } + if (!record) {throw new Error('Failed to create accepted PR record');} + + const copiedRevisions = await copyExternalPatchRevisions(ctx, submitterDid, externalPatch, record); + const copiedDiscussion = await copyExternalPatchDiscussion( + ctx, + submitterDid, + externalPatch, + record, + copiedRevisions.revisionRecordIds, + ); + + console.log(`Accepted external PR ${shortId(externalPatch.id)} as ${shortId(record.id)}: "${title}"`); + console.log(` Submitter: ${submitterDid}`); + console.log(` Source record: ${externalPatch.id}`); + console.log(` Record ID: ${record.id}`); + const copiedParts = [ + copiedRevisions.revisions > 0 ? `${copiedRevisions.revisions} revision${copiedRevisions.revisions !== 1 ? 's' : ''}` : '', + copiedRevisions.bundles > 0 ? `${copiedRevisions.bundles} bundle${copiedRevisions.bundles !== 1 ? 's' : ''}` : '', + copiedDiscussion.reviews > 0 ? `${copiedDiscussion.reviews} review${copiedDiscussion.reviews !== 1 ? 's' : ''}` : '', + copiedDiscussion.reviewComments > 0 ? `${copiedDiscussion.reviewComments} review comment${copiedDiscussion.reviewComments !== 1 ? 's' : ''}` : '', + copiedDiscussion.statusChanges > 0 ? `${copiedDiscussion.statusChanges} status change${copiedDiscussion.statusChanges !== 1 ? 's' : ''}` : '', + ].filter(Boolean); + if (copiedParts.length > 0) { + console.log(` Copied: ${copiedParts.join(', ')}`); + } +} + +// --------------------------------------------------------------------------- +// pr ignore +// --------------------------------------------------------------------------- + +async function prIgnore(ctx: AgentContext, args: string[]): Promise { + const submitterDid = args[0]; + const idStr = args[1]; + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + + if (!submitterDid || !idStr) { + console.error('Usage: gitd pr ignore [--repo ] [--reason ]'); + process.exit(1); + } + + const repo = await getRepoContext(ctx, resolveRepoName(args)); + const { records } = await ctx.patches.records.query('repo/patch', { + from : submitterDid, + filter : { tags: { repoDid: ctx.did, repoRecordId: repo.recordId } }, + }); + + const externalPatch = findExternalRecord(records, idStr); + if (!externalPatch) { + console.error(`External PR ${idStr} from ${submitterDid} not found for ${repo.name}.`); + process.exit(1); + } + + const externalTags = externalPatch.tags as Record | undefined; + if (externalTags?.repoDid !== ctx.did || externalTags?.repoRecordId !== repo.recordId) { + console.error(`External PR ${idStr} does not target ${ctx.did}/${repo.name}.`); + process.exit(1); + } + + const decision = await recordIgnoredSubmission(ctx, repo, 'patch', submitterDid, externalPatch, reason); + if (decision.status && decision.status.code >= 300) { + console.error(`Failed to ignore PR: ${decision.status.code} ${decision.status.detail}`); + process.exit(1); + } + + if (!decision.created) { + console.log(`External PR ${shortId(externalPatch.id)} is already ignored.`); + return; + } + + console.log(`Ignored external PR ${shortId(externalPatch.id)} from ${submitterDid}.`); + console.log(` Decision record: ${decision.record?.id ?? 'unknown'}`); +} + // --------------------------------------------------------------------------- // pr list // --------------------------------------------------------------------------- @@ -827,6 +964,149 @@ async function createRevisionAndBundle( } } +async function copyExternalPatchRevisions( + ctx: AgentContext, + submitterDid: string, + externalPatch: any, + acceptedPatch: any, +): Promise<{ revisions: number; bundles: number; revisionRecordIds: Map }> { + const { records: revisions } = await ctx.patches.records.query('repo/patch/revision' as any, { + from : submitterDid, + filter : { contextId: externalPatch.contextId }, + }); + + let copiedRevisions = 0; + let copiedBundles = 0; + const revisionRecordIds = new Map(); + for (const revision of revisions) { + const revisionData = await revision.data.json(); + const revisionTags = (revision.tags ?? {}) as Record; + const { status, record: acceptedRevision } = await ctx.patches.records.create( + 'repo/patch/revision' as any, + { + data : revisionData, + tags : revisionTags, + parentContextId : acceptedPatch.contextId, + } as any, + ); + if (status.code >= 300 || !acceptedRevision) { + console.error(` Warning: failed to copy revision ${revision.id}: ${status.code} ${status.detail}`); + continue; + } + copiedRevisions++; + revisionRecordIds.set(revision.id, acceptedRevision.id); + + const { records: bundles } = await ctx.patches.records.query('repo/patch/revision/revisionBundle' as any, { + from : submitterDid, + filter : { contextId: revision.contextId }, + }); + for (const bundle of bundles) { + const blob = await bundle.data.blob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const bundleTags = (bundle.tags ?? {}) as Record; + const { status: bundleStatus } = await ctx.patches.records.create( + 'repo/patch/revision/revisionBundle' as any, + { + data : bytes, + dataFormat : 'application/x-git-bundle', + tags : bundleTags, + parentContextId : acceptedRevision.contextId, + } as any, + ); + if (bundleStatus.code >= 300) { + console.error(` Warning: failed to copy bundle ${bundle.id}: ${bundleStatus.code} ${bundleStatus.detail}`); + continue; + } + copiedBundles++; + } + } + + return { revisions: copiedRevisions, bundles: copiedBundles, revisionRecordIds }; +} + +async function copyExternalPatchDiscussion( + ctx: AgentContext, + submitterDid: string, + externalPatch: any, + acceptedPatch: any, + revisionRecordIds: Map, +): Promise<{ reviews: number; reviewComments: number; statusChanges: number }> { + const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { + from : submitterDid, + filter : { contextId: externalPatch.contextId }, + }); + + let copiedReviews = 0; + let copiedReviewComments = 0; + for (const review of reviews) { + const reviewData = await review.data.json(); + const reviewTags = { ...((review.tags ?? {}) as Record) }; + const revisionRecordId = reviewTags.revisionRecordId; + if (typeof revisionRecordId === 'string' && revisionRecordIds.has(revisionRecordId)) { + reviewTags.revisionRecordId = revisionRecordIds.get(revisionRecordId); + } + + const { status, record: acceptedReview } = await ctx.patches.records.create( + 'repo/patch/review' as any, + { + data : reviewData, + tags : reviewTags, + parentContextId : acceptedPatch.contextId, + } as any, + ); + if (status.code >= 300 || !acceptedReview) { + console.error(` Warning: failed to copy review ${review.id}: ${status.code} ${status.detail}`); + continue; + } + copiedReviews++; + + const { records: reviewComments } = await ctx.patches.records.query('repo/patch/review/reviewComment' as any, { + from : submitterDid, + filter : { contextId: review.contextId }, + }); + for (const reviewComment of reviewComments) { + const commentData = await reviewComment.data.json(); + const commentTags = (reviewComment.tags ?? {}) as Record; + const { status: commentStatus } = await ctx.patches.records.create( + 'repo/patch/review/reviewComment' as any, + { + data : commentData, + tags : commentTags, + parentContextId : acceptedReview.contextId, + } as any, + ); + if (commentStatus.code >= 300) { + console.error(` Warning: failed to copy review comment ${reviewComment.id}: ${commentStatus.code} ${commentStatus.detail}`); + continue; + } + copiedReviewComments++; + } + } + + const { records: statusChanges } = await ctx.patches.records.query('repo/patch/statusChange' as any, { + from : submitterDid, + filter : { contextId: externalPatch.contextId }, + }); + + let copiedStatusChanges = 0; + for (const statusChange of statusChanges) { + const statusData = await statusChange.data.json(); + const statusTags = (statusChange.tags ?? {}) as Record; + const { status } = await ctx.patches.records.create('repo/patch/statusChange' as any, { + data : statusData, + tags : statusTags, + parentContextId : acceptedPatch.contextId, + } as any); + if (status.code >= 300) { + console.error(` Warning: failed to copy PR status change ${statusChange.id}: ${status.code} ${status.detail}`); + continue; + } + copiedStatusChanges++; + } + + return { reviews: copiedReviews, reviewComments: copiedReviewComments, statusChanges: copiedStatusChanges }; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -845,3 +1125,11 @@ async function findById( return findByShortId(records, idStr); } + +function findExternalRecord(records: any[], idStr: string): any | undefined { + return records.find(record => record.id === idStr || shortId(record.id).startsWith(idStr.toLowerCase())); +} + +function validPatchStatus(value: string | undefined): value is 'draft' | 'open' | 'closed' | 'merged' { + return value === 'draft' || value === 'open' || value === 'closed' || value === 'merged'; +} diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 5f74639..e118761 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -6,7 +6,7 @@ * gitd repo add-collaborator Grant a role * gitd repo remove-collaborator Revoke a collaborator role * - * Roles: maintainer, triager, contributor + * Roles: maintainer, triager, contributor, viewer * * @module */ @@ -20,7 +20,7 @@ import { flagValue, resolveRepoName } from '../flags.js'; // Valid roles // --------------------------------------------------------------------------- -const VALID_ROLES = ['maintainer', 'triager', 'contributor'] as const; +const VALID_ROLES = ['maintainer', 'triager', 'contributor', 'viewer'] as const; type Role = typeof VALID_ROLES[number]; // --------------------------------------------------------------------------- @@ -88,7 +88,9 @@ async function repoInfo(ctx: AgentContext, args: string[]): Promise { // List collaborators per role. for (const role of VALID_ROLES) { - const { records: collabs } = await ctx.repo.records.query(`repo/${role}` as any); + const { records: collabs } = await ctx.repo.records.query(`repo/${role}` as any, { + filter: { contextId: record.contextId }, + }); if (collabs.length > 0) { console.log(`\n ${role}s:`); for (const collab of collabs) { @@ -174,11 +176,12 @@ async function removeCollaborator(ctx: AgentContext, args: string[]): Promise { } } +async function getLocalDidDocuments(ctx: AgentContext): Promise> { + const documents = new Map(); + try { + const agent = ctx.enbox.agent as EnboxPlatformAgent; + const identities = await agent.identity.list(); + for (const identity of identities) { + documents.set(identity.did.uri, identity.did.document); + } + } catch { + // Resolver lookup still handles non-local DIDs. + } + return documents; +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -116,7 +131,9 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise => { diff --git a/src/cli/dwn-sqlite.ts b/src/cli/dwn-sqlite.ts index 07ca712..f723b29 100644 --- a/src/cli/dwn-sqlite.ts +++ b/src/cli/dwn-sqlite.ts @@ -2,11 +2,11 @@ * SQLite-backed DWN store factory. * * Replaces the default LevelDB stores (`MessageStoreLevel`, `DataStoreLevel`, - * `StateIndexLevel`, `ResumableTaskStoreLevel`) with their SQL equivalents + * `ResumableTaskStoreLevel`) with their SQL equivalents * from `@enbox/dwn-sql-store`, backed by Bun's native `bun:sqlite`. * * This eliminates the `classic-level` / `node-gyp` native dependency for - * the four core DWN stores. The remaining Level-based components + * the core DWN stores. The remaining Level-based components * (`SyncEngineLevel`, `LevelStore` for the vault, `AgentDidResolverCache`) * stay on LevelDB until SQL alternatives are available upstream. * @@ -28,7 +28,6 @@ import { ResumableTaskStoreSql, runDwnStoreMigrations, SqliteDialect, - StateIndexSql, } from '@enbox/dwn-sql-store'; import { DidDht, @@ -38,7 +37,7 @@ import { DidWeb, UniversalResolver, } from '@enbox/dids'; -import { Dwn, EventEmitterEventLog } from '@enbox/dwn-sdk-js'; +import { DurableEventLog, Dwn, EventEmitterWakePublisher } from '@enbox/dwn-sdk-js'; // --------------------------------------------------------------------------- // Public API @@ -70,11 +69,11 @@ export async function createSqliteDwnApi( const migrationDb = new Kysely>({ dialect }); await runDwnStoreMigrations(migrationDb, dialect); - const messageStore = new MessageStoreSql(dialect); + const wakePublisher = new EventEmitterWakePublisher(); + const messageStore = new MessageStoreSql(dialect, wakePublisher); const dataStore = new DataStoreSql(dialect); - const stateIndex = new StateIndexSql(dialect); const resumableTaskStore = new ResumableTaskStoreSql(dialect); - const eventLog = new EventEmitterEventLog(); + const eventLog = new DurableEventLog(messageStore, wakePublisher); // Create a profile-scoped DID resolver with its cache inside the // agent data directory. Without this, Dwn.create() falls back to a @@ -87,7 +86,6 @@ export async function createSqliteDwnApi( const dwn = await Dwn.create({ dataStore, messageStore, - stateIndex, resumableTaskStore, eventLog, didResolver, diff --git a/src/cli/main.ts b/src/cli/main.ts index c48cbd8..77958c4 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -130,7 +130,7 @@ function printUsage(): void { console.log(' serve logs Tail daemon log file'); console.log(''); console.log(' repo info Show repo metadata'); - console.log(' repo add-collaborator Grant a role (maintainer|triager|contributor)'); + console.log(' repo add-collaborator Grant a role (maintainer|triager|contributor|viewer)'); console.log(' repo remove-collaborator Revoke a collaborator role'); console.log(''); console.log(' issue create [--body <text>] File an issue'); @@ -138,6 +138,8 @@ function printUsage(): void { console.log(' issue comment <number> <body> Add a comment to an issue'); console.log(' issue close <number> Close an issue'); console.log(' issue reopen <number> Reopen a closed issue'); + console.log(' issue accept <did> <id> Accept an external issue submission'); + console.log(' issue ignore <did> <id> Ignore an external issue submission'); console.log(' issue list [--status <open|closed>] List issues'); console.log(''); console.log(' pr create <title> [--base ...] [--head ...] Open a pull request'); @@ -146,6 +148,8 @@ function printUsage(): void { console.log(' pr merge <number> [--squash|--rebase] Merge a PR with actual git merge'); console.log(' pr close <number> Close a PR'); console.log(' pr reopen <number> Reopen a closed PR'); + console.log(' pr accept <did> <id> Accept an external PR submission'); + console.log(' pr ignore <did> <id> Ignore an external PR submission'); console.log(' pr list [--status <status>] List PRs'); console.log(''); console.log(' release create <tag> [--name ...] [--body ...] Create a release'); diff --git a/src/cli/repo-context.ts b/src/cli/repo-context.ts index e43731f..bf2f1d0 100644 --- a/src/cli/repo-context.ts +++ b/src/cli/repo-context.ts @@ -17,6 +17,8 @@ import type { AgentContext } from './agent.js'; /** Repo context returned by {@link getRepoContext}. */ export type RepoContext = { + /** The repo record ID (stable identifier used by indexers and cross-DWN submissions). */ + recordId: string; /** The repo record's contextId (used as parentContextId for child records). */ contextId: string; /** Repo visibility — controls encryption of bundle records. */ @@ -86,5 +88,5 @@ function extractContext(record: any, name: string): RepoContext { } const visibility = (record.tags?.visibility as 'public' | 'private') ?? 'public'; - return { contextId, visibility, name }; + return { recordId: record.id, contextId, visibility, name }; } diff --git a/src/cli/submission-decisions.ts b/src/cli/submission-decisions.ts new file mode 100644 index 0000000..f416048 --- /dev/null +++ b/src/cli/submission-decisions.ts @@ -0,0 +1,53 @@ +import type { AgentContext } from './agent.js'; +import type { RepoContext } from './repo-context.js'; + +export type SubmissionKind = 'issue' | 'patch'; + +export type SubmissionDecisionResult = { + created : boolean; + record? : any; + status? : { code: number; detail?: string }; +}; + +export async function recordIgnoredSubmission( + ctx: AgentContext, + repo: RepoContext, + kind: SubmissionKind, + submitterDid: string, + submission: { id: string; contextId?: string }, + reason?: string, +): Promise<SubmissionDecisionResult> { + const matchTags = { + kind, + decision : 'ignored', + submitterDid, + submissionRecordId : submission.id, + }; + const { records: existing } = await ctx.repo.records.query('repo/submissionDecision' as any, { + filter: { + contextId : repo.contextId, + tags : matchTags, + }, + }); + if (existing.length > 0) { + return { created: false, record: existing[0] }; + } + + const tags: Record<string, string> = { ...matchTags }; + if (submission.contextId) { tags.submissionContextId = submission.contextId; } + + const data: Record<string, string> = { + ...tags, + decidedBy : ctx.did, + decidedAt : new Date().toISOString(), + }; + if (reason) { data.reason = reason; } + + const { status, record } = await ctx.repo.records.create('repo/submissionDecision' as any, { + data, + tags, + parentContextId: repo.contextId, + } as any); + + return { created: status.code < 300, record, status }; +} diff --git a/src/daemon/lockfile.ts b/src/daemon/lockfile.ts index ddd50fe..f8a1cbe 100644 --- a/src/daemon/lockfile.ts +++ b/src/daemon/lockfile.ts @@ -2,7 +2,7 @@ * Daemon lockfile — discovery mechanism for the local gitd server. * * When `gitd serve` starts, it writes a JSON lockfile to - * `~/.enbox/daemon.lock` containing `{ pid, port, startedAt }`. + * `~/.enbox/daemon.lock` containing `{ pid, port, startedAt, ownerDid }`. * `git-remote-did` reads this file to discover a running local daemon * and resolve `did::` remotes to `http://localhost:<port>/...` instead * of performing DID document resolution. @@ -35,6 +35,9 @@ export type DaemonLock = { /** The gitd version that started this daemon (for upgrade detection). */ version?: string; + + /** The DID of the identity that owns this daemon. */ + ownerDid?: string; }; // --------------------------------------------------------------------------- @@ -51,12 +54,13 @@ export function lockfilePath(): string { // --------------------------------------------------------------------------- /** Write the daemon lockfile. Overwrites any existing file. */ -export function writeLockfile(port: number, version?: string): void { +export function writeLockfile(port: number, version?: string, ownerDid?: string): void { const lock: DaemonLock = { pid : process.pid, port, startedAt : new Date().toISOString(), ...(version ? { version } : {}), + ...(ownerDid ? { ownerDid } : {}), }; const path = lockfilePath(); mkdirSync(dirname(path), { recursive: true }); diff --git a/src/git-remote/resolve.ts b/src/git-remote/resolve.ts index b85822f..6d97e60 100644 --- a/src/git-remote/resolve.ts +++ b/src/git-remote/resolve.ts @@ -134,6 +134,15 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi // Fast path: check for an already-running daemon. const lock = readLockfile(); if (lock) { + // Only use the local daemon when the requested DID matches the + // daemon's owner. Cloning someone else's repo must fall through + // to DID document resolution so the request reaches the correct + // remote server. Lockfiles without `ownerDid` (written by older + // versions) are treated as matching for backwards compatibility. + if (lock.ownerDid && lock.ownerDid !== did) { + return null; + } + const healthUrl = `http://localhost:${lock.port}/health`; try { const controller = new AbortController(); diff --git a/src/git-server/push-authorizer.ts b/src/git-server/push-authorizer.ts index beec1f5..ab612d7 100644 --- a/src/git-server/push-authorizer.ts +++ b/src/git-server/push-authorizer.ts @@ -31,6 +31,8 @@ export type DwnPushAuthorizerOptions = { repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>; /** The DID of the DWN owner (server operator). */ ownerDid: string; + /** Optional known repo context. If omitted, resolved from the pushed repo name. */ + repoContextId?: string; }; // --------------------------------------------------------------------------- @@ -48,17 +50,22 @@ export type DwnPushAuthorizerOptions = { * @returns A PushAuthorizer callback */ export function createDwnPushAuthorizer(options: DwnPushAuthorizerOptions): PushAuthorizer { - const { repo, ownerDid } = options; + const { repo, ownerDid, repoContextId } = options; - return async (did: string, owner: string, _repoName: string): Promise<boolean> => { + return async (did: string, owner: string, repoName: string): Promise<boolean> => { // The owner can always push to their own repos. if (did === owner || did === ownerDid) { return true; } + const contextId = repoContextId ?? await findRepoContextId(repo, repoName); + if (!contextId) { + return false; + } + // Query for maintainer role records for this DID. const { records: maintainers } = await repo.records.query('repo/maintainer' as any, { - filter: { tags: { did } }, + filter: { contextId, tags: { did } }, }); if (maintainers.length > 0) { return true; @@ -66,7 +73,7 @@ export function createDwnPushAuthorizer(options: DwnPushAuthorizerOptions): Push // Query for contributor role records for this DID. const { records: contributors } = await repo.records.query('repo/contributor' as any, { - filter: { tags: { did } }, + filter: { contextId, tags: { did } }, }); if (contributors.length > 0) { return true; @@ -75,3 +82,13 @@ export function createDwnPushAuthorizer(options: DwnPushAuthorizerOptions): Push return false; }; } + +async function findRepoContextId( + repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>, + repoName: string, +): Promise<string | undefined> { + const { records } = await repo.records.query('repo', { + filter: { tags: { name: repoName } }, + }); + return records[0]?.contextId; +} diff --git a/src/git-server/verify.ts b/src/git-server/verify.ts index 93793f3..73c6bff 100644 --- a/src/git-server/verify.ts +++ b/src/git-server/verify.ts @@ -8,6 +8,7 @@ * @module */ +import type { DidDocument } from '@enbox/dids'; import type { SignatureVerifier } from './auth.js'; import { Ed25519 } from '@enbox/crypto'; @@ -47,9 +48,26 @@ function getResolver(): UniversalResolver { /** DID resolution timeout in milliseconds. */ const DID_RESOLUTION_TIMEOUT_MS = 30_000; -export function createDidSignatureVerifier(): SignatureVerifier { +/** Options for creating a DID signature verifier. */ +export type DidSignatureVerifierOptions = { + /** + * DID documents the caller already trusts, keyed or listed by document ID. + * These are checked before resolver lookup and are useful for freshly + * created local DIDs whose DHT publication may not be visible yet. + */ + didDocuments?: DidDocument[] | Map<string, DidDocument> | Record<string, DidDocument>; +}; + +export function createDidSignatureVerifier(options: DidSignatureVerifierOptions = {}): SignatureVerifier { + const localDocuments = normalizeDidDocuments(options.didDocuments); + return async (did: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean> => { try { + const localDocument = localDocuments.get(did); + if (localDocument) { + return verifyWithDocument(localDocument, payload, signature); + } + const { didDocument, didResolutionMetadata } = await Promise.race([ getResolver().resolve(did), new Promise<never>((_, reject) => @@ -61,17 +79,7 @@ export function createDidSignatureVerifier(): SignatureVerifier { return false; } - // Find an Ed25519 public key from the authentication verification methods. - const publicKeyJwk = findEd25519AuthKey(didDocument); - if (!publicKeyJwk) { - return false; - } - - return await Ed25519.verify({ - key : publicKeyJwk, - data : payload, - signature : signature, - }); + return verifyWithDocument(didDocument, payload, signature); } catch { return false; } @@ -114,3 +122,39 @@ function findEd25519AuthKey(didDocument: { verificationMethod?: any[]; authentic return undefined; } + +async function verifyWithDocument( + didDocument: DidDocument, + payload: Uint8Array, + signature: Uint8Array, +): Promise<boolean> { + const publicKeyJwk = findEd25519AuthKey(didDocument); + if (!publicKeyJwk) { + return false; + } + + return Ed25519.verify({ + key : publicKeyJwk, + data : payload, + signature : signature, + }); +} + +function normalizeDidDocuments( + didDocuments: DidSignatureVerifierOptions['didDocuments'], +): Map<string, DidDocument> { + const result = new Map<string, DidDocument>(); + if (!didDocuments) { return result; } + + if (didDocuments instanceof Map) { + return new Map(didDocuments); + } + + const docs = Array.isArray(didDocuments) ? didDocuments : Object.values(didDocuments); + for (const doc of docs) { + if (doc?.id) { + result.set(doc.id, doc); + } + } + return result; +} diff --git a/src/github-shim/actions.ts b/src/github-shim/actions.ts new file mode 100644 index 0000000..f5ab9ad --- /dev/null +++ b/src/github-shim/actions.ts @@ -0,0 +1,2940 @@ +/** + * GitHub API shim — Actions workflow run and job endpoints. + * + * Maps `forge-ci` check suites and check runs to GitHub Actions-compatible + * workflow run and workflow job responses. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { buildRepoResponse } from './repos.js'; + +import { + baseHeaders, + binaryOk, + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type CiSuite = { + rec : any; + data : Record<string, any>; + tags : Record<string, string>; +}; + +type CiRun = { + suite : CiSuite; + rec : any; + data : Record<string, any>; + tags : Record<string, string>; +}; + +type CiArtifact = { + suite : CiSuite; + run : CiRun; + rec : any; + tags : Record<string, any>; +}; + +type WorkflowState = 'active' | 'disabled_manually'; + +type WorkflowSettingsEntry = { + id : number; + name : string; + path : string; + state : WorkflowState; + createdAt : string; + updatedAt : string; +}; + +type ActionsVariableEntry = { + name : string; + value : string; + createdAt : string; + updatedAt : string; +}; + +type ActionsSecretEntry = { + name : string; + encryptedValue : string; + keyId : string; + createdAt : string; + updatedAt : string; +}; + +type ActionsCacheEntry = { + id : number; + ref : string; + key : string; + version : string; + lastAccessedAt : string; + createdAt : string; + sizeInBytes : number; +}; + +type ActionsAllowedActionsPolicy = 'all' | 'local_only' | 'selected'; +type ActionsDefaultWorkflowPermissions = 'read' | 'write'; + +type ActionsSelectedActionsSettings = { + githubOwnedAllowed : boolean; + verifiedAllowed : boolean; + patternsAllowed : string[]; +}; + +type ActionsPermissionsSettings = { + enabled : boolean; + allowedActions : ActionsAllowedActionsPolicy; + shaPinningRequired : boolean; + selectedActions : ActionsSelectedActionsSettings; + defaultWorkflowPermissions : ActionsDefaultWorkflowPermissions; + canApprovePullRequestReviews : boolean; +}; + +type EnvironmentSettingsEntry = { + id : number; + name : string; + createdAt : string; + updatedAt : string; + waitTimer? : number; + preventSelfReview? : boolean; + reviewers? : Array<{ type: 'User' | 'Team'; id: number }>; + deploymentBranchPolicy? : { protectedBranches: boolean; customBranchPolicies: boolean } | null; + variables? : Record<string, ActionsVariableEntry>; + secrets? : Record<string, ActionsSecretEntry>; +}; + +type RepoSettingsData = { + actionsWorkflows? : Record<string, WorkflowSettingsEntry>; + actionsVariables? : Record<string, ActionsVariableEntry>; + actionsSecrets? : Record<string, ActionsSecretEntry>; + actionsCaches? : Record<string, ActionsCacheEntry>; + actionsCacheRetentionLimitDays? : number; + actionsCacheStorageLimitGb? : number; + actionsPermissions? : ActionsPermissionsSettings; + environments? : Record<string, EnvironmentSettingsEntry>; +}; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +type WorkflowInfo = { + id : number; + name : string; + path : string; + state : WorkflowState; + createdAt : string; + updatedAt : string; +}; + +const ACTION_RUN_STATUSES = new Set([ + 'completed', + 'action_required', + 'cancelled', + 'failure', + 'neutral', + 'skipped', + 'stale', + 'success', + 'timed_out', + 'in_progress', + 'queued', + 'requested', + 'waiting', + 'pending', +]); + +const DEFAULT_ACTIONS_CACHE_RETENTION_LIMIT_DAYS = 7; +const DEFAULT_ACTIONS_CACHE_STORAGE_LIMIT_GB = 10; +const ACTIONS_CACHE_SORTS = new Set(['created_at', 'last_accessed_at', 'size_in_bytes']); +const ACTIONS_ALLOWED_ACTIONS_POLICIES = new Set(['all', 'local_only', 'selected']); +const ACTIONS_DEFAULT_WORKFLOW_PERMISSIONS = new Set(['read', 'write']); +const SORT_ASC = 'asc'; +const SORT_DESC = 'desc'; + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +async function querySuites( + ctx: AgentContext, targetDid: string, repo: RepoInfo, +): Promise<CiSuite[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.ci.records.query('repo/checkSuite' as any, { + from, + filter : { contextId: repo.contextId }, + dateSort : DateSort.CreatedDescending, + }); + + const suites: CiSuite[] = []; + for (const rec of records) { + suites.push({ + rec, + data : await rec.data.json(), + tags : (rec.tags as Record<string, string> | undefined) ?? {}, + }); + } + return suites; +} + +async function queryRunsForSuite( + ctx: AgentContext, targetDid: string, suite: CiSuite, +): Promise<CiRun[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.ci.records.query('repo/checkSuite/checkRun' as any, { + from, + filter : { contextId: suite.rec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const runs: CiRun[] = []; + for (const rec of records) { + runs.push({ + suite, + rec, + data : await rec.data.json(), + tags : (rec.tags as Record<string, string> | undefined) ?? {}, + }); + } + return runs; +} + +async function queryArtifactsForRun( + ctx: AgentContext, targetDid: string, run: CiRun, +): Promise<CiArtifact[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.ci.records.query('repo/checkSuite/checkRun/artifact' as any, { + from, + filter : { contextId: run.rec.contextId }, + dateSort : DateSort.CreatedDescending, + }); + + return records.map(rec => ({ + suite : run.suite, + run, + rec, + tags : (rec.tags as Record<string, any> | undefined) ?? {}, + })); +} + +async function querySettings( + ctx: AgentContext, targetDid: string, repo: RepoInfo, +): Promise<RepoSettingsLookup> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +async function findSuiteByNumber( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<CiSuite | null> { + const wanted = parseInt(id, 10); + const suites = await querySuites(ctx, targetDid, repo); + return suites.find(suite => numericId(suite.rec.id ?? '') === wanted) ?? null; +} + +async function findRunByNumber( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<CiRun | null> { + const wanted = parseInt(id, 10); + const suites = await querySuites(ctx, targetDid, repo); + for (const suite of suites) { + const runs = await queryRunsForSuite(ctx, targetDid, suite); + const match = runs.find(run => numericId(run.rec.id ?? '') === wanted); + if (match) { return match; } + } + return null; +} + +function createdEmpty(): JsonResponse { + return { + status : 201, + headers : baseHeaders(), + body : '', + }; +} + +function acceptedEmpty(): JsonResponse { + return { + status : 202, + headers : baseHeaders(), + body : '', + }; +} + +function jsonConflict(message: string): JsonResponse { + return { + status : 409, + headers : baseHeaders(), + body : JSON.stringify({ message, documentation_url: 'https://docs.github.com/rest' }), + }; +} + +function suiteId(suite: CiSuite): number { + return numericId(suite.rec.id ?? ''); +} + +function runId(run: CiRun): number { + return numericId(run.rec.id ?? ''); +} + +function suiteAppName(suite: CiSuite): string { + return suite.data.app ?? 'gitd-ci'; +} + +function workflowId(name: string): number { + return numericId(`workflow:${name}`); +} + +function workflowSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'gitd-ci'; +} + +function workflowPath(name: string): string { + return `.github/workflows/${workflowSlug(name)}.yml`; +} + +function workflowSettingsKey(workflow: WorkflowInfo): string { + return String(workflow.id); +} + +function suiteHeadSha(suite: CiSuite): string { + return suite.tags.commitSha ?? ''; +} + +function suiteHeadBranch(suite: CiSuite, repo: RepoInfo): string { + return suite.tags.branch ?? suite.data.headBranch ?? repo.defaultBranch; +} + +function suiteStatus(suite: CiSuite): string { + return suite.tags.status ?? 'queued'; +} + +function suiteConclusion(suite: CiSuite): string | null { + return suite.tags.conclusion ?? null; +} + +function elapsedMs(start: unknown, end: unknown): number { + const startTime = Date.parse(String(start ?? '')); + const endTime = Date.parse(String(end ?? '')); + if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) { return 0; } + return Math.max(0, endTime - startTime); +} + +function workflowRunDurationMs(suite: CiSuite): number { + return elapsedMs( + suite.data.startedAt ?? suite.rec.dateCreated, + suite.data.completedAt ?? suite.rec.timestamp ?? suite.rec.dateCreated, + ); +} + +function workflowJobDurationMs(run: CiRun): number { + return elapsedMs( + run.data.startedAt ?? run.rec.dateCreated, + run.data.completedAt ?? run.rec.timestamp ?? run.rec.dateCreated, + ); +} + +function rawRunOutput(run: CiRun): Record<string, any> { + const output = run.data.output; + if (output && typeof output === 'object' && !Array.isArray(output)) { + return output; + } + return run.data; +} + +function actionActor(suite: CiSuite, targetDid: string, baseUrl: string): Record<string, unknown> { + return buildOwner(suite.rec.author ?? targetDid, baseUrl); +} + +function buildWorkflowResponse( + workflow: WorkflowInfo, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + return { + id : workflow.id, + node_id : `workflow:${workflow.name}`, + name : workflow.name, + path : workflow.path, + state : workflow.state, + created_at : workflow.createdAt, + updated_at : workflow.updatedAt, + url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/workflows/${workflow.id}`, + html_url : `${baseUrl}/repos/${targetDid}/${repo.name}/blob/${repo.defaultBranch}/${workflow.path}`, + badge_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/workflows/${encodeURIComponent(workflow.name)}/badge.svg`, + }; +} + +function buildWorkflowRunResponse( + suite: CiSuite, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const id = suiteId(suite); + const appName = suiteAppName(suite); + const branch = suiteHeadBranch(suite, repo); + const headSha = suiteHeadSha(suite); + const idForWorkflow = workflowId(appName); + const actor = actionActor(suite, targetDid, baseUrl); + const repository = buildRepoResponse(repo, targetDid, repo.name, baseUrl); + + return { + id, + name : appName, + node_id : suite.rec.id ?? '', + check_suite_id : id, + check_suite_node_id : suite.rec.id ?? '', + head_branch : branch, + head_sha : headSha, + path : `${workflowPath(appName)}@${branch}`, + run_number : id, + event : suite.data.event ?? 'push', + display_title : suite.data.displayTitle ?? appName, + status : suiteStatus(suite), + conclusion : suiteConclusion(suite), + workflow_id : idForWorkflow, + url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}`, + html_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}`, + pull_requests : [], + created_at : toISODate(suite.rec.dateCreated), + updated_at : toISODate(suite.rec.timestamp), + actor, + run_attempt : 1, + run_started_at : toISODate(suite.data.startedAt ?? suite.rec.dateCreated), + triggering_actor : actor, + jobs_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}/jobs`, + logs_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}/logs`, + check_suite_url : `${baseUrl}/repos/${targetDid}/${repo.name}/check-suites/${id}`, + artifacts_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}/artifacts`, + cancel_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}/cancel`, + rerun_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${id}/rerun`, + workflow_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/workflows/${idForWorkflow}`, + head_commit : { + id : headSha, + tree_id : '', + message : suite.data.message ?? `${appName} workflow run`, + timestamp : toISODate(suite.rec.dateCreated), + author : { name: suite.rec.author ?? targetDid, email: '' }, + committer : { name: suite.rec.author ?? targetDid, email: '' }, + }, + repository, + head_repository: repository, + }; +} + +function workflowsFromSuites(suites: CiSuite[], settings: RepoSettingsData): WorkflowInfo[] { + const byId = new Map<number, WorkflowInfo>(); + + for (const suite of suites) { + const name = suiteAppName(suite); + const id = workflowId(name); + const existing = byId.get(id); + const createdAt = toISODate(suite.rec.dateCreated); + const updatedAt = toISODate(suite.rec.timestamp); + if (!existing) { + byId.set(id, { + id, + name, + path : workflowPath(name), + state : settings.actionsWorkflows?.[String(id)]?.state ?? 'active', + createdAt, + updatedAt, + }); + continue; + } + + if (createdAt < existing.createdAt) { existing.createdAt = createdAt; } + if (updatedAt > existing.updatedAt) { existing.updatedAt = updatedAt; } + } + + for (const workflow of Object.values(settings.actionsWorkflows ?? {})) { + if (!byId.has(workflow.id)) { + byId.set(workflow.id, { + id : workflow.id, + name : workflow.name, + path : workflow.path, + state : workflow.state, + createdAt : workflow.createdAt, + updatedAt : workflow.updatedAt, + }); + } + } + + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function workflowMatchesParam(workflow: WorkflowInfo, rawId: string): boolean { + const decoded = decodeRouteParam(rawId); + const basename = workflow.path.split('/').pop() ?? workflow.path; + const slug = workflowSlug(workflow.name); + return decoded === String(workflow.id) + || decoded === workflow.path + || decoded === basename + || decoded === workflow.name + || decoded === slug + || decoded === `${slug}.yml` + || decoded === `${slug}.yaml`; +} + +async function listWorkflows( + ctx: AgentContext, targetDid: string, repo: RepoInfo, +): Promise<{ suites: CiSuite[]; settingsLookup: RepoSettingsLookup; workflows: WorkflowInfo[] }> { + const [suites, settingsLookup] = await Promise.all([ + querySuites(ctx, targetDid, repo), + querySettings(ctx, targetDid, repo), + ]); + return { suites, settingsLookup, workflows: workflowsFromSuites(suites, settingsLookup.settings) }; +} + +async function findWorkflow( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<{ suites: CiSuite[]; settingsLookup: RepoSettingsLookup; workflow: WorkflowInfo } | null> { + const result = await listWorkflows(ctx, targetDid, repo); + const workflow = result.workflows.find(candidate => workflowMatchesParam(candidate, id)); + return workflow ? { ...result, workflow } : null; +} + +function workflowSettingsWithState( + settings: RepoSettingsData, workflow: WorkflowInfo, state: WorkflowState, +): RepoSettingsData { + const actionsWorkflows = { ...(settings.actionsWorkflows ?? {}) }; + actionsWorkflows[workflowSettingsKey(workflow)] = { + id : workflow.id, + name : workflow.name, + path : workflow.path, + state, + createdAt : workflow.createdAt, + updatedAt : new Date().toISOString(), + }; + return { ...settings, actionsWorkflows }; +} + +function variableKey(name: string): string { + return name.toUpperCase(); +} + +function environmentKey(name: string): string { + return name.toLowerCase(); +} + +function decodeRequiredRouteParam(value: string, fieldName: string): string | JsonResponse { + const decoded = decodeRouteParam(value).trim(); + if (!decoded) { + return jsonValidationError(`Validation Failed: ${fieldName} is invalid.`); + } + return decoded; +} + +function parseRequiredString(value: unknown, fieldName: string): string | JsonResponse { + if (typeof value !== 'string' || value.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${fieldName} is required.`); + } + return value.trim(); +} + +function parseOptionalString(value: unknown, fieldName: string): string | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${fieldName} must be a non-empty string.`); + } + return value.trim(); +} + +function parseRequiredValue(value: unknown, fieldName: string): string | JsonResponse { + if (typeof value !== 'string') { + return jsonValidationError(`Validation Failed: ${fieldName} is required.`); + } + return value; +} + +function parseOptionalValue(value: unknown, fieldName: string): string | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'string') { + return jsonValidationError(`Validation Failed: ${fieldName} must be a string.`); + } + return value; +} + +function parseRequiredBoolean(value: unknown, fieldName: string): boolean | JsonResponse { + if (typeof value !== 'boolean') { + return jsonValidationError(`Validation Failed: ${fieldName} is required.`); + } + return value; +} + +function parseOptionalBoolean(value: unknown, fieldName: string): boolean | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'boolean') { + return jsonValidationError(`Validation Failed: ${fieldName} must be a boolean.`); + } + return value; +} + +function parsePositiveInteger(value: unknown, fieldName: string): number | JsonResponse { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return jsonValidationError(`Validation Failed: ${fieldName} must be a positive integer.`); + } + return value; +} + +function parseAllowedActionsPolicy(value: unknown): ActionsAllowedActionsPolicy | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'string' || !ACTIONS_ALLOWED_ACTIONS_POLICIES.has(value)) { + return jsonValidationError('Validation Failed: allowed_actions must be all, local_only, or selected.'); + } + return value as ActionsAllowedActionsPolicy; +} + +function parseDefaultWorkflowPermissions(value: unknown): ActionsDefaultWorkflowPermissions | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'string' || !ACTIONS_DEFAULT_WORKFLOW_PERMISSIONS.has(value)) { + return jsonValidationError('Validation Failed: default_workflow_permissions must be read or write.'); + } + return value as ActionsDefaultWorkflowPermissions; +} + +function parseOptionalStringArray(value: unknown, fieldName: string): string[] | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (!Array.isArray(value)) { + return jsonValidationError(`Validation Failed: ${fieldName} must be an array of strings.`); + } + + const normalized: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${fieldName} must be an array of non-empty strings.`); + } + const trimmed = item.trim(); + if (!normalized.includes(trimmed)) { + normalized.push(trimmed); + } + } + return normalized; +} + +function buildVariableResponse(variable: ActionsVariableEntry): Record<string, unknown> { + return { + name : variable.name, + value : variable.value, + created_at : toISODate(variable.createdAt), + updated_at : toISODate(variable.updatedAt), + }; +} + +function normalizedActionsPermissions(settings: RepoSettingsData): ActionsPermissionsSettings { + const permissions = settings.actionsPermissions; + return { + enabled : permissions?.enabled ?? true, + allowedActions : permissions?.allowedActions ?? 'all', + shaPinningRequired : permissions?.shaPinningRequired ?? false, + selectedActions : { + githubOwnedAllowed : permissions?.selectedActions?.githubOwnedAllowed ?? true, + verifiedAllowed : permissions?.selectedActions?.verifiedAllowed ?? true, + patternsAllowed : [...(permissions?.selectedActions?.patternsAllowed ?? [])], + }, + defaultWorkflowPermissions : permissions?.defaultWorkflowPermissions ?? 'read', + canApprovePullRequestReviews : permissions?.canApprovePullRequestReviews ?? false, + }; +} + +function buildActionsPermissionsResponse( + permissions: ActionsPermissionsSettings, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + enabled : permissions.enabled, + allowed_actions : permissions.allowedActions, + selected_actions_url : `${baseUrl}/repos/${targetDid}/${repoName}/actions/permissions/selected-actions`, + sha_pinning_required : permissions.shaPinningRequired, + }; +} + +function buildSelectedActionsResponse(permissions: ActionsPermissionsSettings): Record<string, unknown> { + return { + github_owned_allowed : permissions.selectedActions.githubOwnedAllowed, + verified_allowed : permissions.selectedActions.verifiedAllowed, + patterns_allowed : permissions.selectedActions.patternsAllowed, + }; +} + +function buildWorkflowPermissionsResponse(permissions: ActionsPermissionsSettings): Record<string, unknown> { + return { + default_workflow_permissions : permissions.defaultWorkflowPermissions, + can_approve_pull_request_reviews : permissions.canApprovePullRequestReviews, + }; +} + +function buildSecretPublicKey(scope: string): Record<string, string> { + return { + key_id : String(numericId(`actions-secret-key:${scope}`) || 1), + key : createHash('sha256').update(`gitd-actions-secret-key:${scope}`).digest('base64'), + }; +} + +function buildSecretResponse(secret: ActionsSecretEntry): Record<string, unknown> { + return { + name : secret.name, + created_at : toISODate(secret.createdAt), + updated_at : toISODate(secret.updatedAt), + }; +} + +function buildActionsCacheResponse(cache: ActionsCacheEntry): Record<string, unknown> { + return { + id : cache.id, + ref : cache.ref, + key : cache.key, + version : cache.version, + last_accessed_at : toISODate(cache.lastAccessedAt), + created_at : toISODate(cache.createdAt), + size_in_bytes : cache.sizeInBytes, + }; +} + +function variableEntries(variables?: Record<string, ActionsVariableEntry>): ActionsVariableEntry[] { + return Object.values(variables ?? {}) + .filter((variable): variable is ActionsVariableEntry => Boolean(variable) && typeof variable.name === 'string') + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function secretEntries(secrets?: Record<string, ActionsSecretEntry>): ActionsSecretEntry[] { + return Object.values(secrets ?? {}) + .filter((secret): secret is ActionsSecretEntry => Boolean(secret) && typeof secret.name === 'string') + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function cacheEntries(caches?: Record<string, ActionsCacheEntry>): ActionsCacheEntry[] { + return Object.values(caches ?? {}) + .filter((cache): cache is ActionsCacheEntry => ( + Boolean(cache) + && typeof cache.id === 'number' + && typeof cache.key === 'string' + && typeof cache.ref === 'string' + )); +} + +function variableListResponse(variables: ActionsVariableEntry[], url: URL): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(variables, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, variables.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : variables.length, + variables : paged.map(buildVariableResponse), + }, extraHeaders); +} + +function secretListResponse(secrets: ActionsSecretEntry[], url: URL): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(secrets, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, secrets.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : secrets.length, + secrets : paged.map(buildSecretResponse), + }, extraHeaders); +} + +function sortedCacheEntries(caches: ActionsCacheEntry[], sort: string, direction: string): ActionsCacheEntry[] { + const multiplier = direction === SORT_ASC ? 1 : -1; + return [...caches].sort((a, b) => { + let result = 0; + if (sort === 'size_in_bytes') { + result = a.sizeInBytes - b.sizeInBytes; + } else { + const left = sort === 'created_at' ? a.createdAt : a.lastAccessedAt; + const right = sort === 'created_at' ? b.createdAt : b.lastAccessedAt; + result = Date.parse(left) - Date.parse(right); + } + if (result === 0) { + result = a.id - b.id; + } + return result * multiplier; + }); +} + +function filteredCacheEntries(caches: ActionsCacheEntry[], url: URL): ActionsCacheEntry[] | JsonResponse { + const sort = url.searchParams.get('sort') ?? 'last_accessed_at'; + if (!ACTIONS_CACHE_SORTS.has(sort)) { + return jsonValidationError('Validation Failed: sort must be one of created_at, last_accessed_at, size_in_bytes.'); + } + + const direction = url.searchParams.get('direction') ?? SORT_DESC; + if (direction !== SORT_ASC && direction !== SORT_DESC) { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const ref = url.searchParams.get('ref'); + const key = url.searchParams.get('key'); + const filtered = caches.filter(cache => { + if (ref && cache.ref !== ref) { return false; } + if (key && !cache.key.startsWith(key)) { return false; } + return true; + }); + return sortedCacheEntries(filtered, sort, direction); +} + +function actionsCacheListResponse(caches: ActionsCacheEntry[], url: URL): JsonResponse { + const filtered = filteredCacheEntries(caches, url); + if ('status' in filtered) { return filtered; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, filtered.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : filtered.length, + actions_caches : paged.map(buildActionsCacheResponse), + }, extraHeaders); +} + +function repositoryVariableSettingsWithChange( + settings: RepoSettingsData, key: string, variable: ActionsVariableEntry | null, +): RepoSettingsData { + const actionsVariables = { ...(settings.actionsVariables ?? {}) }; + if (variable) { + actionsVariables[key] = variable; + } else { + delete actionsVariables[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(actionsVariables).length > 0) { + next.actionsVariables = actionsVariables; + } else { + delete next.actionsVariables; + } + return next; +} + +function repositorySecretSettingsWithChange( + settings: RepoSettingsData, key: string, secret: ActionsSecretEntry | null, +): RepoSettingsData { + const actionsSecrets = { ...(settings.actionsSecrets ?? {}) }; + if (secret) { + actionsSecrets[key] = secret; + } else { + delete actionsSecrets[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(actionsSecrets).length > 0) { + next.actionsSecrets = actionsSecrets; + } else { + delete next.actionsSecrets; + } + return next; +} + +function repositoryCacheSettingsWithChange( + settings: RepoSettingsData, key: string, cache: ActionsCacheEntry | null, +): RepoSettingsData { + const actionsCaches = { ...(settings.actionsCaches ?? {}) }; + if (cache) { + actionsCaches[key] = cache; + } else { + delete actionsCaches[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(actionsCaches).length > 0) { + next.actionsCaches = actionsCaches; + } else { + delete next.actionsCaches; + } + return next; +} + +function environmentVariableSettingsWithChange( + settings: RepoSettingsData, + environmentKeyValue: string, + environment: EnvironmentSettingsEntry, + key: string, + variable: ActionsVariableEntry | null, +): RepoSettingsData { + const variables = { ...(environment.variables ?? {}) }; + if (variable) { + variables[key] = variable; + } else { + delete variables[key]; + } + + const updatedEnvironment: EnvironmentSettingsEntry = { ...environment }; + if (Object.keys(variables).length > 0) { + updatedEnvironment.variables = variables; + } else { + delete updatedEnvironment.variables; + } + + return { + ...settings, + environments: { + ...(settings.environments ?? {}), + [environmentKeyValue]: updatedEnvironment, + }, + }; +} + +function environmentSecretSettingsWithChange( + settings: RepoSettingsData, + environmentKeyValue: string, + environment: EnvironmentSettingsEntry, + key: string, + secret: ActionsSecretEntry | null, +): RepoSettingsData { + const secrets = { ...(environment.secrets ?? {}) }; + if (secret) { + secrets[key] = secret; + } else { + delete secrets[key]; + } + + const updatedEnvironment: EnvironmentSettingsEntry = { ...environment }; + if (Object.keys(secrets).length > 0) { + updatedEnvironment.secrets = secrets; + } else { + delete updatedEnvironment.secrets; + } + + return { + ...settings, + environments: { + ...(settings.environments ?? {}), + [environmentKeyValue]: updatedEnvironment, + }, + }; +} + +async function getRepoSettingsLookup( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + return querySettings(ctx, targetDid, repo); +} + +async function findRepositoryVariable( + ctx: AgentContext, targetDid: string, repoName: string, rawName: string, +): Promise<{ lookup: RepoSettingsLookup; variable: ActionsVariableEntry; key: string } | JsonResponse> { + const name = decodeRequiredRouteParam(rawName, 'name'); + if (typeof name !== 'string') { return name; } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = variableKey(name); + const variable = lookup.settings.actionsVariables?.[key]; + if (!variable) { + return jsonNotFound(`Variable '${name}' not found.`); + } + return { lookup, variable, key }; +} + +async function findRepositorySecret( + ctx: AgentContext, targetDid: string, repoName: string, rawName: string, +): Promise<{ lookup: RepoSettingsLookup; secret: ActionsSecretEntry; key: string } | JsonResponse> { + const name = decodeRequiredRouteParam(rawName, 'secret_name'); + if (typeof name !== 'string') { return name; } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = variableKey(name); + const secret = lookup.settings.actionsSecrets?.[key]; + if (!secret) { + return jsonNotFound(`Secret '${name}' not found.`); + } + return { lookup, secret, key }; +} + +async function findRepositoryCache( + ctx: AgentContext, targetDid: string, repoName: string, rawId: string, +): Promise<{ lookup: RepoSettingsLookup; cache: ActionsCacheEntry; key: string } | JsonResponse> { + const cacheId = parseInt(rawId, 10); + if (!Number.isInteger(cacheId) || cacheId < 1) { + return jsonNotFound(`Actions cache '${rawId}' not found.`); + } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + for (const [key, cache] of Object.entries(lookup.settings.actionsCaches ?? {})) { + if (cache.id === cacheId) { + return { lookup, cache, key }; + } + } + return jsonNotFound(`Actions cache '${rawId}' not found.`); +} + +async function findEnvironmentForVariables( + ctx: AgentContext, targetDid: string, repoName: string, rawEnvironmentName: string, +): Promise<{ lookup: RepoSettingsLookup; environment: EnvironmentSettingsEntry; key: string; name: string } | JsonResponse> { + const environmentName = decodeRequiredRouteParam(rawEnvironmentName, 'environment_name'); + if (typeof environmentName !== 'string') { return environmentName; } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = environmentKey(environmentName); + const environment = lookup.settings.environments?.[key]; + if (!environment) { + return jsonNotFound(`Environment '${environmentName}' not found.`); + } + return { lookup, environment, key, name: environmentName }; +} + +async function findEnvironmentVariable( + ctx: AgentContext, targetDid: string, repoName: string, rawEnvironmentName: string, rawName: string, +): Promise<{ + lookup: RepoSettingsLookup; + environment: EnvironmentSettingsEntry; + environmentKey: string; + variable: ActionsVariableEntry; + variableKey: string; +} | JsonResponse> { + const environmentResult = await findEnvironmentForVariables(ctx, targetDid, repoName, rawEnvironmentName); + if ('status' in environmentResult) { return environmentResult; } + + const name = decodeRequiredRouteParam(rawName, 'name'); + if (typeof name !== 'string') { return name; } + + const key = variableKey(name); + const variable = environmentResult.environment.variables?.[key]; + if (!variable) { + return jsonNotFound(`Variable '${name}' not found in environment '${environmentResult.name}'.`); + } + + return { + lookup : environmentResult.lookup, + environment : environmentResult.environment, + environmentKey : environmentResult.key, + variable, + variableKey : key, + }; +} + +async function findEnvironmentSecret( + ctx: AgentContext, targetDid: string, repoName: string, rawEnvironmentName: string, rawName: string, +): Promise<{ + lookup: RepoSettingsLookup; + environment: EnvironmentSettingsEntry; + environmentKey: string; + secret: ActionsSecretEntry; + secretKey: string; +} | JsonResponse> { + const environmentResult = await findEnvironmentForVariables(ctx, targetDid, repoName, rawEnvironmentName); + if ('status' in environmentResult) { return environmentResult; } + + const name = decodeRequiredRouteParam(rawName, 'secret_name'); + if (typeof name !== 'string') { return name; } + + const key = variableKey(name); + const secret = environmentResult.environment.secrets?.[key]; + if (!secret) { + return jsonNotFound(`Secret '${name}' not found in environment '${environmentResult.name}'.`); + } + + return { + lookup : environmentResult.lookup, + environment : environmentResult.environment, + environmentKey : environmentResult.key, + secret, + secretKey : key, + }; +} + +function workflowJobSteps(run: CiRun): Record<string, unknown>[] { + const output = rawRunOutput(run); + if (Array.isArray(output.steps)) { + return output.steps + .filter(step => step && typeof step === 'object') + .map((step, index) => ({ + name : typeof step.name === 'string' ? step.name : `Step ${index + 1}`, + status : typeof step.status === 'string' ? step.status : run.tags.status ?? 'queued', + conclusion : typeof step.conclusion === 'string' ? step.conclusion : run.tags.conclusion ?? null, + number : typeof step.number === 'number' ? step.number : index + 1, + started_at : toISODate(step.started_at ?? run.data.startedAt ?? run.rec.dateCreated), + completed_at : step.completed_at ? toISODate(step.completed_at) : null, + })); + } + + return [{ + name : output.title ?? run.tags.name ?? 'check', + status : run.tags.status ?? 'queued', + conclusion : run.tags.conclusion ?? null, + number : 1, + started_at : toISODate(run.data.startedAt ?? run.rec.dateCreated), + completed_at : run.tags.status === 'completed' ? toISODate(run.data.completedAt ?? run.rec.timestamp) : null, + }]; +} + +function buildWorkflowJobResponse( + run: CiRun, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const id = runId(run); + const workflowRunId = suiteId(run.suite); + const status = run.tags.status ?? 'queued'; + + return { + id, + run_id : workflowRunId, + run_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${workflowRunId}`, + node_id : run.rec.id ?? '', + head_sha : suiteHeadSha(run.suite), + url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/jobs/${id}`, + html_url : `${baseUrl}/repos/${targetDid}/${repo.name}/runs/${workflowRunId}/jobs/${id}`, + logs_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/jobs/${id}/logs`, + status, + conclusion : run.tags.conclusion ?? null, + started_at : toISODate(run.data.startedAt ?? run.rec.dateCreated), + completed_at : status === 'completed' ? toISODate(run.data.completedAt ?? run.rec.timestamp) : null, + name : run.tags.name ?? 'check', + steps : workflowJobSteps(run), + check_run_url : `${baseUrl}/repos/${targetDid}/${repo.name}/check-runs/${id}`, + labels : ['gitd'], + runner_id : null, + runner_name : null, + workflow_name : suiteAppName(run.suite), + head_branch : suiteHeadBranch(run.suite, repo), + }; +} + +function artifactId(artifact: CiArtifact): number { + return numericId(artifact.rec.id ?? ''); +} + +function artifactName(artifact: CiArtifact): string { + return String(artifact.tags.name ?? artifact.tags.filename ?? 'artifact'); +} + +function artifactExpired(artifact: CiArtifact): boolean { + return artifact.tags.expired === true || artifact.tags.expired === 'true'; +} + +function artifactExpiresAt(artifact: CiArtifact): string { + if (artifact.tags.expiresAt) { return toISODate(artifact.tags.expiresAt); } + const created = Date.parse(String(artifact.rec.dateCreated ?? '')); + const base = Number.isFinite(created) ? created : 0; + return new Date(base + 90 * 24 * 60 * 60 * 1000).toISOString(); +} + +function taggedArtifactSize(artifact: CiArtifact): number | null { + const raw = artifact.tags.size; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === 'string') { + const parsed = parseInt(raw, 10); + if (Number.isFinite(parsed)) { return parsed; } + } + return null; +} + +async function artifactBytes(artifact: CiArtifact): Promise<Uint8Array> { + const blob = await artifact.rec.data.blob(); + return new Uint8Array(await blob.arrayBuffer()); +} + +async function artifactDigest(artifact: CiArtifact): Promise<string> { + if (artifact.tags.digest) { return artifact.tags.digest; } + const bytes = await artifactBytes(artifact); + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +async function buildArtifactResponse( + artifact: CiArtifact, targetDid: string, repo: RepoInfo, baseUrl: string, +): Promise<Record<string, unknown>> { + const id = artifactId(artifact); + const size = taggedArtifactSize(artifact) ?? (await artifactBytes(artifact)).byteLength; + const repositoryId = numericId(repo.contextId); + + return { + id, + node_id : artifact.rec.id ?? '', + name : artifactName(artifact), + size_in_bytes : size, + url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/artifacts/${id}`, + archive_download_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/artifacts/${id}/zip`, + expired : artifactExpired(artifact), + created_at : toISODate(artifact.rec.dateCreated), + expires_at : artifactExpiresAt(artifact), + updated_at : toISODate(artifact.rec.timestamp ?? artifact.rec.dateCreated), + digest : await artifactDigest(artifact), + workflow_run : { + id : suiteId(artifact.suite), + repository_id : repositoryId, + head_repository_id : repositoryId, + head_branch : suiteHeadBranch(artifact.suite, repo), + head_sha : suiteHeadSha(artifact.suite), + }, + }; +} + +async function queryArtifactsForRuns( + ctx: AgentContext, targetDid: string, runs: CiRun[], +): Promise<CiArtifact[]> { + const artifacts: CiArtifact[] = []; + for (const run of runs) { + artifacts.push(...await queryArtifactsForRun(ctx, targetDid, run)); + } + return artifacts; +} + +async function queryArtifactsForRepo( + ctx: AgentContext, targetDid: string, repo: RepoInfo, +): Promise<CiArtifact[]> { + const artifacts: CiArtifact[] = []; + for (const suite of await querySuites(ctx, targetDid, repo)) { + artifacts.push(...await queryArtifactsForRuns(ctx, targetDid, await queryRunsForSuite(ctx, targetDid, suite))); + } + return sortArtifactsByCreatedAt(artifacts, 'desc'); +} + +function sortArtifactsByCreatedAt(artifacts: CiArtifact[], direction: 'asc' | 'desc'): CiArtifact[] { + return [...artifacts].sort((left, right) => { + const leftTime = Date.parse(String(left.rec.dateCreated ?? '')) || 0; + const rightTime = Date.parse(String(right.rec.dateCreated ?? '')) || 0; + return direction === 'asc' ? leftTime - rightTime : rightTime - leftTime; + }); +} + +function sortArtifacts(artifacts: CiArtifact[], direction: string): CiArtifact[] | JsonResponse { + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + return sortArtifactsByCreatedAt(artifacts, direction); +} + +function filterArtifacts(artifacts: CiArtifact[], url: URL): CiArtifact[] | JsonResponse { + const name = url.searchParams.get('name'); + const direction = url.searchParams.get('direction') ?? 'desc'; + const filtered = name ? artifacts.filter(artifact => artifactName(artifact) === name) : artifacts; + return sortArtifacts(filtered, direction); +} + +async function artifactsResponse( + artifacts: CiArtifact[], targetDid: string, repo: RepoInfo, url: URL, +): Promise<JsonResponse> { + const filtered = filterArtifacts(artifacts, url); + if (!Array.isArray(filtered)) { return filtered; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, filtered.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : filtered.length, + artifacts : await Promise.all(paged.map(artifact => buildArtifactResponse(artifact, targetDid, repo, baseUrl))), + }, extraHeaders); +} + +async function findArtifactByNumber( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<CiArtifact | null> { + const wanted = parseInt(id, 10); + if (Number.isNaN(wanted)) { return null; } + const artifacts = await queryArtifactsForRepo(ctx, targetDid, repo); + return artifacts.find(artifact => artifactId(artifact) === wanted) ?? null; +} + +function artifactDownloadRedirect(location: string): JsonResponse { + return { + status : 302, + headers : { + ...baseHeaders(), + Location: location, + }, + body: '', + }; +} + +function artifactGone(message: string): JsonResponse { + return { + status : 410, + headers : baseHeaders(), + body : JSON.stringify({ message, documentation_url: 'https://docs.github.com/rest' }), + }; +} + +type ZipEntry = { + name : string; + data : Uint8Array; +}; + +const CRC32_TABLE = ((): Uint32Array => { + const table = new Uint32Array(256); + for (let i = 0; i < table.length; i++) { + let crc = i; + for (let j = 0; j < 8; j++) { + crc = (crc & 1) ? (0xedb88320 ^ (crc >>> 1)) : (crc >>> 1); + } + table[i] = crc >>> 0; + } + return table; +})(); + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +function createStoredZip(entries: ZipEntry[]): Uint8Array { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + + for (const entry of entries) { + const name = encoder.encode(entry.name); + const checksum = crc32(entry.data); + const local = new Uint8Array(30 + name.byteLength); + const localView = new DataView(local.buffer); + localView.setUint32(0, 0x04034b50, true); + localView.setUint16(4, 20, true); + localView.setUint16(8, 0, true); + localView.setUint32(14, checksum, true); + localView.setUint32(18, entry.data.byteLength, true); + localView.setUint32(22, entry.data.byteLength, true); + localView.setUint16(26, name.byteLength, true); + local.set(name, 30); + chunks.push(local, entry.data); + + const directory = new Uint8Array(46 + name.byteLength); + const directoryView = new DataView(directory.buffer); + directoryView.setUint32(0, 0x02014b50, true); + directoryView.setUint16(4, 20, true); + directoryView.setUint16(6, 20, true); + directoryView.setUint16(10, 0, true); + directoryView.setUint32(16, checksum, true); + directoryView.setUint32(20, entry.data.byteLength, true); + directoryView.setUint32(24, entry.data.byteLength, true); + directoryView.setUint16(28, name.byteLength, true); + directoryView.setUint32(42, offset, true); + directory.set(name, 46); + central.push(directory); + + offset += local.byteLength + entry.data.byteLength; + } + + const centralDirectory = concatBytes(central); + const end = new Uint8Array(22); + const endView = new DataView(end.buffer); + endView.setUint32(0, 0x06054b50, true); + endView.setUint16(8, entries.length, true); + endView.setUint16(10, entries.length, true); + endView.setUint32(12, centralDirectory.byteLength, true); + endView.setUint32(16, offset, true); + + return concatBytes([...chunks, centralDirectory, end]); +} + +function safeLogFilename(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'job'; +} + +function workflowJobLogText(run: CiRun): string { + const output = rawRunOutput(run); + if (run.data.logsDeleted === true || output.logsDeleted === true) { + return ''; + } + + const lines = [ + `# ${String(run.tags.name ?? output.title ?? 'check')}`, + `status: ${String(run.tags.status ?? 'queued')}`, + ]; + if (run.tags.conclusion) { + lines.push(`conclusion: ${run.tags.conclusion}`); + } + if (typeof output.summary === 'string' && output.summary.length > 0) { + lines.push('', output.summary); + } + if (typeof output.text === 'string' && output.text.length > 0) { + lines.push('', output.text); + } + if (Array.isArray(output.steps)) { + for (const [index, step] of output.steps.entries()) { + if (!step || typeof step !== 'object') { continue; } + lines.push('', `## ${typeof step.name === 'string' ? step.name : `Step ${index + 1}`}`); + const stepLog = [step.log, step.logs, step.text, step.output].find(value => typeof value === 'string'); + if (typeof stepLog === 'string' && stepLog.length > 0) { + lines.push(stepLog); + } + } + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +function workflowRunLogArchive(runs: CiRun[]): Uint8Array { + const encoder = new TextEncoder(); + const entries = runs.map(run => ({ + name : `${String(runId(run)).padStart(2, '0')}_${safeLogFilename(String(run.tags.name ?? 'check'))}.txt`, + data : encoder.encode(workflowJobLogText(run)), + })); + return createStoredZip(entries); +} + +function downloadRedirect(location: string): JsonResponse { + return { + status : 302, + headers : { + ...baseHeaders(), + Location: location, + }, + body: '', + }; +} + +function dataWithDeletedLogs(run: CiRun): Record<string, any> { + const data: Record<string, any> = { ...run.data, logsDeleted: true }; + const output = rawRunOutput(run); + if (output !== run.data) { + const nextOutput: Record<string, any> = { ...output, logsDeleted: true }; + delete nextOutput.text; + delete nextOutput.log; + delete nextOutput.logs; + if (Array.isArray(nextOutput.steps)) { + nextOutput.steps = nextOutput.steps.map((step: unknown) => { + if (!step || typeof step !== 'object') { return step; } + const nextStep = { ...(step as Record<string, unknown>) }; + delete nextStep.text; + delete nextStep.log; + delete nextStep.logs; + delete nextStep.output; + return nextStep; + }); + } + data.output = nextOutput; + } + return data; +} + +function filterWorkflowRuns(suites: CiSuite[], url: URL): CiSuite[] | JsonResponse { + const checkSuiteId = url.searchParams.get('check_suite_id'); + const parsedCheckSuiteId = checkSuiteId ? parseInt(checkSuiteId, 10) : null; + if (checkSuiteId && Number.isNaN(parsedCheckSuiteId)) { + return jsonValidationError('Validation Failed: check_suite_id must be an integer.'); + } + + const status = url.searchParams.get('status'); + if (status && !ACTION_RUN_STATUSES.has(status)) { + return jsonValidationError('Validation Failed: status is not a supported workflow run status or conclusion.'); + } + + const branch = url.searchParams.get('branch'); + const headSha = url.searchParams.get('head_sha'); + const event = url.searchParams.get('event'); + const actor = url.searchParams.get('actor'); + + return suites.filter((suite) => { + if (parsedCheckSuiteId !== null && suiteId(suite) !== parsedCheckSuiteId) { return false; } + if (branch && branch !== (suite.tags.branch ?? suite.data.headBranch)) { return false; } + if (headSha && headSha !== suiteHeadSha(suite)) { return false; } + if (event && event !== (suite.data.event ?? 'push')) { return false; } + if (actor && actor !== (suite.rec.author ?? '')) { return false; } + if (status && status !== suiteStatus(suite) && status !== suiteConclusion(suite)) { return false; } + return true; + }); +} + +async function resolveWorkflowRef( + ctx: AgentContext, targetDid: string, repo: RepoInfo, ref: string, +): Promise<{ headSha: string; branch: string }> { + const decoded = decodeRouteParam(ref); + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.refs.records.query('repo/ref' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const candidates = [ + decoded, + `refs/heads/${decoded}`, + `refs/tags/${decoded}`, + decoded.startsWith('heads/') ? `refs/${decoded}` : '', + decoded.startsWith('tags/') ? `refs/${decoded}` : '', + ].filter(Boolean); + + for (const rec of records) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = data.name ?? tags.name; + if (!candidates.includes(name)) { continue; } + + const target = data.target ?? tags.target ?? decoded; + const type = data.type ?? tags.type; + if (type === 'branch' && typeof name === 'string') { + return { headSha: target, branch: name.replace(/^refs\/heads\//, '') }; + } + return { headSha: target, branch: decoded }; + } + + return { headSha: decoded, branch: decoded.replace(/^refs\/heads\//, '') }; +} + +async function updateSuiteQueued(suite: CiSuite): Promise<JsonResponse | undefined> { + const tags: Record<string, string> = { ...suite.tags, status: 'queued' }; + delete tags.conclusion; + const { status } = await suite.rec.update({ data: suite.data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to re-run workflow: ${status.detail}`); + } + suite.tags = tags; + return undefined; +} + +async function updateRunQueued(run: CiRun): Promise<JsonResponse | undefined> { + const tags: Record<string, string> = { ...run.tags, status: 'queued' }; + delete tags.conclusion; + const { status } = await run.rec.update({ data: run.data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to re-run workflow job: ${status.detail}`); + } + run.tags = tags; + return undefined; +} + +async function updateSuiteCancelled(suite: CiSuite): Promise<JsonResponse | undefined> { + const data = { ...suite.data, conclusion: 'cancelled', completedAt: new Date().toISOString() }; + const tags: Record<string, string> = { ...suite.tags, status: 'completed', conclusion: 'cancelled' }; + const { status } = await suite.rec.update({ data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to cancel workflow run: ${status.detail}`); + } + suite.data = data; + suite.tags = tags; + return undefined; +} + +async function updateRunCancelled(run: CiRun): Promise<JsonResponse | undefined> { + const data = { ...run.data, completedAt: new Date().toISOString() }; + const tags: Record<string, string> = { ...run.tags, status: 'completed', conclusion: 'cancelled' }; + const { status } = await run.rec.update({ data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to cancel workflow job: ${status.detail}`); + } + run.data = data; + run.tags = tags; + return undefined; +} + +async function deleteWorkflowRunRecords( + ctx: AgentContext, targetDid: string, suite: CiSuite, +): Promise<JsonResponse | undefined> { + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + for (const artifact of await queryArtifactsForRun(ctx, targetDid, run)) { + const { status } = await artifact.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete workflow artifact: ${status.detail}`); + } + } + + const { status } = await run.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete workflow job: ${status.detail}`); + } + } + + const { status } = await suite.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete workflow run: ${status.detail}`); + } + return undefined; +} + +async function getRepo( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoInfo | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + return repo; +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/actions/permissions +// --------------------------------------------------------------------------- + +export async function handleGetActionsPermissions( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildActionsPermissionsResponse( + normalizedActionsPermissions(lookup.settings), + targetDid, + lookup.repo.name, + buildApiUrl(url), + )); +} + +export async function handleSetActionsPermissions( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const enabled = parseRequiredBoolean(reqBody.enabled, 'enabled'); + if (typeof enabled !== 'boolean') { return enabled; } + + const allowedActions = parseAllowedActionsPolicy(reqBody.allowed_actions); + if (typeof allowedActions === 'object') { return allowedActions; } + + const shaPinningRequired = parseOptionalBoolean(reqBody.sha_pinning_required, 'sha_pinning_required'); + if (typeof shaPinningRequired === 'object') { return shaPinningRequired; } + + const permissions = normalizedActionsPermissions(lookup.settings); + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + actionsPermissions: { + ...permissions, + enabled, + allowedActions : allowedActions ?? permissions.allowedActions, + shaPinningRequired : shaPinningRequired ?? permissions.shaPinningRequired, + }, + }); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/actions/permissions/selected-actions +// --------------------------------------------------------------------------- + +export async function handleGetActionsSelectedActions( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return jsonOk(buildSelectedActionsResponse(normalizedActionsPermissions(lookup.settings))); +} + +export async function handleSetActionsSelectedActions( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const githubOwnedAllowed = parseOptionalBoolean(reqBody.github_owned_allowed, 'github_owned_allowed'); + if (typeof githubOwnedAllowed === 'object') { return githubOwnedAllowed; } + + const verifiedAllowed = parseOptionalBoolean(reqBody.verified_allowed, 'verified_allowed'); + if (typeof verifiedAllowed === 'object') { return verifiedAllowed; } + + const patternsAllowed = parseOptionalStringArray(reqBody.patterns_allowed, 'patterns_allowed'); + if (patternsAllowed && 'status' in patternsAllowed) { return patternsAllowed; } + + const permissions = normalizedActionsPermissions(lookup.settings); + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + actionsPermissions: { + ...permissions, + selectedActions: { + githubOwnedAllowed : githubOwnedAllowed ?? permissions.selectedActions.githubOwnedAllowed, + verifiedAllowed : verifiedAllowed ?? permissions.selectedActions.verifiedAllowed, + patternsAllowed : patternsAllowed ?? permissions.selectedActions.patternsAllowed, + }, + }, + }); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/actions/permissions/workflow +// --------------------------------------------------------------------------- + +export async function handleGetActionsWorkflowPermissions( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return jsonOk(buildWorkflowPermissionsResponse(normalizedActionsPermissions(lookup.settings))); +} + +export async function handleSetActionsWorkflowPermissions( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const defaultWorkflowPermissions = parseDefaultWorkflowPermissions(reqBody.default_workflow_permissions); + if (typeof defaultWorkflowPermissions === 'object') { return defaultWorkflowPermissions; } + + const canApprovePullRequestReviews = parseOptionalBoolean( + reqBody.can_approve_pull_request_reviews, + 'can_approve_pull_request_reviews', + ); + if (typeof canApprovePullRequestReviews === 'object') { return canApprovePullRequestReviews; } + + const permissions = normalizedActionsPermissions(lookup.settings); + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + actionsPermissions: { + ...permissions, + defaultWorkflowPermissions : defaultWorkflowPermissions ?? permissions.defaultWorkflowPermissions, + canApprovePullRequestReviews : canApprovePullRequestReviews ?? permissions.canApprovePullRequestReviews, + }, + }); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/actions/cache/retention-limit +// --------------------------------------------------------------------------- + +export async function handleGetActionsCacheRetentionLimit( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return jsonOk({ + max_cache_retention_days: lookup.settings.actionsCacheRetentionLimitDays ?? DEFAULT_ACTIONS_CACHE_RETENTION_LIMIT_DAYS, + }); +} + +export async function handleSetActionsCacheRetentionLimit( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const limit = parsePositiveInteger(reqBody.max_cache_retention_days, 'max_cache_retention_days'); + if (typeof limit !== 'number') { return limit; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + actionsCacheRetentionLimitDays: limit, + }); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/actions/cache/storage-limit +// --------------------------------------------------------------------------- + +export async function handleGetActionsCacheStorageLimit( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return jsonOk({ + max_cache_size_gb: lookup.settings.actionsCacheStorageLimitGb ?? DEFAULT_ACTIONS_CACHE_STORAGE_LIMIT_GB, + }); +} + +export async function handleSetActionsCacheStorageLimit( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const limit = parsePositiveInteger(reqBody.max_cache_size_gb, 'max_cache_size_gb'); + if (typeof limit !== 'number') { return limit; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + actionsCacheStorageLimitGb: limit, + }); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/cache/usage +// --------------------------------------------------------------------------- + +export async function handleGetActionsCacheUsage( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const caches = cacheEntries(lookup.settings.actionsCaches); + return jsonOk({ + full_name : `${targetDid}/${lookup.repo.name}`, + active_caches_size_in_bytes : caches.reduce((sum, cache) => sum + Math.max(0, cache.sizeInBytes), 0), + active_caches_count : caches.length, + }); +} + +// --------------------------------------------------------------------------- +// GET/DELETE /repos/:did/:repo/actions/caches +// --------------------------------------------------------------------------- + +export async function handleListActionsCaches( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return actionsCacheListResponse(cacheEntries(lookup.settings.actionsCaches), url); +} + +export async function handleDeleteActionsCachesByKey( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const key = url.searchParams.get('key')?.trim(); + if (!key) { + return jsonValidationError('Validation Failed: key is required.'); + } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const ref = url.searchParams.get('ref'); + const deleted: ActionsCacheEntry[] = []; + let settings = lookup.settings; + for (const [settingsKey, cache] of Object.entries(lookup.settings.actionsCaches ?? {})) { + if (cache.key !== key) { continue; } + if (ref && cache.ref !== ref) { continue; } + deleted.push(cache); + settings = repositoryCacheSettingsWithChange(settings, settingsKey, null); + } + + if (deleted.length > 0) { + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + } + return jsonOk({ + total_count : deleted.length, + actions_caches : sortedCacheEntries(deleted, 'last_accessed_at', SORT_DESC).map(buildActionsCacheResponse), + }); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/actions/caches/:cache_id +// --------------------------------------------------------------------------- + +export async function handleDeleteActionsCacheById( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const result = await findRepositoryCache(ctx, targetDid, repoName, id); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + repositoryCacheSettingsWithChange(result.lookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/secrets/public-key +// --------------------------------------------------------------------------- + +export async function handleGetRepositorySecretsPublicKey( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return jsonOk(buildSecretPublicKey(`repo:${targetDid}/${lookup.repo.name}`)); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/secrets +// --------------------------------------------------------------------------- + +export async function handleListRepositorySecrets( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return secretListResponse(secretEntries(lookup.settings.actionsSecrets), url); +} + +// --------------------------------------------------------------------------- +// GET/PUT/DELETE /repos/:did/:repo/actions/secrets/:secret_name +// --------------------------------------------------------------------------- + +export async function handleGetRepositorySecret( + ctx: AgentContext, targetDid: string, repoName: string, name: string, +): Promise<JsonResponse> { + const result = await findRepositorySecret(ctx, targetDid, repoName, name); + if ('status' in result) { return result; } + return jsonOk(buildSecretResponse(result.secret)); +} + +export async function handleCreateOrUpdateRepositorySecret( + ctx: AgentContext, targetDid: string, repoName: string, name: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const decodedName = decodeRequiredRouteParam(name, 'secret_name'); + if (typeof decodedName !== 'string') { return decodedName; } + + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const encryptedValue = parseRequiredString(reqBody.encrypted_value, 'encrypted_value'); + if (typeof encryptedValue !== 'string') { return encryptedValue; } + + const keyId = parseRequiredString(reqBody.key_id, 'key_id'); + if (typeof keyId !== 'string') { return keyId; } + + const key = variableKey(decodedName); + const existing = lookup.settings.actionsSecrets?.[key]; + const now = new Date().toISOString(); + const saveError = await saveRepoSettings( + ctx, + lookup, + repositorySecretSettingsWithChange(lookup.settings, key, { + name : decodedName, + encryptedValue, + keyId, + createdAt : existing?.createdAt ?? now, + updatedAt : now, + }), + ); + if (saveError) { return saveError; } + return existing ? jsonNoContent() : createdEmpty(); +} + +export async function handleDeleteRepositorySecret( + ctx: AgentContext, targetDid: string, repoName: string, name: string, +): Promise<JsonResponse> { + const result = await findRepositorySecret(ctx, targetDid, repoName, name); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + repositorySecretSettingsWithChange(result.lookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST /repos/:did/:repo/actions/variables +// --------------------------------------------------------------------------- + +export async function handleListRepositoryVariables( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + return variableListResponse(variableEntries(lookup.settings.actionsVariables), url); +} + +export async function handleCreateRepositoryVariable( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettingsLookup(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const name = parseRequiredString(reqBody.name, 'name'); + if (typeof name !== 'string') { return name; } + + const value = parseRequiredValue(reqBody.value, 'value'); + if (typeof value !== 'string') { return value; } + + const key = variableKey(name); + if (lookup.settings.actionsVariables?.[key]) { + return jsonConflict(`Variable '${name}' already exists.`); + } + + const now = new Date().toISOString(); + const saveError = await saveRepoSettings( + ctx, + lookup, + repositoryVariableSettingsWithChange(lookup.settings, key, { + name, + value, + createdAt : now, + updatedAt : now, + }), + ); + if (saveError) { return saveError; } + return createdEmpty(); +} + +// --------------------------------------------------------------------------- +// GET/PATCH/DELETE /repos/:did/:repo/actions/variables/:name +// --------------------------------------------------------------------------- + +export async function handleGetRepositoryVariable( + ctx: AgentContext, targetDid: string, repoName: string, name: string, +): Promise<JsonResponse> { + const result = await findRepositoryVariable(ctx, targetDid, repoName, name); + if ('status' in result) { return result; } + return jsonOk(buildVariableResponse(result.variable)); +} + +export async function handleUpdateRepositoryVariable( + ctx: AgentContext, targetDid: string, repoName: string, name: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await findRepositoryVariable(ctx, targetDid, repoName, name); + if ('status' in result) { return result; } + + if (typeof reqBody.name === 'undefined' && typeof reqBody.value === 'undefined') { + return jsonValidationError('Validation Failed: name or value is required.'); + } + + const nextName = parseOptionalString(reqBody.name, 'name'); + if (typeof nextName === 'object') { return nextName; } + + const nextValue = parseOptionalValue(reqBody.value, 'value'); + if (typeof nextValue === 'object') { return nextValue; } + + const updatedVariable: ActionsVariableEntry = { + name : nextName ?? result.variable.name, + value : nextValue ?? result.variable.value, + createdAt : result.variable.createdAt, + updatedAt : new Date().toISOString(), + }; + const nextKey = variableKey(updatedVariable.name); + if (nextKey !== result.key && result.lookup.settings.actionsVariables?.[nextKey]) { + return jsonConflict(`Variable '${updatedVariable.name}' already exists.`); + } + + let settings = repositoryVariableSettingsWithChange(result.lookup.settings, result.key, null); + settings = repositoryVariableSettingsWithChange(settings, nextKey, updatedVariable); + const saveError = await saveRepoSettings(ctx, result.lookup, settings); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +export async function handleDeleteRepositoryVariable( + ctx: AgentContext, targetDid: string, repoName: string, name: string, +): Promise<JsonResponse> { + const result = await findRepositoryVariable(ctx, targetDid, repoName, name); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + repositoryVariableSettingsWithChange(result.lookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/environments/:environment_name/secrets/public-key +// --------------------------------------------------------------------------- + +export async function handleGetEnvironmentSecretsPublicKey( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, +): Promise<JsonResponse> { + const result = await findEnvironmentForVariables(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + return jsonOk(buildSecretPublicKey(`env:${targetDid}/${repoName}/${result.environment.name}`)); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/environments/:environment_name/secrets +// --------------------------------------------------------------------------- + +export async function handleListEnvironmentSecrets( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, url: URL, +): Promise<JsonResponse> { + const result = await findEnvironmentForVariables(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + return secretListResponse(secretEntries(result.environment.secrets), url); +} + +// --------------------------------------------------------------------------- +// GET/PUT/DELETE /repos/:did/:repo/environments/:environment_name/secrets/:secret_name +// --------------------------------------------------------------------------- + +export async function handleGetEnvironmentSecret( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, +): Promise<JsonResponse> { + const result = await findEnvironmentSecret(ctx, targetDid, repoName, environmentName, name); + if ('status' in result) { return result; } + return jsonOk(buildSecretResponse(result.secret)); +} + +export async function handleCreateOrUpdateEnvironmentSecret( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, + reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await findEnvironmentForVariables(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + + const decodedName = decodeRequiredRouteParam(name, 'secret_name'); + if (typeof decodedName !== 'string') { return decodedName; } + + const encryptedValue = parseRequiredString(reqBody.encrypted_value, 'encrypted_value'); + if (typeof encryptedValue !== 'string') { return encryptedValue; } + + const keyId = parseRequiredString(reqBody.key_id, 'key_id'); + if (typeof keyId !== 'string') { return keyId; } + + const key = variableKey(decodedName); + const existing = result.environment.secrets?.[key]; + const now = new Date().toISOString(); + const saveError = await saveRepoSettings( + ctx, + result.lookup, + environmentSecretSettingsWithChange(result.lookup.settings, result.key, result.environment, key, { + name : decodedName, + encryptedValue, + keyId, + createdAt : existing?.createdAt ?? now, + updatedAt : now, + }), + ); + if (saveError) { return saveError; } + return existing ? jsonNoContent() : createdEmpty(); +} + +export async function handleDeleteEnvironmentSecret( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, +): Promise<JsonResponse> { + const result = await findEnvironmentSecret(ctx, targetDid, repoName, environmentName, name); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + environmentSecretSettingsWithChange( + result.lookup.settings, result.environmentKey, result.environment, result.secretKey, null, + ), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST /repos/:did/:repo/environments/:environment_name/variables +// --------------------------------------------------------------------------- + +export async function handleListEnvironmentVariables( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, url: URL, +): Promise<JsonResponse> { + const result = await findEnvironmentForVariables(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + return variableListResponse(variableEntries(result.environment.variables), url); +} + +export async function handleCreateEnvironmentVariable( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await findEnvironmentForVariables(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + + const name = parseRequiredString(reqBody.name, 'name'); + if (typeof name !== 'string') { return name; } + + const value = parseRequiredValue(reqBody.value, 'value'); + if (typeof value !== 'string') { return value; } + + const key = variableKey(name); + if (result.environment.variables?.[key]) { + return jsonConflict(`Variable '${name}' already exists in environment '${result.name}'.`); + } + + const now = new Date().toISOString(); + const saveError = await saveRepoSettings( + ctx, + result.lookup, + environmentVariableSettingsWithChange(result.lookup.settings, result.key, result.environment, key, { + name, + value, + createdAt : now, + updatedAt : now, + }), + ); + if (saveError) { return saveError; } + return createdEmpty(); +} + +// --------------------------------------------------------------------------- +// GET/PATCH/DELETE /repos/:did/:repo/environments/:environment_name/variables/:name +// --------------------------------------------------------------------------- + +export async function handleGetEnvironmentVariable( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, +): Promise<JsonResponse> { + const result = await findEnvironmentVariable(ctx, targetDid, repoName, environmentName, name); + if ('status' in result) { return result; } + return jsonOk(buildVariableResponse(result.variable)); +} + +export async function handleUpdateEnvironmentVariable( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, + reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await findEnvironmentVariable(ctx, targetDid, repoName, environmentName, name); + if ('status' in result) { return result; } + + if (typeof reqBody.name === 'undefined' && typeof reqBody.value === 'undefined') { + return jsonValidationError('Validation Failed: name or value is required.'); + } + + const nextName = parseOptionalString(reqBody.name, 'name'); + if (typeof nextName === 'object') { return nextName; } + + const nextValue = parseOptionalValue(reqBody.value, 'value'); + if (typeof nextValue === 'object') { return nextValue; } + + const updatedVariable: ActionsVariableEntry = { + name : nextName ?? result.variable.name, + value : nextValue ?? result.variable.value, + createdAt : result.variable.createdAt, + updatedAt : new Date().toISOString(), + }; + const nextKey = variableKey(updatedVariable.name); + if (nextKey !== result.variableKey && result.environment.variables?.[nextKey]) { + return jsonConflict(`Variable '${updatedVariable.name}' already exists in this environment.`); + } + + let settings = environmentVariableSettingsWithChange( + result.lookup.settings, result.environmentKey, result.environment, result.variableKey, null, + ); + const environment = settings.environments?.[result.environmentKey] ?? result.environment; + settings = environmentVariableSettingsWithChange(settings, result.environmentKey, environment, nextKey, updatedVariable); + const saveError = await saveRepoSettings(ctx, result.lookup, settings); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +export async function handleDeleteEnvironmentVariable( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, name: string, +): Promise<JsonResponse> { + const result = await findEnvironmentVariable(ctx, targetDid, repoName, environmentName, name); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + environmentVariableSettingsWithChange( + result.lookup.settings, result.environmentKey, result.environment, result.variableKey, null, + ), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/artifacts +// --------------------------------------------------------------------------- + +export async function handleListArtifacts( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + return artifactsResponse(await queryArtifactsForRepo(ctx, targetDid, repo), targetDid, repo, url); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/runs/:run_id/artifacts +// --------------------------------------------------------------------------- + +export async function handleListWorkflowRunArtifacts( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const artifacts = await queryArtifactsForRuns(ctx, targetDid, await queryRunsForSuite(ctx, targetDid, suite)); + return artifactsResponse(artifacts, targetDid, repo, url); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/artifacts/:artifact_id +// --------------------------------------------------------------------------- + +export async function handleGetArtifact( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const artifact = await findArtifactByNumber(ctx, targetDid, repo, id); + if (!artifact) { + return jsonNotFound(`Artifact ${id} not found.`); + } + + return jsonOk(await buildArtifactResponse(artifact, targetDid, repo, buildApiUrl(url))); +} + +export async function handleDeleteArtifact( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const artifact = await findArtifactByNumber(ctx, targetDid, repo, id); + if (!artifact) { + return jsonNotFound(`Artifact ${id} not found.`); + } + + const { status } = await artifact.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete artifact: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleDownloadArtifact( + ctx: AgentContext, targetDid: string, repoName: string, id: string, archiveFormat: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + if (archiveFormat !== 'zip') { + return jsonValidationError('Validation Failed: archive_format must be zip.'); + } + + const artifact = await findArtifactByNumber(ctx, targetDid, repo, id); + if (!artifact) { + return jsonNotFound(`Artifact ${id} not found.`); + } + if (artifactExpired(artifact)) { + return artifactGone(`Artifact ${id} has expired.`); + } + + const baseUrl = buildApiUrl(url); + if (url.searchParams.get('download') !== '1') { + return artifactDownloadRedirect( + `${baseUrl}/repos/${targetDid}/${repo.name}/actions/artifacts/${artifactId(artifact)}/zip?download=1`, + ); + } + + const filename = `${artifactName(artifact).replace(/"/g, '')}.zip`; + const contentType = String(artifact.tags.contentType ?? artifact.rec.dataFormat ?? 'application/zip'); + return binaryOk(await artifactBytes(artifact), contentType, { + 'Content-Disposition': `attachment; filename="${filename}"`, + }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/workflows +// --------------------------------------------------------------------------- + +export async function handleListWorkflows( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const { workflows } = await listWorkflows(ctx, targetDid, repo); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(workflows, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, workflows.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : workflows.length, + workflows : paged.map(workflow => buildWorkflowResponse(workflow, targetDid, repo, baseUrl)), + }, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/workflows/:workflow_id +// --------------------------------------------------------------------------- + +export async function handleGetWorkflow( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + + return jsonOk(buildWorkflowResponse(result.workflow, targetDid, repo, buildApiUrl(url))); +} + +export async function handleDisableWorkflow( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + + const error = await saveRepoSettings( + ctx, + result.settingsLookup, + workflowSettingsWithState(result.settingsLookup.settings, result.workflow, 'disabled_manually'), + ); + if (error) { return error; } + return jsonNoContent(); +} + +export async function handleEnableWorkflow( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + + const error = await saveRepoSettings( + ctx, + result.settingsLookup, + workflowSettingsWithState(result.settingsLookup.settings, result.workflow, 'active'), + ); + if (error) { return error; } + return jsonNoContent(); +} + +export async function handleCreateWorkflowDispatch( + ctx: AgentContext, targetDid: string, repoName: string, id: string, + reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + if (result.workflow.state !== 'active') { + return jsonValidationError('Validation Failed: workflow is disabled.'); + } + + const ref = reqBody.ref; + if (typeof ref !== 'string' || !ref) { + return jsonValidationError('Validation Failed: ref is required.'); + } + + const inputs = reqBody.inputs; + if (inputs !== undefined && (typeof inputs !== 'object' || inputs === null || Array.isArray(inputs))) { + return jsonValidationError('Validation Failed: inputs must be an object.'); + } + if (inputs && Object.keys(inputs as Record<string, unknown>).length > 25) { + return jsonValidationError('Validation Failed: inputs may not contain more than 25 properties.'); + } + + const resolved = await resolveWorkflowRef(ctx, targetDid, repo, ref); + const tags: Record<string, string> = { + branch : resolved.branch, + commitSha : resolved.headSha, + status : 'queued', + }; + const { status, record } = await ctx.ci.records.create('repo/checkSuite' as any, { + data: { + app : result.workflow.name, + displayTitle : result.workflow.name, + event : 'workflow_dispatch', + headBranch : resolved.branch, + inputs : inputs ?? {}, + message : 'Manual workflow dispatch', + }, + tags, + parentContextId: repo.contextId, + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to dispatch workflow: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create workflow dispatch record'); } + + const runId = numericId(record.id ?? ''); + const baseUrl = buildApiUrl(url); + return jsonOk({ + workflow_run_id : runId, + run_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${runId}`, + html_url : `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${runId}`, + }); +} + +export async function handleGetWorkflowUsage( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + + return jsonOk({ billable: {} }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/workflows/:workflow_id/runs +// --------------------------------------------------------------------------- + +export async function handleListWorkflowRunsForWorkflow( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const result = await findWorkflow(ctx, targetDid, repo, id); + if (!result) { + return jsonNotFound(`Workflow ${decodeRouteParam(id)} not found.`); + } + + const suitesForWorkflow = result.suites.filter(suite => suiteAppName(suite) === result.workflow.name); + const suites = filterWorkflowRuns(suitesForWorkflow, url); + if (!Array.isArray(suites)) { return suites; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(suites, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, suites.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : suites.length, + workflow_runs : paged.map(suite => buildWorkflowRunResponse(suite, targetDid, repo, baseUrl)), + }, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/runs +// --------------------------------------------------------------------------- + +export async function handleListWorkflowRuns( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suites = filterWorkflowRuns(await querySuites(ctx, targetDid, repo), url); + if (!Array.isArray(suites)) { return suites; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(suites, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, suites.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : suites.length, + workflow_runs : paged.map(suite => buildWorkflowRunResponse(suite, targetDid, repo, baseUrl)), + }, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/runs/:run_id +// --------------------------------------------------------------------------- + +export async function handleGetWorkflowRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + return jsonOk(buildWorkflowRunResponse(suite, targetDid, repo, buildApiUrl(url))); +} + +export async function handleGetWorkflowRunAttempt( + ctx: AgentContext, targetDid: string, repoName: string, id: string, attempt: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite || attempt !== '1') { + return jsonNotFound(`Workflow run attempt ${attempt} for run ${id} not found.`); + } + + return jsonOk(buildWorkflowRunResponse(suite, targetDid, repo, buildApiUrl(url))); +} + +export async function handleGetWorkflowRunUsage( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const runs = await queryRunsForSuite(ctx, targetDid, suite); + const jobRuns = runs.map(run => ({ + job_id : runId(run), + duration_ms : workflowJobDurationMs(run), + })); + const totalMs = jobRuns.reduce((sum, run) => sum + run.duration_ms, 0); + + return jsonOk({ + billable: jobRuns.length > 0 + ? { UBUNTU: { total_ms: totalMs, jobs: jobRuns.length, job_runs: jobRuns } } + : {}, + run_duration_ms: workflowRunDurationMs(suite), + }); +} + +export async function handleDownloadWorkflowRunLogs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const baseUrl = buildApiUrl(url); + if (url.searchParams.get('download') !== '1') { + return downloadRedirect(`${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${suiteId(suite)}/logs?download=1`); + } + + const archive = workflowRunLogArchive(await queryRunsForSuite(ctx, targetDid, suite)); + return binaryOk(archive, 'application/zip', { + 'Content-Disposition': `attachment; filename="actions-run-${suiteId(suite)}-logs.zip"`, + }); +} + +export async function handleDownloadWorkflowRunAttemptLogs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, attempt: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite || attempt !== '1') { + return jsonNotFound(`Workflow run attempt ${attempt} for run ${id} not found.`); + } + + const baseUrl = buildApiUrl(url); + if (url.searchParams.get('download') !== '1') { + return downloadRedirect( + `${baseUrl}/repos/${targetDid}/${repo.name}/actions/runs/${suiteId(suite)}/attempts/1/logs?download=1`, + ); + } + + const archive = workflowRunLogArchive(await queryRunsForSuite(ctx, targetDid, suite)); + return binaryOk(archive, 'application/zip', { + 'Content-Disposition': `attachment; filename="actions-run-${suiteId(suite)}-attempt-1-logs.zip"`, + }); +} + +export async function handleDeleteWorkflowRunLogs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + const data = dataWithDeletedLogs(run); + const { status } = await run.rec.update({ data, tags: run.tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete workflow run logs: ${status.detail}`); + } + run.data = data; + } + + return jsonNoContent(); +} + +export async function handleCancelWorkflowRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + if (suiteStatus(suite) === 'completed') { + return jsonConflict(`Workflow run ${id} is already completed.`); + } + + const suiteError = await updateSuiteCancelled(suite); + if (suiteError) { return suiteError; } + + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + if (run.tags.status === 'completed') { continue; } + const runError = await updateRunCancelled(run); + if (runError) { return runError; } + } + + return acceptedEmpty(); +} + +export async function handleForceCancelWorkflowRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const suiteError = await updateSuiteCancelled(suite); + if (suiteError) { return suiteError; } + + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + const runError = await updateRunCancelled(run); + if (runError) { return runError; } + } + + return acceptedEmpty(); +} + +export async function handleDeleteWorkflowRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const deleteError = await deleteWorkflowRunRecords(ctx, targetDid, suite); + if (deleteError) { return deleteError; } + + return jsonNoContent(); +} + +export async function handleRerunWorkflowRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const suiteError = await updateSuiteQueued(suite); + if (suiteError) { return suiteError; } + + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + const runError = await updateRunQueued(run); + if (runError) { return runError; } + } + + return createdEmpty(); +} + +export async function handleRerunFailedWorkflowJobs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const suiteError = await updateSuiteQueued(suite); + if (suiteError) { return suiteError; } + + for (const run of await queryRunsForSuite(ctx, targetDid, suite)) { + if (run.tags.status !== 'completed' || run.tags.conclusion === 'success' || run.tags.conclusion === 'skipped') { + continue; + } + const runError = await updateRunQueued(run); + if (runError) { return runError; } + } + + return createdEmpty(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/runs/:run_id/jobs +// --------------------------------------------------------------------------- + +export async function handleListWorkflowRunJobs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Workflow run ${id} not found.`); + } + + const runs = await queryRunsForSuite(ctx, targetDid, suite); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(runs, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, runs.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : runs.length, + jobs : paged.map(run => buildWorkflowJobResponse(run, targetDid, repo, baseUrl)), + }, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/actions/jobs/:job_id +// --------------------------------------------------------------------------- + +export async function handleGetWorkflowJob( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Workflow job ${id} not found.`); + } + + return jsonOk(buildWorkflowJobResponse(run, targetDid, repo, buildApiUrl(url))); +} + +export async function handleDownloadWorkflowJobLogs( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Workflow job ${id} not found.`); + } + + const baseUrl = buildApiUrl(url); + if (url.searchParams.get('download') !== '1') { + return downloadRedirect(`${baseUrl}/repos/${targetDid}/${repo.name}/actions/jobs/${runId(run)}/logs?download=1`); + } + + const bytes = new TextEncoder().encode(workflowJobLogText(run)); + return binaryOk(bytes, 'text/plain; charset=utf-8', { + 'Content-Disposition': `attachment; filename="${safeLogFilename(String(run.tags.name ?? 'check'))}.txt"`, + }); +} + +export async function handleRerunWorkflowJob( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepo(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Workflow job ${id} not found.`); + } + + const suiteError = await updateSuiteQueued(run.suite); + if (suiteError) { return suiteError; } + + const runError = await updateRunQueued(run); + if (runError) { return runError; } + + return createdEmpty(); +} diff --git a/src/github-shim/activity.ts b/src/github-shim/activity.ts new file mode 100644 index 0000000..52906b1 --- /dev/null +++ b/src/github-shim/activity.ts @@ -0,0 +1,386 @@ +/** + * GitHub API shim — activity event endpoints. + * + * Maps forge-social activity records to GitHub REST API event objects. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + getRepoRecord, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type ActivityEntry = { + record : any; + data : Record<string, unknown>; + tags : Record<string, unknown>; + actorDid : string; +}; + +const REPO_ACTIVITY_TYPES = new Set(['push', 'force_push', 'branch_creation', 'branch_deletion', 'pr_merge', 'merge_queue_merge']); +const REPO_ACTIVITY_TIME_PERIODS = new Set(['day', 'week', 'month', 'quarter', 'year']); + +function asRecord(value: unknown): Record<string, unknown> { + return value && typeof value === 'object' ? value as Record<string, unknown> : {}; +} + +function stringField(entry: ActivityEntry, key: string): string | undefined { + if (typeof entry.data[key] === 'string') { return entry.data[key]; } + if (typeof entry.tags[key] === 'string') { return entry.tags[key]; } + return undefined; +} + +function actorResponse(did: string, baseUrl: string): Record<string, unknown> { + return { + id : numericId(did), + login : did, + display_login : did, + gravatar_id : '', + url : `${baseUrl}/users/${did}`, + avatar_url : '', + }; +} + +function repoResponse(entry: ActivityEntry, baseUrl: string): Record<string, unknown> { + const repoDid = stringField(entry, 'repoDid') ?? stringField(entry, 'targetDid') ?? entry.actorDid; + const repoName = stringField(entry, 'repoName') ?? 'unknown'; + const repoRecordId = stringField(entry, 'repoRecordId') ?? stringField(entry, 'targetRecordId') ?? `${repoDid}/${repoName}`; + const fullName = `${repoDid}/${repoName}`; + return { + id : numericId(repoRecordId), + name : fullName, + url : `${baseUrl}/repos/${fullName}`, + }; +} + +function githubEventType(type: string): string { + switch (type) { + case 'push': return 'PushEvent'; + case 'issue_open': + case 'issue_close': return 'IssuesEvent'; + case 'patch_open': + case 'patch_merge': return 'PullRequestEvent'; + case 'release': return 'ReleaseEvent'; + case 'star': return 'WatchEvent'; + case 'fork': return 'ForkEvent'; + default: return 'CreateEvent'; + } +} + +function issuePayload(entry: ActivityEntry, action: 'opened' | 'closed'): Record<string, unknown> { + const recordId = stringField(entry, 'recordId') ?? entry.record.id; + return { + action, + issue: { + id : numericId(recordId), + number : numericId(recordId), + title : typeof entry.data.summary === 'string' ? entry.data.summary : '', + }, + }; +} + +function pullPayload(entry: ActivityEntry, action: 'opened' | 'closed', merged: boolean): Record<string, unknown> { + const recordId = stringField(entry, 'recordId') ?? entry.record.id; + return { + action, + pull_request: { + id : numericId(recordId), + number : numericId(recordId), + title : typeof entry.data.summary === 'string' ? entry.data.summary : '', + merged, + }, + }; +} + +function eventPayload(entry: ActivityEntry): Record<string, unknown> { + const explicit = asRecord(entry.data.payload); + if (Object.keys(explicit).length > 0) { return explicit; } + + const type = typeof entry.data.type === 'string' ? entry.data.type : ''; + const repo = repoResponse(entry, ''); + switch (type) { + case 'push': + return { + repository_id : repo.id, + push_id : numericId(entry.record.id), + ref : stringField(entry, 'ref') ?? 'refs/heads/main', + head : stringField(entry, 'head') ?? stringField(entry, 'recordId') ?? entry.record.id, + before : stringField(entry, 'before') ?? null, + commits : [], + }; + case 'issue_open': return issuePayload(entry, 'opened'); + case 'issue_close': return issuePayload(entry, 'closed'); + case 'patch_open': return pullPayload(entry, 'opened', false); + case 'patch_merge': return pullPayload(entry, 'closed', true); + case 'release': + return { + action : 'published', + release : { + id : numericId(stringField(entry, 'recordId') ?? entry.record.id), + name : typeof entry.data.summary === 'string' ? entry.data.summary : '', + tag_name : stringField(entry, 'tagName') ?? '', + }, + }; + case 'star': + return { action: 'started' }; + case 'fork': + return { + forkee: { + full_name: stringField(entry, 'forkeeFullName') ?? `${entry.actorDid}/${stringField(entry, 'repoName') ?? 'fork'}`, + }, + }; + default: + return { + ref : stringField(entry, 'ref') ?? null, + ref_type : 'repository', + master_branch : stringField(entry, 'defaultBranch') ?? 'main', + description : typeof entry.data.summary === 'string' ? entry.data.summary : null, + }; + } +} + +function buildEvent(entry: ActivityEntry, baseUrl: string): Record<string, unknown> { + const type = typeof entry.data.type === 'string' ? entry.data.type : 'activity'; + return { + id : entry.record.id, + type : githubEventType(type), + actor : actorResponse(entry.actorDid, baseUrl), + repo : repoResponse(entry, baseUrl), + payload : eventPayload(entry), + public : entry.data.public !== false, + created_at : toISODate(entry.record.dateCreated), + }; +} + +function repoActivityType(entry: ActivityEntry): string | null { + const type = typeof entry.data.type === 'string' ? entry.data.type : ''; + switch (type) { + case 'patch_merge': return 'pr_merge'; + case 'force_push': + case 'branch_creation': + case 'branch_deletion': + case 'merge_queue_merge': + case 'push': + return type; + default: + return null; + } +} + +function repoActivityRef(entry: ActivityEntry, repo: RepoInfo): string { + return stringField(entry, 'ref') ?? `refs/heads/${repo.defaultBranch}`; +} + +function repoActivityResponse(entry: ActivityEntry, repo: RepoInfo, baseUrl: string): Record<string, unknown> { + const payload = eventPayload(entry); + const after = typeof payload.head === 'string' + ? payload.head + : typeof payload.after === 'string' + ? payload.after + : stringField(entry, 'recordId') ?? entry.record.id; + return { + id : numericId(entry.record.id), + node_id : entry.record.id, + before : typeof payload.before === 'string' ? payload.before : null, + after, + ref : repoActivityRef(entry, repo), + timestamp : toISODate(entry.record.dateCreated), + activity_type : repoActivityType(entry) ?? 'push', + actor : actorResponse(entry.actorDid, baseUrl), + }; +} + +function parseRepoActivityCursor(value: string | null): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function filterRepoActivity(entries: ActivityEntry[], repo: RepoInfo, url: URL): ActivityEntry[] | JsonResponse { + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const activityType = url.searchParams.get('activity_type'); + if (activityType && !REPO_ACTIVITY_TYPES.has(activityType)) { + return jsonValidationError('Validation Failed: activity_type is not supported.'); + } + + const timePeriod = url.searchParams.get('time_period'); + if (timePeriod && !REPO_ACTIVITY_TIME_PERIODS.has(timePeriod)) { + return jsonValidationError('Validation Failed: time_period must be day, week, month, quarter, or year.'); + } + + const ref = url.searchParams.get('ref'); + const actor = url.searchParams.get('actor'); + const before = parseRepoActivityCursor(url.searchParams.get('before')); + const after = parseRepoActivityCursor(url.searchParams.get('after')); + + const filtered = entries.filter((entry) => { + const type = repoActivityType(entry); + if (!type) { + return false; + } + if (activityType && type !== activityType) { + return false; + } + if (ref) { + const activityRef = repoActivityRef(entry, repo); + if (activityRef !== ref && activityRef !== `refs/heads/${ref}`) { + return false; + } + } + if (actor && entry.actorDid !== actor) { + return false; + } + + const timestamp = Date.parse(String(entry.record.dateCreated ?? '')); + if (before !== null && Number.isFinite(timestamp) && timestamp >= before) { + return false; + } + if (after !== null && Number.isFinite(timestamp) && timestamp <= after) { + return false; + } + return true; + }); + + filtered.sort((a, b) => { + const comparison = String(a.record.dateCreated ?? '').localeCompare(String(b.record.dateCreated ?? '')); + return direction === 'asc' ? comparison : -comparison; + }); + return filtered; +} + +async function listActorActivity(ctx: AgentContext, actorDid: string): Promise<ActivityEntry[]> { + const { records } = await ctx.social.records.query('activity' as any, { + from : fromOpt(ctx, actorDid), + dateSort : DateSort.CreatedDescending, + } as any); + + const entries: ActivityEntry[] = []; + for (const record of records) { + let data: Record<string, unknown>; + try { + data = await record.data.json(); + } catch { + continue; + } + entries.push({ + record, + data, + tags: (record.tags as Record<string, unknown> | undefined) ?? {}, + actorDid, + }); + } + return entries; +} + +async function listVisibleActivity(ctx: AgentContext, actorDids: string[]): Promise<ActivityEntry[]> { + const seen = new Set<string>(); + const entries: ActivityEntry[] = []; + for (const actorDid of [...new Set(actorDids)]) { + for (const entry of await listActorActivity(ctx, actorDid)) { + if (seen.has(entry.record.id)) { continue; } + seen.add(entry.record.id); + entries.push(entry); + } + } + return entries.sort((a, b) => String(b.record.dateCreated ?? '').localeCompare(String(a.record.dateCreated ?? ''))); +} + +function activityMatchesRepo(entry: ActivityEntry, targetDid: string, repoName: string, repo: RepoInfo): boolean { + const entryRepoDid = stringField(entry, 'repoDid') ?? stringField(entry, 'targetDid'); + if (entryRepoDid !== targetDid) { return false; } + + const entryRepoName = stringField(entry, 'repoName'); + if (entryRepoName) { return entryRepoName === repoName || entryRepoName === repo.name; } + + const entryRepoRecord = stringField(entry, 'repoRecordId') ?? stringField(entry, 'targetRecordId'); + return entryRepoRecord === repo.contextId; +} + +function eventsResponse(entries: ActivityEntry[], url: URL, path: string): JsonResponse { + const pagination = parsePagination(url); + const limited = entries.slice(0, 300); + const paged = paginate(limited, pagination); + const baseUrl = buildApiUrl(url); + const events = paged.map(entry => buildEvent(entry, baseUrl)); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, limited.length); + const headers: Record<string, string> = { 'X-Poll-Interval': '60' }; + if (linkHeader) { headers.Link = linkHeader; } + return jsonOk(events, headers); +} + +export async function handleListPublicEvents(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = (await listVisibleActivity(ctx, [ctx.did])).filter(entry => entry.data.public !== false); + return eventsResponse(entries, url, '/events'); +} + +export async function handleListUserEvents(ctx: AgentContext, userDid: string, url: URL, publicOnly = false): Promise<JsonResponse> { + const path = publicOnly ? `/users/${userDid}/events/public` : `/users/${userDid}/events`; + const entries = await listVisibleActivity(ctx, [userDid]); + return eventsResponse(publicOnly ? entries.filter(entry => entry.data.public !== false) : entries, url, path); +} + +export async function handleListReceivedEvents(ctx: AgentContext, userDid: string, url: URL, publicOnly = false): Promise<JsonResponse> { + const path = publicOnly ? `/users/${userDid}/received_events/public` : `/users/${userDid}/received_events`; + const entries = await listVisibleActivity(ctx, [userDid]); + return eventsResponse(publicOnly ? entries.filter(entry => entry.data.public !== false) : entries, url, path); +} + +export async function handleListRepoEvents( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, pathPrefix = 'repos', +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const activity = await listVisibleActivity(ctx, targetDid === ctx.did ? [ctx.did] : [targetDid, ctx.did]); + const entries = activity.filter(entry => activityMatchesRepo(entry, targetDid, repoName, repo)); + return eventsResponse(entries, url, `/${pathPrefix}/${targetDid}/${repoName}/events`); +} + +export async function handleListRepoActivity( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const activity = await listVisibleActivity(ctx, targetDid === ctx.did ? [ctx.did] : [targetDid, ctx.did]); + const entries = activity.filter(entry => activityMatchesRepo(entry, targetDid, repoName, repo)); + const filtered = filterRepoActivity(entries, repo, url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repo.name}/activity`, + pagination.page, pagination.perPage, filtered.length, + ); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + + return jsonOk(paged.map(entry => repoActivityResponse(entry, repo, baseUrl)), headers); +} diff --git a/src/github-shim/body-media.ts b/src/github-shim/body-media.ts new file mode 100644 index 0000000..ccd152c --- /dev/null +++ b/src/github-shim/body-media.ts @@ -0,0 +1,42 @@ +/** + * GitHub custom media-type helpers for Markdown body fields. + * + * @module + */ + +import { renderMarkdownText } from './meta.js'; + +export type BodyMediaKind = 'raw' | 'text' | 'html' | 'full'; + +export function renderMarkdownPlainText(markdown: string): string { + return markdown.replace(/\r\n/g, '\n').split(/\n{2,}/) + .map((block) => { + const trimmed = block.trim(); + if (!trimmed) { return ''; } + return trimmed + .replace(/^#{1,6}\s+/gm, '') + .replace(/^\s*-\s+/gm, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + }) + .filter(Boolean) + .join('\n'); +} + +export function applyBodyMedia( + response: Record<string, unknown>, body: string | null, mediaKind?: BodyMediaKind | null, +): Record<string, unknown> { + const kind = mediaKind ?? 'raw'; + if (kind === 'raw' || kind === 'full') { + response.body = body; + } + if (kind === 'text' || kind === 'full') { + response.body_text = body === null ? null : renderMarkdownPlainText(body); + } + if (kind === 'html' || kind === 'full') { + response.body_html = body === null ? null : renderMarkdownText(body); + } + return response; +} diff --git a/src/github-shim/checks.ts b/src/github-shim/checks.ts new file mode 100644 index 0000000..918e363 --- /dev/null +++ b/src/github-shim/checks.ts @@ -0,0 +1,1025 @@ +/** + * GitHub API shim — commit statuses and checks endpoints. + * + * Maps `forge-ci` check suites and check runs to GitHub REST API v3 status + * and Checks API responses. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { buildRepoResponse } from './repos.js'; + +import { + baseHeaders, + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonCreated, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type CiSuite = { + rec : any; + data : Record<string, any>; + tags : Record<string, string>; +}; + +type CiRun = { + suite : CiSuite; + rec : any; + data : Record<string, any>; + tags : Record<string, string>; +}; + +type GitHubStatusState = 'error' | 'failure' | 'pending' | 'success'; +type ForgeCiStatus = 'queued' | 'in_progress' | 'completed'; +type ForgeCiConclusion = 'success' | 'failure' | 'cancelled' | 'skipped'; +type CheckRunListResult = { runs: CiRun[] } | { error: JsonResponse }; +type CheckSuiteListResult = { suites: CiSuite[] } | { error: JsonResponse }; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +async function resolveCommitRef( + ctx: AgentContext, targetDid: string, repo: RepoInfo, ref: string, +): Promise<string> { + const decoded = decodeRouteParam(ref); + const from = fromOpt(ctx, targetDid); + + const { records } = await ctx.refs.records.query('repo/ref' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const candidates = [ + decoded, + `refs/heads/${decoded}`, + `refs/tags/${decoded}`, + decoded.startsWith('heads/') ? `refs/${decoded}` : '', + decoded.startsWith('tags/') ? `refs/${decoded}` : '', + ].filter(Boolean); + + for (const rec of records) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = data.name ?? tags.name; + if (candidates.includes(name)) { + return data.target ?? tags.target ?? decoded; + } + } + + return decoded; +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +async function querySuites( + ctx: AgentContext, targetDid: string, repo: RepoInfo, commitSha?: string, +): Promise<CiSuite[]> { + const from = fromOpt(ctx, targetDid); + const filter: Record<string, unknown> = { contextId: repo.contextId }; + if (commitSha) { filter.tags = { commitSha }; } + + const { records } = await ctx.ci.records.query('repo/checkSuite' as any, { + from, + filter, + dateSort: DateSort.CreatedDescending, + }); + + const suites: CiSuite[] = []; + for (const rec of records) { + suites.push({ + rec, + data : await rec.data.json(), + tags : (rec.tags as Record<string, string> | undefined) ?? {}, + }); + } + return suites; +} + +async function queryRunsForSuite( + ctx: AgentContext, targetDid: string, suite: CiSuite, +): Promise<CiRun[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.ci.records.query('repo/checkSuite/checkRun' as any, { + from, + filter : { contextId: suite.rec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const runs: CiRun[] = []; + for (const rec of records) { + runs.push({ + suite, + rec, + data : await rec.data.json(), + tags : (rec.tags as Record<string, string> | undefined) ?? {}, + }); + } + return runs; +} + +async function queryRunsForSuites( + ctx: AgentContext, targetDid: string, suites: CiSuite[], +): Promise<CiRun[]> { + const runs: CiRun[] = []; + for (const suite of suites) { + runs.push(...await queryRunsForSuite(ctx, targetDid, suite)); + } + return runs; +} + +async function findSuiteByNumber( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<CiSuite | null> { + const wanted = parseInt(id, 10); + const suites = await querySuites(ctx, targetDid, repo); + return suites.find(suite => numericId(suite.rec.id ?? '') === wanted) ?? null; +} + +async function findRunByNumber( + ctx: AgentContext, targetDid: string, repo: RepoInfo, id: string, +): Promise<CiRun | null> { + const wanted = parseInt(id, 10); + const suites = await querySuites(ctx, targetDid, repo); + for (const suite of suites) { + const runs = await queryRunsForSuite(ctx, targetDid, suite); + const match = runs.find(run => numericId(run.rec.id ?? '') === wanted); + if (match) { return match; } + } + return null; +} + +async function findBranchForCommit( + ctx: AgentContext, targetDid: string, repo: RepoInfo, commitSha: string, +): Promise<string | null> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.refs.records.query('repo/ref' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + for (const rec of records) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = data.name ?? tags.name; + const target = data.target ?? tags.target; + const type = data.type ?? tags.type; + if (target === commitSha && type === 'branch' && typeof name === 'string') { + return name.replace(/^refs\/heads\//, ''); + } + } + + return null; +} + +function createdEmpty(): JsonResponse { + return { + status : 201, + headers : baseHeaders(), + body : '', + }; +} + +function toGitHubStatusState(status: string | undefined, conclusion: string | undefined): GitHubStatusState { + if (status !== 'completed') { return 'pending'; } + if (conclusion === 'success' || conclusion === 'skipped') { return 'success'; } + if (conclusion === 'cancelled') { return 'error'; } + return 'failure'; +} + +function statusFromGitHubState(state: string): { status: ForgeCiStatus; conclusion?: ForgeCiConclusion } { + switch (state) { + case 'success': + return { status: 'completed', conclusion: 'success' }; + case 'failure': + return { status: 'completed', conclusion: 'failure' }; + case 'error': + return { status: 'completed', conclusion: 'cancelled' }; + case 'pending': + return { status: 'in_progress' }; + default: + return { status: 'queued' }; + } +} + +function combineStatus(states: GitHubStatusState[]): 'failure' | 'pending' | 'success' { + if (states.length === 0) { return 'pending'; } + if (states.some(state => state === 'failure' || state === 'error')) { return 'failure'; } + if (states.some(state => state === 'pending')) { return 'pending'; } + return 'success'; +} + +function buildApp(appName: string, baseUrl: string): Record<string, unknown> { + return { + id : numericId(appName), + slug : appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'gitd-ci', + node_id : appName, + owner : buildOwner(appName, baseUrl), + name : appName, + html_url : `${baseUrl}/apps/${encodeURIComponent(appName)}`, + created_at : new Date(0).toISOString(), + updated_at : new Date(0).toISOString(), + }; +} + +function suiteAppName(suite: CiSuite): string { + return suite.data.app ?? 'gitd-ci'; +} + +function rawRunOutput(run: CiRun): Record<string, any> { + const output = run.data.output; + if (output && typeof output === 'object' && !Array.isArray(output)) { + return output; + } + return run.data; +} + +function runAnnotations(run: CiRun): Record<string, any>[] { + const annotations = rawRunOutput(run).annotations; + return Array.isArray(annotations) + ? annotations.filter(annotation => annotation && typeof annotation === 'object') + : []; +} + +function runOutput( + run: CiRun, targetDid?: string, repo?: RepoInfo, baseUrl?: string, +): Record<string, string | number> { + const output = rawRunOutput(run); + const id = numericId(run.rec.id ?? ''); + return { + title : output.title ?? run.tags.name ?? 'check', + summary : output.summary ?? '', + text : output.text ?? '', + annotations_count : runAnnotations(run).length, + annotations_url : targetDid && repo && baseUrl + ? `${baseUrl}/repos/${targetDid}/${repo.name}/check-runs/${id}/annotations` + : '', + }; +} + +function suiteHeadSha(suite: CiSuite): string { + return suite.tags.commitSha ?? ''; +} + +function runUpdatedAt(run: CiRun): number { + const raw = run.rec.timestamp ?? run.rec.dateCreated ?? ''; + const parsed = Date.parse(raw); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function applyLatestFilter(runs: CiRun[]): CiRun[] { + const latestByName = new Map<string, CiRun>(); + for (const run of runs) { + const name = run.tags.name ?? 'check'; + const previous = latestByName.get(name); + if (!previous || runUpdatedAt(run) >= runUpdatedAt(previous)) { + latestByName.set(name, run); + } + } + return [...latestByName.values()]; +} + +function filterCheckRuns(runs: CiRun[], url: URL): CheckRunListResult { + const status = url.searchParams.get('status'); + if (status && !['queued', 'in_progress', 'completed'].includes(status)) { + return { error: jsonValidationError('Validation Failed: status must be queued, in_progress, or completed.') }; + } + + const filter = url.searchParams.get('filter') ?? 'latest'; + if (!['latest', 'all'].includes(filter)) { + return { error: jsonValidationError('Validation Failed: filter must be latest or all.') }; + } + + const appId = url.searchParams.get('app_id'); + const parsedAppId = appId ? parseInt(appId, 10) : null; + if (appId && Number.isNaN(parsedAppId)) { + return { error: jsonValidationError('Validation Failed: app_id must be an integer.') }; + } + + const checkName = url.searchParams.get('check_name'); + let filtered = runs; + if (checkName) { + filtered = filtered.filter(run => (run.tags.name ?? 'check') === checkName); + } + if (status) { + filtered = filtered.filter(run => (run.tags.status ?? 'queued') === status); + } + if (parsedAppId !== null) { + filtered = filtered.filter(run => numericId(suiteAppName(run.suite)) === parsedAppId); + } + if (filter === 'latest') { + filtered = applyLatestFilter(filtered); + } + + filtered = [...filtered].sort((a, b) => runUpdatedAt(b) - runUpdatedAt(a)); + return { runs: filtered }; +} + +async function filterCheckSuites( + ctx: AgentContext, targetDid: string, suites: CiSuite[], url: URL, +): Promise<CheckSuiteListResult> { + const appId = url.searchParams.get('app_id'); + const parsedAppId = appId ? parseInt(appId, 10) : null; + if (appId && Number.isNaN(parsedAppId)) { + return { error: jsonValidationError('Validation Failed: app_id must be an integer.') }; + } + + const checkName = url.searchParams.get('check_name'); + let filtered = suites; + if (parsedAppId !== null) { + filtered = filtered.filter(suite => numericId(suiteAppName(suite)) === parsedAppId); + } + if (checkName) { + const withNamedRun: CiSuite[] = []; + for (const suite of filtered) { + const runs = await queryRunsForSuite(ctx, targetDid, suite); + if (runs.some(run => (run.tags.name ?? 'check') === checkName)) { + withNamedRun.push(suite); + } + } + filtered = withNamedRun; + } + + return { suites: filtered }; +} + +async function checkSuitesResponse( + ctx: AgentContext, suites: CiSuite[], targetDid: string, repo: RepoInfo, url: URL, +): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(suites, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, suites.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + const checkSuites = []; + + for (const suite of paged) { + checkSuites.push(buildCheckSuiteResponse(suite, await queryRunsForSuite(ctx, targetDid, suite), targetDid, repo, baseUrl)); + } + + return jsonOk({ + total_count : suites.length, + check_suites : checkSuites, + }, extraHeaders); +} + +function checkRunsResponse( + runs: CiRun[], targetDid: string, repo: RepoInfo, url: URL, +): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(runs, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, runs.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk({ + total_count : runs.length, + check_runs : paged.map(run => buildCheckRunResponse(run, targetDid, repo, baseUrl)), + }, extraHeaders); +} + +function buildCheckSuiteResponse( + suite: CiSuite, runs: CiRun[], + targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const id = numericId(suite.rec.id ?? ''); + const commitSha = suiteHeadSha(suite); + const appName = suiteAppName(suite); + + return { + id, + node_id : suite.rec.id ?? '', + head_branch : suite.tags.branch ?? suite.data.headBranch ?? null, + head_sha : commitSha, + status : suite.tags.status ?? 'queued', + conclusion : suite.tags.conclusion ?? null, + url : `${baseUrl}/repos/${targetDid}/${repo.name}/check-suites/${id}`, + before : null, + after : commitSha, + pull_requests : [], + app : buildApp(appName, baseUrl), + created_at : toISODate(suite.rec.dateCreated), + updated_at : toISODate(suite.rec.timestamp), + latest_check_runs_count : runs.length, + check_runs_url : `${baseUrl}/repos/${targetDid}/${repo.name}/check-suites/${id}/check-runs`, + }; +} + +function buildCheckRunResponse( + run: CiRun, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const id = numericId(run.rec.id ?? ''); + const suiteId = numericId(run.suite.rec.id ?? ''); + const appName = suiteAppName(run.suite); + const status = run.tags.status ?? 'queued'; + const conclusion = run.tags.conclusion ?? null; + + return { + id, + node_id : run.rec.id ?? '', + name : run.tags.name ?? 'check', + head_sha : suiteHeadSha(run.suite), + external_id : run.rec.id ?? '', + url : `${baseUrl}/repos/${targetDid}/${repo.name}/check-runs/${id}`, + html_url : `${baseUrl}/repos/${targetDid}/${repo.name}/checks/${id}`, + details_url : '', + status, + conclusion, + started_at : toISODate(run.data.startedAt ?? run.rec.dateCreated), + completed_at : status === 'completed' ? toISODate(run.data.completedAt ?? run.rec.timestamp) : null, + output : runOutput(run, targetDid, repo, baseUrl), + check_suite : { + id : suiteId, + node_id : run.suite.rec.id ?? '', + }, + app : buildApp(appName, baseUrl), + pull_requests : [], + }; +} + +function buildStatusResponse( + run: CiRun, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const id = numericId(run.rec.id ?? ''); + const state = toGitHubStatusState(run.tags.status, run.tags.conclusion); + const output = runOutput(run); + const context = run.tags.name ?? suiteAppName(run.suite); + + return { + url : `${baseUrl}/repos/${targetDid}/${repo.name}/statuses/${suiteHeadSha(run.suite)}`, + avatar_url : '', + id, + node_id : run.rec.id ?? '', + state, + description : output.summary || null, + target_url : '', + context, + created_at : toISODate(run.rec.dateCreated), + updated_at : toISODate(run.rec.timestamp), + creator : buildOwner(run.rec.author ?? targetDid, baseUrl), + }; +} + +function annotationNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function annotationString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function annotationNullableString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function buildAnnotationResponse( + run: CiRun, annotation: Record<string, any>, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const path = annotationString(annotation.path); + const startLine = annotationNumber(annotation.start_line, 1); + const endLine = annotationNumber(annotation.end_line, startLine); + const level = annotationString(annotation.annotation_level, 'warning'); + const fallbackBlobHref = `${baseUrl}/repos/${targetDid}/${repo.name}/contents/${encodeURIComponent(path)}?ref=${suiteHeadSha(run.suite)}`; + + return { + path, + start_line : startLine, + end_line : endLine, + start_column : typeof annotation.start_column === 'number' ? annotation.start_column : null, + end_column : typeof annotation.end_column === 'number' ? annotation.end_column : null, + annotation_level : ['notice', 'warning', 'failure'].includes(level) ? level : 'warning', + title : annotationNullableString(annotation.title), + message : annotationString(annotation.message), + raw_details : annotationNullableString(annotation.raw_details), + blob_href : annotationString(annotation.blob_href, fallbackBlobHref), + }; +} + +function requestOutput(reqBody: Record<string, unknown>): Record<string, any> | undefined { + const output = reqBody.output; + return output && typeof output === 'object' && !Array.isArray(output) + ? output as Record<string, any> + : undefined; +} + +function mergeCheckRunOutput(previous: Record<string, any>, next: Record<string, any>): Record<string, any> { + const merged = { ...previous, ...next }; + if (Array.isArray(next.annotations)) { + merged.annotations = [ + ...(Array.isArray(previous.annotations) ? previous.annotations : []), + ...next.annotations, + ]; + } + return merged; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/commits/:ref/status +// --------------------------------------------------------------------------- + +export async function handleGetCombinedStatus( + ctx: AgentContext, targetDid: string, repoName: string, ref: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + const commitSha = await resolveCommitRef(ctx, targetDid, repo, ref); + const suites = await querySuites(ctx, targetDid, repo, commitSha); + const runs = await queryRunsForSuites(ctx, targetDid, suites); + const statuses = runs.map(run => buildStatusResponse(run, targetDid, repo, baseUrl)); + const states = statuses.map(status => status.state as GitHubStatusState); + + return jsonOk({ + state : combineStatus(states), + statuses, + sha : commitSha, + total_count : statuses.length, + repository : buildRepoResponse(repo, targetDid, repo.name, baseUrl), + commit_url : `${baseUrl}/repos/${targetDid}/${repo.name}/commits/${commitSha}`, + url : `${baseUrl}/repos/${targetDid}/${repo.name}/commits/${commitSha}/status`, + }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/commits/:ref/statuses +// --------------------------------------------------------------------------- + +export async function handleListCommitStatuses( + ctx: AgentContext, targetDid: string, repoName: string, ref: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + const commitSha = await resolveCommitRef(ctx, targetDid, repo, ref); + const suites = await querySuites(ctx, targetDid, repo, commitSha); + const runs = await queryRunsForSuites(ctx, targetDid, suites); + + return jsonOk(runs.map(run => buildStatusResponse(run, targetDid, repo, baseUrl))); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/statuses/:sha +// --------------------------------------------------------------------------- + +export async function handleCreateCommitStatus( + ctx: AgentContext, targetDid: string, repoName: string, + sha: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const state = reqBody.state as string | undefined; + if (!state || !['error', 'failure', 'pending', 'success'].includes(state)) { + return jsonValidationError('Validation Failed: state must be error, failure, pending, or success.'); + } + + const context = (reqBody.context as string) ?? 'default'; + const description = (reqBody.description as string) ?? ''; + const mapped = statusFromGitHubState(state); + const baseUrl = buildApiUrl(url); + + const suiteTags: Record<string, string> = { + commitSha : sha, + status : mapped.status, + }; + if (mapped.conclusion) { suiteTags.conclusion = mapped.conclusion; } + + const { status: suiteStatus, record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, { + data : { app: 'github-status' }, + tags : suiteTags, + parentContextId : repo.contextId, + } as any); + + if (suiteStatus.code >= 300) { + return jsonValidationError(`Failed to create status suite: ${suiteStatus.detail}`); + } + if (!suiteRec) {throw new Error('Failed to create status suite record');} + + const runTags: Record<string, string> = { + name : context, + status : mapped.status, + }; + if (mapped.conclusion) { runTags.conclusion = mapped.conclusion; } + + const { status: runStatus, record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, { + data : { output: { title: context, summary: description } }, + tags : runTags, + parentContextId : suiteRec.contextId, + } as any); + + if (runStatus.code >= 300) { + return jsonValidationError(`Failed to create status run: ${runStatus.detail}`); + } + if (!runRec) {throw new Error('Failed to create status run record');} + + const suite: CiSuite = { + rec : suiteRec, + data : await suiteRec.data.json(), + tags : (suiteRec.tags as Record<string, string> | undefined) ?? {}, + }; + const run: CiRun = { + suite, + rec : runRec, + data : await runRec.data.json(), + tags : (runRec.tags as Record<string, string> | undefined) ?? {}, + }; + + return jsonCreated(buildStatusResponse(run, targetDid, repo, baseUrl)); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/commits/:ref/check-suites +// --------------------------------------------------------------------------- + +export async function handleListCheckSuitesForRef( + ctx: AgentContext, targetDid: string, repoName: string, ref: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const commitSha = await resolveCommitRef(ctx, targetDid, repo, ref); + const suites = await querySuites(ctx, targetDid, repo, commitSha); + const result = await filterCheckSuites(ctx, targetDid, suites, url); + if ('error' in result) { return result.error; } + + return checkSuitesResponse(ctx, result.suites, targetDid, repo, url); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/check-suites +// --------------------------------------------------------------------------- + +export async function handleCreateCheckSuite( + ctx: AgentContext, targetDid: string, repoName: string, + reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const headSha = reqBody.head_sha as string | undefined; + if (!headSha) { return jsonValidationError('Validation Failed: head_sha is required.'); } + + const app = typeof reqBody.app === 'string' && reqBody.app ? reqBody.app : 'github-checks'; + const suites = await querySuites(ctx, targetDid, repo, headSha); + const existing = suites.find(suite => suiteAppName(suite) === app); + if (existing) { + return jsonOk(buildCheckSuiteResponse( + existing, await queryRunsForSuite(ctx, targetDid, existing), targetDid, repo, buildApiUrl(url), + )); + } + + const branch = await findBranchForCommit(ctx, targetDid, repo, headSha); + const tags: Record<string, string> = { commitSha: headSha, status: 'queued' }; + if (branch) { tags.branch = branch; } + + const { status, record } = await ctx.ci.records.create('repo/checkSuite' as any, { + data : { app, headBranch: branch ?? '' }, + tags, + parentContextId : repo.contextId, + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to create check suite: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create check suite record'); } + + const suite: CiSuite = { + rec : record, + data : await record.data.json(), + tags : (record.tags as Record<string, string> | undefined) ?? {}, + }; + + return jsonCreated(buildCheckSuiteResponse(suite, [], targetDid, repo, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/commits/:ref/check-runs +// --------------------------------------------------------------------------- + +export async function handleListCheckRunsForRef( + ctx: AgentContext, targetDid: string, repoName: string, ref: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const commitSha = await resolveCommitRef(ctx, targetDid, repo, ref); + const suites = await querySuites(ctx, targetDid, repo, commitSha); + const result = filterCheckRuns(await queryRunsForSuites(ctx, targetDid, suites), url); + if ('error' in result) { return result.error; } + + return checkRunsResponse(result.runs, targetDid, repo, url); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/check-suites/:id +// --------------------------------------------------------------------------- + +export async function handleGetCheckSuite( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Check suite ${id} not found.`); + } + + return jsonOk(buildCheckSuiteResponse( + suite, await queryRunsForSuite(ctx, targetDid, suite), targetDid, repo, buildApiUrl(url), + )); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/check-suites/:id/rerequest +// --------------------------------------------------------------------------- + +export async function handleRerequestCheckSuite( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Check suite ${id} not found.`); + } + + const tags: Record<string, string> = { ...suite.tags, status: 'queued' }; + delete tags.conclusion; + const { status } = await suite.rec.update({ data: suite.data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to rerequest check suite: ${status.detail}`); + } + + return createdEmpty(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/check-suites/:id/check-runs +// --------------------------------------------------------------------------- + +export async function handleListCheckRunsForSuite( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const suite = await findSuiteByNumber(ctx, targetDid, repo, id); + if (!suite) { + return jsonNotFound(`Check suite ${id} not found.`); + } + + const result = filterCheckRuns(await queryRunsForSuite(ctx, targetDid, suite), url); + if ('error' in result) { return result.error; } + + return checkRunsResponse(result.runs, targetDid, repo, url); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/check-runs/:id +// --------------------------------------------------------------------------- + +export async function handleGetCheckRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Check run ${id} not found.`); + } + + return jsonOk(buildCheckRunResponse(run, targetDid, repo, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/check-runs/:id/annotations +// --------------------------------------------------------------------------- + +export async function handleListCheckRunAnnotations( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Check run ${id} not found.`); + } + + const baseUrl = buildApiUrl(url); + const annotations = runAnnotations(run).map(annotation => buildAnnotationResponse(run, annotation, targetDid, repo, baseUrl)); + const pagination = parsePagination(url); + const paged = paginate(annotations, pagination); + const linkHeader = buildLinkHeader(baseUrl, url.pathname, pagination.page, pagination.perPage, annotations.length); + const extraHeaders = linkHeader ? { Link: linkHeader } : undefined; + + return jsonOk(paged, extraHeaders); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/check-runs/:id/rerequest +// --------------------------------------------------------------------------- + +export async function handleRerequestCheckRun( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Check run ${id} not found.`); + } + + const tags: Record<string, string> = { ...run.suite.tags, status: 'queued' }; + delete tags.conclusion; + const { status } = await run.suite.rec.update({ data: run.suite.data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to rerequest check run: ${status.detail}`); + } + + return createdEmpty(); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/check-runs +// --------------------------------------------------------------------------- + +export async function handleCreateCheckRun( + ctx: AgentContext, targetDid: string, repoName: string, + reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const name = reqBody.name as string | undefined; + const headSha = reqBody.head_sha as string | undefined; + if (!name) { return jsonValidationError('Validation Failed: name is required.'); } + if (!headSha) { return jsonValidationError('Validation Failed: head_sha is required.'); } + + const status = ((reqBody.status as string | undefined) ?? 'queued') as ForgeCiStatus; + if (!['queued', 'in_progress', 'completed'].includes(status)) { + return jsonValidationError('Validation Failed: status must be queued, in_progress, or completed.'); + } + + const conclusion = reqBody.conclusion as ForgeCiConclusion | undefined; + const output = requestOutput(reqBody) ?? {}; + + const suiteTags: Record<string, string> = { commitSha: headSha, status }; + if (conclusion) { suiteTags.conclusion = conclusion; } + + const { status: suiteStatus, record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, { + data : { app: 'github-checks' }, + tags : suiteTags, + parentContextId : repo.contextId, + } as any); + + if (suiteStatus.code >= 300) { + return jsonValidationError(`Failed to create check suite: ${suiteStatus.detail}`); + } + if (!suiteRec) {throw new Error('Failed to create check suite record');} + + const runTags: Record<string, string> = { name, status }; + if (conclusion) { runTags.conclusion = conclusion; } + + const { status: runStatus, record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, { + data : { output }, + tags : runTags, + parentContextId : suiteRec.contextId, + } as any); + + if (runStatus.code >= 300) { + return jsonValidationError(`Failed to create check run: ${runStatus.detail}`); + } + if (!runRec) {throw new Error('Failed to create check run record');} + + const suite: CiSuite = { + rec : suiteRec, + data : await suiteRec.data.json(), + tags : (suiteRec.tags as Record<string, string> | undefined) ?? {}, + }; + const run: CiRun = { + suite, + rec : runRec, + data : await runRec.data.json(), + tags : (runRec.tags as Record<string, string> | undefined) ?? {}, + }; + + return jsonCreated(buildCheckRunResponse(run, targetDid, repo, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// PATCH /repos/:did/:repo/check-runs/:id +// --------------------------------------------------------------------------- + +export async function handleUpdateCheckRun( + ctx: AgentContext, targetDid: string, repoName: string, + id: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const run = await findRunByNumber(ctx, targetDid, repo, id); + if (!run) { + return jsonNotFound(`Check run ${id} not found.`); + } + + const status = ((reqBody.status as string | undefined) ?? run.tags.status ?? 'queued') as ForgeCiStatus; + if (!['queued', 'in_progress', 'completed'].includes(status)) { + return jsonValidationError('Validation Failed: status must be queued, in_progress, or completed.'); + } + + const conclusion = (reqBody.conclusion as ForgeCiConclusion | undefined) ?? run.tags.conclusion; + const nextOutput = requestOutput(reqBody); + const output = nextOutput + ? mergeCheckRunOutput(rawRunOutput(run), nextOutput) + : rawRunOutput(run); + const tags: Record<string, string> = { ...run.tags, status }; + if (conclusion) { tags.conclusion = conclusion; } + + const { status: updateStatus } = await run.rec.update({ + data: { output }, + tags, + }); + + if (updateStatus.code >= 300) { + return jsonValidationError(`Failed to update check run: ${updateStatus.detail}`); + } + + const suiteTags: Record<string, string> = { ...run.suite.tags, status }; + if (conclusion) { suiteTags.conclusion = conclusion; } + await run.suite.rec.update({ + data : run.suite.data, + tags : suiteTags, + }); + + const updatedRun: CiRun = { + ...run, + data : { output }, + tags, + suite : { + ...run.suite, + tags: suiteTags, + }, + }; + + return jsonOk(buildCheckRunResponse(updatedRun, targetDid, repo, buildApiUrl(url))); +} diff --git a/src/github-shim/commit-comments.ts b/src/github-shim/commit-comments.ts new file mode 100644 index 0000000..d3ef43e --- /dev/null +++ b/src/github-shim/commit-comments.ts @@ -0,0 +1,508 @@ +/** + * GitHub API shim — commit comment endpoints. + * + * Stores GitHub-compatible commit comment metadata in the repo settings record. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { BodyMediaKind } from './body-media.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { applyBodyMedia } from './body-media.js'; +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type CommitCommentEntry = { + id : number; + body : string; + commitId : string; + path? : string | null; + position? : number | null; + line? : number | null; + userDid : string; + createdAt : string; + updatedAt : string; + reactions? : Record<string, CommitCommentReactionEntry>; +}; + +type CommitCommentReactionEntry = { + id : number; + userDid : string; + content : ReactionContent; + createdAt : string; +}; + +type RepoSettingsData = { + branchProtection? : Record<string, unknown>; + labels? : Record<string, unknown>; + milestones? : Record<string, unknown>; + deployments? : Record<string, unknown>; + commitComments? : Record<string, CommitCommentEntry>; + mergeStrategies? : string[]; + autoDeleteBranch? : boolean; +}; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +type CommitCommentLookup = { + repo : RepoInfo; + settingsLookup : RepoSettingsLookup; + comment : CommitCommentEntry; + key : string; +}; + +const REACTION_CONTENTS = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes'] as const; +const REACTION_CONTENT_SET = new Set<string>(REACTION_CONTENTS); +type ReactionContent = typeof REACTION_CONTENTS[number]; + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function commentEntries(settings: RepoSettingsData): CommitCommentEntry[] { + return Object.values(settings.commitComments ?? {}) + .filter((entry): entry is CommitCommentEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function commentKey(id: number): string { + return String(id); +} + +function nextCommitCommentId(settings: RepoSettingsData): number { + return commentEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function reactionEntries(comment: CommitCommentEntry): CommitCommentReactionEntry[] { + return Object.values(comment.reactions ?? {}) + .filter((entry): entry is CommitCommentReactionEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function nextCommitCommentReactionId(settings: RepoSettingsData): number { + let max = 0; + for (const comment of commentEntries(settings)) { + for (const reaction of reactionEntries(comment)) { + max = Math.max(max, reaction.id); + } + } + return max + 1; +} + +function reactionKey(id: number): string { + return String(id); +} + +function stringParam(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function numberParam(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) ? value : null; +} + +function bodyParam(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + return value; +} + +function parseReactionContent(value: unknown): ReactionContent | JsonResponse { + if (typeof value !== 'string' || !REACTION_CONTENT_SET.has(value)) { + return jsonValidationError( + `Validation Failed: content must be one of ${REACTION_CONTENTS.map(content => `'${content}'`).join(', ')}.`, + ); + } + return value as ReactionContent; +} + +function buildCommitCommentResponse( + entry: CommitCommentEntry, targetDid: string, repoName: string, baseUrl: string, + bodyMediaKind?: BodyMediaKind | null, +): Record<string, unknown> { + const base = `${baseUrl}/repos/${targetDid}/${repoName}`; + return applyBodyMedia({ + html_url : `${base}/commit/${entry.commitId}#commitcomment-${entry.id}`, + url : `${base}/comments/${entry.id}`, + id : entry.id, + node_id : `commit-comment:${entry.id}`, + path : entry.path ?? null, + position : entry.position ?? null, + line : entry.line ?? null, + commit_id : entry.commitId, + user : buildOwner(entry.userDid, baseUrl), + created_at : toISODate(entry.createdAt), + updated_at : toISODate(entry.updatedAt), + author_association : entry.userDid === targetDid ? 'OWNER' : 'CONTRIBUTOR', + }, entry.body, bodyMediaKind); +} + +function buildCommitCommentReactionResponse( + entry: CommitCommentReactionEntry, baseUrl: string, +): Record<string, unknown> { + return { + id : entry.id, + node_id : `commit-comment-reaction:${entry.id}`, + user : buildOwner(entry.userDid, baseUrl), + content : entry.content, + created_at : toISODate(entry.createdAt), + }; +} + +async function findComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, +): Promise<CommitCommentLookup | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const id = parseInt(commentId, 10); + const key = commentKey(id); + const comment = lookup.settings.commitComments?.[key]; + if (!comment) { + return jsonNotFound(`Commit comment ${commentId} not found.`); + } + + return { + repo : lookup.repo, + settingsLookup : lookup, + comment, + key, + }; +} + +function settingsWithComment( + settings: RepoSettingsData, key: string, comment: CommitCommentEntry | null, +): RepoSettingsData { + const commitComments = { ...(settings.commitComments ?? {}) }; + if (comment) { + commitComments[key] = comment; + } else { + delete commitComments[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(commitComments).length > 0) { + next.commitComments = commitComments; + } else { + delete next.commitComments; + } + return next; +} + +function commentWithReaction( + comment: CommitCommentEntry, key: string, reaction: CommitCommentReactionEntry | null, +): CommitCommentEntry { + const reactions = { ...(comment.reactions ?? {}) }; + if (reaction) { + reactions[key] = reaction; + } else { + delete reactions[key]; + } + + const next: CommitCommentEntry = { ...comment }; + if (Object.keys(reactions).length > 0) { + next.reactions = reactions; + } else { + delete next.reactions; + } + return next; +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/comments +// --------------------------------------------------------------------------- + +export async function handleListCommitComments( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const comments = commentEntries(lookup.settings); + const paged = paginate(comments, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(comment => buildCommitCommentResponse(comment, targetDid, lookup.repo.name, baseUrl, bodyMediaKind)), + extraHeaders, + ); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/comments/:comment_id +// --------------------------------------------------------------------------- + +export async function handleGetCommitComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + return jsonOk(buildCommitCommentResponse(result.comment, targetDid, result.repo.name, buildApiUrl(url), bodyMediaKind)); +} + +export async function handleUpdateCommitComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const body = bodyParam(reqBody.body); + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + + const updated: CommitCommentEntry = { + ...result.comment, + body, + updatedAt: new Date().toISOString(), + }; + + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + settingsWithComment(result.settingsLookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildCommitCommentResponse(updated, targetDid, result.repo.name, buildApiUrl(url), bodyMediaKind)); +} + +export async function handleDeleteCommitComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, +): Promise<JsonResponse> { + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + settingsWithComment(result.settingsLookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/comments/:comment_id/reactions +// --------------------------------------------------------------------------- + +export async function handleListCommitCommentReactions( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, url: URL, +): Promise<JsonResponse> { + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + + const contentFilter = url.searchParams.get('content'); + if (contentFilter !== null) { + const parsed = parseReactionContent(contentFilter); + if (typeof parsed !== 'string') { return parsed; } + } + + const pagination = parsePagination(url); + const reactions = contentFilter === null + ? reactionEntries(result.comment) + : reactionEntries(result.comment).filter(reaction => reaction.content === contentFilter); + const paged = paginate(reactions, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${result.repo.name}/comments/${commentId}/reactions`, + pagination.page, pagination.perPage, reactions.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(reaction => buildCommitCommentReactionResponse(reaction, baseUrl)), extraHeaders); +} + +export async function handleCreateCommitCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const content = parseReactionContent(reqBody.content); + if (typeof content !== 'string') { return content; } + + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + + const baseUrl = buildApiUrl(url); + const duplicate = reactionEntries(result.comment).find((reaction) => { + return reaction.userDid === ctx.did && reaction.content === content; + }); + if (duplicate) { + return jsonOk(buildCommitCommentReactionResponse(duplicate, baseUrl)); + } + + const reaction: CommitCommentReactionEntry = { + id : nextCommitCommentReactionId(result.settingsLookup.settings), + userDid : ctx.did, + content, + createdAt : new Date().toISOString(), + }; + const updatedComment = commentWithReaction(result.comment, reactionKey(reaction.id), reaction); + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + settingsWithComment(result.settingsLookup.settings, result.key, updatedComment), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildCommitCommentReactionResponse(reaction, baseUrl)); +} + +export async function handleDeleteCommitCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, reactionId: string, +): Promise<JsonResponse> { + const result = await findComment(ctx, targetDid, repoName, commentId); + if ('status' in result) { return result; } + + const id = parseInt(reactionId, 10); + const reaction = reactionEntries(result.comment).find(entry => entry.id === id); + if (!reaction) { + return jsonNotFound(`Reaction #${reactionId} not found on commit comment #${commentId}.`); + } + + const updatedComment = commentWithReaction(result.comment, reactionKey(reaction.id), null); + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + settingsWithComment(result.settingsLookup.settings, result.key, updatedComment), + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/commits/:commit_sha/comments +// --------------------------------------------------------------------------- + +export async function handleListCommitCommentsForSha( + ctx: AgentContext, targetDid: string, repoName: string, sha: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const comments = commentEntries(lookup.settings).filter(comment => comment.commitId === sha); + const paged = paginate(comments, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/commits/${sha}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(comment => buildCommitCommentResponse(comment, targetDid, lookup.repo.name, baseUrl, bodyMediaKind)), + extraHeaders, + ); +} + +export async function handleCreateCommitComment( + ctx: AgentContext, targetDid: string, repoName: string, sha: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const body = bodyParam(reqBody.body); + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const now = new Date().toISOString(); + const id = nextCommitCommentId(lookup.settings); + const comment: CommitCommentEntry = { + id, + body, + commitId : sha, + path : stringParam(reqBody.path), + position : numberParam(reqBody.position), + line : numberParam(reqBody.line), + userDid : ctx.did, + createdAt : now, + updatedAt : now, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithComment(lookup.settings, commentKey(id), comment), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildCommitCommentResponse(comment, targetDid, lookup.repo.name, buildApiUrl(url), bodyMediaKind)); +} diff --git a/src/github-shim/contents.ts b/src/github-shim/contents.ts new file mode 100644 index 0000000..9388eb5 --- /dev/null +++ b/src/github-shim/contents.ts @@ -0,0 +1,319 @@ +/** + * GitHub API shim — repository README, license, and contents endpoints. + * + * Maps repo-scoped DWN metadata records (`repo/readme`, `repo/license`) to + * GitHub REST API v3 content responses. This is intentionally limited to + * metadata files that gitd stores as DWN records; full git tree browsing still + * belongs to the git transport layer. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; + +import { renderMarkdownText } from './meta.js'; +import { + binaryOk, + buildApiUrl, + fromOpt, + getRepoRecord, + jsonNotFound, + jsonOk, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ContentResource = { + protocolPath : 'repo/readme' | 'repo/license'; + name : string; + path : string; +}; + +type TextRecord = { + text : string; +}; + +export type ContentMediaKind = 'raw' | 'html' | 'object'; + +// --------------------------------------------------------------------------- +// Resource mapping +// --------------------------------------------------------------------------- + +const README_RESOURCE: ContentResource = { + protocolPath : 'repo/readme', + name : 'README.md', + path : 'README.md', +}; + +const LICENSE_RESOURCE: ContentResource = { + protocolPath : 'repo/license', + name : 'LICENSE', + path : 'LICENSE', +}; + +function resourceForPath(path: string): ContentResource | null { + const normalized = path.replace(/^\/+/, '').toLowerCase(); + if (normalized === 'readme' || normalized === 'readme.md') { + return README_RESOURCE; + } + if (normalized === 'license' || normalized === 'license.md' || normalized === 'license.txt') { + return LICENSE_RESOURCE; + } + return null; +} + +function decodeContentPath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +function encodeContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + +// --------------------------------------------------------------------------- +// DWN reads +// --------------------------------------------------------------------------- + +async function readTextRecord( + ctx: AgentContext, targetDid: string, repo: RepoInfo, resource: ContentResource, +): Promise<TextRecord | null> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query(resource.protocolPath as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const record = records[0]; + if (!record) { return null; } + + const blob = await record.data.blob(); + return { text: await blob.text() }; +} + +// --------------------------------------------------------------------------- +// Response builders +// --------------------------------------------------------------------------- + +function contentSha(text: string): string { + return createHash('sha1').update(Buffer.from(text, 'utf-8')).digest('hex'); +} + +function contentSize(text: string): number { + return Buffer.byteLength(text, 'utf-8'); +} + +function rawContentType(path: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) { return 'text/markdown; charset=utf-8'; } + if (lower.endsWith('.html') || lower.endsWith('.htm')) { return 'text/html; charset=utf-8'; } + if (lower.endsWith('.css')) { return 'text/css; charset=utf-8'; } + if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) { return 'application/javascript; charset=utf-8'; } + if (lower.endsWith('.json')) { return 'application/json; charset=utf-8'; } + if (lower.endsWith('.ts') || lower.endsWith('.tsx')) { return 'application/typescript; charset=utf-8'; } + if (lower.endsWith('.txt') || lower === 'license') { return 'text/plain; charset=utf-8'; } + return 'application/octet-stream'; +} + +function renderContentHtml(path: string, text: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) { + return renderMarkdownText(text); + } + return `<pre>${text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>')}</pre>`; +} + +function buildContentUrls( + targetDid: string, repoName: string, baseUrl: string, path: string, sha: string, +): Record<string, string> { + const fullName = `${targetDid}/${repoName}`; + const encodedPath = encodeContentPath(path); + + return { + url : `${baseUrl}/repos/${fullName}/contents/${encodedPath}`, + html_url : `${baseUrl}/repos/${fullName}/blob/HEAD/${encodedPath}`, + git_url : `${baseUrl}/repos/${fullName}/git/blobs/${sha}`, + download_url : `${baseUrl}/repos/${fullName}/raw/HEAD/${encodedPath}`, + }; +} + +function buildDirectoryEntry( + resource: ContentResource, text: string, + targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const sha = contentSha(text); + const urls = buildContentUrls(targetDid, repoName, baseUrl, resource.path, sha); + + return { + name : resource.name, + path : resource.path, + sha, + size : contentSize(text), + type : 'file', + ...urls, + _links : { + self : urls.url, + git : urls.git_url, + html : urls.html_url, + }, + }; +} + +function buildFileResponse( + resource: ContentResource, text: string, + targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const buffer = Buffer.from(text, 'utf-8'); + const entry = buildDirectoryEntry(resource, text, targetDid, repoName, baseUrl); + + return { + ...entry, + content : buffer.toString('base64'), + encoding : 'base64', + }; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/readme[/:dir] +// --------------------------------------------------------------------------- + +export async function handleGetReadme( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, mediaKind?: ContentMediaKind | null, +): Promise<JsonResponse> { + return handleGetResource(ctx, targetDid, repoName, README_RESOURCE, url, mediaKind); +} + +export async function handleGetReadmeInDirectory( + ctx: AgentContext, targetDid: string, repoName: string, dir: string, +): Promise<JsonResponse> { + if (!await getRepoRecord(ctx, targetDid, repoName)) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + return jsonNotFound(`README not found in '${decodeContentPath(dir)}'.`); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/license +// --------------------------------------------------------------------------- + +export async function handleGetLicense( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, mediaKind?: ContentMediaKind | null, +): Promise<JsonResponse> { + const res = await getResourceResponse(ctx, targetDid, repoName, LICENSE_RESOURCE, url, mediaKind); + if (res.status !== 200 || mediaKind === 'raw' || mediaKind === 'html') { return res; } + + return { + ...res, + body: JSON.stringify({ + ...JSON.parse(res.body as string) as Record<string, unknown>, + license: null, + }), + }; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/contents[/path] +// --------------------------------------------------------------------------- + +export async function handleGetContents( + ctx: AgentContext, targetDid: string, repoName: string, path: string | null, url: URL, mediaKind?: ContentMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + + if (!path) { + const entries: Record<string, unknown>[] = []; + for (const resource of [README_RESOURCE, LICENSE_RESOURCE]) { + const record = await readTextRecord(ctx, targetDid, repo, resource); + if (record) { + entries.push(buildDirectoryEntry(resource, record.text, targetDid, repo.name, baseUrl)); + } + } + return jsonOk(mediaKind === 'object' ? { entries } : entries); + } + + const decodedPath = decodeContentPath(path); + const resource = resourceForPath(decodedPath); + if (!resource) { + return jsonNotFound(`Content '${decodedPath}' not found in DWN repo metadata.`); + } + + return handleGetResource(ctx, targetDid, repoName, resource, url, mediaKind); +} + +export async function handleGetRawContent( + ctx: AgentContext, targetDid: string, repoName: string, rawRef: string, path: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const ref = decodeContentPath(rawRef).replace(/^\/+/, ''); + if (ref && ref !== 'HEAD' && ref !== repo.defaultBranch) { + return jsonNotFound(`Git ref '${ref}' not found.`); + } + + const decodedPath = decodeContentPath(path); + const resource = resourceForPath(decodedPath); + if (!resource) { + return jsonNotFound(`Content '${decodedPath}' not found.`); + } + + const record = await readTextRecord(ctx, targetDid, repo, resource); + if (!record) { + return jsonNotFound(`Content '${resource.path}' not found.`); + } + + return binaryOk(Buffer.from(record.text, 'utf-8'), rawContentType(resource.path)); +} + +async function handleGetResource( + ctx: AgentContext, targetDid: string, repoName: string, + resource: ContentResource, url: URL, mediaKind?: ContentMediaKind | null, +): Promise<JsonResponse> { + return getResourceResponse(ctx, targetDid, repoName, resource, url, mediaKind); +} + +async function getResourceResponse( + ctx: AgentContext, targetDid: string, repoName: string, + resource: ContentResource, url: URL, mediaKind?: ContentMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const textRecord = await readTextRecord(ctx, targetDid, repo, resource); + if (!textRecord) { + return jsonNotFound(`${resource.name} not found.`); + } + + if (mediaKind === 'raw') { + return binaryOk(Buffer.from(textRecord.text, 'utf-8'), rawContentType(resource.path)); + } + + if (mediaKind === 'html') { + return binaryOk(Buffer.from(renderContentHtml(resource.path, textRecord.text), 'utf-8'), 'text/html; charset=utf-8'); + } + + const baseUrl = buildApiUrl(url); + return jsonOk(buildFileResponse(resource, textRecord.text, targetDid, repo.name, baseUrl)); +} diff --git a/src/github-shim/dependency-graph.ts b/src/github-shim/dependency-graph.ts new file mode 100644 index 0000000..3f3ea99 --- /dev/null +++ b/src/github-shim/dependency-graph.ts @@ -0,0 +1,152 @@ +/** + * GitHub API shim — dependency graph endpoints. + * + * Provides a repository-scoped SPDX SBOM export so clients that expect + * GitHub's dependency graph API can discover a stable software bill of + * materials for a gitd repository. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { + baseHeaders, + buildApiUrl, + getRepoRecord, + jsonCreated, + jsonNotFound, + jsonOk, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stableSbomReportId(targetDid: string, repoName: string): string { + const hex = createHash('sha256').update(`dependency-graph:sbom:${targetDid}/${repoName}`).digest('hex'); + const variant = ((parseInt(hex[16] ?? '8', 16) & 0x3) | 0x8).toString(16); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + `4${hex.slice(13, 16)}`, + `${variant}${hex.slice(17, 20)}`, + hex.slice(20, 32), + ].join('-'); +} + +function repoApiPath(baseUrl: string, targetDid: string, repoName: string): string { + return `${baseUrl}/repos/${targetDid}/${repoName}`; +} + +function packageUrl(targetDid: string, repo: RepoInfo): string { + const owner = encodeURIComponent(targetDid); + const name = encodeURIComponent(repo.name); + const version = encodeURIComponent(repo.defaultBranch || 'HEAD'); + return `pkg:generic/${owner}/${name}@${version}`; +} + +function buildRepositorySbom(targetDid: string, repo: RepoInfo, baseUrl: string): Record<string, unknown> { + const repoUrl = repoApiPath(baseUrl, targetDid, repo.name); + const reportId = stableSbomReportId(targetDid, repo.name); + + return { + sbom: { + SPDXID : 'SPDXRef-DOCUMENT', + spdxVersion : 'SPDX-2.3', + creationInfo : { + created : toISODate(repo.timestamp || repo.dateCreated), + creators : ['Tool: gitd-github-shim'], + }, + name : `${targetDid}/${repo.name}`, + dataLicense : 'CC0-1.0', + documentNamespace : `${repoUrl}/dependency-graph/sbom/${reportId}`, + packages : [{ + name : `${targetDid}/${repo.name}`, + SPDXID : 'SPDXRef-Repository', + versionInfo : repo.defaultBranch || 'HEAD', + downloadLocation : repoUrl, + filesAnalyzed : false, + supplier : 'NOASSERTION', + primaryPackagePurpose : 'SOURCE', + externalRefs : [{ + referenceCategory : 'PACKAGE-MANAGER', + referenceType : 'purl', + referenceLocator : packageUrl(targetDid, repo), + }], + }], + relationships: [{ + spdxElementId : 'SPDXRef-DOCUMENT', + relationshipType : 'DESCRIBES', + relatedSpdxElement : 'SPDXRef-Repository', + }], + }, + }; +} + +async function getRepoOrNotFound( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoInfo | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + return repo; +} + +function redirectFound(location: string): JsonResponse { + return { + status : 302, + headers : { + ...baseHeaders(), + Location : location, + 'Content-Length' : '0', + 'X-GitHub-Request' : 'dependency-graph-sbom', + }, + body: '', + }; +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleExportDependencyGraphSbom( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOrNotFound(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + return jsonOk(buildRepositorySbom(targetDid, repo, buildApiUrl(url))); +} + +export async function handleGenerateDependencyGraphSbom( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOrNotFound(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const baseUrl = buildApiUrl(url); + const reportId = stableSbomReportId(targetDid, repo.name); + const sbomUrl = `${repoApiPath(baseUrl, targetDid, repo.name)}/dependency-graph/sbom/fetch-report/${reportId}`; + + return jsonCreated({ sbom_url: sbomUrl }, { Location: sbomUrl }); +} + +export async function handleFetchDependencyGraphSbom( + ctx: AgentContext, targetDid: string, repoName: string, sbomUuid: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOrNotFound(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + if (sbomUuid !== stableSbomReportId(targetDid, repo.name)) { + return jsonNotFound(`SBOM report '${sbomUuid}' not found for repository '${repo.name}'.`); + } + + const location = `${repoApiPath(buildApiUrl(url), targetDid, repo.name)}/dependency-graph/sbom?download=1`; + return redirectFound(location); +} diff --git a/src/github-shim/deployments.ts b/src/github-shim/deployments.ts new file mode 100644 index 0000000..d1e9939 --- /dev/null +++ b/src/github-shim/deployments.ts @@ -0,0 +1,877 @@ +/** + * GitHub API shim — deployment endpoints. + * + * Stores GitHub-compatible deployment metadata in the repo settings record. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + getRepoRecord, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type DeploymentState = 'error' | 'failure' | 'inactive' | 'in_progress' | 'queued' | 'pending' | 'success'; + +type DeploymentStatusEntry = { + id : number; + state : DeploymentState; + targetUrl : string; + logUrl : string; + description : string; + environment : string; + environmentUrl : string; + creatorDid : string; + createdAt : string; + updatedAt : string; +}; + +type DeploymentEntry = { + id : number; + sha : string; + ref : string; + task : string; + payload : unknown; + originalEnvironment : string; + environment : string; + description : string | null; + creatorDid : string; + transientEnvironment : boolean; + productionEnvironment : boolean; + createdAt : string; + updatedAt : string; + statuses? : Record<string, DeploymentStatusEntry>; +}; + +type DeploymentReviewerEntry = { + type : 'User' | 'Team'; + id : number; +}; + +type DeploymentBranchPolicyEntry = { + protectedBranches : boolean; + customBranchPolicies : boolean; +}; + +type ActionsVariableEntry = { + name : string; + value : string; + createdAt : string; + updatedAt : string; +}; + +type ActionsSecretEntry = { + name : string; + encryptedValue : string; + keyId : string; + createdAt : string; + updatedAt : string; +}; + +type EnvironmentEntry = { + id : number; + name : string; + createdAt : string; + updatedAt : string; + waitTimer? : number; + preventSelfReview? : boolean; + reviewers? : DeploymentReviewerEntry[]; + deploymentBranchPolicy? : DeploymentBranchPolicyEntry | null; + variables? : Record<string, ActionsVariableEntry>; + secrets? : Record<string, ActionsSecretEntry>; +}; + +type RepoSettingsData = { + branchProtection? : Record<string, unknown>; + labels? : Record<string, unknown>; + milestones? : Record<string, unknown>; + deployments? : Record<string, DeploymentEntry>; + environments? : Record<string, EnvironmentEntry>; + mergeStrategies? : string[]; + autoDeleteBranch? : boolean; +}; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +type DeploymentLookup = { + repo : RepoInfo; + settingsLookup : RepoSettingsLookup; + deployment : DeploymentEntry; + key : string; +}; + +const DEPLOYMENT_STATES = new Set<string>([ + 'error', + 'failure', + 'inactive', + 'in_progress', + 'queued', + 'pending', + 'success', +]); + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const { records } = await ctx.repo.records.query('repo/settings' as any, { + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function deploymentEntries(settings: RepoSettingsData): DeploymentEntry[] { + return Object.values(settings.deployments ?? {}) + .filter((entry): entry is DeploymentEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id - a.id); +} + +function deploymentKey(id: number): string { + return String(id); +} + +function nextDeploymentId(settings: RepoSettingsData): number { + return deploymentEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function nextDeploymentStatusId(deployment: DeploymentEntry): number { + return Object.values(deployment.statuses ?? {}).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function stringParam(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function booleanParam(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function productionDefault(environment: string, value: unknown): boolean { + if (typeof value === 'boolean') { + return value; + } + return environment === 'production'; +} + +function normalizePayload(value: unknown): unknown { + if (typeof value === 'undefined') { + return {}; + } + return value; +} + +function buildDeploymentResponse( + deployment: DeploymentEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const base = `${baseUrl}/repos/${targetDid}/${repoName}`; + return { + url : `${base}/deployments/${deployment.id}`, + id : deployment.id, + node_id : `deployment:${deployment.id}`, + sha : deployment.sha, + ref : deployment.ref, + task : deployment.task, + payload : deployment.payload, + original_environment : deployment.originalEnvironment, + environment : deployment.environment, + description : deployment.description, + creator : buildOwner(deployment.creatorDid, baseUrl), + created_at : toISODate(deployment.createdAt), + updated_at : toISODate(deployment.updatedAt), + statuses_url : `${base}/deployments/${deployment.id}/statuses`, + repository_url : base, + transient_environment : deployment.transientEnvironment, + production_environment : deployment.productionEnvironment, + }; +} + +function buildDeploymentStatusResponse( + status: DeploymentStatusEntry, deployment: DeploymentEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const base = `${baseUrl}/repos/${targetDid}/${repoName}`; + return { + url : `${base}/deployments/${deployment.id}/statuses/${status.id}`, + id : status.id, + node_id : `deployment-status:${status.id}`, + state : status.state, + creator : buildOwner(status.creatorDid, baseUrl), + description : status.description, + environment : status.environment, + target_url : status.targetUrl, + created_at : toISODate(status.createdAt), + updated_at : toISODate(status.updatedAt), + deployment_url : `${base}/deployments/${deployment.id}`, + repository_url : base, + environment_url : status.environmentUrl, + log_url : status.logUrl, + }; +} + +async function findDeployment( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, +): Promise<DeploymentLookup | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const id = parseInt(deploymentId, 10); + const key = deploymentKey(id); + const deployment = lookup.settings.deployments?.[key]; + if (!deployment) { + return jsonNotFound(`Deployment ${deploymentId} not found.`); + } + + return { + repo : lookup.repo, + settingsLookup : lookup, + deployment, + key, + }; +} + +function filteredDeployments(entries: DeploymentEntry[], url: URL): DeploymentEntry[] { + const sha = url.searchParams.get('sha'); + const ref = url.searchParams.get('ref'); + const task = url.searchParams.get('task'); + const environment = url.searchParams.get('environment'); + + return entries.filter((entry) => { + if (sha && entry.sha !== sha) { return false; } + if (ref && entry.ref !== ref && entry.sha !== ref) { return false; } + if (task && entry.task !== task) { return false; } + if (environment && entry.environment !== environment) { return false; } + return true; + }); +} + +function statusesNewestFirst(deployment: DeploymentEntry): DeploymentStatusEntry[] { + return Object.values(deployment.statuses ?? {}) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id - a.id); +} + +function latestStatus(deployment: DeploymentEntry): DeploymentStatusEntry | undefined { + return statusesNewestFirst(deployment)[0]; +} + +function canDeleteDeployment(settings: RepoSettingsData, deployment: DeploymentEntry): boolean { + const deployments = deploymentEntries(settings); + if (deployments.length <= 1) { + return true; + } + return latestStatus(deployment)?.state === 'inactive'; +} + +function deploymentSettingsWithChange( + settings: RepoSettingsData, key: string, deployment: DeploymentEntry | null, +): RepoSettingsData { + const deployments = { ...(settings.deployments ?? {}) }; + if (deployment) { + deployments[key] = deployment; + } else { + delete deployments[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(deployments).length > 0) { + next.deployments = deployments; + } else { + delete next.deployments; + } + return next; +} + +function environmentEntries(settings: RepoSettingsData): EnvironmentEntry[] { + return Object.values(settings.environments ?? {}) + .filter((entry): entry is EnvironmentEntry => Boolean(entry) && Number.isInteger(entry.id) && typeof entry.name === 'string') + .sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id); +} + +function environmentKey(name: string): string { + return name.toLowerCase(); +} + +function nextEnvironmentId(settings: RepoSettingsData): number { + return environmentEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function environmentSettingsWithChange( + settings: RepoSettingsData, key: string, environment: EnvironmentEntry | null, +): RepoSettingsData { + const environments = { ...(settings.environments ?? {}) }; + if (environment) { + environments[key] = environment; + } else { + delete environments[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(environments).length > 0) { + next.environments = environments; + } else { + delete next.environments; + } + return next; +} + +function decodeEnvironmentName(environmentName: string): string | undefined { + try { + const decoded = decodeURIComponent(environmentName).trim(); + return decoded.length > 0 ? decoded : undefined; + } catch { + return undefined; + } +} + +function environmentApiPath(targetDid: string, repoName: string, environmentName: string): string { + return `/repos/${targetDid}/${repoName}/environments/${encodeURIComponent(environmentName)}`; +} + +function buildReviewerResponse(reviewer: DeploymentReviewerEntry, baseUrl: string): Record<string, unknown> { + if (reviewer.type === 'Team') { + const slug = `team-${reviewer.id}`; + return { + type : 'Team', + reviewer : { + id : reviewer.id, + node_id : `team:${reviewer.id}`, + url : `${baseUrl}/teams/${reviewer.id}`, + html_url : `${baseUrl}/teams/${reviewer.id}`, + name : slug, + slug, + description : null, + privacy : 'closed', + permission : 'push', + members_url : `${baseUrl}/teams/${reviewer.id}/members{/member}`, + repositories_url : `${baseUrl}/teams/${reviewer.id}/repos`, + parent : null, + }, + }; + } + + const login = `user-${reviewer.id}`; + return { + type : 'User', + reviewer : { + login : login, + id : reviewer.id, + node_id : `user:${reviewer.id}`, + avatar_url : `${baseUrl}/avatars/${reviewer.id}`, + gravatar_id : '', + url : `${baseUrl}/users/${login}`, + html_url : `${baseUrl}/users/${login}`, + followers_url : `${baseUrl}/users/${login}/followers`, + following_url : `${baseUrl}/users/${login}/following{/other_user}`, + gists_url : `${baseUrl}/users/${login}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${login}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${login}/subscriptions`, + organizations_url : `${baseUrl}/users/${login}/orgs`, + repos_url : `${baseUrl}/users/${login}/repos`, + events_url : `${baseUrl}/users/${login}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${login}/received_events`, + type : 'User', + site_admin : false, + }, + }; +} + +function buildProtectionRules(environment: EnvironmentEntry, baseUrl: string): Record<string, unknown>[] { + const rules: Record<string, unknown>[] = []; + + if (Number.isInteger(environment.waitTimer)) { + rules.push({ + id : environment.id * 100 + 1, + node_id : `environment-rule:${environment.id}:wait_timer`, + type : 'wait_timer', + wait_timer : environment.waitTimer, + }); + } + + if ((environment.reviewers?.length ?? 0) > 0 || typeof environment.preventSelfReview === 'boolean') { + rules.push({ + id : environment.id * 100 + 2, + node_id : `environment-rule:${environment.id}:required_reviewers`, + type : 'required_reviewers', + prevent_self_review : environment.preventSelfReview ?? false, + reviewers : (environment.reviewers ?? []).map(reviewer => buildReviewerResponse(reviewer, baseUrl)), + }); + } + + if (environment.deploymentBranchPolicy) { + rules.push({ + id : environment.id * 100 + 3, + node_id : `environment-rule:${environment.id}:branch_policy`, + type : 'branch_policy', + }); + } + + return rules; +} + +function buildEnvironmentResponse( + environment: EnvironmentEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const apiPath = environmentApiPath(targetDid, repoName, environment.name); + const activityLog = `${baseUrl}/repos/${targetDid}/${repoName}/deployments/activity_log`; + return { + id : environment.id, + node_id : `environment:${environment.id}`, + name : environment.name, + url : `${baseUrl}${apiPath}`, + html_url : `${activityLog}?environments_filter=${encodeURIComponent(environment.name)}`, + created_at : toISODate(environment.createdAt), + updated_at : toISODate(environment.updatedAt), + protection_rules : buildProtectionRules(environment, baseUrl), + deployment_branch_policy : environment.deploymentBranchPolicy + ? { + protected_branches : environment.deploymentBranchPolicy.protectedBranches, + custom_branch_policies : environment.deploymentBranchPolicy.customBranchPolicies, + } + : null, + }; +} + +function parseWaitTimer(value: unknown): number | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 43_200) { + return jsonValidationError('Validation Failed: wait_timer must be an integer between 0 and 43200.'); + } + return value as number; +} + +function parsePreventSelfReview(value: unknown): boolean | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'boolean') { + return jsonValidationError('Validation Failed: prevent_self_review must be a boolean.'); + } + return value; +} + +function parseReviewers(value: unknown): DeploymentReviewerEntry[] | null | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (value === null) { + return null; + } + if (!Array.isArray(value) || value.length > 6) { + return jsonValidationError('Validation Failed: reviewers must be an array with no more than 6 users or teams.'); + } + + const reviewers: DeploymentReviewerEntry[] = []; + for (const reviewer of value) { + if ( + typeof reviewer !== 'object' + || reviewer === null + || (reviewer as { type?: unknown }).type !== 'User' && (reviewer as { type?: unknown }).type !== 'Team' + || !Number.isInteger((reviewer as { id?: unknown }).id) + ) { + return jsonValidationError('Validation Failed: reviewers must contain objects with type User or Team and integer id.'); + } + + reviewers.push({ + type : (reviewer as { type: 'User' | 'Team' }).type, + id : (reviewer as { id: number }).id, + }); + } + return reviewers; +} + +function parseDeploymentBranchPolicy(value: unknown): DeploymentBranchPolicyEntry | null | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (value === null) { + return null; + } + if (typeof value !== 'object' || value === null) { + return jsonValidationError('Validation Failed: deployment_branch_policy must be an object or null.'); + } + + const protectedBranches = (value as { protected_branches?: unknown }).protected_branches; + const customBranchPolicies = (value as { custom_branch_policies?: unknown }).custom_branch_policies; + if (typeof protectedBranches !== 'boolean' || typeof customBranchPolicies !== 'boolean') { + return jsonValidationError('Validation Failed: deployment_branch_policy requires protected_branches and custom_branch_policies booleans.'); + } + if (protectedBranches === customBranchPolicies) { + return jsonValidationError('Validation Failed: protected_branches and custom_branch_policies cannot have the same value.'); + } + + return { + protectedBranches, + customBranchPolicies, + }; +} + +async function findEnvironment( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, +): Promise<{ lookup: RepoSettingsLookup; environment: EnvironmentEntry; key: string } | JsonResponse> { + const decodedName = decodeEnvironmentName(environmentName); + if (!decodedName) { + return jsonValidationError('Validation Failed: environment_name is invalid.'); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = environmentKey(decodedName); + const environment = lookup.settings.environments?.[key]; + if (!environment) { + return jsonNotFound(`Environment '${decodedName}' not found.`); + } + + return { lookup, environment, key }; +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/environments +// --------------------------------------------------------------------------- + +export async function handleListEnvironments( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const environments = environmentEntries(lookup.settings); + const paged = paginate(environments, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/environments`, + pagination.page, pagination.perPage, environments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk({ + total_count : environments.length, + environments : paged.map(environment => buildEnvironmentResponse(environment, targetDid, lookup.repo.name, baseUrl)), + }, extraHeaders); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/environments/:environment_name +// --------------------------------------------------------------------------- + +export async function handleGetEnvironment( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, url: URL, +): Promise<JsonResponse> { + const result = await findEnvironment(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + return jsonOk(buildEnvironmentResponse(result.environment, targetDid, result.lookup.repo.name, buildApiUrl(url))); +} + +export async function handleCreateOrUpdateEnvironment( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const decodedName = decodeEnvironmentName(environmentName); + if (!decodedName) { + return jsonValidationError('Validation Failed: environment_name is invalid.'); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const waitTimer = parseWaitTimer(reqBody.wait_timer); + if (typeof waitTimer === 'object') { return waitTimer; } + + const preventSelfReview = parsePreventSelfReview(reqBody.prevent_self_review); + if (typeof preventSelfReview === 'object') { return preventSelfReview; } + + const reviewers = parseReviewers(reqBody.reviewers); + if (typeof reviewers === 'object' && reviewers !== null && 'status' in reviewers) { return reviewers; } + + const deploymentBranchPolicy = parseDeploymentBranchPolicy(reqBody.deployment_branch_policy); + if (typeof deploymentBranchPolicy === 'object' && deploymentBranchPolicy !== null && 'status' in deploymentBranchPolicy) { + return deploymentBranchPolicy; + } + + const key = environmentKey(decodedName); + const existing = lookup.settings.environments?.[key]; + const now = new Date().toISOString(); + const environment: EnvironmentEntry = { + id : existing?.id ?? nextEnvironmentId(lookup.settings), + name : decodedName, + createdAt : existing?.createdAt ?? now, + updatedAt : now, + waitTimer : waitTimer ?? existing?.waitTimer, + preventSelfReview : preventSelfReview ?? existing?.preventSelfReview, + reviewers : reviewers === null ? undefined : reviewers ?? existing?.reviewers, + deploymentBranchPolicy : deploymentBranchPolicy === undefined + ? existing?.deploymentBranchPolicy ?? null + : deploymentBranchPolicy, + variables : existing?.variables, + secrets : existing?.secrets, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + environmentSettingsWithChange(lookup.settings, key, environment), + ); + if (saveError) { return saveError; } + + return jsonOk(buildEnvironmentResponse(environment, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleDeleteEnvironment( + ctx: AgentContext, targetDid: string, repoName: string, environmentName: string, +): Promise<JsonResponse> { + const result = await findEnvironment(ctx, targetDid, repoName, environmentName); + if ('status' in result) { return result; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + environmentSettingsWithChange(result.lookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/deployments +// --------------------------------------------------------------------------- + +export async function handleListDeployments( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const deployments = filteredDeployments(deploymentEntries(lookup.settings), url); + const paged = paginate(deployments, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/deployments`, + pagination.page, pagination.perPage, deployments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(deployment => buildDeploymentResponse(deployment, targetDid, lookup.repo.name, baseUrl)), + extraHeaders, + ); +} + +export async function handleCreateDeployment( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const ref = stringParam(reqBody.ref).trim(); + if (!ref) { + return jsonValidationError('Validation Failed: ref is required.'); + } + + const now = new Date().toISOString(); + const environment = stringParam(reqBody.environment, 'production').trim() || 'production'; + const id = nextDeploymentId(lookup.settings); + const deployment: DeploymentEntry = { + id, + sha : stringParam(reqBody.sha, ref), + ref, + task : stringParam(reqBody.task, 'deploy') || 'deploy', + payload : normalizePayload(reqBody.payload), + originalEnvironment : environment, + environment, + description : reqBody.description === null ? null : stringParam(reqBody.description), + creatorDid : ctx.did, + transientEnvironment : booleanParam(reqBody.transient_environment, false), + productionEnvironment : productionDefault(environment, reqBody.production_environment), + createdAt : now, + updatedAt : now, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + deploymentSettingsWithChange(lookup.settings, deploymentKey(id), deployment), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildDeploymentResponse(deployment, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/deployments/:id +// --------------------------------------------------------------------------- + +export async function handleGetDeployment( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, url: URL, +): Promise<JsonResponse> { + const result = await findDeployment(ctx, targetDid, repoName, deploymentId); + if ('status' in result) { return result; } + return jsonOk(buildDeploymentResponse(result.deployment, targetDid, result.repo.name, buildApiUrl(url))); +} + +export async function handleDeleteDeployment( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, +): Promise<JsonResponse> { + const result = await findDeployment(ctx, targetDid, repoName, deploymentId); + if ('status' in result) { return result; } + + if (!canDeleteDeployment(result.settingsLookup.settings, result.deployment)) { + return jsonValidationError('Validation Failed: active deployment cannot be deleted while other deployments exist.'); + } + + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + deploymentSettingsWithChange(result.settingsLookup.settings, result.key, null), + ); + if (saveError) { return saveError; } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/deployments/:id/statuses +// --------------------------------------------------------------------------- + +export async function handleListDeploymentStatuses( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, url: URL, +): Promise<JsonResponse> { + const result = await findDeployment(ctx, targetDid, repoName, deploymentId); + if ('status' in result) { return result; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const statuses = statusesNewestFirst(result.deployment); + const paged = paginate(statuses, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${result.repo.name}/deployments/${result.deployment.id}/statuses`, + pagination.page, pagination.perPage, statuses.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(status => buildDeploymentStatusResponse(status, result.deployment, targetDid, result.repo.name, baseUrl)), + extraHeaders, + ); +} + +export async function handleCreateDeploymentStatus( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await findDeployment(ctx, targetDid, repoName, deploymentId); + if ('status' in result) { return result; } + + const state = stringParam(reqBody.state); + if (!DEPLOYMENT_STATES.has(state)) { + return jsonValidationError('Validation Failed: state must be one of error, failure, inactive, in_progress, queued, pending, success.'); + } + + const now = new Date().toISOString(); + const statusId = nextDeploymentStatusId(result.deployment); + const environment = stringParam(reqBody.environment, latestStatus(result.deployment)?.environment ?? result.deployment.environment) + || result.deployment.environment; + const logUrl = stringParam(reqBody.log_url, stringParam(reqBody.target_url)); + const targetUrl = logUrl || stringParam(reqBody.target_url); + const status: DeploymentStatusEntry = { + id : statusId, + state : state as DeploymentState, + targetUrl, + logUrl, + description : stringParam(reqBody.description), + environment, + environmentUrl : stringParam(reqBody.environment_url), + creatorDid : ctx.did, + createdAt : now, + updatedAt : now, + }; + + const updatedDeployment: DeploymentEntry = { + ...result.deployment, + environment : status.environment, + updatedAt : now, + statuses : { + ...(result.deployment.statuses ?? {}), + [deploymentKey(statusId)]: status, + }, + }; + + const saveError = await saveRepoSettings( + ctx, + result.settingsLookup, + deploymentSettingsWithChange(result.settingsLookup.settings, result.key, updatedDeployment), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildDeploymentStatusResponse(status, updatedDeployment, targetDid, result.repo.name, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/deployments/:id/statuses/:status_id +// --------------------------------------------------------------------------- + +export async function handleGetDeploymentStatus( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, statusId: string, url: URL, +): Promise<JsonResponse> { + const result = await findDeployment(ctx, targetDid, repoName, deploymentId); + if ('status' in result) { return result; } + + const status = result.deployment.statuses?.[deploymentKey(parseInt(statusId, 10))]; + if (!status) { + return jsonNotFound(`Deployment status ${statusId} not found.`); + } + + return jsonOk(buildDeploymentStatusResponse(status, result.deployment, targetDid, result.repo.name, buildApiUrl(url))); +} diff --git a/src/github-shim/emails.ts b/src/github-shim/emails.ts new file mode 100644 index 0000000..f8a359f --- /dev/null +++ b/src/github-shim/emails.ts @@ -0,0 +1,239 @@ +/** + * GitHub API shim - authenticated user email endpoints. + * + * Maps forge-social `email` records to GitHub REST API v3 email responses. + * Email records are account-owned metadata used by GitHub-compatible clients + * to discover commit identity and public profile email visibility. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { EmailData } from '../social.js'; +import type { JsonResponse } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + jsonCreated, + jsonNoContent, + jsonOk, + jsonValidationError, + paginate, + parsePagination, +} from './helpers.js'; + +type EmailEntry = { + record : any; + data : EmailData; +}; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeEmailAddress(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const email = value.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return null; } + return email; +} + +function normalizeVisibility(value: unknown): 'public' | 'private' | null | undefined { + if (value === 'public' || value === 'private' || value === null) { return value; } + return undefined; +} + +function normalizeEmailData(data: unknown, tags?: Record<string, unknown>): EmailData | null { + if (!isObject(data)) { return null; } + + const email = normalizeEmailAddress(typeof tags?.email === 'string' ? tags.email : data.email); + if (!email) { return null; } + + return { + email, + primary : data.primary === true, + verified : data.verified === true, + visibility : normalizeVisibility(data.visibility) ?? (data.primary === true ? 'private' : null), + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + }; +} + +async function normalizeEmailEntry(record: any): Promise<EmailEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeEmailData(await record.data.json(), tags); + if (!data) { return null; } + return { record, data }; +} + +async function listEmailEntries(ctx: AgentContext, userDid: string): Promise<EmailEntry[]> { + const { records } = await ctx.social.records.query('email' as any, { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: EmailEntry[] = []; + for (const record of records) { + const entry = await normalizeEmailEntry(record); + if (entry) { entries.push(entry); } + } + return entries; +} + +function buildEmailResponse(entry: EmailEntry): Record<string, unknown> { + return { + email : entry.data.email, + verified : entry.data.verified === true, + primary : entry.data.primary === true, + visibility : entry.data.visibility ?? null, + }; +} + +function parseEmailList(body: unknown): string[] | JsonResponse { + const value = isObject(body) && 'emails' in body ? body.emails : body; + const rawEmails = Array.isArray(value) ? value : [value]; + const emails = rawEmails.map(normalizeEmailAddress); + if (emails.length === 0 || emails.some(email => !email)) { + return jsonValidationError('Validation Failed: emails must contain at least one valid email address.'); + } + + const uniqueEmails = [...new Set(emails as string[])]; + if (uniqueEmails.length !== emails.length) { + return jsonValidationError('Validation Failed: duplicate email addresses are not allowed.'); + } + return uniqueEmails; +} + +function pagedEmails(entries: EmailEntry[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, entries.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildEmailResponse), extraHeaders); +} + +async function updatePrimaryVisibility(entries: EmailEntry[], visibility: 'public' | 'private'): Promise<JsonResponse | null> { + const primaryEntries = entries.filter(entry => entry.data.primary === true); + if (primaryEntries.length === 0) { + return jsonValidationError('Validation Failed: no primary email exists.'); + } + + for (const entry of primaryEntries) { + const nextData = { ...entry.data, visibility }; + const { status } = await entry.record.update({ data: nextData, tags: { email: entry.data.email } } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update email visibility: ${status.detail}`); + } + entry.data = nextData; + } + return null; +} + +// --------------------------------------------------------------------------- +// GET /user/emails +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedEmails(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = await listEmailEntries(ctx, ctx.did); + return pagedEmails(entries, url, '/user/emails'); +} + +// --------------------------------------------------------------------------- +// POST /user/emails +// --------------------------------------------------------------------------- + +export async function handleAddAuthenticatedEmails(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + const parsed = parseEmailList(body); + if (!Array.isArray(parsed)) { return parsed; } + + const existing = await listEmailEntries(ctx, ctx.did); + const existingEmails = new Set(existing.map(entry => entry.data.email)); + if (parsed.some(email => existingEmails.has(email))) { + return jsonValidationError('Validation Failed: email already exists.'); + } + + const hasPrimary = existing.some(entry => entry.data.primary === true); + const created: EmailEntry[] = []; + for (const [index, email] of parsed.entries()) { + const primary = !hasPrimary && index === 0; + const data: EmailData = { + email, + primary, + verified : false, + visibility : primary ? 'private' : null, + createdAt : new Date().toISOString(), + }; + const { record, status } = await ctx.social.records.create('email' as any, { + data, + tags: { email }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to add email address: ${status.detail}`); + } + + const entry = await normalizeEmailEntry(record); + if (entry) { created.push(entry); } + } + + return jsonCreated(created.map(buildEmailResponse)); +} + +// --------------------------------------------------------------------------- +// DELETE /user/emails +// --------------------------------------------------------------------------- + +export async function handleDeleteAuthenticatedEmails(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + const parsed = parseEmailList(body); + if (!Array.isArray(parsed)) { return parsed; } + + const entries = await listEmailEntries(ctx, ctx.did); + const entriesByEmail = new Map(entries.map(entry => [entry.data.email, entry])); + for (const email of parsed) { + const entry = entriesByEmail.get(email); + if (!entry) { + return jsonValidationError(`Validation Failed: email '${email}' is not associated with this account.`); + } + } + + for (const email of parsed) { + const entry = entriesByEmail.get(email); + if (!entry) { continue; } + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete email address: ${status.detail}`); + } + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// PATCH /user/email/visibility +// --------------------------------------------------------------------------- + +export async function handleSetPrimaryEmailVisibility(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + if (!isObject(body) || (body.visibility !== 'public' && body.visibility !== 'private')) { + return jsonValidationError('Validation Failed: visibility must be public or private.'); + } + + const entries = await listEmailEntries(ctx, ctx.did); + const error = await updatePrimaryVisibility(entries, body.visibility); + if (error) { return error; } + + return jsonOk(entries.map(buildEmailResponse)); +} + +// --------------------------------------------------------------------------- +// GET /user/public_emails +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedPublicEmails(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = await listEmailEntries(ctx, ctx.did); + return pagedEmails(entries.filter(entry => entry.data.visibility === 'public'), url, '/user/public_emails'); +} diff --git a/src/github-shim/follows.ts b/src/github-shim/follows.ts new file mode 100644 index 0000000..7af1091 --- /dev/null +++ b/src/github-shim/follows.ts @@ -0,0 +1,329 @@ +/** + * GitHub API shim - user follower endpoints. + * + * Maps forge-social `follow` records to GitHub REST API v3 follower + * responses. Follows live on the follower's DWN, so follower lists expose + * records readable to the local actor. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, +} from './helpers.js'; + +type FollowEntry = { + record : any; + followerDid : string; + targetDid : string; + alias? : string; +}; + +type BlockEntry = { + record : any; + blockerDid : string; + targetDid : string; +}; + +function buildUserResponse(did: string, baseUrl: string): Record<string, unknown> { + return { + ...buildOwner(did, baseUrl), + node_id : did, + gravatar_id : '', + followers_url : `${baseUrl}/users/${did}/followers`, + following_url : `${baseUrl}/users/${did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${did}/subscriptions`, + organizations_url : `${baseUrl}/users/${did}/orgs`, + repos_url : `${baseUrl}/users/${did}/repos`, + events_url : `${baseUrl}/users/${did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${did}/received_events`, + site_admin : false, + }; +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function routeDid(value: string): string { + return decodeRouteParam(value).trim(); +} + +function normalizeFollowEntry(record: any, fallbackFollowerDid: string): Promise<FollowEntry | null> { + return record.data.json().then((data: any) => { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + const targetDid = tags.targetDid ?? data.targetDid; + if (typeof targetDid !== 'string' || targetDid === '') { return null; } + + return { + record, + followerDid : typeof record.author === 'string' ? record.author : fallbackFollowerDid, + targetDid, + alias : typeof data.alias === 'string' ? data.alias : undefined, + }; + }); +} + +function normalizeBlockEntry(record: any, fallbackBlockerDid: string): Promise<BlockEntry | null> { + return record.data.json().then((data: any) => { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + const targetDid = tags.targetDid ?? data.targetDid; + if (typeof targetDid !== 'string' || targetDid === '') { return null; } + + return { + record, + blockerDid: typeof record.author === 'string' ? record.author : fallbackBlockerDid, + targetDid, + }; + }); +} + +async function listFollowingEntries(ctx: AgentContext, userDid: string): Promise<FollowEntry[]> { + const { records } = await ctx.social.records.query('follow', { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedDescending, + } as any); + + const entries: FollowEntry[] = []; + for (const record of records) { + const entry = await normalizeFollowEntry(record, userDid); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function listBlockedEntries(ctx: AgentContext, blockerDid: string): Promise<BlockEntry[]> { + const { records } = await ctx.social.records.query('block', { + from : fromOpt(ctx, blockerDid), + dateSort : DateSort.CreatedDescending, + } as any); + + const entries: BlockEntry[] = []; + for (const record of records) { + const entry = await normalizeBlockEntry(record, blockerDid); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function listVisibleFollowerEntries(ctx: AgentContext, targetDid: string): Promise<FollowEntry[]> { + const { records } = await ctx.social.records.query('follow', { + filter : { tags: { targetDid } }, + dateSort : DateSort.CreatedDescending, + } as any); + + const entries: FollowEntry[] = []; + for (const record of records) { + const entry = await normalizeFollowEntry(record, ctx.did); + if (entry?.targetDid === targetDid) { entries.push(entry); } + } + return entries; +} + +async function findFollow(ctx: AgentContext, followerDid: string, targetDid: string): Promise<FollowEntry | null> { + const { records } = await ctx.social.records.query('follow', { + from : fromOpt(ctx, followerDid), + filter : { tags: { targetDid } }, + } as any); + const record = records[0]; + if (!record) { return null; } + return normalizeFollowEntry(record, followerDid); +} + +async function findBlock(ctx: AgentContext, blockerDid: string, targetDid: string): Promise<BlockEntry | null> { + const { records } = await ctx.social.records.query('block', { + from : fromOpt(ctx, blockerDid), + filter : { tags: { targetDid } }, + } as any); + const record = records[0]; + if (!record) { return null; } + return normalizeBlockEntry(record, blockerDid); +} + +function uniqueDids(values: string[]): string[] { + return [...new Set(values)]; +} + +function pagedUsers(dids: string[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(dids, pagination); + const items = paged.map(did => buildUserResponse(did, baseUrl)); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, dids.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/followers and GET /users/:did/followers +// --------------------------------------------------------------------------- + +export async function handleListFollowers( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const entries = await listVisibleFollowerEntries(ctx, userDid); + const followers = uniqueDids(entries.map(entry => entry.followerDid)); + const path = userDid === ctx.did ? '/user/followers' : `/users/${userDid}/followers`; + return pagedUsers(followers, url, path); +} + +// --------------------------------------------------------------------------- +// GET /user/following and GET /users/:did/following +// --------------------------------------------------------------------------- + +export async function handleListFollowing( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const entries = await listFollowingEntries(ctx, userDid); + const following = uniqueDids(entries.map(entry => entry.targetDid)); + const path = userDid === ctx.did ? '/user/following' : `/users/${userDid}/following`; + return pagedUsers(following, url, path); +} + +// --------------------------------------------------------------------------- +// GET /user/blocks +// --------------------------------------------------------------------------- + +export async function handleListBlockedUsers( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + const entries = await listBlockedEntries(ctx, ctx.did); + const blocked = uniqueDids(entries.map(entry => entry.targetDid)); + return pagedUsers(blocked, url, '/user/blocks'); +} + +// --------------------------------------------------------------------------- +// GET /user/following/:did and GET /users/:did/following/:target_did +// --------------------------------------------------------------------------- + +export async function handleCheckFollowing( + ctx: AgentContext, followerDid: string, targetDid: string, +): Promise<JsonResponse> { + const follow = await findFollow(ctx, followerDid, targetDid); + if (!follow) { + return jsonNotFound(`User '${followerDid}' does not follow '${targetDid}'.`); + } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /user/blocks/:did +// --------------------------------------------------------------------------- + +export async function handleCheckBlockedUser( + ctx: AgentContext, targetDid: string, +): Promise<JsonResponse> { + const did = routeDid(targetDid); + if (!did) { return jsonNotFound('User not found.'); } + + const block = await findBlock(ctx, ctx.did, did); + if (!block) { + return jsonNotFound(`User '${ctx.did}' has not blocked '${did}'.`); + } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// PUT /user/following/:did +// --------------------------------------------------------------------------- + +export async function handleFollowUser(ctx: AgentContext, targetDid: string): Promise<JsonResponse> { + if (targetDid === ctx.did) { + return jsonValidationError('Validation Failed: users cannot follow themselves.'); + } + + const existing = await findFollow(ctx, ctx.did, targetDid); + if (existing) { return jsonNoContent(); } + + const { status } = await ctx.social.records.create('follow', { + data : { targetDid }, + tags : { targetDid }, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to follow user: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// PUT /user/blocks/:did +// --------------------------------------------------------------------------- + +export async function handleBlockUser(ctx: AgentContext, targetDid: string): Promise<JsonResponse> { + const did = routeDid(targetDid); + if (!did) { return jsonValidationError('Validation Failed: username is required.'); } + if (did === ctx.did) { + return jsonValidationError('Validation Failed: users cannot block themselves.'); + } + + const existing = await findBlock(ctx, ctx.did, did); + if (existing) { return jsonNoContent(); } + + const { status } = await ctx.social.records.create('block', { + data : { targetDid: did, blockedAt: new Date().toISOString() }, + tags : { targetDid: did }, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to block user: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// DELETE /user/following/:did +// --------------------------------------------------------------------------- + +export async function handleUnfollowUser(ctx: AgentContext, targetDid: string): Promise<JsonResponse> { + const existing = await findFollow(ctx, ctx.did, targetDid); + if (!existing) { return jsonNoContent(); } + + const { status } = await existing.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to unfollow user: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// DELETE /user/blocks/:did +// --------------------------------------------------------------------------- + +export async function handleUnblockUser(ctx: AgentContext, targetDid: string): Promise<JsonResponse> { + const did = routeDid(targetDid); + if (!did) { return jsonNoContent(); } + + const existing = await findBlock(ctx, ctx.did, did); + if (!existing) { return jsonNoContent(); } + + const { status } = await existing.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to unblock user: ${status.detail}`); + } + + return jsonNoContent(); +} diff --git a/src/github-shim/gists.ts b/src/github-shim/gists.ts new file mode 100644 index 0000000..7a74f85 --- /dev/null +++ b/src/github-shim/gists.ts @@ -0,0 +1,1048 @@ +/** + * GitHub API shim — Gist endpoints. + * + * Stores GitHub-compatible gists as forge-social `gist` records owned by a DID. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; +import type { GistCommentData, GistData, GistFileData, GistStarData } from '../social.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { collectVisibleUserDids } from './users.js'; +import { + binaryOk, + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types and constants +// --------------------------------------------------------------------------- + +type GistEntry = { + record : any; + ownerDid : string; + data : GistData; +}; + +type GistCommentEntry = { + record : any; + ownerDid : string; + data : GistCommentData; +}; + +type GistStarEntry = { + record : any; + data : GistStarData; +}; + +type ParsedGistFileChange = { + filename? : string; + content? : string; +}; + +const TEXT_ENCODER = new TextEncoder(); + +const FILE_TYPES: Record<string, { type: string; language: string | null }> = { + '.css' : { type: 'text/css', language: 'CSS' }, + '.html' : { type: 'text/html', language: 'HTML' }, + '.js' : { type: 'application/javascript', language: 'JavaScript' }, + '.json' : { type: 'application/json', language: 'JSON' }, + '.md' : { type: 'text/markdown', language: 'Markdown' }, + '.py' : { type: 'text/x-python', language: 'Python' }, + '.rb' : { type: 'application/x-ruby', language: 'Ruby' }, + '.rs' : { type: 'text/rust', language: 'Rust' }, + '.ts' : { type: 'application/typescript', language: 'TypeScript' }, + '.txt' : { type: 'text/plain', language: null }, +}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isJsonResponse(value: unknown): value is JsonResponse { + return isObject(value) && typeof value.status === 'number' && isObject(value.headers); +} + +function fileExtension(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.slice(dot).toLowerCase() : ''; +} + +function inferFileType(filename: string): { type: string; language: string | null } { + return FILE_TYPES[fileExtension(filename)] ?? { type: 'text/plain', language: null }; +} + +function rawFileContentType(file: GistFileData): string { + const inferred = inferFileType(file.filename); + const type = file.type ?? inferred.type; + const utf8Type = type.startsWith('text/') + || type === 'application/javascript' + || type === 'application/json' + || type === 'application/typescript' + || type === 'application/x-ruby'; + return utf8Type + ? `${type}; charset=utf-8` + : type; +} + +function normalizeFilename(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const filename = value.trim(); + if (!filename || filename.includes('\0') || filename.startsWith('/')) { return null; } + return filename; +} + +function normalizePublicFlag(value: unknown): boolean { + if (value === true || value === 'true') { return true; } + if (value === false || value === 'false') { return false; } + return false; +} + +function normalizeStoredFile(key: string, value: unknown): GistFileData | null { + if (!isObject(value)) { return null; } + + const filename = normalizeFilename(value.filename) ?? normalizeFilename(key); + if (!filename || typeof value.content !== 'string') { return null; } + const inferred = inferFileType(filename); + return { + filename, + content : value.content, + type : typeof value.type === 'string' ? value.type : inferred.type, + language : typeof value.language === 'string' || value.language === null ? value.language : inferred.language, + }; +} + +function normalizeGistData(data: unknown, tags?: Record<string, unknown>): GistData | null { + if (!isObject(data) || !isObject(data.files)) { return null; } + + const files: Record<string, GistFileData> = {}; + for (const [key, value] of Object.entries(data.files)) { + const file = normalizeStoredFile(key, value); + if (file) { files[file.filename] = file; } + } + if (Object.keys(files).length === 0) { return null; } + + const forkOfOwnerDid = typeof tags?.forkOfOwnerDid === 'string' + ? tags.forkOfOwnerDid + : typeof data.forkOfOwnerDid === 'string' ? data.forkOfOwnerDid : undefined; + const forkOfGistId = typeof tags?.forkOfGistId === 'string' + ? tags.forkOfGistId + : typeof data.forkOfGistId === 'string' ? data.forkOfGistId : undefined; + + return { + description : typeof data.description === 'string' ? data.description : '', + public : tags?.visibility === 'public' || data.public === true, + files, + forkOfOwnerDid, + forkOfGistId, + forkedAt : typeof data.forkedAt === 'string' ? data.forkedAt : undefined, + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + updatedAt : typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + }; +} + +async function normalizeGistEntry(record: any, ownerDid: string): Promise<GistEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeGistData(await record.data.json(), tags); + if (!data) { return null; } + return { record, ownerDid, data }; +} + +async function listGistEntries(ctx: AgentContext, ownerDid: string, includeSecret: boolean): Promise<GistEntry[]> { + let records: any[]; + try { + const result = await ctx.social.records.query('gist' as any, { + from : fromOpt(ctx, ownerDid), + dateSort : DateSort.CreatedDescending, + } as any); + records = result.records; + } catch { + return []; + } + + const entries: GistEntry[] = []; + for (const record of records) { + const entry = await normalizeGistEntry(record, ownerDid); + if (entry && (includeSecret || entry.data.public !== false)) { + entries.push(entry); + } + } + return entries.sort((left, right) => gistUpdatedMs(right) - gistUpdatedMs(left)); +} + +async function listVisiblePublicGists(ctx: AgentContext): Promise<GistEntry[]> { + const dids = await collectVisibleUserDids(ctx); + const entries: GistEntry[] = []; + for (const did of dids) { + entries.push(...await listGistEntries(ctx, did, false)); + } + return entries.sort((left, right) => gistUpdatedMs(right) - gistUpdatedMs(left)); +} + +async function findGistById(ctx: AgentContext, gistId: string, localOnly = false): Promise<GistEntry | null> { + const dids = localOnly ? [ctx.did] : await collectVisibleUserDids(ctx); + for (const did of dids) { + const entry = (await listGistEntries(ctx, did, true)).find(gist => gist.record.id === gistId); + if (entry) { return entry; } + } + return null; +} + +async function listGistForkEntries(ctx: AgentContext, source: GistEntry): Promise<GistEntry[]> { + const dids = await collectVisibleUserDids(ctx); + const entries: GistEntry[] = []; + for (const did of dids) { + const forks = (await listGistEntries(ctx, did, true)).filter(gist => + gist.data.forkOfGistId === source.record.id && gist.data.forkOfOwnerDid === source.ownerDid); + entries.push(...forks); + } + return entries.sort((left, right) => gistCreatedMs(left) - gistCreatedMs(right)); +} + +async function findLocalGistFork(ctx: AgentContext, source: GistEntry): Promise<GistEntry | null> { + return (await listGistEntries(ctx, ctx.did, true)).find(gist => + gist.data.forkOfGistId === source.record.id && gist.data.forkOfOwnerDid === source.ownerDid) ?? null; +} + +function gistCreatedAt(entry: GistEntry): string { + return toISODate(entry.data.createdAt ?? entry.record.dateCreated); +} + +function gistCreatedMs(entry: GistEntry): number { + return Date.parse(gistCreatedAt(entry)) || 0; +} + +function gistUpdatedAt(entry: GistEntry): string { + return toISODate(entry.data.updatedAt ?? entry.record.timestamp ?? entry.data.createdAt ?? entry.record.dateCreated); +} + +function gistUpdatedMs(entry: GistEntry): number { + return Date.parse(gistUpdatedAt(entry)) || 0; +} + +function applySinceFilter(entries: GistEntry[], url: URL): GistEntry[] { + const since = Date.parse(url.searchParams.get('since') ?? ''); + if (!Number.isFinite(since)) { return entries; } + return entries.filter(entry => gistUpdatedMs(entry) > since); +} + +function buildGistOwner(did: string, baseUrl: string): Record<string, unknown> { + return { + ...buildOwner(did, baseUrl), + node_id : did, + gravatar_id : '', + followers_url : `${baseUrl}/users/${did}/followers`, + following_url : `${baseUrl}/users/${did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${did}/subscriptions`, + organizations_url : `${baseUrl}/users/${did}/orgs`, + repos_url : `${baseUrl}/users/${did}/repos`, + events_url : `${baseUrl}/users/${did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${did}/received_events`, + site_admin : false, + }; +} + +function buildGistFileResponse( + entry: GistEntry, file: GistFileData, baseUrl: string, includeContent: boolean, +): Record<string, unknown> { + const inferred = inferFileType(file.filename); + const response: Record<string, unknown> = { + filename : file.filename, + type : file.type ?? inferred.type, + language : file.language ?? inferred.language, + raw_url : `${baseUrl}/gists/${entry.record.id}/raw/${encodeURIComponent(file.filename)}`, + size : TEXT_ENCODER.encode(file.content).byteLength, + }; + if (includeContent) { + response.truncated = false; + response.content = file.content; + response.encoding = 'utf-8'; + } + return response; +} + +function buildGistFilesResponse(entry: GistEntry, baseUrl: string, includeContent: boolean): Record<string, unknown> { + const files: Record<string, unknown> = {}; + for (const file of Object.values(entry.data.files).sort((left, right) => left.filename.localeCompare(right.filename))) { + files[file.filename] = buildGistFileResponse(entry, file, baseUrl, includeContent); + } + return files; +} + +function gistVersion(entry: GistEntry): string { + const hash = createHash('sha1'); + hash.update(entry.record.id ?? ''); + hash.update('\0'); + hash.update(gistUpdatedAt(entry)); + hash.update('\0'); + hash.update(entry.data.description ?? ''); + hash.update('\0'); + hash.update(String(entry.data.public !== false)); + for (const file of Object.values(entry.data.files).sort((left, right) => left.filename.localeCompare(right.filename))) { + hash.update('\0'); + hash.update(file.filename); + hash.update('\0'); + hash.update(file.content); + hash.update('\0'); + hash.update(file.type ?? ''); + hash.update('\0'); + hash.update(file.language ?? ''); + } + return hash.digest('hex'); +} + +function contentLineCount(content: string): number { + if (!content) { return 0; } + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const trimmedTrailingNewline = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized; + return trimmedTrailingNewline ? trimmedTrailingNewline.split('\n').length : 0; +} + +function buildGistChangeStatus(entry: GistEntry): Record<string, number> { + const additions = Object.values(entry.data.files).reduce((sum, file) => sum + contentLineCount(file.content), 0); + return { + deletions : 0, + additions, + total : additions, + }; +} + +function buildGistCommitResponse(entry: GistEntry, baseUrl: string): Record<string, unknown> { + const version = gistVersion(entry); + return { + url : `${baseUrl}/gists/${entry.record.id}/${version}`, + version, + user : buildGistOwner(entry.ownerDid, baseUrl), + change_status : buildGistChangeStatus(entry), + committed_at : gistUpdatedAt(entry), + }; +} + +function buildGistResponse( + entry: GistEntry, baseUrl: string, includeContent: boolean, commentCount = 0, +): Record<string, unknown> { + const gistUrl = `${baseUrl}/gists/${entry.record.id}`; + const response: Record<string, unknown> = { + url : gistUrl, + forks_url : `${gistUrl}/forks`, + commits_url : `${gistUrl}/commits`, + id : entry.record.id, + node_id : entry.record.id, + git_pull_url : `${gistUrl}.git`, + git_push_url : `${gistUrl}.git`, + html_url : gistUrl, + files : buildGistFilesResponse(entry, baseUrl, includeContent), + public : entry.data.public !== false, + created_at : gistCreatedAt(entry), + updated_at : gistUpdatedAt(entry), + description : entry.data.description ?? '', + comments : commentCount, + comments_enabled : true, + user : null, + comments_url : `${gistUrl}/comments`, + owner : buildGistOwner(entry.ownerDid, baseUrl), + forks : [], + history : [buildGistCommitResponse(entry, baseUrl)], + truncated : false, + }; + if (entry.data.forkOfGistId && entry.data.forkOfOwnerDid) { + response.fork_of = { + url : `${baseUrl}/gists/${entry.data.forkOfGistId}`, + id : entry.data.forkOfGistId, + node_id : entry.data.forkOfGistId, + owner : buildGistOwner(entry.data.forkOfOwnerDid, baseUrl), + html_url : `${baseUrl}/gists/${entry.data.forkOfGistId}`, + }; + } + return response; +} + +async function pagedGistResponse(ctx: AgentContext, entries: GistEntry[], url: URL, path: string): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, entries.length); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + + const response: Record<string, unknown>[] = []; + for (const entry of paged) { + response.push(buildGistResponse(entry, baseUrl, false, await gistCommentCount(ctx, entry.record.id))); + } + return jsonOk(response, headers); +} + +function parseCreateFiles(value: unknown): Record<string, GistFileData> | JsonResponse { + if (!isObject(value) || Object.keys(value).length === 0) { + return jsonValidationError('Validation Failed: files must include at least one file with content.'); + } + + const files: Record<string, GistFileData> = {}; + for (const [key, fileValue] of Object.entries(value)) { + if (!isObject(fileValue) || typeof fileValue.content !== 'string') { + return jsonValidationError('Validation Failed: each gist file must include string content.'); + } + const filename = normalizeFilename(fileValue.filename) ?? normalizeFilename(key); + if (!filename) { + return jsonValidationError('Validation Failed: gist file names must be non-empty relative paths.'); + } + const inferred = inferFileType(filename); + files[filename] = { + filename, + content : fileValue.content, + type : inferred.type, + language : inferred.language, + }; + } + return files; +} + +function parseFilePatch(value: unknown): Record<string, ParsedGistFileChange | null> | JsonResponse { + if (!isObject(value)) { + return jsonValidationError('Validation Failed: files must be an object.'); + } + + const changes: Record<string, ParsedGistFileChange | null> = {}; + for (const [key, fileValue] of Object.entries(value)) { + const currentName = normalizeFilename(key); + if (!currentName) { + return jsonValidationError('Validation Failed: gist file names must be non-empty relative paths.'); + } + if (fileValue === null) { + changes[currentName] = null; + continue; + } + if (!isObject(fileValue)) { + return jsonValidationError('Validation Failed: gist file updates must be objects or null.'); + } + const filename = fileValue.filename === null ? undefined : normalizeFilename(fileValue.filename); + if (fileValue.filename !== undefined && fileValue.filename !== null && !filename) { + return jsonValidationError('Validation Failed: gist file names must be non-empty relative paths.'); + } + if (fileValue.content !== undefined && typeof fileValue.content !== 'string') { + return jsonValidationError('Validation Failed: gist file content must be a string.'); + } + changes[currentName] = { + ...(filename ? { filename } : {}), + ...(typeof fileValue.content === 'string' ? { content: fileValue.content } : {}), + }; + } + return changes; +} + +function applyFilePatch( + existing: Record<string, GistFileData>, changes: Record<string, ParsedGistFileChange | null>, +): Record<string, GistFileData> { + const files = { ...existing }; + for (const [currentName, change] of Object.entries(changes)) { + if (change === null || (!change.filename && change.content === undefined)) { + delete files[currentName]; + continue; + } + + const nextName = change.filename ?? currentName; + const previous = files[currentName] ?? { + filename : currentName, + content : '', + ...inferFileType(currentName), + }; + if (nextName !== currentName) { + delete files[currentName]; + } + const inferred = inferFileType(nextName); + files[nextName] = { + filename : nextName, + content : change.content ?? previous.content, + type : inferred.type, + language : inferred.language, + }; + } + return files; +} + +function parseCreateBody(body: unknown): GistData | JsonResponse { + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const files = parseCreateFiles(body.files); + if (isJsonResponse(files)) { return files; } + + const now = new Date().toISOString(); + return { + description : typeof body.description === 'string' ? body.description : '', + public : normalizePublicFlag(body.public), + files, + createdAt : now, + updatedAt : now, + }; +} + +function gistTags(data: GistData): Record<string, string> { + return { + visibility: data.public === false ? 'secret' : 'public', + ...(data.forkOfOwnerDid ? { forkOfOwnerDid: data.forkOfOwnerDid } : {}), + ...(data.forkOfGistId ? { forkOfGistId: data.forkOfGistId } : {}), + }; +} + +function normalizeGistCommentData(data: unknown, tags?: Record<string, unknown>): GistCommentData | null { + if (!isObject(data)) { return null; } + + const gistId = typeof tags?.gistId === 'string' ? tags.gistId : data.gistId; + if (typeof gistId !== 'string' || gistId.trim() === '') { return null; } + if (typeof data.body !== 'string') { return null; } + + return { + gistId : gistId.trim(), + body : data.body, + userDid : typeof data.userDid === 'string' ? data.userDid : undefined, + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + updatedAt : typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + }; +} + +async function normalizeGistCommentEntry(record: any, ownerDid: string): Promise<GistCommentEntry | null> { + try { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeGistCommentData(await record.data.json(), tags); + if (!data) { return null; } + return { record, ownerDid: data.userDid ?? ownerDid, data }; + } catch { + return null; + } +} + +async function listGistCommentEntries( + ctx: AgentContext, gistId: string, localOnly = false, +): Promise<GistCommentEntry[]> { + const dids = localOnly ? [ctx.did] : await collectVisibleUserDids(ctx); + const entries: GistCommentEntry[] = []; + for (const did of dids) { + let records: any[]; + try { + const result = await ctx.social.records.query('gistComment' as any, { + from : fromOpt(ctx, did), + filter : { tags: { gistId } }, + dateSort : DateSort.CreatedAscending, + } as any); + records = result.records; + } catch { + continue; + } + + for (const record of records) { + const entry = await normalizeGistCommentEntry(record, did); + if (entry) { entries.push(entry); } + } + } + return entries.sort((left, right) => gistCommentCreatedMs(left) - gistCommentCreatedMs(right)); +} + +async function gistCommentCount(ctx: AgentContext, gistId: string): Promise<number> { + return (await listGistCommentEntries(ctx, gistId)).length; +} + +function gistCommentCreatedAt(entry: GistCommentEntry): string { + return toISODate(entry.data.createdAt ?? entry.record.dateCreated); +} + +function gistCommentUpdatedAt(entry: GistCommentEntry): string { + return toISODate(entry.data.updatedAt ?? entry.record.timestamp ?? entry.data.createdAt ?? entry.record.dateCreated); +} + +function gistCommentCreatedMs(entry: GistCommentEntry): number { + return Date.parse(gistCommentCreatedAt(entry)) || 0; +} + +function gistCommentId(entry: GistCommentEntry): number { + return numericId(entry.record.id ?? ''); +} + +function parseCommentBody(value: unknown): string | null { + if (typeof value !== 'string' || value.trim() === '') { return null; } + return value; +} + +function buildGistCommentResponse( + comment: GistCommentEntry, gist: GistEntry, baseUrl: string, +): Record<string, unknown> { + const id = gistCommentId(comment); + return { + id, + node_id : comment.record.id ?? `gist-comment:${id}`, + url : `${baseUrl}/gists/${gist.record.id}/comments/${id}`, + body : comment.data.body, + user : buildGistOwner(comment.ownerDid, baseUrl), + created_at : gistCommentCreatedAt(comment), + updated_at : gistCommentUpdatedAt(comment), + author_association : comment.ownerDid === gist.ownerDid ? 'OWNER' : 'CONTRIBUTOR', + }; +} + +async function findGistCommentById( + ctx: AgentContext, gistId: string, commentId: string, localOnly = false, +): Promise<GistCommentEntry | null> { + const id = Number.parseInt(commentId, 10); + if (!Number.isInteger(id) || id < 1) { return null; } + return (await listGistCommentEntries(ctx, gistId, localOnly)).find(entry => gistCommentId(entry) === id) ?? null; +} + +function normalizeGistStarData(data: unknown, tags?: Record<string, unknown>): GistStarData | null { + if (!isObject(data)) { return null; } + + const ownerDid = typeof tags?.ownerDid === 'string' ? tags.ownerDid : data.ownerDid; + const gistId = typeof tags?.gistId === 'string' ? tags.gistId : data.gistId; + if (typeof ownerDid !== 'string' || ownerDid.trim() === '') { return null; } + if (typeof gistId !== 'string' || gistId.trim() === '') { return null; } + + return { + ownerDid : ownerDid.trim(), + gistId : gistId.trim(), + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + }; +} + +async function normalizeGistStarEntry(record: any): Promise<GistStarEntry | null> { + try { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeGistStarData(await record.data.json(), tags); + if (!data) { return null; } + return { record, data }; + } catch { + return null; + } +} + +async function listGistStarEntries(ctx: AgentContext): Promise<GistStarEntry[]> { + let records: any[]; + try { + const result = await ctx.social.records.query('gistStar' as any, { + dateSort: DateSort.CreatedAscending, + } as any); + records = result.records; + } catch { + return []; + } + + const entries: GistStarEntry[] = []; + for (const record of records) { + const entry = await normalizeGistStarEntry(record); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function findGistStar(ctx: AgentContext, gistId: string): Promise<GistStarEntry | null> { + return (await listGistStarEntries(ctx)).find(entry => entry.data.gistId === gistId) ?? null; +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedGists(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = applySinceFilter(await listGistEntries(ctx, ctx.did, true), url); + return pagedGistResponse(ctx, entries, url, '/gists'); +} + +export async function handleCreateGist(ctx: AgentContext, body: unknown, url: URL): Promise<JsonResponse> { + const parsed = parseCreateBody(body); + if (isJsonResponse(parsed)) { return parsed; } + + const { status, record } = await ctx.social.records.create('gist' as any, { + data : parsed, + tags : gistTags(parsed), + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create gist: ${status.detail}`); + } + + const entry = await normalizeGistEntry(record, ctx.did); + if (!entry) { + return jsonValidationError('Failed to create gist: stored gist data is invalid.'); + } + return jsonCreated(buildGistResponse(entry, buildApiUrl(url), true)); +} + +export async function handleListPublicGists(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = applySinceFilter(await listVisiblePublicGists(ctx), url); + return pagedGistResponse(ctx, entries, url, '/gists/public'); +} + +export async function handleListStarredGists(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries: GistEntry[] = []; + const seen = new Set<string>(); + for (const star of await listGistStarEntries(ctx)) { + if (seen.has(star.data.gistId)) { continue; } + const gist = await findGistById(ctx, star.data.gistId); + if (gist) { + entries.push(gist); + seen.add(gist.record.id); + } + } + + const sorted = entries.sort((left, right) => gistUpdatedMs(right) - gistUpdatedMs(left)); + return pagedGistResponse(ctx, applySinceFilter(sorted, url), url, '/gists/starred'); +} + +export async function handleListUserGists(ctx: AgentContext, userDid: string, url: URL): Promise<JsonResponse> { + const entries = applySinceFilter(await listGistEntries(ctx, userDid, false), url); + return pagedGistResponse(ctx, entries, url, `/users/${userDid}/gists`); +} + +export async function handleGetGist(ctx: AgentContext, gistId: string, url: URL): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + return jsonOk(buildGistResponse(entry, buildApiUrl(url), true, await gistCommentCount(ctx, entry.record.id))); +} + +export async function handleGetGistRawFile( + ctx: AgentContext, gistId: string, encodedFilename: string, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + let filename: string; + try { + filename = decodeURIComponent(encodedFilename); + } catch { + return jsonNotFound(`Gist file '${encodedFilename}' not found.`); + } + + if (!normalizeFilename(filename)) { + return jsonNotFound(`Gist file '${filename}' not found.`); + } + + const file = entry.data.files[filename] ?? Object.values(entry.data.files).find(item => item.filename === filename); + if (!file) { return jsonNotFound(`Gist file '${filename}' not found.`); } + + return binaryOk(TEXT_ENCODER.encode(file.content), rawFileContentType(file)); +} + +export async function handleListGistCommits(ctx: AgentContext, gistId: string, url: URL): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const commits = [buildGistCommitResponse(entry, baseUrl)]; + const paged = paginate(commits, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/gists/${entry.record.id}/commits`, + pagination.page, pagination.perPage, commits.length, + ); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + return jsonOk(paged, headers); +} + +export async function handleGetGistRevision( + ctx: AgentContext, gistId: string, version: string, url: URL, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + if (version.toLowerCase() !== gistVersion(entry)) { + return jsonNotFound(`Gist revision '${version}' not found.`); + } + return jsonOk(buildGistResponse(entry, buildApiUrl(url), true, await gistCommentCount(ctx, entry.record.id))); +} + +export async function handleListGistForks(ctx: AgentContext, gistId: string, url: URL): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const forks = await listGistForkEntries(ctx, entry); + const paged = paginate(forks, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/gists/${entry.record.id}/forks`, + pagination.page, pagination.perPage, forks.length, + ); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + + const response: Record<string, unknown>[] = []; + for (const fork of paged) { + response.push(buildGistResponse(fork, baseUrl, false, await gistCommentCount(ctx, fork.record.id))); + } + return jsonOk(response, headers); +} + +export async function handleForkGist(ctx: AgentContext, gistId: string, url: URL): Promise<JsonResponse> { + const source = await findGistById(ctx, gistId); + if (!source) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const existing = await findLocalGistFork(ctx, source); + if (existing) { + return jsonValidationError(`Validation Failed: gist '${gistId}' has already been forked by the authenticated user.`); + } + + const now = new Date().toISOString(); + const files = Object.fromEntries( + Object.entries(source.data.files).map(([name, file]) => [name, { ...file }]), + ); + const data: GistData = { + description : source.data.description ?? '', + public : source.data.public !== false, + files, + forkOfOwnerDid : source.ownerDid, + forkOfGistId : source.record.id, + forkedAt : now, + createdAt : now, + updatedAt : now, + }; + + const { status, record } = await ctx.social.records.create('gist' as any, { + data, + tags: gistTags(data), + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to fork gist: ${status.detail}`); + } + + const fork = await normalizeGistEntry(record, ctx.did); + if (!fork) { + return jsonValidationError('Failed to fork gist: stored gist data is invalid.'); + } + return jsonCreated(buildGistResponse(fork, buildApiUrl(url), true)); +} + +export async function handleListGistComments(ctx: AgentContext, gistId: string, url: URL): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const comments = await listGistCommentEntries(ctx, entry.record.id); + const paged = paginate(comments, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/gists/${entry.record.id}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + + return jsonOk(paged.map(comment => buildGistCommentResponse(comment, entry, baseUrl)), headers); +} + +export async function handleCreateGistComment( + ctx: AgentContext, gistId: string, body: unknown, url: URL, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const commentBody = parseCommentBody(body.body); + if (!commentBody) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const now = new Date().toISOString(); + const data: GistCommentData = { + gistId : entry.record.id, + body : commentBody, + userDid : ctx.did, + createdAt : now, + updatedAt : now, + }; + const { status, record } = await ctx.social.records.create('gistComment' as any, { + data, + tags: { gistId: data.gistId }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create gist comment: ${status.detail}`); + } + + const comment = await normalizeGistCommentEntry(record, ctx.did); + if (!comment) { + return jsonValidationError('Failed to create gist comment: stored comment data is invalid.'); + } + return jsonCreated(buildGistCommentResponse(comment, entry, buildApiUrl(url))); +} + +export async function handleGetGistComment( + ctx: AgentContext, gistId: string, commentId: string, url: URL, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const comment = await findGistCommentById(ctx, entry.record.id, commentId); + if (!comment) { return jsonNotFound(`Gist comment #${commentId} not found.`); } + + return jsonOk(buildGistCommentResponse(comment, entry, buildApiUrl(url))); +} + +export async function handleCheckGistStar(ctx: AgentContext, gistId: string): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const star = await findGistStar(ctx, gistId); + return star ? jsonNoContent() : jsonNotFound(`Gist '${gistId}' is not starred.`); +} + +export async function handleStarGist(ctx: AgentContext, gistId: string): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const existing = await findGistStar(ctx, entry.record.id); + if (existing) { return jsonNoContent(); } + + const data: GistStarData = { + ownerDid : entry.ownerDid, + gistId : entry.record.id, + createdAt : new Date().toISOString(), + }; + const { status } = await ctx.social.records.create('gistStar' as any, { + data, + tags: { ownerDid: data.ownerDid, gistId: data.gistId }, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to star gist: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleUnstarGist(ctx: AgentContext, gistId: string): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const star = await findGistStar(ctx, entry.record.id); + if (!star) { return jsonNoContent(); } + + const { status } = await star.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to unstar gist: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleUpdateGistComment( + ctx: AgentContext, gistId: string, commentId: string, body: unknown, url: URL, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const commentBody = parseCommentBody(body.body); + if (!commentBody) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const comment = await findGistCommentById(ctx, entry.record.id, commentId, true); + if (!comment) { return jsonNotFound(`Gist comment #${commentId} not found.`); } + + const nextData: GistCommentData = { + ...comment.data, + body : commentBody, + updatedAt : new Date().toISOString(), + }; + const { status } = await comment.record.update({ + data : nextData, + tags : { gistId: entry.record.id }, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update gist comment: ${status.detail}`); + } + + return jsonOk(buildGistCommentResponse({ ...comment, data: nextData }, entry, buildApiUrl(url))); +} + +export async function handleDeleteGistComment( + ctx: AgentContext, gistId: string, commentId: string, +): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const comment = await findGistCommentById(ctx, entry.record.id, commentId, true); + if (!comment) { return jsonNotFound(`Gist comment #${commentId} not found.`); } + + const { status } = await comment.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete gist comment: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleUpdateGist( + ctx: AgentContext, gistId: string, body: unknown, url: URL, +): Promise<JsonResponse> { + if (!isObject(body) || (body.description === undefined && body.files === undefined)) { + return jsonValidationError('Validation Failed: at least one of description or files is required.'); + } + + const entry = await findGistById(ctx, gistId, true); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + let files = entry.data.files; + if (body.files !== undefined) { + const patch = parseFilePatch(body.files); + if (isJsonResponse(patch)) { return patch; } + files = applyFilePatch(files, patch); + if (Object.keys(files).length === 0) { + return jsonValidationError('Validation Failed: a gist must contain at least one file.'); + } + } + + const now = new Date().toISOString(); + const nextData: GistData = { + ...entry.data, + ...(typeof body.description === 'string' ? { description: body.description } : {}), + files, + updatedAt: now, + }; + const { status } = await entry.record.update({ + data : nextData, + tags : gistTags(nextData), + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update gist: ${status.detail}`); + } + + return jsonOk(buildGistResponse( + { ...entry, data: nextData }, buildApiUrl(url), true, await gistCommentCount(ctx, entry.record.id), + )); +} + +export async function handleDeleteGist(ctx: AgentContext, gistId: string): Promise<JsonResponse> { + const entry = await findGistById(ctx, gistId, true); + if (!entry) { return jsonNotFound(`Gist '${gistId}' not found.`); } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete gist: ${status.detail}`); + } + return jsonNoContent(); +} diff --git a/src/github-shim/git-objects.ts b/src/github-shim/git-objects.ts new file mode 100644 index 0000000..78b3a44 --- /dev/null +++ b/src/github-shim/git-objects.ts @@ -0,0 +1,2629 @@ +/** + * GitHub API shim — git object and tree-backed content endpoints. + * + * Reads objects from the local bare git repository store and maps them to + * GitHub REST API v3 responses. This complements the DWN-backed social and + * metadata records with the actual git object graph exposed through familiar + * `/git/blobs`, `/git/trees`, `/git/commits`, `/commits`, and `/contents` + * shapes, plus history comparison. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { ContentMediaKind } from './contents.js'; +import type { JsonResponse } from './helpers.js'; + +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + +import { createRefSyncer } from '../git-server/ref-sync.js'; +import { GitBackend } from '../git-server/git-backend.js'; +import { renderMarkdownText } from './meta.js'; +import { resolveReposPath } from '../cli/flags.js'; + +import { + baseHeaders, + binaryOk, + buildApiUrl, + buildLinkHeader, + getRepoRecord, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type GitObjectOptions = { + /** Base directory where gitd stores bare repositories. */ + reposPath? : string; +}; + +type GitResult = { + status : number; + stdout : Buffer; + stderr : Buffer; +}; + +type GitObjectType = 'blob' | 'tree' | 'commit' | 'tag'; +type GitTagTargetType = 'blob' | 'tree' | 'commit'; + +type GitIdentity = { + name : string; + email : string; + date : string; +}; + +type GitIdentityInput = { + name : string; + email : string; + date? : string; +}; + +type GitCommitInfo = { + sha : string; + treeSha : string; + parentShas : string[]; + author : GitIdentity; + committer : GitIdentity; + message : string; +}; + +type GitTagInfo = { + sha : string; + tag : string; + message : string; + object : { + sha : string; + type : GitTagTargetType; + }; + tagger : GitIdentity; +}; + +type TreeEntry = { + path : string; + mode : string; + type : GitTagTargetType; + sha : string; + size? : number; +}; + +type LocalRepoLookup = { + repoPath : string; +} | null; + +type ContentLookup = + | { kind: 'missing-local-repo' } + | { kind: 'response'; response: JsonResponse }; + +type RequestedRef = { + ref : string; + explicit : boolean; +}; + +type DiffNameStatus = { + filename : string; + status : string; + previousFilename? : string; +}; + +type DiffStats = { + additions : number; + deletions : number; +}; + +type GitContributor = { + name : string; + email : string; + contributions : number; +}; + +type ArchiveKind = 'tarball' | 'zipball'; +type CompareTextKind = 'diff' | 'patch'; +type CommitMediaKind = 'diff' | 'patch' | 'sha'; + +const README_CANDIDATES = ['README.md', 'README', 'README.txt', 'README.rst']; + +type TreeWriteEntry = { + path : string; + mode : string; + type : GitTagTargetType; + sha? : string | null; + content? : string; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/; +const SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const TREE_MODES = new Set(['100644', '100755', '040000', '160000', '120000']); +const DEFAULT_GIT_IDENTITY: GitIdentityInput = { + name : 'gitd', + email : 'gitd@example.invalid', +}; + +// --------------------------------------------------------------------------- +// Local repo lookup +// --------------------------------------------------------------------------- + +function localRepo(ctx: AgentContext, targetDid: string, repoName: string, options: GitObjectOptions): LocalRepoLookup { + const reposPath = options.reposPath ?? resolveReposPath([], ctx.profileName ?? null); + const backend = new GitBackend({ basePath: reposPath }); + + try { + if (!backend.exists(targetDid, repoName)) { + return null; + } + return { repoPath: backend.repoPath(targetDid, repoName) }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Git process helpers +// --------------------------------------------------------------------------- + +async function runGit( + repoPath: string, + args: string[], + env?: Record<string, string>, + input?: Uint8Array | string, +): Promise<GitResult> { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', repoPath, ...args], { + env : env ? { ...process.env, ...env } : process.env, + stdio : input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + resolve({ + status : code ?? 128, + stdout : Buffer.concat(stdout), + stderr : Buffer.concat(stderr), + }); + }); + + if (input !== undefined) { + child.stdin!.end(input); + } + }); +} + +async function gitOk(repoPath: string, args: string[]): Promise<Buffer | null> { + const result = await runGit(repoPath, args); + if (result.status !== 0) { + return null; + } + return result.stdout; +} + +async function gitText(repoPath: string, args: string[]): Promise<string | null> { + const out = await gitOk(repoPath, args); + return out ? out.toString('utf-8').trim() : null; +} + +function gitError(result: GitResult): string { + return result.stderr.toString('utf-8').trim() || result.stdout.toString('utf-8').trim() || `git exited with status ${result.status}`; +} + +function jsonConflict(message: string): JsonResponse { + return { + status : 409, + headers : baseHeaders(), + body : JSON.stringify({ message, documentation_url: 'https://docs.github.com/rest' }), + }; +} + +function redirectFound(location: string): JsonResponse { + return { + status : 302, + headers : { + ...baseHeaders(), + 'Content-Type' : 'text/plain; charset=utf-8', + Location : location, + }, + body: '', + }; +} + +// --------------------------------------------------------------------------- +// Revision/object resolution +// --------------------------------------------------------------------------- + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function isSafeRef(value: string): boolean { + if (FULL_SHA_RE.test(value)) { + return true; + } + if (!SAFE_REF_RE.test(value)) { + return false; + } + if (value.includes('..') || value.includes('//') || value.endsWith('/') || value.endsWith('.lock')) { + return false; + } + return !value.split('/').some((part) => part === '.' || part === '..' || part.startsWith('-')); +} + +function refCandidates(rawRef: string): string[] { + const ref = decodeRouteParam(rawRef).replace(/^\/+/, ''); + if (!isSafeRef(ref)) { + return []; + } + if (FULL_SHA_RE.test(ref)) { + return [ref.toLowerCase()]; + } + if (ref.startsWith('refs/')) { + return [ref]; + } + return [ref, `refs/heads/${ref}`, `refs/tags/${ref}`]; +} + +async function resolveObject(repoPath: string, rawRef: string, peel: 'commit' | 'tree' | 'object'): Promise<string | null> { + for (const candidate of refCandidates(rawRef)) { + const suffix = peel === 'object' ? '' : `^{${peel}}`; + const resolved = await gitText(repoPath, ['rev-parse', '--verify', '--end-of-options', `${candidate}${suffix}`]); + if (resolved && FULL_SHA_RE.test(resolved)) { + return resolved.toLowerCase(); + } + } + return null; +} + +async function objectType(repoPath: string, sha: string): Promise<GitObjectType | null> { + if (!FULL_SHA_RE.test(sha)) { + return null; + } + const type = await gitText(repoPath, ['cat-file', '-t', sha.toLowerCase()]); + if (type === 'blob' || type === 'tree' || type === 'commit' || type === 'tag') { + return type; + } + return null; +} + +// --------------------------------------------------------------------------- +// Git object parsing +// --------------------------------------------------------------------------- + +async function readCommit(repoPath: string, rawRef: string): Promise<GitCommitInfo | null> { + const sha = await resolveObject(repoPath, rawRef, 'commit'); + if (!sha) { + return null; + } + + const out = await gitOk(repoPath, [ + 'show', + '-s', + '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B', + sha, + ]); + if (!out) { + return null; + } + + const parts = out.toString('utf-8').split('\0'); + if (parts.length < 10) { + return null; + } + + const message = parts.slice(9).join('\0').replace(/\n+$/, ''); + return { + sha : parts[0], + treeSha : parts[1], + parentShas : parts[2] ? parts[2].split(' ') : [], + author : { name: parts[3], email: parts[4], date: parts[5] }, + committer : { name: parts[6], email: parts[7], date: parts[8] }, + message, + }; +} + +function parseGitIdentity(raw: string): GitIdentity | null { + const match = raw.match(/^(.+) <([^<>]*)> (\d+) ([+-]\d{4})$/); + if (!match) { + return null; + } + + const seconds = Number.parseInt(match[3], 10); + if (!Number.isFinite(seconds)) { + return null; + } + + return { + name : match[1], + email : match[2], + date : new Date(seconds * 1000).toISOString(), + }; +} + +async function readTag(repoPath: string, rawRef: string): Promise<GitTagInfo | null> { + const sha = await resolveObject(repoPath, rawRef, 'object'); + if (!sha || await objectType(repoPath, sha) !== 'tag') { + return null; + } + + const out = await gitOk(repoPath, ['cat-file', 'tag', sha]); + if (!out) { + return null; + } + + const raw = out.toString('utf-8'); + const split = raw.indexOf('\n\n'); + if (split < 0) { + return null; + } + + const headerLines = raw.slice(0, split).split('\n'); + const headers = new Map<string, string>(); + for (const line of headerLines) { + const space = line.indexOf(' '); + if (space <= 0) { + continue; + } + headers.set(line.slice(0, space), line.slice(space + 1)); + } + + const type = headers.get('type'); + const objectSha = headers.get('object'); + const tag = headers.get('tag'); + const tagger = headers.get('tagger'); + if ( + (type !== 'blob' && type !== 'tree' && type !== 'commit') || + !objectSha || + !FULL_SHA_RE.test(objectSha) || + !tag || + !tagger + ) { + return null; + } + + const identity = parseGitIdentity(tagger); + if (!identity) { + return null; + } + + return { + sha, + tag, + message : raw.slice(split + 2).replace(/\n+$/, ''), + object : { sha: objectSha.toLowerCase(), type }, + tagger : identity, + }; +} + +function parseTreeEntries(raw: Buffer, prefix = ''): TreeEntry[] { + const entries: TreeEntry[] = []; + const records = raw.toString('utf-8').split('\0'); + + for (const record of records) { + if (!record) { + continue; + } + + const match = record.match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40})(?: +(-|\d+))?\t(.+)$/); + if (!match) { + continue; + } + + const size = match[4] && match[4] !== '-' ? parseInt(match[4], 10) : undefined; + const entryPath = prefix ? `${prefix}/${match[5]}` : match[5]; + entries.push({ + path : entryPath, + mode : match[1], + type : match[2] as 'blob' | 'tree' | 'commit', + sha : match[3], + ...(size !== undefined ? { size } : {}), + }); + } + + return entries; +} + +async function listTree(repoPath: string, treeSha: string, recursive: boolean): Promise<TreeEntry[] | null> { + const args = recursive + ? ['ls-tree', '-l', '-z', '-r', '-t', treeSha] + : ['ls-tree', '-l', '-z', treeSha]; + const out = await gitOk(repoPath, args); + return out ? parseTreeEntries(out) : null; +} + +function normalizeContentPath(path: string | null): string | null { + if (!path) { + return ''; + } + + const decoded = decodeRouteParam(path).replace(/^\/+/, '').replace(/\/+$/, ''); + if (!decoded) { + return ''; + } + if (decoded.includes('\0') || decoded.includes('//')) { + return null; + } + if (decoded.split('/').some((part) => part === '' || part === '.' || part === '..')) { + return null; + } + return decoded; +} + +function normalizeWriteBranch(branch: string): string | null { + const decoded = decodeRouteParam(branch).replace(/^\/+/, ''); + const shortName = decoded.startsWith('refs/heads/') ? decoded.slice('refs/heads/'.length) : decoded; + if (!shortName || shortName === 'HEAD' || shortName === '@' || FULL_SHA_RE.test(shortName) || shortName.startsWith('refs/')) { + return null; + } + return isSafeRef(shortName) ? shortName : null; +} + +function branchRef(branch: string): string { + return `refs/heads/${branch}`; +} + +async function requestedWriteBranch( + ctx: AgentContext, targetDid: string, repoName: string, repoPath: string, body: Record<string, unknown>, +): Promise<string | JsonResponse> { + if (body.branch !== undefined && body.branch !== null) { + if (typeof body.branch !== 'string') { + return jsonValidationError('Validation Failed: branch must be a string.'); + } + const branch = normalizeWriteBranch(body.branch); + return branch ?? jsonValidationError('Validation Failed: branch is invalid.'); + } + + try { + const repo = await getRepoRecord(ctx, targetDid, repoName); + const branch = repo?.defaultBranch ? normalizeWriteBranch(repo.defaultBranch) : null; + if (branch) { + return branch; + } + } catch { + // A local git repository can still be written when the caller supplies a branch. + } + + const symbolicHead = await gitText(repoPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']); + const headBranch = symbolicHead ? normalizeWriteBranch(symbolicHead) : null; + return headBranch ?? 'main'; +} + +function requiredString(body: Record<string, unknown>, key: string): string | JsonResponse { + const value = body[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${key} is required.`); + } + return value.trim(); +} + +function optionalBlobSha(body: Record<string, unknown>): string | JsonResponse | null { + const value = body.sha; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== 'string' || !FULL_SHA_RE.test(value)) { + return jsonValidationError('Validation Failed: sha must be a git blob SHA.'); + } + return value.toLowerCase(); +} + +function decodeBase64Content(body: Record<string, unknown>): Buffer | JsonResponse { + const value = body.content; + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: content is required.'); + } + + const compact = value.replace(/\s+/g, ''); + if (!BASE64_RE.test(compact)) { + return jsonValidationError('Validation Failed: content must be Base64 encoded.'); + } + return Buffer.from(compact, 'base64'); +} + +function optionalIdentity(value: unknown, field: string, fallback: GitIdentityInput): GitIdentityInput | JsonResponse { + if (value === undefined || value === null) { + return fallback; + } + if (typeof value !== 'object') { + return jsonValidationError(`Validation Failed: ${field} must be an object.`); + } + + const input = value as Record<string, unknown>; + if (typeof input.name !== 'string' || input.name.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${field}.name is required.`); + } + if (typeof input.email !== 'string' || input.email.trim().length === 0) { + return jsonValidationError(`Validation Failed: ${field}.email is required.`); + } + if (input.date !== undefined && (typeof input.date !== 'string' || Number.isNaN(Date.parse(input.date)))) { + return jsonValidationError(`Validation Failed: ${field}.date must be an ISO 8601 timestamp.`); + } + + return { + name : input.name.trim(), + email : input.email.trim(), + ...(typeof input.date === 'string' ? { date: input.date } : {}), + }; +} + +function commitEnv(author: GitIdentityInput, committer: GitIdentityInput): Record<string, string> { + return { + GIT_AUTHOR_NAME : author.name, + GIT_AUTHOR_EMAIL : author.email, + GIT_COMMITTER_NAME : committer.name, + GIT_COMMITTER_EMAIL : committer.email, + ...(author.date ? { GIT_AUTHOR_DATE: author.date } : {}), + ...(committer.date ? { GIT_COMMITTER_DATE: committer.date } : {}), + }; +} + +async function syncRefsAfterContentWrite(ctx: AgentContext, targetDid: string, repoName: string, repoPath: string): Promise<void> { + if (targetDid !== ctx.did) { + return; + } + + try { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo?.contextId) { + return; + } + await createRefSyncer({ refs: ctx.refs, repoContextId: repo.contextId })(targetDid, repoName, repoPath); + } catch (err) { + console.warn(`ref-sync: failed after contents write for ${targetDid}/${repoName}: ${(err as Error).message}`); + } +} + +async function commitViaWorktree( + repoPath: string, + branch: string, + message: string, + author: GitIdentityInput, + committer: GitIdentityInput, + mutate: (workDir: string) => Promise<JsonResponse | undefined>, +): Promise<string | JsonResponse> { + const tmpRoot = mkdtempSync(join(tmpdir(), 'gitd-content-write-')); + const workDir = join(tmpRoot, 'work'); + + try { + const clone = await runGit(process.cwd(), ['clone', '--quiet', repoPath, workDir]); + if (clone.status !== 0) { + return jsonValidationError(`Failed to clone repository storage: ${gitError(clone)}`); + } + + const checkout = await runGit(workDir, ['checkout', '--quiet', branch]); + if (checkout.status !== 0) { + return jsonNotFound(`Branch '${branch}' not found.`); + } + + const mutationError = await mutate(workDir); + if (mutationError) { + return mutationError; + } + + const status = await gitText(workDir, ['status', '--porcelain']); + if (!status) { + return jsonConflict('No changes to commit.'); + } + + const commit = await runGit(workDir, ['commit', '-m', message], commitEnv(author, committer)); + if (commit.status !== 0) { + return jsonValidationError(`Failed to commit contents change: ${gitError(commit)}`); + } + + const commitSha = await gitText(workDir, ['rev-parse', '--verify', 'HEAD']); + if (!commitSha) { + return jsonValidationError('Failed to resolve committed contents change.'); + } + + const push = await runGit(workDir, ['push', '--quiet', 'origin', `HEAD:${branchRef(branch)}`]); + if (push.status !== 0) { + return jsonValidationError(`Failed to update branch '${branch}': ${gitError(push)}`); + } + + return commitSha; + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +function gitObjectUrlKind(type: GitObjectType): string { + return type === 'blob' ? 'blobs' : type === 'tree' ? 'trees' : type === 'tag' ? 'tags' : 'commits'; +} + +function buildGitRefObjectResponse(ref: string, sha: string, type: GitObjectType, repoBase: string): Record<string, unknown> { + const refPath = ref.replace(/^refs\//, ''); + return { + ref, + node_id : nodeId('ref', ref), + url : `${repoBase}/git/refs/${refPath}`, + object : { + type, + sha, + url: `${repoBase}/git/${gitObjectUrlKind(type)}/${sha}`, + }, + }; +} + +function buildGitTagResponse(tag: GitTagInfo, repoBase: string): Record<string, unknown> { + return { + node_id : nodeId('tag', tag.sha), + tag : tag.tag, + sha : tag.sha, + url : `${repoBase}/git/tags/${tag.sha}`, + message : tag.message, + tagger : tag.tagger, + object : { + type : tag.object.type, + sha : tag.object.sha, + url : `${repoBase}/git/${gitObjectUrlKind(tag.object.type)}/${tag.object.sha}`, + }, + verification: verification(), + }; +} + +function decodeGitBlobContent(body: Record<string, unknown>): Buffer | JsonResponse { + const value = body.content; + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: content is required.'); + } + + const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : 'utf-8'; + if (encoding === 'utf-8' || encoding === 'utf8') { + return Buffer.from(value, 'utf-8'); + } + if (encoding === 'base64') { + const compact = value.replace(/\s+/g, ''); + if (!BASE64_RE.test(compact)) { + return jsonValidationError('Validation Failed: content must be Base64 encoded.'); + } + return Buffer.from(compact, 'base64'); + } + return jsonValidationError('Validation Failed: encoding must be utf-8 or base64.'); +} + +function cleanTagName(value: unknown): string | JsonResponse { + if (typeof value !== 'string' || value.trim().length === 0) { + return jsonValidationError('Validation Failed: tag is required.'); + } + const tag = value.trim(); + if (tag.includes('\0') || tag.includes('\n') || tag.includes('\r')) { + return jsonValidationError('Validation Failed: tag is invalid.'); + } + return tag; +} + +function gitTimestamp(identity: GitIdentityInput): string { + const millis = identity.date ? Date.parse(identity.date) : Date.now(); + const seconds = Math.floor((Number.isFinite(millis) ? millis : Date.now()) / 1000); + return `${seconds} +0000`; +} + +function taggerLine(identity: GitIdentityInput): string { + return `${identity.name} <${identity.email}> ${gitTimestamp(identity)}`; +} + +async function writeGitBlob(repoPath: string, content: Buffer): Promise<string | JsonResponse> { + const result = await runGit(repoPath, ['hash-object', '-w', '--stdin'], undefined, content); + const sha = result.stdout.toString('utf-8').trim(); + if (result.status !== 0 || !FULL_SHA_RE.test(sha)) { + return jsonValidationError(`Failed to create git blob: ${gitError(result)}`); + } + return sha.toLowerCase(); +} + +function normalizedObjectPath(value: unknown, fieldName = 'path'): string | JsonResponse { + if (typeof value !== 'string') { + return jsonValidationError(`Validation Failed: ${fieldName} is required.`); + } + const path = normalizeContentPath(value); + if (!path) { + return jsonValidationError(`Validation Failed: ${fieldName} is invalid.`); + } + return path; +} + +function requiredSha(value: unknown, fieldName: string): string | JsonResponse { + if (typeof value !== 'string' || !FULL_SHA_RE.test(value)) { + return jsonValidationError(`Validation Failed: ${fieldName} must be a SHA.`); + } + return value.toLowerCase(); +} + +function modeMatchesType(mode: string, type: GitObjectType): boolean { + if ((mode === '100644' || mode === '100755' || mode === '120000') && type === 'blob') { + return true; + } + if (mode === '040000' && type === 'tree') { + return true; + } + return mode === '160000' && type === 'commit'; +} + +function parseTreeEntry(value: unknown, index: number): TreeWriteEntry | JsonResponse { + if (typeof value !== 'object' || value === null) { + return jsonValidationError(`Validation Failed: tree[${index}] must be an object.`); + } + + const raw = value as Record<string, unknown>; + const path = normalizedObjectPath(raw.path, `tree[${index}].path`); + if (typeof path !== 'string') { + return path; + } + + if (typeof raw.mode !== 'string' || !TREE_MODES.has(raw.mode)) { + return jsonValidationError(`Validation Failed: tree[${index}].mode is invalid.`); + } + if (raw.type !== 'blob' && raw.type !== 'tree' && raw.type !== 'commit') { + return jsonValidationError(`Validation Failed: tree[${index}].type is invalid.`); + } + if (!modeMatchesType(raw.mode, raw.type)) { + return jsonValidationError(`Validation Failed: tree[${index}].mode does not match type.`); + } + if (raw.sha !== undefined && raw.sha !== null && typeof raw.sha !== 'string') { + return jsonValidationError(`Validation Failed: tree[${index}].sha must be a SHA or null.`); + } + if (raw.sha !== undefined && raw.sha !== null && !FULL_SHA_RE.test(raw.sha)) { + return jsonValidationError(`Validation Failed: tree[${index}].sha must be a SHA or null.`); + } + if (raw.content !== undefined && typeof raw.content !== 'string') { + return jsonValidationError(`Validation Failed: tree[${index}].content must be a string.`); + } + if (raw.content !== undefined && raw.sha !== undefined) { + return jsonValidationError(`Validation Failed: tree[${index}] cannot specify both sha and content.`); + } + if (raw.content !== undefined && raw.type !== 'blob') { + return jsonValidationError(`Validation Failed: tree[${index}].content is only supported for blobs.`); + } + if (raw.content === undefined && raw.sha === undefined) { + return jsonValidationError(`Validation Failed: tree[${index}] must specify sha, null sha, or content.`); + } + + return { + path, + mode : raw.mode, + type : raw.type, + sha : raw.sha === null ? null : typeof raw.sha === 'string' ? raw.sha.toLowerCase() : undefined, + content : typeof raw.content === 'string' ? raw.content : undefined, + }; +} + +async function removeIndexPath(repoPath: string, path: string, env: Record<string, string>): Promise<boolean | JsonResponse> { + const listed = await runGit(repoPath, ['ls-files', '-z', '--', path], env); + if (listed.status !== 0) { + return jsonValidationError(`Failed to inspect tree path '${path}': ${gitError(listed)}`); + } + if (listed.stdout.byteLength === 0) { + return false; + } + + const removed = await runGit(repoPath, ['update-index', '--force-remove', '-z', '--stdin'], env, listed.stdout); + if (removed.status !== 0) { + return jsonValidationError(`Failed to remove tree path '${path}': ${gitError(removed)}`); + } + return true; +} + +async function applyTreeEntry(repoPath: string, entry: TreeWriteEntry, env: Record<string, string>): Promise<JsonResponse | undefined> { + if (entry.sha === null) { + const removed = await removeIndexPath(repoPath, entry.path, env); + if (typeof removed !== 'boolean') { + return removed; + } + if (!removed) { + return jsonConflict(`Tree path '${entry.path}' does not exist.`); + } + return undefined; + } + + let sha = entry.sha; + if (entry.content !== undefined) { + const blobSha = await writeGitBlob(repoPath, Buffer.from(entry.content, 'utf-8')); + if (typeof blobSha !== 'string') { + return blobSha; + } + sha = blobSha; + } + if (!sha) { + return jsonValidationError(`Validation Failed: tree entry '${entry.path}' is missing a SHA.`); + } + + const type = await objectType(repoPath, sha); + if (type !== entry.type) { + return jsonValidationError(`Validation Failed: tree entry '${entry.path}' points to a missing or incompatible object.`); + } + + const removed = await removeIndexPath(repoPath, entry.path, env); + if (typeof removed !== 'boolean') { + return removed; + } + + if (entry.type === 'tree') { + const imported = await runGit(repoPath, ['read-tree', `--prefix=${entry.path}/`, '-i', sha], env); + if (imported.status !== 0) { + return jsonValidationError(`Failed to import tree '${entry.path}': ${gitError(imported)}`); + } + return undefined; + } + + const added = await runGit(repoPath, ['update-index', '--add', '--cacheinfo', `${entry.mode},${sha},${entry.path}`], env); + if (added.status !== 0) { + return jsonValidationError(`Failed to stage tree entry '${entry.path}': ${gitError(added)}`); + } + return undefined; +} + +async function createGitTreeObject(repoPath: string, body: Record<string, unknown>): Promise<string | JsonResponse> { + if (!Array.isArray(body.tree)) { + return jsonValidationError('Validation Failed: tree is required.'); + } + + const entries: TreeWriteEntry[] = []; + for (let i = 0; i < body.tree.length; i++) { + const entry = parseTreeEntry(body.tree[i], i); + if ('status' in entry) { + return entry; + } + entries.push(entry); + } + + let baseTree: string | null = null; + if (body.base_tree !== undefined && body.base_tree !== null) { + if (typeof body.base_tree !== 'string') { + return jsonValidationError('Validation Failed: base_tree must be a tree SHA or ref.'); + } + baseTree = await resolveObject(repoPath, body.base_tree, 'tree'); + if (!baseTree) { + return jsonNotFound(`Base tree '${body.base_tree}' not found.`); + } + } + + const tmpRoot = mkdtempSync(join(tmpdir(), 'gitd-tree-index-')); + const env = { GIT_INDEX_FILE: join(tmpRoot, 'index') }; + try { + const reset = await runGit(repoPath, ['read-tree', '--empty'], env); + if (reset.status !== 0) { + return jsonValidationError(`Failed to initialize temporary tree index: ${gitError(reset)}`); + } + if (baseTree) { + const base = await runGit(repoPath, ['read-tree', baseTree], env); + if (base.status !== 0) { + return jsonValidationError(`Failed to read base tree: ${gitError(base)}`); + } + } + + for (const entry of entries) { + const error = await applyTreeEntry(repoPath, entry, env); + if (error) { + return error; + } + } + + const write = await runGit(repoPath, ['write-tree'], env); + const sha = write.stdout.toString('utf-8').trim(); + if (write.status !== 0 || !FULL_SHA_RE.test(sha)) { + return jsonValidationError(`Failed to create git tree: ${gitError(write)}`); + } + return sha.toLowerCase(); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +async function parseCommitParents(repoPath: string, value: unknown): Promise<string[] | JsonResponse> { + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value)) { + return jsonValidationError('Validation Failed: parents must be an array.'); + } + + const parents: string[] = []; + for (const [index, parent] of value.entries()) { + const sha = requiredSha(parent, `parents[${index}]`); + if (typeof sha !== 'string') { + return sha; + } + const type = await objectType(repoPath, sha); + if (type !== 'commit') { + return jsonNotFound(`Parent commit '${sha}' not found.`); + } + parents.push(sha); + } + return parents; +} + +function normalizeGitReference(value: string): string | null { + const ref = decodeRouteParam(value).replace(/^\/+/, ''); + if (!ref.startsWith('refs/') || ref.split('/').length < 3 || !isSafeRef(ref)) { + return null; + } + return ref; +} + +function normalizeRouteGitReference(value: string): string | null { + const ref = decodeRouteParam(value).replace(/^\/+/, ''); + return normalizeGitReference(ref.startsWith('refs/') ? ref : `refs/${ref}`); +} + +async function localRefExists(repoPath: string, ref: string): Promise<boolean> { + const result = await runGit(repoPath, ['show-ref', '--verify', '--quiet', ref]); + return result.status === 0; +} + +async function localRefSha(repoPath: string, ref: string): Promise<string | null> { + const sha = await gitText(repoPath, ['rev-parse', '--verify', '--end-of-options', ref]); + return sha && FULL_SHA_RE.test(sha) ? sha.toLowerCase() : null; +} + +async function hasLocalHead(repoPath: string): Promise<boolean> { + const result = await runGit(repoPath, ['show-ref', '--heads', '--verify']); + if (result.status === 0 && result.stdout.byteLength > 0) { + return true; + } + const heads = await runGit(repoPath, ['for-each-ref', '--format=%(refname)', 'refs/heads/']); + return heads.status === 0 && heads.stdout.toString('utf-8').trim().length > 0; +} + +async function updateLocalRef(repoPath: string, ref: string, sha: string): Promise<JsonResponse | undefined> { + const result = await runGit(repoPath, ['update-ref', ref, sha]); + if (result.status !== 0) { + return jsonValidationError(`Failed to update reference '${ref}': ${gitError(result)}`); + } + return undefined; +} + +async function treeEntryAtPath(repoPath: string, commitSha: string, path: string): Promise<TreeEntry | null> { + const out = await gitOk(repoPath, ['ls-tree', '-l', '-z', commitSha, '--', path]); + const entries = out ? parseTreeEntries(out) : []; + return entries.find((entry) => entry.path === path) ?? null; +} + +async function listDirectory(repoPath: string, commitSha: string, path: string): Promise<TreeEntry[] | null> { + if (!path) { + const out = await gitOk(repoPath, ['ls-tree', '-l', '-z', commitSha]); + return out ? parseTreeEntries(out) : null; + } + + const entry = await treeEntryAtPath(repoPath, commitSha, path); + if (!entry || entry.type !== 'tree') { + return null; + } + + const out = await gitOk(repoPath, ['ls-tree', '-l', '-z', entry.sha]); + return out ? parseTreeEntries(out, path) : null; +} + +async function readBlob(repoPath: string, sha: string): Promise<Buffer | null> { + if (!FULL_SHA_RE.test(sha)) { + return null; + } + const type = await objectType(repoPath, sha); + if (type !== 'blob') { + return null; + } + return gitOk(repoPath, ['cat-file', 'blob', sha.toLowerCase()]); +} + +async function requestedContentRef(ctx: AgentContext, targetDid: string, repoName: string, url: URL): Promise<RequestedRef> { + const explicitRef = url.searchParams.get('ref'); + if (explicitRef) { + return { ref: explicitRef, explicit: true }; + } + + try { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (repo?.defaultBranch) { + return { ref: repo.defaultBranch, explicit: false }; + } + } catch { + // A local git repo can still serve content if the DWN repo record is unavailable. + } + + return { ref: 'HEAD', explicit: false }; +} + +async function defaultCommitRef(ctx: AgentContext, targetDid: string, repoName: string): Promise<string> { + try { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (repo?.defaultBranch) { + return repo.defaultBranch; + } + } catch { + // A local git repo can still serve history if the DWN repo record is unavailable. + } + + return 'HEAD'; +} + +function gitLines(out: Buffer | null): string[] { + if (!out) { + return []; + } + return out.toString('utf-8').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function contributorKey(contributor: GitContributor): string { + return (contributor.email || contributor.name).trim().toLowerCase(); +} + +function contributorLogin(contributor: GitContributor): string { + const base = contributor.name || contributor.email.split('@')[0] || 'contributor'; + const slug = base + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32); + const suffix = numericId(contributorKey(contributor)).toString(36); + return `${slug || 'contributor'}-${suffix}`; +} + +function parseShortlogContributors(raw: Buffer | null): GitContributor[] { + const byKey = new Map<string, GitContributor>(); + + for (const line of gitLines(raw)) { + const match = line.match(/^(\d+)\s+(.+?)(?:\s+<([^<>]*)>)?$/); + if (!match) { + continue; + } + + const contributions = parseInt(match[1], 10); + if (!Number.isFinite(contributions) || contributions <= 0) { + continue; + } + + const name = (match[2] ?? '').trim(); + const email = (match[3] ?? '').trim(); + const key = (email || name).toLowerCase(); + if (!key) { + continue; + } + + const existing = byKey.get(key); + if (existing) { + existing.contributions += contributions; + if (!existing.name && name) { + existing.name = name; + } + if (!existing.email && email) { + existing.email = email; + } + continue; + } + + byKey.set(key, { name, email, contributions }); + } + + return [...byKey.values()].sort((a, b) => { + if (b.contributions !== a.contributions) { + return b.contributions - a.contributions; + } + return contributorKey(a).localeCompare(contributorKey(b)); + }); +} + +async function listCommitShas( + repoPath: string, commitSha: string, url: URL, path: string | null, +): Promise<string[] | null> { + const { page, perPage } = parsePagination(url); + const args = [ + 'rev-list', + `--max-count=${perPage}`, + `--skip=${(page - 1) * perPage}`, + ]; + + const since = url.searchParams.get('since'); + if (since) { + args.push(`--since=${since}`); + } + + const until = url.searchParams.get('until'); + if (until) { + args.push(`--until=${until}`); + } + + args.push(commitSha); + if (path) { + args.push('--', path); + } + + const out = await gitOk(repoPath, args); + return out ? gitLines(out) : null; +} + +async function revListCount(repoPath: string, range: string): Promise<number> { + const out = await gitText(repoPath, ['rev-list', '--count', range]); + if (!out) { + return 0; + } + const count = parseInt(out, 10); + return Number.isFinite(count) ? count : 0; +} + +async function compareCommitShas(repoPath: string, baseSha: string, headSha: string): Promise<string[] | null> { + const out = await gitOk(repoPath, ['rev-list', '--reverse', '--max-count=250', `${baseSha}..${headSha}`]); + return out ? gitLines(out) : null; +} + +async function mergeBase(repoPath: string, baseSha: string, headSha: string): Promise<string | null> { + const out = await gitText(repoPath, ['merge-base', baseSha, headSha]); + return out && FULL_SHA_RE.test(out) ? out.toLowerCase() : null; +} + +function compareStatus(aheadBy: number, behindBy: number): string { + if (aheadBy === 0 && behindBy === 0) { + return 'identical'; + } + if (aheadBy > 0 && behindBy > 0) { + return 'diverged'; + } + return aheadBy > 0 ? 'ahead' : 'behind'; +} + +function statusFromDiffCode(code: string): string { + switch (code.charAt(0)) { + case 'A': + return 'added'; + case 'D': + return 'removed'; + case 'R': + return 'renamed'; + case 'C': + return 'copied'; + default: + return 'modified'; + } +} + +function parseNameStatus(raw: Buffer | null): DiffNameStatus[] { + const files: DiffNameStatus[] = []; + for (const line of gitLines(raw)) { + const parts = line.split('\t'); + const code = parts[0] ?? ''; + if (!code) { + continue; + } + + if ((code.startsWith('R') || code.startsWith('C')) && parts.length >= 3) { + files.push({ + filename : parts[2], + previousFilename : parts[1], + status : statusFromDiffCode(code), + }); + continue; + } + + const filename = parts.slice(1).join('\t'); + if (filename) { + files.push({ filename, status: statusFromDiffCode(code) }); + } + } + return files; +} + +function parseNumStatValue(value: string): number { + if (value === '-') { + return 0; + } + const count = parseInt(value, 10); + return Number.isFinite(count) ? count : 0; +} + +function parseNumStats(raw: Buffer | null): Map<string, DiffStats> { + const stats = new Map<string, DiffStats>(); + for (const line of gitLines(raw)) { + const parts = line.split('\t'); + if (parts.length < 3) { + continue; + } + + const filename = parts[parts.length - 1]; + stats.set(filename, { + additions : parseNumStatValue(parts[0]), + deletions : parseNumStatValue(parts[1]), + }); + } + return stats; +} + +async function blobShaAtPath(repoPath: string, commitSha: string, path: string): Promise<string | null> { + const out = await gitText(repoPath, ['rev-parse', '--verify', '--end-of-options', `${commitSha}:${path}`]); + return out && FULL_SHA_RE.test(out) ? out.toLowerCase() : null; +} + +async function filePatch(repoPath: string, baseSha: string, headSha: string, path: string): Promise<string | undefined> { + const out = await gitOk(repoPath, ['diff', '--patch', '--find-renames', '--unified=3', baseSha, headSha, '--', path]); + if (!out || out.byteLength === 0) { + return undefined; + } + + const patch = out.toString('utf-8'); + return patch.length > 120_000 ? patch.slice(0, 120_000) : patch; +} + +async function compareFiles(repoPath: string, baseSha: string, headSha: string, repoBase: string): Promise<Record<string, unknown>[]> { + const names = parseNameStatus(await gitOk(repoPath, ['diff', '--name-status', '--find-renames', baseSha, headSha])); + const stats = parseNumStats(await gitOk(repoPath, ['diff', '--numstat', '--find-renames', baseSha, headSha])); + const files: Record<string, unknown>[] = []; + + for (const file of names) { + const pathForBlob = file.status === 'removed' ? file.previousFilename ?? file.filename : file.filename; + const blobRef = file.status === 'removed' ? baseSha : headSha; + const sha = await blobShaAtPath(repoPath, blobRef, pathForBlob); + const fileStats = stats.get(file.filename) ?? (file.previousFilename ? stats.get(file.previousFilename) : undefined) ?? { + additions : 0, + deletions : 0, + }; + const encodedPath = encodePath(file.filename); + const encodedRef = encodeURIComponent(blobRef); + const patch = await filePatch(repoPath, baseSha, headSha, file.filename); + + files.push({ + sha, + filename : file.filename, + status : file.status, + additions : fileStats.additions, + deletions : fileStats.deletions, + changes : fileStats.additions + fileStats.deletions, + blob_url : `${repoBase}/blob/${encodedRef}/${encodedPath}`, + raw_url : `${repoBase}/raw/${encodedRef}/${encodedPath}`, + contents_url : `${repoBase}/contents/${encodedPath}?ref=${encodedRef}`, + ...(patch ? { patch } : {}), + ...(file.previousFilename ? { previous_filename: file.previousFilename } : {}), + }); + } + + return files; +} + +// --------------------------------------------------------------------------- +// URL/response builders +// --------------------------------------------------------------------------- + +function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + +function repoApiBase(baseUrl: string, targetDid: string, repoName: string): string { + return `${baseUrl}/repos/${targetDid}/${repoName}`; +} + +function nodeId(kind: string, id: string): string { + return Buffer.from(`${kind}:${id}`, 'utf-8').toString('base64'); +} + +function archivePrefix(repoName: string, commitSha: string): string { + const safeRepo = repoName.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repo'; + return `${safeRepo}-${commitSha.slice(0, 7)}`; +} + +function archiveFilename(repoName: string, commitSha: string, kind: ArchiveKind): string { + const ext = kind === 'tarball' ? 'tar.gz' : 'zip'; + return `${archivePrefix(repoName, commitSha)}.${ext}`; +} + +function buildContributorResponse(contributor: GitContributor, baseUrl: string): Record<string, unknown> { + const login = contributorLogin(contributor); + const encodedLogin = encodeURIComponent(login); + const idSource = contributorKey(contributor); + + return { + login, + id : numericId(idSource), + node_id : nodeId('contributor', idSource), + avatar_url : '', + gravatar_id : '', + url : `${baseUrl}/users/${encodedLogin}`, + html_url : `${baseUrl}/users/${encodedLogin}`, + followers_url : `${baseUrl}/users/${encodedLogin}/followers`, + following_url : `${baseUrl}/users/${encodedLogin}/following{/other_user}`, + gists_url : `${baseUrl}/users/${encodedLogin}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${encodedLogin}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${encodedLogin}/subscriptions`, + organizations_url : `${baseUrl}/users/${encodedLogin}/orgs`, + repos_url : `${baseUrl}/users/${encodedLogin}/repos`, + events_url : `${baseUrl}/users/${encodedLogin}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${encodedLogin}/received_events`, + type : 'User', + site_admin : false, + contributions : contributor.contributions, + name : contributor.name, + email : contributor.email || null, + }; +} + +function verification(): Record<string, unknown> { + return { + verified : false, + reason : 'unsigned', + signature : null, + payload : null, + verified_at : null, + }; +} + +function buildGitTreeEntry(entry: TreeEntry, repoBase: string): Record<string, unknown> { + const kind = entry.type === 'tree' ? 'trees' : entry.type === 'commit' ? 'commits' : 'blobs'; + return { + path : entry.path, + mode : entry.mode, + type : entry.type, + sha : entry.sha, + ...(entry.size !== undefined && entry.type === 'blob' ? { size: entry.size } : {}), + url : `${repoBase}/git/${kind}/${entry.sha}`, + }; +} + +function buildCommitParents(parentShas: string[], repoBase: string): Record<string, unknown>[] { + return parentShas.map((sha) => ({ + sha, + url : `${repoBase}/git/commits/${sha}`, + html_url : `${repoBase}/commit/${sha}`, + })); +} + +function buildGitCommitResponse(commit: GitCommitInfo, repoBase: string): Record<string, unknown> { + return { + sha : commit.sha, + node_id : nodeId('commit', commit.sha), + url : `${repoBase}/git/commits/${commit.sha}`, + html_url : `${repoBase}/commit/${commit.sha}`, + author : commit.author, + committer : commit.committer, + message : commit.message, + tree : { + sha : commit.treeSha, + url : `${repoBase}/git/trees/${commit.treeSha}`, + }, + parents : buildCommitParents(commit.parentShas, repoBase), + verification : verification(), + }; +} + +function buildRepoCommitResponse(commit: GitCommitInfo, repoBase: string): Record<string, unknown> { + return { + sha : commit.sha, + node_id : nodeId('repo-commit', commit.sha), + commit : { + author : commit.author, + committer : commit.committer, + message : commit.message, + tree : { sha: commit.treeSha, url: `${repoBase}/git/trees/${commit.treeSha}` }, + url : `${repoBase}/git/commits/${commit.sha}`, + comment_count : 0, + verification : verification(), + }, + url : `${repoBase}/commits/${commit.sha}`, + html_url : `${repoBase}/commit/${commit.sha}`, + comments_url : `${repoBase}/commits/${commit.sha}/comments`, + author : null, + committer : null, + parents : buildCommitParents(commit.parentShas, repoBase), + }; +} + +function contentType(entry: TreeEntry): 'file' | 'dir' | 'submodule' { + if (entry.type === 'tree') { + return 'dir'; + } + if (entry.type === 'commit') { + return 'submodule'; + } + return 'file'; +} + +function rawBlobContentType(path: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) { return 'text/markdown; charset=utf-8'; } + if (lower.endsWith('.html') || lower.endsWith('.htm')) { return 'text/html; charset=utf-8'; } + if (lower.endsWith('.css')) { return 'text/css; charset=utf-8'; } + if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) { return 'application/javascript; charset=utf-8'; } + if (lower.endsWith('.json')) { return 'application/json; charset=utf-8'; } + if (lower.endsWith('.ts') || lower.endsWith('.tsx')) { return 'application/typescript; charset=utf-8'; } + if (lower.endsWith('.txt') || lower === 'license') { return 'text/plain; charset=utf-8'; } + return 'application/octet-stream'; +} + +function renderBlobHtml(path: string, content: Buffer): string { + const text = content.toString('utf-8'); + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) { + return renderMarkdownText(text); + } + return `<pre>${text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>')}</pre>`; +} + +function buildContentUrls(repoBase: string, path: string, ref: string, entry: TreeEntry): Record<string, unknown> { + const encodedPath = encodePath(path); + const encodedRef = encodeURIComponent(ref); + const gitKind = entry.type === 'tree' ? 'trees' : entry.type === 'commit' ? 'commits' : 'blobs'; + const urls = { + url : `${repoBase}/contents/${encodedPath}`, + html_url : `${repoBase}/blob/${encodedRef}/${encodedPath}`, + git_url : `${repoBase}/git/${gitKind}/${entry.sha}`, + download_url : entry.type === 'blob' ? `${repoBase}/raw/${encodedRef}/${encodedPath}` : null, + }; + + return { + ...urls, + _links: { + self : urls.url, + git : urls.git_url, + html : urls.html_url, + }, + }; +} + +function buildContentEntry(repoBase: string, ref: string, entry: TreeEntry): Record<string, unknown> { + return { + name : basename(entry.path), + path : entry.path, + sha : entry.sha, + size : entry.type === 'blob' ? entry.size ?? 0 : 0, + type : contentType(entry), + ...buildContentUrls(repoBase, entry.path, ref, entry), + }; +} + +function buildFileContent(repoBase: string, ref: string, entry: TreeEntry, content: Buffer): Record<string, unknown> { + return { + ...buildContentEntry(repoBase, ref, entry), + content : content.toString('base64'), + encoding : 'base64', + }; +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +export async function handleGetGitBlob( + ctx: AgentContext, targetDid: string, repoName: string, sha: string, url: URL, options: GitObjectOptions, + mediaKind?: 'raw' | null, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const blob = await readBlob(repo.repoPath, sha); + if (!blob) { + return jsonNotFound(`Git blob '${sha}' not found.`); + } + + if (mediaKind === 'raw') { + return binaryOk(blob, 'application/octet-stream'); + } + + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + return jsonOk({ + sha : sha.toLowerCase(), + node_id : nodeId('blob', sha.toLowerCase()), + size : blob.byteLength, + url : `${repoBase}/git/blobs/${sha.toLowerCase()}`, + content : blob.toString('base64'), + encoding : 'base64', + }); +} + +export async function handleGetGitTree( + ctx: AgentContext, targetDid: string, repoName: string, rawTree: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const treeSha = await resolveObject(repo.repoPath, rawTree, 'tree'); + if (!treeSha) { + return jsonNotFound(`Git tree '${decodeRouteParam(rawTree)}' not found.`); + } + + const recursive = url.searchParams.get('recursive'); + const entries = await listTree(repo.repoPath, treeSha, recursive === '1' || recursive === 'true'); + if (!entries) { + return jsonNotFound(`Git tree '${decodeRouteParam(rawTree)}' not found.`); + } + + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + return jsonOk({ + sha : treeSha, + url : `${repoBase}/git/trees/${treeSha}`, + tree : entries.map((entry) => buildGitTreeEntry(entry, repoBase)), + truncated : false, + }); +} + +export async function handleGetGitCommit( + ctx: AgentContext, targetDid: string, repoName: string, rawCommit: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const commit = await readCommit(repo.repoPath, rawCommit); + if (!commit) { + return jsonNotFound(`Git commit '${decodeRouteParam(rawCommit)}' not found.`); + } + + const baseUrl = buildApiUrl(url); + return jsonOk(buildGitCommitResponse(commit, repoApiBase(baseUrl, targetDid, repoName))); +} + +export async function tryGetGitRawContent( + ctx: AgentContext, targetDid: string, repoName: string, rawRef: string, rawPath: string, options: GitObjectOptions, +): Promise<ContentLookup> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return { kind: 'missing-local-repo' }; + } + + const normalizedPath = normalizeContentPath(rawPath); + if (!normalizedPath) { + return { kind: 'response', response: jsonNotFound('Content path not found.') }; + } + + const commitSha = await resolveObject(repo.repoPath, rawRef, 'commit'); + if (!commitSha) { + return { kind: 'response', response: jsonNotFound(`Git ref '${decodeRouteParam(rawRef)}' not found.`) }; + } + + const entry = await treeEntryAtPath(repo.repoPath, commitSha, normalizedPath); + if (!entry || entry.type !== 'blob') { + return { kind: 'response', response: jsonNotFound(`Content '${normalizedPath}' not found.`) }; + } + + const blob = await readBlob(repo.repoPath, entry.sha); + if (!blob) { + return { kind: 'response', response: jsonNotFound(`Content '${normalizedPath}' not found.`) }; + } + + return { kind: 'response', response: binaryOk(blob, rawBlobContentType(normalizedPath)) }; +} + +export async function handleCreateGitBlob( + ctx: AgentContext, + targetDid: string, + repoName: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const content = decodeGitBlobContent(body); + if (!Buffer.isBuffer(content)) { + return content; + } + + const sha = await writeGitBlob(repo.repoPath, content); + if (typeof sha !== 'string') { + return sha; + } + + const repoBase = repoApiBase(buildApiUrl(url), targetDid, repoName); + return jsonCreated({ + sha, + url: `${repoBase}/git/blobs/${sha}`, + }); +} + +export async function handleCreateGitTree( + ctx: AgentContext, + targetDid: string, + repoName: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const sha = await createGitTreeObject(repo.repoPath, body); + if (typeof sha !== 'string') { + return sha; + } + + const entries = await listTree(repo.repoPath, sha, false); + if (!entries) { + return jsonValidationError('Failed to read created git tree.'); + } + + const repoBase = repoApiBase(buildApiUrl(url), targetDid, repoName); + return jsonCreated({ + sha, + url : `${repoBase}/git/trees/${sha}`, + tree : entries.map((entry) => buildGitTreeEntry(entry, repoBase)), + truncated : false, + }); +} + +export async function handleCreateGitCommit( + ctx: AgentContext, + targetDid: string, + repoName: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const message = requiredString(body, 'message'); + if (typeof message !== 'string') { + return message; + } + + if (typeof body.tree !== 'string') { + return jsonValidationError('Validation Failed: tree is required.'); + } + const treeSha = await resolveObject(repo.repoPath, body.tree, 'tree'); + if (!treeSha) { + return jsonNotFound(`Tree '${body.tree}' not found.`); + } + + const parents = await parseCommitParents(repo.repoPath, body.parents); + if (!Array.isArray(parents)) { + return parents; + } + + const author = optionalIdentity(body.author, 'author', DEFAULT_GIT_IDENTITY); + if ('status' in author) { + return author; + } + const committer = optionalIdentity(body.committer, 'committer', author); + if ('status' in committer) { + return committer; + } + + if (body.signature !== undefined) { + return jsonValidationError('Validation Failed: signature is not supported by this shim.'); + } + + const args = ['commit-tree', treeSha]; + for (const parent of parents) { + args.push('-p', parent); + } + args.push('-F', '-'); + + const result = await runGit(repo.repoPath, args, commitEnv(author, committer), message); + const sha = result.stdout.toString('utf-8').trim(); + if (result.status !== 0 || !FULL_SHA_RE.test(sha)) { + return jsonValidationError(`Failed to create git commit: ${gitError(result)}`); + } + + const commit = await readCommit(repo.repoPath, sha); + if (!commit) { + return jsonValidationError('Failed to read created git commit.'); + } + + return jsonCreated(buildGitCommitResponse(commit, repoApiBase(buildApiUrl(url), targetDid, repoName))); +} + +export async function handleCreateGitTag( + ctx: AgentContext, + targetDid: string, + repoName: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const tagName = cleanTagName(body.tag); + if (typeof tagName !== 'string') { + return tagName; + } + + const message = requiredString(body, 'message'); + if (typeof message !== 'string') { + return message; + } + + const targetSha = requiredSha(body.object, 'object'); + if (typeof targetSha !== 'string') { + return targetSha; + } + if (body.type !== 'blob' && body.type !== 'tree' && body.type !== 'commit') { + return jsonValidationError('Validation Failed: type must be blob, tree, or commit.'); + } + const requestedType: GitTagTargetType = body.type; + + const actualType = await objectType(repo.repoPath, targetSha); + if (actualType !== requestedType) { + return jsonNotFound(`Git object '${targetSha}' not found.`); + } + + const tagger = optionalIdentity(body.tagger, 'tagger', DEFAULT_GIT_IDENTITY); + if ('status' in tagger) { + return tagger; + } + + const rawTag = [ + `object ${targetSha}`, + `type ${requestedType}`, + `tag ${tagName}`, + `tagger ${taggerLine(tagger)}`, + '', + message, + '', + ].join('\n'); + const result = await runGit(repo.repoPath, ['mktag'], undefined, rawTag); + const tagSha = result.stdout.toString('utf-8').trim(); + if (result.status !== 0 || !FULL_SHA_RE.test(tagSha)) { + return jsonValidationError(`Failed to create git tag object: ${gitError(result)}`); + } + + const tag = await readTag(repo.repoPath, tagSha); + if (!tag) { + return jsonValidationError('Failed to read created git tag object.'); + } + + return jsonCreated(buildGitTagResponse(tag, repoApiBase(buildApiUrl(url), targetDid, repoName))); +} + +export async function handleGetGitTag( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawTag: string, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const tag = await readTag(repo.repoPath, rawTag); + if (!tag) { + return jsonNotFound(`Git tag '${decodeRouteParam(rawTag)}' not found.`); + } + + return jsonOk(buildGitTagResponse(tag, repoApiBase(buildApiUrl(url), targetDid, repoName))); +} + +export async function handleCreateGitRef( + ctx: AgentContext, + targetDid: string, + repoName: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const rawRef = requiredString(body, 'ref'); + if (typeof rawRef !== 'string') { + return rawRef; + } + const ref = normalizeGitReference(rawRef); + if (!ref) { + return jsonValidationError('Validation Failed: ref must be a fully qualified git reference.'); + } + + const sha = requiredSha(body.sha, 'sha'); + if (typeof sha !== 'string') { + return sha; + } + const type = await objectType(repo.repoPath, sha); + if (!type) { + return jsonNotFound(`Git object '${sha}' not found.`); + } + if (ref.startsWith('refs/heads/') && type !== 'commit') { + return jsonValidationError('Validation Failed: branch references must point to commits.'); + } + if (!await hasLocalHead(repo.repoPath)) { + return jsonConflict('Git repository is empty.'); + } + if (await localRefExists(repo.repoPath, ref)) { + return jsonConflict(`Reference '${ref}' already exists.`); + } + + const updateError = await updateLocalRef(repo.repoPath, ref, sha); + if (updateError) { + return updateError; + } + await syncRefsAfterContentWrite(ctx, targetDid, repoName, repo.repoPath); + + return jsonCreated(buildGitRefObjectResponse(ref, sha, type, repoApiBase(buildApiUrl(url), targetDid, repoName))); +} + +export async function handleUpdateGitRef( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawRef: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const ref = normalizeRouteGitReference(rawRef); + if (!ref) { + return jsonValidationError('Validation Failed: ref is invalid.'); + } + + const sha = requiredSha(body.sha, 'sha'); + if (typeof sha !== 'string') { + return sha; + } + const type = await objectType(repo.repoPath, sha); + if (!type) { + return jsonNotFound(`Git object '${sha}' not found.`); + } + if (ref.startsWith('refs/heads/') && type !== 'commit') { + return jsonValidationError('Validation Failed: branch references must point to commits.'); + } + + const currentSha = await localRefSha(repo.repoPath, ref); + if (!currentSha) { + return jsonNotFound(`Reference '${ref}' not found.`); + } + + const force = body.force === true; + if (!force && currentSha !== sha) { + const currentType = await objectType(repo.repoPath, currentSha); + if (currentType !== 'commit' || type !== 'commit') { + return jsonConflict('Reference update is not a fast-forward.'); + } + const ancestor = await runGit(repo.repoPath, ['merge-base', '--is-ancestor', currentSha, sha]); + if (ancestor.status !== 0) { + return jsonConflict('Reference update is not a fast-forward.'); + } + } + + const updateError = await updateLocalRef(repo.repoPath, ref, sha); + if (updateError) { + return updateError; + } + await syncRefsAfterContentWrite(ctx, targetDid, repoName, repo.repoPath); + + return jsonOk(buildGitRefObjectResponse(ref, sha, type, repoApiBase(buildApiUrl(url), targetDid, repoName))); +} + +export async function handleDeleteGitRef( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawRef: string, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const ref = normalizeRouteGitReference(rawRef); + if (!ref) { + return jsonValidationError('Validation Failed: ref is invalid.'); + } + + if (!await localRefExists(repo.repoPath, ref)) { + return jsonNotFound(`Reference '${ref}' not found.`); + } + + try { + const repoRecord = await getRepoRecord(ctx, targetDid, repoName); + if (repoRecord?.defaultBranch && ref === branchRef(repoRecord.defaultBranch)) { + return jsonValidationError('Validation Failed: cannot delete the default branch.'); + } + } catch { + // A local git repository can still delete non-default refs if metadata is unavailable. + } + + const result = await runGit(repo.repoPath, ['update-ref', '-d', ref]); + if (result.status !== 0) { + return jsonValidationError(`Failed to delete reference '${ref}': ${gitError(result)}`); + } + await syncRefsAfterContentWrite(ctx, targetDid, repoName, repo.repoPath); + + return jsonNoContent(); +} + +export async function handleListRepoCommits( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const rawRef = url.searchParams.get('sha') ?? await defaultCommitRef(ctx, targetDid, repoName); + const commitSha = await resolveObject(repo.repoPath, rawRef, 'commit'); + if (!commitSha) { + return jsonNotFound(`Commit ref '${decodeRouteParam(rawRef)}' not found.`); + } + + const normalizedPath = normalizeContentPath(url.searchParams.get('path')); + if (normalizedPath === null) { + return jsonNotFound('Commit path not found.'); + } + + const shas = await listCommitShas(repo.repoPath, commitSha, url, normalizedPath || null); + if (!shas) { + return jsonNotFound(`Commit ref '${decodeRouteParam(rawRef)}' not found.`); + } + + const commits = (await Promise.all(shas.map((sha) => readCommit(repo.repoPath, sha)))) + .filter((commit): commit is GitCommitInfo => commit !== null); + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + return jsonOk(commits.map((commit) => buildRepoCommitResponse(commit, repoBase))); +} + +export async function handleListContributors( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const raw = await gitOk(repo.repoPath, ['shortlog', '-sne', '--all']); + if (raw === null) { + return jsonNotFound(`Contributors for repository '${repoName}' not found.`); + } + + const anon = url.searchParams.get('anon')?.toLowerCase(); + const includeAnonymous = anon === '1' || anon === 'true'; + const contributors = parseShortlogContributors(raw) + .filter((contributor) => includeAnonymous || contributor.email); + if (contributors.length === 0) { + return jsonNoContent(); + } + + const pagination = parsePagination(url); + const paged = paginate(contributors, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/contributors`, + pagination.page, pagination.perPage, contributors.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(paged.map((contributor) => buildContributorResponse(contributor, baseUrl)), extraHeaders); +} + +export async function handleDownloadArchive( + ctx: AgentContext, + targetDid: string, + repoName: string, + kind: ArchiveKind, + rawRef: string | null, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const ref = rawRef ? decodeRouteParam(rawRef) : await defaultCommitRef(ctx, targetDid, repoName); + const commitSha = await resolveObject(repo.repoPath, ref, 'commit'); + if (!commitSha) { + return jsonNotFound(`Archive ref '${ref}' not found.`); + } + + const download = url.searchParams.get('download'); + if (download !== '1' && download !== 'true') { + const location = new URL(url.href); + location.searchParams.set('download', '1'); + return redirectFound(location.toString()); + } + + const gitFormat = kind === 'tarball' ? 'tar.gz' : 'zip'; + const result = await runGit(repo.repoPath, [ + 'archive', + `--format=${gitFormat}`, + `--prefix=${archivePrefix(repoName, commitSha)}/`, + commitSha, + ]); + if (result.status !== 0 || result.stdout.byteLength === 0) { + return jsonNotFound(`Archive for ref '${ref}' not found.`); + } + + return binaryOk( + result.stdout, + kind === 'tarball' ? 'application/gzip' : 'application/zip', + { 'Content-Disposition': `attachment; filename="${archiveFilename(repoName, commitSha, kind)}"` }, + ); +} + +export async function handleGetRepoCommit( + ctx: AgentContext, targetDid: string, repoName: string, rawCommit: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const commit = await readCommit(repo.repoPath, rawCommit); + if (!commit) { + return jsonNotFound(`Commit '${decodeRouteParam(rawCommit)}' not found.`); + } + + const baseUrl = buildApiUrl(url); + return jsonOk(buildRepoCommitResponse(commit, repoApiBase(baseUrl, targetDid, repoName))); +} + +export async function handleGetRepoCommitMedia( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawCommit: string, + kind: CommitMediaKind, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const commitSha = await resolveObject(repo.repoPath, rawCommit, 'commit'); + if (!commitSha) { + return jsonNotFound(`Commit '${decodeRouteParam(rawCommit)}' not found.`); + } + + if (kind === 'sha') { + return binaryOk(Buffer.from(`${commitSha}\n`, 'utf-8'), 'application/vnd.github.sha; charset=utf-8'); + } + + const args = kind === 'diff' + ? ['show', '--format=', '--patch', '--find-renames', commitSha] + : ['format-patch', '--stdout', '--find-renames', '-1', commitSha]; + const result = await runGit(repo.repoPath, args); + if (result.status !== 0) { + return jsonNotFound(`Commit ${kind} for '${decodeRouteParam(rawCommit)}' not found.`); + } + + return binaryOk( + result.stdout, + kind === 'diff' ? 'application/vnd.github.diff; charset=utf-8' : 'application/vnd.github.patch; charset=utf-8', + ); +} + +export async function handleCompareCommits( + ctx: AgentContext, targetDid: string, repoName: string, rawBase: string, rawHead: string, url: URL, options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseSha = await resolveObject(repo.repoPath, rawBase, 'commit'); + const headSha = await resolveObject(repo.repoPath, rawHead, 'commit'); + if (!baseSha) { + return jsonNotFound(`Base commit '${decodeRouteParam(rawBase)}' not found.`); + } + if (!headSha) { + return jsonNotFound(`Head commit '${decodeRouteParam(rawHead)}' not found.`); + } + + const baseCommit = await readCommit(repo.repoPath, baseSha); + const headCommit = await readCommit(repo.repoPath, headSha); + if (!baseCommit || !headCommit) { + return jsonNotFound('Compare commits not found.'); + } + + const aheadBy = await revListCount(repo.repoPath, `${baseSha}..${headSha}`); + const behindBy = await revListCount(repo.repoPath, `${headSha}..${baseSha}`); + const comparedShas = await compareCommitShas(repo.repoPath, baseSha, headSha); + if (!comparedShas) { + return jsonNotFound('Compare commits not found.'); + } + + const commits = (await Promise.all(comparedShas.map((sha) => readCommit(repo.repoPath, sha)))) + .filter((commit): commit is GitCommitInfo => commit !== null); + const mergeBaseSha = await mergeBase(repo.repoPath, baseSha, headSha); + const mergeBaseCommit = mergeBaseSha ? await readCommit(repo.repoPath, mergeBaseSha) : null; + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + const encodedBase = encodeURIComponent(decodeRouteParam(rawBase)); + const encodedHead = encodeURIComponent(decodeRouteParam(rawHead)); + + return jsonOk({ + url : `${repoBase}/compare/${encodedBase}...${encodedHead}`, + html_url : `${repoBase}/compare/${encodedBase}...${encodedHead}`, + permalink_url : `${repoBase}/compare/${baseSha}...${headSha}`, + diff_url : `${repoBase}/compare/${baseSha}...${headSha}.diff`, + patch_url : `${repoBase}/compare/${baseSha}...${headSha}.patch`, + base_commit : buildRepoCommitResponse(baseCommit, repoBase), + merge_base_commit : buildRepoCommitResponse(mergeBaseCommit ?? baseCommit, repoBase), + status : compareStatus(aheadBy, behindBy), + ahead_by : aheadBy, + behind_by : behindBy, + total_commits : aheadBy, + commits : commits.map((commit) => buildRepoCommitResponse(commit, repoBase)), + files : await compareFiles(repo.repoPath, baseSha, headSha, repoBase), + head_commit : buildRepoCommitResponse(headCommit, repoBase), + }); +} + +export async function handleCompareCommitText( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawBase: string, + rawHead: string, + kind: CompareTextKind, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseSha = await resolveObject(repo.repoPath, rawBase, 'commit'); + const headSha = await resolveObject(repo.repoPath, rawHead, 'commit'); + if (!baseSha) { + return jsonNotFound(`Base commit '${decodeRouteParam(rawBase)}' not found.`); + } + if (!headSha) { + return jsonNotFound(`Head commit '${decodeRouteParam(rawHead)}' not found.`); + } + + const args = kind === 'diff' + ? ['diff', '--patch', '--find-renames', baseSha, headSha] + : ['format-patch', '--stdout', '--find-renames', `${baseSha}..${headSha}`]; + const result = await runGit(repo.repoPath, args); + if (result.status !== 0) { + return jsonNotFound(`Compare ${kind} for '${decodeRouteParam(rawBase)}...${decodeRouteParam(rawHead)}' not found.`); + } + + return binaryOk( + result.stdout, + kind === 'diff' ? 'application/vnd.github.diff; charset=utf-8' : 'application/vnd.github.patch; charset=utf-8', + ); +} + +export async function handlePutGitContents( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawPath: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const normalizedPath = normalizeContentPath(rawPath); + if (!normalizedPath) { + return jsonNotFound('Content path not found.'); + } + + const message = requiredString(body, 'message'); + if (typeof message !== 'string') { + return message; + } + + const content = decodeBase64Content(body); + if (!Buffer.isBuffer(content)) { + return content; + } + + const requestSha = optionalBlobSha(body); + if (requestSha && typeof requestSha !== 'string') { + return requestSha; + } + + const author = optionalIdentity(body.author, 'author', DEFAULT_GIT_IDENTITY); + if ('status' in author) { + return author; + } + const committer = optionalIdentity(body.committer, 'committer', DEFAULT_GIT_IDENTITY); + if ('status' in committer) { + return committer; + } + + const branch = await requestedWriteBranch(ctx, targetDid, repoName, repo.repoPath, body); + if (typeof branch !== 'string') { + return branch; + } + + const currentCommitSha = await resolveObject(repo.repoPath, branchRef(branch), 'commit'); + if (!currentCommitSha) { + return jsonNotFound(`Branch '${branch}' not found.`); + } + + const currentEntry = await treeEntryAtPath(repo.repoPath, currentCommitSha, normalizedPath); + if (currentEntry && currentEntry.type !== 'blob') { + return jsonConflict(`Content '${normalizedPath}' is not a file.`); + } + + const currentSha = currentEntry?.sha ?? null; + if (currentSha && !requestSha) { + return jsonConflict(`Content '${normalizedPath}' already exists. The sha field is required for updates.`); + } + if (!currentSha && requestSha) { + return jsonConflict(`Content '${normalizedPath}' does not exist.`); + } + if (currentSha && requestSha !== currentSha) { + return jsonConflict(`sha does not match Content '${normalizedPath}'.`); + } + + const commitSha = await commitViaWorktree( + repo.repoPath, + branch, + message, + author, + committer, + async (workDir) => { + const filePath = join(workDir, ...normalizedPath.split('/')); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const add = await runGit(workDir, ['add', '--', normalizedPath]); + if (add.status !== 0) { + return jsonValidationError(`Failed to stage content '${normalizedPath}': ${gitError(add)}`); + } + return undefined; + }, + ); + if (typeof commitSha !== 'string') { + return commitSha; + } + + await syncRefsAfterContentWrite(ctx, targetDid, repoName, repo.repoPath); + + const commit = await readCommit(repo.repoPath, commitSha); + const entry = await treeEntryAtPath(repo.repoPath, commitSha, normalizedPath); + if (!commit || !entry || entry.type !== 'blob') { + return jsonValidationError('Failed to read committed contents change.'); + } + + const blob = await readBlob(repo.repoPath, entry.sha); + if (!blob) { + return jsonValidationError('Failed to read committed content blob.'); + } + + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + const response = { + content : buildFileContent(repoBase, branch, entry, blob), + commit : buildRepoCommitResponse(commit, repoBase), + }; + return currentSha ? jsonOk(response) : jsonCreated(response); +} + +export async function handleDeleteGitContents( + ctx: AgentContext, + targetDid: string, + repoName: string, + rawPath: string, + body: Record<string, unknown>, + url: URL, + options: GitObjectOptions, +): Promise<JsonResponse> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return jsonNotFound(`Local git repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const normalizedPath = normalizeContentPath(rawPath); + if (!normalizedPath) { + return jsonNotFound('Content path not found.'); + } + + const message = requiredString(body, 'message'); + if (typeof message !== 'string') { + return message; + } + + const requestSha = optionalBlobSha(body); + if (!requestSha) { + return jsonValidationError('Validation Failed: sha is required.'); + } + if (typeof requestSha !== 'string') { + return requestSha; + } + + const author = optionalIdentity(body.author, 'author', DEFAULT_GIT_IDENTITY); + if ('status' in author) { + return author; + } + const committer = optionalIdentity(body.committer, 'committer', DEFAULT_GIT_IDENTITY); + if ('status' in committer) { + return committer; + } + + const branch = await requestedWriteBranch(ctx, targetDid, repoName, repo.repoPath, body); + if (typeof branch !== 'string') { + return branch; + } + + const currentCommitSha = await resolveObject(repo.repoPath, branchRef(branch), 'commit'); + if (!currentCommitSha) { + return jsonNotFound(`Branch '${branch}' not found.`); + } + + const currentEntry = await treeEntryAtPath(repo.repoPath, currentCommitSha, normalizedPath); + if (!currentEntry) { + return jsonNotFound(`Content '${normalizedPath}' not found.`); + } + if (currentEntry.type !== 'blob') { + return jsonConflict(`Content '${normalizedPath}' is not a file.`); + } + if (currentEntry.sha !== requestSha) { + return jsonConflict(`sha does not match Content '${normalizedPath}'.`); + } + + const commitSha = await commitViaWorktree( + repo.repoPath, + branch, + message, + author, + committer, + async (workDir) => { + const remove = await runGit(workDir, ['rm', '--quiet', '--', normalizedPath]); + if (remove.status !== 0) { + return jsonValidationError(`Failed to stage content removal '${normalizedPath}': ${gitError(remove)}`); + } + return undefined; + }, + ); + if (typeof commitSha !== 'string') { + return commitSha; + } + + await syncRefsAfterContentWrite(ctx, targetDid, repoName, repo.repoPath); + + const commit = await readCommit(repo.repoPath, commitSha); + if (!commit) { + return jsonValidationError('Failed to read committed contents deletion.'); + } + + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + return jsonOk({ + content : null, + commit : buildRepoCommitResponse(commit, repoBase), + }); +} + +export async function tryGetGitContents( + ctx: AgentContext, targetDid: string, repoName: string, path: string | null, url: URL, options: GitObjectOptions, + mediaKind?: ContentMediaKind | null, +): Promise<ContentLookup> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return { kind: 'missing-local-repo' }; + } + + const normalizedPath = normalizeContentPath(path); + if (normalizedPath === null) { + return { kind: 'response', response: jsonNotFound('Content path not found.') }; + } + + const { ref, explicit } = await requestedContentRef(ctx, targetDid, repoName, url); + const commitSha = await resolveObject(repo.repoPath, ref, 'commit'); + if (!commitSha) { + if (!explicit) { + return { kind: 'missing-local-repo' }; + } + return { kind: 'response', response: jsonNotFound(`Git ref '${ref}' not found.`) }; + } + + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + + if (!normalizedPath) { + const rootEntries = await listDirectory(repo.repoPath, commitSha, ''); + if (!rootEntries) { + return { kind: 'response', response: jsonNotFound('Repository contents not found.') }; + } + const entries = rootEntries.map((entry) => buildContentEntry(repoBase, ref, entry)); + return { + kind : 'response', + response : jsonOk(mediaKind === 'object' ? { entries } : entries), + }; + } + + const entry = await treeEntryAtPath(repo.repoPath, commitSha, normalizedPath); + if (!entry) { + return { kind: 'response', response: jsonNotFound(`Content '${normalizedPath}' not found.`) }; + } + + if (entry.type === 'tree') { + const entries = await listDirectory(repo.repoPath, commitSha, normalizedPath); + if (!entries) { + return { kind: 'response', response: jsonNotFound(`Content '${normalizedPath}' not found.`) }; + } + const childEntries = entries.map((child) => buildContentEntry(repoBase, ref, child)); + return { + kind : 'response', + response : jsonOk(mediaKind === 'object' ? { entries: childEntries } : childEntries), + }; + } + + if (entry.type === 'commit') { + return { + kind : 'response', + response : jsonOk(buildContentEntry(repoBase, ref, entry)), + }; + } + + const blob = await readBlob(repo.repoPath, entry.sha); + if (!blob) { + return { kind: 'response', response: jsonNotFound(`Content '${normalizedPath}' not found.`) }; + } + + if (mediaKind === 'raw') { + return { kind: 'response', response: binaryOk(blob, rawBlobContentType(normalizedPath)) }; + } + + if (mediaKind === 'html') { + return { + kind : 'response', + response : binaryOk(Buffer.from(renderBlobHtml(normalizedPath, blob), 'utf-8'), 'text/html; charset=utf-8'), + }; + } + + return { + kind : 'response', + response : jsonOk(buildFileContent(repoBase, ref, entry, blob)), + }; +} + +export async function tryGetGitReadme( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: GitObjectOptions, + mediaKind?: ContentMediaKind | null, +): Promise<ContentLookup> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return { kind: 'missing-local-repo' }; + } + + const { ref } = await requestedContentRef(ctx, targetDid, repoName, url); + const commitSha = await resolveObject(repo.repoPath, ref, 'commit'); + if (!commitSha) { + return { kind: 'missing-local-repo' }; + } + + for (const candidate of README_CANDIDATES) { + const entry = await treeEntryAtPath(repo.repoPath, commitSha, candidate); + if (entry?.type === 'blob') { + const blob = await readBlob(repo.repoPath, entry.sha); + if (!blob) { + continue; + } + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + if (mediaKind === 'raw') { + return { kind: 'response', response: binaryOk(blob, rawBlobContentType(entry.path)) }; + } + if (mediaKind === 'html') { + return { + kind : 'response', + response : binaryOk(Buffer.from(renderBlobHtml(entry.path, blob), 'utf-8'), 'text/html; charset=utf-8'), + }; + } + return { + kind : 'response', + response : jsonOk(buildFileContent(repoBase, ref, entry, blob)), + }; + } + } + + return { kind: 'response', response: jsonNotFound('README not found.') }; +} + +export async function tryGetGitReadmeInDirectory( + ctx: AgentContext, targetDid: string, repoName: string, dir: string, url: URL, options: GitObjectOptions, + mediaKind?: ContentMediaKind | null, +): Promise<ContentLookup> { + const repo = localRepo(ctx, targetDid, repoName, options); + if (!repo) { + return { kind: 'missing-local-repo' }; + } + + const normalizedDir = normalizeContentPath(dir); + if (!normalizedDir) { + return { kind: 'response', response: jsonNotFound('README not found.') }; + } + + const { ref } = await requestedContentRef(ctx, targetDid, repoName, url); + const commitSha = await resolveObject(repo.repoPath, ref, 'commit'); + if (!commitSha) { + return { kind: 'missing-local-repo' }; + } + + for (const candidate of README_CANDIDATES) { + const entry = await treeEntryAtPath(repo.repoPath, commitSha, `${normalizedDir}/${candidate}`); + if (entry?.type === 'blob') { + const blob = await readBlob(repo.repoPath, entry.sha); + if (!blob) { + continue; + } + const baseUrl = buildApiUrl(url); + const repoBase = repoApiBase(baseUrl, targetDid, repoName); + if (mediaKind === 'raw') { + return { kind: 'response', response: binaryOk(blob, rawBlobContentType(entry.path)) }; + } + if (mediaKind === 'html') { + return { + kind : 'response', + response : binaryOk(Buffer.from(renderBlobHtml(entry.path, blob), 'utf-8'), 'text/html; charset=utf-8'), + }; + } + return { + kind : 'response', + response : jsonOk(buildFileContent(repoBase, ref, entry, blob)), + }; + } + } + + return { kind: 'response', response: jsonNotFound('README not found.') }; +} + +export async function tryGetGitLicense( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: GitObjectOptions, + mediaKind?: ContentMediaKind | null, +): Promise<ContentLookup> { + const contents = await tryGetGitContents(ctx, targetDid, repoName, 'LICENSE', url, options, mediaKind); + if (contents.kind !== 'response' || contents.response.status !== 200) { + return contents; + } + if (mediaKind === 'raw' || mediaKind === 'html') { + return contents; + } + + return { + kind : 'response', + response : { + ...contents.response, + body: JSON.stringify({ + ...JSON.parse(contents.response.body as string) as Record<string, unknown>, + license: null, + }), + }, + }; +} diff --git a/src/github-shim/git-refs.ts b/src/github-shim/git-refs.ts new file mode 100644 index 0000000..00b8f17 --- /dev/null +++ b/src/github-shim/git-refs.ts @@ -0,0 +1,1724 @@ +/** + * GitHub API shim — branch, tag, and git ref endpoints. + * + * Maps mirrored `forge-refs` DWN records to GitHub REST API v3 responses. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { + BranchProtectionRestrictionsData, + BranchProtectionRuleData, + SettingsData, +} from '../repo.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; + +import { GitBackend } from '../git-server/git-backend.js'; +import { resolveReposPath } from '../cli/flags.js'; + +import { rulesetProtectsBranch } from './repo-metadata.js'; +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type GitRefInfo = { + id : string; + nodeId : string; + name : string; + target : string; + type : 'branch' | 'tag'; +}; + +type BranchProtectionRule = BranchProtectionRuleData; + +type BranchProtectionRestrictions = BranchProtectionRestrictionsData; + +type ParsedRequiredChecks = { + checkApps : Record<string, number | null>; + contexts : string[]; + contextsProvided : boolean; + enabled : boolean; + strict? : boolean; + strictProvided : boolean; +}; + +type RequiredStatusCheck = { + app_id : number | null; + context : string; +}; + +type ParsedRequiredReviews = { + bypassAllowances? : BranchProtectionRestrictions; + bypassAllowancesProvided : boolean; + count : number; + countProvided : boolean; + dismissalRestrictions? : BranchProtectionRestrictions; + dismissalRestrictionsProvided : boolean; + dismissStaleReviews? : boolean; + dismissStaleReviewsProvided : boolean; + enabled : boolean; + requireCodeOwnerReviews? : boolean; + requireCodeOwnerReviewsProvided : boolean; + requireLastPushApproval? : boolean; + requireLastPushApprovalProvided : boolean; +}; + +export type BranchRestrictionKind = 'apps' | 'teams' | 'users'; + +type BranchProtectionBooleanRuleKey = + | 'allowDeletions' + | 'allowForcePushes' + | 'allowForkSyncing' + | 'blockCreations' + | 'enforceAdmins' + | 'lockBranch' + | 'requiredConversationResolution' + | 'requiredLinearHistory'; + +type BranchProtectionBooleanField = { + requestKey : string; + ruleKey : BranchProtectionBooleanRuleKey; +}; + +const BRANCH_PROTECTION_BOOLEAN_FIELDS: BranchProtectionBooleanField[] = [ + { requestKey: 'enforce_admins', ruleKey: 'enforceAdmins' }, + { requestKey: 'required_linear_history', ruleKey: 'requiredLinearHistory' }, + { requestKey: 'allow_force_pushes', ruleKey: 'allowForcePushes' }, + { requestKey: 'allow_deletions', ruleKey: 'allowDeletions' }, + { requestKey: 'block_creations', ruleKey: 'blockCreations' }, + { requestKey: 'required_conversation_resolution', ruleKey: 'requiredConversationResolution' }, + { requestKey: 'lock_branch', ruleKey: 'lockBranch' }, + { requestKey: 'allow_fork_syncing', ruleKey: 'allowForkSyncing' }, +]; + +type RepoSettingsData = SettingsData; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +type BranchProtectionLookup = { + lookup : RepoSettingsLookup; + branchName : string; + rule? : BranchProtectionRule; +}; + +export type GitRefOptions = { + /** Base directory where gitd stores bare repositories. */ + reposPath? : string; +}; + +// --------------------------------------------------------------------------- +// DWN reads +// --------------------------------------------------------------------------- + +async function listRefRecords( + ctx: AgentContext, targetDid: string, repo: RepoInfo, +): Promise<GitRefInfo[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.refs.records.query('repo/ref' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const refs: GitRefInfo[] = []; + for (const rec of records) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = data.name ?? tags.name ?? ''; + const target = data.target ?? tags.target ?? ''; + const rawType = data.type ?? tags.type ?? (name.startsWith('refs/tags/') ? 'tag' : 'branch'); + const type = rawType === 'tag' ? 'tag' : 'branch'; + + if (!name || !target) { continue; } + refs.push({ + id : rec.id ?? name, + nodeId : rec.id ?? '', + name, + target, + type, + }); + } + + refs.sort((a, b) => a.name.localeCompare(b.name)); + return refs; +} + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, repo: RepoInfo, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update branch protection: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create branch protection: ${status.detail}`); + } + return undefined; +} + +function shortRefName(refName: string): string { + return refName.replace(/^refs\/heads\//, '').replace(/^refs\/tags\//, ''); +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeGitRef(ref: string): string { + const decoded = decodeRouteParam(ref).replace(/^\/+/, ''); + return decoded.startsWith('refs/') ? decoded : `refs/${decoded}`; +} + +async function findBranchRef( + ctx: AgentContext, targetDid: string, repo: RepoInfo, branch: string, +): Promise<{ branchName: string; ref?: GitRefInfo }> { + const branchName = decodeRouteParam(branch); + const refs = await listRefRecords(ctx, targetDid, repo); + return { + branchName, + ref: refs.find(r => r.name === `refs/heads/${branchName}`), + }; +} + +function protectionRuleFor(settings: RepoSettingsData, branchName: string): BranchProtectionRule | undefined { + return settings.branchProtection?.[branchName]; +} + +function branchIsProtected(settings: RepoSettingsData, repo: RepoInfo, branchName: string): boolean { + return Boolean(protectionRuleFor(settings, branchName)) + || rulesetProtectsBranch(settings, branchName, repo.defaultBranch); +} + +function normalizeStringArray(value: unknown, fieldName: string): string[] | JsonResponse { + if (!Array.isArray(value)) { + return jsonValidationError(`${fieldName} must be an array of strings.`); + } + + const normalized: string[] = []; + for (const item of value) { + if (typeof item !== 'string') { + return jsonValidationError(`${fieldName} must be an array of strings.`); + } + if (!normalized.includes(item)) { + normalized.push(item); + } + } + return normalized; +} + +function parseRequiredCheckAppId(value: unknown, fieldName: string): number | null | JsonResponse { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < -1) { + return jsonValidationError(`${fieldName} must be an integer greater than or equal to -1.`); + } + return value; +} + +function parseRequiredChecks(value: unknown): ParsedRequiredChecks | JsonResponse { + if (value === undefined || value === null) { + return { + checkApps : {}, + contexts : [], + contextsProvided : false, + enabled : false, + strictProvided : false, + }; + } + if (typeof value !== 'object') { + return jsonValidationError('required_status_checks must be an object or null.'); + } + + const requiredStatusChecks = value as Record<string, unknown>; + const rawStrict = requiredStatusChecks.strict; + if (rawStrict !== undefined && rawStrict !== null && typeof rawStrict !== 'boolean') { + return jsonValidationError('required_status_checks.strict must be a boolean or null.'); + } + + const contexts = requiredStatusChecks.contexts === undefined + ? [] + : normalizeStringArray(requiredStatusChecks.contexts, 'required_status_checks.contexts'); + if (!Array.isArray(contexts)) { return contexts; } + + const checks = requiredStatusChecks.checks; + const combined = [...contexts]; + const checkApps: Record<string, number | null> = {}; + for (const context of contexts) { + checkApps[context] = null; + } + if (checks !== undefined) { + if (!Array.isArray(checks)) { + return jsonValidationError('required_status_checks.checks must be an array.'); + } + for (const [index, check] of checks.entries()) { + if (typeof check !== 'object' || check === null || typeof (check as Record<string, unknown>).context !== 'string') { + return jsonValidationError('required_status_checks.checks items must include a string context.'); + } + const rawCheck = check as Record<string, unknown>; + const appId = parseRequiredCheckAppId(rawCheck.app_id, `required_status_checks.checks[${index}].app_id`); + if (typeof appId !== 'number' && appId !== null) { return appId; } + + const context = rawCheck.context as string; + if (!combined.includes(context)) { + combined.push(context); + } + checkApps[context] = appId; + } + } + + return { + checkApps : checkApps, + contexts : combined, + contextsProvided : requiredStatusChecks.contexts !== undefined || checks !== undefined, + enabled : true, + strict : rawStrict === true, + strictProvided : typeof rawStrict === 'boolean', + }; +} + +function parseOptionalObjectBoolean( + source: Record<string, unknown>, key: string, fieldName: string, +): { provided: boolean; value: boolean } | JsonResponse { + const raw = source[key]; + if (raw === undefined || raw === null) { + return { provided: false, value: false }; + } + if (typeof raw !== 'boolean') { + return jsonValidationError(`${fieldName} must be a boolean.`); + } + return { provided: true, value: raw }; +} + +function parseRequiredReviews(value: unknown): ParsedRequiredReviews | JsonResponse { + if (value === undefined || value === null) { + return { + bypassAllowancesProvided : false, + count : 0, + countProvided : false, + dismissalRestrictionsProvided : false, + dismissStaleReviewsProvided : false, + enabled : false, + requireCodeOwnerReviewsProvided : false, + requireLastPushApprovalProvided : false, + }; + } + if (typeof value !== 'object') { + return jsonValidationError('required_pull_request_reviews must be an object or null.'); + } + + const requiredPullRequestReviews = value as Record<string, unknown>; + const rawCount = requiredPullRequestReviews.required_approving_review_count; + const countProvided = rawCount !== undefined && rawCount !== null; + if (countProvided && (typeof rawCount !== 'number' || !Number.isInteger(rawCount) || rawCount < 0 || rawCount > 6)) { + return jsonValidationError('required_pull_request_reviews.required_approving_review_count must be an integer between 0 and 6.'); + } + + const dismissalRestrictionsProvided = requiredPullRequestReviews.dismissal_restrictions !== undefined; + const dismissalRestrictions = dismissalRestrictionsProvided + ? parseOptionalBranchProtectionRestrictions( + requiredPullRequestReviews.dismissal_restrictions, + 'required_pull_request_reviews.dismissal_restrictions', + ) + : emptyBranchProtectionRestrictions(); + if ('status' in dismissalRestrictions) { return dismissalRestrictions; } + + const bypassAllowancesProvided = requiredPullRequestReviews.bypass_pull_request_allowances !== undefined; + const bypassAllowances = bypassAllowancesProvided + ? parseOptionalBranchProtectionRestrictions( + requiredPullRequestReviews.bypass_pull_request_allowances, + 'required_pull_request_reviews.bypass_pull_request_allowances', + ) + : emptyBranchProtectionRestrictions(); + if ('status' in bypassAllowances) { return bypassAllowances; } + + const dismissStaleReviews = parseOptionalObjectBoolean( + requiredPullRequestReviews, + 'dismiss_stale_reviews', + 'required_pull_request_reviews.dismiss_stale_reviews', + ); + if ('status' in dismissStaleReviews) { return dismissStaleReviews; } + + const requireCodeOwnerReviews = parseOptionalObjectBoolean( + requiredPullRequestReviews, + 'require_code_owner_reviews', + 'required_pull_request_reviews.require_code_owner_reviews', + ); + if ('status' in requireCodeOwnerReviews) { return requireCodeOwnerReviews; } + + const requireLastPushApproval = parseOptionalObjectBoolean( + requiredPullRequestReviews, + 'require_last_push_approval', + 'required_pull_request_reviews.require_last_push_approval', + ); + if ('status' in requireLastPushApproval) { return requireLastPushApproval; } + + return { + bypassAllowances : bypassAllowances, + bypassAllowancesProvided : bypassAllowancesProvided, + count : countProvided ? rawCount as number : 0, + countProvided : countProvided, + dismissalRestrictions : dismissalRestrictions, + dismissalRestrictionsProvided : dismissalRestrictionsProvided, + dismissStaleReviews : dismissStaleReviews.value, + dismissStaleReviewsProvided : dismissStaleReviews.provided, + enabled : true, + requireCodeOwnerReviews : requireCodeOwnerReviews.value, + requireCodeOwnerReviewsProvided : requireCodeOwnerReviews.provided, + requireLastPushApproval : requireLastPushApproval.value, + requireLastPushApprovalProvided : requireLastPushApproval.provided, + }; +} + +function parseOptionalBranchProtectionBoolean(value: unknown, fieldName: string): boolean | JsonResponse { + if (value === undefined || value === null) { return false; } + if (typeof value !== 'boolean') { + return jsonValidationError(`${fieldName} must be a boolean or null.`); + } + return value; +} + +function parseBranchProtectedFilter(url: URL): boolean | undefined | JsonResponse { + const value = url.searchParams.get('protected'); + if (value === null) { return undefined; } + if (value === 'true') { return true; } + if (value === 'false') { return false; } + return jsonValidationError('Validation Failed: protected must be true or false.'); +} + +function parseRequiredRestrictionList(value: unknown, fieldName: string): string[] | JsonResponse { + if (value === undefined) { + return jsonValidationError(`${fieldName} must be an array of strings.`); + } + return normalizeStringArray(value, fieldName); +} + +function parseOptionalRestrictionList(value: unknown, fieldName: string): string[] | JsonResponse { + if (value === undefined || value === null) { return []; } + return normalizeStringArray(value, fieldName); +} + +function totalRestrictionActors(restrictions: BranchProtectionRestrictions): number { + return restrictions.apps.length + restrictions.teams.length + restrictions.users.length; +} + +function parseBranchProtectionRestrictions(value: unknown): BranchProtectionRestrictions | undefined | JsonResponse { + if (value === undefined || value === null) { return undefined; } + if (typeof value !== 'object' || Array.isArray(value)) { + return jsonValidationError('restrictions must be an object or null.'); + } + + const raw = value as Record<string, unknown>; + const users = parseRequiredRestrictionList(raw.users, 'restrictions.users'); + if (!Array.isArray(users)) { return users; } + const teams = parseRequiredRestrictionList(raw.teams, 'restrictions.teams'); + if (!Array.isArray(teams)) { return teams; } + const apps = parseRequiredRestrictionList(raw.apps, 'restrictions.apps'); + if (!Array.isArray(apps)) { return apps; } + + const restrictions = { apps, teams, users }; + if (totalRestrictionActors(restrictions) > 100) { + return jsonValidationError('restrictions users, teams, and apps are limited to 100 total items.'); + } + return restrictions; +} + +function parseOptionalBranchProtectionRestrictions(value: unknown, fieldName: string): BranchProtectionRestrictions | JsonResponse { + if (value === undefined || value === null) { + return emptyBranchProtectionRestrictions(); + } + if (typeof value !== 'object' || Array.isArray(value)) { + return jsonValidationError(`${fieldName} must be an object.`); + } + + const raw = value as Record<string, unknown>; + const users = parseOptionalRestrictionList(raw.users, `${fieldName}.users`); + if (!Array.isArray(users)) { return users; } + const teams = parseOptionalRestrictionList(raw.teams, `${fieldName}.teams`); + if (!Array.isArray(teams)) { return teams; } + const apps = parseOptionalRestrictionList(raw.apps, `${fieldName}.apps`); + if (!Array.isArray(apps)) { return apps; } + + const restrictions = { apps, teams, users }; + if (totalRestrictionActors(restrictions) > 100) { + return jsonValidationError(`${fieldName} users, teams, and apps are limited to 100 total items.`); + } + return restrictions; +} + +function parseRestrictionActorBody(reqBody: Record<string, unknown>, kind: BranchRestrictionKind): string[] | JsonResponse { + const actors = normalizeStringArray(reqBody[kind], kind); + if (!Array.isArray(actors)) { return actors; } + return actors; +} + +function emptyBranchProtectionRestrictions(): BranchProtectionRestrictions { + return { apps: [], teams: [], users: [] }; +} + +function normalizeStoredRestrictions(restrictions: BranchProtectionRestrictions | undefined): BranchProtectionRestrictions | undefined { + if (!restrictions) { return undefined; } + return { + apps : Array.isArray(restrictions.apps) ? restrictions.apps.filter(item => typeof item === 'string') : [], + teams : Array.isArray(restrictions.teams) ? restrictions.teams.filter(item => typeof item === 'string') : [], + users : Array.isArray(restrictions.users) ? restrictions.users.filter(item => typeof item === 'string') : [], + }; +} + +function mergeRestrictionActors(current: string[], additions: string[]): string[] { + return normalizeStringArray([...current, ...additions], 'restrictions') as string[]; +} + +function removeRestrictionActors(current: string[], removals: string[]): string[] { + return current.filter(actor => !removals.includes(actor)); +} + +function parseContextsBody(reqBody: Record<string, unknown>): string[] | JsonResponse { + return normalizeStringArray(reqBody.contexts, 'contexts'); +} + +function hasRequiredChecks(rule: BranchProtectionRule | undefined): boolean { + return Boolean(rule) && ( + Object.prototype.hasOwnProperty.call(rule, 'requiredChecks') || + Object.prototype.hasOwnProperty.call(rule, 'requiredCheckApps') + ); +} + +function hasRequiredReviews(rule: BranchProtectionRule | undefined): boolean { + return Boolean(rule) && ( + Object.prototype.hasOwnProperty.call(rule, 'requiredReviews') || + Object.prototype.hasOwnProperty.call(rule, 'reviewBypassAllowances') || + Object.prototype.hasOwnProperty.call(rule, 'reviewDismissalRestrictions') || + Object.prototype.hasOwnProperty.call(rule, 'dismissStaleReviews') || + Object.prototype.hasOwnProperty.call(rule, 'requireCodeOwnerReviews') || + Object.prototype.hasOwnProperty.call(rule, 'requireLastPushApproval') + ); +} + +function requiredChecks(rule: BranchProtectionRule | undefined): string[] { + const contexts = Array.isArray(rule?.requiredChecks) + ? rule.requiredChecks.filter((context): context is string => typeof context === 'string') + : []; + for (const context of Object.keys(requiredCheckApps(rule))) { + if (!contexts.includes(context)) { + contexts.push(context); + } + } + return contexts; +} + +function requiredCheckApps(rule: BranchProtectionRule | undefined): Record<string, number | null> { + if (!rule?.requiredCheckApps || typeof rule.requiredCheckApps !== 'object' || Array.isArray(rule.requiredCheckApps)) { + return {}; + } + + const appIds: Record<string, number | null> = {}; + for (const [context, appId] of Object.entries(rule.requiredCheckApps)) { + if (appId === null || (typeof appId === 'number' && Number.isInteger(appId) && appId >= -1)) { + appIds[context] = appId; + } + } + return appIds; +} + +function requiredStatusChecks(rule: BranchProtectionRule | undefined): RequiredStatusCheck[] { + const appIds = requiredCheckApps(rule); + return requiredChecks(rule).map(context => ({ + app_id : appIds[context] ?? null, + context : context, + })); +} + +function persistRequiredCheckApps(rule: BranchProtectionRule, checkApps: Record<string, number | null>): void { + const persisted: Record<string, number | null> = {}; + for (const [context, appId] of Object.entries(checkApps)) { + if (appId !== null) { + persisted[context] = appId; + } + } + + if (Object.keys(persisted).length > 0) { + rule.requiredCheckApps = persisted; + } else { + delete rule.requiredCheckApps; + } +} + +function pruneRequiredCheckApps(rule: BranchProtectionRule, contexts: string[]): void { + const appIds = requiredCheckApps(rule); + const persisted: Record<string, number | null> = {}; + for (const context of contexts) { + const appId = appIds[context] ?? null; + if (appId !== null) { + persisted[context] = appId; + } + } + + if (Object.keys(persisted).length > 0) { + rule.requiredCheckApps = persisted; + } else { + delete rule.requiredCheckApps; + } +} + +function buildAdminBranchProtectionResponse( + rule: BranchProtectionRule | undefined, + targetDid: string, + repoName: string, + branchName: string, + baseUrl: string, +): Record<string, unknown> { + const encodedBranch = encodeURIComponent(branchName); + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection/enforce_admins`, + enabled : rule?.enforceAdmins === true, + }; +} + +function buildCommitSignatureProtectionResponse( + rule: BranchProtectionRule | undefined, + targetDid: string, + repoName: string, + branchName: string, + baseUrl: string, +): Record<string, unknown> { + const encodedBranch = encodeURIComponent(branchName); + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection/required_signatures`, + enabled : rule?.requiredSignatures === true, + }; +} + +function buildRestrictedUser(login: string, baseUrl: string): Record<string, unknown> { + const id = numericId(login); + const encodedLogin = encodeURIComponent(login); + return { + login : login, + id : id, + node_id : `U_${id.toString(36)}`, + avatar_url : '', + gravatar_id : '', + url : `${baseUrl}/users/${encodedLogin}`, + html_url : `${baseUrl}/users/${encodedLogin}`, + followers_url : `${baseUrl}/users/${encodedLogin}/followers`, + following_url : `${baseUrl}/users/${encodedLogin}/following{/other_user}`, + gists_url : `${baseUrl}/users/${encodedLogin}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${encodedLogin}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${encodedLogin}/subscriptions`, + organizations_url : `${baseUrl}/users/${encodedLogin}/orgs`, + repos_url : `${baseUrl}/users/${encodedLogin}/repos`, + events_url : `${baseUrl}/users/${encodedLogin}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${encodedLogin}/received_events`, + type : 'User', + site_admin : false, + }; +} + +function buildRestrictedTeam(slug: string, baseUrl: string): Record<string, unknown> { + const id = numericId(`team:${slug}`); + const encodedSlug = encodeURIComponent(slug); + return { + id : id, + node_id : `T_${id.toString(36)}`, + url : `${baseUrl}/teams/${id}`, + html_url : `${baseUrl}/teams/${encodedSlug}`, + name : slug, + slug : slug, + description : null, + privacy : 'closed', + notification_setting : 'notifications_enabled', + permission : 'pull', + members_url : `${baseUrl}/teams/${id}/members{/member}`, + repositories_url : `${baseUrl}/teams/${id}/repos`, + parent : null, + }; +} + +function buildRestrictedApp(slug: string, baseUrl: string): Record<string, unknown> { + const id = numericId(`app:${slug}`); + const encodedSlug = encodeURIComponent(slug); + return { + id : id, + slug : slug, + node_id : `A_${id.toString(36)}`, + owner : buildOwner(slug, baseUrl), + name : slug, + description : '', + external_url : null, + html_url : `${baseUrl}/apps/${encodedSlug}`, + created_at : new Date(0).toISOString(), + updated_at : new Date(0).toISOString(), + permissions : { metadata: 'read' }, + events : [], + }; +} + +function buildRestrictionActorsResponse( + restrictions: BranchProtectionRestrictions, kind: BranchRestrictionKind, baseUrl: string, +): Record<string, unknown>[] { + if (kind === 'apps') { + return restrictions.apps.map(app => buildRestrictedApp(app, baseUrl)); + } + if (kind === 'teams') { + return restrictions.teams.map(team => buildRestrictedTeam(team, baseUrl)); + } + return restrictions.users.map(user => buildRestrictedUser(user, baseUrl)); +} + +function buildBranchAccessRestrictionsResponse( + rule: BranchProtectionRule, + targetDid: string, + repoName: string, + branchName: string, + baseUrl: string, +): Record<string, unknown> | null { + const restrictions = normalizeStoredRestrictions(rule.restrictions); + if (!restrictions) { return null; } + + const encodedBranch = encodeURIComponent(branchName); + const base = `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection/restrictions`; + return { + url : base, + users_url : `${base}/users`, + teams_url : `${base}/teams`, + apps_url : `${base}/apps`, + users : buildRestrictionActorsResponse(restrictions, 'users', baseUrl), + teams : buildRestrictionActorsResponse(restrictions, 'teams', baseUrl), + apps : buildRestrictionActorsResponse(restrictions, 'apps', baseUrl), + }; +} + +async function saveBranchProtectionRule( + ctx: AgentContext, lookup: RepoSettingsLookup, branchName: string, rule: BranchProtectionRule, +): Promise<JsonResponse | undefined> { + const settings: RepoSettingsData = { + ...lookup.settings, + branchProtection: { + ...(lookup.settings.branchProtection ?? {}), + [branchName]: rule, + }, + }; + return saveRepoSettings(ctx, lookup.repo, lookup, settings); +} + +async function getBranchProtectionLookup( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<BranchProtectionLookup | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const { branchName, ref } = await findBranchRef(ctx, targetDid, lookup.repo, branch); + if (!ref) { + return jsonNotFound(`Branch '${branchName}' not found.`); + } + + return { + lookup, + branchName, + rule: protectionRuleFor(lookup.settings, branchName), + }; +} + +// --------------------------------------------------------------------------- +// Local git helpers +// --------------------------------------------------------------------------- + +const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/; + +function localRepoPath(ctx: AgentContext, targetDid: string, repoName: string, options?: GitRefOptions): string | null { + const reposPath = options?.reposPath ?? resolveReposPath([], ctx.profileName ?? null); + const backend = new GitBackend({ basePath: reposPath }); + + try { + return backend.exists(targetDid, repoName) ? backend.repoPath(targetDid, repoName) : null; + } catch { + return null; + } +} + +async function gitText(repoPath: string, args: string[]): Promise<string | null> { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', repoPath, ...args], { + env : process.env, + stdio : ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + resolve(null); + return; + } + resolve(Buffer.concat(stdout).toString('utf-8').trim()); + }); + }); +} + +async function peelTagCommit(repoPath: string | null, ref: GitRefInfo): Promise<string> { + if (!repoPath || !ref.name.startsWith('refs/tags/')) { + return ref.target; + } + + const peeled = await gitText(repoPath, ['rev-parse', '--verify', '--end-of-options', `${ref.name}^{commit}`]); + return peeled && FULL_SHA_RE.test(peeled) ? peeled.toLowerCase() : ref.target; +} + +// --------------------------------------------------------------------------- +// Response builders +// --------------------------------------------------------------------------- + +function buildBranchResponse( + ref: GitRefInfo, targetDid: string, repoName: string, baseUrl: string, rule?: BranchProtectionRule, protectedBranch = Boolean(rule), +): Record<string, unknown> { + const branchName = shortRefName(ref.name); + const contexts = requiredChecks(rule); + return { + name : branchName, + commit : { + sha : ref.target, + url : `${baseUrl}/repos/${targetDid}/${repoName}/commits/${ref.target}`, + }, + protected : protectedBranch, + protection : { + enabled : protectedBranch, + required_status_checks : { + enforcement_level : contexts.length > 0 ? 'non_admins' : 'off', + contexts : contexts, + }, + }, + protection_url: `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodeURIComponent(branchName)}/protection`, + }; +} + +function buildBranchProtectionResponse( + rule: BranchProtectionRule, targetDid: string, repoName: string, branchName: string, baseUrl: string, +): Record<string, unknown> { + const encodedBranch = encodeURIComponent(branchName); + const base = `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection`; + + return { + url : base, + required_status_checks : buildRequiredStatusChecksResponse(rule, targetDid, repoName, branchName, baseUrl), + enforce_admins : buildAdminBranchProtectionResponse(rule, targetDid, repoName, branchName, baseUrl), + required_pull_request_reviews : buildPullRequestReviewProtectionResponse(rule, targetDid, repoName, branchName, baseUrl), + required_signatures : buildCommitSignatureProtectionResponse(rule, targetDid, repoName, branchName, baseUrl), + restrictions : buildBranchAccessRestrictionsResponse(rule, targetDid, repoName, branchName, baseUrl), + required_linear_history : { enabled: rule.requiredLinearHistory === true }, + allow_force_pushes : { enabled: rule.allowForcePushes === true }, + allow_deletions : { enabled: rule.allowDeletions === true }, + block_creations : { enabled: rule.blockCreations === true }, + required_conversation_resolution : { enabled: rule.requiredConversationResolution === true }, + lock_branch : { enabled: rule.lockBranch === true }, + allow_fork_syncing : { enabled: rule.allowForkSyncing === true }, + }; +} + +function buildRequiredStatusChecksResponse( + rule: BranchProtectionRule, targetDid: string, repoName: string, branchName: string, baseUrl: string, +): Record<string, unknown> { + const encodedBranch = encodeURIComponent(branchName); + const base = `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection/required_status_checks`; + const contexts = requiredChecks(rule); + + return { + url : base, + strict : rule.requiredChecksStrict === true, + checks : requiredStatusChecks(rule), + contexts : contexts, + contexts_url : `${base}/contexts`, + }; +} + +function buildPullRequestReviewProtectionResponse( + rule: BranchProtectionRule, targetDid: string, repoName: string, branchName: string, baseUrl: string, +): Record<string, unknown> { + const encodedBranch = encodeURIComponent(branchName); + const base = `${baseUrl}/repos/${targetDid}/${repoName}/branches/${encodedBranch}/protection/required_pull_request_reviews`; + const restrictionsBase = `${base.replace('/required_pull_request_reviews', '')}/dismissal_restrictions`; + const requiredReviews = Number.isInteger(rule.requiredReviews) ? rule.requiredReviews ?? 0 : 0; + const bypassAllowances = normalizeStoredRestrictions(rule.reviewBypassAllowances) ?? emptyBranchProtectionRestrictions(); + const dismissalRestrictions = normalizeStoredRestrictions(rule.reviewDismissalRestrictions) ?? emptyBranchProtectionRestrictions(); + + return { + url : base, + dismissal_restrictions : { + url : restrictionsBase, + users_url : `${restrictionsBase}/users`, + teams_url : `${restrictionsBase}/teams`, + users : buildRestrictionActorsResponse(dismissalRestrictions, 'users', baseUrl), + teams : buildRestrictionActorsResponse(dismissalRestrictions, 'teams', baseUrl), + apps : buildRestrictionActorsResponse(dismissalRestrictions, 'apps', baseUrl), + }, + bypass_pull_request_allowances: { + users : buildRestrictionActorsResponse(bypassAllowances, 'users', baseUrl), + teams : buildRestrictionActorsResponse(bypassAllowances, 'teams', baseUrl), + apps : buildRestrictionActorsResponse(bypassAllowances, 'apps', baseUrl), + }, + dismiss_stale_reviews : rule.dismissStaleReviews === true, + require_code_owner_reviews : rule.requireCodeOwnerReviews === true, + required_approving_review_count : requiredReviews, + require_last_push_approval : rule.requireLastPushApproval === true, + }; +} + +function buildGitRefResponse( + ref: GitRefInfo, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const refPath = ref.name.replace(/^refs\//, ''); + const objectType = ref.type === 'tag' ? 'tag' : 'commit'; + + return { + ref : ref.name, + node_id : ref.nodeId, + url : `${baseUrl}/repos/${targetDid}/${repoName}/git/refs/${refPath}`, + object : { + type : objectType, + sha : ref.target, + url : `${baseUrl}/repos/${targetDid}/${repoName}/git/${objectType}s/${ref.target}`, + }, + }; +} + +function buildTagResponse( + ref: GitRefInfo, targetDid: string, repoName: string, baseUrl: string, commitSha = ref.target, +): Record<string, unknown> { + const tagName = shortRefName(ref.name); + + return { + name : tagName, + node_id : ref.nodeId, + zipball_url : `${baseUrl}/repos/${targetDid}/${repoName}/zipball/${encodeURIComponent(tagName)}`, + tarball_url : `${baseUrl}/repos/${targetDid}/${repoName}/tarball/${encodeURIComponent(tagName)}`, + commit : { + sha : commitSha, + url : `${baseUrl}/repos/${targetDid}/${repoName}/commits/${commitSha}`, + }, + }; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/branches +// --------------------------------------------------------------------------- + +export async function handleListBranches( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const protectedFilter = parseBranchProtectedFilter(url); + if (typeof protectedFilter !== 'boolean' && protectedFilter !== undefined) { return protectedFilter; } + + let refs = (await listRefRecords(ctx, targetDid, lookup.repo)).filter(r => r.type === 'branch'); + if (typeof protectedFilter === 'boolean') { + refs = refs.filter(ref => branchIsProtected(lookup.settings, lookup.repo, shortRefName(ref.name)) === protectedFilter); + } + const paged = paginate(refs, pagination); + const listPath = `/repos/${targetDid}/${lookup.repo.name}/branches${typeof protectedFilter === 'boolean' ? `?protected=${protectedFilter}` : ''}`; + + const linkHeader = buildLinkHeader( + baseUrl, listPath, + pagination.page, pagination.perPage, refs.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk( + paged.map(ref => { + const branchName = shortRefName(ref.name); + const rule = protectionRuleFor(lookup.settings, branchName); + return buildBranchResponse( + ref, + targetDid, + lookup.repo.name, + baseUrl, + rule, + branchIsProtected(lookup.settings, lookup.repo, branchName), + ); + }), + extraHeaders, + ); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/branches/:branch +// --------------------------------------------------------------------------- + +export async function handleGetBranch( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const { branchName, ref } = await findBranchRef(ctx, targetDid, lookup.repo, branch); + if (!ref) { + return jsonNotFound(`Branch '${branchName}' not found.`); + } + + return jsonOk(buildBranchResponse( + ref, + targetDid, + lookup.repo.name, + buildApiUrl(url), + protectionRuleFor(lookup.settings, branchName), + branchIsProtected(lookup.settings, lookup.repo, branchName), + )); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection +// --------------------------------------------------------------------------- + +export async function handleGetBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + return jsonOk(buildBranchProtectionResponse(rule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleUpdateBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName } = result; + + const requiredChecks = parseRequiredChecks(reqBody.required_status_checks); + if ('status' in requiredChecks) { return requiredChecks; } + + const requiredReviews = parseRequiredReviews(reqBody.required_pull_request_reviews); + if ('status' in requiredReviews) { return requiredReviews; } + + const restrictions = parseBranchProtectionRestrictions(reqBody.restrictions); + if (restrictions && 'status' in restrictions) { return restrictions; } + + const rule: BranchProtectionRule = {}; + if (requiredChecks.enabled) { + rule.requiredChecks = requiredChecks.contexts; + persistRequiredCheckApps(rule, requiredChecks.checkApps); + if (requiredChecks.strict === true) { + rule.requiredChecksStrict = true; + } + } + if (requiredReviews.enabled) { + rule.requiredReviews = requiredReviews.count; + if ( + requiredReviews.bypassAllowancesProvided && + requiredReviews.bypassAllowances && + totalRestrictionActors(requiredReviews.bypassAllowances) > 0 + ) { + rule.reviewBypassAllowances = requiredReviews.bypassAllowances; + } + if ( + requiredReviews.dismissalRestrictionsProvided && + requiredReviews.dismissalRestrictions && + totalRestrictionActors(requiredReviews.dismissalRestrictions) > 0 + ) { + rule.reviewDismissalRestrictions = requiredReviews.dismissalRestrictions; + } + if (requiredReviews.dismissStaleReviews === true) { + rule.dismissStaleReviews = true; + } + if (requiredReviews.requireCodeOwnerReviews === true) { + rule.requireCodeOwnerReviews = true; + } + if (requiredReviews.requireLastPushApproval === true) { + rule.requireLastPushApproval = true; + } + } + for (const { requestKey, ruleKey } of BRANCH_PROTECTION_BOOLEAN_FIELDS) { + const value = parseOptionalBranchProtectionBoolean(reqBody[requestKey], requestKey); + if (typeof value !== 'boolean') { return value; } + if (value) { + rule[ruleKey] = true; + } + } + if (restrictions) { + rule.restrictions = restrictions; + } + + const error = await saveBranchProtectionRule(ctx, lookup, branchName, rule); + if (error) { return error; } + + return jsonOk(buildBranchProtectionResponse(rule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleDeleteBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName } = result; + + if (!lookup.settings.branchProtection?.[branchName]) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const branchProtection = { ...lookup.settings.branchProtection }; + delete branchProtection[branchName]; + const settings: RepoSettingsData = { ...lookup.settings }; + if (Object.keys(branchProtection).length > 0) { + settings.branchProtection = branchProtection; + } else { + delete settings.branchProtection; + } + + const error = await saveRepoSettings(ctx, lookup.repo, lookup, settings); + if (error) { return error; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/restrictions +// --------------------------------------------------------------------------- + +export async function handleGetBranchAccessRestrictions( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const restrictions = buildBranchAccessRestrictionsResponse(rule, targetDid, lookup.repo.name, branchName, buildApiUrl(url)); + if (!restrictions) { + return jsonNotFound(`Access restrictions for '${branchName}' not found.`); + } + return jsonOk(restrictions); +} + +export async function handleDeleteBranchAccessRestrictions( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + if (!rule.restrictions) { + return jsonNotFound(`Access restrictions for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...rule }; + delete updatedRule.restrictions; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonNoContent(); +} + +export async function handleListBranchAccessRestrictionActors( + ctx: AgentContext, + targetDid: string, + repoName: string, + branch: string, + kind: BranchRestrictionKind, + url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const restrictions = normalizeStoredRestrictions(rule.restrictions); + if (!restrictions) { + return jsonNotFound(`Access restrictions for '${branchName}' not found.`); + } + return jsonOk(buildRestrictionActorsResponse(restrictions, kind, buildApiUrl(url))); +} + +export async function handleAddBranchAccessRestrictionActors( + ctx: AgentContext, + targetDid: string, + repoName: string, + branch: string, + kind: BranchRestrictionKind, + reqBody: Record<string, unknown>, + url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const actors = parseRestrictionActorBody(reqBody, kind); + if (!Array.isArray(actors)) { return actors; } + + const restrictions = normalizeStoredRestrictions(rule.restrictions) ?? emptyBranchProtectionRestrictions(); + restrictions[kind] = mergeRestrictionActors(restrictions[kind], actors); + if (totalRestrictionActors(restrictions) > 100) { + return jsonValidationError('restrictions users, teams, and apps are limited to 100 total items.'); + } + + const updatedRule: BranchProtectionRule = { ...rule, restrictions }; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildRestrictionActorsResponse(restrictions, kind, buildApiUrl(url))); +} + +export async function handleSetBranchAccessRestrictionActors( + ctx: AgentContext, + targetDid: string, + repoName: string, + branch: string, + kind: BranchRestrictionKind, + reqBody: Record<string, unknown>, + url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const actors = parseRestrictionActorBody(reqBody, kind); + if (!Array.isArray(actors)) { return actors; } + + const restrictions = normalizeStoredRestrictions(rule.restrictions) ?? emptyBranchProtectionRestrictions(); + restrictions[kind] = actors; + if (totalRestrictionActors(restrictions) > 100) { + return jsonValidationError('restrictions users, teams, and apps are limited to 100 total items.'); + } + + const updatedRule: BranchProtectionRule = { ...rule, restrictions }; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildRestrictionActorsResponse(restrictions, kind, buildApiUrl(url))); +} + +export async function handleRemoveBranchAccessRestrictionActors( + ctx: AgentContext, + targetDid: string, + repoName: string, + branch: string, + kind: BranchRestrictionKind, + reqBody: Record<string, unknown>, + url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const actors = parseRestrictionActorBody(reqBody, kind); + if (!Array.isArray(actors)) { return actors; } + + const restrictions = normalizeStoredRestrictions(rule.restrictions); + if (!restrictions) { + return jsonNotFound(`Access restrictions for '${branchName}' not found.`); + } + restrictions[kind] = removeRestrictionActors(restrictions[kind], actors); + + const updatedRule: BranchProtectionRule = { ...rule, restrictions }; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildRestrictionActorsResponse(restrictions, kind, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/required_signatures +// --------------------------------------------------------------------------- + +export async function handleGetCommitSignatureProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + return jsonOk(buildCommitSignatureProtectionResponse(rule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleCreateCommitSignatureProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...rule, requiredSignatures: true }; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildCommitSignatureProtectionResponse(updatedRule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleDeleteCommitSignatureProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...rule }; + delete updatedRule.requiredSignatures; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/enforce_admins +// --------------------------------------------------------------------------- + +export async function handleGetAdminBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + return jsonOk(buildAdminBranchProtectionResponse(rule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleSetAdminBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...rule, enforceAdmins: true }; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildAdminBranchProtectionResponse(updatedRule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleDeleteAdminBranchProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!rule) { + return jsonNotFound(`Branch protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...rule }; + delete updatedRule.enforceAdmins; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/required_status_checks +// --------------------------------------------------------------------------- + +export async function handleGetRequiredStatusChecksProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!hasRequiredChecks(rule)) { + return jsonNotFound(`Status check protection for '${branchName}' not found.`); + } + + return jsonOk(buildRequiredStatusChecksResponse(rule ?? {}, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleUpdateRequiredStatusChecksProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + + const contexts = parseRequiredChecks(reqBody); + if ('status' in contexts) { return contexts; } + + const updatedRule: BranchProtectionRule = { + ...(rule ?? {}), + requiredChecks: contexts.contextsProvided ? contexts.contexts : requiredChecks(rule), + }; + if (contexts.contextsProvided) { + persistRequiredCheckApps(updatedRule, contexts.checkApps); + } + if (contexts.strictProvided) { + if (contexts.strict === true) { + updatedRule.requiredChecksStrict = true; + } else { + delete updatedRule.requiredChecksStrict; + } + } + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildRequiredStatusChecksResponse(updatedRule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleDeleteRequiredStatusChecksProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!hasRequiredChecks(rule)) { + return jsonNotFound(`Status check protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...(rule ?? {}) }; + delete updatedRule.requiredChecks; + delete updatedRule.requiredCheckApps; + delete updatedRule.requiredChecksStrict; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts +// --------------------------------------------------------------------------- + +export async function handleListStatusCheckContexts( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { branchName, rule } = result; + if (!hasRequiredChecks(rule)) { + return jsonNotFound(`Status check protection for '${branchName}' not found.`); + } + + return jsonOk(requiredChecks(rule)); +} + +export async function handleAddStatusCheckContexts( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + + const contexts = parseContextsBody(reqBody); + if (!Array.isArray(contexts)) { return contexts; } + + const combined = [...requiredChecks(rule)]; + for (const context of contexts) { + if (!combined.includes(context)) { + combined.push(context); + } + } + + const updatedRule: BranchProtectionRule = { ...(rule ?? {}), requiredChecks: combined }; + pruneRequiredCheckApps(updatedRule, combined); + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(combined); +} + +export async function handleSetStatusCheckContexts( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + + const contexts = parseContextsBody(reqBody); + if (!Array.isArray(contexts)) { return contexts; } + + const updatedRule: BranchProtectionRule = { ...(rule ?? {}), requiredChecks: contexts }; + delete updatedRule.requiredCheckApps; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(contexts); +} + +export async function handleRemoveStatusCheckContexts( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!hasRequiredChecks(rule)) { + return jsonNotFound(`Status check protection for '${branchName}' not found.`); + } + + const contexts = parseContextsBody(reqBody); + if (!Array.isArray(contexts)) { return contexts; } + + const remaining = requiredChecks(rule).filter(context => !contexts.includes(context)); + const updatedRule: BranchProtectionRule = { ...(rule ?? {}), requiredChecks: remaining }; + pruneRequiredCheckApps(updatedRule, remaining); + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(remaining); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews +// --------------------------------------------------------------------------- + +export async function handleGetPullRequestReviewProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!hasRequiredReviews(rule)) { + return jsonNotFound(`Pull request review protection for '${branchName}' not found.`); + } + + return jsonOk(buildPullRequestReviewProtectionResponse(rule ?? {}, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleUpdatePullRequestReviewProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + + const requiredReviews = parseRequiredReviews(reqBody); + if ('status' in requiredReviews) { return requiredReviews; } + + const updatedRule: BranchProtectionRule = { ...(rule ?? {}) }; + updatedRule.requiredReviews = requiredReviews.countProvided + ? requiredReviews.count + : rule?.requiredReviews ?? 0; + + if (requiredReviews.dismissStaleReviewsProvided) { + if (requiredReviews.dismissStaleReviews === true) { + updatedRule.dismissStaleReviews = true; + } else { + delete updatedRule.dismissStaleReviews; + } + } + if (requiredReviews.requireCodeOwnerReviewsProvided) { + if (requiredReviews.requireCodeOwnerReviews === true) { + updatedRule.requireCodeOwnerReviews = true; + } else { + delete updatedRule.requireCodeOwnerReviews; + } + } + if (requiredReviews.requireLastPushApprovalProvided) { + if (requiredReviews.requireLastPushApproval === true) { + updatedRule.requireLastPushApproval = true; + } else { + delete updatedRule.requireLastPushApproval; + } + } + if (requiredReviews.dismissalRestrictionsProvided) { + if (requiredReviews.dismissalRestrictions && totalRestrictionActors(requiredReviews.dismissalRestrictions) > 0) { + updatedRule.reviewDismissalRestrictions = requiredReviews.dismissalRestrictions; + } else { + delete updatedRule.reviewDismissalRestrictions; + } + } + if (requiredReviews.bypassAllowancesProvided) { + if (requiredReviews.bypassAllowances && totalRestrictionActors(requiredReviews.bypassAllowances) > 0) { + updatedRule.reviewBypassAllowances = requiredReviews.bypassAllowances; + } else { + delete updatedRule.reviewBypassAllowances; + } + } + + if (!hasRequiredReviews(updatedRule)) { + updatedRule.requiredReviews = 0; + } + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonOk(buildPullRequestReviewProtectionResponse(updatedRule, targetDid, lookup.repo.name, branchName, buildApiUrl(url))); +} + +export async function handleDeletePullRequestReviewProtection( + ctx: AgentContext, targetDid: string, repoName: string, branch: string, +): Promise<JsonResponse> { + const result = await getBranchProtectionLookup(ctx, targetDid, repoName, branch); + if ('status' in result) { return result; } + const { lookup, branchName, rule } = result; + if (!hasRequiredReviews(rule)) { + return jsonNotFound(`Pull request review protection for '${branchName}' not found.`); + } + + const updatedRule: BranchProtectionRule = { ...(rule ?? {}) }; + delete updatedRule.requiredReviews; + delete updatedRule.reviewBypassAllowances; + delete updatedRule.reviewDismissalRestrictions; + delete updatedRule.dismissStaleReviews; + delete updatedRule.requireCodeOwnerReviews; + delete updatedRule.requireLastPushApproval; + const error = await saveBranchProtectionRule(ctx, lookup, branchName, updatedRule); + if (error) { return error; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/tags +// --------------------------------------------------------------------------- + +export async function handleListTags( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options?: GitRefOptions, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const refs = (await listRefRecords(ctx, targetDid, repo)).filter(r => r.type === 'tag'); + const paged = paginate(refs, pagination); + const repoPath = localRepoPath(ctx, targetDid, repo.name, options); + const commitShas = await Promise.all(paged.map(ref => peelTagCommit(repoPath, ref))); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/tags`, + pagination.page, pagination.perPage, refs.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk( + paged.map((ref, index) => buildTagResponse(ref, targetDid, repo.name, baseUrl, commitShas[index])), + extraHeaders, + ); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/git/ref/:ref +// --------------------------------------------------------------------------- + +export async function handleGetGitRef( + ctx: AgentContext, targetDid: string, repoName: string, refPath: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const fullRef = normalizeGitRef(refPath); + const refs = await listRefRecords(ctx, targetDid, repo); + const ref = refs.find(r => r.name === fullRef); + if (!ref) { + return jsonNotFound(`Reference '${fullRef}' not found.`); + } + + return jsonOk(buildGitRefResponse(ref, targetDid, repo.name, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/git/matching-refs/:ref +// --------------------------------------------------------------------------- + +export async function handleListMatchingGitRefs( + ctx: AgentContext, targetDid: string, repoName: string, refPrefix: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const prefix = normalizeGitRef(refPrefix); + const refs = await listRefRecords(ctx, targetDid, repo); + const matching = refs.filter(r => r.name.startsWith(prefix)); + + return jsonOk(matching.map(ref => buildGitRefResponse(ref, targetDid, repo.name, buildApiUrl(url)))); +} diff --git a/src/github-shim/gpg-keys.ts b/src/github-shim/gpg-keys.ts new file mode 100644 index 0000000..070296c --- /dev/null +++ b/src/github-shim/gpg-keys.ts @@ -0,0 +1,328 @@ +/** + * GitHub API shim - user GPG key endpoints. + * + * Maps forge-social `gpgKey` records to GitHub REST API v3 GPG key + * responses. GitHub parses armored keys into cryptographic key material; + * this shim preserves the uploaded armored key and exposes deterministic + * compatibility metadata. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; +import type { GpgKeyData, GpgKeyEmailData, GpgSubkeyData } from '../social.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type GpgKeyEntry = { + record : any; + data : GpgKeyData; +}; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function gpgKeyNumericId(entry: GpgKeyEntry): number { + return numericId(entry.record.contextId ?? entry.record.id); +} + +function gpgKeyIdFromMaterial(material: string): string { + return createHash('sha256').update(material).digest('hex').slice(0, 16).toUpperCase(); +} + +function normalizeEmails(value: unknown): GpgKeyEmailData[] { + if (!Array.isArray(value)) { return []; } + + const emails: GpgKeyEmailData[] = []; + for (const item of value) { + if (!isObject(item) || typeof item.email !== 'string' || item.email.trim() === '') { continue; } + emails.push({ + email : item.email.trim(), + verified : item.verified === true, + }); + } + return emails; +} + +function extractEmailsFromArmoredKey(armoredPublicKey: string): GpgKeyEmailData[] { + const matches = armoredPublicKey.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? []; + return [...new Set(matches.map(email => email.toLowerCase()))].map(email => ({ email, verified: false })); +} + +function normalizeSubkeys(value: unknown): GpgSubkeyData[] { + if (!Array.isArray(value)) { return []; } + + const subkeys: GpgSubkeyData[] = []; + for (const item of value) { + if (!isObject(item) || typeof item.publicKey !== 'string' || item.publicKey.trim() === '') { continue; } + subkeys.push({ + publicKey : item.publicKey.trim(), + id : typeof item.id === 'number' ? item.id : undefined, + primaryKeyId : typeof item.primaryKeyId === 'number' ? item.primaryKeyId : undefined, + keyId : typeof item.keyId === 'string' && item.keyId.trim() !== '' ? item.keyId.trim() : undefined, + emails : normalizeEmails(item.emails), + canSign : typeof item.canSign === 'boolean' ? item.canSign : undefined, + canEncryptComms : typeof item.canEncryptComms === 'boolean' ? item.canEncryptComms : undefined, + canEncryptStorage : typeof item.canEncryptStorage === 'boolean' ? item.canEncryptStorage : undefined, + canCertify : typeof item.canCertify === 'boolean' ? item.canCertify : undefined, + createdAt : typeof item.createdAt === 'string' ? item.createdAt : undefined, + expiresAt : typeof item.expiresAt === 'string' || item.expiresAt === null ? item.expiresAt : undefined, + revoked : typeof item.revoked === 'boolean' ? item.revoked : undefined, + }); + } + return subkeys; +} + +function normalizeGpgKeyData(data: unknown, tags?: Record<string, unknown>): GpgKeyData | null { + if (!isObject(data)) { return null; } + + const armoredPublicKey = data.armoredPublicKey ?? data.armored_public_key; + if (typeof armoredPublicKey !== 'string' || armoredPublicKey.trim() === '') { return null; } + + const trimmedKey = armoredPublicKey.trim(); + const keyId = typeof tags?.keyId === 'string' ? tags.keyId : data.keyId; + const emails = normalizeEmails(data.emails); + return { + armoredPublicKey : trimmedKey, + name : typeof data.name === 'string' ? data.name : undefined, + publicKey : typeof data.publicKey === 'string' ? data.publicKey : undefined, + keyId : typeof keyId === 'string' && keyId.trim() !== '' ? keyId.trim() : gpgKeyIdFromMaterial(trimmedKey), + emails : emails.length > 0 ? emails : extractEmailsFromArmoredKey(trimmedKey), + subkeys : normalizeSubkeys(data.subkeys), + canSign : typeof data.canSign === 'boolean' ? data.canSign : undefined, + canEncryptComms : typeof data.canEncryptComms === 'boolean' ? data.canEncryptComms : undefined, + canEncryptStorage : typeof data.canEncryptStorage === 'boolean' ? data.canEncryptStorage : undefined, + canCertify : typeof data.canCertify === 'boolean' ? data.canCertify : undefined, + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + expiresAt : typeof data.expiresAt === 'string' || data.expiresAt === null ? data.expiresAt : undefined, + revoked : typeof data.revoked === 'boolean' ? data.revoked : undefined, + }; +} + +async function normalizeGpgKeyEntry(record: any): Promise<GpgKeyEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeGpgKeyData(await record.data.json(), tags); + if (!data) { return null; } + return { record, data }; +} + +async function listGpgKeyEntries(ctx: AgentContext, userDid: string): Promise<GpgKeyEntry[]> { + const { records } = await ctx.social.records.query('gpgKey' as any, { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: GpgKeyEntry[] = []; + for (const record of records) { + const entry = await normalizeGpgKeyEntry(record); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function findGpgKey(ctx: AgentContext, userDid: string, keyId: string): Promise<GpgKeyEntry | null> { + const id = Number.parseInt(keyId, 10); + if (!Number.isInteger(id) || id <= 0) { return null; } + + const entries = await listGpgKeyEntries(ctx, userDid); + return entries.find(entry => gpgKeyNumericId(entry) === id) ?? null; +} + +function defaultGpgKeyName(data: GpgKeyData): string { + return data.emails?.[0]?.email ? `${data.emails[0].email} GPG key` : `GPG key ${data.keyId ?? gpgKeyIdFromMaterial(data.armoredPublicKey)}`; +} + +function gpgCreatedAt(entry: GpgKeyEntry): string { + return toISODate(entry.data.createdAt ?? entry.record.dateCreated ?? entry.record.timestamp); +} + +function nullableISODate(date: string | null | undefined): string | null { + return date ? toISODate(date) : null; +} + +function buildGpgSubkeyResponse(subkey: GpgSubkeyData, entry: GpgKeyEntry, index: number): Record<string, unknown> { + const primaryKeyId = gpgKeyNumericId(entry); + return { + id : subkey.id ?? numericId(`${entry.record.id}:subkey:${index}`), + primary_key_id : subkey.primaryKeyId ?? primaryKeyId, + key_id : subkey.keyId ?? gpgKeyIdFromMaterial(subkey.publicKey), + public_key : subkey.publicKey, + emails : subkey.emails ?? [], + can_sign : subkey.canSign === true, + can_encrypt_comms : subkey.canEncryptComms === true, + can_encrypt_storage : subkey.canEncryptStorage === true, + can_certify : subkey.canCertify === true, + created_at : toISODate(subkey.createdAt ?? entry.data.createdAt ?? entry.record.dateCreated ?? entry.record.timestamp), + expires_at : nullableISODate(subkey.expiresAt), + revoked : subkey.revoked === true, + }; +} + +function buildGpgKeyResponse(entry: GpgKeyEntry): Record<string, unknown> { + const id = gpgKeyNumericId(entry); + const keyId = entry.data.keyId ?? gpgKeyIdFromMaterial(entry.data.armoredPublicKey); + return { + id, + name : entry.data.name ?? defaultGpgKeyName(entry.data), + primary_key_id : id, + key_id : keyId, + public_key : entry.data.publicKey ?? entry.data.armoredPublicKey, + emails : entry.data.emails ?? [], + subkeys : (entry.data.subkeys ?? []).map((subkey, index) => buildGpgSubkeyResponse(subkey, entry, index)), + can_sign : entry.data.canSign !== false, + can_encrypt_comms : entry.data.canEncryptComms === true, + can_encrypt_storage : entry.data.canEncryptStorage === true, + can_certify : entry.data.canCertify !== false, + created_at : gpgCreatedAt(entry), + expires_at : nullableISODate(entry.data.expiresAt), + revoked : entry.data.revoked === true, + raw_key : entry.data.armoredPublicKey, + }; +} + +function parseCreateGpgKeyInput(body: unknown): GpgKeyData | JsonResponse { + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const armoredPublicKey = body.armored_public_key; + if (typeof armoredPublicKey !== 'string' || armoredPublicKey.trim() === '') { + return jsonValidationError('Validation Failed: armored_public_key is required.'); + } + + const trimmedKey = armoredPublicKey.trim(); + if (!trimmedKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) { + return jsonValidationError('Validation Failed: armored_public_key must be an ASCII-armored GPG public key.'); + } + + const name = body.name; + if (name !== undefined && (typeof name !== 'string' || name.trim() === '')) { + return jsonValidationError('Validation Failed: name must be a non-empty string.'); + } + + return { + armoredPublicKey : trimmedKey, + name : typeof name === 'string' ? name.trim() : undefined, + publicKey : trimmedKey, + keyId : gpgKeyIdFromMaterial(trimmedKey), + emails : extractEmailsFromArmoredKey(trimmedKey), + subkeys : [], + canSign : true, + canEncryptComms : false, + canEncryptStorage : false, + canCertify : true, + createdAt : new Date().toISOString(), + expiresAt : null, + revoked : false, + }; +} + +async function hasDuplicateGpgKey(ctx: AgentContext, keyId: string, armoredPublicKey: string): Promise<boolean> { + const entries = await listGpgKeyEntries(ctx, ctx.did); + return entries.some(entry => entry.data.keyId === keyId || entry.data.armoredPublicKey === armoredPublicKey); +} + +function pagedGpgKeys(entries: GpgKeyEntry[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, entries.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildGpgKeyResponse), extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/gpg_keys +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedGpgKeys(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = await listGpgKeyEntries(ctx, ctx.did); + return pagedGpgKeys(entries, url, '/user/gpg_keys'); +} + +// --------------------------------------------------------------------------- +// POST /user/gpg_keys +// --------------------------------------------------------------------------- + +export async function handleCreateAuthenticatedGpgKey(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + const parsed = parseCreateGpgKeyInput(body); + if ('status' in parsed) { return parsed; } + + const keyId = parsed.keyId ?? gpgKeyIdFromMaterial(parsed.armoredPublicKey); + if (await hasDuplicateGpgKey(ctx, keyId, parsed.armoredPublicKey)) { + return jsonValidationError('Validation Failed: key already exists.'); + } + + const { record, status } = await ctx.social.records.create('gpgKey' as any, { + data : parsed, + tags : { keyId }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create GPG key: ${status.detail}`); + } + + const entry = await normalizeGpgKeyEntry(record); + if (!entry) { + return jsonValidationError('Failed to create GPG key.'); + } + + return jsonCreated(buildGpgKeyResponse(entry)); +} + +// --------------------------------------------------------------------------- +// GET /user/gpg_keys/:gpg_key_id +// --------------------------------------------------------------------------- + +export async function handleGetAuthenticatedGpgKey(ctx: AgentContext, keyId: string): Promise<JsonResponse> { + const entry = await findGpgKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('GPG key not found.'); } + return jsonOk(buildGpgKeyResponse(entry)); +} + +// --------------------------------------------------------------------------- +// DELETE /user/gpg_keys/:gpg_key_id +// --------------------------------------------------------------------------- + +export async function handleDeleteAuthenticatedGpgKey(ctx: AgentContext, keyId: string): Promise<JsonResponse> { + const entry = await findGpgKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('GPG key not found.'); } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete GPG key: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /users/:did/gpg_keys +// --------------------------------------------------------------------------- + +export async function handleListUserGpgKeys( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const entries = await listGpgKeyEntries(ctx, userDid); + return pagedGpgKeys(entries, url, `/users/${userDid}/gpg_keys`); +} diff --git a/src/github-shim/helpers.ts b/src/github-shim/helpers.ts index 4a2dfe6..240668d 100644 --- a/src/github-shim/helpers.ts +++ b/src/github-shim/helpers.ts @@ -31,10 +31,30 @@ export type RepoInfo = { name : string; description : string; defaultBranch : string; + homepage : string; contextId : string; visibility : string; + language : string; + archived : boolean; + hasIssues : boolean; + hasProjects : boolean; + hasWiki : boolean; + hasDownloads : boolean; + hasPullRequests : boolean; + isTemplate : boolean; + allowSquashMerge : boolean; + allowMergeCommit : boolean; + allowRebaseMerge : boolean; + allowAutoMerge : boolean; + allowForking : boolean; + deleteBranchOnMerge : boolean; + webCommitSignoffRequired : boolean; + pullRequestCreationPolicy : string; dateCreated : string; timestamp : string; + forkedFromDid? : string; + forkedFromRepoName? : string; + forkedFromRecordId? : string; }; /** Pagination parameters parsed from query string. */ @@ -47,7 +67,7 @@ export type PaginationParams = { export type JsonResponse = { status : number; headers : Record<string, string>; - body : string; + body : string | Uint8Array; }; // --------------------------------------------------------------------------- @@ -128,16 +148,36 @@ export async function getRepoRecord( const rec = records[0]; const data = await rec.data.json(); - const tags = rec.tags as Record<string, string> | undefined; + const tags = rec.tags as Record<string, unknown> | undefined; return { - name : data.name ?? 'unnamed', - description : data.description ?? '', - defaultBranch : data.defaultBranch ?? 'main', - contextId : rec.contextId ?? '', - visibility : tags?.visibility ?? 'public', - dateCreated : rec.dateCreated, - timestamp : rec.timestamp, + name : data.name ?? 'unnamed', + description : data.description ?? '', + defaultBranch : data.defaultBranch ?? 'main', + homepage : data.homepage ?? '', + contextId : rec.contextId ?? '', + visibility : typeof tags?.visibility === 'string' ? tags.visibility : 'public', + language : typeof tags?.language === 'string' ? tags.language : '', + archived : tags?.archived === true || tags?.archived === 'true', + hasIssues : data.hasIssues !== false, + hasProjects : data.hasProjects === true, + hasWiki : data.hasWiki !== false, + hasDownloads : data.hasDownloads !== false, + hasPullRequests : data.hasPullRequests !== false, + isTemplate : data.isTemplate === true, + allowSquashMerge : data.allowSquashMerge !== false, + allowMergeCommit : data.allowMergeCommit !== false, + allowRebaseMerge : data.allowRebaseMerge !== false, + allowAutoMerge : data.allowAutoMerge === true, + allowForking : data.allowForking !== false, + deleteBranchOnMerge : data.deleteBranchOnMerge === true, + webCommitSignoffRequired : data.webCommitSignoffRequired === true, + pullRequestCreationPolicy : data.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' : 'all', + dateCreated : rec.dateCreated, + timestamp : rec.timestamp, + forkedFromDid : typeof data.forkedFromDid === 'string' ? data.forkedFromDid : undefined, + forkedFromRepoName : typeof data.forkedFromRepoName === 'string' ? data.forkedFromRepoName : undefined, + forkedFromRecordId : typeof data.forkedFromRecordId === 'string' ? data.forkedFromRecordId : undefined, }; } @@ -192,13 +232,14 @@ export function buildLinkHeader(baseUrl: string, path: string, page: number, per if (lastPage <= 1) { return null; } const links: string[] = []; + const separator = path.includes('?') ? '&' : '?'; if (page < lastPage) { - links.push(`<${baseUrl}${path}?page=${page + 1}&per_page=${perPage}>; rel="next"`); - links.push(`<${baseUrl}${path}?page=${lastPage}&per_page=${perPage}>; rel="last"`); + links.push(`<${baseUrl}${path}${separator}page=${page + 1}&per_page=${perPage}>; rel="next"`); + links.push(`<${baseUrl}${path}${separator}page=${lastPage}&per_page=${perPage}>; rel="last"`); } if (page > 1) { - links.push(`<${baseUrl}${path}?page=1&per_page=${perPage}>; rel="first"`); - links.push(`<${baseUrl}${path}?page=${page - 1}&per_page=${perPage}>; rel="prev"`); + links.push(`<${baseUrl}${path}${separator}page=1&per_page=${perPage}>; rel="first"`); + links.push(`<${baseUrl}${path}${separator}page=${page - 1}&per_page=${perPage}>; rel="prev"`); } return links.length > 0 ? links.join(', ') : null; @@ -230,6 +271,20 @@ export function jsonOk(data: unknown, extraHeaders?: Record<string, string>): Js }; } +/** Build a successful binary response. */ +export function binaryOk(body: Uint8Array, contentType: string, extraHeaders?: Record<string, string>): JsonResponse { + return { + status : 200, + headers : { + ...baseHeaders(), + 'Content-Type' : contentType, + 'Content-Length' : String(body.byteLength), + ...extraHeaders, + }, + body, + }; +} + /** Build a 404 JSON response. */ export function jsonNotFound(message: string): JsonResponse { return { @@ -248,6 +303,24 @@ export function jsonCreated(data: unknown, extraHeaders?: Record<string, string> }; } +/** Build a 202 Accepted JSON response. */ +export function jsonAccepted(data: unknown, extraHeaders?: Record<string, string>): JsonResponse { + return { + status : 202, + headers : { ...baseHeaders(), ...extraHeaders }, + body : JSON.stringify(data), + }; +} + +/** Build a 204 No Content response. */ +export function jsonNoContent(extraHeaders?: Record<string, string>): JsonResponse { + return { + status : 204, + headers : { ...baseHeaders(), ...extraHeaders }, + body : '', + }; +} + /** Build a 422 JSON response (validation error). */ export function jsonValidationError(message: string): JsonResponse { return { diff --git a/src/github-shim/index.ts b/src/github-shim/index.ts index 7a50f03..79463b8 100644 --- a/src/github-shim/index.ts +++ b/src/github-shim/index.ts @@ -7,12 +7,385 @@ export { handleShimRequest, startShimServer } from './server.js'; export type { ShimServerOptions } from './server.js'; -export { handleGetRepo, buildRepoResponse } from './repos.js'; -export { handleCreateIssue, handleCreateIssueComment, handleGetIssue, handleListIssueComments, handleListIssues, handleUpdateIssue } from './issues.js'; -export { handleCreatePull, handleCreatePullReview, handleGetPull, handleListPullReviews, handleListPulls, handleMergePull, handleUpdatePull } from './pulls.js'; -export { handleCreateRelease, handleGetReleaseByTag, handleListReleases } from './releases.js'; -export { handleGetUser } from './users.js'; +export { + handleCancelWorkflowRun, + handleCreateOrUpdateEnvironmentSecret, + handleCreateOrUpdateRepositorySecret, + handleCreateEnvironmentVariable, + handleCreateRepositoryVariable, + handleCreateWorkflowDispatch, + handleDeleteActionsCacheById, + handleDeleteActionsCachesByKey, + handleDeleteArtifact, + handleDeleteEnvironmentSecret, + handleDeleteEnvironmentVariable, + handleDeleteRepositorySecret, + handleDeleteRepositoryVariable, + handleDeleteWorkflowRun, + handleDeleteWorkflowRunLogs, + handleDisableWorkflow, + handleDownloadArtifact, + handleDownloadWorkflowRunAttemptLogs, + handleDownloadWorkflowJobLogs, + handleDownloadWorkflowRunLogs, + handleEnableWorkflow, + handleForceCancelWorkflowRun, + handleGetActionsCacheRetentionLimit, + handleGetActionsCacheStorageLimit, + handleGetActionsCacheUsage, + handleGetActionsPermissions, + handleGetActionsSelectedActions, + handleGetActionsWorkflowPermissions, + handleGetArtifact, + handleGetEnvironmentSecret, + handleGetEnvironmentSecretsPublicKey, + handleGetEnvironmentVariable, + handleGetRepositorySecret, + handleGetRepositorySecretsPublicKey, + handleGetRepositoryVariable, + handleGetWorkflow, + handleGetWorkflowJob, + handleGetWorkflowRun, + handleGetWorkflowRunAttempt, + handleGetWorkflowRunUsage, + handleGetWorkflowUsage, + handleListArtifacts, + handleListActionsCaches, + handleListEnvironmentSecrets, + handleListEnvironmentVariables, + handleListRepositorySecrets, + handleListRepositoryVariables, + handleListWorkflowRunArtifacts, + handleListWorkflowRunJobs, + handleListWorkflowRuns, + handleListWorkflowRunsForWorkflow, + handleListWorkflows, + handleRerunFailedWorkflowJobs, + handleRerunWorkflowJob, + handleRerunWorkflowRun, + handleSetActionsCacheRetentionLimit, + handleSetActionsCacheStorageLimit, + handleSetActionsPermissions, + handleSetActionsSelectedActions, + handleSetActionsWorkflowPermissions, + handleUpdateEnvironmentVariable, + handleUpdateRepositoryVariable, +} from './actions.js'; +export { handleListPublicEvents, handleListReceivedEvents, handleListRepoActivity, handleListRepoEvents, handleListUserEvents } from './activity.js'; +export { handleCreateCommitComment, handleCreateCommitCommentReaction, handleDeleteCommitComment, handleDeleteCommitCommentReaction, handleGetCommitComment, handleListCommitCommentReactions, handleListCommitComments, handleListCommitCommentsForSha, handleUpdateCommitComment } from './commit-comments.js'; +export { handleGetContents, handleGetLicense, handleGetRawContent, handleGetReadme, handleGetReadmeInDirectory } from './contents.js'; +export { handleCreateDeployment, handleCreateDeploymentStatus, handleCreateOrUpdateEnvironment, handleDeleteDeployment, handleDeleteEnvironment, handleGetDeployment, handleGetDeploymentStatus, handleGetEnvironment, handleListDeployments, handleListDeploymentStatuses, handleListEnvironments } from './deployments.js'; +export { handleAddAuthenticatedEmails, handleDeleteAuthenticatedEmails, handleListAuthenticatedEmails, handleListAuthenticatedPublicEmails, handleSetPrimaryEmailVisibility } from './emails.js'; +export { handleBlockUser, handleCheckBlockedUser, handleCheckFollowing, handleFollowUser, handleListBlockedUsers, handleListFollowers, handleListFollowing, handleUnblockUser, handleUnfollowUser } from './follows.js'; +export { handleCheckGistStar, handleCreateGist, handleCreateGistComment, handleDeleteGist, handleDeleteGistComment, handleForkGist, handleGetGist, handleGetGistComment, handleGetGistRawFile, handleGetGistRevision, handleListAuthenticatedGists, handleListGistComments, handleListGistCommits, handleListGistForks, handleListPublicGists, handleListStarredGists, handleListUserGists, handleStarGist, handleUnstarGist, handleUpdateGist, handleUpdateGistComment } from './gists.js'; +export { handleCreateAuthenticatedGpgKey, handleDeleteAuthenticatedGpgKey, handleGetAuthenticatedGpgKey, handleListAuthenticatedGpgKeys, handleListUserGpgKeys } from './gpg-keys.js'; +export { handleAddAuthenticatedSocialAccounts, handleDeleteAuthenticatedSocialAccounts, handleListAuthenticatedSocialAccounts, handleListUserSocialAccounts } from './social-accounts.js'; +export { handleCreateAuthenticatedSshKey, handleDeleteAuthenticatedSshKey, handleGetAuthenticatedSshKey, handleListAuthenticatedSshKeys, handleListUserSshKeys } from './user-keys.js'; +export { handleCreateAuthenticatedSshSigningKey, handleDeleteAuthenticatedSshSigningKey, handleGetAuthenticatedSshSigningKey, handleListAuthenticatedSshSigningKeys, handleListUserSshSigningKeys } from './user-keys.js'; +export { handleCancelPagesDeployment, handleCreatePagesDeployment, handleCreatePagesSite, handleDeletePagesSite, handleGetLatestPagesBuild, handleGetPagesBuild, handleGetPagesDeploymentStatus, handleGetPagesHealth, handleGetPagesSite, handleListPagesBuilds, handleRequestPagesBuild, handleUpdatePagesSite } from './pages.js'; +export { handleGetCommunityProfile, handleGetStatsCodeFrequency, handleGetStatsCommitActivity, handleGetStatsContributors, handleGetStatsParticipation, handleGetStatsPunchCard, handleGetTrafficClones, handleGetTrafficPopularPaths, handleGetTrafficPopularReferrers, handleGetTrafficViews } from './metrics.js'; +export { + handleAddOrgTeamMembership, + handleAddOrgTeamMembershipById, + handleAddOrUpdateOrgTeamRepo, + handleAddOrUpdateOrgTeamRepoById, + handleAddOrUpdateTeamRepoById, + handleAddTeamMembershipById, + handleBlockOrgUser, + handleCancelOrgInvitation, + handleCheckOrgBlockedUser, + handleCheckOrgMember, + handleCheckOrgTeamMember, + handleCheckOrgTeamRepo, + handleCheckOrgTeamRepoById, + handleCheckPublicOrgMember, + handleCheckTeamMemberById, + handleCheckTeamRepoById, + handleDeleteOrgCustomProperty, + handleCreateOrgIssueField, + handleCreateOrgIssueType, + handleCreateOrgRepo, + handleCreateOrgTeam, + handleConvertOrgMemberToOutsideCollaborator, + handleDeleteOrgIssueField, + handleDeleteOrgIssueType, + handleDeleteOrgTeam, + handleDeleteOrgTeamById, + handleDeleteTeamById, + handleGetAuthenticatedOrgMembership, + handleGetOrg, + handleGetOrgCustomProperty, + handleGetOrgMembership, + handleGetOrgTeam, + handleGetOrgTeamById, + handleGetOrgTeamMembership, + handleGetOrgTeamMembershipById, + handleGetTeamById, + handleGetTeamMembershipById, + handleListAuthenticatedOrgMemberships, + handleListAuthenticatedOrgs, + handleListAuthenticatedUserTeams, + handleListFailedOrgInvitations, + handleListOrganizations, + handleListOrgBlockedUsers, + handleListOrgCustomProperties, + handleListOrgCustomPropertyValues, + handleListOrgInvitationTeams, + handleListOrgInvitations, + handleListOrgIssueFields, + handleListOrgIssueTypes, + handleListOrgMembers, + handleListOrgRepos, + handleListOrgTeamChildTeams, + handleListOrgTeamChildTeamsById, + handleListOrgTeamInvitations, + handleListOrgTeamInvitationsById, + handleListOrgTeamMembers, + handleListOrgTeamMembersById, + handleListOrgTeamRepos, + handleListOrgTeamReposById, + handleListOrgTeams, + handleListOutsideCollaborators, + handleListRepoTeams, + handleListTeamChildTeamsById, + handleListTeamInvitationsById, + handleListTeamMembersById, + handleListTeamReposById, + handleListUserOrgs, + handleRemoveOrgMembership, + handleRemoveOrgTeamMembership, + handleRemoveOrgTeamMembershipById, + handleRemoveOrgTeamRepo, + handleRemoveOrgTeamRepoById, + handleRemoveOutsideCollaborator, + handleRemovePublicOrgMembership, + handleRemoveTeamMembershipById, + handleRemoveTeamRepoById, + handleSetOrgMembership, + handleSetPublicOrgMembership, + handleUnblockOrgUser, + handleUpdateAuthenticatedOrgMembership, + handleUpdateOrg, + handlePutOrgCustomProperty, + handleUpdateOrgCustomPropertyValues, + handleUpdateOrgIssueField, + handleUpdateOrgIssueType, + handleUpdateOrgTeam, + handleUpdateOrgTeamById, + handleUpsertOrgCustomProperties, + handleUpdateTeamById, +} from './orgs.js'; +export { + buildRepoResponse, + createRepoForOwner, + handleCreateFork, + handleCreateUserRepo, + handleDeleteRepo, + handleGenerateRepoFromTemplate, + handleGetRepo, + handleListAuthenticatedRepos, + handleListForks, + handleListPublicRepositories, + handleListRepos, + handleListUserRepos, + handleTransferRepo, + handleUpdateRepo, +} from './repos.js'; +export { + handleAddIssueAssignees, + handleAddIssueLabels, + handleCreateIssue, + handleCreateIssueComment, + handleCreateIssueCommentReaction, + handleCreateIssueReaction, + handleCreateMilestone, + handleCreateRepoLabel, + handleDeleteIssueComment, + handleDeleteIssueCommentReaction, + handleDeleteIssueReaction, + handleDeleteMilestone, + handleDeleteRepoLabel, + handleGetIssue, + handleGetIssueComment, + handleGetIssueEvent, + handleGetMilestone, + handleGetRepoLabel, + handleListAssignedIssues, + handleListAuthenticatedUserIssues, + handleListIssueCommentReactions, + handleListIssueComments, + handleListIssueEvents, + handleListIssueLabels, + handleListIssueReactions, + handleListIssues, + handleListIssueTimeline, + handleListMilestoneLabels, + handleListMilestones, + handleListOrgIssues, + handleListRepoIssueComments, + handleListRepoIssueEvents, + handleListRepoLabels, + handleLockIssue, + handlePinIssueComment, + handleRemoveAllIssueLabels, + handleRemoveIssueAssignees, + handleRemoveIssueLabel, + handleReplaceIssueLabels, + handleUnlockIssue, + handleUnpinIssueComment, + handleUpdateIssue, + handleUpdateIssueComment, + handleUpdateMilestone, + handleUpdateRepoLabel, +} from './issues.js'; +export { + handleCheckPullMerged, + handleCreatePull, + handleCreatePullReview, + handleCreatePullReviewComment, + handleCreatePullReviewCommentReaction, + handleCreatePullReviewCommentReply, + handleDeletePendingPullReview, + handleDeletePullReviewComment, + handleDeletePullReviewCommentReaction, + handleDismissPullReview, + handleGetPull, + handleGetPullReview, + handleGetPullReviewComment, + handleGetPullText, + handleListPullCommits, + handleListPullFiles, + handleListPullRequestedReviewers, + handleListPullReviewCommentReactions, + handleListPullReviewComments, + handleListPullReviewCommentsForReview, + handleListPullReviews, + handleListPulls, + handleListRepoPullReviewComments, + handleMergePull, + handleRemovePullRequestedReviewers, + handleRequestPullReviewers, + handleSubmitPullReview, + handleUpdatePull, + handleUpdatePullBranch, + handleUpdatePullReview, + handleUpdatePullReviewComment, +} from './pulls.js'; +export { handleCreateRelease, handleCreateReleaseReaction, handleDeleteRelease, handleDeleteReleaseAsset, handleDeleteReleaseReaction, handleDownloadReleaseAsset, handleDownloadReleaseAssetByName, handleGenerateReleaseNotes, handleGetLatestRelease, handleGetReleaseAsset, handleGetReleaseById, handleGetReleaseByTag, handleListReleaseAssets, handleListReleaseReactions, handleListReleases, handleUpdateRelease, handleUpdateReleaseAsset, handleUploadReleaseAsset } from './releases.js'; +export { + handleCheckAutomatedSecurityFixes, + handleCheckImmutableReleases, + handleCheckVulnerabilityAlerts, + handleCreateAutolink, + handleCreateDeployKey, + handleCreateRepositoryAttestation, + handleCreateRepositoryDispatch, + handleCreateRepositoryRuleset, + handleDeleteAutolink, + handleDeleteDeployKey, + handleDeleteInteractionLimit, + handleDeleteRepositoryRuleset, + handleDisableAutomatedSecurityFixes, + handleDisableImmutableReleases, + handleDisablePrivateVulnerabilityReporting, + handleDisableVulnerabilityAlerts, + handleEnableAutomatedSecurityFixes, + handleEnableImmutableReleases, + handleEnablePrivateVulnerabilityReporting, + handleEnableVulnerabilityAlerts, + handleGetAutolink, + handleGetDeployKey, + handleGetInteractionLimit, + handleGetPrivateVulnerabilityReporting, + handleGetRepositoryCustomProperties, + handleGetRepositoryHashAlgorithm, + handleGetRepositoryRuleset, + handleGetRepositoryRulesetVersion, + handleGetRepositoryRuleSuite, + handleGetRulesForBranch, + handleListAutolinks, + handleListCodeownersErrors, + handleListDeployKeys, + handleListRepositoryAttestations, + handleListRepositoryIssueTypes, + handleListRepositoryRulesetHistory, + handleListRepositoryRulesets, + handleListRepositoryRuleSuites, + handleSetInteractionLimit, + handleUpdateRepositoryCustomProperties, + handleUpdateRepositoryRuleset, +} from './repo-metadata.js'; +export { handleBulkDeleteUserAttestations, handleBulkListUserAttestations, handleDeleteUserAttestationById, handleDeleteUserAttestationsBySubjectDigest, handleListUserAttestations } from './user-attestations.js'; +export { collectVisibleUserDids, handleGetAuthenticatedUser, handleGetUser, handleGetUserById, handleGetUserHovercard, handleListUsers, handleUpdateAuthenticatedUser } from './users.js'; +export { handleSearchCode, handleSearchCommits, handleSearchIssues, handleSearchLabels, handleSearchRepositories, handleSearchTopics, handleSearchUsers } from './search.js'; +export { handleGetApiVersions, handleGetEmojis, handleGetGitignoreTemplate, handleGetLicense as handleGetLicenseTemplate, handleGetMeta, handleGetRateLimit, handleGetZen, handleGitHubApiRoot, handleListGitignoreTemplates, handleListLicenses, handleRenderMarkdown, handleRenderRawMarkdown } from './meta.js'; +export { handleCheckStarredRepo, handleDeleteRepoSubscription, handleGetRepoSubscription, handleListStargazers, handleListStarredRepos, handleListSubscribers, handleListSubscriptions, handleSetRepoSubscription, handleStarRepo, handleUnstarRepo } from './stars.js'; +export { + handleCreateOrgWebhook, + handleCreateRepoWebhook, + handleDeleteOrgWebhook, + handleDeleteRepoWebhook, + handleGetOrgWebhook, + handleGetOrgWebhookConfig, + handleGetOrgWebhookDelivery, + handleGetRepoWebhook, + handleGetRepoWebhookConfig, + handleGetRepoWebhookDelivery, + handleListOrgWebhookDeliveries, + handleListOrgWebhooks, + handleListRepoWebhookDeliveries, + handleListRepoWebhooks, + handlePingOrgWebhook, + handlePingRepoWebhook, + handleRedeliverOrgWebhookDelivery, + handleRedeliverRepoWebhookDelivery, + handleTestRepoWebhook, + handleUpdateOrgWebhook, + handleUpdateOrgWebhookConfig, + handleUpdateRepoWebhook, + handleUpdateRepoWebhookConfig, +} from './webhooks.js'; +export { handleDeleteNotificationThread, handleDeleteNotificationThreadSubscription, handleGetNotificationThread, handleGetNotificationThreadSubscription, handleListNotifications, handleListRepoNotifications, handleMarkNotificationThreadRead, handleMarkNotificationsRead, handleMarkRepoNotificationsRead, handleSetNotificationThreadSubscription } from './notifications.js'; +export { handleCreateCheckRun, handleCreateCheckSuite, handleCreateCommitStatus, handleGetCheckRun, handleGetCheckSuite, handleGetCombinedStatus, handleListCheckRunAnnotations, handleListCheckRunsForRef, handleListCheckRunsForSuite, handleListCheckSuitesForRef, handleListCommitStatuses, handleRerequestCheckRun, handleRerequestCheckSuite, handleUpdateCheckRun } from './checks.js'; +export { + handleAddStatusCheckContexts, + handleDeleteBranchProtection, + handleDeletePullRequestReviewProtection, + handleDeleteRequiredStatusChecksProtection, + handleGetBranch, + handleGetBranchProtection, + handleGetGitRef, + handleGetPullRequestReviewProtection, + handleGetRequiredStatusChecksProtection, + handleListBranches, + handleListMatchingGitRefs, + handleListStatusCheckContexts, + handleListTags, + handleRemoveStatusCheckContexts, + handleSetStatusCheckContexts, + handleUpdateBranchProtection, + handleUpdatePullRequestReviewProtection, + handleUpdateRequiredStatusChecksProtection, +} from './git-refs.js'; +export { + handleCompareCommitText, + handleCreateGitBlob, + handleCreateGitCommit, + handleCreateGitRef, + handleCreateGitTag, + handleCreateGitTree, + handleDeleteGitContents, + handleDeleteGitRef, + handleDownloadArchive, + handleGetGitTag, + handleGetRepoCommitMedia, + handleListContributors, + handleListRepoCommits, + handlePutGitContents, + handleUpdateGitRef, + tryGetGitRawContent, +} from './git-objects.js'; export { buildApiUrl, buildLinkHeader, diff --git a/src/github-shim/issues.ts b/src/github-shim/issues.ts index 3767f8d..04015b6 100644 --- a/src/github-shim/issues.ts +++ b/src/github-shim/issues.ts @@ -4,21 +4,67 @@ * Maps DWN issue records to GitHub REST API v3 issue responses. * * Endpoints: + * GET /issues Assigned issues across visible repositories + * GET /user/issues Authenticated user's issues + * GET /orgs/:org/issues Organization issues + * GET /repos/:did/:repo/labels Repository labels + * GET /repos/:did/:repo/labels/:name Repository label detail + * POST /repos/:did/:repo/labels Create repository label + * PATCH /repos/:did/:repo/labels/:name Update repository label + * DELETE /repos/:did/:repo/labels/:name Delete repository label + * GET /repos/:did/:repo/milestones Repository milestones + * POST /repos/:did/:repo/milestones Create milestone + * GET /repos/:did/:repo/milestones/:number Milestone detail + * PATCH /repos/:did/:repo/milestones/:number Update milestone + * DELETE /repos/:did/:repo/milestones/:number Delete milestone + * GET /repos/:did/:repo/milestones/:number/labels Labels for issues in milestone * GET /repos/:did/:repo/issues List issues * GET /repos/:did/:repo/issues/:number Issue detail + * PUT /repos/:did/:repo/issues/:number/lock Lock issue conversation + * DELETE /repos/:did/:repo/issues/:number/lock Unlock issue conversation + * GET /repos/:did/:repo/issues/events List repo issue events + * GET /repos/:did/:repo/issues/events/:id Issue event detail + * GET /repos/:did/:repo/issues/comments List repo issue comments + * GET /repos/:did/:repo/issues/comments/:id Issue comment detail + * GET /repos/:did/:repo/issues/comments/:id/reactions Issue comment reactions * GET /repos/:did/:repo/issues/:number/comments Issue comments + * PUT /repos/:did/:repo/issues/comments/:id/pin Pin issue comment + * DELETE /repos/:did/:repo/issues/comments/:id/pin Unpin issue comment + * GET /repos/:did/:repo/issues/:number/events Issue events + * GET /repos/:did/:repo/issues/:number/timeline Issue timeline + * GET /repos/:did/:repo/issues/:number/reactions Issue reactions + * GET /repos/:did/:repo/issues/:number/labels Issue labels * POST /repos/:did/:repo/issues Create issue * PATCH /repos/:did/:repo/issues/:number Update issue + * PATCH /repos/:did/:repo/issues/comments/:id Update issue comment + * DELETE /repos/:did/:repo/issues/comments/:id Delete issue comment + * POST /repos/:did/:repo/issues/comments/:id/reactions Create issue comment reaction + * DELETE /repos/:did/:repo/issues/comments/:id/reactions/:reaction_id Delete issue comment reaction + * POST /repos/:did/:repo/issues/:number/reactions Create issue reaction + * DELETE /repos/:did/:repo/issues/:number/reactions/:reaction_id Delete issue reaction * POST /repos/:did/:repo/issues/:number/comments Create comment + * POST /repos/:did/:repo/issues/:number/labels Add labels + * PUT /repos/:did/:repo/issues/:number/labels Replace labels + * DELETE /repos/:did/:repo/issues/:number/labels Remove all labels + * DELETE /repos/:did/:repo/issues/:number/labels/:name Remove label + * GET /repos/:did/:repo/issues/:number/assignees/:did Check assignable user for issue + * POST /repos/:did/:repo/issues/:number/assignees Add assignees + * DELETE /repos/:did/:repo/issues/:number/assignees Remove assignees * * @module */ import type { AgentContext } from '../cli/agent.js'; +import type { BodyMediaKind } from './body-media.js'; import type { JsonResponse } from './helpers.js'; import { DateSort } from '@enbox/dwn-sdk-js'; +import { applyBodyMedia } from './body-media.js'; +import { handleCheckAssignee } from './repo-metadata.js'; +import { orgRouteExists } from './orgs.js'; +import { buildRepoResponse, repoInfoFromRecord } from './repos.js'; + import { buildApiUrl, buildLinkHeader, @@ -26,6 +72,7 @@ import { fromOpt, getRepoRecord, jsonCreated, + jsonNoContent, jsonNotFound, jsonOk, jsonValidationError, @@ -42,198 +89,3152 @@ import { function buildIssueResponse( rec: any, data: any, tags: Record<string, string>, targetDid: string, repoName: string, baseUrl: string, + labels: Record<string, unknown>[] = [], + assignees: Record<string, unknown>[] = [], + reactions: any[] = [], + milestone: Record<string, unknown> | null = null, + bodyMediaKind?: BodyMediaKind | null, ): Record<string, unknown> { const authorDid = rec.author ?? targetDid; const user = buildOwner(authorDid, baseUrl); const number = numericId(rec.id ?? ''); const state = tags.status === 'closed' ? 'closed' : 'open'; + const locked = tags.locked === 'true'; + const body = typeof data.body === 'string' ? data.body : null; - return { + return applyBodyMedia({ id : numericId(rec.id ?? ''), node_id : rec.id ?? '', url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}`, html_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}`, repository_url : `${baseUrl}/repos/${targetDid}/${repoName}`, comments_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}/comments`, + events_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}/events`, + timeline_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}/timeline`, + labels_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}/labels{/name}`, number, title : data.title ?? '', - body : data.body ?? null, state, - locked : false, + locked, + active_lock_reason : locked ? tags.lockReason ?? null : null, comments : 0, created_at : toISODate(rec.dateCreated), updated_at : toISODate(rec.timestamp), closed_at : state === 'closed' ? toISODate(rec.timestamp) : null, user, author_association : authorDid === targetDid ? 'OWNER' : 'CONTRIBUTOR', - labels : [], - assignees : [], - milestone : null, - reactions : { url: '', total_count: 0 }, + labels, + assignee : assignees[0] ?? null, + assignees, + milestone, + reactions : buildIssueReactionsSummary(targetDid, repoName, number, baseUrl, reactions), + }, body, bodyMediaKind); +} + +function buildLabelResponse( + rec: any, data: any, tags: Record<string, string>, + targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const name = tags.name ?? data.name ?? ''; + const color = normalizeLabelColor(tags.color ?? data.color) ?? 'ededed'; + return { + id : numericId(rec.id ?? ''), + node_id : rec.id ?? '', + url : `${baseUrl}/repos/${targetDid}/${repoName}/labels/${encodeURIComponent(name)}`, + name, + color, + default : false, + description : data.description ?? null, }; } -// --------------------------------------------------------------------------- -// GET /repos/:did/:repo/issues -// --------------------------------------------------------------------------- +function buildAssigneeResponse( + rec: any, data: any, tags: Record<string, string>, baseUrl: string, +): Record<string, unknown> { + const did = tags.assigneeDid ?? data.assigneeDid ?? ''; + return { + ...buildOwner(did, baseUrl), + node_id : rec.id ?? did, + gravatar_id : '', + followers_url : `${baseUrl}/users/${did}/followers`, + following_url : `${baseUrl}/users/${did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${did}/subscriptions`, + organizations_url : `${baseUrl}/users/${did}/orgs`, + repos_url : `${baseUrl}/users/${did}/repos`, + events_url : `${baseUrl}/users/${did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${did}/received_events`, + site_admin : false, + ...(typeof data.alias === 'string' && data.alias ? { name: data.alias } : {}), + }; +} -export async function handleListIssues( - ctx: AgentContext, targetDid: string, repoName: string, url: URL, -): Promise<JsonResponse> { +function buildIssueFieldValueResponse(entry: IssueFieldValueEntry): Record<string, unknown> { + const response: Record<string, unknown> = { + issue_field_id : entry.fieldId, + node_id : entry.rec.id ?? `issue-field-${entry.fieldId}`, + data_type : entry.dataType, + }; + + if (entry.dataType === 'multi_select' && Array.isArray(entry.value)) { + response.value = entry.value.join(','); + response.multi_select_options = entry.value.map((name, index) => ({ + id : numericId(`issue-field:${entry.fieldId}:${name}`) || index + 1, + name, + color : 'gray', + })); + return response; + } + + response.value = entry.value; + if (entry.dataType === 'single_select' && typeof entry.value === 'string') { + response.single_select_option = { + id : numericId(`issue-field:${entry.fieldId}:${entry.value}`) || 1, + name : entry.value, + color : 'gray', + }; + } + return response; +} + +type IssueLookup = { + repo : NonNullable<Awaited<ReturnType<typeof getRepoRecord>>>; + from : string | undefined; + issue : any; +}; + +type IssueCommentEntry = { + issue : any; + issueNumber : number; + comment : any; +}; + +type IssueCommentLookup = { + from : string | undefined; + entry : IssueCommentEntry; +}; + +type IssueEventEntry = { + issue : any; + issueNumber : number; + event : any; +}; + +type IssueTimelineEntry = + | { + kind : 'comment'; + issueNumber : number; + comment : any; + createdAt : string; + recordId : string; + } + | { + kind : 'event'; + issue : any; + issueNumber : number; + event : any; + createdAt : string; + recordId : string; + }; + +type IssueInboxEntry = { + ownerDid : string; + repoName : string; + repo : ReturnType<typeof repoInfoFromRecord>; + issue : any; + data : any; + tags : Record<string, string>; + commentCount : number; + response : Record<string, unknown>; +}; + +type RepoLabelEntry = { + rec : any; + data : any; + tags : Record<string, string>; + name : string; + updatedAt : string; +}; + +type RepoLabelRecordEntry = RepoLabelEntry & { + issue : any; +}; + +type RepoLabelCatalogEntry = { + name : string; + color : string; + description? : string | null; + createdAt? : string; + updatedAt? : string; +}; + +type RepoMilestoneCatalogEntry = { + title : string; + number : number; + state? : 'open' | 'closed'; + description? : string | null; + dueOn? : string | null; + createdAt? : string; + updatedAt? : string; + closedAt? : string | null; +}; + +type RepoSettingsData = { + branchProtection? : Record<string, unknown>; + labels? : Record<string, RepoLabelCatalogEntry>; + milestones? : Record<string, RepoMilestoneCatalogEntry>; + mergeStrategies? : ('merge' | 'squash' | 'rebase')[]; + autoDeleteBranch? : boolean; +}; + +type RepoSettingsLookup = { + repo : NonNullable<Awaited<ReturnType<typeof getRepoRecord>>>; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +type MilestoneEntry = { + title : string; + number : number; + state? : 'open' | 'closed'; + description : string | null; + dueOn : string | null; + openIssues : number; + closedIssues : number; + createdAt : string; + updatedAt : string; + closedAt? : string | null; +}; + +type MilestoneLookup = { + repo : NonNullable<Awaited<ReturnType<typeof getRepoRecord>>>; + from : string | undefined; + milestone : MilestoneEntry; +}; + +async function findIssueRecord( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<IssueLookup | JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); } const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); + const num = parseInt(number, 10); + const { records } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repo.contextId }, + }); - // Query params. - const stateFilter = url.searchParams.get('state') ?? 'open'; - const direction = url.searchParams.get('direction') ?? 'desc'; - const pagination = parsePagination(url); + const issue = records.find(r => numericId(r.id ?? '') === num); + if (!issue) { + return jsonNotFound(`Issue #${number} not found.`); + } - const dateSort = direction === 'asc' - ? DateSort.CreatedAscending - : DateSort.CreatedDescending; + return { repo, from, issue }; +} + +async function listLabelRecords( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<any[]> { + const { records } = await ctx.issues.records.query('repo/issue/label' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + return records; +} + +function normalizeMilestoneTitle(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const title = value.trim(); + return title.length > 0 ? title : null; +} + +function milestoneKey(title: string): string { + return title.toLocaleLowerCase(); +} + +function milestoneNumber(title: string): number { + return numericId(`milestone:${milestoneKey(title)}`) || 1; +} + +function milestoneState(entry: MilestoneEntry): 'open' | 'closed' { + if (entry.state === 'open' || entry.state === 'closed') { return entry.state; } + return entry.openIssues > 0 ? 'open' : 'closed'; +} + +function catalogMilestoneEntry(key: string, milestone: RepoMilestoneCatalogEntry): MilestoneEntry | null { + const title = normalizeMilestoneTitle(milestone.title); + if (!title) { return null; } + + const number = Number.isInteger(milestone.number) && milestone.number > 0 + ? milestone.number + : milestoneNumber(title); + const state = milestone.state === 'closed' ? 'closed' : 'open'; + const createdAt = String(milestone.createdAt ?? milestone.updatedAt ?? ''); + const updatedAt = String(milestone.updatedAt ?? milestone.createdAt ?? ''); + + return { + title, + number, + state, + description : typeof milestone.description === 'string' ? milestone.description : null, + dueOn : typeof milestone.dueOn === 'string' ? milestone.dueOn : null, + openIssues : 0, + closedIssues : 0, + createdAt, + updatedAt, + closedAt : state === 'closed' ? milestone.closedAt ?? updatedAt : null, + }; +} + +function nextMilestoneNumber(entries: MilestoneEntry[]): number { + return entries.reduce((max, entry) => Math.max(max, entry.number), 0) + 1; +} +async function listIssueRecordsForRepo( + ctx: AgentContext, repoContextId: string, from?: string, +): Promise<any[]> { const { records } = await ctx.issues.records.query('repo/issue', { from, - filter: { contextId: repo.contextId }, - dateSort, + filter: { contextId: repoContextId }, }); + return records; +} - // Filter by state. - let filtered = records; - if (stateFilter !== 'all') { - filtered = records.filter((r) => { - const t = r.tags as Record<string, string> | undefined; - const s = t?.status ?? 'open'; - return stateFilter === 'closed' ? s === 'closed' : s === 'open'; - }); +async function listMilestoneEntriesForRepo( + ctx: AgentContext, repoContextId: string, from?: string, settings: RepoSettingsData = {}, +): Promise<MilestoneEntry[]> { + const issues = await listIssueRecordsForRepo(ctx, repoContextId, from); + const issueEntriesByTitle = new Map<string, MilestoneEntry>(); + + for (const issue of issues) { + const tags = (issue.tags as Record<string, string> | undefined) ?? {}; + const title = normalizeMilestoneTitle(tags.milestone); + if (!title) { continue; } + + const key = milestoneKey(title); + const createdAt = String(issue.dateCreated ?? issue.timestamp ?? ''); + const updatedAt = String(issue.timestamp ?? issue.dateCreated ?? ''); + let entry = issueEntriesByTitle.get(key); + if (!entry) { + entry = { + title, + number : milestoneNumber(title), + description : null, + dueOn : null, + openIssues : 0, + closedIssues : 0, + createdAt, + updatedAt, + }; + issueEntriesByTitle.set(key, entry); + } + + if (tags.status === 'closed') { + entry.closedIssues++; + } else { + entry.openIssues++; + } + + if (createdAt && (!entry.createdAt || createdAt.localeCompare(entry.createdAt) < 0)) { + entry.createdAt = createdAt; + } + if (updatedAt && (!entry.updatedAt || updatedAt.localeCompare(entry.updatedAt) > 0)) { + entry.updatedAt = updatedAt; + } } - // Paginate. - const page = paginate(filtered, pagination); + const entriesByTitle = new Map<string, MilestoneEntry>(); + for (const [key, milestone] of Object.entries(settings.milestones ?? {})) { + const entry = catalogMilestoneEntry(key, milestone); + if (!entry) { continue; } + + const issueEntry = issueEntriesByTitle.get(milestoneKey(entry.title)); + if (issueEntry) { + entry.openIssues = issueEntry.openIssues; + entry.closedIssues = issueEntry.closedIssues; + if (!entry.createdAt || issueEntry.createdAt.localeCompare(entry.createdAt) < 0) { + entry.createdAt = issueEntry.createdAt; + } + if (issueEntry.updatedAt && issueEntry.updatedAt.localeCompare(entry.updatedAt) > 0) { + entry.updatedAt = issueEntry.updatedAt; + } + } + + entriesByTitle.set(milestoneKey(entry.title), entry); + } - // Build response. - const items: Record<string, unknown>[] = []; - for (const rec of page) { + for (const [key, entry] of issueEntriesByTitle.entries()) { + if (!entriesByTitle.has(key)) { + entriesByTitle.set(key, entry); + } + } + + return [...entriesByTitle.values()]; +} + +async function listMilestoneEntries( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<MilestoneEntry[] | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return listMilestoneEntriesForRepo(ctx, lookup.repo.contextId, fromOpt(ctx, targetDid), lookup.settings); +} + +function buildMilestoneResponse( + entry: MilestoneEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const state = milestoneState(entry); + const updatedAt = toISODate(entry.updatedAt || entry.createdAt); + + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/milestones/${entry.number}`, + html_url : `${baseUrl}/repos/${targetDid}/${repoName}/milestones/${encodeURIComponent(entry.title)}`, + labels_url : `${baseUrl}/repos/${targetDid}/${repoName}/milestones/${entry.number}/labels`, + id : entry.number, + node_id : `milestone:${entry.number}`, + number : entry.number, + state, + title : entry.title, + description : entry.description, + creator : buildOwner(targetDid, baseUrl), + open_issues : entry.openIssues, + closed_issues : entry.closedIssues, + created_at : toISODate(entry.createdAt), + updated_at : updatedAt, + closed_at : state === 'closed' ? toISODate(entry.closedAt ?? entry.updatedAt) : null, + due_on : entry.dueOn ? toISODate(entry.dueOn) : null, + }; +} + +async function buildIssueMilestone( + ctx: AgentContext, targetDid: string, repoName: string, baseUrl: string, titleValue: unknown, +): Promise<Record<string, unknown> | null> { + const title = normalizeMilestoneTitle(titleValue); + if (!title) { return null; } + + const entries = await listMilestoneEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return null; } + + const entry = entries.find(item => milestoneKey(item.title) === milestoneKey(title)) ?? { + title, + number : milestoneNumber(title), + description : null, + dueOn : null, + openIssues : 0, + closedIssues : 0, + createdAt : new Date(0).toISOString(), + updatedAt : new Date(0).toISOString(), + }; + + return buildMilestoneResponse(entry, targetDid, repoName, baseUrl); +} + +function sortMilestoneEntries(entries: MilestoneEntry[], url: URL): MilestoneEntry[] { + const stateFilter = url.searchParams.get('state') ?? 'open'; + const sort = url.searchParams.get('sort') === 'completeness' ? 'completeness' : 'due_on'; + const direction = url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc'; + + const filtered = stateFilter === 'all' + ? entries + : entries.filter(entry => milestoneState(entry) === stateFilter); + + return [...filtered].sort((a, b) => { + let result = 0; + if (sort === 'completeness') { + const leftTotal = a.openIssues + a.closedIssues; + const rightTotal = b.openIssues + b.closedIssues; + const left = leftTotal === 0 ? 0 : a.closedIssues / leftTotal; + const right = rightTotal === 0 ? 0 : b.closedIssues / rightTotal; + result = left - right; + } else { + const leftDue = a.dueOn ? Date.parse(a.dueOn) : Number.POSITIVE_INFINITY; + const rightDue = b.dueOn ? Date.parse(b.dueOn) : Number.POSITIVE_INFINITY; + result = leftDue - rightDue; + } + + if (result === 0) { + result = a.title.localeCompare(b.title) || a.number - b.number; + } + + return direction === 'desc' ? -result : result; + }); +} + +async function findMilestoneEntry( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<MilestoneLookup | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const from = fromOpt(ctx, targetDid); + const num = parseInt(number, 10); + const entries = await listMilestoneEntriesForRepo(ctx, lookup.repo.contextId, from, lookup.settings); + const milestone = entries.find(entry => entry.number === num); + if (!milestone) { + return jsonNotFound(`Milestone #${number} not found.`); + } + + return { repo: lookup.repo, from, milestone }; +} + +async function parseMilestoneInput( + ctx: AgentContext, targetDid: string, repoName: string, value: unknown, +): Promise<string | null | JsonResponse> { + if (value === null) { return null; } + + const title = normalizeMilestoneTitle(value); + if (title) { return title; } + + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + const entries = await listMilestoneEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const entry = entries.find(item => item.number === value); + if (!entry) { + return jsonValidationError(`Validation Failed: milestone ${value} does not exist.`); + } + return entry.title; + } + + return jsonValidationError('Validation Failed: milestone must be a milestone number, string title, or null.'); +} + +async function buildIssueLabels( + ctx: AgentContext, from: string | undefined, issueRec: any, + targetDid: string, repoName: string, baseUrl: string, +): Promise<Record<string, unknown>[]> { + const records = await listLabelRecords(ctx, from, issueRec); + const labels = []; + for (const rec of records) { const data = await rec.data.json(); const tags = (rec.tags as Record<string, string> | undefined) ?? {}; - items.push(buildIssueResponse(rec, data, tags, targetDid, repoName, baseUrl)); + labels.push(buildLabelResponse(rec, data, tags, targetDid, repoName, baseUrl)); } + return labels; +} - const linkHeader = buildLinkHeader( - baseUrl, `/repos/${targetDid}/${repoName}/issues`, - pagination.page, pagination.perPage, filtered.length, - ); - const extraHeaders: Record<string, string> = {}; - if (linkHeader) { extraHeaders['Link'] = linkHeader; } +async function listIssueStatusChangeRecords( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<any[]> { + const { records } = await ctx.issues.records.query('repo/issue/statusChange' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + return records; +} - return jsonOk(items, extraHeaders); +async function listIssueEventEntries( + ctx: AgentContext, repoContextId: string, from?: string, +): Promise<IssueEventEntry[]> { + const { records: issues } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repoContextId }, + }); + + const entries: IssueEventEntry[] = []; + for (const issue of issues) { + const issueNumber = numericId(issue.id ?? ''); + const events = await listIssueStatusChangeRecords(ctx, from, issue); + for (const event of events) { + entries.push({ issue, issueNumber, event }); + } + } + + entries.sort((a, b) => String(a.event.dateCreated).localeCompare(String(b.event.dateCreated))); + return entries; } -// --------------------------------------------------------------------------- -// GET /repos/:did/:repo/issues/:number -// --------------------------------------------------------------------------- +function decodeLabelName(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} -export async function handleGetIssue( - ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, -): Promise<JsonResponse> { +function labelKey(name: string): string { + return name.toLocaleLowerCase(); +} + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); } const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); - - const num = parseInt(number, 10); - const { records } = await ctx.issues.records.query('repo/issue', { + const { records } = await ctx.repo.records.query('repo/settings' as any, { from, filter: { contextId: repo.contextId }, }); + if (records.length === 0) { + return { repo, settings: {} }; + } - const rec = records.find(r => numericId(r.id ?? '') === num); - if (!rec) { - return jsonNotFound(`Issue #${number} not found.`); + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; } - const data = await rec.data.json(); - const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} - // Fetch comment count. - const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { +function catalogLabelEntry(key: string, label: RepoLabelCatalogEntry): RepoLabelEntry | null { + const name = normalizeLabelName(label.name); + const color = normalizeLabelColor(label.color); + if (!name || !color) { return null; } + + const updatedAt = String(label.updatedAt ?? label.createdAt ?? ''); + return { + rec: { + id : `repo-label:${key}`, + timestamp : label.updatedAt ?? label.createdAt, + dateCreated : label.createdAt ?? label.updatedAt, + }, + data: { + name, + color, + description: label.description ?? null, + }, + tags: { name, color }, + name, + updatedAt, + }; +} + +async function listRepoLabelRecords( + ctx: AgentContext, from: string | undefined, repoContextId: string, +): Promise<RepoLabelRecordEntry[]> { + const issues = await listIssueRecordsForRepo(ctx, repoContextId, from); + const labels: RepoLabelRecordEntry[] = []; + for (const issue of issues) { + const labelRecords = await listLabelRecords(ctx, from, issue); + for (const rec of labelRecords) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = normalizeLabelName(tags.name ?? data.name); + if (!name) { continue; } + + labels.push({ + issue, + rec, + data, + tags : { ...tags, name }, + name, + updatedAt : String(rec.timestamp ?? rec.dateCreated ?? ''), + }); + } + } + return labels; +} + +async function listRepoLabelEntries( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoLabelEntry[] | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const from = fromOpt(ctx, targetDid); + const labelsByName = new Map<string, RepoLabelEntry>(); + + for (const [key, label] of Object.entries(lookup.settings.labels ?? {})) { + const entry = catalogLabelEntry(key, label); + if (entry) { + labelsByName.set(labelKey(entry.name), entry); + } + } + + const labelRecords = await listRepoLabelRecords(ctx, from, lookup.repo.contextId); + for (const entry of labelRecords) { + const key = labelKey(entry.name); + const existing = labelsByName.get(key); + const hasCatalogEntry = Object.prototype.hasOwnProperty.call(lookup.settings.labels ?? {}, key); + if (!existing || (!hasCatalogEntry && entry.updatedAt.localeCompare(existing.updatedAt) >= 0)) { + labelsByName.set(key, entry); + } + } + + return [...labelsByName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +async function listAssignmentRecords( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<any[]> { + const { records } = await ctx.issues.records.query('repo/issue/assignment' as any, { from, - filter: { contextId: rec.contextId }, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, }); + return records; +} - const issue = buildIssueResponse(rec, data, tags, targetDid, repoName, baseUrl); - issue.comments = comments.length; +type IssueDependencyEntry = { + rec : any; + issueId : number; +}; - return jsonOk(issue); +type IssueSubIssueEntry = { + rec : any; + issueId : number; + priority : number; +}; + +type IssueParentLookup = { + parent : any; + entry : IssueSubIssueEntry; +}; + +type IssueFieldValueDataType = 'text' | 'single_select' | 'number' | 'date' | 'multi_select'; + +type IssueFieldValueInput = { + fieldId : number; + dataType : IssueFieldValueDataType; + value : string | number | string[]; +}; + +type IssueFieldValueEntry = IssueFieldValueInput & { + rec : any; +}; + +function getIssueDependencyIssueId(rec: any, data?: any): number { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const value = tags.issueId ?? data?.issueId; + const issueId = typeof value === 'number' ? value : parseInt(String(value ?? ''), 10); + return Number.isInteger(issueId) && issueId > 0 ? issueId : 0; } -// --------------------------------------------------------------------------- -// GET /repos/:did/:repo/issues/:number/comments -// --------------------------------------------------------------------------- +function getIssueSubIssueIssueId(rec: any, data?: any): number { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const value = tags.issueId ?? data?.issueId; + const issueId = typeof value === 'number' ? value : parseInt(String(value ?? ''), 10); + return Number.isInteger(issueId) && issueId > 0 ? issueId : 0; +} -export async function handleListIssueComments( - ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, -): Promise<JsonResponse> { - const repo = await getRepoRecord(ctx, targetDid, repoName); - if (!repo) { - return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); +function getIssueSubIssuePriority(rec: any, data?: any): number { + const value = data?.priority; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; } + return numericId(rec.id ?? '') || 1; +} - const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); - const pagination = parsePagination(url); +async function listIssueDependencyEntries( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<IssueDependencyEntry[]> { + const { records } = await ctx.issues.records.query('repo/issue/issueDependency' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); - // Find the issue first. - const num = parseInt(number, 10); - const { records: issues } = await ctx.issues.records.query('repo/issue', { + const entries: IssueDependencyEntry[] = []; + for (const rec of records) { + const data = await rec.data.json(); + const issueId = getIssueDependencyIssueId(rec, data); + if (issueId > 0) { + entries.push({ rec, issueId }); + } + } + return entries; +} + +async function listIssueSubIssueEntries( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<IssueSubIssueEntry[]> { + const { records } = await ctx.issues.records.query('repo/issue/subIssue' as any, { from, - filter: { contextId: repo.contextId }, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, }); - const issueRec = issues.find(r => numericId(r.id ?? '') === num); - if (!issueRec) { - return jsonNotFound(`Issue #${number} not found.`); + const entries: IssueSubIssueEntry[] = []; + for (const rec of records) { + const data = await rec.data.json(); + const issueId = getIssueSubIssueIssueId(rec, data); + if (issueId > 0) { + entries.push({ rec, issueId, priority: getIssueSubIssuePriority(rec, data) }); + } + } + entries.sort((a, b) => a.priority - b.priority || String(a.rec.dateCreated).localeCompare(String(b.rec.dateCreated))); + return entries; +} + +function inferIssueFieldValueDataType(value: string | number | string[]): IssueFieldValueDataType { + if (Array.isArray(value)) { return 'multi_select'; } + if (typeof value === 'number') { return 'number'; } + if (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))) { + return 'date'; + } + return 'text'; +} + +function getIssueFieldValueFieldId(rec: any, data?: any): number { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const value = tags.fieldId ?? data?.fieldId; + const fieldId = typeof value === 'number' ? value : parseInt(String(value ?? ''), 10); + return Number.isInteger(fieldId) && fieldId > 0 ? fieldId : 0; +} + +async function listIssueFieldValueEntries( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<IssueFieldValueEntry[]> { + const { records } = await ctx.issues.records.query('repo/issue/issueFieldValue' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const entries: IssueFieldValueEntry[] = []; + for (const rec of records) { + const data = await rec.data.json(); + const fieldId = getIssueFieldValueFieldId(rec, data); + if (fieldId <= 0) { continue; } + + const value = data.value; + if ( + typeof value !== 'string' + && typeof value !== 'number' + && !Array.isArray(value) + ) { + continue; + } + + const normalizedValue = Array.isArray(value) ? value.map(item => String(item)) : value; + const dataType = typeof data.dataType === 'string' + ? data.dataType as IssueFieldValueDataType + : inferIssueFieldValueDataType(normalizedValue); + entries.push({ rec, fieldId, dataType, value: normalizedValue }); + } + + entries.sort((a, b) => a.fieldId - b.fieldId); + return entries; +} + +function findIssueByNumericId(issues: any[], issueId: number): any | undefined { + return issues.find(issue => numericId(issue.id ?? '') === issueId); +} + +async function findParentIssueForSubIssue( + ctx: AgentContext, from: string | undefined, repoContextId: string, subIssueId: number, +): Promise<IssueParentLookup | undefined> { + const repoIssues = await listIssueRecordsForRepo(ctx, repoContextId, from); + for (const issue of repoIssues) { + const entries = await listIssueSubIssueEntries(ctx, from, issue); + const entry = entries.find(item => item.issueId === subIssueId); + if (entry) { + return { parent: issue, entry }; + } + } + return undefined; +} + +async function rewriteSubIssuePriorities(entries: IssueSubIssueEntry[]): Promise<JsonResponse | undefined> { + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + const priority = index + 1; + if (entry.priority === priority) { continue; } + + const { status } = await entry.rec.update({ + data : { issueId: entry.issueId, priority }, + tags : { issueId: String(entry.issueId) }, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to reprioritize sub-issue ${entry.issueId}: ${status.detail}`); + } + entry.priority = priority; + } + + return undefined; +} + +async function deleteIssueFieldValueEntries(entries: IssueFieldValueEntry[]): Promise<JsonResponse | undefined> { + for (const entry of entries) { + const { status } = await entry.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue field value ${entry.fieldId}: ${status.detail}`); + } + } + return undefined; +} + +async function upsertIssueFieldValueEntries( + ctx: AgentContext, issueRec: any, existing: IssueFieldValueEntry[], inputs: IssueFieldValueInput[], +): Promise<JsonResponse | undefined> { + const entriesByField = new Map(existing.map(entry => [entry.fieldId, entry])); + + for (const input of inputs) { + const data = { fieldId: input.fieldId, dataType: input.dataType, value: input.value }; + const tags = { fieldId: String(input.fieldId) }; + const existingEntry = entriesByField.get(input.fieldId); + if (existingEntry) { + const { status } = await existingEntry.rec.update({ data, tags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update issue field value ${input.fieldId}: ${status.detail}`); + } + continue; + } + + const { status } = await ctx.issues.records.create('repo/issue/issueFieldValue' as any, { + data, + tags, + parentContextId: issueRec.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to add issue field value ${input.fieldId}: ${status.detail}`); + } + } + + return undefined; +} + +async function buildIssueAssignees( + ctx: AgentContext, from: string | undefined, issueRec: any, baseUrl: string, +): Promise<Record<string, unknown>[]> { + const records = await listAssignmentRecords(ctx, from, issueRec); + const assignees = []; + for (const rec of records) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + assignees.push(buildAssigneeResponse(rec, data, tags, baseUrl)); + } + return assignees; +} + +type LabelInput = { + name : string; + color : string; + description? : string | null; +}; + +type MilestoneInput = { + title : string; + state : 'open' | 'closed'; + description : string | null; + dueOn : string | null; +}; + +type AssigneeInput = { + did : string; + alias : string; +}; + +const REACTION_CONTENTS = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes'] as const; +type ReactionContent = typeof REACTION_CONTENTS[number]; +const REACTION_CONTENT_SET = new Set<string>(REACTION_CONTENTS); +const ISSUE_LOCK_REASONS = ['off-topic', 'too heated', 'resolved', 'spam'] as const; +type IssueLockReason = typeof ISSUE_LOCK_REASONS[number]; +const ISSUE_LOCK_REASON_SET = new Set<string>(ISSUE_LOCK_REASONS); +const MAX_ISSUE_ASSIGNEES = 10; + +function normalizeLabelName(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const name = value.trim(); + return name.length > 0 ? name : null; +} + +function normalizeLabelColor(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const color = value.trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(color) ? color.toLowerCase() : null; +} + +function normalizeLabelDescription(value: unknown): string | null | JsonResponse { + if (value === undefined || value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + return value; +} + +function normalizeMilestoneDescriptionInput(value: unknown): string | null | JsonResponse { + if (value === undefined || value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + return value; +} + +function normalizeMilestoneDueOn(value: unknown): string | null | JsonResponse { + if (value === undefined || value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: due_on must be an ISO 8601 timestamp or null.'); + } + + const dueOn = value.trim(); + if (!dueOn) { return null; } + const timestamp = Date.parse(dueOn); + if (Number.isNaN(timestamp)) { + return jsonValidationError('Validation Failed: due_on must be an ISO 8601 timestamp or null.'); + } + return new Date(timestamp).toISOString(); +} + +function normalizeMilestoneState(value: unknown, fallback: 'open' | 'closed'): 'open' | 'closed' | JsonResponse { + if (value === undefined) { return fallback; } + if (value === 'open' || value === 'closed') { return value; } + return jsonValidationError('Validation Failed: state must be open or closed.'); +} + +function parseMilestoneCreateInput(reqBody: Record<string, unknown>): MilestoneInput | JsonResponse { + const title = normalizeMilestoneTitle(reqBody.title); + if (!title) { + return jsonValidationError('Validation Failed: title is required.'); + } + + const state = normalizeMilestoneState(reqBody.state, 'open'); + if (typeof state !== 'string') { return state; } + + const description = normalizeMilestoneDescriptionInput(reqBody.description); + if (typeof description !== 'string' && description !== null) { + return description; + } + + const dueOn = normalizeMilestoneDueOn(reqBody.due_on); + if (typeof dueOn !== 'string' && dueOn !== null) { + return dueOn; + } + + return { title, state, description, dueOn }; +} + +function parseMilestoneUpdateInput( + reqBody: Record<string, unknown>, existing: MilestoneEntry, +): MilestoneInput | JsonResponse { + const title = reqBody.title === undefined ? existing.title : normalizeMilestoneTitle(reqBody.title); + if (!title) { + return jsonValidationError('Validation Failed: title must be a non-empty string.'); + } + + const state = normalizeMilestoneState(reqBody.state, milestoneState(existing)); + if (typeof state !== 'string') { return state; } + + const description = reqBody.description === undefined + ? existing.description + : normalizeMilestoneDescriptionInput(reqBody.description); + if (typeof description !== 'string' && description !== null) { + return description; + } + + const dueOn = reqBody.due_on === undefined ? existing.dueOn : normalizeMilestoneDueOn(reqBody.due_on); + if (typeof dueOn !== 'string' && dueOn !== null) { + return dueOn; + } + + return { title, state, description, dueOn }; +} + +function parseRepoLabelCreateInput(reqBody: Record<string, unknown>): LabelInput | JsonResponse { + const name = normalizeLabelName(reqBody.name); + if (!name) { + return jsonValidationError('Validation Failed: name is required.'); + } + + const color = reqBody.color === undefined ? 'ededed' : normalizeLabelColor(reqBody.color); + if (!color) { + return jsonValidationError('Validation Failed: color must be a 6-character hex color.'); + } + + const description = normalizeLabelDescription(reqBody.description); + if (typeof description !== 'string' && description !== null) { + return description; + } + + return { name, color, description }; +} + +function parseRepoLabelUpdateInput( + reqBody: Record<string, unknown>, existing: RepoLabelEntry, +): LabelInput | JsonResponse { + const name = reqBody.new_name === undefined + ? existing.name + : normalizeLabelName(reqBody.new_name); + if (!name) { + return jsonValidationError('Validation Failed: new_name must be a non-empty string.'); + } + + const existingColor = normalizeLabelColor(existing.tags.color ?? existing.data.color) ?? 'ededed'; + const color = reqBody.color === undefined ? existingColor : normalizeLabelColor(reqBody.color); + if (!color) { + return jsonValidationError('Validation Failed: color must be a 6-character hex color.'); + } + + const existingDescription = typeof existing.data.description === 'string' ? existing.data.description : null; + const description = reqBody.description === undefined + ? existingDescription + : normalizeLabelDescription(reqBody.description); + if (typeof description !== 'string' && description !== null) { + return description; + } + + return { name, color, description }; +} + +function parseLabelInputs(reqBody: unknown): LabelInput[] | JsonResponse { + const raw = Array.isArray(reqBody) + ? reqBody + : (reqBody as Record<string, unknown>).labels; + + if (!Array.isArray(raw)) { + return jsonValidationError('Validation Failed: labels must be an array.'); + } + + const labels: LabelInput[] = []; + const seen = new Set<string>(); + for (const item of raw) { + const name = normalizeLabelName( + typeof item === 'string' ? item : (item as Record<string, unknown> | undefined)?.name, + ); + if (!name) { + return jsonValidationError('Validation Failed: each label must have a non-empty name.'); + } + + const key = name.toLocaleLowerCase(); + if (seen.has(key)) { continue; } + seen.add(key); + + const color = typeof item === 'object' && item !== null + ? normalizeLabelColor((item as Record<string, unknown>).color) ?? 'ededed' + : 'ededed'; + const description = typeof item === 'object' && item !== null + ? normalizeLabelDescription((item as Record<string, unknown>).description) + : null; + if (typeof description !== 'string' && description !== null) { + return description; + } + labels.push({ name, color, description }); + } + + return labels; +} + +function hasAssigneeInput(reqBody: Record<string, unknown>): boolean { + return 'assignees' in reqBody || 'assignee' in reqBody; +} + +function normalizeAssigneeDid(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const did = value.trim(); + return did.length > 0 ? did : null; +} + +function parseAssigneeInputs(reqBody: unknown): AssigneeInput[] | JsonResponse { + const body = reqBody as Record<string, unknown>; + const raw = Array.isArray(reqBody) + ? reqBody + : Array.isArray(body.assignees) + ? body.assignees + : 'assignee' in body + ? body.assignee === null ? [] : [body.assignee] + : undefined; + + if (!Array.isArray(raw)) { + return jsonValidationError('Validation Failed: assignees must be an array.'); + } + + const assignees: AssigneeInput[] = []; + const seen = new Set<string>(); + for (const item of raw) { + const itemObj = typeof item === 'object' && item !== null + ? item as Record<string, unknown> + : undefined; + const did = normalizeAssigneeDid( + typeof item === 'string' + ? item + : itemObj?.assigneeDid ?? itemObj?.did ?? itemObj?.login, + ); + if (!did) { + return jsonValidationError('Validation Failed: each assignee must be a non-empty DID login.'); + } + + if (seen.has(did)) { continue; } + seen.add(did); + assignees.push({ + did, + alias: typeof itemObj?.alias === 'string' ? itemObj.alias : '', + }); + } + + return assignees; +} + +function issueAssigneeLimitError(): JsonResponse { + return jsonValidationError(`Validation Failed: issues can have up to ${MAX_ISSUE_ASSIGNEES} assignees.`); +} + +function validateAssigneeLimit(assignees: AssigneeInput[]): JsonResponse | undefined { + return assignees.length > MAX_ISSUE_ASSIGNEES ? issueAssigneeLimitError() : undefined; +} + +function validateMergedAssigneeLimit(existing: any[], assignees: AssigneeInput[]): JsonResponse | undefined { + const dids = new Set<string>(); + for (const rec of existing) { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const did = tags.assigneeDid; + if (did) { dids.add(did); } + } + for (const assignee of assignees) { + dids.add(assignee.did); + } + return dids.size > MAX_ISSUE_ASSIGNEES ? issueAssigneeLimitError() : undefined; +} + +export async function canMutateIssueMetadata( + ctx: AgentContext, + targetDid: string, + repo: NonNullable<Awaited<ReturnType<typeof getRepoRecord>>>, +): Promise<boolean> { + if (ctx.did === targetDid) { + return true; + } + + const from = fromOpt(ctx, targetDid); + for (const role of ['repo/maintainer', 'repo/contributor'] as const) { + const { records } = await ctx.repo.records.query(role as any, { + from, + filter: { contextId: repo.contextId, tags: { did: ctx.did } }, + }); + if (records.length > 0) { + return true; + } + } + + return false; +} + +function parseIssueLockReason(reqBody: Record<string, unknown>): IssueLockReason | null | JsonResponse { + if (reqBody.lock_reason === undefined || reqBody.lock_reason === null) { return null; } + if (typeof reqBody.lock_reason !== 'string' || !ISSUE_LOCK_REASON_SET.has(reqBody.lock_reason)) { + return jsonValidationError('Validation Failed: lock_reason must be one of off-topic, too heated, resolved, or spam.'); + } + return reqBody.lock_reason as IssueLockReason; +} + +async function updateIssueLock( + issueRec: any, locked: boolean, reason: IssueLockReason | null, +): Promise<JsonResponse | undefined> { + const data = await issueRec.data.json(); + const tags = (issueRec.tags as Record<string, string> | undefined) ?? {}; + const updatedTags: Record<string, string> = { ...tags, locked: locked ? 'true' : 'false' }; + if (locked && reason) { + updatedTags.lockReason = reason; + } else { + delete updatedTags.lockReason; + } + + const { status } = await issueRec.update({ data, tags: updatedTags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to ${locked ? 'lock' : 'unlock'} issue: ${status.detail}`); + } + return undefined; +} + +async function deleteLabels(labels: any[]): Promise<JsonResponse | undefined> { + for (const label of labels) { + const { status } = await label.delete(); + if (status.code >= 300) { + const tags = (label.tags as Record<string, string> | undefined) ?? {}; + return jsonValidationError(`Failed to delete label '${tags.name ?? label.id}': ${status.detail}`); + } + } + return undefined; +} + +async function createMissingLabels( + ctx: AgentContext, issueRec: any, existing: any[], labels: LabelInput[], +): Promise<JsonResponse | undefined> { + const existingNames = new Set(existing.map((rec) => { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + return (tags.name ?? '').toLocaleLowerCase(); + })); + + for (const label of labels) { + if (existingNames.has(label.name.toLocaleLowerCase())) { continue; } + const data = label.description === undefined + ? { name: label.name, color: label.color } + : { name: label.name, color: label.color, description: label.description }; + const { status } = await ctx.issues.records.create('repo/issue/label' as any, { + data, + tags : { name: label.name, color: label.color }, + parentContextId : issueRec.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create label '${label.name}': ${status.detail}`); + } + } + + return undefined; +} + +function labelData(label: LabelInput): Record<string, unknown> { + return label.description === undefined + ? { name: label.name, color: label.color } + : { name: label.name, color: label.color, description: label.description }; +} + +async function createIssueLabelRecord(ctx: AgentContext, issueRec: any, label: LabelInput): Promise<JsonResponse | undefined> { + const { status } = await ctx.issues.records.create('repo/issue/label' as any, { + data : labelData(label), + tags : { name: label.name, color: label.color }, + parentContextId : issueRec.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create label '${label.name}': ${status.detail}`); + } + return undefined; +} + +async function replaceIssueLabelRecords( + ctx: AgentContext, labelRecords: RepoLabelRecordEntry[], label: LabelInput, +): Promise<JsonResponse | undefined> { + for (const entry of labelRecords) { + const { status } = await entry.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to update label '${entry.name}': ${status.detail}`); + } + const createError = await createIssueLabelRecord(ctx, entry.issue, label); + if (createError) { return createError; } + } + return undefined; +} + +function labelsSettingWithChange( + settings: RepoSettingsData, key: string, label: RepoLabelCatalogEntry | null, +): RepoSettingsData { + const labels = { ...(settings.labels ?? {}) }; + if (label) { + labels[key] = label; + } else { + delete labels[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(labels).length > 0) { + next.labels = labels; + } else { + delete next.labels; + } + return next; +} + +function milestonesSettingWithChange( + settings: RepoSettingsData, key: string, milestone: RepoMilestoneCatalogEntry | null, +): RepoSettingsData { + const milestones = { ...(settings.milestones ?? {}) }; + if (milestone) { + milestones[key] = milestone; + } else { + delete milestones[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(milestones).length > 0) { + next.milestones = milestones; + } else { + delete next.milestones; + } + return next; +} + +async function replaceIssueMilestoneTags( + ctx: AgentContext, from: string | undefined, repoContextId: string, oldTitle: string, newTitle: string | null, +): Promise<JsonResponse | undefined> { + const oldKey = milestoneKey(oldTitle); + const issues = await listIssueRecordsForRepo(ctx, repoContextId, from); + + for (const issue of issues) { + const tags = (issue.tags as Record<string, string> | undefined) ?? {}; + if (milestoneKey(tags.milestone ?? '') !== oldKey) { continue; } + + const data = await issue.data.json(); + const updatedTags = { ...tags }; + if (newTitle) { + updatedTags.milestone = newTitle; + } else { + delete updatedTags.milestone; + } + + const { status } = await issue.update({ data, tags: updatedTags }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update issue milestone '${oldTitle}': ${status.detail}`); + } + } + + return undefined; +} + +async function createIssueStatusChange( + ctx: AgentContext, issueRec: any, fromStatus: string, toStatus: string, reason?: string, +): Promise<JsonResponse | undefined> { + const data = reason ? { reason } : {}; + const { status } = await ctx.issues.records.create('repo/issue/statusChange' as any, { + data, + tags : { from: fromStatus, to: toStatus }, + parentContextId : issueRec.contextId, + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to record issue status change: ${status.detail}`); + } + + return undefined; +} + +async function deleteAssignments(assignments: any[]): Promise<JsonResponse | undefined> { + for (const assignment of assignments) { + const { status } = await assignment.delete(); + if (status.code >= 300) { + const tags = (assignment.tags as Record<string, string> | undefined) ?? {}; + return jsonValidationError(`Failed to delete assignee '${tags.assigneeDid ?? assignment.id}': ${status.detail}`); + } + } + return undefined; +} + +async function createMissingAssignments( + ctx: AgentContext, issueRec: any, existing: any[], assignees: AssigneeInput[], +): Promise<JsonResponse | undefined> { + const existingDids = new Set(existing.map((rec) => { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + return tags.assigneeDid ?? ''; + })); + + for (const assignee of assignees) { + if (existingDids.has(assignee.did)) { continue; } + const data = assignee.alias + ? { assigneeDid: assignee.did, alias: assignee.alias } + : { assigneeDid: assignee.did }; + const { status } = await ctx.issues.records.create('repo/issue/assignment' as any, { + data, + tags : { assigneeDid: assignee.did }, + parentContextId : issueRec.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to assign '${assignee.did}': ${status.detail}`); + } + } + + return undefined; +} + +function parseReactionContent(value: unknown): ReactionContent | JsonResponse { + if (typeof value !== 'string' || !REACTION_CONTENT_SET.has(value)) { + return jsonValidationError( + `Validation Failed: content must be one of ${REACTION_CONTENTS.map(content => `'${content}'`).join(', ')}.`, + ); + } + return value as ReactionContent; +} + +function parseIssueDependencyId(value: unknown): number | JsonResponse { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return jsonValidationError('Validation Failed: issue_id must be a positive integer.'); + } + return value; +} + +function parseSubIssueId(value: unknown): number | JsonResponse { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return jsonValidationError('Validation Failed: sub_issue_id must be a positive integer.'); + } + return value; +} + +function parseIssueFieldValueInput(value: unknown): IssueFieldValueInput | JsonResponse { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return jsonValidationError('Validation Failed: each issue_field_values entry must be an object.'); + } + + const item = value as Record<string, unknown>; + const fieldId = item.field_id; + if (typeof fieldId !== 'number' || !Number.isInteger(fieldId) || fieldId <= 0) { + return jsonValidationError('Validation Failed: field_id must be a positive integer.'); + } + + const rawValue = item.value; + if (typeof rawValue === 'number') { + if (!Number.isFinite(rawValue)) { + return jsonValidationError('Validation Failed: value must be a finite number.'); + } + return { fieldId, dataType: 'number', value: rawValue }; + } + if (typeof rawValue === 'string') { + return { fieldId, dataType: inferIssueFieldValueDataType(rawValue), value: rawValue }; + } + if (Array.isArray(rawValue) && rawValue.every(entry => typeof entry === 'string')) { + return { fieldId, dataType: 'multi_select', value: rawValue }; + } + + return jsonValidationError('Validation Failed: value must be a string, number, or string array.'); +} + +function parseIssueFieldValueInputs(reqBody: Record<string, unknown>): IssueFieldValueInput[] | JsonResponse { + const values = reqBody.issue_field_values; + if (!Array.isArray(values)) { + return jsonValidationError('Validation Failed: issue_field_values must be an array.'); + } + + const parsed: IssueFieldValueInput[] = []; + const seen = new Set<number>(); + for (const value of values) { + const input = parseIssueFieldValueInput(value); + if (!('fieldId' in input)) { return input; } + if (seen.has(input.fieldId)) { + return jsonValidationError('Validation Failed: duplicate field_id values are not allowed.'); + } + seen.add(input.fieldId); + parsed.push(input); + } + return parsed; +} + +function parseIssueFieldId(value: string): number | JsonResponse { + const fieldId = parseInt(value, 10); + if (!Number.isInteger(fieldId) || fieldId <= 0) { + return jsonValidationError('Validation Failed: issue_field_id must be a positive integer.'); + } + return fieldId; +} + +function parseOptionalSubIssuePositionId(value: unknown, name: 'after_id' | 'before_id'): number | JsonResponse | undefined { + if (value === undefined || value === null) { return undefined; } + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return jsonValidationError(`Validation Failed: ${name} must be a positive integer.`); + } + return value; +} + +function getReactionContent(rec: any, data?: any): string { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + return tags.emoji ?? data?.emoji ?? ''; +} + +function countReactions(reactions: any[]): Record<ReactionContent, number> { + const counts = Object.fromEntries(REACTION_CONTENTS.map(content => [content, 0])) as Record<ReactionContent, number>; + for (const rec of reactions) { + const content = getReactionContent(rec); + if (REACTION_CONTENT_SET.has(content)) { + counts[content as ReactionContent]++; + } + } + return counts; +} + +async function listIssueReactionRecords( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<any[]> { + const { records } = await ctx.issues.records.query('repo/issue/reaction' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + return records; +} + +async function listIssueCommentReactionRecords( + ctx: AgentContext, from: string | undefined, commentRec: any, +): Promise<any[]> { + const { records } = await ctx.issues.records.query('repo/issue/comment/reaction' as any, { + from, + filter : { contextId: commentRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + return records; +} + +function buildIssueReactionsSummary( + targetDid: string, repoName: string, issueNumber: number, baseUrl: string, reactions: any[] = [], +): Record<string, unknown> { + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${issueNumber}/reactions`, + total_count : reactions.length, + ...countReactions(reactions), + }; +} + +function buildIssueCommentReactionsSummary( + targetDid: string, repoName: string, commentId: number, baseUrl: string, reactions: any[] = [], +): Record<string, unknown> { + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/comments/${commentId}/reactions`, + total_count : reactions.length, + ...countReactions(reactions), + }; +} + +function buildReactionResponse( + rec: any, data: any, baseUrl: string, fallbackAuthor: string, +): Record<string, unknown> { + const authorDid = rec.author ?? fallbackAuthor; + return { + id : numericId(rec.id ?? ''), + node_id : rec.id ?? '', + user : buildOwner(authorDid, baseUrl), + content : getReactionContent(rec, data), + created_at : toISODate(rec.dateCreated), + }; +} + +async function buildIssueWithChildren( + ctx: AgentContext, from: string | undefined, issueRec: any, + data: any, tags: Record<string, string>, targetDid: string, repoName: string, baseUrl: string, + bodyMediaKind?: BodyMediaKind | null, +): Promise<Record<string, unknown>> { + const labels = await buildIssueLabels(ctx, from, issueRec, targetDid, repoName, baseUrl); + const assignees = await buildIssueAssignees(ctx, from, issueRec, baseUrl); + const reactions = await listIssueReactionRecords(ctx, from, issueRec); + const milestone = await buildIssueMilestone(ctx, targetDid, repoName, baseUrl, tags.milestone); + return buildIssueResponse( + issueRec, data, tags, targetDid, repoName, baseUrl, labels, assignees, reactions, milestone, bodyMediaKind, + ); +} + +async function buildIssueRecordWithChildren( + ctx: AgentContext, from: string | undefined, issueRec: any, + targetDid: string, repoName: string, baseUrl: string, + bodyMediaKind?: BodyMediaKind | null, +): Promise<Record<string, unknown>> { + const data = await issueRec.data.json(); + const tags = (issueRec.tags as Record<string, string> | undefined) ?? {}; + return buildIssueWithChildren(ctx, from, issueRec, data, tags, targetDid, repoName, baseUrl, bodyMediaKind); +} + +async function listIssueInboxEntries( + ctx: AgentContext, ownerDid: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<IssueInboxEntry[]> { + const from = fromOpt(ctx, ownerDid); + const baseUrl = buildApiUrl(url); + const { records: repoRecords } = await ctx.repo.records.query('repo', { + from, + dateSort: DateSort.CreatedAscending, + } as any); + + const entries: IssueInboxEntry[] = []; + for (const repoRecord of repoRecords) { + let repoData: Record<string, unknown>; + try { + repoData = await repoRecord.data.json(); + } catch { + continue; + } + + const repoTags = (repoRecord.tags as Record<string, unknown> | undefined) ?? {}; + const repo = repoInfoFromRecord(repoRecord, repoData, repoTags); + const repoName = repo.name; + const { records: issueRecords } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repo.contextId }, + }); + + for (const issue of issueRecords) { + const data = await issue.data.json(); + const tags = (issue.tags as Record<string, string> | undefined) ?? {}; + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + from, + filter: { contextId: issue.contextId }, + }); + const response = await buildIssueWithChildren( + ctx, from, issue, data, tags, ownerDid, repoName, baseUrl, bodyMediaKind, + ); + response.comments = comments.length; + response.repository = buildRepoResponse(repo, ownerDid, repoName, baseUrl); + entries.push({ + ownerDid, + repoName, + repo, + issue, + data, + tags, + commentCount: comments.length, + response, + }); + } + } + + return entries; +} + +function issueInboxQuery(url: URL): { + filter : string; + state : string; + labels : string[]; + sort : string; + direction : 'asc' | 'desc'; + sinceTime : number | null; +} | JsonResponse { + const filter = url.searchParams.get('filter') ?? 'assigned'; + if (!['assigned', 'created', 'mentioned', 'subscribed', 'repos', 'all'].includes(filter)) { + return jsonValidationError('Validation Failed: filter must be assigned, created, mentioned, subscribed, repos, or all.'); + } + + const state = url.searchParams.get('state') ?? 'open'; + if (!['open', 'closed', 'all'].includes(state)) { + return jsonValidationError('Validation Failed: state must be open, closed, or all.'); + } + + const sort = url.searchParams.get('sort') ?? 'created'; + if (!['created', 'updated', 'comments'].includes(sort)) { + return jsonValidationError('Validation Failed: sort must be created, updated, or comments.'); + } + + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const since = url.searchParams.get('since'); + const sinceTime = since ? Date.parse(since) : null; + if (since && !Number.isFinite(sinceTime)) { + return jsonValidationError('Validation Failed: since must be an ISO 8601 timestamp.'); + } + + const labels = (url.searchParams.get('labels') ?? '') + .split(',') + .map(label => label.trim()) + .filter(Boolean); + + return { filter, state, labels, sort, direction, sinceTime }; +} + +function userParticipatesInIssue(entry: IssueInboxEntry, userDid: string, filter: string): boolean { + if (filter === 'all' || filter === 'repos') { return true; } + + const responseUser = entry.response.user as { login?: string } | undefined; + const createdByUser = responseUser?.login === userDid || entry.issue.author === userDid; + const assignees = Array.isArray(entry.response.assignees) + ? entry.response.assignees as { login?: string }[] + : []; + const assignedToUser = assignees.some(assignee => assignee.login === userDid); + + if (filter === 'assigned') { return assignedToUser; } + if (filter === 'created') { return createdByUser; } + if (filter === 'subscribed') { return assignedToUser || createdByUser; } + + const haystack = `${entry.data.title ?? ''}\n${entry.data.body ?? ''}`.toLowerCase(); + return haystack.includes(userDid.toLowerCase()); +} + +function issueHasLabels(entry: IssueInboxEntry, labels: string[]): boolean { + if (labels.length === 0) { return true; } + + const issueLabels = Array.isArray(entry.response.labels) + ? entry.response.labels as { name?: string }[] + : []; + const names = new Set(issueLabels.map(label => String(label.name ?? '').toLowerCase())); + return labels.every(label => names.has(label.toLowerCase())); +} + +function filterIssueInboxEntries( + entries: IssueInboxEntry[], userDid: string, url: URL, +): IssueInboxEntry[] | JsonResponse { + const query = issueInboxQuery(url); + if ('status' in query) { return query; } + + return entries.filter((entry) => { + const state = entry.response.state; + if (query.state !== 'all' && state !== query.state) { return false; } + if (!userParticipatesInIssue(entry, userDid, query.filter)) { return false; } + if (!issueHasLabels(entry, query.labels)) { return false; } + if (query.sinceTime !== null) { + const updated = Date.parse(String(entry.response.updated_at ?? '')); + if (!Number.isFinite(updated) || updated <= query.sinceTime) { return false; } + } + return true; + }).sort((a, b) => { + let result: number; + if (query.sort === 'comments') { + result = a.commentCount - b.commentCount; + } else { + const key = query.sort === 'updated' ? 'updated_at' : 'created_at'; + result = String(a.response[key] ?? '').localeCompare(String(b.response[key] ?? '')); + } + if (result === 0) { + result = a.repoName.localeCompare(b.repoName) || numericId(String(a.issue.id ?? '')) - numericId(String(b.issue.id ?? '')); + } + return query.direction === 'asc' ? result : -result; + }); +} + +async function handleListIssueInbox( + ctx: AgentContext, ownerDid: string, url: URL, path: string, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const entries = await listIssueInboxEntries(ctx, ownerDid, url, bodyMediaKind); + const filtered = filterIssueInboxEntries(entries, ctx.did, url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const linkHeader = buildLinkHeader(buildApiUrl(url), path, pagination.page, pagination.perPage, filtered.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(entry => entry.response), extraHeaders); +} + +function issueEventName(tags: Record<string, string>): string { + if (tags.to === 'closed') { return 'closed'; } + if (tags.from === 'closed' && tags.to === 'open') { return 'reopened'; } + return 'updated'; +} + +async function buildIssueEventResponse( + ctx: AgentContext, from: string | undefined, entry: IssueEventEntry, + targetDid: string, repoName: string, baseUrl: string, +): Promise<Record<string, unknown>> { + const data = await entry.event.data.json(); + const eventTags = (entry.event.tags as Record<string, string> | undefined) ?? {}; + const issueData = await entry.issue.data.json(); + const issueTags = (entry.issue.tags as Record<string, string> | undefined) ?? {}; + const id = numericId(entry.event.id ?? ''); + + return { + id, + node_id : entry.event.id ?? '', + url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/events/${id}`, + actor : buildOwner(entry.event.author ?? targetDid, baseUrl), + event : issueEventName(eventTags), + commit_id : null, + commit_url : null, + created_at : toISODate(entry.event.dateCreated), + performed_via_github_app : null, + issue : await buildIssueWithChildren( + ctx, from, entry.issue, issueData, issueTags, targetDid, repoName, baseUrl, + ), + ...(typeof data.reason === 'string' && data.reason ? { reason: data.reason } : {}), + }; +} + +function buildIssueCommentResponse( + comment: any, data: any, issueNumber: number, + targetDid: string, repoName: string, baseUrl: string, + reactions: any[] = [], + bodyMediaKind?: BodyMediaKind | null, +): Record<string, unknown> { + const commentAuthor = comment.author ?? targetDid; + const id = numericId(comment.id ?? ''); + const pinnedAt = typeof data.pinnedAt === 'string' ? data.pinnedAt : null; + const pinnedBy = typeof data.pinnedBy === 'string' ? data.pinnedBy : commentAuthor; + const body = typeof data.body === 'string' ? data.body : ''; + return applyBodyMedia({ + id, + node_id : comment.id ?? '', + url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/comments/${id}`, + html_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${issueNumber}#issuecomment-${id}`, + issue_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${issueNumber}`, + created_at : toISODate(comment.dateCreated), + updated_at : toISODate(comment.timestamp), + user : buildOwner(commentAuthor, baseUrl), + author_association : commentAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', + reactions : buildIssueCommentReactionsSummary(targetDid, repoName, id, baseUrl, reactions), + pin : pinnedAt + ? { + pinned_at : toISODate(pinnedAt), + pinned_by : buildOwner(pinnedBy, baseUrl), + } + : null, + }, body, bodyMediaKind); +} + +async function buildIssueTimelineCommentResponse( + ctx: AgentContext, from: string | undefined, entry: Extract<IssueTimelineEntry, { kind: 'comment' }>, + targetDid: string, repoName: string, baseUrl: string, +): Promise<Record<string, unknown>> { + const data = await entry.comment.data.json(); + const reactions = await listIssueCommentReactionRecords(ctx, from, entry.comment); + const commentAuthor = entry.comment.author ?? targetDid; + return { + ...buildIssueCommentResponse(entry.comment, data, entry.issueNumber, targetDid, repoName, baseUrl, reactions), + performed_via_github_app : null, + event : 'commented', + actor : buildOwner(commentAuthor, baseUrl), + }; +} + +async function buildIssueTimelineEventResponse( + ctx: AgentContext, from: string | undefined, entry: Extract<IssueTimelineEntry, { kind: 'event' }>, + targetDid: string, repoName: string, baseUrl: string, +): Promise<Record<string, unknown>> { + const response = await buildIssueEventResponse(ctx, from, entry, targetDid, repoName, baseUrl); + delete response.issue; + return response; +} + +async function listIssueCommentEntries( + ctx: AgentContext, repoContextId: string, from?: string, +): Promise<IssueCommentEntry[]> { + const { records: issues } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repoContextId }, + }); + + const entries: IssueCommentEntry[] = []; + for (const issue of issues) { + const issueNumber = numericId(issue.id ?? ''); + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + from, + filter : { contextId: issue.contextId }, + dateSort : DateSort.CreatedAscending, + }); + for (const comment of comments) { + entries.push({ issue, issueNumber, comment }); + } + } + return entries; +} + +async function listIssueTimelineEntries( + ctx: AgentContext, from: string | undefined, issueRec: any, +): Promise<IssueTimelineEntry[]> { + const issueNumber = numericId(issueRec.id ?? ''); + const [{ records: comments }, events] = await Promise.all([ + ctx.issues.records.query('repo/issue/comment' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }), + listIssueStatusChangeRecords(ctx, from, issueRec), + ]); + + const entries: IssueTimelineEntry[] = []; + for (const comment of comments) { + entries.push({ + kind : 'comment', + issueNumber, + comment, + createdAt : String(comment.dateCreated ?? ''), + recordId : String(comment.id ?? ''), + }); + } + for (const event of events) { + entries.push({ + kind : 'event', + issue : issueRec, + issueNumber, + event, + createdAt : String(event.dateCreated ?? ''), + recordId : String(event.id ?? ''), + }); + } + + entries.sort((a, b) => { + const dateOrder = a.createdAt.localeCompare(b.createdAt); + if (dateOrder !== 0) { return dateOrder; } + const idOrder = a.recordId.localeCompare(b.recordId); + if (idOrder !== 0) { return idOrder; } + return a.kind.localeCompare(b.kind); + }); + return entries; +} + +async function findIssueCommentRecord( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, +): Promise<IssueCommentLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const id = parseInt(commentId, 10); + const entries = await listIssueCommentEntries(ctx, repo.contextId, from); + const entry = entries.find(item => numericId(item.comment.id ?? '') === id); + if (!entry) { + return jsonNotFound(`Issue comment #${commentId} not found.`); + } + + return { from, entry }; +} + +// --------------------------------------------------------------------------- +// GET /issues, /user/issues, and /orgs/:org/issues +// --------------------------------------------------------------------------- + +export async function handleListAssignedIssues( + ctx: AgentContext, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + return handleListIssueInbox(ctx, ctx.did, url, '/issues', bodyMediaKind); +} + +export async function handleListAuthenticatedUserIssues( + ctx: AgentContext, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + return handleListIssueInbox(ctx, ctx.did, url, '/user/issues', bodyMediaKind); +} + +export async function handleListOrgIssues( + ctx: AgentContext, routeOrg: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + if (!(await orgRouteExists(ctx, routeOrg))) { + return jsonNotFound(`Organization '${routeOrg}' not found.`); + } + + return handleListIssueInbox(ctx, ctx.did, url, `/orgs/${routeOrg}/issues`, bodyMediaKind); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues +// --------------------------------------------------------------------------- + +export async function handleListIssues( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + + // Query params. + const stateFilter = url.searchParams.get('state') ?? 'open'; + const direction = url.searchParams.get('direction') ?? 'desc'; + const pagination = parsePagination(url); + + const dateSort = direction === 'asc' + ? DateSort.CreatedAscending + : DateSort.CreatedDescending; + + const { records } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repo.contextId }, + dateSort, + }); + + // Filter by state. + let filtered = records; + if (stateFilter !== 'all') { + filtered = records.filter((r) => { + const t = r.tags as Record<string, string> | undefined; + const s = t?.status ?? 'open'; + return stateFilter === 'closed' ? s === 'closed' : s === 'open'; + }); + } + + // Paginate. + const page = paginate(filtered, pagination); + + // Build response. + const items: Record<string, unknown>[] = []; + for (const rec of page) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + items.push(await buildIssueWithChildren(ctx, from, rec, data, tags, targetDid, repoName, baseUrl, bodyMediaKind)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/:number +// --------------------------------------------------------------------------- + +export async function handleGetIssue( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + + const num = parseInt(number, 10); + const { records } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repo.contextId }, + }); + + const rec = records.find(r => numericId(r.id ?? '') === num); + if (!rec) { + return jsonNotFound(`Issue #${number} not found.`); + } + + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + + // Fetch comment count. + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + from, + filter: { contextId: rec.contextId }, + }); + + const issue = await buildIssueWithChildren(ctx, from, rec, data, tags, targetDid, repoName, baseUrl, bodyMediaKind); + issue.comments = comments.length; + + return jsonOk(issue); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/:number/comments +// --------------------------------------------------------------------------- + +export async function handleListIssueComments( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + + // Find the issue first. + const num = parseInt(number, 10); + const { records: issues } = await ctx.issues.records.query('repo/issue', { + from, + filter: { contextId: repo.contextId }, + }); + + const issueRec = issues.find(r => numericId(r.id ?? '') === num); + if (!issueRec) { + return jsonNotFound(`Issue #${number} not found.`); + } + + // Fetch comments. + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + from, + filter : { contextId: issueRec.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const paged = paginate(comments, pagination); + + const items: Record<string, unknown>[] = []; + for (const comment of paged) { + const cData = await comment.data.json(); + items.push(buildIssueCommentResponse(comment, cData, num, targetDid, repoName, baseUrl, [], bodyMediaKind)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/comments +// --------------------------------------------------------------------------- + +export async function handleListRepoIssueComments( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const direction = url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc'; + const sort = url.searchParams.get('sort') === 'updated' ? 'updated' : 'created'; + const since = url.searchParams.get('since'); + const sinceTime = since ? Date.parse(since) : NaN; + + let entries = await listIssueCommentEntries(ctx, repo.contextId, from); + if (Number.isFinite(sinceTime)) { + entries = entries.filter((entry) => { + const updated = Date.parse(entry.comment.timestamp ?? entry.comment.dateCreated ?? ''); + return Number.isFinite(updated) && updated > sinceTime; + }); + } + + entries.sort((a, b) => { + const leftDate = sort === 'updated' + ? a.comment.timestamp ?? a.comment.dateCreated + : a.comment.dateCreated; + const rightDate = sort === 'updated' + ? b.comment.timestamp ?? b.comment.dateCreated + : b.comment.dateCreated; + const result = String(leftDate).localeCompare(String(rightDate)); + return direction === 'desc' ? -result : result; + }); + + const paged = paginate(entries, pagination); + const items: Record<string, unknown>[] = []; + for (const entry of paged) { + const data = await entry.comment.data.json(); + items.push(buildIssueCommentResponse( + entry.comment, data, entry.issueNumber, targetDid, repoName, baseUrl, [], bodyMediaKind, + )); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/comments`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/events +// --------------------------------------------------------------------------- + +export async function handleListRepoIssueEvents( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const entries = await listIssueEventEntries(ctx, repo.contextId, from); + const paged = paginate(entries, pagination); + + const items: Record<string, unknown>[] = []; + for (const entry of paged) { + items.push(await buildIssueEventResponse(ctx, from, entry, targetDid, repoName, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/events`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/events/:id +// --------------------------------------------------------------------------- + +export async function handleGetIssueEvent( + ctx: AgentContext, targetDid: string, repoName: string, eventId: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const id = parseInt(eventId, 10); + const entries = await listIssueEventEntries(ctx, repo.contextId, from); + const entry = entries.find(item => numericId(item.event.id ?? '') === id); + if (!entry) { + return jsonNotFound(`Issue event #${eventId} not found.`); + } + + return jsonOk(await buildIssueEventResponse(ctx, from, entry, targetDid, repoName, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/:number/events +// --------------------------------------------------------------------------- + +export async function handleListIssueEvents( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const records = await listIssueStatusChangeRecords(ctx, lookup.from, lookup.issue); + const entries = records.map(event => ({ + event, + issue : lookup.issue, + issueNumber : numericId(lookup.issue.id ?? ''), + })); + const paged = paginate(entries, pagination); + + const items: Record<string, unknown>[] = []; + for (const entry of paged) { + items.push(await buildIssueEventResponse(ctx, lookup.from, entry, targetDid, repoName, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/events`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/:number/timeline +// --------------------------------------------------------------------------- + +export async function handleListIssueTimeline( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const entries = await listIssueTimelineEntries(ctx, lookup.from, lookup.issue); + const paged = paginate(entries, pagination); + + const items: Record<string, unknown>[] = []; + for (const entry of paged) { + if (entry.kind === 'comment') { + items.push(await buildIssueTimelineCommentResponse(ctx, lookup.from, entry, targetDid, repoName, baseUrl)); + } else { + items.push(await buildIssueTimelineEventResponse(ctx, lookup.from, entry, targetDid, repoName, baseUrl)); + } + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/timeline`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET/POST/DELETE /repos/:did/:repo/issues/:number/dependencies/blocked_by +// GET /repos/:did/:repo/issues/:number/dependencies/blocking +// --------------------------------------------------------------------------- + +export async function handleListIssueDependenciesBlockedBy( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const dependencies = await listIssueDependencyEntries(ctx, lookup.from, lookup.issue); + const repoIssues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const issuesById = new Map(repoIssues.map(issue => [numericId(issue.id ?? ''), issue])); + + const blockedByIssues: any[] = []; + for (const dependency of dependencies) { + const issue = issuesById.get(dependency.issueId); + if (issue) { + blockedByIssues.push(issue); + } + } + + const paged = paginate(blockedByIssues, pagination); + const items: Record<string, unknown>[] = []; + for (const issue of paged) { + const data = await issue.data.json(); + const tags = (issue.tags as Record<string, string> | undefined) ?? {}; + items.push(await buildIssueWithChildren(ctx, lookup.from, issue, data, tags, targetDid, repoName, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/dependencies/blocked_by`, + pagination.page, pagination.perPage, blockedByIssues.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleAddIssueDependencyBlockedBy( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const issueId = parseIssueDependencyId(reqBody.issue_id); + if (typeof issueId !== 'number') { return issueId; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const currentIssueId = numericId(lookup.issue.id ?? ''); + if (issueId === currentIssueId) { + return jsonValidationError('Validation Failed: an issue cannot depend on itself.'); + } + + const repoIssues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const blockingIssue = repoIssues.find(issue => numericId(issue.id ?? '') === issueId); + if (!blockingIssue) { + return jsonNotFound(`Issue id ${issueId} not found.`); + } + + const existing = await listIssueDependencyEntries(ctx, undefined, lookup.issue); + if (existing.some(entry => entry.issueId === issueId)) { + return jsonValidationError('Validation Failed: issue dependency already exists.'); + } + + const { status } = await ctx.issues.records.create('repo/issue/issueDependency' as any, { + data : { issueId }, + tags : { issueId: String(issueId) }, + parentContextId : lookup.issue.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to add issue dependency: ${status.detail}`); + } + + const data = await lookup.issue.data.json(); + const tags = (lookup.issue.tags as Record<string, string> | undefined) ?? {}; + const issue = await buildIssueWithChildren( + ctx, undefined, lookup.issue, data, tags, targetDid, repoName, buildApiUrl(url), + ); + return jsonCreated(issue); +} + +export async function handleRemoveIssueDependencyBlockedBy( + ctx: AgentContext, targetDid: string, repoName: string, number: string, issueIdValue: string, url: URL, +): Promise<JsonResponse> { + const issueId = parseIssueDependencyId(parseInt(issueIdValue, 10)); + if (typeof issueId !== 'number') { return issueId; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const existing = await listIssueDependencyEntries(ctx, undefined, lookup.issue); + const dependency = existing.find(entry => entry.issueId === issueId); + if (!dependency) { + return jsonNotFound(`Issue dependency ${issueId} not found.`); + } + + const { status } = await dependency.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove issue dependency: ${status.detail}`); + } + + const data = await lookup.issue.data.json(); + const tags = (lookup.issue.tags as Record<string, string> | undefined) ?? {}; + const issue = await buildIssueWithChildren( + ctx, undefined, lookup.issue, data, tags, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(issue); +} + +export async function handleListIssueDependenciesBlocking( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const targetIssueId = numericId(lookup.issue.id ?? ''); + const repoIssues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const blockingIssues: any[] = []; + + for (const issue of repoIssues) { + const dependencies = await listIssueDependencyEntries(ctx, lookup.from, issue); + if (dependencies.some(entry => entry.issueId === targetIssueId)) { + blockingIssues.push(issue); + } + } + blockingIssues.sort((a, b) => numericId(a.id ?? '') - numericId(b.id ?? '')); + + const pagination = parsePagination(url); + const paged = paginate(blockingIssues, pagination); + const items: Record<string, unknown>[] = []; + for (const issue of paged) { + const data = await issue.data.json(); + const tags = (issue.tags as Record<string, string> | undefined) ?? {}; + items.push(await buildIssueWithChildren(ctx, lookup.from, issue, data, tags, targetDid, repoName, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/dependencies/blocking`, + pagination.page, pagination.perPage, blockingIssues.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issues/:number/parent +// GET/POST /repos/:did/:repo/issues/:number/sub_issues +// DELETE /repos/:did/:repo/issues/:number/sub_issue +// PATCH /repos/:did/:repo/issues/:number/sub_issues/priority +// --------------------------------------------------------------------------- + +export async function handleGetIssueParent( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const issueId = numericId(lookup.issue.id ?? ''); + const parent = await findParentIssueForSubIssue(ctx, lookup.from, lookup.repo.contextId, issueId); + if (!parent) { + return jsonNotFound(`Parent issue for issue #${number} not found.`); + } + + return jsonOk(await buildIssueRecordWithChildren( + ctx, lookup.from, parent.parent, targetDid, repoName, buildApiUrl(url), + )); +} + +export async function handleListIssueSubIssues( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const entries = await listIssueSubIssueEntries(ctx, lookup.from, lookup.issue); + const repoIssues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const issuesById = new Map(repoIssues.map(issue => [numericId(issue.id ?? ''), issue])); + const subIssues = entries + .map(entry => issuesById.get(entry.issueId)) + .filter((issue): issue is any => Boolean(issue)); + + const paged = paginate(subIssues, pagination); + const items: Record<string, unknown>[] = []; + for (const issue of paged) { + items.push(await buildIssueRecordWithChildren(ctx, lookup.from, issue, targetDid, repoName, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/sub_issues`, + pagination.page, pagination.perPage, subIssues.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleAddIssueSubIssue( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const subIssueId = parseSubIssueId(reqBody.sub_issue_id); + if (typeof subIssueId !== 'number') { return subIssueId; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const parentIssueId = numericId(lookup.issue.id ?? ''); + if (subIssueId === parentIssueId) { + return jsonValidationError('Validation Failed: an issue cannot be its own sub-issue.'); + } + + const repoIssues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const subIssue = findIssueByNumericId(repoIssues, subIssueId); + if (!subIssue) { + return jsonNotFound(`Sub-issue id ${subIssueId} not found.`); + } + + const existing = await listIssueSubIssueEntries(ctx, undefined, lookup.issue); + if (existing.some(entry => entry.issueId === subIssueId)) { + return jsonValidationError('Validation Failed: sub-issue already exists under this parent.'); + } + + const previousParent = await findParentIssueForSubIssue(ctx, undefined, lookup.repo.contextId, subIssueId); + if (previousParent) { + if (reqBody.replace_parent !== true) { + return jsonValidationError('Validation Failed: sub-issue already has a parent.'); + } + const { status } = await previousParent.entry.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to replace sub-issue parent: ${status.detail}`); + } + } + + const priority = existing.reduce((max, entry) => Math.max(max, entry.priority), 0) + 1; + const { status } = await ctx.issues.records.create('repo/issue/subIssue' as any, { + data : { issueId: subIssueId, priority }, + tags : { issueId: String(subIssueId) }, + parentContextId : lookup.issue.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to add sub-issue: ${status.detail}`); + } + + return jsonCreated(await buildIssueRecordWithChildren( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + )); +} + +export async function handleRemoveIssueSubIssue( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const subIssueId = parseSubIssueId(reqBody.sub_issue_id); + if (typeof subIssueId !== 'number') { return subIssueId; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const entries = await listIssueSubIssueEntries(ctx, undefined, lookup.issue); + const entry = entries.find(item => item.issueId === subIssueId); + if (!entry) { + return jsonNotFound(`Sub-issue id ${subIssueId} not found under issue #${number}.`); + } + + const { status } = await entry.rec.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove sub-issue: ${status.detail}`); + } + + const priorityError = await rewriteSubIssuePriorities(entries.filter(item => item !== entry)); + if (priorityError) { return priorityError; } + + return jsonOk(await buildIssueRecordWithChildren( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + )); +} + +export async function handleReprioritizeIssueSubIssue( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const subIssueId = parseSubIssueId(reqBody.sub_issue_id); + if (typeof subIssueId !== 'number') { return subIssueId; } + + const afterId = parseOptionalSubIssuePositionId(reqBody.after_id, 'after_id'); + if (afterId && typeof afterId !== 'number') { return afterId; } + const beforeId = parseOptionalSubIssuePositionId(reqBody.before_id, 'before_id'); + if (beforeId && typeof beforeId !== 'number') { return beforeId; } + + if ((afterId === undefined && beforeId === undefined) || (afterId !== undefined && beforeId !== undefined)) { + return jsonValidationError('Validation Failed: specify exactly one of after_id or before_id.'); + } + if (afterId === subIssueId || beforeId === subIssueId) { + return jsonValidationError('Validation Failed: sub_issue_id cannot match after_id or before_id.'); + } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const entries = await listIssueSubIssueEntries(ctx, undefined, lookup.issue); + const movingIndex = entries.findIndex(entry => entry.issueId === subIssueId); + if (movingIndex === -1) { + return jsonNotFound(`Sub-issue id ${subIssueId} not found under issue #${number}.`); + } + + const positionId = afterId ?? beforeId; + const positionIndex = entries.findIndex(entry => entry.issueId === positionId); + if (positionIndex === -1) { + return jsonNotFound(`Sub-issue id ${positionId} not found under issue #${number}.`); + } + + const [moving] = entries.splice(movingIndex, 1); + const targetIndex = entries.findIndex(entry => entry.issueId === positionId); + entries.splice(afterId !== undefined ? targetIndex + 1 : targetIndex, 0, moving); + + const priorityError = await rewriteSubIssuePriorities(entries); + if (priorityError) { return priorityError; } + + return jsonOk(await buildIssueRecordWithChildren( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + )); +} + +// --------------------------------------------------------------------------- +// GET/POST/PUT/DELETE /repos/:did/:repo/issues/:number/issue-field-values +// --------------------------------------------------------------------------- + +export async function handleListIssueFieldValues( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const pagination = parsePagination(url); + const entries = await listIssueFieldValueEntries(ctx, lookup.from, lookup.issue); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader( + buildApiUrl(url), `/repos/${targetDid}/${repoName}/issues/${number}/issue-field-values`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(paged.map(buildIssueFieldValueResponse), extraHeaders); +} + +export async function handleAddIssueFieldValues( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const parsed = parseIssueFieldValueInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const existing = await listIssueFieldValueEntries(ctx, undefined, lookup.issue); + if (parsed.length === 0) { + const deleteError = await deleteIssueFieldValueEntries(existing); + if (deleteError) { return deleteError; } + } else { + const upsertError = await upsertIssueFieldValueEntries(ctx, lookup.issue, existing, parsed); + if (upsertError) { return upsertError; } + } + + const entries = await listIssueFieldValueEntries(ctx, undefined, lookup.issue); + return jsonOk(entries.map(buildIssueFieldValueResponse)); +} + +export async function handleSetIssueFieldValues( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const parsed = parseIssueFieldValueInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const existing = await listIssueFieldValueEntries(ctx, undefined, lookup.issue); + const deleteError = await deleteIssueFieldValueEntries(existing); + if (deleteError) { return deleteError; } + + const upsertError = await upsertIssueFieldValueEntries(ctx, lookup.issue, [], parsed); + if (upsertError) { return upsertError; } + + const entries = await listIssueFieldValueEntries(ctx, undefined, lookup.issue); + return jsonOk(entries.map(buildIssueFieldValueResponse)); +} + +export async function handleDeleteIssueFieldValue( + ctx: AgentContext, targetDid: string, repoName: string, number: string, fieldIdValue: string, +): Promise<JsonResponse> { + const fieldId = parseIssueFieldId(fieldIdValue); + if (typeof fieldId !== 'number') { return fieldId; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const entries = await listIssueFieldValueEntries(ctx, undefined, lookup.issue); + const entry = entries.find(item => item.fieldId === fieldId); + if (!entry) { + return jsonNotFound(`Issue field value ${fieldId} not found.`); + } + + const deleteError = await deleteIssueFieldValueEntries([entry]); + if (deleteError) { return deleteError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST /repos/:did/:repo/labels +// --------------------------------------------------------------------------- + +export async function handleListRepoLabels( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const entries = await listRepoLabelEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const items = paged.map(entry => buildLabelResponse( + entry.rec, entry.data, entry.tags, targetDid, repoName, baseUrl, + )); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/labels`, + pagination.page, pagination.perPage, entries.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCreateRepoLabel( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = parseRepoLabelCreateInput(reqBody); + if ('status' in parsed) { return parsed; } + + const entries = await listRepoLabelEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const key = labelKey(parsed.name); + if (entries.some(entry => labelKey(entry.name) === key)) { + return jsonValidationError(`Validation Failed: label '${parsed.name}' already exists.`); + } + + const now = new Date().toISOString(); + const label: RepoLabelCatalogEntry = { + name : parsed.name, + color : parsed.color, + description : parsed.description ?? null, + createdAt : now, + updatedAt : now, + }; + + const saveError = await saveRepoSettings(ctx, lookup, labelsSettingWithChange(lookup.settings, key, label)); + if (saveError) { return saveError; } + + const entry = catalogLabelEntry(key, label); + if (!entry) { + return jsonValidationError('Failed to create repository label.'); + } + return jsonCreated(buildLabelResponse(entry.rec, entry.data, entry.tags, targetDid, repoName, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET/PATCH/DELETE /repos/:did/:repo/labels/:name +// --------------------------------------------------------------------------- + +export async function handleGetRepoLabel( + ctx: AgentContext, targetDid: string, repoName: string, labelName: string, url: URL, +): Promise<JsonResponse> { + const entries = await listRepoLabelEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const decodedLabelName = decodeLabelName(labelName); + const label = entries.find(entry => entry.name.toLocaleLowerCase() === decodedLabelName.toLocaleLowerCase()); + if (!label) { + return jsonNotFound(`Label '${decodedLabelName}' not found.`); + } + + return jsonOk(buildLabelResponse(label.rec, label.data, label.tags, targetDid, repoName, buildApiUrl(url))); +} + +export async function handleUpdateRepoLabel( + ctx: AgentContext, targetDid: string, repoName: string, + labelName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const entries = await listRepoLabelEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const decodedLabelName = decodeLabelName(labelName); + const oldKey = labelKey(decodedLabelName); + const existing = entries.find(entry => labelKey(entry.name) === oldKey); + if (!existing) { + return jsonNotFound(`Label '${decodedLabelName}' not found.`); + } + + const parsed = parseRepoLabelUpdateInput(reqBody, existing); + if ('status' in parsed) { return parsed; } + + const newKey = labelKey(parsed.name); + if (newKey !== oldKey && entries.some(entry => labelKey(entry.name) === newKey)) { + return jsonValidationError(`Validation Failed: label '${parsed.name}' already exists.`); + } + + const now = new Date().toISOString(); + const previous = lookup.settings.labels?.[oldKey]; + const label: RepoLabelCatalogEntry = { + name : parsed.name, + color : parsed.color, + description : parsed.description ?? null, + createdAt : previous?.createdAt ?? String(existing.rec.dateCreated ?? existing.updatedAt ?? now), + updatedAt : now, + }; + + let settings = labelsSettingWithChange(lookup.settings, oldKey, null); + settings = labelsSettingWithChange(settings, newKey, label); + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + const records = await listRepoLabelRecords(ctx, fromOpt(ctx, targetDid), lookup.repo.contextId); + const matchingRecords = records.filter(entry => labelKey(entry.name) === oldKey); + const replaceError = await replaceIssueLabelRecords(ctx, matchingRecords, parsed); + if (replaceError) { return replaceError; } + + const entry = catalogLabelEntry(newKey, label); + if (!entry) { + return jsonValidationError('Failed to update repository label.'); + } + return jsonOk(buildLabelResponse(entry.rec, entry.data, entry.tags, targetDid, repoName, buildApiUrl(url))); +} + +export async function handleDeleteRepoLabel( + ctx: AgentContext, targetDid: string, repoName: string, labelName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const entries = await listRepoLabelEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const decodedLabelName = decodeLabelName(labelName); + const key = labelKey(decodedLabelName); + const existing = entries.find(entry => labelKey(entry.name) === key); + if (!existing) { + return jsonNotFound(`Label '${decodedLabelName}' not found.`); + } + + if (lookup.settings.labels?.[key]) { + const saveError = await saveRepoSettings(ctx, lookup, labelsSettingWithChange(lookup.settings, key, null)); + if (saveError) { return saveError; } + } + + const records = await listRepoLabelRecords(ctx, fromOpt(ctx, targetDid), lookup.repo.contextId); + const deleteError = await deleteLabels(records.filter(entry => labelKey(entry.name) === key).map(entry => entry.rec)); + if (deleteError) { return deleteError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/milestones +// --------------------------------------------------------------------------- + +export async function handleListMilestones( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const entries = await listMilestoneEntries(ctx, targetDid, repoName); + if (!Array.isArray(entries)) { return entries; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const filtered = sortMilestoneEntries(entries, url); + const paged = paginate(filtered, pagination); + const items = paged.map(entry => buildMilestoneResponse(entry, targetDid, repoName, baseUrl)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/milestones`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCreateMilestone( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = parseMilestoneCreateInput(reqBody); + if ('status' in parsed) { return parsed; } + + const from = fromOpt(ctx, targetDid); + const entries = await listMilestoneEntriesForRepo(ctx, lookup.repo.contextId, from, lookup.settings); + const key = milestoneKey(parsed.title); + if (entries.some(entry => milestoneKey(entry.title) === key)) { + return jsonValidationError(`Validation Failed: milestone '${parsed.title}' already exists.`); + } + + const now = new Date().toISOString(); + const milestone: RepoMilestoneCatalogEntry = { + title : parsed.title, + number : nextMilestoneNumber(entries), + state : parsed.state, + description : parsed.description, + dueOn : parsed.dueOn, + createdAt : now, + updatedAt : now, + closedAt : parsed.state === 'closed' ? now : null, + }; + + const saveError = await saveRepoSettings(ctx, lookup, milestonesSettingWithChange(lookup.settings, key, milestone)); + if (saveError) { return saveError; } + + const entry = catalogMilestoneEntry(key, milestone); + if (!entry) { + return jsonValidationError('Failed to create milestone.'); + } + + return jsonCreated(buildMilestoneResponse(entry, targetDid, repoName, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET/PATCH/DELETE /repos/:did/:repo/milestones/:number +// --------------------------------------------------------------------------- + +export async function handleGetMilestone( + ctx: AgentContext, targetDid: string, repoName: string, milestoneNumberValue: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findMilestoneEntry(ctx, targetDid, repoName, milestoneNumberValue); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildMilestoneResponse(lookup.milestone, targetDid, repoName, buildApiUrl(url))); +} + +export async function handleUpdateMilestone( + ctx: AgentContext, targetDid: string, repoName: string, + milestoneNumberValue: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const from = fromOpt(ctx, targetDid); + const entries = await listMilestoneEntriesForRepo(ctx, lookup.repo.contextId, from, lookup.settings); + const number = parseInt(milestoneNumberValue, 10); + const existing = entries.find(entry => entry.number === number); + if (!existing) { + return jsonNotFound(`Milestone #${milestoneNumberValue} not found.`); + } + + const parsed = parseMilestoneUpdateInput(reqBody, existing); + if ('status' in parsed) { return parsed; } + + const oldKey = milestoneKey(existing.title); + const newKey = milestoneKey(parsed.title); + if (newKey !== oldKey && entries.some(entry => milestoneKey(entry.title) === newKey)) { + return jsonValidationError(`Validation Failed: milestone '${parsed.title}' already exists.`); + } + + const previous = lookup.settings.milestones?.[oldKey]; + const now = new Date().toISOString(); + const previousState = milestoneState(existing); + const milestone: RepoMilestoneCatalogEntry = { + title : parsed.title, + number : existing.number, + state : parsed.state, + description : parsed.description, + dueOn : parsed.dueOn, + createdAt : previous?.createdAt ?? existing.createdAt, + updatedAt : now, + closedAt : parsed.state === 'closed' + ? previousState === 'closed' ? existing.closedAt ?? previous?.closedAt ?? now : now + : null, + }; + + let settings = milestonesSettingWithChange(lookup.settings, oldKey, null); + settings = milestonesSettingWithChange(settings, newKey, milestone); + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + if (newKey !== oldKey) { + const replaceError = await replaceIssueMilestoneTags(ctx, from, lookup.repo.contextId, existing.title, parsed.title); + if (replaceError) { return replaceError; } + } + + const entry = catalogMilestoneEntry(newKey, milestone); + if (!entry) { + return jsonValidationError('Failed to update milestone.'); + } + entry.openIssues = existing.openIssues; + entry.closedIssues = existing.closedIssues; + + return jsonOk(buildMilestoneResponse(entry, targetDid, repoName, buildApiUrl(url))); +} + +export async function handleDeleteMilestone( + ctx: AgentContext, targetDid: string, repoName: string, milestoneNumberValue: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const from = fromOpt(ctx, targetDid); + const entries = await listMilestoneEntriesForRepo(ctx, lookup.repo.contextId, from, lookup.settings); + const number = parseInt(milestoneNumberValue, 10); + const existing = entries.find(entry => entry.number === number); + if (!existing) { + return jsonNotFound(`Milestone #${milestoneNumberValue} not found.`); } - // Fetch comments. - const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { - from, - filter : { contextId: issueRec.contextId }, - dateSort : DateSort.CreatedAscending, - }); + const key = milestoneKey(existing.title); + if (lookup.settings.milestones?.[key]) { + const saveError = await saveRepoSettings(ctx, lookup, milestonesSettingWithChange(lookup.settings, key, null)); + if (saveError) { return saveError; } + } - const paged = paginate(comments, pagination); + const replaceError = await replaceIssueMilestoneTags(ctx, from, lookup.repo.contextId, existing.title, null); + if (replaceError) { return replaceError; } - const items: Record<string, unknown>[] = []; - for (const comment of paged) { - const cData = await comment.data.json(); - const commentAuthor = comment.author ?? targetDid; - items.push({ - id : numericId(comment.id ?? ''), - node_id : comment.id ?? '', - url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/comments/${numericId(comment.id ?? '')}`, - html_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}#issuecomment-${numericId(comment.id ?? '')}`, - issue_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}`, - body : cData.body ?? '', - created_at : toISODate(comment.dateCreated), - updated_at : toISODate(comment.timestamp), - user : buildOwner(commentAuthor, baseUrl), - author_association : commentAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', - reactions : { url: '', total_count: 0 }, - }); + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/milestones/:number/labels +// --------------------------------------------------------------------------- + +export async function handleListMilestoneLabels( + ctx: AgentContext, targetDid: string, repoName: string, milestoneNumberValue: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findMilestoneEntry(ctx, targetDid, repoName, milestoneNumberValue); + if ('status' in lookup) { return lookup; } + + const issues = await listIssueRecordsForRepo(ctx, lookup.repo.contextId, lookup.from); + const labelsByName = new Map<string, RepoLabelEntry>(); + for (const issue of issues) { + const issueTags = (issue.tags as Record<string, string> | undefined) ?? {}; + if (milestoneKey(issueTags.milestone ?? '') !== milestoneKey(lookup.milestone.title)) { continue; } + + const labelRecords = await listLabelRecords(ctx, lookup.from, issue); + for (const rec of labelRecords) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const name = normalizeLabelName(tags.name ?? data.name); + if (!name) { continue; } + + const entry = { + rec, + data, + tags : { ...tags, name }, + name, + updatedAt : String(rec.timestamp ?? rec.dateCreated ?? ''), + }; + const key = name.toLocaleLowerCase(); + const existing = labelsByName.get(key); + if (!existing || entry.updatedAt.localeCompare(existing.updatedAt) >= 0) { + labelsByName.set(key, entry); + } + } } + const baseUrl = buildApiUrl(url); + const entries = [...labelsByName.values()].sort((a, b) => a.name.localeCompare(b.name)); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const items = paged.map(entry => buildLabelResponse( + entry.rec, entry.data, entry.tags, targetDid, repoName, baseUrl, + )); + const linkHeader = buildLinkHeader( - baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/comments`, - pagination.page, pagination.perPage, comments.length, + baseUrl, `/repos/${targetDid}/${repoName}/milestones/${milestoneNumberValue}/labels`, + pagination.page, pagination.perPage, entries.length, ); const extraHeaders: Record<string, string> = {}; if (linkHeader) { extraHeaders['Link'] = linkHeader; } @@ -248,6 +3249,7 @@ export async function handleListIssueComments( export async function handleCreateIssue( ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -261,10 +3263,31 @@ export async function handleCreateIssue( const body = (reqBody.body as string) ?? ''; const baseUrl = buildApiUrl(url); + const hasMutableMetadata = 'labels' in reqBody || hasAssigneeInput(reqBody) || 'milestone' in reqBody; + const canWriteMetadata = hasMutableMetadata + ? await canMutateIssueMetadata(ctx, targetDid, repo) + : false; + const parsedLabels = 'labels' in reqBody && canWriteMetadata ? parseLabelInputs(reqBody) : undefined; + if (parsedLabels && !Array.isArray(parsedLabels)) { return parsedLabels; } + const parsedAssignees = hasAssigneeInput(reqBody) && canWriteMetadata ? parseAssigneeInputs(reqBody) : undefined; + if (parsedAssignees && !Array.isArray(parsedAssignees)) { return parsedAssignees; } + if (parsedAssignees) { + const assigneeLimitError = validateAssigneeLimit(parsedAssignees); + if (assigneeLimitError) { return assigneeLimitError; } + } + const parsedMilestone = 'milestone' in reqBody && canWriteMetadata + ? await parseMilestoneInput(ctx, targetDid, repoName, reqBody.milestone) + : undefined; + if (typeof parsedMilestone === 'object' && parsedMilestone !== null) { return parsedMilestone; } + + const issueTags: Record<string, string> = { status: 'open' }; + if (typeof parsedMilestone === 'string') { + issueTags.milestone = parsedMilestone; + } const { status, record } = await ctx.issues.records.create('repo/issue', { data : { title, body }, - tags : { status: 'open' }, + tags : issueTags, parentContextId : repo.contextId, }); @@ -273,9 +3296,18 @@ export async function handleCreateIssue( } if (!record) {throw new Error('Failed to create issue record');} - const tags = (record.tags as Record<string, string> | undefined) ?? {}; const data = await record.data.json(); - const issue = buildIssueResponse(record, data, tags, targetDid, repoName, baseUrl); + if (parsedLabels) { + const labelError = await createMissingLabels(ctx, record, [], parsedLabels); + if (labelError) { return labelError; } + } + if (parsedAssignees) { + const assignmentError = await createMissingAssignments(ctx, record, [], parsedAssignees); + if (assignmentError) { return assignmentError; } + } + const issue = await buildIssueWithChildren( + ctx, undefined, record, data, issueTags, targetDid, repoName, baseUrl, bodyMediaKind, + ); return jsonCreated(issue); } @@ -287,6 +3319,7 @@ export async function handleCreateIssue( export async function handleUpdateIssue( ctx: AgentContext, targetDid: string, repoName: string, number: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -311,28 +3344,110 @@ export async function handleUpdateIssue( // Apply updates. const newTitle = (reqBody.title as string | undefined) ?? data.title; const newBody = (reqBody.body as string | undefined) ?? data.body; + const hasMutableMetadata = 'labels' in reqBody || hasAssigneeInput(reqBody) || 'milestone' in reqBody; + const canWriteMetadata = hasMutableMetadata + ? await canMutateIssueMetadata(ctx, targetDid, repo) + : false; + const parsedLabels = 'labels' in reqBody && canWriteMetadata ? parseLabelInputs(reqBody) : undefined; + if (parsedLabels && !Array.isArray(parsedLabels)) { return parsedLabels; } + const parsedAssignees = hasAssigneeInput(reqBody) && canWriteMetadata ? parseAssigneeInputs(reqBody) : undefined; + if (parsedAssignees && !Array.isArray(parsedAssignees)) { return parsedAssignees; } + if (parsedAssignees) { + const assigneeLimitError = validateAssigneeLimit(parsedAssignees); + if (assigneeLimitError) { return assigneeLimitError; } + } + const parsedMilestone = 'milestone' in reqBody && canWriteMetadata + ? await parseMilestoneInput(ctx, targetDid, repoName, reqBody.milestone) + : undefined; + if (typeof parsedMilestone === 'object' && parsedMilestone !== null) { return parsedMilestone; } // GitHub API uses "state" (open/closed), DWN uses "status" tag. - let newStatus = tags.status ?? 'open'; + const previousStatus = tags.status === 'closed' ? 'closed' : 'open'; + let newStatus = previousStatus; if (reqBody.state === 'closed') { newStatus = 'closed'; } if (reqBody.state === 'open') { newStatus = 'open'; } + const updatedTags: Record<string, string> = { ...tags, status: newStatus }; + if ('milestone' in reqBody && canWriteMetadata) { + if (typeof parsedMilestone === 'string') { + updatedTags.milestone = parsedMilestone; + } else { + delete updatedTags.milestone; + } + } + const { status } = await rec.update({ data : { title: newTitle, body: newBody }, - tags : { ...tags, status: newStatus }, + tags : updatedTags, }); if (status.code >= 300) { return jsonValidationError(`Failed to update issue: ${status.detail}`); } - const updatedTags = { ...tags, status: newStatus }; const updatedData = { title: newTitle, body: newBody }; - const issue = buildIssueResponse(rec, updatedData, updatedTags, targetDid, repoName, baseUrl); + if (newStatus !== previousStatus) { + const reason = typeof reqBody.state_reason === 'string' + ? reqBody.state_reason + : typeof reqBody.reason === 'string' + ? reqBody.reason + : undefined; + const eventError = await createIssueStatusChange(ctx, rec, previousStatus, newStatus, reason); + if (eventError) { return eventError; } + } + if (parsedLabels) { + const existing = await listLabelRecords(ctx, undefined, rec); + const deleteError = await deleteLabels(existing); + if (deleteError) { return deleteError; } + const createError = await createMissingLabels(ctx, rec, [], parsedLabels); + if (createError) { return createError; } + } + if (parsedAssignees) { + const existing = await listAssignmentRecords(ctx, undefined, rec); + const deleteError = await deleteAssignments(existing); + if (deleteError) { return deleteError; } + const createError = await createMissingAssignments(ctx, rec, [], parsedAssignees); + if (createError) { return createError; } + } + + const issue = await buildIssueWithChildren( + ctx, undefined, rec, updatedData, updatedTags, targetDid, repoName, baseUrl, bodyMediaKind, + ); return jsonOk(issue); } +// --------------------------------------------------------------------------- +// PUT/DELETE /repos/:did/:repo/issues/:number/lock — lock/unlock issue +// --------------------------------------------------------------------------- + +export async function handleLockIssue( + ctx: AgentContext, targetDid: string, repoName: string, number: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const reason = parseIssueLockReason(reqBody); + if (reason && typeof reason !== 'string') { return reason; } + + const updateError = await updateIssueLock(lookup.issue, true, reason); + if (updateError) { return updateError; } + + return jsonNoContent(); +} + +export async function handleUnlockIssue( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const updateError = await updateIssueLock(lookup.issue, false, null); + if (updateError) { return updateError; } + + return jsonNoContent(); +} + // --------------------------------------------------------------------------- // POST /repos/:did/:repo/issues/:number/comments — create comment // --------------------------------------------------------------------------- @@ -340,6 +3455,7 @@ export async function handleUpdateIssue( export async function handleCreateIssueComment( ctx: AgentContext, targetDid: string, repoName: string, number: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -374,19 +3490,475 @@ export async function handleCreateIssueComment( } if (!commentRec) {throw new Error('Failed to create comment record');} - const commentAuthor = commentRec.author ?? targetDid; - - return jsonCreated({ - id : numericId(commentRec.id ?? ''), - node_id : commentRec.id ?? '', - url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/comments/${numericId(commentRec.id ?? '')}`, - html_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}#issuecomment-${numericId(commentRec.id ?? '')}`, - issue_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}`, - body, - created_at : toISODate(commentRec.dateCreated), - updated_at : toISODate(commentRec.dateCreated), - user : buildOwner(commentAuthor, baseUrl), - author_association : commentAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', - reactions : { url: '', total_count: 0 }, + const data = await commentRec.data.json(); + return jsonCreated(buildIssueCommentResponse(commentRec, data, num, targetDid, repoName, baseUrl, [], bodyMediaKind)); +} + +// --------------------------------------------------------------------------- +// GET/PATCH/DELETE /repos/:did/:repo/issues/comments/:id +// --------------------------------------------------------------------------- + +export async function handleGetIssueComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const data = await lookup.entry.comment.data.json(); + const reactions = await listIssueCommentReactionRecords(ctx, lookup.from, lookup.entry.comment); + return jsonOk(buildIssueCommentResponse( + lookup.entry.comment, data, lookup.entry.issueNumber, targetDid, repoName, buildApiUrl(url), reactions, bodyMediaKind, + )); +} + +export async function handleUpdateIssueComment( + ctx: AgentContext, targetDid: string, repoName: string, + commentId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const body = reqBody.body as string | undefined; + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const currentData = await lookup.entry.comment.data.json(); + const nextData = { ...currentData, body }; + const { status } = await lookup.entry.comment.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update issue comment: ${status.detail}`); + } + + return jsonOk(buildIssueCommentResponse( + lookup.entry.comment, + nextData, + lookup.entry.issueNumber, + targetDid, + repoName, + buildApiUrl(url), + await listIssueCommentReactionRecords(ctx, lookup.from, lookup.entry.comment), + bodyMediaKind, + )); +} + +export async function handleDeleteIssueComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const { status } = await lookup.entry.comment.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue comment: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handlePinIssueComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const currentData = await lookup.entry.comment.data.json(); + const pinnedAt = new Date().toISOString(); + const nextData = { ...currentData, pinnedAt, pinnedBy: ctx.did }; + const { status } = await lookup.entry.comment.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to pin issue comment: ${status.detail}`); + } + + return jsonOk(buildIssueCommentResponse( + lookup.entry.comment, + nextData, + lookup.entry.issueNumber, + targetDid, + repoName, + buildApiUrl(url), + await listIssueCommentReactionRecords(ctx, lookup.from, lookup.entry.comment), + bodyMediaKind, + )); +} + +export async function handleUnpinIssueComment( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const currentData = await lookup.entry.comment.data.json(); + const nextData = { ...currentData }; + delete nextData.pinnedAt; + delete nextData.pinnedBy; + const { status } = await lookup.entry.comment.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to unpin issue comment: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST/DELETE /repos/:did/:repo/issues/:number/reactions +// --------------------------------------------------------------------------- + +export async function handleListIssueReactions( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const contentFilter = url.searchParams.get('content'); + if (contentFilter !== null) { + const parsed = parseReactionContent(contentFilter); + if (typeof parsed !== 'string') { return parsed; } + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const records = await listIssueReactionRecords(ctx, lookup.from, lookup.issue); + const filtered = contentFilter === null + ? records + : records.filter(rec => getReactionContent(rec) === contentFilter); + const paged = paginate(filtered, pagination); + + const items: Record<string, unknown>[] = []; + for (const rec of paged) { + const data = await rec.data.json(); + items.push(buildReactionResponse(rec, data, baseUrl, targetDid)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/${number}/reactions`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCreateIssueReaction( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const content = parseReactionContent(reqBody.content); + if (typeof content !== 'string') { return content; } + + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const existing = await listIssueReactionRecords(ctx, undefined, lookup.issue); + const duplicate = existing.find((rec) => { + const authorDid = rec.author ?? ctx.did; + return authorDid === ctx.did && getReactionContent(rec) === content; + }); + if (duplicate) { + const data = await duplicate.data.json(); + return jsonOk(buildReactionResponse(duplicate, data, baseUrl, targetDid)); + } + + const { status, record } = await ctx.issues.records.create('repo/issue/reaction' as any, { + data : { emoji: content }, + tags : { emoji: content }, + parentContextId : lookup.issue.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create issue reaction: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create issue reaction record'); } + + const data = await record.data.json(); + return jsonCreated(buildReactionResponse(record, data, baseUrl, targetDid)); +} + +export async function handleDeleteIssueReaction( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reactionId: string, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const id = parseInt(reactionId, 10); + const records = await listIssueReactionRecords(ctx, undefined, lookup.issue); + const reaction = records.find(rec => numericId(rec.id ?? '') === id); + if (!reaction) { + return jsonNotFound(`Reaction #${reactionId} not found on issue #${number}.`); + } + + const { status } = await reaction.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue reaction: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST/DELETE /repos/:did/:repo/issues/comments/:id/reactions +// --------------------------------------------------------------------------- + +export async function handleListIssueCommentReactions( + ctx: AgentContext, targetDid: string, repoName: string, commentId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const contentFilter = url.searchParams.get('content'); + if (contentFilter !== null) { + const parsed = parseReactionContent(contentFilter); + if (typeof parsed !== 'string') { return parsed; } + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const records = await listIssueCommentReactionRecords(ctx, lookup.from, lookup.entry.comment); + const filtered = contentFilter === null + ? records + : records.filter(rec => getReactionContent(rec) === contentFilter); + const paged = paginate(filtered, pagination); + + const items: Record<string, unknown>[] = []; + for (const rec of paged) { + const data = await rec.data.json(); + items.push(buildReactionResponse(rec, data, baseUrl, targetDid)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/issues/comments/${commentId}/reactions`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCreateIssueCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, + commentId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const content = parseReactionContent(reqBody.content); + if (typeof content !== 'string') { return content; } + + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const existing = await listIssueCommentReactionRecords(ctx, undefined, lookup.entry.comment); + const duplicate = existing.find((rec) => { + const authorDid = rec.author ?? ctx.did; + return authorDid === ctx.did && getReactionContent(rec) === content; + }); + if (duplicate) { + const data = await duplicate.data.json(); + return jsonOk(buildReactionResponse(duplicate, data, baseUrl, targetDid)); + } + + const { status, record } = await ctx.issues.records.create('repo/issue/comment/reaction' as any, { + data : { emoji: content }, + tags : { emoji: content }, + parentContextId : lookup.entry.comment.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create issue comment reaction: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create issue comment reaction record'); } + + const data = await record.data.json(); + return jsonCreated(buildReactionResponse(record, data, baseUrl, targetDid)); +} + +export async function handleDeleteIssueCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, + commentId: string, reactionId: string, +): Promise<JsonResponse> { + const lookup = await findIssueCommentRecord(ctx, targetDid, repoName, commentId); + if ('status' in lookup) { return lookup; } + + const id = parseInt(reactionId, 10); + const records = await listIssueCommentReactionRecords(ctx, undefined, lookup.entry.comment); + const reaction = records.find(rec => numericId(rec.id ?? '') === id); + if (!reaction) { + return jsonNotFound(`Reaction #${reactionId} not found on issue comment #${commentId}.`); + } + + const { status } = await reaction.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue comment reaction: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST/PUT/DELETE /repos/:did/:repo/issues/:number/labels +// --------------------------------------------------------------------------- + +export async function handleListIssueLabels( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const labels = await buildIssueLabels( + ctx, lookup.from, lookup.issue, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(labels); +} + +export async function handleAddIssueLabels( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const parsed = parseLabelInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + const existing = await listLabelRecords(ctx, undefined, lookup.issue); + const createError = await createMissingLabels(ctx, lookup.issue, existing, parsed); + if (createError) { return createError; } + + const labels = await buildIssueLabels( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(labels); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/issues/:number/assignees +// --------------------------------------------------------------------------- + +export async function handleCheckIssueAssignee( + ctx: AgentContext, targetDid: string, repoName: string, number: string, encodedDid: string, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + return handleCheckAssignee(ctx, targetDid, repoName, encodedDid); +} + +export async function handleAddIssueAssignees( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const parsed = parseAssigneeInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + if (await canMutateIssueMetadata(ctx, targetDid, lookup.repo)) { + const existing = await listAssignmentRecords(ctx, undefined, lookup.issue); + const assigneeLimitError = validateMergedAssigneeLimit(existing, parsed); + if (assigneeLimitError) { return assigneeLimitError; } + const createError = await createMissingAssignments(ctx, lookup.issue, existing, parsed); + if (createError) { return createError; } + } + + const data = await lookup.issue.data.json(); + const tags = (lookup.issue.tags as Record<string, string> | undefined) ?? {}; + const issue = await buildIssueWithChildren( + ctx, undefined, lookup.issue, data, tags, targetDid, repoName, buildApiUrl(url), + ); + return jsonCreated(issue); +} + +export async function handleRemoveIssueAssignees( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const parsed = parseAssigneeInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + if (await canMutateIssueMetadata(ctx, targetDid, lookup.repo)) { + const toRemove = new Set(parsed.map(assignee => assignee.did)); + const existing = await listAssignmentRecords(ctx, undefined, lookup.issue); + const deleteError = await deleteAssignments(existing.filter((rec) => { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + return toRemove.has(tags.assigneeDid ?? ''); + })); + if (deleteError) { return deleteError; } + } + + const data = await lookup.issue.data.json(); + const tags = (lookup.issue.tags as Record<string, string> | undefined) ?? {}; + const issue = await buildIssueWithChildren( + ctx, undefined, lookup.issue, data, tags, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(issue); +} + +export async function handleReplaceIssueLabels( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const parsed = parseLabelInputs(reqBody); + if (!Array.isArray(parsed)) { return parsed; } + + const existing = await listLabelRecords(ctx, undefined, lookup.issue); + const deleteError = await deleteLabels(existing); + if (deleteError) { return deleteError; } + + const createError = await createMissingLabels(ctx, lookup.issue, [], parsed); + if (createError) { return createError; } + + const labels = await buildIssueLabels( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(labels); +} + +export async function handleRemoveAllIssueLabels( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const existing = await listLabelRecords(ctx, undefined, lookup.issue); + const deleteError = await deleteLabels(existing); + if (deleteError) { return deleteError; } + + return jsonNoContent(); +} + +export async function handleRemoveIssueLabel( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, labelName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findIssueRecord(ctx, targetDid, repoName, number); + if ('status' in lookup) { return lookup; } + + const existing = await listLabelRecords(ctx, undefined, lookup.issue); + const target = decodeURIComponent(labelName).toLocaleLowerCase(); + const toDelete = existing.filter((rec) => { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + return (tags.name ?? '').toLocaleLowerCase() === target; }); + + if (toDelete.length === 0) { + return jsonNotFound(`Label '${decodeURIComponent(labelName)}' not found on issue #${number}.`); + } + + const deleteError = await deleteLabels(toDelete); + if (deleteError) { return deleteError; } + + const labels = await buildIssueLabels( + ctx, undefined, lookup.issue, targetDid, repoName, buildApiUrl(url), + ); + return jsonOk(labels); } diff --git a/src/github-shim/meta.ts b/src/github-shim/meta.ts new file mode 100644 index 0000000..9db7680 --- /dev/null +++ b/src/github-shim/meta.ts @@ -0,0 +1,364 @@ +/** + * GitHub API shim - global utility endpoints. + * + * Provides GitHub-compatible public metadata, rate limit, emoji, + * gitignore, license, and Markdown rendering endpoints used by generic + * API clients before they touch repository-specific resources. + * + * @module + */ + +import type { JsonResponse } from './helpers.js'; + +import { + baseHeaders, + buildApiUrl, + jsonNotFound, + jsonOk, + jsonValidationError, +} from './helpers.js'; + +type GitignoreTemplate = { + name : string; + source : string; +}; + +type LicenseTemplate = { + key : string; + name : string; + spdxId : string; + body : string; +}; + +const GITIGNORE_TEMPLATES: GitignoreTemplate[] = [ + { + name : 'Node', + source : [ + 'node_modules/', + 'npm-debug.log*', + 'yarn-debug.log*', + 'yarn-error.log*', + '.env', + 'dist/', + '', + ].join('\n'), + }, + { + name : 'Python', + source : [ + '__pycache__/', + '*.py[cod]', + '.venv/', + 'venv/', + 'dist/', + 'build/', + '', + ].join('\n'), + }, + { + name : 'macOS', + source : [ + '.DS_Store', + '.AppleDouble', + '.LSOverride', + '', + ].join('\n'), + }, + { + name : 'VisualStudioCode', + source : [ + '.vscode/*', + '!.vscode/settings.json', + '!.vscode/tasks.json', + '!.vscode/launch.json', + '', + ].join('\n'), + }, +]; + +const LICENSES: LicenseTemplate[] = [ + { + key : 'mit', + name : 'MIT License', + spdxId : 'MIT', + body : [ + 'MIT License', + '', + '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, including without limitation the rights', + 'to use, copy, modify, merge, publish, distribute, sublicense, and/or sell', + 'copies of the Software.', + '', + ].join('\n'), + }, + { + key : 'apache-2.0', + name : 'Apache License 2.0', + spdxId : 'Apache-2.0', + body : [ + 'Apache License', + 'Version 2.0, January 2004', + '', + 'Licensed under the Apache License, Version 2.0 (the "License");', + 'you may not use this file except in compliance with the License.', + '', + ].join('\n'), + }, + { + key : 'gpl-3.0', + name : 'GNU General Public License v3.0', + spdxId : 'GPL-3.0', + body : [ + 'GNU GENERAL PUBLIC LICENSE', + 'Version 3, 29 June 2007', + '', + 'Everyone is permitted to copy and distribute verbatim copies', + 'of this license document, but changing it is not allowed.', + '', + ].join('\n'), + }, + { + key : 'unlicense', + name : 'The Unlicense', + spdxId : 'Unlicense', + body : [ + 'This is free and unencumbered software released into the public domain.', + '', + ].join('\n'), + }, +]; + +function textOk(body: string, contentType: string): JsonResponse { + return { + status : 200, + headers : { + ...baseHeaders(), + 'Content-Type' : contentType, + 'Content-Length' : String(Buffer.byteLength(body)), + }, + body, + }; +} + +function normalizedLookup(value: string): string { + return decodeURIComponent(value).toLowerCase(); +} + +function licenseResponse(license: LicenseTemplate, baseUrl: string, includeBody: boolean): Record<string, unknown> { + const data: Record<string, unknown> = { + key : license.key, + name : license.name, + spdx_id : license.spdxId, + url : `${baseUrl}/licenses/${license.key}`, + node_id : `MDc6TGljZW5zZQ${Buffer.from(license.key).toString('base64url')}`, + }; + if (includeBody) { + data.html_url = `https://choosealicense.com/licenses/${license.key}/`; + data.description = `${license.name} template.`; + data.implementation = 'Create a LICENSE file in the root of your repository.'; + data.permissions = ['commercial-use', 'modifications', 'distribution']; + data.conditions = ['include-copyright']; + data.limitations = ['liability']; + data.body = license.body; + data.featured = true; + } + return data; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderInlineMarkdown(value: string): string { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '<code>$1</code>') + .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') + .replace(/\*([^*]+)\*/g, '<em>$1</em>'); +} + +export function renderMarkdownText(markdown: string): string { + const blocks = markdown.replace(/\r\n/g, '\n').split(/\n{2,}/); + const html = blocks + .map((block) => { + const trimmed = block.trim(); + if (!trimmed) { + return ''; + } + + const heading = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + const level = heading[1].length; + return `<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`; + } + + const lines = trimmed.split('\n'); + if (lines.every(line => line.startsWith('- '))) { + const items = lines.map(line => `<li>${renderInlineMarkdown(line.slice(2).trim())}</li>`).join(''); + return `<ul>${items}</ul>`; + } + + return `<p>${renderInlineMarkdown(lines.join('\n'))}</p>`; + }) + .filter(Boolean); + + return html.join('\n'); +} + +function rateBucket(limit: number, used = 0): Record<string, number> { + const reset = Math.floor(Date.now() / 1000) + 3600; + return { + limit, + used, + remaining: Math.max(0, limit - used), + reset, + }; +} + +export function handleGitHubApiRoot(url: URL): JsonResponse { + const baseUrl = buildApiUrl(url); + return jsonOk({ + current_user_url : `${baseUrl}/user`, + code_search_url : `${baseUrl}/search/code?q={query}{&page,per_page,sort,order}`, + commit_search_url : `${baseUrl}/search/commits?q={query}{&page,per_page,sort,order}`, + emojis_url : `${baseUrl}/emojis`, + followers_url : `${baseUrl}/user/followers`, + following_url : `${baseUrl}/user/following{/target}`, + issue_search_url : `${baseUrl}/search/issues?q={query}{&page,per_page,sort,order}`, + label_search_url : `${baseUrl}/search/labels?q={query}&repository_id={repository_id}{&page,per_page}`, + notifications_url : `${baseUrl}/notifications`, + organization_url : `${baseUrl}/orgs/{org}`, + organization_repositories_url : `${baseUrl}/orgs/{org}/repos{?type,page,per_page,sort}`, + organization_teams_url : `${baseUrl}/orgs/{org}/teams`, + rate_limit_url : `${baseUrl}/rate_limit`, + repository_url : `${baseUrl}/repos/{owner}/{repo}`, + repository_search_url : `${baseUrl}/search/repositories?q={query}{&page,per_page,sort,order}`, + current_user_repositories_url : `${baseUrl}/user/repos{?type,page,per_page,sort}`, + starred_url : `${baseUrl}/user/starred{/owner}{/repo}`, + topic_search_url : `${baseUrl}/search/topics?q={query}{&page,per_page}`, + user_url : `${baseUrl}/users/{user}`, + user_organizations_url : `${baseUrl}/user/orgs`, + user_repositories_url : `${baseUrl}/users/{user}/repos{?type,page,per_page,sort}`, + user_search_url : `${baseUrl}/search/users?q={query}{&page,per_page,sort,order}`, + }); +} + +export function handleGetMeta(url: URL): JsonResponse { + const host = new URL(buildApiUrl(url)).host; + return jsonOk({ + verifiable_password_authentication : false, + ssh_key_fingerprints : {}, + ssh_keys : [], + hooks : [], + web : [], + api : [], + git : [], + packages : [], + pages : [], + importer : [], + actions : [], + dependabot : [], + copilot : [], + domains : { + website : [host], + api : [host], + packages : [host], + }, + }); +} + +export function handleGetApiVersions(): JsonResponse { + return jsonOk(['2022-11-28', '2026-03-10']); +} + +export function handleGetZen(): JsonResponse { + return textOk('Design for decentralization.', 'text/plain; charset=utf-8'); +} + +export function handleGetRateLimit(): JsonResponse { + const core = rateBucket(5000, 1); + return jsonOk({ + resources: { + core, + search : rateBucket(30), + code_search : rateBucket(10), + graphql : rateBucket(5000), + integration_manifest : rateBucket(5000), + dependency_snapshots : rateBucket(100), + dependency_sbom : rateBucket(100), + code_scanning_upload : rateBucket(500), + actions_runner_registration : rateBucket(10000), + scim : rateBucket(15000), + }, + rate: core, + }); +} + +export function handleGetEmojis(): JsonResponse { + const base = 'https://github.githubassets.com/images/icons/emoji/unicode'; + return jsonOk({ + '+1' : `${base}/1f44d.png?v8`, + '-1' : `${base}/1f44e.png?v8`, + heart : `${base}/2764.png?v8`, + eyes : `${base}/1f440.png?v8`, + rocket : `${base}/1f680.png?v8`, + white_check_mark : `${base}/2705.png?v8`, + }); +} + +export function handleListGitignoreTemplates(): JsonResponse { + return jsonOk(GITIGNORE_TEMPLATES.map(template => template.name)); +} + +export function handleGetGitignoreTemplate(name: string): JsonResponse { + const template = GITIGNORE_TEMPLATES.find(item => item.name.toLowerCase() === normalizedLookup(name)); + if (!template) { + return jsonNotFound(`Gitignore template '${decodeURIComponent(name)}' not found.`); + } + + return jsonOk({ + name : template.name, + source : template.source, + }); +} + +export function handleListLicenses(url: URL): JsonResponse { + const baseUrl = buildApiUrl(url); + return jsonOk(LICENSES.map(license => licenseResponse(license, baseUrl, false))); +} + +export function handleGetLicense(licenseKey: string, url: URL): JsonResponse { + const normalized = normalizedLookup(licenseKey); + const license = LICENSES.find(item => item.key === normalized || item.spdxId.toLowerCase() === normalized); + if (!license) { + return jsonNotFound(`License '${decodeURIComponent(licenseKey)}' not found.`); + } + + return jsonOk(licenseResponse(license, buildApiUrl(url), true)); +} + +export function handleRenderMarkdown(reqBody: Record<string, unknown>): JsonResponse { + if (typeof reqBody.text !== 'string') { + return jsonValidationError('Validation Failed: text is required.'); + } + + if (reqBody.mode !== undefined && reqBody.mode !== 'markdown' && reqBody.mode !== 'gfm') { + return jsonValidationError('Validation Failed: mode must be markdown or gfm.'); + } + + return textOk(renderMarkdownText(reqBody.text), 'text/html; charset=utf-8'); +} + +export function handleRenderRawMarkdown(options: { rawBody?: Uint8Array }): JsonResponse { + const body = options.rawBody ?? new Uint8Array(); + if (body.byteLength > 400 * 1024) { + return jsonValidationError('Validation Failed: Markdown content must be 400 KB or less.'); + } + + return textOk(renderMarkdownText(Buffer.from(body).toString('utf-8')), 'text/html; charset=utf-8'); +} diff --git a/src/github-shim/metrics.ts b/src/github-shim/metrics.ts new file mode 100644 index 0000000..8402bf8 --- /dev/null +++ b/src/github-shim/metrics.ts @@ -0,0 +1,595 @@ +/** + * GitHub API shim - repository metrics endpoints. + * + * Provides GitHub-compatible community profile, repository statistics, and + * traffic responses. Statistics are derived from local bare git history when + * repo storage is available; traffic currently returns valid empty aggregates. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; + +import { GitBackend } from '../git-server/git-backend.js'; +import { resolveReposPath } from '../cli/flags.js'; +import { + buildApiUrl, + fromOpt, + getRepoRecord, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MetricsOptions = { + reposPath? : string; +}; + +type GitResult = { + status : number; + stdout : Buffer; + stderr : Buffer; +}; + +type LocalRepoLookup = { + repoPath : string; +} | null; + +type CommitMetric = { + sha : string; + name : string; + email : string; + timestamp : number; + additions : number; + deletions : number; +}; + +type WeekBucket = { + additions : number; + deletions : number; + commits : number; + days : number[]; +}; + +type CommunityFiles = { + code_of_conduct : Record<string, unknown> | null; + code_of_conduct_file : Record<string, unknown> | null; + contributing : Record<string, unknown> | null; + issue_template : Record<string, unknown> | null; + pull_request_template : Record<string, unknown> | null; + license : Record<string, unknown> | null; + readme : Record<string, unknown> | null; +}; + +// --------------------------------------------------------------------------- +// Local git helpers +// --------------------------------------------------------------------------- + +function localRepo(ctx: AgentContext, targetDid: string, repoName: string, options: MetricsOptions): LocalRepoLookup { + const reposPath = options.reposPath ?? resolveReposPath([], ctx.profileName ?? null); + const backend = new GitBackend({ basePath: reposPath }); + + try { + if (!backend.exists(targetDid, repoName)) { + return null; + } + return { repoPath: backend.repoPath(targetDid, repoName) }; + } catch { + return null; + } +} + +async function runGit(repoPath: string, args: string[]): Promise<GitResult> { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', repoPath, ...args], { + env : process.env, + stdio : ['ignore', 'pipe', 'pipe'], + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + resolve({ + status : code ?? 128, + stdout : Buffer.concat(stdout), + stderr : Buffer.concat(stderr), + }); + }); + }); +} + +async function gitText(repoPath: string, args: string[]): Promise<string | null> { + const result = await runGit(repoPath, args); + if (result.status !== 0) { + return null; + } + return result.stdout.toString('utf-8').trim(); +} + +function branchRef(repo: RepoInfo): string { + return `refs/heads/${repo.defaultBranch || 'main'}`; +} + +async function treePaths(repoPath: string, repo: RepoInfo): Promise<Set<string>> { + const out = await gitText(repoPath, ['ls-tree', '-r', '--name-only', branchRef(repo)]); + if (!out) { + return new Set(); + } + return new Set(out.split('\n').map(path => path.trim()).filter(Boolean)); +} + +async function readCommitMetrics(repoPath: string, repo: RepoInfo): Promise<CommitMetric[]> { + const out = await gitText(repoPath, [ + 'log', + '--no-merges', + '--pretty=format:%x1e%H%x1f%an%x1f%ae%x1f%at', + '--numstat', + branchRef(repo), + ]); + if (!out) { + return []; + } + + const commits: CommitMetric[] = []; + for (const chunk of out.split('\x1e')) { + const lines = chunk.split('\n').map(line => line.trim()).filter(Boolean); + const header = lines.shift(); + if (!header) { + continue; + } + + const [sha, name, email, timestampRaw] = header.split('\x1f'); + const timestamp = parseInt(timestampRaw ?? '', 10); + if (!sha || !name || !Number.isFinite(timestamp)) { + continue; + } + + let additions = 0; + let deletions = 0; + for (const line of lines) { + const [addedRaw, deletedRaw] = line.split('\t'); + additions += statNumber(addedRaw); + deletions += statNumber(deletedRaw); + } + + commits.push({ + sha, + name, + email: email ?? '', + timestamp, + additions, + deletions, + }); + } + + return commits; +} + +function statNumber(value: string | undefined): number { + if (!value || value === '-') { + return 0; + } + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +// --------------------------------------------------------------------------- +// Time buckets +// --------------------------------------------------------------------------- + +function startOfUtcDay(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) / 1000; +} + +function startOfStatsWeek(timestamp: number): number { + const date = new Date(timestamp * 1000); + const dayStart = startOfUtcDay(date); + return dayStart - (date.getUTCDay() * 86_400); +} + +function last52WeekStarts(): number[] { + const currentWeek = startOfStatsWeek(Math.floor(Date.now() / 1000)); + const starts: number[] = []; + for (let i = 51; i >= 0; i--) { + starts.push(currentWeek - (i * 7 * 86_400)); + } + return starts; +} + +function buildWeekBuckets(commits: CommitMetric[]): Map<number, WeekBucket> { + const buckets = new Map<number, WeekBucket>(); + for (const commit of commits) { + const week = startOfStatsWeek(commit.timestamp); + const day = new Date(commit.timestamp * 1000).getUTCDay(); + const bucket = buckets.get(week) ?? { + additions : 0, + deletions : 0, + commits : 0, + days : [0, 0, 0, 0, 0, 0, 0], + }; + bucket.additions += commit.additions; + bucket.deletions += commit.deletions; + bucket.commits += 1; + bucket.days[day] += 1; + buckets.set(week, bucket); + } + return buckets; +} + +// --------------------------------------------------------------------------- +// Shared response helpers +// --------------------------------------------------------------------------- + +async function getRepoOr404(ctx: AgentContext, targetDid: string, repoName: string): Promise<RepoInfo | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + return repo; +} + +async function commitMetricsForRepo( + ctx: AgentContext, targetDid: string, repo: RepoInfo, options: MetricsOptions, +): Promise<CommitMetric[]> { + const local = localRepo(ctx, targetDid, repo.name, options); + if (!local) { + return []; + } + return readCommitMetrics(local.repoPath, repo); +} + +function isResponse(value: RepoInfo | JsonResponse): value is JsonResponse { + return typeof (value as JsonResponse).status === 'number'; +} + +function slug(value: string): string { + const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized || 'user'; +} + +function authorLogin(commit: CommitMetric): string { + const basis = commit.name || commit.email.split('@')[0] || commit.sha; + return `${slug(basis)}-${numericId(commit.email || commit.name || commit.sha).toString(16)}`; +} + +function buildAuthor(commit: CommitMetric, baseUrl: string): Record<string, unknown> { + const login = authorLogin(commit); + const encoded = encodeURIComponent(login); + return { + login, + id : numericId(`author:${commit.email || commit.name}`), + node_id : Buffer.from(`author:${commit.email || commit.name}`, 'utf-8').toString('base64'), + avatar_url : '', + gravatar_id : '', + url : `${baseUrl}/users/${encoded}`, + html_url : `${baseUrl}/users/${encoded}`, + followers_url : `${baseUrl}/users/${encoded}/followers`, + following_url : `${baseUrl}/users/${encoded}/following{/other_user}`, + gists_url : `${baseUrl}/users/${encoded}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${encoded}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${encoded}/subscriptions`, + organizations_url : `${baseUrl}/users/${encoded}/orgs`, + repos_url : `${baseUrl}/users/${encoded}/repos`, + events_url : `${baseUrl}/users/${encoded}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${encoded}/received_events`, + type : 'User', + site_admin : false, + }; +} + +// --------------------------------------------------------------------------- +// Community profile +// --------------------------------------------------------------------------- + +async function hasTextRecord( + ctx: AgentContext, targetDid: string, repo: RepoInfo, protocolPath: 'repo/readme' | 'repo/license', +): Promise<boolean> { + const { records } = await ctx.repo.records.query(protocolPath as any, { + from : fromOpt(ctx, targetDid), + filter : { contextId: repo.contextId }, + }); + return records.length > 0; +} + +function matchPath(paths: Set<string>, candidates: string[]): string | null { + const byLower = new Map<string, string>(); + for (const path of paths) { + byLower.set(path.toLowerCase(), path); + } + for (const candidate of candidates) { + const found = byLower.get(candidate.toLowerCase()); + if (found) { + return found; + } + } + return null; +} + +function contentFile(targetDid: string, repoName: string, baseUrl: string, path: string): Record<string, unknown> { + const encodedPath = path.split('/').map(encodeURIComponent).join('/'); + const fullName = `${targetDid}/${repoName}`; + return { + url : `${baseUrl}/repos/${fullName}/contents/${encodedPath}`, + html_url : `${baseUrl}/repos/${fullName}/blob/HEAD/${encodedPath}`, + }; +} + +function licenseFile(targetDid: string, repoName: string, baseUrl: string, path: string): Record<string, unknown> { + return { + name : 'License', + key : 'other', + spdx_id : 'NOASSERTION', + url : `${baseUrl}/licenses/other`, + html_url : contentFile(targetDid, repoName, baseUrl, path).html_url, + node_id : Buffer.from(`license:${targetDid}/${repoName}`, 'utf-8').toString('base64'), + }; +} + +function codeOfConduct(path: string, targetDid: string, repoName: string, baseUrl: string): Record<string, unknown> { + return { + name : 'Code of Conduct', + key : 'other', + url : `${baseUrl}/codes_of_conduct/other`, + html_url : contentFile(targetDid, repoName, baseUrl, path).html_url, + }; +} + +function healthPercentage(repo: RepoInfo, files: CommunityFiles): number { + const checks = [ + Boolean(repo.description), + Boolean(files.readme), + Boolean(files.license), + Boolean(files.code_of_conduct_file), + Boolean(files.contributing), + Boolean(files.issue_template), + Boolean(files.pull_request_template), + ]; + const present = checks.filter(Boolean).length; + return Math.round((present / checks.length) * 100); +} + +export async function handleGetCommunityProfile( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const local = localRepo(ctx, targetDid, repo.name, options); + const paths = local ? await treePaths(local.repoPath, repo) : new Set<string>(); + const baseUrl = buildApiUrl(url); + const readmePath = matchPath(paths, ['README.md', 'README', 'README.txt', 'README.markdown']) + ?? (await hasTextRecord(ctx, targetDid, repo, 'repo/readme') ? 'README.md' : null); + const licensePath = matchPath(paths, ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING']) + ?? (await hasTextRecord(ctx, targetDid, repo, 'repo/license') ? 'LICENSE' : null); + const codePath = matchPath(paths, ['CODE_OF_CONDUCT.md', 'CODE_OF_CONDUCT', '.github/CODE_OF_CONDUCT.md']); + const contributingPath = matchPath(paths, ['CONTRIBUTING.md', 'CONTRIBUTING', '.github/CONTRIBUTING.md']); + const issueTemplatePath = matchPath(paths, ['ISSUE_TEMPLATE.md', 'ISSUE_TEMPLATE', '.github/ISSUE_TEMPLATE.md']); + const prTemplatePath = matchPath(paths, ['PULL_REQUEST_TEMPLATE.md', 'PULL_REQUEST_TEMPLATE', '.github/PULL_REQUEST_TEMPLATE.md']); + + const files: CommunityFiles = { + code_of_conduct : codePath ? codeOfConduct(codePath, targetDid, repo.name, baseUrl) : null, + code_of_conduct_file : codePath ? contentFile(targetDid, repo.name, baseUrl, codePath) : null, + contributing : contributingPath ? contentFile(targetDid, repo.name, baseUrl, contributingPath) : null, + issue_template : issueTemplatePath ? contentFile(targetDid, repo.name, baseUrl, issueTemplatePath) : null, + pull_request_template : prTemplatePath ? contentFile(targetDid, repo.name, baseUrl, prTemplatePath) : null, + license : licensePath ? licenseFile(targetDid, repo.name, baseUrl, licensePath) : null, + readme : readmePath ? contentFile(targetDid, repo.name, baseUrl, readmePath) : null, + }; + + return jsonOk({ + health_percentage : healthPercentage(repo, files), + description : repo.description || null, + documentation : null, + files, + updated_at : repo.timestamp, + }); +} + +// --------------------------------------------------------------------------- +// Repository traffic +// --------------------------------------------------------------------------- + +function validateTrafficPeriod(url: URL): JsonResponse | 'day' | 'week' { + const per = url.searchParams.get('per') ?? 'day'; + if (per !== 'day' && per !== 'week') { + return jsonValidationError('Validation Failed: per must be day or week.'); + } + return per; +} + +export async function handleGetTrafficClones( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + const period = validateTrafficPeriod(url); + if (typeof period !== 'string') { + return period; + } + return jsonOk({ count: 0, uniques: 0, clones: [] }); +} + +export async function handleGetTrafficViews( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + const period = validateTrafficPeriod(url); + if (typeof period !== 'string') { + return period; + } + return jsonOk({ count: 0, uniques: 0, views: [] }); +} + +export async function handleGetTrafficPopularPaths( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + return jsonOk([]); +} + +export async function handleGetTrafficPopularReferrers( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + return jsonOk([]); +} + +// --------------------------------------------------------------------------- +// Repository statistics +// --------------------------------------------------------------------------- + +export async function handleGetStatsCodeFrequency( + ctx: AgentContext, targetDid: string, repoName: string, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const commits = await commitMetricsForRepo(ctx, targetDid, repo, options); + const weeks = [...buildWeekBuckets(commits).entries()] + .sort(([a], [b]) => a - b) + .map(([week, bucket]) => [week, bucket.additions, -bucket.deletions]); + return jsonOk(weeks); +} + +export async function handleGetStatsCommitActivity( + ctx: AgentContext, targetDid: string, repoName: string, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const commits = await commitMetricsForRepo(ctx, targetDid, repo, options); + const weeks = [...buildWeekBuckets(commits).entries()] + .sort(([a], [b]) => a - b) + .map(([week, bucket]) => ({ + days : bucket.days, + total : bucket.commits, + week, + })); + return jsonOk(weeks); +} + +export async function handleGetStatsContributors( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const commits = await commitMetricsForRepo(ctx, targetDid, repo, options); + const byAuthor = new Map<string, { author: CommitMetric; total: number; weeks: Map<number, WeekBucket> }>(); + for (const commit of commits) { + const key = commit.email || commit.name; + const entry = byAuthor.get(key) ?? { author: commit, total: 0, weeks: new Map<number, WeekBucket>() }; + entry.total += 1; + const week = startOfStatsWeek(commit.timestamp); + const bucket = entry.weeks.get(week) ?? { + additions : 0, + deletions : 0, + commits : 0, + days : [0, 0, 0, 0, 0, 0, 0], + }; + bucket.additions += commit.additions; + bucket.deletions += commit.deletions; + bucket.commits += 1; + entry.weeks.set(week, bucket); + byAuthor.set(key, entry); + } + + const baseUrl = buildApiUrl(url); + const contributors = [...byAuthor.values()] + .sort((a, b) => b.total - a.total || a.author.name.localeCompare(b.author.name)) + .map(entry => ({ + author : buildAuthor(entry.author, baseUrl), + total : entry.total, + weeks : [...entry.weeks.entries()] + .sort(([a], [b]) => a - b) + .map(([week, bucket]) => ({ + w : week, + a : bucket.additions, + d : bucket.deletions, + c : bucket.commits, + })), + })); + return jsonOk(contributors); +} + +export async function handleGetStatsParticipation( + ctx: AgentContext, targetDid: string, repoName: string, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const commits = await commitMetricsForRepo(ctx, targetDid, repo, options); + const weeks = last52WeekStarts(); + const indexByWeek = new Map(weeks.map((week, index) => [week, index])); + const all = Array(52).fill(0); + const owner = Array(52).fill(0); + for (const commit of commits) { + const index = indexByWeek.get(startOfStatsWeek(commit.timestamp)); + if (index === undefined) { + continue; + } + all[index] += 1; + if (commit.email === targetDid || commit.name === targetDid) { + owner[index] += 1; + } + } + return jsonOk({ all, owner }); +} + +export async function handleGetStatsPunchCard( + ctx: AgentContext, targetDid: string, repoName: string, options: MetricsOptions, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if (isResponse(repo)) { + return repo; + } + + const commits = await commitMetricsForRepo(ctx, targetDid, repo, options); + const counts = new Map<string, number>(); + for (const commit of commits) { + const date = new Date(commit.timestamp * 1000); + const key = `${date.getUTCDay()}:${date.getUTCHours()}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const data: number[][] = []; + for (let day = 0; day < 7; day++) { + for (let hour = 0; hour < 24; hour++) { + data.push([day, hour, counts.get(`${day}:${hour}`) ?? 0]); + } + } + return jsonOk(data); +} diff --git a/src/github-shim/notifications.ts b/src/github-shim/notifications.ts new file mode 100644 index 0000000..cb24107 --- /dev/null +++ b/src/github-shim/notifications.ts @@ -0,0 +1,575 @@ +/** + * GitHub API shim - notification endpoints. + * + * Maps private forge-notifications inbox records to GitHub REST API v3 + * notification thread responses. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { buildRepoResponse } from './repos.js'; + +import { + baseHeaders, + buildApiUrl, + buildLinkHeader, + fromOpt, + getRepoRecord, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type NotificationData = { + title : string; + body? : string; + url? : string; +}; + +type NotificationTags = Record<string, unknown> & { + type? : string; + read? : boolean | string; + repoDid? : string; + repoRecordId? : string; + repoName? : string; + sourceRecordId? : string; + reason? : string; + subjectType? : string; + lastReadAt? : string; + ignored? : boolean | string; + subscribed? : boolean | string; + participating? : boolean | string; +}; + +type NotificationEntry = { + record : any; + data : NotificationData; + tags : NotificationTags; +}; + +type RepoLookup = { + repo : RepoInfo; + repoName : string; + repoDid : string; +}; + +function jsonResetContent(): JsonResponse { + return { + status : 205, + headers : baseHeaders(), + body : '', + }; +} + +function normalizeBool(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') { return value; } + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') { return true; } + if (value.toLowerCase() === 'false') { return false; } + } + return fallback; +} + +function parseBoolQuery(url: URL, name: string, fallback: boolean): boolean { + const value = url.searchParams.get(name); + if (value === null) { return fallback; } + return value === 'true' || value === '1'; +} + +function parseDate(value: string | null, fieldName: string): Date | JsonResponse | null { + if (!value) { return null; } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return jsonValidationError(`Validation Failed: ${fieldName} must be an ISO 8601 timestamp.`); + } + return date; +} + +function notificationThreadId(record: any): string { + return String(numericId(record.id ?? '')); +} + +function notificationType(entry: NotificationEntry): string { + return typeof entry.tags.type === 'string' ? entry.tags.type : 'mention'; +} + +function notificationIsRead(entry: NotificationEntry): boolean { + return normalizeBool(entry.tags.read, false); +} + +function notificationUpdatedAt(entry: NotificationEntry): string { + return toISODate(entry.record.timestamp ?? entry.record.dateCreated); +} + +function notificationReason(entry: NotificationEntry): string { + if (typeof entry.tags.reason === 'string' && entry.tags.reason) { + return entry.tags.reason; + } + + switch (notificationType(entry)) { + case 'assignment': return 'assign'; + case 'ci_failure': return 'ci_activity'; + case 'issue_comment': return 'comment'; + case 'mention': return 'mention'; + case 'patch_merged': return 'state_change'; + case 'review': return 'comment'; + case 'review_request': return 'review_requested'; + default: return 'subscribed'; + } +} + +function notificationSubjectType(entry: NotificationEntry): string { + if (typeof entry.tags.subjectType === 'string' && entry.tags.subjectType) { + return entry.tags.subjectType; + } + + switch (notificationType(entry)) { + case 'assignment': + case 'issue_comment': + return 'Issue'; + case 'ci_failure': + return 'CheckSuite'; + case 'patch_merged': + case 'review': + case 'review_request': + return 'PullRequest'; + default: + return 'Repository'; + } +} + +function normalizeNotificationData(value: Record<string, unknown>): NotificationData { + return { + title : typeof value.title === 'string' ? value.title : '', + body : typeof value.body === 'string' ? value.body : undefined, + url : typeof value.url === 'string' ? value.url : undefined, + }; +} + +async function listNotificationEntries(ctx: AgentContext): Promise<NotificationEntry[]> { + const { records } = await ctx.notifications.records.query('notification', { + dateSort: DateSort.CreatedDescending, + }); + + const entries: NotificationEntry[] = []; + for (const record of records) { + const data = normalizeNotificationData(await record.data.json()); + const tags = (record.tags as NotificationTags | undefined) ?? {}; + entries.push({ record, data, tags }); + } + + entries.sort((a, b) => notificationUpdatedAt(b).localeCompare(notificationUpdatedAt(a))); + return entries; +} + +async function findNotificationEntry(ctx: AgentContext, threadId: string): Promise<NotificationEntry | null> { + const entries = await listNotificationEntries(ctx); + return entries.find(entry => notificationThreadId(entry.record) === threadId) ?? null; +} + +function repoInfoFromRecord(record: any, data: Record<string, unknown>, tags: Record<string, unknown>): RepoInfo { + return { + name : typeof data.name === 'string' ? data.name : 'unnamed', + description : typeof data.description === 'string' ? data.description : '', + defaultBranch : typeof data.defaultBranch === 'string' ? data.defaultBranch : 'main', + homepage : typeof data.homepage === 'string' ? data.homepage : '', + contextId : record.contextId ?? '', + visibility : typeof tags.visibility === 'string' ? tags.visibility : 'public', + language : typeof tags.language === 'string' ? tags.language : '', + archived : tags.archived === true || tags.archived === 'true', + hasIssues : data.hasIssues !== false, + hasProjects : data.hasProjects === true, + hasWiki : data.hasWiki !== false, + hasDownloads : data.hasDownloads !== false, + hasPullRequests : data.hasPullRequests !== false, + isTemplate : data.isTemplate === true, + allowSquashMerge : data.allowSquashMerge !== false, + allowMergeCommit : data.allowMergeCommit !== false, + allowRebaseMerge : data.allowRebaseMerge !== false, + allowAutoMerge : data.allowAutoMerge === true, + allowForking : data.allowForking !== false, + deleteBranchOnMerge : data.deleteBranchOnMerge === true, + webCommitSignoffRequired : data.webCommitSignoffRequired === true, + pullRequestCreationPolicy : data.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' : 'all', + dateCreated : record.dateCreated, + timestamp : record.timestamp, + forkedFromDid : typeof data.forkedFromDid === 'string' ? data.forkedFromDid : undefined, + forkedFromRepoName : typeof data.forkedFromRepoName === 'string' ? data.forkedFromRepoName : undefined, + forkedFromRecordId : typeof data.forkedFromRecordId === 'string' ? data.forkedFromRecordId : undefined, + }; +} + +async function findRepoByRecordId( + ctx: AgentContext, repoDid: string, repoRecordId: string, +): Promise<RepoLookup | null> { + const { records } = await ctx.repo.records.query('repo', { from: fromOpt(ctx, repoDid) }); + const record = records.find(item => item.id === repoRecordId); + if (!record) { return null; } + + const data = await record.data.json(); + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const repo = repoInfoFromRecord(record, data, tags); + return { repo, repoName: repo.name, repoDid }; +} + +function fallbackRepoLookup(entry: NotificationEntry, ctx: AgentContext): RepoLookup { + const repoDid = typeof entry.tags.repoDid === 'string' ? entry.tags.repoDid : ctx.did; + const repoName = typeof entry.tags.repoName === 'string' ? entry.tags.repoName : 'unknown'; + const timestamp = notificationUpdatedAt(entry); + return { + repoDid, + repoName, + repo: { + name : repoName, + description : '', + defaultBranch : 'main', + homepage : '', + contextId : `${repoDid}/${repoName}`, + visibility : 'public', + language : '', + archived : false, + hasIssues : true, + hasProjects : false, + hasWiki : true, + hasDownloads : true, + hasPullRequests : true, + isTemplate : false, + allowSquashMerge : true, + allowMergeCommit : true, + allowRebaseMerge : true, + allowAutoMerge : false, + allowForking : true, + deleteBranchOnMerge : false, + webCommitSignoffRequired : false, + pullRequestCreationPolicy : 'all', + dateCreated : entry.record.dateCreated ?? timestamp, + timestamp, + }, + }; +} + +async function repoLookupForNotification(ctx: AgentContext, entry: NotificationEntry): Promise<RepoLookup> { + if (typeof entry.tags.repoDid === 'string' && typeof entry.tags.repoRecordId === 'string') { + const lookup = await findRepoByRecordId(ctx, entry.tags.repoDid, entry.tags.repoRecordId); + if (lookup) { return lookup; } + } + return fallbackRepoLookup(entry, ctx); +} + +async function notificationMatchesRepo( + ctx: AgentContext, entry: NotificationEntry, targetDid: string, repoName: string, +): Promise<boolean> { + if (entry.tags.repoDid !== targetDid) { return false; } + + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { return false; } + if (typeof entry.tags.repoRecordId !== 'string') { + return typeof entry.tags.repoName === 'string' && entry.tags.repoName === repo.name; + } + + const lookup = await findRepoByRecordId(ctx, targetDid, entry.tags.repoRecordId); + return lookup?.repoName === repo.name; +} + +function filterNotificationEntries(entries: NotificationEntry[], url: URL): NotificationEntry[] | JsonResponse { + const all = parseBoolQuery(url, 'all', false); + const participating = parseBoolQuery(url, 'participating', false); + const since = parseDate(url.searchParams.get('since'), 'since'); + if (since && 'status' in since) { return since; } + const before = parseDate(url.searchParams.get('before'), 'before'); + if (before && 'status' in before) { return before; } + + return entries.filter((entry) => { + const updated = new Date(notificationUpdatedAt(entry)); + if (!all && notificationIsRead(entry)) { return false; } + if (participating && !normalizeBool(entry.tags.participating, true)) { return false; } + if (since && updated <= since) { return false; } + if (before && updated >= before) { return false; } + return true; + }); +} + +async function buildNotificationResponse( + ctx: AgentContext, entry: NotificationEntry, baseUrl: string, +): Promise<Record<string, unknown>> { + const threadId = notificationThreadId(entry.record); + const repo = await repoLookupForNotification(ctx, entry); + const read = notificationIsRead(entry); + const updatedAt = notificationUpdatedAt(entry); + const lastReadAt = typeof entry.tags.lastReadAt === 'string' + ? toISODate(entry.tags.lastReadAt) + : read ? updatedAt : null; + + return { + id : threadId, + repository : buildRepoResponse(repo.repo, repo.repoDid, repo.repoName, baseUrl), + subject : { + title : entry.data.title, + url : entry.data.url ?? null, + latest_comment_url : entry.data.url ?? null, + type : notificationSubjectType(entry), + }, + reason : notificationReason(entry), + unread : !read, + updated_at : updatedAt, + last_read_at : lastReadAt, + url : `${baseUrl}/notifications/threads/${threadId}`, + subscription_url : `${baseUrl}/notifications/threads/${threadId}/subscription`, + }; +} + +async function markNotification( + entry: NotificationEntry, read: boolean, extraTags: NotificationTags = {}, +): Promise<JsonResponse | null> { + const now = new Date().toISOString(); + const tags: NotificationTags = { + ...entry.tags, + ...extraTags, + read, + ...(read ? { lastReadAt: now } : {}), + }; + const { status } = await entry.record.update({ data: entry.data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update notification: ${status.detail}`); + } + entry.tags = tags; + return null; +} + +async function markEntriesRead(entries: NotificationEntry[], reqBody: Record<string, unknown>): Promise<JsonResponse> { + if (reqBody.read !== undefined && typeof reqBody.read !== 'boolean') { + return jsonValidationError('Validation Failed: read must be a boolean.'); + } + + const read = reqBody.read === false ? false : true; + const lastReadAt = parseDate(typeof reqBody.last_read_at === 'string' ? reqBody.last_read_at : null, 'last_read_at'); + if (lastReadAt && 'status' in lastReadAt) { return lastReadAt; } + + for (const entry of entries) { + const updated = new Date(notificationUpdatedAt(entry)); + if (lastReadAt && updated > lastReadAt) { continue; } + const error = await markNotification(entry, read); + if (error) { return error; } + } + + return jsonResetContent(); +} + +function buildThreadSubscription(entry: NotificationEntry, baseUrl: string): Record<string, unknown> { + const threadId = notificationThreadId(entry.record); + const ignored = normalizeBool(entry.tags.ignored, false); + const subscribed = normalizeBool(entry.tags.subscribed, !ignored); + return { + subscribed, + ignored, + reason : null, + created_at : toISODate(entry.record.dateCreated), + url : `${baseUrl}/notifications/threads/${threadId}/subscription`, + thread_url : `${baseUrl}/notifications/threads/${threadId}`, + }; +} + +// --------------------------------------------------------------------------- +// /notifications +// --------------------------------------------------------------------------- + +export async function handleListNotifications(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = filterNotificationEntries(await listNotificationEntries(ctx), url); + if ('status' in entries) { return entries; } + + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const baseUrl = buildApiUrl(url); + const items = []; + for (const entry of paged) { + items.push(await buildNotificationResponse(ctx, entry, baseUrl)); + } + + const linkHeader = buildLinkHeader(baseUrl, '/notifications', pagination.page, pagination.perPage, entries.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleMarkNotificationsRead( + ctx: AgentContext, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + return markEntriesRead(await listNotificationEntries(ctx), reqBody); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/notifications +// --------------------------------------------------------------------------- + +export async function handleListRepoNotifications( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const allEntries = await listNotificationEntries(ctx); + const repoEntries: NotificationEntry[] = []; + for (const entry of allEntries) { + if (await notificationMatchesRepo(ctx, entry, targetDid, repo.name)) { + repoEntries.push(entry); + } + } + + const filtered = filterNotificationEntries(repoEntries, url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const items = []; + for (const entry of paged) { + items.push(await buildNotificationResponse(ctx, entry, baseUrl)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repo.name}/notifications`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleMarkRepoNotificationsRead( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const allEntries = await listNotificationEntries(ctx); + const repoEntries: NotificationEntry[] = []; + for (const entry of allEntries) { + if (await notificationMatchesRepo(ctx, entry, targetDid, repo.name)) { + repoEntries.push(entry); + } + } + + return markEntriesRead(repoEntries, reqBody); +} + +// --------------------------------------------------------------------------- +// /notifications/threads/:thread_id +// --------------------------------------------------------------------------- + +export async function handleGetNotificationThread( + ctx: AgentContext, threadId: string, url: URL, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + + return jsonOk(await buildNotificationResponse(ctx, entry, buildApiUrl(url))); +} + +export async function handleMarkNotificationThreadRead( + ctx: AgentContext, threadId: string, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + + const error = await markNotification(entry, true); + if (error) { return error; } + return jsonResetContent(); +} + +export async function handleDeleteNotificationThread( + ctx: AgentContext, threadId: string, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete notification thread: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /notifications/threads/:thread_id/subscription +// --------------------------------------------------------------------------- + +export async function handleGetNotificationThreadSubscription( + ctx: AgentContext, threadId: string, url: URL, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + + return jsonOk(buildThreadSubscription(entry, buildApiUrl(url))); +} + +export async function handleSetNotificationThreadSubscription( + ctx: AgentContext, threadId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + if (reqBody.ignored !== undefined && typeof reqBody.ignored !== 'boolean') { + return jsonValidationError('Validation Failed: ignored must be a boolean.'); + } + + const ignored = reqBody.ignored === true; + const tags: NotificationTags = { + ...entry.tags, + ignored, + subscribed: !ignored, + }; + const { status } = await entry.record.update({ data: entry.data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update notification subscription: ${status.detail}`); + } + entry.tags = tags; + + return jsonOk(buildThreadSubscription(entry, buildApiUrl(url))); +} + +export async function handleDeleteNotificationThreadSubscription( + ctx: AgentContext, threadId: string, +): Promise<JsonResponse> { + const entry = await findNotificationEntry(ctx, threadId); + if (!entry) { + return jsonNotFound(`Notification thread '${threadId}' not found.`); + } + + const tags: NotificationTags = { + ...entry.tags, + ignored : false, + subscribed : false, + }; + const { status } = await entry.record.update({ data: entry.data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete notification subscription: ${status.detail}`); + } + + return jsonNoContent(); +} diff --git a/src/github-shim/orgs.ts b/src/github-shim/orgs.ts new file mode 100644 index 0000000..964b434 --- /dev/null +++ b/src/github-shim/orgs.ts @@ -0,0 +1,3590 @@ +/** + * GitHub API shim - organization and team endpoints. + * + * Maps forge-org records onto GitHub REST API v3 organization, membership, + * and team routes. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { OrgBlockedUserData } from '../org.js'; +import type { OrgCustomPropertyData } from '../org.js'; +import type { OrgData } from '../org.js'; +import type { OrgIssueFieldData } from '../org.js'; +import type { OrgIssueFieldOptionData } from '../org.js'; +import type { OrgIssueTypeData } from '../org.js'; +import type { OrgMemberData } from '../org.js'; +import type { RepoCreateOptions } from './repos.js'; +import type { RepositoryCustomPropertyValue } from '../repo.js'; +import type { SettingsData } from '../repo.js'; +import type { TeamData } from '../org.js'; +import type { TeamMemberData } from '../org.js'; +import type { CodeScanningAlertEntry, DependabotAlertEntry, SecretScanningAlertEntry, SecurityAdvisoryEntry } from './repo-metadata.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildCodeScanningAlertResponse, + buildDependabotAlertResponse, + buildSecretScanningAlertResponse, + buildSecurityAdvisoryResponse, + codeScanningAlertEntries, + dependabotAlertEntries, + filterCodeScanningAlerts, + filterDependabotAlerts, + filterSecretScanningAlerts, + filterSecurityAdvisories, + parseBooleanQuery, + secretScanningAlertEntries, + securityAdvisoryEntries, +} from './repo-metadata.js'; +import { buildRepoResponse, createRepoForOwner, handleListRepos, listRepoEntries, repoInfoFromRecord } from './repos.js'; + +import { + baseHeaders, + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonAccepted, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type OrgEntry = { + record : any; + data : OrgData; + slug : string; + routeLogin : string; +}; + +type OrgMemberRole = 'admin' | 'member'; + +type OrgMemberEntry = { + record : any; + data : OrgMemberData; + role : OrgMemberRole; +}; + +type OrgBlockedUserEntry = { + record : any; + data : OrgBlockedUserData; +}; + +type TeamEntry = { + record : any; + data : TeamData; + slug : string; +}; + +type TeamMemberEntry = { + record : any; + data : TeamMemberData; +}; + +type TeamMemberRole = NonNullable<TeamMemberData['role']>; +type TeamMemberRoleFilter = TeamMemberRole | 'all'; +type TeamMemberState = NonNullable<TeamMemberData['state']>; +type OrgInvitationRoleFilter = 'all' | 'admin' | 'direct_member' | 'billing_manager' | 'hiring_manager'; +type OrgInvitationSourceFilter = 'all' | 'member' | 'scim'; + +type TeamRepositoryPermission = NonNullable<TeamData['repositories']>[string]['permission']; + +type TeamRepositoryGrant = { + owner : string; + repo : string; + permission : TeamRepositoryPermission; +}; + +type OrgInvitationEntry = { + did : string; + members : TeamMemberEntry[]; + teams : TeamEntry[]; +}; + +type OutsideCollaboratorEntry = { + did : string; + alias : string; + recordId : string; +}; + +type TeamLookup = { + org : OrgEntry; + team : TeamEntry; +}; + +type OrgRepoEntry = { + record : any; + repo : RepoInfo; + name : string; +}; + +type OrgSecurityAdvisoryItem = { + advisory : SecurityAdvisoryEntry; + repo : RepoInfo; +}; + +type OrgCodeScanningAlertItem = { + alert : CodeScanningAlertEntry; + repo : RepoInfo; +}; + +type OrgDependabotAlertItem = { + alert : DependabotAlertEntry; + repo : RepoInfo; +}; + +type OrgSecretScanningAlertItem = { + alert : SecretScanningAlertEntry; + repo : RepoInfo; +}; + +type OrgIssueFieldEntry = { + record : any; + data : OrgIssueFieldData; +}; + +type OrgIssueTypeEntry = { + record : any; + data : OrgIssueTypeData; +}; + +type OrgCustomPropertyEntry = { + record : any; + data : OrgCustomPropertyData; +}; + +type RepoSettingsEntry = { + entry : OrgRepoEntry; + record? : { + update : (options: { data: SettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : SettingsData; +}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const REPO_COLLABORATOR_TYPES = ['repo/maintainer', 'repo/contributor', 'repo/triager', 'repo/viewer'] as const; +const ISSUE_FIELD_DATA_TYPES = ['text', 'date', 'single_select', 'multi_select', 'number'] as const; +const ISSUE_FIELD_OPTION_COLORS = ['gray', 'blue', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'] as const; +const ISSUE_TYPE_COLORS = ['gray', 'blue', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'] as const; +const CUSTOM_PROPERTY_VALUE_TYPES = ['string', 'single_select', 'multi_select', 'true_false', 'url'] as const; +const CUSTOM_PROPERTY_VALUES_EDITABLE_BY = ['org_actors', 'org_and_repo_actors'] as const; + +type IssueFieldDataType = typeof ISSUE_FIELD_DATA_TYPES[number]; +type IssueFieldOptionColor = typeof ISSUE_FIELD_OPTION_COLORS[number]; +type IssueFieldVisibility = NonNullable<OrgIssueFieldData['visibility']>; +type IssueTypeColor = typeof ISSUE_TYPE_COLORS[number]; +type CustomPropertyValueType = typeof CUSTOM_PROPERTY_VALUE_TYPES[number]; +type CustomPropertyValuesEditableBy = typeof CUSTOM_PROPERTY_VALUES_EDITABLE_BY[number]; + +function jsonForbidden(message: string): JsonResponse { + return { + status : 403, + headers : baseHeaders(), + body : JSON.stringify({ + message, + documentation_url: 'https://docs.github.com/rest', + }), + }; +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || value.trim().toLowerCase(); +} + +function normalizeOrgRoute(value: string): string { + return decodeRouteParam(value).trim(); +} + +function orgMatchesRoute(org: OrgEntry, routeOrg: string, ctxDid: string): boolean { + const route = normalizeOrgRoute(routeOrg).toLowerCase(); + if (route === ctxDid.toLowerCase()) { return true; } + if (route === org.data.name.toLowerCase()) { return true; } + return route === org.slug.toLowerCase(); +} + +function routeOrgPath(org: OrgEntry): string { + return encodeURIComponent(org.routeLogin); +} + +function parseNumericRouteId(value: string): number | null { + const id = Number(decodeRouteParam(value)); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +function orgNumericId(org: OrgEntry): number { + return numericId(org.record.contextId ?? org.record.id); +} + +function teamNumericId(team: TeamEntry): number { + return numericId(team.record.contextId ?? team.record.id); +} + +function issueFieldNumericId(field: OrgIssueFieldEntry): number { + return numericId(field.record.contextId ?? field.record.id); +} + +function issueTypeNumericId(issueType: OrgIssueTypeEntry): number { + return numericId(issueType.record.contextId ?? issueType.record.id); +} + +async function listOrgEntries(ctx: AgentContext): Promise<OrgEntry[]> { + const { records } = await ctx.org.records.query('org', { + dateSort: DateSort.CreatedAscending, + } as any); + + const entries: OrgEntry[] = []; + for (const record of records) { + let data: OrgData; + try { + data = await record.data.json(); + } catch { + continue; + } + if (!isNonEmptyString(data.name)) { continue; } + + entries.push({ + record, + data, + slug : slugify(data.name), + routeLogin : data.name, + }); + } + return entries; +} + +async function findOrg(ctx: AgentContext, routeOrg: string): Promise<OrgEntry | null> { + const orgs = await listOrgEntries(ctx); + return orgs.find(org => orgMatchesRoute(org, routeOrg, ctx.did)) ?? null; +} + +async function findOrgByNumericId(ctx: AgentContext, routeOrgId: string): Promise<OrgEntry | null> { + const orgId = parseNumericRouteId(routeOrgId); + if (!orgId) { return null; } + + const orgs = await listOrgEntries(ctx); + return orgs.find(org => orgNumericId(org) === orgId) ?? null; +} + +export async function orgRouteExists(ctx: AgentContext, routeOrg: string): Promise<boolean> { + return (await findOrg(ctx, routeOrg)) !== null; +} + +function buildOrgResponse(org: OrgEntry, baseUrl: string): Record<string, unknown> { + const login = org.routeLogin; + const encodedLogin = routeOrgPath(org); + const avatarUrl = org.data.avatar ?? ''; + + return { + login, + id : numericId(org.record.contextId ?? org.record.id), + node_id : org.record.id, + url : `${baseUrl}/orgs/${encodedLogin}`, + repos_url : `${baseUrl}/orgs/${encodedLogin}/repos`, + events_url : `${baseUrl}/orgs/${encodedLogin}/events`, + hooks_url : `${baseUrl}/orgs/${encodedLogin}/hooks`, + issues_url : `${baseUrl}/orgs/${encodedLogin}/issues`, + members_url : `${baseUrl}/orgs/${encodedLogin}/members{/member}`, + public_members_url : `${baseUrl}/orgs/${encodedLogin}/public_members{/member}`, + avatar_url : avatarUrl, + description : org.data.description ?? null, + name : org.data.name, + company : null, + blog : org.data.homepage ?? '', + location : null, + email : null, + twitter_username : null, + is_verified : false, + has_organization_projects : false, + has_repository_projects : false, + public_repos : 0, + public_gists : 0, + followers : 0, + following : 0, + html_url : `${baseUrl}/orgs/${encodedLogin}`, + type : 'Organization', + created_at : toISODate(org.record.dateCreated), + updated_at : toISODate(org.record.timestamp), + }; +} + +function buildOrgUser(member: OrgMemberEntry | TeamMemberEntry, baseUrl: string): Record<string, unknown> { + const user = buildOwner(member.data.did, baseUrl); + return { + ...user, + node_id : member.record.id, + gravatar_id : '', + followers_url : `${baseUrl}/users/${member.data.did}/followers`, + following_url : `${baseUrl}/users/${member.data.did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${member.data.did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${member.data.did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${member.data.did}/subscriptions`, + organizations_url : `${baseUrl}/users/${member.data.did}/orgs`, + repos_url : `${baseUrl}/users/${member.data.did}/repos`, + events_url : `${baseUrl}/users/${member.data.did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${member.data.did}/received_events`, + site_admin : false, + ...(member.data.alias ? { name: member.data.alias } : {}), + }; +} + +function parseBlockedUserDid(value: string): string | null { + const did = decodeRouteParam(value).trim(); + return did === '' ? null : did; +} + +function blockedUserTags(data: OrgBlockedUserData): Record<string, string> { + return { did: data.did }; +} + +function normalizeBlockedUserData(data: unknown): OrgBlockedUserData | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) { return null; } + const raw = data as Record<string, unknown>; + if (!isNonEmptyString(raw.did)) { return null; } + return { + did : raw.did.trim(), + blockedAt : typeof raw.blockedAt === 'string' ? raw.blockedAt : undefined, + blockedBy : typeof raw.blockedBy === 'string' ? raw.blockedBy : undefined, + }; +} + +function buildBlockedOrgUser(entry: OrgBlockedUserEntry, baseUrl: string): Record<string, unknown> { + const user = buildOwner(entry.data.did, baseUrl); + return { + ...user, + node_id : entry.record.id, + gravatar_id : '', + followers_url : `${baseUrl}/users/${entry.data.did}/followers`, + following_url : `${baseUrl}/users/${entry.data.did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${entry.data.did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${entry.data.did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${entry.data.did}/subscriptions`, + organizations_url : `${baseUrl}/users/${entry.data.did}/orgs`, + repos_url : `${baseUrl}/users/${entry.data.did}/repos`, + events_url : `${baseUrl}/users/${entry.data.did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${entry.data.did}/received_events`, + site_admin : false, + }; +} + +async function listOrgBlockedUsers(ctx: AgentContext, org: OrgEntry): Promise<OrgBlockedUserEntry[]> { + const { records } = await ctx.org.records.query('org/blockedUser' as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const blockedUsers: OrgBlockedUserEntry[] = []; + for (const record of records) { + let data: unknown; + try { + data = await record.data.json(); + } catch { + continue; + } + + const normalized = normalizeBlockedUserData(data); + if (normalized) { + blockedUsers.push({ record, data: normalized }); + } + } + return blockedUsers.sort((a, b) => a.data.did.localeCompare(b.data.did)); +} + +async function findOrgBlockedUser( + ctx: AgentContext, org: OrgEntry, routeUsername: string, +): Promise<OrgBlockedUserEntry | null> { + const did = parseBlockedUserDid(routeUsername); + if (!did) { return null; } + + return (await listOrgBlockedUsers(ctx, org)) + .find(blockedUser => blockedUser.data.did === did) ?? null; +} + +async function listOrgMembers(ctx: AgentContext, org: OrgEntry): Promise<OrgMemberEntry[]> { + const entries = await listOrgMemberRecords(ctx, org); + + const byDid = new Map<string, OrgMemberEntry>(); + for (const entry of entries) { + const existing = byDid.get(entry.data.did); + if (!existing || entry.role === 'admin') { + byDid.set(entry.data.did, entry); + } + } + + return [...byDid.values()]; +} + +async function listOrgMemberRecords(ctx: AgentContext, org: OrgEntry): Promise<OrgMemberEntry[]> { + const entries: OrgMemberEntry[] = []; + + for (const [recordType, role] of [ + ['org/owner', 'admin'], + ['org/member', 'member'], + ] as const) { + const { records } = await ctx.org.records.query(recordType as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + for (const record of records) { + let data: OrgMemberData; + try { + data = await record.data.json(); + } catch { + continue; + } + if (isNonEmptyString(data.did)) { + entries.push({ record, data, role }); + } + } + } + + return entries; +} + +async function findOrgMember(ctx: AgentContext, org: OrgEntry, did: string): Promise<OrgMemberEntry | null> { + const members = await listOrgMembers(ctx, org); + return members.find(member => member.data.did === did) ?? null; +} + +function orgMemberRecordType(role: OrgMemberRole): 'org/owner' | 'org/member' { + return role === 'admin' ? 'org/owner' : 'org/member'; +} + +function orgMembershipRole(value: unknown): OrgMemberRole | null { + if (value === undefined || value === null) { return 'member'; } + if (value === 'admin' || value === 'member') { return value; } + return null; +} + +function orgMemberIsPublic(member: OrgMemberEntry): boolean { + return member.data.public !== false; +} + +async function setOrgMemberPublicVisibility(member: OrgMemberEntry, visible: boolean): Promise<OrgMemberEntry | JsonResponse> { + const nextData: OrgMemberData = { ...member.data, public: visible }; + const { status } = await member.record.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update public organization membership: ${status.detail}`); + } + return { ...member, data: nextData }; +} + +function teamPrivacyFromGitHub(value: unknown): 'visible' | 'secret' | null { + if (value === undefined || value === null) { return 'visible'; } + if (value === 'closed' || value === 'visible') { return 'visible'; } + if (value === 'secret') { return 'secret'; } + return null; +} + +function teamPrivacyToGitHub(value: TeamData['privacy']): 'closed' | 'secret' { + return value === 'secret' ? 'secret' : 'closed'; +} + +function teamRepositoryPermission(value: unknown): TeamRepositoryPermission | null { + if (value === undefined || value === null) { return 'pull'; } + if (value === 'pull' || value === 'triage' || value === 'push' || value === 'maintain' || value === 'admin') { + return value; + } + return null; +} + +function teamMemberRole(value: unknown): TeamMemberRole | null { + if (value === undefined || value === null) { return 'member'; } + if (value === 'member' || value === 'maintainer') { return value; } + return null; +} + +function teamMemberRoleFilter(value: string | null): TeamMemberRoleFilter | null { + if (value === null || value === '' || value === 'all') { return 'all'; } + if (value === 'member' || value === 'maintainer') { return value; } + return null; +} + +function teamMemberEffectiveRole(member: TeamMemberEntry, orgOwnerDids: Set<string>): TeamMemberRole { + if (orgOwnerDids.has(member.data.did)) { return 'maintainer'; } + return teamMemberRole(member.data.role) ?? 'member'; +} + +function teamMemberState(member: TeamMemberEntry): TeamMemberState { + return member.data.state === 'pending' ? 'pending' : 'active'; +} + +function teamMemberIsActive(member: TeamMemberEntry): boolean { + return teamMemberState(member) === 'active'; +} + +function orgOwnerDidSet(members: OrgMemberEntry[]): Set<string> { + return new Set(members.filter(member => member.role === 'admin').map(member => member.data.did)); +} + +function orgMemberDidSet(members: OrgMemberEntry[]): Set<string> { + return new Set(members.map(member => member.data.did)); +} + +function orgInvitationRoleFilter(value: string | null): OrgInvitationRoleFilter | null { + if (value === null || value === '' || value === 'all') { return 'all'; } + if (value === 'admin' || value === 'direct_member' || value === 'billing_manager' || value === 'hiring_manager') { + return value; + } + return null; +} + +function orgInvitationSourceFilter(value: string | null): OrgInvitationSourceFilter | null { + if (value === null || value === '' || value === 'all') { return 'all'; } + if (value === 'member' || value === 'scim') { return value; } + return null; +} + +function outsideCollaboratorFilter(value: string | null): 'all' | '2fa_disabled' | '2fa_insecure' | null { + if (value === null || value === '' || value === 'all') { return 'all'; } + if (value === '2fa_disabled' || value === '2fa_insecure') { return value; } + return null; +} + +function orgInvitationId(org: OrgEntry, did: string): number { + return numericId(`org-invitation:${org.record.contextId ?? org.record.id}:${did}`); +} + +function teamRepoKey(owner: string, repo: string): string { + return `${owner}/${repo}`; +} + +function teamRepositoryGrants(team: TeamEntry): Record<string, TeamRepositoryGrant> { + const grants = team.data.repositories; + if (!grants || typeof grants !== 'object' || Array.isArray(grants)) { return {}; } + + const normalized: Record<string, TeamRepositoryGrant> = {}; + for (const [key, grant] of Object.entries(grants)) { + if (!grant || typeof grant !== 'object' || Array.isArray(grant)) { continue; } + const data = grant as Record<string, unknown>; + const owner = typeof data.owner === 'string' ? data.owner : ''; + const repo = typeof data.repo === 'string' ? data.repo : ''; + const permission = teamRepositoryPermission(data.permission); + if (!owner || !repo || !permission) { continue; } + normalized[key] = { owner, repo, permission }; + } + return normalized; +} + +function teamPermissionBooleans(permission: TeamRepositoryPermission): Record<string, boolean> { + return { + admin : permission === 'admin', + maintain : permission === 'admin' || permission === 'maintain', + push : permission === 'admin' || permission === 'maintain' || permission === 'push', + triage : permission === 'admin' || permission === 'maintain' || permission === 'push' || permission === 'triage', + pull : true, + }; +} + +function teamRepositoryRoleName(permission: TeamRepositoryPermission): string { + if (permission === 'pull') { return 'read'; } + if (permission === 'push') { return 'write'; } + return permission; +} + +function issueFieldDataType(value: unknown): IssueFieldDataType | null { + if (typeof value !== 'string') { return null; } + return (ISSUE_FIELD_DATA_TYPES as readonly string[]).includes(value) ? value as IssueFieldDataType : null; +} + +function issueFieldOptionColor(value: unknown): IssueFieldOptionColor | null { + if (typeof value !== 'string') { return null; } + return (ISSUE_FIELD_OPTION_COLORS as readonly string[]).includes(value) ? value as IssueFieldOptionColor : null; +} + +function issueFieldVisibility(value: unknown): IssueFieldVisibility | null { + if (value === undefined || value === null) { return 'organization_members_only'; } + if (value === 'organization_members_only' || value === 'all') { return value; } + return null; +} + +function isIssueFieldSelectType(dataType: IssueFieldDataType): boolean { + return dataType === 'single_select' || dataType === 'multi_select'; +} + +function issueFieldTags(data: OrgIssueFieldData): Record<string, string> { + return { + name : data.name, + dataType : data.dataType, + }; +} + +function normalizeIssueFieldData(data: unknown): OrgIssueFieldData | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) { return null; } + const raw = data as Record<string, unknown>; + const dataType = issueFieldDataType(raw.dataType); + if (!isNonEmptyString(raw.name) || !dataType) { return null; } + + const visibility = issueFieldVisibility(raw.visibility); + return { + name : raw.name.trim(), + description : typeof raw.description === 'string' || raw.description === null ? raw.description : undefined, + dataType, + ...(visibility ? { visibility } : {}), + ...(Array.isArray(raw.options) ? { options: raw.options as OrgIssueFieldOptionData[] } : {}), + }; +} + +function sortedIssueFieldOptions(options: OrgIssueFieldOptionData[] | undefined): OrgIssueFieldOptionData[] { + return [...(options ?? [])].sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)); +} + +function nextIssueFieldOptionId(options: OrgIssueFieldOptionData[]): number { + let max = 0; + for (const option of options) { + if (Number.isSafeInteger(option.id) && (option.id ?? 0) > max) { + max = option.id ?? 0; + } + } + return max + 1; +} + +function buildIssueFieldOptionResponse( + option: OrgIssueFieldOptionData, index: number, field: OrgIssueFieldEntry, +): Record<string, unknown> { + return { + id : option.id ?? index + 1, + name : option.name, + description : option.description ?? null, + color : option.color, + priority : option.priority, + created_at : toISODate(field.record.dateCreated), + updated_at : toISODate(field.record.timestamp), + }; +} + +function buildIssueFieldResponse(field: OrgIssueFieldEntry): Record<string, unknown> { + const result: Record<string, unknown> = { + id : issueFieldNumericId(field), + node_id : field.record.id, + name : field.data.name, + description : field.data.description ?? null, + data_type : field.data.dataType, + visibility : field.data.visibility ?? 'organization_members_only', + created_at : toISODate(field.record.dateCreated), + updated_at : toISODate(field.record.timestamp), + }; + + if (isIssueFieldSelectType(field.data.dataType)) { + result.options = sortedIssueFieldOptions(field.data.options) + .map((option, index) => buildIssueFieldOptionResponse(option, index, field)); + } + + return result; +} + +function parseIssueFieldDescription(value: unknown): string | null | undefined | JsonResponse { + if (value === undefined) { return undefined; } + if (value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + return value; +} + +function parseIssueFieldOptionDescription(value: unknown): string | null | undefined | JsonResponse { + if (value === undefined) { return undefined; } + if (value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: option description must be a string or null.'); + } + return value; +} + +function parseIssueFieldOptionPriority(value: unknown, fallback: number): number | JsonResponse { + if (value === undefined || value === null) { return fallback; } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return jsonValidationError('Validation Failed: option priority must be a positive integer.'); + } + return value; +} + +function parseIssueFieldOptionId(value: unknown): number | undefined | JsonResponse { + if (value === undefined || value === null) { return undefined; } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return jsonValidationError('Validation Failed: option id must be a positive integer.'); + } + return value; +} + +function parseIssueFieldOptions( + value: unknown, + dataType: IssueFieldDataType, + required: boolean, + existingOptions: OrgIssueFieldOptionData[] = [], +): OrgIssueFieldOptionData[] | undefined | JsonResponse { + if (value === undefined || value === null) { + if (required && isIssueFieldSelectType(dataType)) { + return jsonValidationError('Validation Failed: options are required for single_select and multi_select issue fields.'); + } + return undefined; + } + + if (!isIssueFieldSelectType(dataType)) { + return jsonValidationError('Validation Failed: options are only supported for single_select and multi_select issue fields.'); + } + if (!Array.isArray(value)) { + return jsonValidationError('Validation Failed: options must be an array.'); + } + if (required && value.length === 0) { + return jsonValidationError('Validation Failed: options are required for single_select and multi_select issue fields.'); + } + + const existingById = new Map<number, OrgIssueFieldOptionData>(); + for (const option of existingOptions) { + if (Number.isSafeInteger(option.id)) { + existingById.set(option.id ?? 0, option); + } + } + + const seenIds = new Set<number>(); + let nextId = nextIssueFieldOptionId(existingOptions); + const options: OrgIssueFieldOptionData[] = []; + for (const [index, rawOption] of value.entries()) { + if (!rawOption || typeof rawOption !== 'object' || Array.isArray(rawOption)) { + return jsonValidationError('Validation Failed: each option must be an object.'); + } + + const raw = rawOption as Record<string, unknown>; + if (!isNonEmptyString(raw.name)) { + return jsonValidationError('Validation Failed: option name is required.'); + } + + const color = issueFieldOptionColor(raw.color); + if (!color) { + return jsonValidationError('Validation Failed: option color must be one of gray, blue, green, yellow, orange, red, pink, or purple.'); + } + + const optionId = parseIssueFieldOptionId(raw.id); + if (typeof optionId !== 'number' && optionId !== undefined && 'status' in optionId) { return optionId; } + + let id = optionId; + if (id === undefined) { + while (seenIds.has(nextId) || existingById.has(nextId)) { + nextId += 1; + } + id = nextId; + nextId += 1; + } + if (seenIds.has(id)) { + return jsonValidationError('Validation Failed: option ids must be unique.'); + } + seenIds.add(id); + + const existing = existingById.get(id); + const description = parseIssueFieldOptionDescription(raw.description); + if (description !== undefined && description !== null && typeof description !== 'string' && 'status' in description) { + return description; + } + + const priority = parseIssueFieldOptionPriority(raw.priority, existing?.priority ?? index + 1); + if (typeof priority !== 'number') { return priority; } + + options.push({ + id, + name : raw.name.trim(), + description : description ?? null, + color, + priority, + }); + } + + return options; +} + +async function listOrgIssueFields(ctx: AgentContext, org: OrgEntry): Promise<OrgIssueFieldEntry[]> { + const { records } = await ctx.org.records.query('org/issueField' as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const fields: OrgIssueFieldEntry[] = []; + for (const record of records) { + let data: unknown; + try { + data = await record.data.json(); + } catch { + continue; + } + + const normalized = normalizeIssueFieldData(data); + if (normalized) { + fields.push({ record, data: normalized }); + } + } + return fields; +} + +async function findOrgIssueField(ctx: AgentContext, org: OrgEntry, routeFieldId: string): Promise<OrgIssueFieldEntry | null> { + const fieldId = parseNumericRouteId(routeFieldId); + if (!fieldId) { return null; } + + const fields = await listOrgIssueFields(ctx, org); + return fields.find(field => issueFieldNumericId(field) === fieldId) ?? null; +} + +async function hasDuplicateIssueFieldName( + ctx: AgentContext, org: OrgEntry, name: string, exceptRecordId?: string, +): Promise<boolean> { + const normalized = name.toLowerCase(); + return (await listOrgIssueFields(ctx, org)) + .some(field => field.record.id !== exceptRecordId && field.data.name.toLowerCase() === normalized); +} + +function parseCreateIssueFieldInput(body: Record<string, unknown>): OrgIssueFieldData | JsonResponse { + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name is required.'); + } + + const dataType = issueFieldDataType(body.data_type); + if (!dataType) { + return jsonValidationError('Validation Failed: data_type must be one of text, date, single_select, multi_select, or number.'); + } + + const visibility = issueFieldVisibility(body.visibility); + if (!visibility) { + return jsonValidationError('Validation Failed: visibility must be organization_members_only or all.'); + } + + const description = parseIssueFieldDescription(body.description); + if (description !== undefined && description !== null && typeof description !== 'string' && 'status' in description) { + return description; + } + + const options = parseIssueFieldOptions(body.options, dataType, true); + if (options !== undefined && !Array.isArray(options) && 'status' in options) { return options; } + + const data: OrgIssueFieldData = { + name: body.name.trim(), + ...(description !== undefined ? { description } : {}), + dataType, + visibility, + ...(Array.isArray(options) ? { options } : {}), + }; + return data; +} + +function parseUpdateIssueFieldInput(field: OrgIssueFieldEntry, body: Record<string, unknown>): OrgIssueFieldData | JsonResponse { + const next: OrgIssueFieldData = { ...field.data }; + + if (body.name !== undefined) { + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name must be a non-empty string.'); + } + next.name = body.name.trim(); + } + + if (body.description !== undefined) { + const description = parseIssueFieldDescription(body.description); + if (description !== undefined && description !== null && typeof description !== 'string' && 'status' in description) { + return description; + } + next.description = description; + } + + if (body.visibility !== undefined) { + const visibility = issueFieldVisibility(body.visibility); + if (!visibility) { + return jsonValidationError('Validation Failed: visibility must be organization_members_only or all.'); + } + next.visibility = visibility; + } + + if (body.options !== undefined) { + const options = parseIssueFieldOptions(body.options, field.data.dataType, false, field.data.options ?? []); + if (options !== undefined && !Array.isArray(options) && 'status' in options) { return options; } + next.options = options; + } + + return next; +} + +function issueTypeColor(value: unknown): IssueTypeColor | null | undefined { + if (value === undefined) { return undefined; } + if (value === null) { return null; } + if (typeof value !== 'string') { return undefined; } + return (ISSUE_TYPE_COLORS as readonly string[]).includes(value) ? value as IssueTypeColor : undefined; +} + +function issueTypeTags(data: OrgIssueTypeData): Record<string, string | boolean> { + return { + name : data.name, + isEnabled : data.isEnabled, + }; +} + +function normalizeIssueTypeData(data: unknown): OrgIssueTypeData | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) { return null; } + const raw = data as Record<string, unknown>; + if (!isNonEmptyString(raw.name) || typeof raw.isEnabled !== 'boolean') { return null; } + + const color = issueTypeColor(raw.color); + return { + name : raw.name.trim(), + description : typeof raw.description === 'string' || raw.description === null ? raw.description : null, + color : color ?? null, + isEnabled : raw.isEnabled, + }; +} + +function buildIssueTypeResponse(issueType: OrgIssueTypeEntry): Record<string, unknown> { + return { + id : issueTypeNumericId(issueType), + node_id : issueType.record.id, + name : issueType.data.name, + description : issueType.data.description ?? null, + color : issueType.data.color ?? null, + is_enabled : issueType.data.isEnabled, + created_at : toISODate(issueType.record.dateCreated), + updated_at : toISODate(issueType.record.timestamp), + }; +} + +async function listOrgIssueTypes(ctx: AgentContext, org: OrgEntry): Promise<OrgIssueTypeEntry[]> { + const { records } = await ctx.org.records.query('org/issueType' as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const issueTypes: OrgIssueTypeEntry[] = []; + for (const record of records) { + let data: unknown; + try { + data = await record.data.json(); + } catch { + continue; + } + + const normalized = normalizeIssueTypeData(data); + if (normalized) { + issueTypes.push({ record, data: normalized }); + } + } + return issueTypes; +} + +async function findOrgIssueType( + ctx: AgentContext, org: OrgEntry, routeIssueTypeId: string, +): Promise<OrgIssueTypeEntry | null> { + const issueTypeId = parseNumericRouteId(routeIssueTypeId); + if (!issueTypeId) { return null; } + + const issueTypes = await listOrgIssueTypes(ctx, org); + return issueTypes.find(issueType => issueTypeNumericId(issueType) === issueTypeId) ?? null; +} + +async function hasDuplicateIssueTypeName( + ctx: AgentContext, org: OrgEntry, name: string, exceptRecordId?: string, +): Promise<boolean> { + const normalized = name.toLowerCase(); + return (await listOrgIssueTypes(ctx, org)) + .some(issueType => issueType.record.id !== exceptRecordId && issueType.data.name.toLowerCase() === normalized); +} + +function parseIssueTypeInput(body: Record<string, unknown>): OrgIssueTypeData | JsonResponse { + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name is required.'); + } + if (typeof body.is_enabled !== 'boolean') { + return jsonValidationError('Validation Failed: is_enabled is required and must be a boolean.'); + } + + const description = parseIssueFieldDescription(body.description); + if (description !== undefined && description !== null && typeof description !== 'string' && 'status' in description) { + return description; + } + + const color = issueTypeColor(body.color); + if (color === undefined && body.color !== undefined) { + return jsonValidationError('Validation Failed: color must be one of gray, blue, green, yellow, orange, red, pink, purple, or null.'); + } + + return { + name : body.name.trim(), + description : description ?? null, + color : color ?? null, + isEnabled : body.is_enabled, + }; +} + +function customPropertyValueType(value: unknown): CustomPropertyValueType | null { + if (typeof value !== 'string') { return null; } + return (CUSTOM_PROPERTY_VALUE_TYPES as readonly string[]).includes(value) ? value as CustomPropertyValueType : null; +} + +function customPropertyValuesEditableBy(value: unknown): CustomPropertyValuesEditableBy | null | undefined { + if (value === undefined) { return undefined; } + if (value === null) { return null; } + if (typeof value !== 'string') { return undefined; } + return (CUSTOM_PROPERTY_VALUES_EDITABLE_BY as readonly string[]).includes(value) ? value as CustomPropertyValuesEditableBy : undefined; +} + +function parseCustomPropertyName(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const name = value.trim(); + if (!name || name.length > 75 || /\s/.test(name)) { return null; } + return name; +} + +function customPropertyTags(data: OrgCustomPropertyData): Record<string, string> { + return { + propertyName : data.propertyName, + valueType : data.valueType, + }; +} + +function normalizeCustomPropertyData(data: unknown): OrgCustomPropertyData | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) { return null; } + const raw = data as Record<string, unknown>; + const propertyName = parseCustomPropertyName(raw.propertyName); + const valueType = customPropertyValueType(raw.valueType); + if (!propertyName || !valueType) { return null; } + + const valuesEditableBy = customPropertyValuesEditableBy(raw.valuesEditableBy); + return { + propertyName, + valueType, + required : typeof raw.required === 'boolean' ? raw.required : false, + defaultValue : parseStoredCustomPropertyDefault(raw.defaultValue), + description : typeof raw.description === 'string' || raw.description === null ? raw.description : null, + allowedValues : Array.isArray(raw.allowedValues) && raw.allowedValues.every(item => typeof item === 'string') ? [...raw.allowedValues] : null, + valuesEditableBy : valuesEditableBy === undefined ? 'org_actors' : valuesEditableBy, + requireExplicitValues : typeof raw.requireExplicitValues === 'boolean' ? raw.requireExplicitValues : false, + }; +} + +function parseStoredCustomPropertyDefault(value: unknown): string | string[] | null { + if (typeof value === 'string') { return value; } + if (Array.isArray(value) && value.every(item => typeof item === 'string')) { return [...value]; } + return null; +} + +function buildCustomPropertyResponse( + org: OrgEntry, property: OrgCustomPropertyData, baseUrl: string, +): Record<string, unknown> { + const orgPath = routeOrgPath(org); + return { + property_name : property.propertyName, + url : `${baseUrl}/orgs/${orgPath}/properties/schema/${encodeURIComponent(property.propertyName)}`, + source_type : 'organization', + value_type : property.valueType, + required : property.required ?? false, + default_value : property.defaultValue ?? null, + description : property.description ?? null, + allowed_values : property.allowedValues ?? null, + values_editable_by : property.valuesEditableBy === undefined ? 'org_actors' : property.valuesEditableBy, + require_explicit_values : property.requireExplicitValues ?? false, + }; +} + +function parseStringArray(value: unknown, fieldName: string, maxItems?: number): string[] | null | JsonResponse { + if (value === null) { return null; } + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + return jsonValidationError(`Validation Failed: ${fieldName} must be an array of strings or null.`); + } + if (maxItems !== undefined && value.length > maxItems) { + return jsonValidationError(`Validation Failed: ${fieldName} cannot contain more than ${maxItems} values.`); + } + return [...value]; +} + +function isValidUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function parseCustomPropertyDefaultValue( + value: unknown, valueType: CustomPropertyValueType, allowedValues: string[] | null, +): string | string[] | null | JsonResponse { + if (value === undefined || value === null) { return null; } + if (valueType === 'multi_select') { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + return jsonValidationError('Validation Failed: default_value must be an array of strings or null for multi_select properties.'); + } + if (allowedValues && value.some(item => !allowedValues.includes(item))) { + return jsonValidationError('Validation Failed: default_value must be one of the allowed_values.'); + } + return [...value]; + } + + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: default_value must be a string or null.'); + } + if (valueType === 'single_select' && allowedValues && !allowedValues.includes(value)) { + return jsonValidationError('Validation Failed: default_value must be one of the allowed_values.'); + } + if (valueType === 'true_false' && value !== 'true' && value !== 'false') { + return jsonValidationError('Validation Failed: default_value for true_false properties must be true or false.'); + } + if (valueType === 'url' && !isValidUrl(value)) { + return jsonValidationError('Validation Failed: default_value for url properties must be an HTTP or HTTPS URL.'); + } + return value; +} + +function parseCustomPropertyDefinitionInput( + body: Record<string, unknown>, routePropertyName?: string, +): OrgCustomPropertyData | JsonResponse { + const propertyName = parseCustomPropertyName(routePropertyName ?? body.property_name); + if (!propertyName) { + return jsonValidationError('Validation Failed: property_name is required, cannot contain whitespace, and must be 75 characters or fewer.'); + } + + if (body.source_type !== undefined && body.source_type !== 'organization' && body.source_type !== 'enterprise') { + return jsonValidationError('Validation Failed: source_type must be organization or enterprise.'); + } + + const valueType = customPropertyValueType(body.value_type); + if (!valueType) { + return jsonValidationError('Validation Failed: value_type must be string, single_select, multi_select, true_false, or url.'); + } + + if (body.required !== undefined && typeof body.required !== 'boolean') { + return jsonValidationError('Validation Failed: required must be a boolean.'); + } + if (body.description !== undefined && body.description !== null && typeof body.description !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + if (body.require_explicit_values !== undefined && typeof body.require_explicit_values !== 'boolean') { + return jsonValidationError('Validation Failed: require_explicit_values must be a boolean.'); + } + + const valuesEditableBy = customPropertyValuesEditableBy(body.values_editable_by); + if (valuesEditableBy === undefined && body.values_editable_by !== undefined) { + return jsonValidationError('Validation Failed: values_editable_by must be org_actors, org_and_repo_actors, or null.'); + } + + const allowedValues = body.allowed_values === undefined + ? null + : parseStringArray(body.allowed_values, 'allowed_values', 200); + if (allowedValues !== null && !Array.isArray(allowedValues) && 'status' in allowedValues) { return allowedValues; } + if ((valueType === 'single_select' || valueType === 'multi_select') && Array.isArray(allowedValues) && allowedValues.length === 0) { + return jsonValidationError('Validation Failed: allowed_values cannot be empty for select properties.'); + } + if (valueType !== 'single_select' && valueType !== 'multi_select' && Array.isArray(allowedValues)) { + return jsonValidationError('Validation Failed: allowed_values are only supported for single_select and multi_select properties.'); + } + + const defaultValue = parseCustomPropertyDefaultValue(body.default_value, valueType, Array.isArray(allowedValues) ? allowedValues : null); + if (defaultValue !== null && typeof defaultValue !== 'string' && !Array.isArray(defaultValue) && 'status' in defaultValue) { + return defaultValue; + } + + return { + propertyName, + valueType, + required : typeof body.required === 'boolean' ? body.required : false, + defaultValue, + description : typeof body.description === 'string' || body.description === null ? body.description : null, + allowedValues : Array.isArray(allowedValues) ? allowedValues : null, + valuesEditableBy : valuesEditableBy === undefined ? 'org_actors' : valuesEditableBy, + requireExplicitValues : typeof body.require_explicit_values === 'boolean' ? body.require_explicit_values : false, + }; +} + +function validateCustomPropertyValue( + property: OrgCustomPropertyData, value: unknown, +): RepositoryCustomPropertyValue | null | JsonResponse { + if (value === null) { return null; } + const allowed = property.allowedValues ?? null; + + if (property.valueType === 'multi_select') { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + return jsonValidationError('Validation Failed: multi_select property values must be arrays of strings.'); + } + if (allowed && value.some(item => !allowed.includes(item))) { + return jsonValidationError(`Validation Failed: value for '${property.propertyName}' must be one of the allowed_values.`); + } + return [...value]; + } + + if (typeof value !== 'string') { + return jsonValidationError(`Validation Failed: value for '${property.propertyName}' must be a string or null.`); + } + if (property.valueType === 'single_select' && allowed && !allowed.includes(value)) { + return jsonValidationError(`Validation Failed: value for '${property.propertyName}' must be one of the allowed_values.`); + } + if (property.valueType === 'true_false' && value !== 'true' && value !== 'false') { + return jsonValidationError(`Validation Failed: value for '${property.propertyName}' must be true or false.`); + } + if (property.valueType === 'url' && !isValidUrl(value)) { + return jsonValidationError(`Validation Failed: value for '${property.propertyName}' must be an HTTP or HTTPS URL.`); + } + return value; +} + +async function listOrgCustomProperties(ctx: AgentContext, org: OrgEntry): Promise<OrgCustomPropertyEntry[]> { + const { records } = await ctx.org.records.query('org/customProperty' as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const properties: OrgCustomPropertyEntry[] = []; + for (const record of records) { + let data: unknown; + try { + data = await record.data.json(); + } catch { + continue; + } + const normalized = normalizeCustomPropertyData(data); + if (normalized) { + properties.push({ record, data: normalized }); + } + } + return properties.sort((a, b) => a.data.propertyName.localeCompare(b.data.propertyName)); +} + +async function findOrgCustomProperty( + ctx: AgentContext, org: OrgEntry, routePropertyName: string, +): Promise<OrgCustomPropertyEntry | null> { + const propertyName = decodeRouteParam(routePropertyName); + return (await listOrgCustomProperties(ctx, org)) + .find(property => property.data.propertyName === propertyName) ?? null; +} + +async function upsertOrgCustomProperty( + ctx: AgentContext, org: OrgEntry, data: OrgCustomPropertyData, +): Promise<OrgCustomPropertyEntry | JsonResponse> { + const existing = (await listOrgCustomProperties(ctx, org)) + .find(property => property.data.propertyName === data.propertyName); + if (existing) { + const { status } = await existing.record.update({ data, tags: customPropertyTags(data) } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update custom property: ${status.detail}`); + } + return { ...existing, data }; + } + + const { record, status } = await ctx.org.records.create('org/customProperty' as any, { + data, + tags : customPropertyTags(data), + parentContextId : org.record.contextId ?? '', + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create custom property: ${status.detail}`); + } + return { record, data }; +} + +async function getRepoSettingsEntry(ctx: AgentContext, entry: OrgRepoEntry): Promise<RepoSettingsEntry> { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from : fromOpt(ctx, ctx.did), + filter : { contextId: entry.repo.contextId }, + } as any); + + if (records.length === 0) { + return { entry, settings: {} }; + } + + const record = records[0] as RepoSettingsEntry['record'] & { data: { json: () => Promise<SettingsData> } }; + const settings = await record.data.json(); + return { entry, record, settings: settings ?? {} }; +} + +async function saveRepoSettingsEntry( + ctx: AgentContext, lookup: RepoSettingsEntry, settings: SettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.entry.repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function customPropertyValueEntries(settings: SettingsData): Array<{ property_name: string; value: RepositoryCustomPropertyValue }> { + return Object.entries(settings.customProperties ?? {}) + .filter((entry): entry is [string, RepositoryCustomPropertyValue] => typeof entry[0] === 'string') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([propertyName, value]) => ({ + property_name: propertyName, + value, + })); +} + +function buildOrgRepoCustomPropertiesResponse(entry: OrgRepoEntry, ownerDid: string, settings: SettingsData): Record<string, unknown> { + return { + repository_id : numericId(entry.repo.contextId || entry.record.id), + repository_name : entry.repo.name, + repository_full_name : `${ownerDid}/${entry.repo.name}`, + properties : customPropertyValueEntries(settings), + }; +} + +function filterOrgRepoCustomPropertyEntries(entries: OrgRepoEntry[], ownerDid: string, url: URL): OrgRepoEntry[] { + const query = url.searchParams.get('repository_query')?.trim().toLowerCase(); + if (!query) { return entries; } + + const terms = query + .split(/\s+/) + .map(term => term.replace(/^repo:/, '').replace(/^org:/, '')) + .filter(Boolean); + if (terms.length === 0) { return entries; } + + return entries.filter(entry => { + const haystack = `${entry.repo.name} ${repoFullName(ownerDid, entry)}`.toLowerCase(); + return terms.every(term => haystack.includes(term)); + }); +} + +function repoFullName(ownerDid: string, entry: OrgRepoEntry): string { + return `${ownerDid}/${entry.repo.name}`; +} + +function parseOrgCustomPropertyValuesInput( + body: Record<string, unknown>, definitions: Map<string, OrgCustomPropertyData>, +): { repositoryNames: string[]; values: Record<string, RepositoryCustomPropertyValue | null> } | JsonResponse { + if (!Array.isArray(body.repository_names) || !body.repository_names.every(name => typeof name === 'string' && name.trim() !== '')) { + return jsonValidationError('Validation Failed: repository_names must be a non-empty array of repository names.'); + } + if (body.repository_names.length > 30) { + return jsonValidationError('Validation Failed: repository_names cannot contain more than 30 repositories.'); + } + if (!Array.isArray(body.properties)) { + return jsonValidationError('Validation Failed: properties must be an array.'); + } + + const repositoryNames = [...new Set(body.repository_names.map(name => String(name).trim()))]; + const values: Record<string, RepositoryCustomPropertyValue | null> = {}; + for (const property of body.properties) { + if (!property || typeof property !== 'object' || Array.isArray(property)) { + return jsonValidationError('Validation Failed: each property must be an object.'); + } + const raw = property as Record<string, unknown>; + const propertyName = parseCustomPropertyName(raw.property_name); + if (!propertyName) { + return jsonValidationError('Validation Failed: property_name is required, cannot contain whitespace, and must be 75 characters or fewer.'); + } + if (!Object.prototype.hasOwnProperty.call(raw, 'value')) { + return jsonValidationError('Validation Failed: value is required.'); + } + + const definition = definitions.get(propertyName); + if (!definition) { + return jsonValidationError(`Validation Failed: custom property '${propertyName}' is not defined for this organization.`); + } + + const value = validateCustomPropertyValue(definition, raw.value); + if (value !== null && typeof value !== 'string' && !Array.isArray(value) && 'status' in value) { return value; } + values[propertyName] = value; + } + + return { repositoryNames, values }; +} + +function buildTeamRepositoryResponse( + repo: RepoInfo, + ownerDid: string, + repoName: string, + baseUrl: string, + permission: TeamRepositoryPermission, +): Record<string, unknown> { + return { + ...buildRepoResponse(repo, ownerDid, repoName, baseUrl), + permissions : teamPermissionBooleans(permission), + role_name : teamRepositoryRoleName(permission), + }; +} + +async function listTeams(ctx: AgentContext, org: OrgEntry): Promise<TeamEntry[]> { + const { records } = await ctx.org.records.query('org/team' as any, { + filter : { contextId: org.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const teams: TeamEntry[] = []; + for (const record of records) { + let data: TeamData; + try { + data = await record.data.json(); + } catch { + continue; + } + if (isNonEmptyString(data.name)) { + teams.push({ + record, + data : { ...data, privacy: data.privacy === 'secret' ? 'secret' : 'visible' }, + slug : slugify(data.name), + }); + } + } + return teams; +} + +async function findTeam(ctx: AgentContext, org: OrgEntry, routeTeam: string): Promise<TeamEntry | null> { + const slug = slugify(decodeRouteParam(routeTeam)); + const teams = await listTeams(ctx, org); + return teams.find(team => team.slug === slug || team.data.name.toLowerCase() === decodeRouteParam(routeTeam).toLowerCase()) ?? null; +} + +async function findTeamByNumericId(ctx: AgentContext, routeTeamId: string): Promise<TeamLookup | null> { + const teamId = parseNumericRouteId(routeTeamId); + if (!teamId) { return null; } + + for (const org of await listOrgEntries(ctx)) { + const team = (await listTeams(ctx, org)).find(candidate => teamNumericId(candidate) === teamId); + if (team) { return { org, team }; } + } + return null; +} + +async function findOrgTeamByNumericIds( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, +): Promise<TeamLookup | null> { + const teamId = parseNumericRouteId(routeTeamId); + if (!teamId) { return null; } + + const org = await findOrgByNumericId(ctx, routeOrgId); + if (!org) { return null; } + + const team = (await listTeams(ctx, org)).find(candidate => teamNumericId(candidate) === teamId); + return team ? { org, team } : null; +} + +function buildTeamResponse(team: TeamEntry, org: OrgEntry, baseUrl: string): Record<string, unknown> { + const orgPath = routeOrgPath(org); + const teamPath = encodeURIComponent(team.slug); + + return { + id : numericId(team.record.contextId ?? team.record.id), + node_id : team.record.id, + url : `${baseUrl}/orgs/${orgPath}/teams/${teamPath}`, + html_url : `${baseUrl}/orgs/${orgPath}/teams/${teamPath}`, + name : team.data.name, + slug : team.slug, + description : team.data.description ?? null, + privacy : teamPrivacyToGitHub(team.data.privacy), + notification_setting : 'notifications_enabled', + permission : 'pull', + members_url : `${baseUrl}/orgs/${orgPath}/teams/${teamPath}/members{/member}`, + repositories_url : `${baseUrl}/orgs/${orgPath}/teams/${teamPath}/repos`, + parent : null, + }; +} + +function buildDetailedTeamResponse(team: TeamEntry, org: OrgEntry, baseUrl: string, membersCount: number): Record<string, unknown> { + return { + ...buildTeamResponse(team, org, baseUrl), + members_count : membersCount, + repos_count : Object.keys(teamRepositoryGrants(team)).length, + created_at : toISODate(team.record.dateCreated), + updated_at : toISODate(team.record.timestamp), + organization : buildOrgResponse(org, baseUrl), + }; +} + +async function listTeamMembers(ctx: AgentContext, team: TeamEntry): Promise<TeamMemberEntry[]> { + const { records } = await ctx.org.records.query('org/team/teamMember' as any, { + filter : { contextId: team.record.contextId ?? '' }, + dateSort : DateSort.CreatedAscending, + } as any); + + const members: TeamMemberEntry[] = []; + for (const record of records) { + let data: TeamMemberData; + try { + data = await record.data.json(); + } catch { + continue; + } + if (isNonEmptyString(data.did)) { + members.push({ record, data }); + } + } + return members; +} + +async function findTeamMember(ctx: AgentContext, team: TeamEntry, did: string): Promise<TeamMemberEntry | null> { + const members = await listTeamMembers(ctx, team); + return members.find(member => member.data.did === did) ?? null; +} + +async function listPendingOrgInvitations(ctx: AgentContext, org: OrgEntry): Promise<OrgInvitationEntry[]> { + const byDid = new Map<string, OrgInvitationEntry>(); + for (const team of await listTeams(ctx, org)) { + for (const member of await listTeamMembers(ctx, team)) { + if (teamMemberState(member) !== 'pending') { continue; } + + const existing = byDid.get(member.data.did); + if (existing) { + existing.members.push(member); + existing.teams.push(team); + } else { + byDid.set(member.data.did, { + did : member.data.did, + members : [member], + teams : [team], + }); + } + } + } + + return [...byDid.values()].sort((a, b) => a.did.localeCompare(b.did)); +} + +async function findOrgInvitation(ctx: AgentContext, org: OrgEntry, routeInvitationId: string): Promise<OrgInvitationEntry | null> { + const invitationId = Number(decodeRouteParam(routeInvitationId)); + if (!Number.isSafeInteger(invitationId) || invitationId <= 0) { return null; } + + const invitations = await listPendingOrgInvitations(ctx, org); + return invitations.find(invitation => orgInvitationId(org, invitation.did) === invitationId) ?? null; +} + +async function listOrgRepoEntries(ctx: AgentContext): Promise<OrgRepoEntry[]> { + const { records } = await ctx.repo.records.query('repo', { + dateSort: DateSort.CreatedAscending, + } as any); + + const repos: OrgRepoEntry[] = []; + for (const record of records) { + let data: Record<string, unknown>; + try { + data = await record.data.json(); + } catch { + continue; + } + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const repo = repoInfoFromRecord(record, data, tags); + repos.push({ record, repo, name: repo.name }); + } + return repos; +} + +async function getRepoSettings(ctx: AgentContext, targetDid: string, repo: RepoInfo): Promise<SettingsData> { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from : fromOpt(ctx, targetDid), + filter : { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return {}; + } + + const record = records[0] as { data: { json: () => Promise<SettingsData> } }; + return (await record.data.json()) ?? {}; +} + +function buildOutsideCollaboratorUser(collab: OutsideCollaboratorEntry, baseUrl: string): Record<string, unknown> { + const user = buildOwner(collab.did, baseUrl); + return { + ...user, + node_id : collab.recordId, + gravatar_id : '', + followers_url : `${baseUrl}/users/${collab.did}/followers`, + following_url : `${baseUrl}/users/${collab.did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${collab.did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${collab.did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${collab.did}/subscriptions`, + organizations_url : `${baseUrl}/users/${collab.did}/orgs`, + repos_url : `${baseUrl}/users/${collab.did}/repos`, + events_url : `${baseUrl}/users/${collab.did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${collab.did}/received_events`, + site_admin : false, + ...(collab.alias ? { name: collab.alias } : {}), + }; +} + +function addOutsideCollaborator( + byDid: Map<string, OutsideCollaboratorEntry>, + candidate: OutsideCollaboratorEntry, + orgMemberDids: Set<string>, +): void { + if (orgMemberDids.has(candidate.did)) { return; } + + const existing = byDid.get(candidate.did); + if (!existing || (!existing.alias && candidate.alias)) { + byDid.set(candidate.did, candidate); + } +} + +async function listOutsideCollaborators(ctx: AgentContext, org: OrgEntry): Promise<OutsideCollaboratorEntry[]> { + const orgMemberDids = orgMemberDidSet(await listOrgMembers(ctx, org)); + const byDid = new Map<string, OutsideCollaboratorEntry>(); + + for (const entry of await listOrgRepoEntries(ctx)) { + for (const recordType of REPO_COLLABORATOR_TYPES) { + const { records } = await ctx.repo.records.query(recordType as any, { + filter: { contextId: entry.repo.contextId }, + } as any); + + for (const record of records) { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + let data: Record<string, unknown> = {}; + try { + data = await record.data.json(); + } catch { + data = {}; + } + + const did = typeof data.did === 'string' ? data.did : tags.did; + if (!isNonEmptyString(did)) { continue; } + + addOutsideCollaborator(byDid, { + did, + alias : typeof data.alias === 'string' ? data.alias : '', + recordId : record.id ?? `${did}:${recordType}`, + }, orgMemberDids); + } + } + } + + for (const team of await listTeams(ctx, org)) { + if (Object.keys(teamRepositoryGrants(team)).length === 0) { continue; } + + for (const member of await listTeamMembers(ctx, team)) { + if (!teamMemberIsActive(member)) { continue; } + + addOutsideCollaborator(byDid, { + did : member.data.did, + alias : member.data.alias ?? '', + recordId : member.record.id ?? `${member.data.did}:${team.slug}`, + }, orgMemberDids); + } + } + + return [...byDid.values()].sort((a, b) => a.did.localeCompare(b.did)); +} + +async function deleteOutsideCollaboratorRepoRoles(ctx: AgentContext, did: string): Promise<JsonResponse | null> { + for (const entry of await listOrgRepoEntries(ctx)) { + for (const recordType of REPO_COLLABORATOR_TYPES) { + const { records } = await ctx.repo.records.query(recordType as any, { + filter: { contextId: entry.repo.contextId, tags: { did } }, + } as any); + for (const record of records) { + const { status } = await record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove outside collaborator repository access: ${status.detail}`); + } + } + } + } + return null; +} + +async function deleteOutsideCollaboratorTeamMemberships(ctx: AgentContext, org: OrgEntry, did: string): Promise<JsonResponse | null> { + for (const team of await listTeams(ctx, org)) { + for (const member of await listTeamMembers(ctx, team)) { + if (member.data.did !== did) { continue; } + + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove outside collaborator team access: ${status.detail}`); + } + } + } + return null; +} + +function buildTeamMembershipResponse( + team: TeamEntry, + member: TeamMemberEntry, + org: OrgEntry, + baseUrl: string, + role: TeamMemberRole, + state: TeamMemberState, +): Record<string, unknown> { + const teamUrl = `${baseUrl}/orgs/${routeOrgPath(org)}/teams/${encodeURIComponent(team.slug)}`; + + return { + url : `${teamUrl}/memberships/${encodeURIComponent(member.data.did)}`, + role, + state, + organization : buildOrgResponse(org, baseUrl), + team : buildTeamResponse(team, org, baseUrl), + user : buildOrgUser(member, baseUrl), + }; +} + +function buildTeamInvitationResponse( + team: TeamEntry, + member: TeamMemberEntry, + org: OrgEntry, + baseUrl: string, + inviterDid: string, +): Record<string, unknown> { + return { + ...buildOrgInvitationResponse( + org, + { did: member.data.did, members: [member], teams: [team] }, + baseUrl, + inviterDid, + ), + team: buildTeamResponse(team, org, baseUrl), + }; +} + +function buildOrgInvitationResponse( + org: OrgEntry, + invitation: OrgInvitationEntry, + baseUrl: string, + inviterDid: string, +): Record<string, unknown> { + const invitationId = orgInvitationId(org, invitation.did); + const firstMember = invitation.members[0]; + const inviter = { + ...buildOwner(inviterDid, baseUrl), + gravatar_id : '', + type : 'User', + site_admin : false, + }; + + return { + id : invitationId, + login : invitation.did, + node_id : firstMember?.record.id ?? String(invitationId), + email : null, + role : 'direct_member', + created_at : toISODate(firstMember?.record.dateCreated), + failed_at : '', + failed_reason : '', + inviter, + team_count : invitation.teams.length, + invitation_teams_url : `${baseUrl}/organizations/${numericId(org.record.contextId ?? org.record.id)}/invitations/${invitationId}/teams`, + invitation_source : 'member', + }; +} + +function buildOrgMembershipResponse(org: OrgEntry, member: OrgMemberEntry, baseUrl: string): Record<string, unknown> { + const orgUrl = `${baseUrl}/orgs/${routeOrgPath(org)}`; + + return { + url : `${orgUrl}/memberships/${encodeURIComponent(member.data.did)}`, + state : 'active', + role : member.role, + organization_url : orgUrl, + direct_membership : true, + enterprise_teams_providing_indirect_membership : [], + organization : buildOrgResponse(org, baseUrl), + user : buildOrgUser(member, baseUrl), + }; +} + +function pagedResponse(items: Record<string, unknown>[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(items, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, items.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + return jsonOk(paged, extraHeaders); +} + +async function listUserOrgs(ctx: AgentContext, userDid: string): Promise<OrgEntry[]> { + const orgs = await listOrgEntries(ctx); + const matches: OrgEntry[] = []; + for (const org of orgs) { + const members = await listOrgMembers(ctx, org); + if (members.some(member => member.data.did === userDid)) { + matches.push(org); + } + } + return matches; +} + +function orgListItem(org: OrgEntry, baseUrl: string): Record<string, unknown> { + const full = buildOrgResponse(org, baseUrl); + return { + login : full.login, + id : full.id, + node_id : full.node_id, + url : full.url, + repos_url : full.repos_url, + events_url : full.events_url, + hooks_url : full.hooks_url, + issues_url : full.issues_url, + members_url : full.members_url, + public_members_url : full.public_members_url, + avatar_url : full.avatar_url, + description : full.description, + }; +} + +function pagedOrgs(orgs: OrgEntry[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const items = orgs.map(org => orgListItem(org, baseUrl)); + return pagedResponse(items, url, path); +} + +function listOrganizationsSince(orgs: OrgEntry[], url: URL): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const since = parseInt(url.searchParams.get('since') ?? '0', 10); + const minId = Number.isNaN(since) ? 0 : since; + const sorted = [...orgs] + .map(org => ({ org, id: numericId(org.record.contextId ?? org.record.id) })) + .filter(entry => entry.id > minId) + .sort((a, b) => a.id - b.id); + const paged = sorted.slice(0, pagination.perPage); + const items = paged.map(entry => orgListItem(entry.org, baseUrl)); + const extraHeaders: Record<string, string> = {}; + + if (sorted.length > pagination.perPage) { + const nextSince = paged[paged.length - 1]?.id ?? minId; + extraHeaders.Link = `<${baseUrl}/organizations?since=${nextSince}&per_page=${pagination.perPage}>; rel="next"`; + } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// Organization handlers +// --------------------------------------------------------------------------- + +export async function handleGetOrg( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + return jsonOk(buildOrgResponse(org, buildApiUrl(url))); +} + +export async function handleListOrganizations( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + return listOrganizationsSince(await listOrgEntries(ctx), url); +} + +export async function handleListAuthenticatedOrgs( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + return pagedOrgs(await listUserOrgs(ctx, ctx.did), url, '/user/orgs'); +} + +export async function handleListAuthenticatedOrgMemberships( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + const state = url.searchParams.get('state'); + if (state !== null && state !== 'active' && state !== 'pending') { + return jsonValidationError('Validation Failed: state must be active or pending.'); + } + if (state === 'pending') { + return pagedResponse([], url, '/user/memberships/orgs'); + } + + const baseUrl = buildApiUrl(url); + const memberships: Record<string, unknown>[] = []; + for (const org of await listOrgEntries(ctx)) { + const member = await findOrgMember(ctx, org, ctx.did); + if (member) { + memberships.push(buildOrgMembershipResponse(org, member, baseUrl)); + } + } + return pagedResponse(memberships, url, '/user/memberships/orgs'); +} + +export async function handleGetAuthenticatedOrgMembership( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + return handleGetOrgMembership(ctx, routeOrg, ctx.did, url); +} + +export async function handleUpdateAuthenticatedOrgMembership( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + if (body.state !== 'active') { + return jsonValidationError('Validation Failed: state must be active.'); + } + return handleGetOrgMembership(ctx, routeOrg, ctx.did, url); +} + +export async function handleListUserOrgs( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + return pagedOrgs(await listUserOrgs(ctx, decodeRouteParam(userDid)), url, `/users/${userDid}/orgs`); +} + +export async function handleListOrgBlockedUsers( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const baseUrl = buildApiUrl(url); + const items = (await listOrgBlockedUsers(ctx, org)) + .map(blockedUser => buildBlockedOrgUser(blockedUser, baseUrl)); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/blocks`); +} + +export async function handleCheckOrgBlockedUser( + ctx: AgentContext, routeOrg: string, routeUsername: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const blockedUser = await findOrgBlockedUser(ctx, org, routeUsername); + if (!blockedUser) { + return jsonNotFound(`User '${decodeRouteParam(routeUsername)}' is not blocked by organization '${org.routeLogin}'.`); + } + return jsonNoContent(); +} + +export async function handleBlockOrgUser( + ctx: AgentContext, routeOrg: string, routeUsername: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = parseBlockedUserDid(routeUsername); + if (!did) { return jsonValidationError('Validation Failed: username is required.'); } + + const existing = await findOrgBlockedUser(ctx, org, routeUsername); + if (existing) { return jsonNoContent(); } + + const member = await findOrgMember(ctx, org, did); + if (member) { + return jsonValidationError(`Validation Failed: organization members cannot be blocked. Remove '${did}' from the organization first.`); + } + + const data: OrgBlockedUserData = { + did, + blockedAt : new Date().toISOString(), + blockedBy : ctx.did, + }; + const { record, status } = await ctx.org.records.create('org/blockedUser' as any, { + data, + tags : blockedUserTags(data), + parentContextId : org.record.contextId ?? '', + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to block user: ${status.detail}`); + } + return jsonNoContent(); +} + +export async function handleUnblockOrgUser( + ctx: AgentContext, routeOrg: string, routeUsername: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const blockedUser = await findOrgBlockedUser(ctx, org, routeUsername); + if (!blockedUser) { return jsonNoContent(); } + + const { status } = await blockedUser.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to unblock user: ${status.detail}`); + } + return jsonNoContent(); +} + +export async function handleUpdateOrg( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const next: OrgData = { ...org.data }; + if (body.name !== undefined) { + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name must be a non-empty string.'); + } + next.name = body.name.trim(); + } + if (body.description !== undefined) { + if (body.description !== null && typeof body.description !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + next.description = body.description ?? undefined; + } + if (body.blog !== undefined || body.homepage !== undefined) { + const homepage = body.blog ?? body.homepage; + if (homepage !== null && homepage !== undefined && typeof homepage !== 'string') { + return jsonValidationError('Validation Failed: blog must be a string or null.'); + } + next.homepage = homepage ? String(homepage) : undefined; + } + if (body.avatar_url !== undefined || body.avatar !== undefined) { + const avatar = body.avatar_url ?? body.avatar; + if (avatar !== null && avatar !== undefined && typeof avatar !== 'string') { + return jsonValidationError('Validation Failed: avatar_url must be a string or null.'); + } + next.avatar = avatar ? String(avatar) : undefined; + } + + const { status } = await org.record.update({ data: next }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update organization: ${status.detail}`); + } + + return jsonOk(buildOrgResponse({ ...org, data: next, slug: slugify(next.name), routeLogin: next.name }, buildApiUrl(url))); +} + +export async function handleListOrgMembers( + ctx: AgentContext, routeOrg: string, url: URL, publicOnly: boolean = false, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const role = url.searchParams.get('role'); + let members = await listOrgMembers(ctx, org); + if (publicOnly) { + members = members.filter(orgMemberIsPublic); + } else if (role === 'admin') { + members = members.filter(member => member.role === 'admin'); + } else if (role === 'member') { + members = members.filter(member => member.role === 'member'); + } + + const baseUrl = buildApiUrl(url); + const items = members.map(member => buildOrgUser(member, baseUrl)); + const path = publicOnly + ? `/orgs/${routeOrgPath(org)}/public_members` + : `/orgs/${routeOrgPath(org)}/members`; + return pagedResponse(items, url, path); +} + +export async function handleCheckOrgMember( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const member = await findOrgMember(ctx, org, decodeRouteParam(memberDid)); + return member ? jsonNoContent() : jsonNotFound(`User '${decodeRouteParam(memberDid)}' is not an organization member.`); +} + +export async function handleCheckPublicOrgMember( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findOrgMember(ctx, org, did); + return member && orgMemberIsPublic(member) + ? jsonNoContent() + : jsonNotFound(`User '${did}' is not a public organization member.`); +} + +export async function handleSetPublicOrgMembership( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findOrgMember(ctx, org, did); + if (!member) { + return jsonNotFound(`User '${did}' is not an organization member.`); + } + if (orgMemberIsPublic(member)) { + return jsonNoContent(); + } + + const updated = await setOrgMemberPublicVisibility(member, true); + return 'status' in updated ? updated : jsonNoContent(); +} + +export async function handleRemovePublicOrgMembership( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findOrgMember(ctx, org, did); + if (!member) { + return jsonNotFound(`User '${did}' is not an organization member.`); + } + if (!orgMemberIsPublic(member)) { + return jsonNoContent(); + } + + const updated = await setOrgMemberPublicVisibility(member, false); + return 'status' in updated ? updated : jsonNoContent(); +} + +export async function handleGetOrgMembership( + ctx: AgentContext, routeOrg: string, memberDid: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findOrgMember(ctx, org, did); + if (!member) { + return jsonNotFound(`User '${did}' is not an organization member.`); + } + + return jsonOk(buildOrgMembershipResponse(org, member, buildApiUrl(url))); +} + +export async function handleSetOrgMembership( + ctx: AgentContext, routeOrg: string, memberDid: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + if (!isNonEmptyString(did)) { + return jsonValidationError('Validation Failed: member must be a DID.'); + } + + const role = orgMembershipRole(body.role); + if (!role) { + return jsonValidationError('Validation Failed: role must be admin or member.'); + } + + const existingRecords = (await listOrgMemberRecords(ctx, org)).filter(member => member.data.did === did); + let target = existingRecords.find(member => member.role === role) ?? null; + const publicVisibility = typeof body.public === 'boolean' + ? body.public + : existingRecords.find(member => member.data.public !== undefined)?.data.public; + + if (!target) { + const alias = typeof body.alias === 'string' ? body.alias : undefined; + const data: OrgMemberData = { + did, + ...(alias ? { alias } : {}), + ...(publicVisibility !== undefined ? { public: publicVisibility } : {}), + }; + const { record, status } = await ctx.org.records.create(orgMemberRecordType(role) as any, { + data, + tags : { did }, + parentContextId : org.record.contextId ?? '', + recipient : did, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to set organization membership: ${status.detail}`); + } + target = { record, data, role }; + } else if (typeof body.alias === 'string' && body.alias !== target.data.alias) { + const nextData: OrgMemberData = { + ...target.data, + alias: body.alias, + ...(publicVisibility !== undefined ? { public: publicVisibility } : {}), + }; + const { status } = await target.record.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update organization membership: ${status.detail}`); + } + target = { ...target, data: nextData }; + } + + for (const member of existingRecords) { + if (member.record.id === target.record.id) { continue; } + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to update organization membership: ${status.detail}`); + } + } + + return jsonOk(buildOrgMembershipResponse(org, target, buildApiUrl(url))); +} + +export async function handleRemoveOrgMembership( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const members = await listOrgMemberRecords(ctx, org); + const matching = members.filter(member => member.data.did === did); + if (matching.length === 0) { + return jsonNotFound(`User '${did}' is not an organization member.`); + } + + for (const member of matching) { + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove organization membership: ${status.detail}`); + } + } + return jsonNoContent(); +} + +export async function handleListOrgRepos( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + return handleListRepos(ctx, ctx.did, url, `/orgs/${routeOrgPath(org)}/repos`, 'org'); +} + +export async function handleListOrgSecurityAdvisories( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const items: OrgSecurityAdvisoryItem[] = []; + for (const entry of await listRepoEntries(ctx, ctx.did)) { + const settings = await getRepoSettings(ctx, ctx.did, entry.repo); + for (const advisory of securityAdvisoryEntries(settings)) { + items.push({ advisory, repo: entry.repo }); + } + } + + const itemByAdvisory = new Map<SecurityAdvisoryEntry, OrgSecurityAdvisoryItem>(); + for (const item of items) { + itemByAdvisory.set(item.advisory, item); + } + + const filtered = filterSecurityAdvisories(items.map(item => item.advisory), url); + if ('status' in filtered) { return filtered; } + + const filteredItems = filtered + .map(advisory => itemByAdvisory.get(advisory)) + .filter((item): item is OrgSecurityAdvisoryItem => Boolean(item)); + const pagination = parsePagination(url); + const paged = paginate(filteredItems, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/orgs/${routeOrgPath(org)}/security-advisories`, + pagination.page, pagination.perPage, filteredItems.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(item => buildSecurityAdvisoryResponse(item.advisory, ctx.did, item.repo, baseUrl)), + extraHeaders, + ); +} + +function orgCodeScanningAlertIsOnDefaultBranch(alert: CodeScanningAlertEntry, repo: RepoInfo): boolean { + return alert.mostRecentInstance.ref === `refs/heads/${repo.defaultBranch || 'main'}`; +} + +export async function handleListOrgCodeScanningAlerts( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const items: OrgCodeScanningAlertItem[] = []; + for (const entry of await listRepoEntries(ctx, ctx.did)) { + const settings = await getRepoSettings(ctx, ctx.did, entry.repo); + for (const alert of codeScanningAlertEntries(settings)) { + if (orgCodeScanningAlertIsOnDefaultBranch(alert, entry.repo)) { + items.push({ alert, repo: entry.repo }); + } + } + } + + const itemByAlert = new Map<CodeScanningAlertEntry, OrgCodeScanningAlertItem>(); + for (const item of items) { + itemByAlert.set(item.alert, item); + } + + const filtered = filterCodeScanningAlerts(items.map(item => item.alert), url); + if ('status' in filtered) { return filtered; } + + const filteredItems = filtered + .map(alert => itemByAlert.get(alert)) + .filter((item): item is OrgCodeScanningAlertItem => Boolean(item)); + const pagination = parsePagination(url); + const paged = paginate(filteredItems, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/orgs/${routeOrgPath(org)}/code-scanning/alerts`, + pagination.page, pagination.perPage, filteredItems.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(item => ({ + ...buildCodeScanningAlertResponse(item.alert, ctx.did, item.repo, baseUrl), + repository: buildRepoResponse(item.repo, ctx.did, item.repo.name, baseUrl), + })), + extraHeaders, + ); +} + +export async function handleListOrgDependabotAlerts( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const items: OrgDependabotAlertItem[] = []; + for (const entry of await listRepoEntries(ctx, ctx.did)) { + const settings = await getRepoSettings(ctx, ctx.did, entry.repo); + for (const alert of dependabotAlertEntries(settings)) { + items.push({ alert, repo: entry.repo }); + } + } + + const itemByAlert = new Map<DependabotAlertEntry, OrgDependabotAlertItem>(); + for (const item of items) { + itemByAlert.set(item.alert, item); + } + + const filtered = filterDependabotAlerts(items.map(item => item.alert), url); + if ('status' in filtered) { return filtered; } + + const filteredItems = filtered + .map(alert => itemByAlert.get(alert)) + .filter((item): item is OrgDependabotAlertItem => Boolean(item)); + const pagination = parsePagination(url); + const paged = paginate(filteredItems, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/orgs/${routeOrgPath(org)}/dependabot/alerts`, + pagination.page, pagination.perPage, filteredItems.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(item => buildDependabotAlertResponse(item.alert, ctx.did, item.repo, baseUrl)), + extraHeaders, + ); +} + +export async function handleListOrgSecretScanningAlerts( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const hideSecret = parseBooleanQuery(url, 'hide_secret'); + if (hideSecret !== null && typeof hideSecret !== 'boolean') { return hideSecret; } + + const items: OrgSecretScanningAlertItem[] = []; + for (const entry of await listRepoEntries(ctx, ctx.did)) { + const settings = await getRepoSettings(ctx, ctx.did, entry.repo); + for (const alert of secretScanningAlertEntries(settings)) { + items.push({ alert, repo: entry.repo }); + } + } + + const itemByAlert = new Map<SecretScanningAlertEntry, OrgSecretScanningAlertItem>(); + for (const item of items) { + itemByAlert.set(item.alert, item); + } + + const filtered = filterSecretScanningAlerts(items.map(item => item.alert), url); + if ('status' in filtered) { return filtered; } + + const filteredItems = filtered + .map(alert => itemByAlert.get(alert)) + .filter((item): item is OrgSecretScanningAlertItem => Boolean(item)); + const pagination = parsePagination(url); + const paged = paginate(filteredItems, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/orgs/${routeOrgPath(org)}/secret-scanning/alerts`, + pagination.page, pagination.perPage, filteredItems.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(item => buildSecretScanningAlertResponse(item.alert, ctx.did, item.repo, baseUrl, hideSecret === true)), + extraHeaders, + ); +} + +export async function handleCreateOrgRepo( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + return createRepoForOwner(ctx, ctx.did, body, url, options); +} + +// --------------------------------------------------------------------------- +// Organization issue field handlers +// --------------------------------------------------------------------------- + +export async function handleListOrgIssueFields( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const items = (await listOrgIssueFields(ctx, org)).map(buildIssueFieldResponse); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/issue-fields`); +} + +export async function handleCreateOrgIssueField( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const data = parseCreateIssueFieldInput(body); + if ('status' in data) { return data; } + if (await hasDuplicateIssueFieldName(ctx, org, data.name)) { + return jsonValidationError(`Validation Failed: issue field '${data.name}' already exists.`); + } + + const { record, status } = await ctx.org.records.create('org/issueField' as any, { + data, + tags : issueFieldTags(data), + parentContextId : org.record.contextId ?? '', + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create issue field: ${status.detail}`); + } + + return jsonOk(buildIssueFieldResponse({ record, data })); +} + +export async function handleUpdateOrgIssueField( + ctx: AgentContext, routeOrg: string, routeFieldId: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const field = await findOrgIssueField(ctx, org, routeFieldId); + if (!field) { return jsonNotFound(`Issue field '${decodeRouteParam(routeFieldId)}' not found.`); } + + const data = parseUpdateIssueFieldInput(field, body); + if ('status' in data) { return data; } + if (await hasDuplicateIssueFieldName(ctx, org, data.name, field.record.id)) { + return jsonValidationError(`Validation Failed: issue field '${data.name}' already exists.`); + } + + const { status } = await field.record.update({ data, tags: issueFieldTags(data) } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update issue field: ${status.detail}`); + } + + return jsonOk(buildIssueFieldResponse({ ...field, data })); +} + +export async function handleDeleteOrgIssueField( + ctx: AgentContext, routeOrg: string, routeFieldId: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const field = await findOrgIssueField(ctx, org, routeFieldId); + if (!field) { return jsonNotFound(`Issue field '${decodeRouteParam(routeFieldId)}' not found.`); } + + const { status } = await field.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue field: ${status.detail}`); + } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// Organization issue type handlers +// --------------------------------------------------------------------------- + +export async function handleListOrgIssueTypes( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const items = (await listOrgIssueTypes(ctx, org)).map(buildIssueTypeResponse); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/issue-types`); +} + +export async function handleCreateOrgIssueType( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const data = parseIssueTypeInput(body); + if ('status' in data) { return data; } + if (await hasDuplicateIssueTypeName(ctx, org, data.name)) { + return jsonValidationError(`Validation Failed: issue type '${data.name}' already exists.`); + } + + const { record, status } = await ctx.org.records.create('org/issueType' as any, { + data, + tags : issueTypeTags(data), + parentContextId : org.record.contextId ?? '', + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create issue type: ${status.detail}`); + } + + return jsonOk(buildIssueTypeResponse({ record, data })); +} + +export async function handleUpdateOrgIssueType( + ctx: AgentContext, routeOrg: string, routeIssueTypeId: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const issueType = await findOrgIssueType(ctx, org, routeIssueTypeId); + if (!issueType) { return jsonNotFound(`Issue type '${decodeRouteParam(routeIssueTypeId)}' not found.`); } + + const data = parseIssueTypeInput(body); + if ('status' in data) { return data; } + if (await hasDuplicateIssueTypeName(ctx, org, data.name, issueType.record.id)) { + return jsonValidationError(`Validation Failed: issue type '${data.name}' already exists.`); + } + + const { status } = await issueType.record.update({ data, tags: issueTypeTags(data) } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update issue type: ${status.detail}`); + } + + return jsonOk(buildIssueTypeResponse({ ...issueType, data })); +} + +export async function handleDeleteOrgIssueType( + ctx: AgentContext, routeOrg: string, routeIssueTypeId: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const issueType = await findOrgIssueType(ctx, org, routeIssueTypeId); + if (!issueType) { return jsonNotFound(`Issue type '${decodeRouteParam(routeIssueTypeId)}' not found.`); } + + const { status } = await issueType.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete issue type: ${status.detail}`); + } + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// Organization custom property handlers +// --------------------------------------------------------------------------- + +export async function handleListOrgCustomProperties( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const baseUrl = buildApiUrl(url); + const properties = (await listOrgCustomProperties(ctx, org)) + .map(property => buildCustomPropertyResponse(org, property.data, baseUrl)); + return jsonOk(properties); +} + +export async function handleUpsertOrgCustomProperties( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + if (!Array.isArray(body.properties)) { + return jsonValidationError('Validation Failed: properties must be an array.'); + } + + const parsed: OrgCustomPropertyData[] = []; + for (const item of body.properties) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return jsonValidationError('Validation Failed: each property must be an object.'); + } + const property = parseCustomPropertyDefinitionInput(item as Record<string, unknown>); + if ('status' in property) { return property; } + parsed.push(property); + } + + const baseUrl = buildApiUrl(url); + const responses: Record<string, unknown>[] = []; + for (const property of parsed) { + const saved = await upsertOrgCustomProperty(ctx, org, property); + if ('status' in saved) { return saved; } + responses.push(buildCustomPropertyResponse(org, saved.data, baseUrl)); + } + + return jsonOk(responses); +} + +export async function handleGetOrgCustomProperty( + ctx: AgentContext, routeOrg: string, routePropertyName: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const property = await findOrgCustomProperty(ctx, org, routePropertyName); + if (!property) { return jsonNotFound(`Custom property '${decodeRouteParam(routePropertyName)}' not found.`); } + + return jsonOk(buildCustomPropertyResponse(org, property.data, buildApiUrl(url))); +} + +export async function handlePutOrgCustomProperty( + ctx: AgentContext, routeOrg: string, routePropertyName: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const data = parseCustomPropertyDefinitionInput(body, decodeRouteParam(routePropertyName)); + if ('status' in data) { return data; } + + const saved = await upsertOrgCustomProperty(ctx, org, data); + if ('status' in saved) { return saved; } + + return jsonOk(buildCustomPropertyResponse(org, saved.data, buildApiUrl(url))); +} + +export async function handleDeleteOrgCustomProperty( + ctx: AgentContext, routeOrg: string, routePropertyName: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const property = await findOrgCustomProperty(ctx, org, routePropertyName); + if (!property) { return jsonNotFound(`Custom property '${decodeRouteParam(routePropertyName)}' not found.`); } + + const { status } = await property.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete custom property: ${status.detail}`); + } + return jsonNoContent(); +} + +export async function handleListOrgCustomPropertyValues( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const entries = filterOrgRepoCustomPropertyEntries(await listOrgRepoEntries(ctx), ctx.did, url); + const items: Record<string, unknown>[] = []; + for (const entry of entries) { + const settings = await getRepoSettingsEntry(ctx, entry); + items.push(buildOrgRepoCustomPropertiesResponse(entry, ctx.did, settings.settings)); + } + + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/properties/values`); +} + +export async function handleUpdateOrgCustomPropertyValues( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const definitions = new Map((await listOrgCustomProperties(ctx, org)).map(property => [property.data.propertyName, property.data])); + const parsed = parseOrgCustomPropertyValuesInput(body, definitions); + if ('status' in parsed) { return parsed; } + + const entries = await listOrgRepoEntries(ctx); + const byName = new Map(entries.map(entry => [entry.repo.name, entry])); + for (const repoName of parsed.repositoryNames) { + if (!byName.has(repoName)) { + return jsonNotFound(`Repository '${repoName}' not found in organization '${org.routeLogin}'.`); + } + } + + for (const repoName of parsed.repositoryNames) { + const entry = byName.get(repoName); + if (!entry) { continue; } + + const lookup = await getRepoSettingsEntry(ctx, entry); + const customProperties = { ...(lookup.settings.customProperties ?? {}) }; + for (const [propertyName, value] of Object.entries(parsed.values)) { + if (value === null) { + delete customProperties[propertyName]; + } else { + customProperties[propertyName] = value; + } + } + + const settings = { ...lookup.settings }; + if (Object.keys(customProperties).length > 0) { + settings.customProperties = customProperties; + } else { + delete settings.customProperties; + } + + const saveError = await saveRepoSettingsEntry(ctx, lookup, settings); + if (saveError) { return saveError; } + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// Team handlers +// --------------------------------------------------------------------------- + +export async function handleListOrgTeams( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const baseUrl = buildApiUrl(url); + const items = (await listTeams(ctx, org)).map(team => buildTeamResponse(team, org, baseUrl)); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/teams`); +} + +export async function handleListAuthenticatedUserTeams( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + const matches: Array<{ members: TeamMemberEntry[]; org: OrgEntry; team: TeamEntry }> = []; + for (const org of await listOrgEntries(ctx)) { + for (const team of await listTeams(ctx, org)) { + const members = await listTeamMembers(ctx, team); + if (members.some(member => member.data.did === ctx.did && teamMemberIsActive(member))) { + matches.push({ members, org, team }); + } + } + } + + matches.sort((a, b) => `${a.org.routeLogin}/${a.team.slug}`.localeCompare(`${b.org.routeLogin}/${b.team.slug}`)); + + const baseUrl = buildApiUrl(url); + const items = matches.map(({ members, org, team }) => buildDetailedTeamResponse(team, org, baseUrl, members.filter(teamMemberIsActive).length)); + return pagedResponse(items, url, '/user/teams'); +} + +export async function handleCreateOrgTeam( + ctx: AgentContext, routeOrg: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name is required.'); + } + + const privacy = teamPrivacyFromGitHub(body.privacy); + if (!privacy) { + return jsonValidationError('Validation Failed: privacy must be closed, visible, or secret.'); + } + if (body.description !== undefined && body.description !== null && typeof body.description !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + + const name = body.name.trim(); + const existing = await findTeam(ctx, org, slugify(name)); + if (existing) { + return jsonValidationError(`Validation Failed: team '${name}' already exists.`); + } + + const data: TeamData = { + name, + privacy, + ...(typeof body.description === 'string' ? { description: body.description } : {}), + }; + const { record, status } = await ctx.org.records.create('org/team' as any, { + data, + parentContextId: org.record.contextId ?? '', + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create team: ${status.detail}`); + } + + const team = { record, data, slug: slugify(data.name) }; + return jsonCreated(buildTeamResponse(team, org, buildApiUrl(url))); +} + +export async function handleGetTeamById( + ctx: AgentContext, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return jsonOk(buildTeamResponse(lookup.team, lookup.org, buildApiUrl(url))); +} + +export async function handleGetOrgTeamById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return jsonOk(buildTeamResponse(lookup.team, lookup.org, buildApiUrl(url))); +} + +export async function handleGetOrgTeam( + ctx: AgentContext, routeOrg: string, routeTeam: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + return jsonOk(buildTeamResponse(team, org, buildApiUrl(url))); +} + +export async function handleUpdateOrgTeam( + ctx: AgentContext, routeOrg: string, routeTeam: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const next: TeamData = { ...team.data }; + if (body.name !== undefined) { + if (!isNonEmptyString(body.name)) { + return jsonValidationError('Validation Failed: name must be a non-empty string.'); + } + const name = body.name.trim(); + const existing = await findTeam(ctx, org, slugify(name)); + if (existing && existing.record.id !== team.record.id) { + return jsonValidationError(`Validation Failed: team '${name}' already exists.`); + } + next.name = name; + } + if (body.description !== undefined) { + if (body.description !== null && typeof body.description !== 'string') { + return jsonValidationError('Validation Failed: description must be a string or null.'); + } + next.description = body.description ?? undefined; + } + if (body.privacy !== undefined) { + const privacy = teamPrivacyFromGitHub(body.privacy); + if (!privacy) { + return jsonValidationError('Validation Failed: privacy must be closed, visible, or secret.'); + } + next.privacy = privacy; + } + if ( + body.notification_setting !== undefined && + body.notification_setting !== 'notifications_enabled' && + body.notification_setting !== 'notifications_disabled' + ) { + return jsonValidationError('Validation Failed: notification_setting must be notifications_enabled or notifications_disabled.'); + } + if (body.permission !== undefined && body.permission !== 'pull' && body.permission !== 'push' && body.permission !== 'admin') { + return jsonValidationError('Validation Failed: permission must be pull, push, or admin.'); + } + if (body.parent_team_id !== undefined && body.parent_team_id !== null) { + return jsonValidationError('Validation Failed: nested teams are not supported by this forge.'); + } + + const { status } = await team.record.update({ data: next }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update team: ${status.detail}`); + } + + return jsonOk(buildTeamResponse({ ...team, data: next, slug: slugify(next.name) }, org, buildApiUrl(url))); +} + +export async function handleUpdateTeamById( + ctx: AgentContext, routeTeamId: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleUpdateOrgTeam(ctx, lookup.org.routeLogin, lookup.team.slug, body, url); +} + +export async function handleUpdateOrgTeamById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleUpdateOrgTeam(ctx, lookup.org.routeLogin, lookup.team.slug, body, url); +} + +export async function handleListOrgTeamChildTeams( + ctx: AgentContext, routeOrg: string, routeTeam: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + return pagedResponse([], url, `/orgs/${routeOrgPath(org)}/teams/${encodeURIComponent(team.slug)}/teams`); +} + +export async function handleListTeamChildTeamsById( + ctx: AgentContext, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamChildTeams(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgTeamChildTeamsById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamChildTeams(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgTeamInvitations( + ctx: AgentContext, routeOrg: string, routeTeam: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const baseUrl = buildApiUrl(url); + const invitations = (await listTeamMembers(ctx, team)) + .filter(member => teamMemberState(member) === 'pending') + .map(member => buildTeamInvitationResponse(team, member, org, baseUrl, ctx.did)); + return pagedResponse(invitations, url, `/orgs/${routeOrgPath(org)}/teams/${encodeURIComponent(team.slug)}/invitations`); +} + +export async function handleListTeamInvitationsById( + ctx: AgentContext, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamInvitations(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgTeamInvitationsById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamInvitations(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgInvitations( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const role = orgInvitationRoleFilter(url.searchParams.get('role')); + if (!role) { + return jsonValidationError('Validation Failed: role must be one of all, admin, direct_member, billing_manager, hiring_manager.'); + } + + const source = orgInvitationSourceFilter(url.searchParams.get('invitation_source')); + if (!source) { + return jsonValidationError('Validation Failed: invitation_source must be one of all, member, scim.'); + } + + const baseUrl = buildApiUrl(url); + const invitations = (await listPendingOrgInvitations(ctx, org)) + .filter(() => role === 'all' || role === 'direct_member') + .filter(() => source === 'all' || source === 'member') + .map(invitation => buildOrgInvitationResponse(org, invitation, baseUrl, ctx.did)); + return pagedResponse(invitations, url, `/orgs/${routeOrgPath(org)}/invitations`); +} + +export async function handleListFailedOrgInvitations( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + return pagedResponse([], url, `/orgs/${routeOrgPath(org)}/failed_invitations`); +} + +export async function handleListOutsideCollaborators( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const filter = outsideCollaboratorFilter(url.searchParams.get('filter')); + if (!filter) { + return jsonValidationError('Validation Failed: filter must be one of all, 2fa_disabled, or 2fa_insecure.'); + } + + const baseUrl = buildApiUrl(url); + const collaborators = filter === 'all' + ? await listOutsideCollaborators(ctx, org) + : []; + const items = collaborators.map(collab => buildOutsideCollaboratorUser(collab, baseUrl)); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/outside_collaborators`); +} + +export async function handleConvertOrgMemberToOutsideCollaborator( + ctx: AgentContext, routeOrg: string, memberDid: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + if (body.async !== undefined && typeof body.async !== 'boolean') { + return jsonValidationError('Validation Failed: async must be a boolean.'); + } + + const did = decodeRouteParam(memberDid); + const members = await listOrgMemberRecords(ctx, org); + const matching = members.filter(member => member.data.did === did); + if (matching.length === 0) { + return jsonForbidden(`User '${did}' is not an organization member.`); + } + + const ownerDids = orgOwnerDidSet(members); + if (matching.some(member => member.role === 'admin') && ownerDids.size <= 1) { + return jsonForbidden(`User '${did}' is the last organization owner.`); + } + + for (const member of matching) { + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to convert organization member to outside collaborator: ${status.detail}`); + } + } + + return body.async === true ? jsonAccepted({}) : jsonNoContent(); +} + +export async function handleRemoveOutsideCollaborator( + ctx: AgentContext, routeOrg: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findOrgMember(ctx, org, did); + if (member) { + return jsonValidationError(`Validation Failed: User '${did}' is an organization member.`); + } + + const roleDelete = await deleteOutsideCollaboratorRepoRoles(ctx, did); + if (roleDelete) { return roleDelete; } + + const teamDelete = await deleteOutsideCollaboratorTeamMemberships(ctx, org, did); + if (teamDelete) { return teamDelete; } + + return jsonNoContent(); +} + +export async function handleListOrgInvitationTeams( + ctx: AgentContext, routeOrg: string, routeInvitationId: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const invitation = await findOrgInvitation(ctx, org, routeInvitationId); + if (!invitation) { + return jsonNotFound(`Organization invitation '${decodeRouteParam(routeInvitationId)}' not found.`); + } + + const baseUrl = buildApiUrl(url); + const teams = invitation.teams.map(team => buildTeamResponse(team, org, baseUrl)); + return pagedResponse(teams, url, `/orgs/${routeOrgPath(org)}/invitations/${decodeRouteParam(routeInvitationId)}/teams`); +} + +export async function handleCancelOrgInvitation( + ctx: AgentContext, routeOrg: string, routeInvitationId: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const invitation = await findOrgInvitation(ctx, org, routeInvitationId); + if (!invitation) { + return jsonNotFound(`Organization invitation '${decodeRouteParam(routeInvitationId)}' not found.`); + } + + for (const member of invitation.members) { + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to cancel organization invitation: ${status.detail}`); + } + } + return jsonNoContent(); +} + +export async function handleDeleteOrgTeam( + ctx: AgentContext, routeOrg: string, routeTeam: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + for (const member of await listTeamMembers(ctx, team)) { + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete team membership: ${status.detail}`); + } + } + + const { status } = await team.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete team: ${status.detail}`); + } + return jsonNoContent(); +} + +export async function handleDeleteTeamById( + ctx: AgentContext, routeTeamId: string, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleDeleteOrgTeam(ctx, lookup.org.routeLogin, lookup.team.slug); +} + +export async function handleDeleteOrgTeamById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleDeleteOrgTeam(ctx, lookup.org.routeLogin, lookup.team.slug); +} + +export async function handleListOrgTeamRepos( + ctx: AgentContext, routeOrg: string, routeTeam: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const baseUrl = buildApiUrl(url); + const grants = Object.values(teamRepositoryGrants(team)).sort((a, b) => teamRepoKey(a.owner, a.repo).localeCompare(teamRepoKey(b.owner, b.repo))); + const items: Record<string, unknown>[] = []; + for (const grant of grants) { + const repo = await getRepoRecord(ctx, grant.owner, grant.repo); + if (!repo) { continue; } + items.push(buildTeamRepositoryResponse(repo, grant.owner, repo.name, baseUrl, grant.permission)); + } + + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/teams/${encodeURIComponent(team.slug)}/repos`); +} + +export async function handleListTeamReposById( + ctx: AgentContext, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamRepos(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgTeamReposById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamRepos(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListRepoTeams( + ctx: AgentContext, routeOwner: string, routeRepo: string, url: URL, +): Promise<JsonResponse> { + const owner = decodeRouteParam(routeOwner); + const repoName = decodeRouteParam(routeRepo); + const repo = await getRepoRecord(ctx, owner, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${owner}'.`); + } + + const matches: Array<{ org: OrgEntry; permission: TeamRepositoryPermission; team: TeamEntry }> = []; + for (const org of await listOrgEntries(ctx)) { + for (const team of await listTeams(ctx, org)) { + const grant = Object.values(teamRepositoryGrants(team)) + .find(item => item.owner === owner && item.repo === repo.name); + if (grant) { + matches.push({ org, permission: grant.permission, team }); + } + } + } + + matches.sort((a, b) => `${a.org.routeLogin}/${a.team.slug}`.localeCompare(`${b.org.routeLogin}/${b.team.slug}`)); + + const baseUrl = buildApiUrl(url); + const items = matches.map(({ org, permission, team }) => ({ + ...buildTeamResponse(team, org, baseUrl), + permission, + })); + return pagedResponse(items, url, `/repos/${owner}/${repo.name}/teams`); +} + +export async function handleCheckOrgTeamRepo( + ctx: AgentContext, routeOrg: string, routeTeam: string, routeOwner: string, routeRepo: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const owner = decodeRouteParam(routeOwner); + const repoName = decodeRouteParam(routeRepo); + const grant = teamRepositoryGrants(team)[teamRepoKey(owner, repoName)]; + if (!grant) { + return jsonNotFound(`Team '${team.slug}' does not have repository '${owner}/${repoName}'.`); + } + + const repo = await getRepoRecord(ctx, grant.owner, grant.repo); + if (!repo) { + return jsonNotFound(`Repository '${owner}/${repoName}' not found.`); + } + + return jsonOk(buildTeamRepositoryResponse(repo, grant.owner, repo.name, buildApiUrl(url), grant.permission)); +} + +export async function handleCheckTeamRepoById( + ctx: AgentContext, routeTeamId: string, routeOwner: string, routeRepo: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleCheckOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo, url); +} + +export async function handleCheckOrgTeamRepoById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, routeOwner: string, routeRepo: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleCheckOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo, url); +} + +export async function handleAddOrUpdateOrgTeamRepo( + ctx: AgentContext, routeOrg: string, routeTeam: string, routeOwner: string, routeRepo: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const owner = decodeRouteParam(routeOwner); + const repoName = decodeRouteParam(routeRepo); + if (owner !== ctx.did) { + return jsonValidationError('Validation Failed: repository must be owned by the organization.'); + } + + const repo = await getRepoRecord(ctx, owner, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${owner}'.`); + } + + const permission = teamRepositoryPermission(body.permission); + if (!permission) { + return jsonValidationError('Validation Failed: permission must be pull, triage, push, maintain, or admin.'); + } + + const repositories = { + ...teamRepositoryGrants(team), + [teamRepoKey(owner, repo.name)]: { owner, repo: repo.name, permission }, + }; + const next: TeamData = { ...team.data, repositories }; + const { status } = await team.record.update({ data: next }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update team repository permissions: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleAddOrUpdateTeamRepoById( + ctx: AgentContext, routeTeamId: string, routeOwner: string, routeRepo: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleAddOrUpdateOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo, body); +} + +export async function handleAddOrUpdateOrgTeamRepoById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, routeOwner: string, routeRepo: string, body: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleAddOrUpdateOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo, body); +} + +export async function handleRemoveOrgTeamRepo( + ctx: AgentContext, routeOrg: string, routeTeam: string, routeOwner: string, routeRepo: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const owner = decodeRouteParam(routeOwner); + const repoName = decodeRouteParam(routeRepo); + const repositories = teamRepositoryGrants(team); + const key = teamRepoKey(owner, repoName); + if (!repositories[key]) { + return jsonNotFound(`Team '${team.slug}' does not have repository '${owner}/${repoName}'.`); + } + + delete repositories[key]; + const next: TeamData = { ...team.data, repositories }; + const { status } = await team.record.update({ data: next }); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove team repository permissions: ${status.detail}`); + } + + return jsonNoContent(); +} + +export async function handleRemoveTeamRepoById( + ctx: AgentContext, routeTeamId: string, routeOwner: string, routeRepo: string, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleRemoveOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo); +} + +export async function handleRemoveOrgTeamRepoById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, routeOwner: string, routeRepo: string, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleRemoveOrgTeamRepo(ctx, lookup.org.routeLogin, lookup.team.slug, routeOwner, routeRepo); +} + +export async function handleListOrgTeamMembers( + ctx: AgentContext, routeOrg: string, routeTeam: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const roleFilter = teamMemberRoleFilter(url.searchParams.get('role')); + if (!roleFilter) { + return jsonValidationError('Validation Failed: role must be one of member, maintainer, all.'); + } + + const baseUrl = buildApiUrl(url); + const orgOwnerDids = orgOwnerDidSet(await listOrgMembers(ctx, org)); + const members = await listTeamMembers(ctx, team); + const items = members + .filter(teamMemberIsActive) + .filter(member => roleFilter === 'all' || teamMemberEffectiveRole(member, orgOwnerDids) === roleFilter) + .map(member => buildOrgUser(member, baseUrl)); + return pagedResponse(items, url, `/orgs/${routeOrgPath(org)}/teams/${encodeURIComponent(team.slug)}/members`); +} + +export async function handleListTeamMembersById( + ctx: AgentContext, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamMembers(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleListOrgTeamMembersById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleListOrgTeamMembers(ctx, lookup.org.routeLogin, lookup.team.slug, url); +} + +export async function handleCheckOrgTeamMember( + ctx: AgentContext, routeOrg: string, routeTeam: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const member = await findTeamMember(ctx, team, decodeRouteParam(memberDid)); + return member && teamMemberIsActive(member) ? jsonNoContent() : jsonNotFound(`User '${decodeRouteParam(memberDid)}' is not a team member.`); +} + +export async function handleCheckTeamMemberById( + ctx: AgentContext, routeTeamId: string, memberDid: string, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleCheckOrgTeamMember(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid); +} + +export async function handleAddOrgTeamMembership( + ctx: AgentContext, routeOrg: string, routeTeam: string, memberDid: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const did = decodeRouteParam(memberDid); + if (!isNonEmptyString(did)) { + return jsonValidationError('Validation Failed: member must be a DID.'); + } + + const role = teamMemberRole(body.role); + if (!role) { + return jsonValidationError('Validation Failed: role must be one of member, maintainer.'); + } + + const baseUrl = buildApiUrl(url); + const orgMembers = await listOrgMembers(ctx, org); + const orgOwnerDids = orgOwnerDidSet(orgMembers); + const state: TeamMemberState = orgMemberDidSet(orgMembers).has(did) ? 'active' : 'pending'; + const existing = await findTeamMember(ctx, team, did); + if (existing) { + const alias = typeof body.alias === 'string' ? body.alias : existing.data.alias; + const nextData: TeamMemberData = { ...existing.data, role, state, ...(alias ? { alias } : {}) }; + const { status } = await existing.record.update({ data: nextData }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update team membership: ${status.detail}`); + } + const updated = { ...existing, data: nextData }; + return jsonOk(buildTeamMembershipResponse(team, updated, org, baseUrl, teamMemberEffectiveRole(updated, orgOwnerDids), teamMemberState(updated))); + } + + const alias = typeof body.alias === 'string' ? body.alias : undefined; + const data: TeamMemberData = { did, role, state, ...(alias ? { alias } : {}) }; + const { record, status } = await ctx.org.records.create('org/team/teamMember' as any, { + data, + tags : { did }, + parentContextId : team.record.contextId ?? '', + recipient : did, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to add team membership: ${status.detail}`); + } + + const member = { record, data }; + return jsonOk(buildTeamMembershipResponse(team, member, org, baseUrl, teamMemberEffectiveRole(member, orgOwnerDids), teamMemberState(member))); +} + +export async function handleAddTeamMembershipById( + ctx: AgentContext, routeTeamId: string, memberDid: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleAddOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid, body, url); +} + +export async function handleAddOrgTeamMembershipById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, memberDid: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleAddOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid, body, url); +} + +export async function handleGetOrgTeamMembership( + ctx: AgentContext, routeOrg: string, routeTeam: string, memberDid: string, url: URL, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const did = decodeRouteParam(memberDid); + const member = await findTeamMember(ctx, team, did); + if (!member) { + return jsonNotFound(`User '${did}' is not a team member.`); + } + + const orgOwnerDids = orgOwnerDidSet(await listOrgMembers(ctx, org)); + const role = teamMemberEffectiveRole(member, orgOwnerDids); + const state = teamMemberState(member); + return jsonOk(buildTeamMembershipResponse(team, member, org, buildApiUrl(url), role, state)); +} + +export async function handleGetTeamMembershipById( + ctx: AgentContext, routeTeamId: string, memberDid: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleGetOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid, url); +} + +export async function handleGetOrgTeamMembershipById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, memberDid: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleGetOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid, url); +} + +export async function handleRemoveOrgTeamMembership( + ctx: AgentContext, routeOrg: string, routeTeam: string, memberDid: string, +): Promise<JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); } + + const team = await findTeam(ctx, org, routeTeam); + if (!team) { return jsonNotFound(`Team '${decodeRouteParam(routeTeam)}' not found.`); } + + const member = await findTeamMember(ctx, team, decodeRouteParam(memberDid)); + if (!member) { return jsonNotFound(`User '${decodeRouteParam(memberDid)}' is not a team member.`); } + + const { status } = await member.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to remove team membership: ${status.detail}`); + } + return jsonNoContent(); +} + +export async function handleRemoveTeamMembershipById( + ctx: AgentContext, routeTeamId: string, memberDid: string, +): Promise<JsonResponse> { + const lookup = await findTeamByNumericId(ctx, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleRemoveOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid); +} + +export async function handleRemoveOrgTeamMembershipById( + ctx: AgentContext, routeOrgId: string, routeTeamId: string, memberDid: string, +): Promise<JsonResponse> { + const lookup = await findOrgTeamByNumericIds(ctx, routeOrgId, routeTeamId); + if (!lookup) { return jsonNotFound(`Team '${decodeRouteParam(routeTeamId)}' not found.`); } + + return handleRemoveOrgTeamMembership(ctx, lookup.org.routeLogin, lookup.team.slug, memberDid); +} diff --git a/src/github-shim/pages.ts b/src/github-shim/pages.ts new file mode 100644 index 0000000..6939b7b --- /dev/null +++ b/src/github-shim/pages.ts @@ -0,0 +1,668 @@ +/** + * GitHub API shim — GitHub Pages endpoints. + * + * Stores GitHub-compatible Pages site, build, and deployment metadata in the + * repository settings record. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { + baseHeaders, + buildApiUrl, + buildLinkHeader, + buildOwner, + getRepoRecord, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type PagesBuildType = 'legacy' | 'workflow'; +type PagesBuildStatus = 'queued' | 'building' | 'built' | 'errored'; +type PagesDeploymentStatus = 'pending' | 'succeed' | 'failed' | 'cancelled'; + +type PagesSource = { + branch : string; + path : '/' | '/docs'; +}; + +type PagesBuildEntry = { + id : number; + status : PagesBuildStatus; + errorMessage : string | null; + pusherDid : string; + commit : string; + duration : number; + createdAt : string; + updatedAt : string; +}; + +type PagesDeploymentEntry = { + id : string; + artifactId? : number; + artifactUrl? : string; + environment : string; + pagesBuildVersion : string; + oidcTokenHash : string; + status : PagesDeploymentStatus; + createdAt : string; + updatedAt : string; +}; + +type PagesSettingsEntry = { + status : PagesBuildStatus; + cname : string | null; + custom404 : boolean; + source : PagesSource | null; + buildType : PagesBuildType; + public : boolean; + httpsEnforced : boolean; + createdAt : string; + updatedAt : string; + builds? : Record<string, PagesBuildEntry>; + deployments? : Record<string, PagesDeploymentEntry>; +}; + +type RepoSettingsData = { + pages? : PagesSettingsEntry; +}; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const { records } = await ctx.repo.records.query('repo/settings' as any, { + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function jsonConflict(message: string): JsonResponse { + return { + status : 409, + headers : baseHeaders(), + body : JSON.stringify({ message, documentation_url: 'https://docs.github.com/rest/pages/pages' }), + }; +} + +function pagesKey(id: string | number): string { + return String(id); +} + +function sha1(...parts: string[]): string { + return createHash('sha1').update(parts.join(':')).digest('hex'); +} + +function pagesHtmlUrl(site: PagesSettingsEntry, targetDid: string, repoName: string): string { + if (site.cname) { + return `https://${site.cname}`; + } + return `https://${encodeURIComponent(targetDid)}.pages.enbox.local/${encodeURIComponent(repoName)}`; +} + +function parseSource(value: unknown, required: boolean): PagesSource | null | undefined | JsonResponse { + if (typeof value === 'undefined') { + if (!required) { return undefined; } + return jsonValidationError('Validation Failed: source.branch is required.'); + } + if (value === null) { + return null; + } + if (typeof value !== 'object') { + return jsonValidationError('Validation Failed: source must be an object.'); + } + + const branch = (value as { branch?: unknown }).branch; + if (typeof branch !== 'string' || branch.trim().length === 0) { + return jsonValidationError('Validation Failed: source.branch is required.'); + } + + const rawPath = (value as { path?: unknown }).path; + const path = typeof rawPath === 'undefined' ? '/' : rawPath; + if (path !== '/' && path !== '/docs') { + return jsonValidationError('Validation Failed: source.path must be / or /docs.'); + } + + return { branch: branch.trim(), path }; +} + +function parseBuildType(value: unknown): PagesBuildType | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (value !== 'legacy' && value !== 'workflow') { + return jsonValidationError('Validation Failed: build_type must be legacy or workflow.'); + } + return value; +} + +function parseOptionalBoolean(value: unknown, fieldName: string): boolean | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (typeof value !== 'boolean') { + return jsonValidationError(`Validation Failed: ${fieldName} must be a boolean.`); + } + return value; +} + +function parseOptionalCname(value: unknown): string | null | undefined | JsonResponse { + if (typeof value === 'undefined') { + return undefined; + } + if (value === null) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + return jsonValidationError('Validation Failed: cname must be a non-empty string or null.'); + } + return value.trim(); +} + +function nextBuildId(site: PagesSettingsEntry): number { + const ids = Object.values(site.builds ?? {}).map(build => build.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; +} + +function pagesBuildEntries(site: PagesSettingsEntry): PagesBuildEntry[] { + return Object.values(site.builds ?? {}).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); +} + +function buildPagesSiteResponse( + site: PagesSettingsEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const htmlUrl = pagesHtmlUrl(site, targetDid, repoName); + const certificateDomain = site.cname ?? `${targetDid}.pages.enbox.local`; + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/pages`, + status : site.status, + cname : site.cname, + custom_404 : site.custom404, + html_url : htmlUrl, + source : site.source ? { branch: site.source.branch, path: site.source.path } : null, + public : site.public, + pending_domain_unverified_at : null, + protected_domain_state : site.cname ? 'verified' : null, + https_certificate : { + state : 'approved', + description : 'Certificate is approved', + domains : [certificateDomain], + expires_at : null, + }, + https_enforced: site.httpsEnforced, + }; +} + +function buildPagesBuildResponse( + build: PagesBuildEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/pages/builds/${build.id}`, + status : build.status, + error : { message: build.errorMessage }, + pusher : buildOwner(build.pusherDid, baseUrl), + commit : build.commit, + duration : build.duration, + created_at : toISODate(build.createdAt), + updated_at : toISODate(build.updatedAt), + }; +} + +function buildDeploymentResponse( + deployment: PagesDeploymentEntry, site: PagesSettingsEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + id : deployment.id, + status_url : `${baseUrl}/repos/${targetDid}/${repoName}/pages/deployments/${deployment.id}/status`, + page_url : pagesHtmlUrl(site, targetDid, repoName), + }; +} + +function buildHealthDomain(host: string, httpsEnforced: boolean): Record<string, unknown> { + return { + host, + uri : `http://${host}/`, + nameservers : 'default', + dns_resolves : true, + is_proxied : false, + is_cloudflare_ip : false, + is_fastly_ip : false, + is_old_ip_address : false, + is_a_record : true, + has_cname_record : !host.startsWith('www.'), + has_mx_records_present : false, + is_valid_domain : true, + is_apex_domain : !host.startsWith('www.'), + should_be_a_record : !host.startsWith('www.'), + is_cname_to_github_user_domain : false, + is_cname_to_pages_dot_github_dot_com : false, + is_cname_to_fastly : false, + is_pointed_to_github_pages_ip : true, + is_non_github_pages_ip_present : false, + is_pages_domain : false, + is_served_by_pages : true, + is_valid : true, + reason : null, + responds_to_https : true, + enforces_https : httpsEnforced, + https_error : null, + is_https_eligible : true, + caa_error : null, + }; +} + +function requirePagesSite(lookup: RepoSettingsLookup): PagesSettingsEntry | JsonResponse { + if (!lookup.settings.pages) { + return jsonNotFound('GitHub Pages site not found.'); + } + return lookup.settings.pages; +} + +function isJsonResponse(value: PagesSettingsEntry | JsonResponse): value is JsonResponse { + return typeof (value as JsonResponse).status === 'number'; +} + +function createQueuedBuild(site: PagesSettingsEntry, ctx: AgentContext, lookup: RepoSettingsLookup): PagesBuildEntry { + const now = new Date().toISOString(); + const id = nextBuildId(site); + return { + id, + status : 'queued', + errorMessage : null, + pusherDid : ctx.did, + commit : sha1(lookup.repo.contextId, lookup.repo.defaultBranch, String(id), now), + duration : 0, + createdAt : now, + updatedAt : now, + }; +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/pages +// --------------------------------------------------------------------------- + +export async function handleGetPagesSite( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + return jsonOk(buildPagesSiteResponse(site, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleCreatePagesSite( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + if (lookup.settings.pages) { + return jsonConflict('GitHub Pages site already exists.'); + } + + const buildType = parseBuildType(reqBody.build_type); + if (typeof buildType === 'object') { return buildType; } + + const source = parseSource(reqBody.source, buildType !== 'workflow'); + if (typeof source === 'object' && source !== null && 'status' in source) { return source; } + + const now = new Date().toISOString(); + const site: PagesSettingsEntry = { + status : 'built', + cname : null, + custom404 : false, + source : source === undefined ? null : source, + buildType : buildType ?? 'legacy', + public : lookup.repo.visibility === 'public', + httpsEnforced : true, + createdAt : now, + updatedAt : now, + builds : {}, + deployments : {}, + }; + + const saveError = await saveRepoSettings(ctx, lookup, { ...lookup.settings, pages: site }); + if (saveError) { return saveError; } + + return jsonCreated(buildPagesSiteResponse(site, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleUpdatePagesSite( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const buildType = parseBuildType(reqBody.build_type); + if (typeof buildType === 'object') { return buildType; } + + const source = parseSource(reqBody.source, false); + if (typeof source === 'object' && source !== null && 'status' in source) { return source; } + + const cname = parseOptionalCname(reqBody.cname); + if (typeof cname === 'object' && cname !== null && 'status' in cname) { return cname; } + + const httpsEnforced = parseOptionalBoolean(reqBody.https_enforced, 'https_enforced'); + if (typeof httpsEnforced === 'object') { return httpsEnforced; } + + const updated: PagesSettingsEntry = { + ...site, + cname : cname === undefined ? site.cname : cname, + httpsEnforced : httpsEnforced ?? site.httpsEnforced, + buildType : buildType ?? site.buildType, + source : source === undefined ? site.source : source, + updatedAt : new Date().toISOString(), + }; + + const saveError = await saveRepoSettings(ctx, lookup, { ...lookup.settings, pages: updated }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleDeletePagesSite( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + if (!lookup.settings.pages) { + return jsonNotFound('GitHub Pages site not found.'); + } + + const next = { ...lookup.settings }; + delete next.pages; + const saveError = await saveRepoSettings(ctx, lookup, next); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/pages/builds +// --------------------------------------------------------------------------- + +export async function handleListPagesBuilds( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const builds = pagesBuildEntries(site); + const paged = paginate(builds, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/pages/builds`, + pagination.page, pagination.perPage, builds.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(build => buildPagesBuildResponse(build, targetDid, lookup.repo.name, baseUrl)), + extraHeaders, + ); +} + +export async function handleRequestPagesBuild( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const build = createQueuedBuild(site, ctx, lookup); + const updated: PagesSettingsEntry = { + ...site, + status : 'queued', + updatedAt : build.updatedAt, + builds : { + ...(site.builds ?? {}), + [pagesKey(build.id)]: build, + }, + }; + + const saveError = await saveRepoSettings(ctx, lookup, { ...lookup.settings, pages: updated }); + if (saveError) { return saveError; } + + return jsonCreated({ + url : `${buildApiUrl(url)}/repos/${targetDid}/${lookup.repo.name}/pages/builds/latest`, + status : build.status, + }); +} + +export async function handleGetLatestPagesBuild( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const latest = pagesBuildEntries(site)[0]; + if (!latest) { + return jsonNotFound('GitHub Pages build not found.'); + } + + return jsonOk(buildPagesBuildResponse(latest, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleGetPagesBuild( + ctx: AgentContext, targetDid: string, repoName: string, buildId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const build = site.builds?.[pagesKey(parseInt(buildId, 10))]; + if (!build) { + return jsonNotFound(`GitHub Pages build ${buildId} not found.`); + } + + return jsonOk(buildPagesBuildResponse(build, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/pages/deployments +// --------------------------------------------------------------------------- + +export async function handleCreatePagesDeployment( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const artifactId = reqBody.artifact_id; + const artifactUrl = reqBody.artifact_url; + if ( + (typeof artifactId === 'undefined' || !Number.isInteger(artifactId)) + && (typeof artifactUrl !== 'string' || artifactUrl.trim().length === 0) + ) { + return jsonValidationError('Validation Failed: artifact_id or artifact_url is required.'); + } + + const pagesBuildVersion = reqBody.pages_build_version; + if (typeof pagesBuildVersion !== 'string' || pagesBuildVersion.trim().length === 0) { + return jsonValidationError('Validation Failed: pages_build_version is required.'); + } + + const oidcToken = reqBody.oidc_token; + if (typeof oidcToken !== 'string' || oidcToken.trim().length === 0) { + return jsonValidationError('Validation Failed: oidc_token is required.'); + } + + const environment = typeof reqBody.environment === 'string' && reqBody.environment.trim() + ? reqBody.environment.trim() + : 'github-pages'; + const id = pagesBuildVersion.trim(); + const now = new Date().toISOString(); + const deployment: PagesDeploymentEntry = { + id, + artifactId : Number.isInteger(artifactId) ? artifactId as number : undefined, + artifactUrl : typeof artifactUrl === 'string' ? artifactUrl.trim() : undefined, + environment, + pagesBuildVersion : id, + oidcTokenHash : sha1(oidcToken), + status : 'succeed', + createdAt : now, + updatedAt : now, + }; + + const updated: PagesSettingsEntry = { + ...site, + status : 'built', + updatedAt : now, + deployments : { + ...(site.deployments ?? {}), + [deployment.id]: deployment, + }, + }; + + const saveError = await saveRepoSettings(ctx, lookup, { ...lookup.settings, pages: updated }); + if (saveError) { return saveError; } + + return jsonOk(buildDeploymentResponse(deployment, updated, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleGetPagesDeploymentStatus( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const deployment = site.deployments?.[deploymentId]; + if (!deployment) { + return jsonNotFound(`GitHub Pages deployment ${deploymentId} not found.`); + } + + return jsonOk({ status: deployment.status }); +} + +export async function handleCancelPagesDeployment( + ctx: AgentContext, targetDid: string, repoName: string, deploymentId: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + const deployment = site.deployments?.[deploymentId]; + if (!deployment) { + return jsonNotFound(`GitHub Pages deployment ${deploymentId} not found.`); + } + + const updatedDeployment: PagesDeploymentEntry = { + ...deployment, + status : 'cancelled', + updatedAt : new Date().toISOString(), + }; + const updated: PagesSettingsEntry = { + ...site, + deployments: { + ...(site.deployments ?? {}), + [deploymentId]: updatedDeployment, + }, + }; + + const saveError = await saveRepoSettings(ctx, lookup, { ...lookup.settings, pages: updated }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/pages/health +// --------------------------------------------------------------------------- + +export async function handleGetPagesHealth( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const site = requirePagesSite(lookup); + if (isJsonResponse(site)) { return site; } + + if (!site.cname) { + return jsonValidationError('Validation Failed: there isn\'t a CNAME for this page.'); + } + + const host = site.cname; + const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`; + return jsonOk({ + domain : buildHealthDomain(host, site.httpsEnforced), + alt_domain : buildHealthDomain(altHost, site.httpsEnforced), + }); +} diff --git a/src/github-shim/pulls.ts b/src/github-shim/pulls.ts index 1a28cff..9b0f231 100644 --- a/src/github-shim/pulls.ts +++ b/src/github-shim/pulls.ts @@ -6,14 +6,34 @@ * Endpoints: * GET /repos/:did/:repo/pulls List pull requests * GET /repos/:did/:repo/pulls/:number Pull request detail + * GET /repos/:did/:repo/pulls/:number/commits Pull request commits + * GET /repos/:did/:repo/pulls/:number/comments Pull request review comments + * GET /repos/:did/:repo/pulls/:number/merge Check if pull request has been merged + * GET /repos/:did/:repo/pulls/:number/requested_reviewers Pull request review requests * GET /repos/:did/:repo/pulls/:number/reviews Pull request reviews + * GET /repos/:did/:repo/pulls/:number/reviews/:id Pull request review + * GET /repos/:did/:repo/pulls/:number/reviews/:id/comments Pull request review comments + * GET /repos/:did/:repo/pulls/comments Pull request review comments across repository + * GET /repos/:did/:repo/pulls/comments/:id Pull request review comment + * PATCH /repos/:did/:repo/pulls/comments/:id Update pull request review comment + * DELETE /repos/:did/:repo/pulls/comments/:id Delete pull request review comment * POST /repos/:did/:repo/pulls Create pull request + * POST /repos/:did/:repo/pulls/:number/comments Create pull request review comment + * POST /repos/:did/:repo/pulls/:number/comments/:id/replies Create review comment reply + * POST /repos/:did/:repo/pulls/:number/requested_reviewers Request pull reviewers + * DELETE /repos/:did/:repo/pulls/:number/requested_reviewers Remove pull review requests * PATCH /repos/:did/:repo/pulls/:number Update pull request * PUT /repos/:did/:repo/pulls/:number/merge Merge pull request + * PUT /repos/:did/:repo/pulls/:number/update-branch Update pull request branch * POST /repos/:did/:repo/pulls/:number/reviews Create review + * PUT/PATCH /repos/:did/:repo/pulls/:number/reviews/:id Update review body + * DELETE /repos/:did/:repo/pulls/:number/reviews/:id Delete pending review + * PUT /repos/:did/:repo/pulls/:number/reviews/:id/dismissals Dismiss review + * POST /repos/:did/:repo/pulls/:number/reviews/:id/events Submit pending review * * Status mapping: * - `open` -> state: "open" + * - `draft` -> state: "open", draft: true * - `closed` -> state: "closed", merged: false * - `merged` -> state: "closed", merged: true * @@ -21,18 +41,33 @@ */ import type { AgentContext } from '../cli/agent.js'; +import type { BodyMediaKind } from './body-media.js'; +import type { GitObjectOptions } from './git-objects.js'; import type { JsonResponse } from './helpers.js'; +import { Buffer } from 'node:buffer'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + import { DateSort } from '@enbox/dwn-sdk-js'; +import { applyBodyMedia } from './body-media.js'; +import { GitBackend } from '../git-server/git-backend.js'; +import { resolveReposPath } from '../cli/flags.js'; + import { + binaryOk, buildApiUrl, buildLinkHeader, buildOwner, fromOpt, getRepoRecord, + jsonAccepted, jsonCreated, jsonMethodNotAllowed, + jsonNoContent, jsonNotFound, jsonOk, jsonValidationError, @@ -52,6 +87,121 @@ const VERDICT_MAP: Record<string, string> = { comment : 'COMMENTED', }; +const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/; + +type PatchLookup = + | { kind: 'found'; from: string | undefined; patch: any } + | { kind: 'response'; response: JsonResponse }; + +type ReviewCommentEntry = { + patch : any; + review : any; + comment : any; + data : any; + tags : Record<string, string>; +}; + +type ReviewCommentReactionEntry = { + id : number; + userDid : string; + content : ReactionContent; + createdAt : string; +}; + +type PullReviewEntry = { + patch : any; + review : any; + data : any; + tags : Record<string, string>; + override? : ReviewOverride; +}; + +type ReviewCommentLookup = + | { kind: 'found'; from: string | undefined; pullNumber: string; entry: ReviewCommentEntry } + | { kind: 'response'; response: JsonResponse }; + +type PullReviewLookup = + | { kind: 'found'; from: string | undefined; entry: PullReviewEntry } + | { kind: 'response'; response: JsonResponse }; + +type RepoReviewCommentEntry = ReviewCommentEntry & { + pullNumber : string; + commitId : string; +}; + +type PullRevisionEntry = { + record : any; + data : any; + tags : Record<string, string>; + headCommit : string; + baseCommit : string; +}; + +type GitIdentity = { + name : string; + email : string; + date : string; +}; + +type GitCommitInfo = { + sha : string; + treeSha : string; + parentShas : string[]; + author : GitIdentity; + committer : GitIdentity; + message : string; +}; + +type GitResult = { + status : number; + stdout : Buffer; +}; + +type PullTextKind = 'diff' | 'patch'; + +type GitHubReviewState = 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | 'DISMISSED'; + +type ReviewOverride = { + body? : string; + state? : GitHubReviewState; + submittedAt? : string | null; + deleted? : boolean; + dismissedAt? : string; + dismissedMessage? : string; + commitId? : string; +}; + +type ReviewCommentDraft = { + body : string; + path : string; + line : number | null; + startLine? : number | null; + side : string; + startSide? : string | null; + subjectType : 'line' | 'file'; + diffHunk : string; +}; + +type ReviewCommentDraftsResult = + | { kind: 'ok'; drafts: ReviewCommentDraft[] } + | { kind: 'error'; message: string }; + +type RepoSettingsData = Record<string, unknown> & { + pullReviewCommentReactions? : Record<string, Record<string, ReviewCommentReactionEntry>>; +}; + +type RepoSettingsLookup = { + repo : { contextId: string; name: string }; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +const REACTION_CONTENTS = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes'] as const; +const REACTION_CONTENT_SET = new Set<string>(REACTION_CONTENTS); +type ReactionContent = typeof REACTION_CONTENTS[number]; + // --------------------------------------------------------------------------- // Pull request object builder // --------------------------------------------------------------------------- @@ -59,7 +209,7 @@ const VERDICT_MAP: Record<string, string> = { async function buildPullResponse( ctx: AgentContext, rec: any, data: any, tags: Record<string, string>, targetDid: string, repoName: string, baseUrl: string, - from?: string, + from?: string, bodyMediaKind?: BodyMediaKind | null, ): Promise<Record<string, unknown>> { const owner = buildOwner(targetDid, baseUrl); const sourceDid = tags.sourceDid; @@ -71,6 +221,8 @@ async function buildPullResponse( const state = (dwnStatus === 'open' || draft) ? 'open' : 'closed'; const baseBranch = tags.baseBranch ?? 'main'; const headBranch = tags.headBranch ?? ''; + const requestedReviewers = storedStringArray(data.requestedReviewers); + const requestedTeams = storedStringArray(data.requestedTeams); // Fetch latest revision to populate commit + diff stats. let headSha = ''; @@ -124,7 +276,7 @@ async function buildPullResponse( } } - return { + return applyBodyMedia({ id : numericId(rec.id ?? ''), node_id : rec.id ?? '', url : `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${number}`, @@ -137,7 +289,6 @@ async function buildPullResponse( comments_url : `${baseUrl}/repos/${targetDid}/${repoName}/issues/${number}/comments`, number, title : data.title ?? '', - body : data.body ?? null, state, locked : false, merged, @@ -164,172 +315,1723 @@ async function buildPullResponse( labels : [], assignees : [], milestone : null, - requested_reviewers : [], + requested_reviewers : requestedReviewers.map(did => buildOwner(did, baseUrl)), + requested_teams : requestedTeams.map(slug => buildRequestedTeam(slug, baseUrl)), commits, additions, deletions, changed_files : changedFiles, + }, data.body ?? null, bodyMediaKind); +} + +async function findPatchForPull( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<PatchLookup> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return { + kind : 'response', + response : jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`), + }; + } + + const from = fromOpt(ctx, targetDid); + const num = parseInt(number, 10); + const { records } = await ctx.patches.records.query('repo/patch', { + from, + filter: { contextId: repo.contextId }, + }); + const patch = records.find(r => numericId(r.id ?? '') === num); + if (!patch) { + return { + kind : 'response', + response : jsonNotFound(`Pull request #${number} not found.`), + }; + } + + return { kind: 'found', from, patch }; +} + +async function latestRevisionHead(ctx: AgentContext, from: string | undefined, patch: any): Promise<string> { + const { records } = await ctx.patches.records.query('repo/patch/revision' as any, { + from, + filter : { contextId: patch.contextId }, + dateSort : DateSort.CreatedDescending, + }); + const tags = (records[0]?.tags as Record<string, string> | undefined) ?? {}; + return tags.headCommit ?? ''; +} + +async function latestPullRevision( + ctx: AgentContext, from: string | undefined, patch: any, +): Promise<PullRevisionEntry | null> { + const { records } = await ctx.patches.records.query('repo/patch/revision' as any, { + from, + filter : { contextId: patch.contextId }, + dateSort : DateSort.CreatedDescending, + }); + const record = records[0]; + if (!record) { + return null; + } + + let data: any = {}; + try { + data = await record.data.json(); + } catch { + data = {}; + } + + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + return { + record, + data, + tags, + headCommit : tags.headCommit ?? '', + baseCommit : tags.baseCommit ?? '', }; } -// --------------------------------------------------------------------------- -// GET /repos/:did/:repo/pulls +async function latestRevisionBundleBytes( + ctx: AgentContext, from: string | undefined, revision: PullRevisionEntry, +): Promise<Uint8Array | null> { + const { records } = await ctx.patches.records.query('repo/patch/revision/revisionBundle' as any, { + from, + filter : { contextId: revision.record.contextId }, + dateSort : DateSort.CreatedDescending, + }); + const record = records[0]; + if (!record) { + return null; + } + + try { + const blob = await record.data.blob(); + return new Uint8Array(await blob.arrayBuffer()); + } catch { + return null; + } +} + +function localRepoPath( + ctx: AgentContext, targetDid: string, repoName: string, options: GitObjectOptions, +): string | null { + const reposPath = options.reposPath ?? resolveReposPath([], ctx.profileName ?? null); + const backend = new GitBackend({ basePath: reposPath }); + + try { + if (!backend.exists(targetDid, repoName)) { + return null; + } + return backend.repoPath(targetDid, repoName); + } catch { + return null; + } +} + +async function runGit(repoPath: string, args: string[]): Promise<GitResult> { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', repoPath, ...args], { + env : process.env, + stdio : ['ignore', 'pipe', 'ignore'], + }); + + const stdout: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.on('error', reject); + child.on('close', code => resolve({ + status : code ?? 128, + stdout : Buffer.concat(stdout), + })); + }); +} + +async function gitOk(repoPath: string, args: string[]): Promise<Buffer | null> { + const result = await runGit(repoPath, args); + return result.status === 0 ? result.stdout : null; +} + +function gitLines(out: Buffer | null): string[] { + if (!out) { + return []; + } + return out.toString('utf-8').split(/\r?\n/).map(line => line.trim()).filter(Boolean); +} + +async function resolveCommit(repoPath: string, sha: string): Promise<string | null> { + if (!FULL_SHA_RE.test(sha)) { + return null; + } + + const out = await gitOk(repoPath, ['rev-parse', '--verify', `${sha}^{commit}`]); + const resolved = out?.toString('utf-8').trim().toLowerCase() ?? ''; + return FULL_SHA_RE.test(resolved) ? resolved : null; +} + +async function readCommit(repoPath: string, rawSha: string): Promise<GitCommitInfo | null> { + const sha = await resolveCommit(repoPath, rawSha); + if (!sha) { + return null; + } + + const out = await gitOk(repoPath, [ + 'show', + '-s', + '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B', + sha, + ]); + if (!out) { + return null; + } + + const parts = out.toString('utf-8').split('\0'); + if (parts.length < 10) { + return null; + } + + return { + sha : parts[0], + treeSha : parts[1], + parentShas : parts[2] ? parts[2].split(' ') : [], + author : { name: parts[3], email: parts[4], date: parts[5] }, + committer : { name: parts[6], email: parts[7], date: parts[8] }, + message : parts.slice(9).join('\0').replace(/\n+$/, ''), + }; +} + +async function listPullCommitShas(repoPath: string, baseSha: string, headSha: string): Promise<string[] | null> { + if (!FULL_SHA_RE.test(headSha)) { + return null; + } + if (!FULL_SHA_RE.test(baseSha)) { + return [headSha.toLowerCase()]; + } + + const out = await gitOk(repoPath, ['rev-list', '--reverse', '--max-count=250', `${baseSha}..${headSha}`]); + return out ? gitLines(out) : null; +} + +async function pullRevisionText( + repoPath: string, revision: PullRevisionEntry, kind: PullTextKind, +): Promise<Buffer | null> { + const headSha = await resolveCommit(repoPath, revision.headCommit); + if (!headSha) { + return null; + } + const baseSha = await resolveCommit(repoPath, revision.baseCommit); + + const args = kind === 'diff' + ? (baseSha + ? ['diff', '--patch', '--find-renames', baseSha, headSha] + : ['show', '--format=', '--find-renames', headSha]) + : (baseSha + ? ['format-patch', '--stdout', '--find-renames', `${baseSha}..${headSha}`] + : ['format-patch', '--stdout', '--find-renames', '-1', headSha]); + const result = await runGit(repoPath, args); + return result.status === 0 ? result.stdout : null; +} + +async function pullRevisionTextFromBundle( + localPath: string | null, bundleBytes: Uint8Array, revision: PullRevisionEntry, kind: PullTextKind, +): Promise<Buffer | null> { + const tempRoot = mkdtempSync(join(tmpdir(), 'gitd-pr-text-')); + try { + const tempRepoPath = join(tempRoot, 'repo.git'); + const setup = localPath + ? await runGit(tempRoot, ['clone', '--bare', '--no-hardlinks', localPath, tempRepoPath]) + : await runGit(tempRoot, ['init', '--bare', tempRepoPath]); + if (setup.status !== 0) { + return null; + } + + const bundlePath = join(tempRoot, 'revision.bundle'); + writeFileSync(bundlePath, bundleBytes); + const fetch = await runGit(tempRepoPath, ['fetch', bundlePath]); + if (fetch.status !== 0) { + return null; + } + + return pullRevisionText(tempRepoPath, revision, kind); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function nodeId(kind: string, id: string): string { + return Buffer.from(`${kind}:${id}`, 'utf-8').toString('base64'); +} + +function verification(): Record<string, unknown> { + return { + verified : false, + reason : 'unsigned', + signature : null, + payload : null, + verified_at : null, + }; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function storedStringArray(value: unknown): string[] { + return Array.isArray(value) ? uniqueStrings(value.filter((item): item is string => typeof item === 'string')) : []; +} + +function requestStringArray(value: unknown): string[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) { + return null; + } + return uniqueStrings(value); +} + +function buildRequestedTeam(slug: string, baseUrl: string): Record<string, unknown> { + const id = numericId(slug); + return { + id, + node_id : nodeId('team', slug), + url : `${baseUrl}/teams/${id}`, + html_url : `${baseUrl}/teams/${slug}`, + name : slug, + slug, + description : null, + privacy : 'closed', + notification_setting : 'notifications_enabled', + permission : 'pull', + members_url : `${baseUrl}/teams/${id}/members{/member}`, + repositories_url : `${baseUrl}/teams/${id}/repos`, + parent : null, + }; +} + +function requestedReviewersPayload(data: any, baseUrl: string): Record<string, unknown> { + const reviewers = storedStringArray(data.requestedReviewers); + const teams = storedStringArray(data.requestedTeams); + + return { + users : reviewers.map(did => buildOwner(did, baseUrl)), + teams : teams.map(slug => buildRequestedTeam(slug, baseUrl)), + }; +} + +async function pullData(record: any): Promise<any> { + try { + return await record.data.json(); + } catch { + return {}; + } +} + +function reviewOverrides(data: any): Record<string, ReviewOverride> { + if (!data.reviewOverrides || typeof data.reviewOverrides !== 'object' || Array.isArray(data.reviewOverrides)) { + return {}; + } + return data.reviewOverrides as Record<string, ReviewOverride>; +} + +function reviewOverrideKey(review: any): string { + return review.id ?? String(numericId(review.id ?? '')); +} + +function reviewCommentReactionKey(entry: ReviewCommentEntry): string { + return entry.comment.id ?? String(numericId(entry.comment.id ?? '')); +} + +function reactionKey(id: number): string { + return String(id); +} + +function parseReactionContent(value: unknown): ReactionContent | JsonResponse { + if (typeof value !== 'string' || !REACTION_CONTENT_SET.has(value)) { + return jsonValidationError( + `Validation Failed: content must be one of ${REACTION_CONTENTS.map(content => `'${content}'`).join(', ')}.`, + ); + } + return value as ReactionContent; +} + +function validReviewState(value: unknown): value is GitHubReviewState { + return value === 'APPROVED' + || value === 'CHANGES_REQUESTED' + || value === 'COMMENTED' + || value === 'PENDING' + || value === 'DISMISSED'; +} + +function reviewState(entry: PullReviewEntry): GitHubReviewState { + const overrideState = entry.override?.state; + if (validReviewState(overrideState)) { + return overrideState; + } + + const dataState = entry.data.githubReviewState; + if (validReviewState(dataState)) { + return dataState; + } + + const verdict = entry.tags.verdict ?? 'comment'; + return (VERDICT_MAP[verdict] as GitHubReviewState | undefined) ?? 'COMMENTED'; +} + +function eventToReviewState(event: unknown): GitHubReviewState | null { + const normalized = typeof event === 'string' ? event.toUpperCase() : ''; + if (normalized === 'APPROVE') { + return 'APPROVED'; + } + if (normalized === 'REQUEST_CHANGES') { + return 'CHANGES_REQUESTED'; + } + if (normalized === 'COMMENT') { + return 'COMMENTED'; + } + return null; +} + +function stateToVerdict(state: GitHubReviewState): string { + if (state === 'APPROVED') { + return 'approve'; + } + if (state === 'CHANGES_REQUESTED') { + return 'reject'; + } + return 'comment'; +} + +async function saveReviewOverride( + patch: any, review: any, patchData: any, override: ReviewOverride, +): Promise<{ status: any; data: any; override: ReviewOverride }> { + const overrides = reviewOverrides(patchData); + const key = reviewOverrideKey(review); + const nextOverride = { ...(overrides[key] ?? {}), ...override }; + const updatedData = { + ...patchData, + reviewOverrides: { + ...overrides, + [key]: nextOverride, + }, + }; + const tags = (patch.tags as Record<string, string> | undefined) ?? {}; + const { status } = await patch.update({ data: updatedData, tags }); + return { status, data: updatedData, override: nextOverride }; +} + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function reviewCommentReactionEntries( + settings: RepoSettingsData, entry: ReviewCommentEntry, +): ReviewCommentReactionEntry[] { + const bucket = settings.pullReviewCommentReactions?.[reviewCommentReactionKey(entry)] ?? {}; + return Object.values(bucket) + .filter((reaction): reaction is ReviewCommentReactionEntry => Boolean(reaction) && Number.isInteger(reaction.id)) + .sort((a, b) => a.id - b.id); +} + +function nextReviewCommentReactionId(settings: RepoSettingsData): number { + let max = 0; + for (const bucket of Object.values(settings.pullReviewCommentReactions ?? {})) { + for (const reaction of Object.values(bucket ?? {})) { + if (reaction && Number.isInteger(reaction.id)) { + max = Math.max(max, reaction.id); + } + } + } + return max + 1; +} + +function settingsWithReviewCommentReaction( + settings: RepoSettingsData, entry: ReviewCommentEntry, key: string, reaction: ReviewCommentReactionEntry | null, +): RepoSettingsData { + const commentKey = reviewCommentReactionKey(entry); + const allReactions = { ...(settings.pullReviewCommentReactions ?? {}) }; + const commentReactions = { ...(allReactions[commentKey] ?? {}) }; + if (reaction) { + commentReactions[key] = reaction; + } else { + delete commentReactions[key]; + } + + if (Object.keys(commentReactions).length > 0) { + allReactions[commentKey] = commentReactions; + } else { + delete allReactions[commentKey]; + } + + const next = { ...settings }; + if (Object.keys(allReactions).length > 0) { + next.pullReviewCommentReactions = allReactions; + } else { + delete next.pullReviewCommentReactions; + } + return next; +} + +function buildReviewCommentReactionResponse( + entry: ReviewCommentReactionEntry, baseUrl: string, +): Record<string, unknown> { + return { + id : entry.id, + node_id : `pull-review-comment-reaction:${entry.id}`, + user : buildOwner(entry.userDid, baseUrl), + content : entry.content, + created_at : toISODate(entry.createdAt), + }; +} + +function buildPullCommitParents(parentShas: string[], repoBase: string): Record<string, unknown>[] { + return parentShas.map(sha => ({ + sha, + url : `${repoBase}/git/commits/${sha}`, + html_url : `${repoBase}/commit/${sha}`, + })); +} + +function buildPullCommitResponse(commit: GitCommitInfo, repoBase: string): Record<string, unknown> { + return { + sha : commit.sha, + node_id : nodeId('repo-commit', commit.sha), + commit : { + author : commit.author, + committer : commit.committer, + message : commit.message, + tree : { sha: commit.treeSha, url: `${repoBase}/git/trees/${commit.treeSha}` }, + url : `${repoBase}/git/commits/${commit.sha}`, + comment_count : 0, + verification : verification(), + }, + url : `${repoBase}/commits/${commit.sha}`, + html_url : `${repoBase}/commit/${commit.sha}`, + comments_url : `${repoBase}/commits/${commit.sha}/comments`, + author : null, + committer : null, + parents : buildPullCommitParents(commit.parentShas, repoBase), + }; +} + +function buildRevisionFallbackCommitResponse( + revision: PullRevisionEntry, targetDid: string, repoBase: string, +): Record<string, unknown> | null { + const sha = revision.headCommit.toLowerCase(); + if (!FULL_SHA_RE.test(sha)) { + return null; + } + + const date = toISODate(revision.record.dateCreated); + const message = typeof revision.data.description === 'string' && revision.data.description + ? revision.data.description + : `Pull request revision ${sha.slice(0, 7)}`; + const identity = { + name : targetDid, + email : `${numericId(targetDid)}@users.noreply.gitd.invalid`, + date, + }; + const parentShas = FULL_SHA_RE.test(revision.baseCommit) ? [revision.baseCommit.toLowerCase()] : []; + return buildPullCommitResponse({ + sha, + treeSha : sha, + parentShas, + author : identity, + committer : identity, + message, + }, repoBase); +} + +function numericTag(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +function reviewCommentSide(value: unknown): string { + const side = typeof value === 'string' ? value.toUpperCase() : 'RIGHT'; + return side === 'LEFT' ? 'LEFT' : 'RIGHT'; +} + +function reviewCommentSubjectType(value: unknown): 'line' | 'file' | null { + if (value === undefined) { return 'line'; } + if (typeof value !== 'string') { return null; } + const normalized = value.toLowerCase(); + return normalized === 'line' || normalized === 'file' ? normalized : null; +} + +function parseReviewCommentDrafts(value: unknown): ReviewCommentDraftsResult { + if (value === undefined) { + return { kind: 'ok', drafts: [] }; + } + if (!Array.isArray(value)) { + return { kind: 'error', message: 'Validation Failed: comments must be an array.' }; + } + + const drafts: ReviewCommentDraft[] = []; + for (const item of value) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + return { kind: 'error', message: 'Validation Failed: comments must be objects.' }; + } + + const comment = item as Record<string, unknown>; + const body = comment.body; + const path = comment.path; + const subjectType = reviewCommentSubjectType(comment.subject_type); + if (!subjectType) { + return { kind: 'error', message: 'Validation Failed: subject_type must be line or file.' }; + } + const line = numericTag(comment.line ?? comment.position); + const startLine = numericTag(comment.start_line); + if (typeof body !== 'string' || body.length === 0 || typeof path !== 'string' || path.length === 0) { + return { kind: 'error', message: 'Validation Failed: comments require path, body, and line or position.' }; + } + if (subjectType !== 'file' && !line) { + return { kind: 'error', message: 'Validation Failed: comments require path, body, and line or position.' }; + } + + drafts.push({ + body, + path, + line, + startLine, + side : reviewCommentSide(comment.side).toLowerCase(), + startSide : startLine ? reviewCommentSide(comment.start_side ?? comment.side).toLowerCase() : null, + subjectType, + diffHunk : typeof comment.diff_hunk === 'string' ? comment.diff_hunk : '', + }); + } + + return { kind: 'ok', drafts }; +} + +function buildReviewCommentResponse( + entry: ReviewCommentEntry, + targetDid: string, repoName: string, pullNumber: string, baseUrl: string, commitId: string, + bodyMediaKind?: BodyMediaKind | null, +): Record<string, unknown> { + const id = numericId(entry.comment.id ?? ''); + const reviewId = numericId(entry.review.id ?? ''); + const authorDid = entry.comment.author ?? entry.review.author ?? targetDid; + const path = entry.tags.path ?? ''; + const line = numericTag(entry.tags.line); + const startLine = numericTag(entry.tags.startLine ?? entry.tags.start_line); + const side = reviewCommentSide(entry.tags.side); + const startSide = startLine ? reviewCommentSide(entry.tags.startSide ?? entry.tags.start_side ?? entry.tags.side) : null; + const subjectType = entry.tags.subjectType ?? entry.tags.subject_type ?? (line ? 'line' : 'file'); + const commentCommit = entry.tags.commitId ?? commitId; + const inReplyToId = numericTag(entry.tags.inReplyToId ?? entry.tags.inReplyTo); + const htmlUrl = `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${pullNumber}#discussion_r${id}`; + const selfUrl = `${baseUrl}/repos/${targetDid}/${repoName}/pulls/comments/${id}`; + const pullUrl = `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${pullNumber}`; + + const body = typeof entry.data.body === 'string' ? entry.data.body : ''; + const response = applyBodyMedia({ + url : selfUrl, + pull_request_review_id : reviewId, + id : id, + node_id : entry.comment.id ?? '', + diff_hunk : entry.data.diffHunk ?? '', + path : path, + commit_id : commentCommit, + original_commit_id : commentCommit, + user : buildOwner(authorDid, baseUrl), + created_at : toISODate(entry.comment.dateCreated), + updated_at : toISODate(entry.comment.timestamp), + html_url : htmlUrl, + pull_request_url : pullUrl, + author_association : authorDid === targetDid ? 'OWNER' : 'CONTRIBUTOR', + start_line : startLine, + original_start_line : startLine, + start_side : startSide, + line : line, + original_line : line, + side : side, + subject_type : subjectType, + _links : { + self : { href: selfUrl }, + html : { href: htmlUrl }, + pull_request : { href: pullUrl }, + }, + }, body, bodyMediaKind); + if (inReplyToId) { + response.in_reply_to_id = inReplyToId; + } + return response; +} + +function buildPullReviewResponse( + entry: PullReviewEntry, + targetDid: string, repoName: string, pullNumber: string, baseUrl: string, commitId: string, + bodyMediaKind?: BodyMediaKind | null, +): Record<string, unknown> { + const reviewAuthor = entry.review.author ?? targetDid; + const id = numericId(entry.review.id ?? ''); + const htmlUrl = `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${pullNumber}#pullrequestreview-${id}`; + const pullUrl = `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${pullNumber}`; + const state = reviewState(entry); + const body = entry.override?.body ?? entry.data.body ?? ''; + const response = applyBodyMedia({ + id, + node_id : entry.review.id ?? '', + user : buildOwner(reviewAuthor, baseUrl), + state, + html_url : htmlUrl, + pull_request_url : pullUrl, + commit_id : entry.override?.commitId ?? entry.tags.commitId ?? commitId, + author_association : reviewAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', + _links : { + html : { href: htmlUrl }, + pull_request : { href: pullUrl }, + }, + }, body, bodyMediaKind); + + if (state !== 'PENDING') { + response.submitted_at = entry.override?.submittedAt ?? toISODate(entry.review.dateCreated); + } + + return response; +} + +async function listPullReviewEntries(ctx: AgentContext, from: string | undefined, patch: any): Promise<PullReviewEntry[]> { + const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { + from, + filter : { contextId: patch.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const entries: PullReviewEntry[] = []; + const patchData = await pullData(patch); + const overrides = reviewOverrides(patchData); + for (const review of reviews) { + const override = overrides[reviewOverrideKey(review)]; + if (override?.deleted) { + continue; + } + + entries.push({ + patch, + review, + data : await review.data.json(), + tags : (review.tags as Record<string, string> | undefined) ?? {}, + override, + }); + } + + return entries; +} + +async function listReviewCommentEntries(ctx: AgentContext, from: string | undefined, patch: any): Promise<ReviewCommentEntry[]> { + const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { + from, + filter : { contextId: patch.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const entries: ReviewCommentEntry[] = []; + const patchData = await pullData(patch); + const overrides = reviewOverrides(patchData); + for (const review of reviews) { + if (overrides[reviewOverrideKey(review)]?.deleted) { + continue; + } + + const { records: comments } = await ctx.patches.records.query('repo/patch/review/reviewComment' as any, { + from, + filter : { contextId: review.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + for (const comment of comments) { + entries.push({ + patch, + review, + comment, + data : await comment.data.json(), + tags : (comment.tags as Record<string, string> | undefined) ?? {}, + }); + } + } + + return entries; +} + +async function findPullReview( + ctx: AgentContext, targetDid: string, repoName: string, number: string, reviewId: string, +): Promise<PullReviewLookup> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup; + } + + const reviews = await listPullReviewEntries(ctx, lookup.from, lookup.patch); + const entry = reviews.find((review) => String(numericId(review.review.id ?? '')) === reviewId); + if (!entry) { + return { + kind : 'response', + response : jsonNotFound(`Pull request review '${reviewId}' not found for pull request #${number}.`), + }; + } + + return { kind: 'found', from: lookup.from, entry }; +} + +async function findPullReviewComment( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<ReviewCommentLookup> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return { + kind : 'response', + response : jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`), + }; + } + + const from = fromOpt(ctx, targetDid); + const { records: patches } = await ctx.patches.records.query('repo/patch', { + from, + filter: { contextId: repo.contextId }, + }); + + for (const patch of patches) { + const comments = await listReviewCommentEntries(ctx, from, patch); + const entry = comments.find((comment) => String(numericId(comment.comment.id ?? '')) === id); + if (entry) { + return { + kind : 'found', + from, + pullNumber : String(numericId(patch.id ?? '')), + entry, + }; + } + } + + return { + kind : 'response', + response : jsonNotFound(`Pull request review comment '${id}' not found.`), + }; +} + +async function listRepoReviewCommentEntries( + ctx: AgentContext, from: string | undefined, repoContextId: string, +): Promise<RepoReviewCommentEntry[]> { + const { records: patches } = await ctx.patches.records.query('repo/patch', { + from, + filter : { contextId: repoContextId }, + dateSort : DateSort.CreatedAscending, + }); + + const entries: RepoReviewCommentEntry[] = []; + for (const patch of patches) { + const pullNumber = String(numericId(patch.id ?? '')); + const commitId = await latestRevisionHead(ctx, from, patch); + const comments = await listReviewCommentEntries(ctx, from, patch); + for (const comment of comments) { + entries.push({ ...comment, pullNumber, commitId }); + } + } + + return entries; +} + +function recordTime(rec: any, key: 'created' | 'updated'): number { + const value = key === 'created' + ? rec.dateCreated + : rec.timestamp ?? rec.dateCreated; + const parsed = Date.parse(String(value ?? '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +function sortRepoReviewComments(entries: RepoReviewCommentEntry[], url: URL): RepoReviewCommentEntry[] { + const sort = url.searchParams.get('sort'); + const direction = url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc'; + const sorted = [...entries]; + + if (sort === 'created' || sort === 'created_at') { + sorted.sort((left, right) => recordTime(left.comment, 'created') - recordTime(right.comment, 'created')); + return direction === 'desc' ? sorted.reverse() : sorted; + } + + if (sort === 'updated') { + sorted.sort((left, right) => recordTime(left.comment, 'updated') - recordTime(right.comment, 'updated')); + return direction === 'desc' ? sorted.reverse() : sorted; + } + + sorted.sort((left, right) => numericId(left.comment.id ?? '') - numericId(right.comment.id ?? '')); + return sorted; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls +// --------------------------------------------------------------------------- + +export async function handleListPulls( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + + const stateFilter = url.searchParams.get('state') ?? 'open'; + const headFilter = url.searchParams.get('head'); + const baseFilter = url.searchParams.get('base'); + const sort = url.searchParams.get('sort') ?? 'created'; + const directionParam = url.searchParams.get('direction'); + const direction = directionParam === 'asc' || directionParam === 'desc' + ? directionParam + : (sort === 'created' ? 'desc' : 'asc'); + const pagination = parsePagination(url); + + const { records } = await ctx.patches.records.query('repo/patch', { + from, + filter : { contextId: repo.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + // Filter by state — GitHub treats `closed` as "closed or merged". + let filtered = records; + if (stateFilter !== 'all') { + filtered = records.filter((r) => { + const t = r.tags as Record<string, string> | undefined; + const s = t?.status ?? 'open'; + if (stateFilter === 'open') { return s === 'open' || s === 'draft'; } + // 'closed' includes both closed and merged. + return s === 'closed' || s === 'merged'; + }); + } + if (headFilter) { + filtered = filtered.filter((r) => { + const tags = (r.tags as Record<string, string> | undefined) ?? {}; + const headBranch = tags.headBranch ?? ''; + const sourceDid = tags.sourceDid ?? targetDid; + return headFilter.includes(':') ? `${sourceDid}:${headBranch}` === headFilter : headBranch === headFilter; + }); + } + if (baseFilter) { + filtered = filtered.filter((r) => { + const tags = (r.tags as Record<string, string> | undefined) ?? {}; + return (tags.baseBranch ?? 'main') === baseFilter; + }); + } + + const sortKey = sort === 'updated' ? 'updated' : 'created'; + const sorted = [...filtered].sort((left, right) => { + const timeDiff = recordTime(left, sortKey) - recordTime(right, sortKey); + if (timeDiff !== 0) { + return timeDiff; + } + return numericId(left.id ?? '') - numericId(right.id ?? ''); + }); + if (direction === 'desc') { + sorted.reverse(); + } + + const page = paginate(sorted, pagination); + + const items: Record<string, unknown>[] = []; + for (const rec of page) { + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + items.push(await buildPullResponse(ctx, rec, data, tags, targetDid, repoName, baseUrl, from, bodyMediaKind)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls`, + pagination.page, pagination.perPage, sorted.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number +// --------------------------------------------------------------------------- + +export async function handleGetPull( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + + const num = parseInt(number, 10); + const { records } = await ctx.patches.records.query('repo/patch', { + from, + filter: { contextId: repo.contextId }, + }); + + const rec = records.find(r => numericId(r.id ?? '') === num); + if (!rec) { + return jsonNotFound(`Pull request #${number} not found.`); + } + + const data = await pullData(rec); + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + + return jsonOk(await buildPullResponse(ctx, rec, data, tags, targetDid, repoName, baseUrl, from, bodyMediaKind)); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/commits +// --------------------------------------------------------------------------- + +export async function handleListPullCommits( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, + options: GitObjectOptions = {}, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const revision = await latestPullRevision(ctx, lookup.from, lookup.patch); + if (!revision) { + return jsonOk([]); + } + + const baseUrl = buildApiUrl(url); + const repoBase = `${baseUrl}/repos/${targetDid}/${repoName}`; + const repoPath = localRepoPath(ctx, targetDid, repoName, options); + let commits: Record<string, unknown>[] = []; + + if (repoPath) { + const shas = await listPullCommitShas(repoPath, revision.baseCommit, revision.headCommit); + if (shas) { + const resolved = (await Promise.all(shas.map(sha => readCommit(repoPath, sha)))) + .filter((commit): commit is GitCommitInfo => commit !== null); + commits = resolved.map(commit => buildPullCommitResponse(commit, repoBase)); + } + } + + if (commits.length === 0) { + const fallback = buildRevisionFallbackCommitResponse(revision, targetDid, repoBase); + if (fallback) { + commits = [fallback]; + } + } + + const capped = commits.slice(0, 250); + const pagination = parsePagination(url); + const paged = paginate(capped, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls/${number}/commits`, + pagination.page, pagination.perPage, capped.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(paged, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number.{diff,patch} +// --------------------------------------------------------------------------- + +export async function handleGetPullText( + ctx: AgentContext, targetDid: string, repoName: string, number: string, kind: PullTextKind, + options: GitObjectOptions = {}, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const revision = await latestPullRevision(ctx, lookup.from, lookup.patch); + if (!revision) { + return jsonNotFound(`Pull request ${kind} for #${number} not found.`); + } + + const repoPath = localRepoPath(ctx, targetDid, repoName, options); + const localText = repoPath ? await pullRevisionText(repoPath, revision, kind) : null; + if (localText) { + return binaryOk( + localText, + kind === 'diff' ? 'application/vnd.github.diff; charset=utf-8' : 'application/vnd.github.patch; charset=utf-8', + ); + } + + const bundleBytes = await latestRevisionBundleBytes(ctx, lookup.from, revision); + const bundleText = bundleBytes ? await pullRevisionTextFromBundle(repoPath, bundleBytes, revision, kind) : null; + if (bundleText) { + return binaryOk( + bundleText, + kind === 'diff' ? 'application/vnd.github.diff; charset=utf-8' : 'application/vnd.github.patch; charset=utf-8', + ); + } + + return jsonNotFound(`Pull request ${kind} for #${number} is not available from local git objects or revision bundles.`); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/reviews +// --------------------------------------------------------------------------- + +export async function handleListPullReviews( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const reviews = await listPullReviewEntries(ctx, lookup.from, lookup.patch); + const paged = paginate(reviews, pagination); + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.patch); + const items = paged.map((entry) => + buildPullReviewResponse(entry, targetDid, repoName, number, baseUrl, commitId, bodyMediaKind)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls/${number}/reviews`, + pagination.page, pagination.perPage, reviews.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/reviews/:review_id +// --------------------------------------------------------------------------- + +export async function handleGetPullReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildPullReviewResponse( + lookup.entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind, + )); +} + +// --------------------------------------------------------------------------- +// PUT/PATCH /repos/:did/:repo/pulls/:number/reviews/:review_id +// --------------------------------------------------------------------------- + +export async function handleUpdatePullReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const body = reqBody.body; + if (typeof body !== 'string' || body.length === 0) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + const patchData = await pullData(lookup.entry.patch); + const saved = await saveReviewOverride(lookup.entry.patch, lookup.entry.review, patchData, { body }); + if (saved.status.code >= 300) { + return jsonValidationError(`Failed to update pull request review: ${saved.status.detail}`); + } + + const entry = { ...lookup.entry, override: saved.override }; + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildPullReviewResponse( + entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind, + )); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/pulls/:number/reviews/:review_id +// --------------------------------------------------------------------------- + +export async function handleDeletePendingPullReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + if (reviewState(lookup.entry) !== 'PENDING') { + return jsonValidationError('Validation Failed: submitted reviews cannot be deleted.'); + } + + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + const response = buildPullReviewResponse( + lookup.entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind, + ); + + const patchData = await pullData(lookup.entry.patch); + const saved = await saveReviewOverride(lookup.entry.patch, lookup.entry.review, patchData, { deleted: true }); + if (saved.status.code >= 300) { + return jsonValidationError(`Failed to delete pending pull request review: ${saved.status.detail}`); + } + + return jsonOk(response); +} + +// --------------------------------------------------------------------------- +// PUT /repos/:did/:repo/pulls/:number/reviews/:review_id/dismissals +// --------------------------------------------------------------------------- + +export async function handleDismissPullReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const message = reqBody.message; + if (typeof message !== 'string' || message.length === 0) { + return jsonValidationError('Validation Failed: message is required.'); + } + + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + if (reviewState(lookup.entry) === 'PENDING') { + return jsonValidationError('Validation Failed: pending reviews cannot be dismissed.'); + } + + const patchData = await pullData(lookup.entry.patch); + const saved = await saveReviewOverride(lookup.entry.patch, lookup.entry.review, patchData, { + dismissedAt : new Date().toISOString(), + dismissedMessage : message, + state : 'DISMISSED', + }); + if (saved.status.code >= 300) { + return jsonValidationError(`Failed to dismiss pull request review: ${saved.status.detail}`); + } + + const entry = { ...lookup.entry, override: saved.override }; + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildPullReviewResponse( + entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind, + )); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/pulls/:number/reviews/:review_id/events +// --------------------------------------------------------------------------- + +export async function handleSubmitPullReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const state = eventToReviewState(reqBody.event); + if (!state) { + return jsonValidationError('Validation Failed: event must be APPROVE, REQUEST_CHANGES, or COMMENT.'); + } + + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + if (reviewState(lookup.entry) !== 'PENDING') { + return jsonValidationError('Validation Failed: only pending reviews can be submitted.'); + } + + const body = typeof reqBody.body === 'string' ? reqBody.body : undefined; + const patchData = await pullData(lookup.entry.patch); + const saved = await saveReviewOverride(lookup.entry.patch, lookup.entry.review, patchData, { + ...(body !== undefined ? { body } : {}), + state, + submittedAt: new Date().toISOString(), + }); + if (saved.status.code >= 300) { + return jsonValidationError(`Failed to submit pull request review: ${saved.status.detail}`); + } + + const entry = { ...lookup.entry, override: saved.override }; + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildPullReviewResponse( + entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind, + )); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/reviews/:review_id/comments +// --------------------------------------------------------------------------- + +export async function handleListPullReviewCommentsForReview( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reviewId: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPullReview(ctx, targetDid, repoName, number, reviewId); + if (lookup.kind === 'response') { + return lookup.response; + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const { records: comments } = await ctx.patches.records.query('repo/patch/review/reviewComment' as any, { + from : lookup.from, + filter : { contextId: lookup.entry.review.contextId }, + dateSort : DateSort.CreatedAscending, + }); + + const paged = paginate(comments, pagination); + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + const items: Record<string, unknown>[] = []; + for (const comment of paged) { + items.push(buildReviewCommentResponse({ + patch : lookup.entry.patch, + review : lookup.entry.review, + comment, + data : await comment.data.json(), + tags : (comment.tags as Record<string, string> | undefined) ?? {}, + }, targetDid, repoName, number, baseUrl, commitId, bodyMediaKind)); + } + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls/${number}/reviews/${reviewId}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/comments +// --------------------------------------------------------------------------- + +export async function handleListRepoPullReviewComments( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const since = url.searchParams.get('since'); + const sinceTime = since ? Date.parse(since) : NaN; + + let comments = await listRepoReviewCommentEntries(ctx, from, repo.contextId); + if (Number.isFinite(sinceTime)) { + comments = comments.filter((entry) => recordTime(entry.comment, 'updated') > sinceTime); + } + const sorted = sortRepoReviewComments(comments, url); + const paged = paginate(sorted, pagination); + const items = paged.map((entry) => buildReviewCommentResponse( + entry, targetDid, repoName, entry.pullNumber, baseUrl, entry.commitId, bodyMediaKind, + )); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls/comments`, + pagination.page, pagination.perPage, sorted.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/comments +// --------------------------------------------------------------------------- + +export async function handleListPullReviewComments( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const comments = await listReviewCommentEntries(ctx, lookup.from, lookup.patch); + const paged = paginate(comments, pagination); + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.patch); + const items = paged.map((entry) => buildReviewCommentResponse( + entry, targetDid, repoName, number, baseUrl, commitId, bodyMediaKind, + )); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/pulls/${number}/comments`, + pagination.page, pagination.perPage, comments.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/comments/:id +// --------------------------------------------------------------------------- + +export async function handleGetPullReviewComment( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; + } + + const baseUrl = buildApiUrl(url); + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildReviewCommentResponse( + lookup.entry, targetDid, repoName, lookup.pullNumber, baseUrl, commitId, bodyMediaKind, + )); +} + +export async function handleUpdatePullReviewComment( + ctx: AgentContext, targetDid: string, repoName: string, + id: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const body = reqBody.body as string | undefined; + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); + } + + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; + } + + const data = { ...lookup.entry.data, body }; + const { status } = await lookup.entry.comment.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update review comment: ${status.detail}`); + } + + const entry = { ...lookup.entry, data }; + const commitId = await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + return jsonOk(buildReviewCommentResponse( + entry, targetDid, repoName, lookup.pullNumber, buildApiUrl(url), commitId, bodyMediaKind, + )); +} + +export async function handleDeletePullReviewComment( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; + } + + const { status } = await lookup.entry.comment.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete review comment: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST/DELETE /repos/:did/:repo/pulls/comments/:id/reactions // --------------------------------------------------------------------------- -export async function handleListPulls( - ctx: AgentContext, targetDid: string, repoName: string, url: URL, +export async function handleListPullReviewCommentReactions( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, ): Promise<JsonResponse> { - const repo = await getRepoRecord(ctx, targetDid, repoName); - if (!repo) { - return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; } - const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); + const contentFilter = url.searchParams.get('content'); + if (contentFilter !== null) { + const parsed = parseReactionContent(contentFilter); + if (typeof parsed !== 'string') { return parsed; } + } + + const settingsLookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in settingsLookup) { return settingsLookup; } - const stateFilter = url.searchParams.get('state') ?? 'open'; - const direction = url.searchParams.get('direction') ?? 'desc'; const pagination = parsePagination(url); + const reactions = contentFilter === null + ? reviewCommentReactionEntries(settingsLookup.settings, lookup.entry) + : reviewCommentReactionEntries(settingsLookup.settings, lookup.entry).filter(reaction => reaction.content === contentFilter); + const paged = paginate(reactions, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${settingsLookup.repo.name}/pulls/comments/${id}/reactions`, + pagination.page, pagination.perPage, reactions.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } - const dateSort = direction === 'asc' - ? DateSort.CreatedAscending - : DateSort.CreatedDescending; + return jsonOk(paged.map(reaction => buildReviewCommentReactionResponse(reaction, baseUrl)), extraHeaders); +} - const { records } = await ctx.patches.records.query('repo/patch', { - from, - filter: { contextId: repo.contextId }, - dateSort, +export async function handleCreatePullReviewCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, id: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const content = parseReactionContent(reqBody.content); + if (typeof content !== 'string') { return content; } + + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; + } + + const settingsLookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in settingsLookup) { return settingsLookup; } + + const baseUrl = buildApiUrl(url); + const duplicate = reviewCommentReactionEntries(settingsLookup.settings, lookup.entry).find((reaction) => { + return reaction.userDid === ctx.did && reaction.content === content; }); + if (duplicate) { + return jsonOk(buildReviewCommentReactionResponse(duplicate, baseUrl)); + } - // Filter by state — GitHub treats `closed` as "closed or merged". - let filtered = records; - if (stateFilter !== 'all') { - filtered = records.filter((r) => { - const t = r.tags as Record<string, string> | undefined; - const s = t?.status ?? 'open'; - if (stateFilter === 'open') { return s === 'open'; } - // 'closed' includes both closed and merged. - return s === 'closed' || s === 'merged'; - }); + const reaction: ReviewCommentReactionEntry = { + id : nextReviewCommentReactionId(settingsLookup.settings), + userDid : ctx.did, + content, + createdAt : new Date().toISOString(), + }; + const saveError = await saveRepoSettings( + ctx, + settingsLookup, + settingsWithReviewCommentReaction(settingsLookup.settings, lookup.entry, reactionKey(reaction.id), reaction), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildReviewCommentReactionResponse(reaction, baseUrl)); +} + +export async function handleDeletePullReviewCommentReaction( + ctx: AgentContext, targetDid: string, repoName: string, id: string, reactionId: string, +): Promise<JsonResponse> { + const lookup = await findPullReviewComment(ctx, targetDid, repoName, id); + if (lookup.kind === 'response') { + return lookup.response; } - const page = paginate(filtered, pagination); + const settingsLookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in settingsLookup) { return settingsLookup; } - const items: Record<string, unknown>[] = []; - for (const rec of page) { - const data = await rec.data.json(); - const tags = (rec.tags as Record<string, string> | undefined) ?? {}; - items.push(await buildPullResponse(ctx, rec, data, tags, targetDid, repoName, baseUrl, from)); + const parsedId = parseInt(reactionId, 10); + const reaction = reviewCommentReactionEntries(settingsLookup.settings, lookup.entry).find(entry => entry.id === parsedId); + if (!reaction) { + return jsonNotFound(`Reaction #${reactionId} not found on pull request review comment #${id}.`); } - const linkHeader = buildLinkHeader( - baseUrl, `/repos/${targetDid}/${repoName}/pulls`, - pagination.page, pagination.perPage, filtered.length, + const saveError = await saveRepoSettings( + ctx, + settingsLookup, + settingsWithReviewCommentReaction(settingsLookup.settings, lookup.entry, reactionKey(reaction.id), null), ); - const extraHeaders: Record<string, string> = {}; - if (linkHeader) { extraHeaders['Link'] = linkHeader; } + if (saveError) { return saveError; } - return jsonOk(items, extraHeaders); + return jsonNoContent(); } // --------------------------------------------------------------------------- -// GET /repos/:did/:repo/pulls/:number +// POST /repos/:did/:repo/pulls/:number/comments // --------------------------------------------------------------------------- -export async function handleGetPull( - ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +export async function handleCreatePullReviewComment( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { - const repo = await getRepoRecord(ctx, targetDid, repoName); - if (!repo) { - return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + if (reqBody.in_reply_to !== undefined) { + return handleCreatePullReviewCommentReply( + ctx, targetDid, repoName, number, String(reqBody.in_reply_to), reqBody, url, bodyMediaKind, + ); } - const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } - const num = parseInt(number, 10); - const { records } = await ctx.patches.records.query('repo/patch', { - from, - filter: { contextId: repo.contextId }, - }); + const body = reqBody.body as string | undefined; + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); + } - const rec = records.find(r => numericId(r.id ?? '') === num); - if (!rec) { - return jsonNotFound(`Pull request #${number} not found.`); + const path = reqBody.path as string | undefined; + const subjectType = reviewCommentSubjectType(reqBody.subject_type); + if (!subjectType) { + return jsonValidationError('Validation Failed: subject_type must be line or file.'); + } + const line = numericTag(reqBody.line ?? reqBody.position); + const startLine = numericTag(reqBody.start_line); + if (!path || (subjectType !== 'file' && !line)) { + return jsonValidationError('Validation Failed: path and line are required.'); } - const data = await rec.data.json(); - const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const side = reviewCommentSide(reqBody.side).toLowerCase(); + const startSide = startLine ? reviewCommentSide(reqBody.start_side ?? reqBody.side).toLowerCase() : null; + const commitId = (reqBody.commit_id as string | undefined) ?? await latestRevisionHead(ctx, lookup.from, lookup.patch); - return jsonOk(await buildPullResponse(ctx, rec, data, tags, targetDid, repoName, baseUrl, from)); -} + const { status: reviewStatus, record: reviewRec } = await ctx.patches.records.create('repo/patch/review' as any, { + data : { body: '' }, + tags : { verdict: 'comment' }, + parentContextId : lookup.patch.contextId, + } as any); -// --------------------------------------------------------------------------- -// GET /repos/:did/:repo/pulls/:number/reviews -// --------------------------------------------------------------------------- + if (reviewStatus.code >= 300) { + return jsonValidationError(`Failed to create review: ${reviewStatus.detail}`); + } + if (!reviewRec) {throw new Error('Failed to create review record');} -export async function handleListPullReviews( - ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, -): Promise<JsonResponse> { - const repo = await getRepoRecord(ctx, targetDid, repoName); - if (!repo) { - return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + const tags: Record<string, unknown> = { + path, + side, + subjectType, + }; + if (line) { tags.line = line; } + if (startLine) { tags.startLine = startLine; } + if (startSide) { tags.startSide = startSide; } + if (commitId) { + tags.commitId = commitId; } - const from = fromOpt(ctx, targetDid); - const baseUrl = buildApiUrl(url); - const pagination = parsePagination(url); + const { status, record: commentRec } = await ctx.patches.records.create('repo/patch/review/reviewComment' as any, { + data : { body, diffHunk: (reqBody.diff_hunk as string | undefined) ?? '' }, + tags, + parentContextId : reviewRec.contextId, + } as any); - // Find the patch first. - const num = parseInt(number, 10); - const { records: patches } = await ctx.patches.records.query('repo/patch', { - from, - filter: { contextId: repo.contextId }, - }); + if (status.code >= 300) { + return jsonValidationError(`Failed to create review comment: ${status.detail}`); + } + if (!commentRec) {throw new Error('Failed to create review comment record');} + + const entry: ReviewCommentEntry = { + patch : lookup.patch, + review : reviewRec, + comment : commentRec, + data : await commentRec.data.json(), + tags : (commentRec.tags as Record<string, string> | undefined) ?? {}, + }; - const patchRec = patches.find(r => numericId(r.id ?? '') === num); - if (!patchRec) { - return jsonNotFound(`Pull request #${number} not found.`); + return jsonCreated(buildReviewCommentResponse(entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind)); +} + +export async function handleCreatePullReviewCommentReply( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, commentId: string, reqBody: Record<string, unknown>, url: URL, + bodyMediaKind?: BodyMediaKind | null, +): Promise<JsonResponse> { + const body = reqBody.body as string | undefined; + if (!body) { + return jsonValidationError('Validation Failed: body is required.'); } - // Fetch reviews. - const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { - from, - filter : { contextId: patchRec.contextId }, - dateSort : DateSort.CreatedAscending, - }); + const lookup = await findPullReviewComment(ctx, targetDid, repoName, commentId); + if (lookup.kind === 'response') { + return lookup.response; + } + if (lookup.pullNumber !== number) { + return jsonNotFound(`Pull request review comment '${commentId}' not found for pull request #${number}.`); + } - const paged = paginate(reviews, pagination); + const inReplyToId = numericTag(lookup.entry.tags.inReplyToId ?? lookup.entry.tags.inReplyTo); + if (inReplyToId) { + return jsonValidationError('Validation Failed: replies to replies are not supported.'); + } - const items: Record<string, unknown>[] = []; - for (const review of paged) { - const rData = await review.data.json(); - const rTags = (review.tags as Record<string, string> | undefined) ?? {}; - const verdict = rTags.verdict ?? 'comment'; - const reviewAuthor = review.author ?? targetDid; - - items.push({ - id : numericId(review.id ?? ''), - node_id : review.id ?? '', - user : buildOwner(reviewAuthor, baseUrl), - body : rData.body ?? '', - state : VERDICT_MAP[verdict] ?? 'COMMENTED', - html_url : `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${number}#pullrequestreview-${numericId(review.id ?? '')}`, - pull_request_url : `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${number}`, - submitted_at : toISODate(review.dateCreated), - commit_id : '', - author_association : reviewAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', - }); + const parentId = numericId(lookup.entry.comment.id ?? ''); + const commitId = lookup.entry.tags.commitId ?? await latestRevisionHead(ctx, lookup.from, lookup.entry.patch); + const tags: Record<string, unknown> = { + ...lookup.entry.tags, + inReplyToId: String(parentId), + }; + if (commitId) { + tags.commitId = commitId; } - const linkHeader = buildLinkHeader( - baseUrl, `/repos/${targetDid}/${repoName}/pulls/${number}/reviews`, - pagination.page, pagination.perPage, reviews.length, - ); - const extraHeaders: Record<string, string> = {}; - if (linkHeader) { extraHeaders['Link'] = linkHeader; } + const { status, record: commentRec } = await ctx.patches.records.create('repo/patch/review/reviewComment' as any, { + data : { body, diffHunk: lookup.entry.data.diffHunk ?? '' }, + tags, + parentContextId : lookup.entry.review.contextId, + } as any); - return jsonOk(items, extraHeaders); + if (status.code >= 300) { + return jsonValidationError(`Failed to create review comment reply: ${status.detail}`); + } + if (!commentRec) { throw new Error('Failed to create review comment reply record'); } + + const entry: ReviewCommentEntry = { + patch : lookup.entry.patch, + review : lookup.entry.review, + comment : commentRec, + data : await commentRec.data.json(), + tags : (commentRec.tags as Record<string, string> | undefined) ?? {}, + }; + + return jsonCreated(buildReviewCommentResponse(entry, targetDid, repoName, number, buildApiUrl(url), commitId, bodyMediaKind)); } // --------------------------------------------------------------------------- @@ -338,7 +2040,7 @@ export async function handleListPullReviews( export async function handleCreatePull( ctx: AgentContext, targetDid: string, repoName: string, - reqBody: Record<string, unknown>, url: URL, + reqBody: Record<string, unknown>, url: URL, bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -355,9 +2057,13 @@ export async function handleCreatePull( const headBranch = (reqBody.head as string) ?? ''; const baseUrl = buildApiUrl(url); + if (reqBody.draft !== undefined && typeof reqBody.draft !== 'boolean') { + return jsonValidationError('Validation Failed: draft must be a boolean.'); + } + const tags: Record<string, string> = { baseBranch, - status: 'open', + status: reqBody.draft === true ? 'draft' : 'open', }; if (headBranch) { tags.headBranch = headBranch; } @@ -374,7 +2080,7 @@ export async function handleCreatePull( const recTags = (record.tags as Record<string, string> | undefined) ?? {}; const data = await record.data.json(); - const pr = await buildPullResponse(ctx, record, data, recTags, targetDid, repoName, baseUrl); + const pr = await buildPullResponse(ctx, record, data, recTags, targetDid, repoName, baseUrl, undefined, bodyMediaKind); return jsonCreated(pr); } @@ -385,7 +2091,7 @@ export async function handleCreatePull( export async function handleUpdatePull( ctx: AgentContext, targetDid: string, repoName: string, - number: string, reqBody: Record<string, unknown>, url: URL, + number: string, reqBody: Record<string, unknown>, url: URL, bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -404,7 +2110,7 @@ export async function handleUpdatePull( return jsonNotFound(`Pull request #${number} not found.`); } - const data = await rec.data.json(); + const data = await pullData(rec); const tags = (rec.tags as Record<string, string> | undefined) ?? {}; // Apply updates. @@ -421,7 +2127,7 @@ export async function handleUpdatePull( if (newBase) { newTags.baseBranch = newBase; } const { status } = await rec.update({ - data : { title: newTitle, body: newBody }, + data : { ...data, title: newTitle, body: newBody }, tags : newTags, }); @@ -429,12 +2135,202 @@ export async function handleUpdatePull( return jsonValidationError(`Failed to update pull request: ${status.detail}`); } - const updatedData = { title: newTitle, body: newBody }; - const pr = await buildPullResponse(ctx, rec, updatedData, newTags, targetDid, repoName, baseUrl); + const updatedData = { ...data, title: newTitle, body: newBody }; + const pr = await buildPullResponse(ctx, rec, updatedData, newTags, targetDid, repoName, baseUrl, undefined, bodyMediaKind); + + return jsonOk(pr); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/requested_reviewers — list review requests +// --------------------------------------------------------------------------- + +export async function handleListPullRequestedReviewers( + ctx: AgentContext, targetDid: string, repoName: string, number: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const data = await pullData(lookup.patch); + return jsonOk(requestedReviewersPayload(data, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/pulls/:number/requested_reviewers — request reviewers +// --------------------------------------------------------------------------- + +export async function handleRequestPullReviewers( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const reviewers = requestStringArray(reqBody.reviewers); + const teamReviewers = requestStringArray(reqBody.team_reviewers); + if (!reviewers || !teamReviewers) { + return jsonValidationError('Validation Failed: reviewers and team_reviewers must be arrays of strings.'); + } + if (reviewers.length === 0 && teamReviewers.length === 0) { + return jsonValidationError('Validation Failed: reviewers or team_reviewers is required.'); + } + + const data = await pullData(lookup.patch); + const updatedData = { + ...data, + requestedReviewers : uniqueStrings([...storedStringArray(data.requestedReviewers), ...reviewers]), + requestedTeams : uniqueStrings([...storedStringArray(data.requestedTeams), ...teamReviewers]), + }; + const tags = (lookup.patch.tags as Record<string, string> | undefined) ?? {}; + + const { status } = await lookup.patch.update({ + data: updatedData, + tags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to request pull request reviewers: ${status.detail}`); + } + + const pr = await buildPullResponse(ctx, lookup.patch, updatedData, tags, targetDid, repoName, buildApiUrl(url), lookup.from); + return jsonCreated(pr); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/pulls/:number/requested_reviewers — remove requests +// --------------------------------------------------------------------------- + +export async function handleRemovePullRequestedReviewers( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const reviewers = reqBody.reviewers === undefined + ? null + : requestStringArray(reqBody.reviewers); + const teamReviewers = requestStringArray(reqBody.team_reviewers); + if (!reviewers) { + return jsonValidationError('Validation Failed: reviewers must be an array of strings.'); + } + if (!teamReviewers) { + return jsonValidationError('Validation Failed: team_reviewers must be an array of strings.'); + } + if (reviewers.length === 0 && teamReviewers.length === 0) { + return jsonValidationError('Validation Failed: reviewers or team_reviewers is required.'); + } + + const data = await pullData(lookup.patch); + const reviewerSet = new Set(reviewers); + const teamSet = new Set(teamReviewers); + const updatedData = { + ...data, + requestedReviewers : storedStringArray(data.requestedReviewers).filter(did => !reviewerSet.has(did)), + requestedTeams : storedStringArray(data.requestedTeams).filter(slug => !teamSet.has(slug)), + }; + const tags = (lookup.patch.tags as Record<string, string> | undefined) ?? {}; + + const { status } = await lookup.patch.update({ + data: updatedData, + tags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to remove pull request reviewers: ${status.detail}`); + } + const pr = await buildPullResponse(ctx, lookup.patch, updatedData, tags, targetDid, repoName, buildApiUrl(url), lookup.from); return jsonOk(pr); } +// --------------------------------------------------------------------------- +// PUT /repos/:did/:repo/pulls/:number/update-branch — update pull request branch +// --------------------------------------------------------------------------- + +export async function handleUpdatePullBranch( + ctx: AgentContext, targetDid: string, repoName: string, + number: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const tags = (lookup.patch.tags as Record<string, string> | undefined) ?? {}; + if ((tags.status ?? 'open') !== 'open') { + return jsonValidationError('Validation Failed: pull request branch can only be updated for open pull requests.'); + } + + const expectedHeadSha = reqBody.expected_head_sha; + if (expectedHeadSha !== undefined && typeof expectedHeadSha !== 'string') { + return jsonValidationError('Validation Failed: expected_head_sha must be a string.'); + } + + const currentHeadSha = await latestRevisionHead(ctx, lookup.from, lookup.patch); + if (expectedHeadSha && expectedHeadSha !== currentHeadSha) { + return jsonValidationError('Validation Failed: expected_head_sha does not match the pull request head SHA.'); + } + + const currentStatus = tags.status ?? 'open'; + const { status } = await ctx.patches.records.create('repo/patch/statusChange' as any, { + data: { + reason : 'Update branch requested', + baseBranch : tags.baseBranch ?? 'main', + headBranch : tags.headBranch ?? null, + currentHeadSha : currentHeadSha || null, + expectedHeadSha : expectedHeadSha ?? null, + }, + tags : { from: currentStatus, to: currentStatus }, + parentContextId : lookup.patch.contextId, + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to update pull request branch: ${status.detail}`); + } + + const pullUrl = `${buildApiUrl(url)}/repos/${targetDid}/${repoName}/pulls/${number}`; + return jsonAccepted({ + message : 'Updating pull request branch.', + url : pullUrl, + }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/pulls/:number/merge — check if pull request has merged +// --------------------------------------------------------------------------- + +export async function handleCheckPullMerged( + ctx: AgentContext, targetDid: string, repoName: string, number: string, +): Promise<JsonResponse> { + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; + } + + const tags = (lookup.patch.tags as Record<string, string> | undefined) ?? {}; + if (tags.status === 'merged') { + return jsonNoContent(); + } + + const { records: mergeResults } = await ctx.patches.records.query('repo/patch/mergeResult' as any, { + from : lookup.from, + filter : { contextId: lookup.patch.contextId }, + }); + + if (mergeResults.length > 0) { + return jsonNoContent(); + } + + return jsonNotFound(`Pull request #${number} has not been merged.`); +} + // --------------------------------------------------------------------------- // PUT /repos/:did/:repo/pulls/:number/merge — merge pull request // --------------------------------------------------------------------------- @@ -510,38 +2406,46 @@ export async function handleMergePull( export async function handleCreatePullReview( ctx: AgentContext, targetDid: string, repoName: string, - number: string, reqBody: Record<string, unknown>, url: URL, + number: string, reqBody: Record<string, unknown>, url: URL, bodyMediaKind?: BodyMediaKind | null, ): Promise<JsonResponse> { - const repo = await getRepoRecord(ctx, targetDid, repoName); - if (!repo) { - return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + const lookup = await findPatchForPull(ctx, targetDid, repoName, number); + if (lookup.kind === 'response') { + return lookup.response; } const baseUrl = buildApiUrl(url); + const reviewBody = (reqBody.body as string) ?? ''; - // Find the patch. - const num = parseInt(number, 10); - const { records: patches } = await ctx.patches.records.query('repo/patch', { - filter: { contextId: repo.contextId }, - }); + const hasEvent = typeof reqBody.event === 'string' && reqBody.event !== ''; + const state = hasEvent ? eventToReviewState(reqBody.event) : 'PENDING'; + if (!state) { + return jsonValidationError('Validation Failed: event must be APPROVE, REQUEST_CHANGES, or COMMENT.'); + } + if ((state === 'CHANGES_REQUESTED' || state === 'COMMENTED') && !reviewBody) { + return jsonValidationError('Validation Failed: body is required for REQUEST_CHANGES or COMMENT reviews.'); + } - const patchRec = patches.find(r => numericId(r.id ?? '') === num); - if (!patchRec) { - return jsonNotFound(`Pull request #${number} not found.`); + const commentDrafts = parseReviewCommentDrafts(reqBody.comments); + if (commentDrafts.kind === 'error') { + return jsonValidationError(commentDrafts.message); } - const reviewBody = (reqBody.body as string) ?? ''; - // GitHub API uses "event" field: APPROVE, REQUEST_CHANGES, COMMENT. - // Map to DWN verdict tags. - const event = (reqBody.event as string | undefined)?.toUpperCase() ?? 'COMMENT'; - let verdict = 'comment'; - if (event === 'APPROVE') { verdict = 'approve'; } - if (event === 'REQUEST_CHANGES') { verdict = 'reject'; } + const commitId = typeof reqBody.commit_id === 'string' + ? reqBody.commit_id + : await latestRevisionHead(ctx, lookup.from, lookup.patch); + const data: Record<string, unknown> = { body: reviewBody }; + if (state === 'PENDING') { + data.githubReviewState = 'PENDING'; + } + const tags: Record<string, string> = { verdict: stateToVerdict(state) }; + if (commitId) { + tags.commitId = commitId; + } const { status, record: reviewRec } = await ctx.patches.records.create('repo/patch/review' as any, { - data : { body: reviewBody }, - tags : { verdict }, - parentContextId : patchRec.contextId, + data : data, + tags : tags, + parentContextId : lookup.patch.contextId, } as any); if (status.code >= 300) { @@ -549,20 +2453,38 @@ export async function handleCreatePullReview( } if (!reviewRec) {throw new Error('Failed to create review record');} - const reviewAuthor = reviewRec.author ?? targetDid; + for (const comment of commentDrafts.drafts) { + const tags: Record<string, unknown> = { + path : comment.path, + side : comment.side, + subjectType : comment.subjectType, + }; + if (comment.line) { tags.line = comment.line; } + if (comment.startLine) { tags.startLine = comment.startLine; } + if (comment.startSide) { tags.startSide = comment.startSide; } + if (commitId) { + tags.commitId = commitId; + } - return jsonCreated({ - id : numericId(reviewRec.id ?? ''), - node_id : reviewRec.id ?? '', - user : buildOwner(reviewAuthor, baseUrl), - body : reviewBody, - state : VERDICT_MAP[verdict] ?? 'COMMENTED', - html_url : `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${number}#pullrequestreview-${numericId(reviewRec.id ?? '')}`, - pull_request_url : `${baseUrl}/repos/${targetDid}/${repoName}/pulls/${number}`, - submitted_at : toISODate(reviewRec.dateCreated), - commit_id : '', - author_association : reviewAuthor === targetDid ? 'OWNER' : 'CONTRIBUTOR', - }); + const { status: commentStatus } = await ctx.patches.records.create('repo/patch/review/reviewComment' as any, { + data : { body: comment.body, diffHunk: comment.diffHunk }, + tags, + parentContextId : reviewRec.contextId, + } as any); + + if (commentStatus.code >= 300) { + return jsonValidationError(`Failed to create review comment: ${commentStatus.detail}`); + } + } + + const entry: PullReviewEntry = { + patch : lookup.patch, + review : reviewRec, + data, + tags, + }; + + return jsonCreated(buildPullReviewResponse(entry, targetDid, repoName, number, baseUrl, commitId, bodyMediaKind)); } // --------------------------------------------------------------------------- diff --git a/src/github-shim/releases.ts b/src/github-shim/releases.ts index 479e7be..e972a41 100644 --- a/src/github-shim/releases.ts +++ b/src/github-shim/releases.ts @@ -5,8 +5,19 @@ * * Endpoints: * GET /repos/:did/:repo/releases List releases + * GET /repos/:did/:repo/releases/latest Latest published full release + * GET /repos/:did/:repo/releases/:id Release by numeric ID + * GET /repos/:did/:repo/releases/:id/assets List release assets + * GET /repos/:did/:repo/releases/assets/:id Release asset metadata * GET /repos/:did/:repo/releases/tags/:tag Release by tag name + * GET /repos/:did/:repo/releases/download/:tag/:asset Release asset bytes * POST /repos/:did/:repo/releases Create release + * POST /repos/:did/:repo/releases/generate-notes Generate release notes + * POST /repos/:did/:repo/releases/:id/assets Upload release asset + * PATCH /repos/:did/:repo/releases/:id Update release + * DELETE /repos/:did/:repo/releases/:id Delete release + * PATCH /repos/:did/:repo/releases/assets/:id Update release asset metadata + * DELETE /repos/:did/:repo/releases/assets/:id Delete release asset * * @module */ @@ -14,15 +25,19 @@ import type { AgentContext } from '../cli/agent.js'; import type { JsonResponse } from './helpers.js'; +import { createHash } from 'node:crypto'; + import { DateSort } from '@enbox/dwn-sdk-js'; import { + binaryOk, buildApiUrl, buildLinkHeader, buildOwner, fromOpt, getRepoRecord, jsonCreated, + jsonNoContent, jsonNotFound, jsonOk, jsonValidationError, @@ -36,17 +51,351 @@ import { // Release object builder // --------------------------------------------------------------------------- -function buildReleaseResponse( +type AssetOverride = { + name? : string; + label? : string | null; + state? : string; + updatedAt? : string; +}; + +type ReleaseReactionEntry = { + id : number; + userDid : string; + content : ReleaseReactionContent; + createdAt : string; +}; + +type ReleaseUploadOptions = { + rawBody? : Uint8Array; + contentType? : string; +}; + +type MakeLatestMode = 'true' | 'false' | 'legacy'; + +type ReleaseCandidate = { + release: any; + tags: Record<string, unknown>; + data: any; +}; + +type ParsedSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[]; +}; + +const MAKE_LATEST_MODES = new Set<MakeLatestMode>(['true', 'false', 'legacy']); +const RELEASE_REACTION_CONTENTS = ['+1', 'laugh', 'heart', 'hooray', 'rocket', 'eyes'] as const; +const RELEASE_REACTION_CONTENT_SET = new Set<string>(RELEASE_REACTION_CONTENTS); +type ReleaseReactionContent = typeof RELEASE_REACTION_CONTENTS[number]; + +function overrideHas<K extends keyof AssetOverride>(override: AssetOverride, key: K): boolean { + return Object.prototype.hasOwnProperty.call(override, key); +} + +function hasOwn(obj: Record<string, unknown>, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function releaseId(rec: any): number { + return numericId(rec.id ?? ''); +} + +function releaseAssetId(rec: any): number { + return numericId(rec.id ?? ''); +} + +function releaseMatchesId(rec: any, id: string): boolean { + return String(releaseId(rec)) === id; +} + +function releaseAssetMatchesId(rec: any, id: string): boolean { + return String(releaseAssetId(rec)) === id; +} + +function tagNameForRelease(tags: Record<string, unknown>): string { + return (tags.tagName as string) ?? ''; +} + +function tagFlag(tags: Record<string, unknown>, name: string): boolean { + const value = tags[name]; + return value === true || value === 'true'; +} + +function releaseCreatedTime(rec: any): number { + const parsed = Date.parse(String(rec.dateCreated ?? '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +function semverFromTag(tag: string): ParsedSemver | null { + const match = tag.match(/(?:^|[^0-9A-Za-z])v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/); + if (!match) { + return null; + } + + return { + major : parseInt(match[1], 10), + minor : parseInt(match[2], 10), + patch : parseInt(match[3], 10), + prerelease : match[4]?.split('.') ?? [], + }; +} + +function comparePrerelease(left: string[], right: string[]): number { + if (left.length === 0 && right.length === 0) { + return 0; + } + if (left.length === 0) { + return 1; + } + if (right.length === 0) { + return -1; + } + + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + if (leftPart === undefined) { return -1; } + if (rightPart === undefined) { return 1; } + if (leftPart === rightPart) { continue; } + + const leftNumber = /^\d+$/.test(leftPart) ? parseInt(leftPart, 10) : null; + const rightNumber = /^\d+$/.test(rightPart) ? parseInt(rightPart, 10) : null; + if (leftNumber !== null && rightNumber !== null) { + return leftNumber - rightNumber; + } + if (leftNumber !== null) { + return -1; + } + if (rightNumber !== null) { + return 1; + } + return leftPart.localeCompare(rightPart); + } + + return 0; +} + +function compareSemver(left: ParsedSemver, right: ParsedSemver): number { + if (left.major !== right.major) { return left.major - right.major; } + if (left.minor !== right.minor) { return left.minor - right.minor; } + if (left.patch !== right.patch) { return left.patch - right.patch; } + return comparePrerelease(left.prerelease, right.prerelease); +} + +function compareLegacyLatestCandidates(left: ReleaseCandidate, right: ReleaseCandidate): number { + const leftSemver = semverFromTag(tagNameForRelease(left.tags)); + const rightSemver = semverFromTag(tagNameForRelease(right.tags)); + if (leftSemver && rightSemver) { + const semverOrder = compareSemver(rightSemver, leftSemver); + if (semverOrder !== 0) { + return semverOrder; + } + } + + return releaseCreatedTime(right.release) - releaseCreatedTime(left.release); +} + +function isPublishedFullRelease(tags: Record<string, unknown>): boolean { + return !tagFlag(tags, 'draft') && !tagFlag(tags, 'prerelease'); +} + +function parseMakeLatest(value: unknown): MakeLatestMode | undefined { + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'string' && MAKE_LATEST_MODES.has(value as MakeLatestMode)) { + return value as MakeLatestMode; + } + return undefined; +} + +function releaseMakeLatest(data: any): MakeLatestMode { + return parseMakeLatest(data?.makeLatest) ?? 'legacy'; +} + +function parseDiscussionCategoryName(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + return value.trim() ? value : null; +} + +function parseReleaseReactionContent(value: unknown): ReleaseReactionContent | JsonResponse { + if (typeof value !== 'string' || !RELEASE_REACTION_CONTENT_SET.has(value)) { + return jsonValidationError( + `Validation Failed: content must be one of ${RELEASE_REACTION_CONTENTS.map(content => `'${content}'`).join(', ')}.`, + ); + } + return value as ReleaseReactionContent; +} + +function releaseDiscussionUrl( + rec: any, data: any, targetDid: string, repoName: string, baseUrl: string, +): string | undefined { + if (typeof data?.discussionCategoryName !== 'string' || !data.discussionCategoryName.trim()) { + return undefined; + } + + return `${baseUrl}/repos/${targetDid}/${repoName}/discussions/${numericId(`${rec.id ?? releaseId(rec)}:discussion`)}`; +} + +async function repoImmutableReleasesEnabled( + ctx: AgentContext, targetDid: string, repoContextId: string, +): Promise<boolean> { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from : fromOpt(ctx, targetDid), + filter : { contextId: repoContextId }, + }); + + if (records.length === 0) { + return false; + } + + const settings = await records[0].data.json(); + return settings?.immutableReleasesEnabled === true; +} + +function assetSize(tags: Record<string, unknown>, fallback = 0): number { + const raw = tags.size; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === 'string') { + const parsed = parseInt(raw, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +} + +function assetOverrides(data: any): Record<string, AssetOverride> { + if (!data.assetOverrides || typeof data.assetOverrides !== 'object' || Array.isArray(data.assetOverrides)) { + return {}; + } + return data.assetOverrides as Record<string, AssetOverride>; +} + +function releaseReactions(data: any): Record<string, ReleaseReactionEntry> { + if (!data.reactions || typeof data.reactions !== 'object' || Array.isArray(data.reactions)) { + return {}; + } + return data.reactions as Record<string, ReleaseReactionEntry>; +} + +function releaseReactionEntries(data: any): ReleaseReactionEntry[] { + return Object.values(releaseReactions(data)) + .filter((entry): entry is ReleaseReactionEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function nextReleaseReactionId(data: any): number { + return releaseReactionEntries(data).reduce((max, reaction) => Math.max(max, reaction.id), 0) + 1; +} + +function releaseReactionKey(id: number): string { + return String(id); +} + +function assetOverrideKey(rec: any): string { + return rec.id ?? String(releaseAssetId(rec)); +} + +async function releaseAssetDigest(rec: any): Promise<string> { + const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; + if (typeof tags.digest === 'string' && tags.digest) { + return tags.digest; + } + + const bytes = await readAssetBytes(rec); + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +async function buildAssetResponse( + rec: any, releaseTagName: string, targetDid: string, repoName: string, baseUrl: string, + override: AssetOverride = {}, +): Promise<Record<string, unknown>> { + const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; + const filename = override.name ?? String(tags.filename ?? 'asset'); + const contentType = String(tags.contentType ?? rec.dataFormat ?? 'application/octet-stream'); + const label = overrideHas(override, 'label') + ? (override.label ?? null) + : (typeof tags.label === 'string' ? tags.label : null); + const id = releaseAssetId(rec); + const encodedTag = encodeURIComponent(releaseTagName); + const encodedName = encodeURIComponent(filename); + const owner = buildOwner(targetDid, baseUrl); + + return { + url : `${baseUrl}/repos/${targetDid}/${repoName}/releases/assets/${id}`, + id, + node_id : rec.id ?? '', + name : filename, + label, + uploader : owner, + content_type : contentType, + state : override.state ?? 'uploaded', + size : assetSize(tags), + digest : await releaseAssetDigest(rec), + download_count : 0, + created_at : toISODate(rec.dateCreated), + updated_at : override.updatedAt ?? toISODate(rec.timestamp ?? rec.dateCreated), + browser_download_url : `${baseUrl}/repos/${targetDid}/${repoName}/releases/download/${encodedTag}/${encodedName}`, + }; +} + +function buildReleaseReactionResponse( + reaction: ReleaseReactionEntry, baseUrl: string, +): Record<string, unknown> { + return { + id : reaction.id, + node_id : `release-reaction:${reaction.id}`, + user : buildOwner(reaction.userDid, baseUrl), + content : reaction.content, + created_at : toISODate(reaction.createdAt), + }; +} + +async function listReleaseAssets(ctx: AgentContext, from: string | undefined, releaseContextId: string): Promise<any[]> { + const { records } = await ctx.releases.records.query('repo/release/asset' as any, { + from, + filter : { contextId: releaseContextId }, + dateSort : DateSort.CreatedAscending, + }); + + return records; +} + +async function buildReleaseResponse( + ctx: AgentContext, from: string | undefined, rec: any, data: any, tags: Record<string, unknown>, targetDid: string, repoName: string, baseUrl: string, -): Record<string, unknown> { + immutableReleasesEnabled = false, defaultBranch = 'main', +): Promise<Record<string, unknown>> { const owner = buildOwner(targetDid, baseUrl); - const tagName = (tags.tagName as string) ?? ''; - const prerelease = tags.prerelease === true; - const draft = tags.draft === true; - const id = numericId(rec.id ?? ''); + const tagName = tagNameForRelease(tags); + const prerelease = tagFlag(tags, 'prerelease'); + const draft = tagFlag(tags, 'draft'); + const id = releaseId(rec); + const assetRecords = await listReleaseAssets(ctx, from, rec.contextId ?? ''); + const overrides = assetOverrides(data); + const assets = await Promise.all(assetRecords.map((asset) => { + return buildAssetResponse(asset, tagName, targetDid, repoName, baseUrl, overrides[assetOverrideKey(asset)]); + })); - return { + const response: Record<string, unknown> = { id, node_id : rec.id ?? '', url : `${baseUrl}/repos/${targetDid}/${repoName}/releases/${id}`, @@ -56,18 +405,139 @@ function buildReleaseResponse( tarball_url : `${baseUrl}/repos/${targetDid}/${repoName}/tarball/${tagName}`, zipball_url : `${baseUrl}/repos/${targetDid}/${repoName}/zipball/${tagName}`, tag_name : tagName, - target_commitish : (tags.commitSha as string) ?? 'main', + target_commitish : (tags.commitSha as string) ?? defaultBranch, name : data.name ?? tagName, body : data.body ?? '', draft, prerelease, + immutable : immutableReleasesEnabled, created_at : toISODate(rec.dateCreated), - published_at : toISODate(rec.dateCreated), + published_at : draft ? null : toISODate(data.publishedAt ?? rec.dateCreated), author : owner, - assets : [], + assets, + }; + const discussionUrl = releaseDiscussionUrl(rec, data, targetDid, repoName, baseUrl); + if (discussionUrl) { + response.discussion_url = discussionUrl; + } + + return response; +} + +async function findReleaseById( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<{ + from: string | undefined; + immutableReleasesEnabled: boolean; + defaultBranch: string; + repoContextId: string; + release: any; + tags: Record<string, unknown>; + data: any; +} | null> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return null; + } + + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.releases.records.query('repo/release' as any, { + from, + filter: { contextId: repo.contextId }, + }); + const release = records.find((rec: any) => releaseMatchesId(rec, id)); + if (!release) { + return null; + } + + return { + from, + immutableReleasesEnabled : await repoImmutableReleasesEnabled(ctx, targetDid, repo.contextId), + defaultBranch : repo.defaultBranch, + repoContextId : repo.contextId, + release, + tags : (release.tags as Record<string, unknown> | undefined) ?? {}, + data : await release.data.json(), }; } +async function clearExplicitLatestReleases( + ctx: AgentContext, from: string | undefined, repoContextId: string, keepId: string, +): Promise<JsonResponse | undefined> { + const { records } = await ctx.releases.records.query('repo/release' as any, { + from, + filter: { contextId: repoContextId }, + }); + + for (const rec of records) { + if (rec.id === keepId) { + continue; + } + + const data = await rec.data.json(); + if (releaseMakeLatest(data) !== 'true') { + continue; + } + + const { status } = await rec.update({ + data : { ...data, makeLatest: 'false' }, + tags : ((rec.tags as Record<string, unknown> | undefined) ?? {}) as any, + }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update release latest state: ${status.detail}`); + } + } + + return undefined; +} + +async function findAssetById( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<{ + from: string | undefined; + release: any; + releaseData: any; + releaseTags: Record<string, unknown>; + asset: any; +} | null> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return null; + } + + const from = fromOpt(ctx, targetDid); + const { records: releases } = await ctx.releases.records.query('repo/release' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + for (const release of releases) { + const assets = await listReleaseAssets(ctx, from, release.contextId ?? ''); + const asset = assets.find((rec: any) => releaseAssetMatchesId(rec, id)); + if (asset) { + return { + from, + release, + releaseData : await release.data.json(), + releaseTags : (release.tags as Record<string, unknown> | undefined) ?? {}, + asset, + }; + } + } + + return null; +} + +function effectiveAssetName(asset: any, overrides: Record<string, AssetOverride>): string { + const tags = (asset.tags as Record<string, unknown> | undefined) ?? {}; + return overrides[assetOverrideKey(asset)]?.name ?? String(tags.filename ?? ''); +} + +async function readAssetBytes(asset: any): Promise<Uint8Array> { + const blob = await asset.data.blob(); + return new Uint8Array(await blob.arrayBuffer()); +} + // --------------------------------------------------------------------------- // GET /repos/:did/:repo/releases // --------------------------------------------------------------------------- @@ -83,6 +553,7 @@ export async function handleListReleases( const from = fromOpt(ctx, targetDid); const baseUrl = buildApiUrl(url); const pagination = parsePagination(url); + const immutableReleasesEnabled = await repoImmutableReleasesEnabled(ctx, targetDid, repo.contextId); const { records } = await ctx.releases.records.query('repo/release' as any, { from, @@ -96,7 +567,10 @@ export async function handleListReleases( for (const rec of paged) { const data = await rec.data.json(); const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; - items.push(buildReleaseResponse(rec, data, tags, targetDid, repoName, baseUrl)); + items.push(await buildReleaseResponse( + ctx, from, rec, data, tags, targetDid, repoName, baseUrl, immutableReleasesEnabled, + repo.defaultBranch, + )); } const linkHeader = buildLinkHeader( @@ -110,11 +584,11 @@ export async function handleListReleases( } // --------------------------------------------------------------------------- -// GET /repos/:did/:repo/releases/tags/:tag +// GET /repos/:did/:repo/releases/latest // --------------------------------------------------------------------------- -export async function handleGetReleaseByTag( - ctx: AgentContext, targetDid: string, repoName: string, tag: string, url: URL, +export async function handleGetLatestRelease( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, ): Promise<JsonResponse> { const repo = await getRepoRecord(ctx, targetDid, repoName); if (!repo) { @@ -122,22 +596,647 @@ export async function handleGetReleaseByTag( } const from = fromOpt(ctx, targetDid); + const immutableReleasesEnabled = await repoImmutableReleasesEnabled(ctx, targetDid, repo.contextId); + const { records } = await ctx.releases.records.query('repo/release' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const explicitCandidates: ReleaseCandidate[] = []; + const legacyCandidates: ReleaseCandidate[] = []; + for (const release of records) { + const tags = (release.tags as Record<string, unknown> | undefined) ?? {}; + if (isPublishedFullRelease(tags)) { + const data = await release.data.json(); + const makeLatest = releaseMakeLatest(data); + if (makeLatest === 'true') { + explicitCandidates.push({ release, tags, data }); + } else if (makeLatest === 'legacy') { + legacyCandidates.push({ release, tags, data }); + } + } + } + + explicitCandidates.sort((left, right) => releaseCreatedTime(right.release) - releaseCreatedTime(left.release)); + legacyCandidates.sort(compareLegacyLatestCandidates); + const latest = explicitCandidates[0] ?? legacyCandidates[0]; + if (!latest) { + return jsonNotFound('Latest release not found.'); + } + + return jsonOk(await buildReleaseResponse( + ctx, from, latest.release, latest.data, latest.tags, targetDid, repoName, buildApiUrl(url), + immutableReleasesEnabled, repo.defaultBranch, + )); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/:id +// --------------------------------------------------------------------------- + +export async function handleGetReleaseById( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + const baseUrl = buildApiUrl(url); + return jsonOk(await buildReleaseResponse( + ctx, found.from, found.release, found.data, found.tags, targetDid, repoName, baseUrl, + found.immutableReleasesEnabled, found.defaultBranch, + )); +} + +// --------------------------------------------------------------------------- +// PATCH /repos/:did/:repo/releases/:id +// --------------------------------------------------------------------------- +export async function handleUpdateRelease( + ctx: AgentContext, targetDid: string, repoName: string, + id: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const makeLatestProvided = hasOwn(reqBody, 'make_latest'); + const requestedMakeLatest = makeLatestProvided ? parseMakeLatest(reqBody.make_latest) : undefined; + if (makeLatestProvided && requestedMakeLatest === undefined) { + return jsonValidationError('Validation Failed: make_latest must be one of true, false, or legacy.'); + } + + const hasLinkedDiscussion = typeof found.data.discussionCategoryName === 'string' + && found.data.discussionCategoryName.trim() !== ''; + const discussionCategoryProvided = hasOwn(reqBody, 'discussion_category_name'); + const requestedDiscussionCategory = discussionCategoryProvided && !hasLinkedDiscussion + ? parseDiscussionCategoryName(reqBody.discussion_category_name) + : undefined; + if (discussionCategoryProvided && !hasLinkedDiscussion && requestedDiscussionCategory === null) { + return jsonNotFound('Discussion category not found.'); + } + + const tagName = typeof reqBody.tag_name === 'string' ? reqBody.tag_name : tagNameForRelease(found.tags); + if (!tagName) { + return jsonValidationError('Validation Failed: tag_name is required.'); + } + const existingTagRelease = await findReleaseByTag(ctx, targetDid, repoName, tagName); + if (existingTagRelease && String(releaseId(existingTagRelease.release)) !== id) { + return jsonValidationError(`Validation Failed: release already exists for tag_name '${tagName}'.`); + } + + const updatedData: Record<string, unknown> = { + ...found.data, + name : typeof reqBody.name === 'string' ? reqBody.name : found.data.name, + body : typeof reqBody.body === 'string' ? reqBody.body : (found.data.body ?? ''), + }; + if (requestedDiscussionCategory) { + updatedData.discussionCategoryName = requestedDiscussionCategory; + } + const updatedTags: Record<string, unknown> = { + ...found.tags, + tagName, + }; + + if (typeof reqBody.target_commitish === 'string') { + updatedTags.commitSha = reqBody.target_commitish; + } + if (typeof reqBody.draft === 'boolean') { + updatedTags.draft = reqBody.draft; + } + if (typeof reqBody.prerelease === 'boolean') { + updatedTags.prerelease = reqBody.prerelease; + } + if (tagFlag(found.tags, 'draft') && updatedTags.draft === false && typeof updatedData.publishedAt !== 'string') { + updatedData.publishedAt = new Date().toISOString(); + } + + let latestMode = requestedMakeLatest ?? releaseMakeLatest(found.data); + if (makeLatestProvided && requestedMakeLatest === 'true' && !isPublishedFullRelease(updatedTags)) { + return jsonValidationError('Validation Failed: draft and prerelease releases cannot be set as latest.'); + } + if (!isPublishedFullRelease(updatedTags)) { + latestMode = 'false'; + } + if (latestMode === 'true' || makeLatestProvided || !isPublishedFullRelease(updatedTags)) { + updatedData.makeLatest = latestMode; + } + + const { status } = await found.release.update({ + data : updatedData, + tags : updatedTags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to update release: ${status.detail}`); + } + + if (updatedData.makeLatest === 'true') { + const clearError = await clearExplicitLatestReleases(ctx, found.from, found.repoContextId, found.release.id ?? ''); + if (clearError) { return clearError; } + } + + return jsonOk(await buildReleaseResponse( + ctx, found.from, found.release, updatedData, updatedTags, targetDid, repoName, buildApiUrl(url), + found.immutableReleasesEnabled, found.defaultBranch, + )); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/releases/:id +// --------------------------------------------------------------------------- + +export async function handleDeleteRelease( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const { status } = await found.release.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete release: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET/POST/DELETE /repos/:did/:repo/releases/:id/reactions +// --------------------------------------------------------------------------- + +export async function handleListReleaseReactions( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const contentFilter = url.searchParams.get('content'); + if (contentFilter !== null) { + const parsed = parseReleaseReactionContent(contentFilter); + if (typeof parsed !== 'string') { return parsed; } + } + + const pagination = parsePagination(url); + const reactions = contentFilter === null + ? releaseReactionEntries(found.data) + : releaseReactionEntries(found.data).filter(reaction => reaction.content === contentFilter); + const paged = paginate(reactions, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/releases/${id}/reactions`, + pagination.page, pagination.perPage, reactions.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(reaction => buildReleaseReactionResponse(reaction, baseUrl)), extraHeaders); +} + +export async function handleCreateReleaseReaction( + ctx: AgentContext, targetDid: string, repoName: string, id: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const content = parseReleaseReactionContent(reqBody.content); + if (typeof content !== 'string') { return content; } + + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const baseUrl = buildApiUrl(url); + const duplicate = releaseReactionEntries(found.data).find((reaction) => { + return reaction.userDid === ctx.did && reaction.content === content; + }); + if (duplicate) { + return jsonOk(buildReleaseReactionResponse(duplicate, baseUrl)); + } + + const reaction: ReleaseReactionEntry = { + id : nextReleaseReactionId(found.data), + userDid : ctx.did, + content, + createdAt : new Date().toISOString(), + }; + const reactions = releaseReactions(found.data); + const updatedData = { + ...found.data, + reactions: { + ...reactions, + [releaseReactionKey(reaction.id)]: reaction, + }, + }; + const { status } = await found.release.update({ + data : updatedData, + tags : found.tags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to create release reaction: ${status.detail}`); + } + + return jsonCreated(buildReleaseReactionResponse(reaction, baseUrl)); +} + +export async function handleDeleteReleaseReaction( + ctx: AgentContext, targetDid: string, repoName: string, id: string, reactionId: string, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const parsedId = parseInt(reactionId, 10); + const reactions = releaseReactions(found.data); + const reaction = Object.values(reactions).find(entry => entry.id === parsedId); + if (!reaction) { + return jsonNotFound(`Reaction #${reactionId} not found on release #${id}.`); + } + + const updatedReactions = { ...reactions }; + delete updatedReactions[releaseReactionKey(reaction.id)]; + const updatedData: Record<string, unknown> = { ...found.data }; + if (Object.keys(updatedReactions).length > 0) { + updatedData.reactions = updatedReactions; + } else { + delete updatedData.reactions; + } + + const { status } = await found.release.update({ + data : updatedData, + tags : found.tags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to delete release reaction: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/:id/assets +// --------------------------------------------------------------------------- + +export async function handleListReleaseAssets( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const baseUrl = buildApiUrl(url); + const tagName = tagNameForRelease(found.tags); + const pagination = parsePagination(url); + const assets = await listReleaseAssets(ctx, found.from, found.release.contextId ?? ''); + const overrides = assetOverrides(found.data); + const paged = paginate(assets, pagination); + const items = await Promise.all(paged.map((asset) => { + return buildAssetResponse(asset, tagName, targetDid, repoName, baseUrl, overrides[assetOverrideKey(asset)]); + })); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/releases/${id}/assets`, + pagination.page, pagination.perPage, assets.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/releases/:id/assets +// --------------------------------------------------------------------------- + +export async function handleUploadReleaseAsset( + ctx: AgentContext, targetDid: string, repoName: string, + id: string, url: URL, options: ReleaseUploadOptions = {}, +): Promise<JsonResponse> { + const found = await findReleaseById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release '${id}' not found.`); + } + + const name = url.searchParams.get('name') ?? ''; + if (!name) { + return jsonValidationError('Validation Failed: name is required.'); + } + + const assets = await listReleaseAssets(ctx, found.from, found.release.contextId ?? ''); + const overrides = assetOverrides(found.data); + if (assets.some((asset) => effectiveAssetName(asset, overrides) === name)) { + return jsonValidationError('Validation Failed: asset with same filename already exists.'); + } + + const bytes = options.rawBody ? new Uint8Array(options.rawBody) : new Uint8Array(); + const contentType = options.contentType?.split(';')[0]?.trim() || 'application/octet-stream'; + const label = url.searchParams.get('label'); + const tags: Record<string, unknown> = { + filename : name, + contentType : contentType, + size : bytes.byteLength, + }; + + const { status, record } = await ctx.releases.records.create('repo/release/asset' as any, { + data : bytes, + dataFormat : contentType, + tags, + parentContextId : found.release.contextId ?? '', + } as any); + + if (status.code >= 300) { + return jsonValidationError(`Failed to upload release asset: ${status.detail}`); + } + if (!record) {throw new Error('Failed to create release asset record');} + + let override: AssetOverride = {}; + if (label !== null) { + override = { label, updatedAt: new Date().toISOString() }; + const { status: updateStatus } = await found.release.update({ + data: { + ...found.data, + assetOverrides: { + ...overrides, + [assetOverrideKey(record)]: override, + }, + }, + tags: found.tags, + }); + + if (updateStatus.code >= 300) { + return jsonValidationError(`Failed to store release asset label: ${updateStatus.detail}`); + } + } + + return jsonCreated(await buildAssetResponse( + record, tagNameForRelease(found.tags), targetDid, repoName, buildApiUrl(url), override, + )); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/assets/:id +// --------------------------------------------------------------------------- + +export async function handleGetReleaseAsset( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, + mediaKind: 'binary' | null = null, +): Promise<JsonResponse> { + const found = await findAssetById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release asset '${id}' not found.`); + } + + const baseUrl = buildApiUrl(url); + if (mediaKind === 'binary') { + return releaseAssetBinaryResponse(found.asset, baseUrl, targetDid, repoName); + } + + return jsonOk(await buildAssetResponse( + found.asset, tagNameForRelease(found.releaseTags), targetDid, repoName, baseUrl, + assetOverrides(found.releaseData)[assetOverrideKey(found.asset)], + )); +} + +// --------------------------------------------------------------------------- +// PATCH /repos/:did/:repo/releases/assets/:id +// --------------------------------------------------------------------------- + +export async function handleUpdateReleaseAsset( + ctx: AgentContext, targetDid: string, repoName: string, + id: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const found = await findAssetById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release asset '${id}' not found.`); + } + + if (reqBody.name !== undefined && typeof reqBody.name !== 'string') { + return jsonValidationError('Validation Failed: name must be a string.'); + } + if (reqBody.label !== undefined && reqBody.label !== null && typeof reqBody.label !== 'string') { + return jsonValidationError('Validation Failed: label must be a string or null.'); + } + if (reqBody.state !== undefined && typeof reqBody.state !== 'string') { + return jsonValidationError('Validation Failed: state must be a string.'); + } + + const overrides = assetOverrides(found.releaseData); + const key = assetOverrideKey(found.asset); + const nextOverride: AssetOverride = { + ...(overrides[key] ?? {}), + ...(typeof reqBody.name === 'string' ? { name: reqBody.name } : {}), + ...(reqBody.label !== undefined ? { label: reqBody.label as string | null } : {}), + ...(typeof reqBody.state === 'string' ? { state: reqBody.state } : {}), + updatedAt: new Date().toISOString(), + }; + const updatedReleaseData = { + ...found.releaseData, + assetOverrides: { + ...overrides, + [key]: nextOverride, + }, + }; + + const { status } = await found.release.update({ + data : updatedReleaseData, + tags : found.releaseTags, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to update release asset: ${status.detail}`); + } + + return jsonOk(await buildAssetResponse( + found.asset, tagNameForRelease(found.releaseTags), targetDid, repoName, buildApiUrl(url), nextOverride, + )); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/releases/assets/:id +// --------------------------------------------------------------------------- + +export async function handleDeleteReleaseAsset( + ctx: AgentContext, targetDid: string, repoName: string, id: string, +): Promise<JsonResponse> { + const found = await findAssetById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release asset '${id}' not found.`); + } + + const { status } = await found.asset.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete release asset: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/assets/:id/download +// --------------------------------------------------------------------------- + +export async function handleDownloadReleaseAsset( + ctx: AgentContext, targetDid: string, repoName: string, id: string, url: URL, +): Promise<JsonResponse> { + const found = await findAssetById(ctx, targetDid, repoName, id); + if (!found) { + return jsonNotFound(`Release asset '${id}' not found.`); + } + + return releaseAssetBinaryResponse(found.asset, buildApiUrl(url), targetDid, repoName); +} + +async function releaseAssetBinaryResponse( + asset: any, baseUrl: string, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const tags = (asset.tags as Record<string, unknown> | undefined) ?? {}; + const filename = String(tags.filename ?? 'asset'); + const contentType = String(tags.contentType ?? asset.dataFormat ?? 'application/octet-stream'); + const bytes = await readAssetBytes(asset); + + return binaryOk(bytes, contentType, { + 'Content-Disposition' : `attachment; filename="${filename.replace(/"/g, '')}"`, + 'X-GitHub-Asset-Url' : `${baseUrl}/repos/${targetDid}/${repoName}/releases/assets/${releaseAssetId(asset)}`, + }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/download/:tag/:asset +// --------------------------------------------------------------------------- + +export async function handleDownloadReleaseAssetByName( + ctx: AgentContext, targetDid: string, repoName: string, rawTag: string, rawName: string, +): Promise<JsonResponse> { + const tag = decodeRouteParam(rawTag); + const name = decodeRouteParam(rawName); + const release = await findReleaseByTag(ctx, targetDid, repoName, tag); + if (!release) { + return jsonNotFound(`Release with tag '${tag}' not found.`); + } + + const assets = await listReleaseAssets(ctx, release.from, release.release.contextId ?? ''); + const overrides = assetOverrides(release.data); + const asset = assets.find((rec) => { + return effectiveAssetName(rec, overrides) === name; + }); + if (!asset) { + return jsonNotFound(`Release asset '${name}' not found.`); + } + + const tags = (asset.tags as Record<string, unknown> | undefined) ?? {}; + const contentType = String(tags.contentType ?? asset.dataFormat ?? 'application/octet-stream'); + const effectiveName = overrides[assetOverrideKey(asset)]?.name ?? name; + return binaryOk(await readAssetBytes(asset), contentType, { + 'Content-Disposition': `attachment; filename="${effectiveName.replace(/"/g, '')}"`, + }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/releases/tags/:tag +// --------------------------------------------------------------------------- + +async function findReleaseByTag( + ctx: AgentContext, targetDid: string, repoName: string, tag: string, +): Promise<{ + from: string | undefined; + immutableReleasesEnabled: boolean; + defaultBranch: string; + release: any; + tags: Record<string, unknown>; + data: any; +} | null> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return null; + } + + const from = fromOpt(ctx, targetDid); const { records } = await ctx.releases.records.query('repo/release' as any, { from, filter: { contextId: repo.contextId, tags: { tagName: tag } }, }); if (records.length === 0) { + return null; + } + + const release = records[0]; + return { + from, + immutableReleasesEnabled : await repoImmutableReleasesEnabled(ctx, targetDid, repo.contextId), + defaultBranch : repo.defaultBranch, + release, + tags : (release.tags as Record<string, unknown> | undefined) ?? {}, + data : await release.data.json(), + }; +} + +export async function handleGetReleaseByTag( + ctx: AgentContext, targetDid: string, repoName: string, rawTag: string, url: URL, +): Promise<JsonResponse> { + const tag = decodeRouteParam(rawTag); + const found = await findReleaseByTag(ctx, targetDid, repoName, tag); + if (!found) { return jsonNotFound(`Release with tag '${tag}' not found.`); } - const rec = records[0]; - const data = await rec.data.json(); - const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; + const baseUrl = buildApiUrl(url); + return jsonOk(await buildReleaseResponse( + ctx, found.from, found.release, found.data, found.tags, targetDid, repoName, baseUrl, + found.immutableReleasesEnabled, found.defaultBranch, + )); +} + +// --------------------------------------------------------------------------- +// POST /repos/:did/:repo/releases/generate-notes +// --------------------------------------------------------------------------- + +function buildGeneratedReleaseNotes( + targetDid: string, repoName: string, tagName: string, targetCommitish: string, previousTagName: string | null = null, +): { name: string; body: string } { + const body = [ + `## Changes in ${tagName}`, + '', + `Generated release notes for ${targetDid}/${repoName}.`, + '', + `Target: ${targetCommitish}`, + ...(previousTagName ? [`Previous tag: ${previousTagName}`] : []), + ].join('\n'); - return jsonOk(buildReleaseResponse(rec, data, tags, targetDid, repoName, baseUrl)); + return { + name: `Release ${tagName}`, + body, + }; +} + +export async function handleGenerateReleaseNotes( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const tagName = typeof reqBody.tag_name === 'string' ? reqBody.tag_name : ''; + if (!tagName) { + return jsonValidationError('Validation Failed: tag_name is required.'); + } + + const targetCommitish = typeof reqBody.target_commitish === 'string' + ? reqBody.target_commitish + : repo.defaultBranch; + const previousTagName = typeof reqBody.previous_tag_name === 'string' + ? reqBody.previous_tag_name + : null; + + return jsonOk(buildGeneratedReleaseNotes(targetDid, repoName, tagName, targetCommitish, previousTagName)); } // --------------------------------------------------------------------------- @@ -157,18 +1256,52 @@ export async function handleCreateRelease( if (!tagName) { return jsonValidationError('Validation Failed: tag_name is required.'); } + const existingRelease = await findReleaseByTag(ctx, targetDid, repoName, tagName); + if (existingRelease) { + return jsonValidationError(`Validation Failed: release already exists for tag_name '${tagName}'.`); + } - const name = (reqBody.name as string) ?? tagName; - const body = (reqBody.body as string) ?? ''; + let name = typeof reqBody.name === 'string' ? reqBody.name : tagName; + let body = typeof reqBody.body === 'string' ? reqBody.body : ''; const baseUrl = buildApiUrl(url); + const immutableReleasesEnabled = await repoImmutableReleasesEnabled(ctx, targetDid, repo.contextId); const tags: Record<string, unknown> = { tagName }; if (reqBody.target_commitish) { tags.commitSha = reqBody.target_commitish; } if (reqBody.prerelease === true) { tags.prerelease = true; } if (reqBody.draft === true) { tags.draft = true; } + if (reqBody.generate_release_notes === true) { + const targetCommitish = typeof reqBody.target_commitish === 'string' + ? reqBody.target_commitish + : repo.defaultBranch; + const notes = buildGeneratedReleaseNotes(targetDid, repoName, tagName, targetCommitish); + if (typeof reqBody.name !== 'string') { + name = notes.name; + } + body = body ? `${body}\n\n${notes.body}` : notes.body; + } + + const makeLatestProvided = hasOwn(reqBody, 'make_latest'); + const requestedMakeLatest = makeLatestProvided ? parseMakeLatest(reqBody.make_latest) : undefined; + if (makeLatestProvided && requestedMakeLatest === undefined) { + return jsonValidationError('Validation Failed: make_latest must be one of true, false, or legacy.'); + } + if (requestedMakeLatest === 'true' && !isPublishedFullRelease(tags)) { + return jsonValidationError('Validation Failed: draft and prerelease releases cannot be set as latest.'); + } + const makeLatest = requestedMakeLatest ?? (isPublishedFullRelease(tags) ? 'true' : 'false'); + const releaseData: Record<string, unknown> = { name, body, makeLatest }; + if (hasOwn(reqBody, 'discussion_category_name')) { + const discussionCategoryName = parseDiscussionCategoryName(reqBody.discussion_category_name); + if (discussionCategoryName === null) { + return jsonNotFound('Discussion category not found.'); + } + releaseData.discussionCategoryName = discussionCategoryName; + } + const { status, record } = await ctx.releases.records.create('repo/release' as any, { - data : { name, body }, + data : releaseData, tags, parentContextId : repo.contextId, } as any); @@ -178,9 +1311,17 @@ export async function handleCreateRelease( } if (!record) {throw new Error('Failed to create release record');} + if (makeLatest === 'true') { + const clearError = await clearExplicitLatestReleases(ctx, fromOpt(ctx, targetDid), repo.contextId, record.id ?? ''); + if (clearError) { return clearError; } + } + const recTags = (record.tags as Record<string, unknown> | undefined) ?? {}; const data = await record.data.json(); - const release = buildReleaseResponse(record, data, recTags, targetDid, repoName, baseUrl); + const release = await buildReleaseResponse( + ctx, fromOpt(ctx, targetDid), record, data, recTags, targetDid, repoName, baseUrl, + immutableReleasesEnabled, repo.defaultBranch, + ); return jsonCreated(release); } diff --git a/src/github-shim/repo-metadata.ts b/src/github-shim/repo-metadata.ts new file mode 100644 index 0000000..1e4008d --- /dev/null +++ b/src/github-shim/repo-metadata.ts @@ -0,0 +1,3919 @@ +/** + * GitHub API shim — repository metadata, topics, deploy keys, and collaborator endpoints. + * + * Maps forge repo topic records and collaborator role records onto GitHub REST + * API v3 repository metadata routes. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { + CodeScanningAlertData, + DependabotAlertData, + RepositoryRulesetData, + RepositoryRulesetStateData, + SettingsData, +} from '../repo.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + getRepoRecord, + jsonAccepted, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type CollaboratorRole = 'maintainer' | 'triager' | 'contributor' | 'viewer'; +type CollaboratorAffiliationFilter = 'outside' | 'direct' | 'all'; +type CollaboratorPermissionFilter = 'pull' | 'triage' | 'push' | 'maintain' | 'admin'; + +type CollaboratorInfo = { + did : string; + alias : string; + role : CollaboratorRole; + recordId : string; +}; + +type TopicRecord = { + record : any; + name : string; +}; + +type DeployKeyEntry = NonNullable<SettingsData['deployKeys']>[string]; +type AutolinkEntry = NonNullable<SettingsData['autolinks']>[string]; +type InteractionLimitEntry = NonNullable<SettingsData['interactionLimit']>; +type IssueTypeEntry = NonNullable<SettingsData['issueTypes']>[string]; +type RuleSuiteEntry = NonNullable<SettingsData['ruleSuites']>[string]; +type RulesetEntry = NonNullable<SettingsData['rulesets']>[string]; +type CustomPropertyValue = NonNullable<SettingsData['customProperties']>[string]; +type AttestationEntry = NonNullable<SettingsData['attestations']>[string]; +export type CodeScanningAlertEntry = NonNullable<SettingsData['codeScanningAlerts']>[string]; +type CodeScanningInstanceEntry = CodeScanningAlertEntry['mostRecentInstance']; +export type DependabotAlertEntry = NonNullable<SettingsData['dependabotAlerts']>[string]; +export type SecretScanningAlertEntry = NonNullable<SettingsData['secretScanningAlerts']>[string]; +type SecretScanningLocationEntry = NonNullable<SecretScanningAlertEntry['locations']>[number]; +type SecretScanningScanEntry = NonNullable<NonNullable<SettingsData['secretScanningScanHistory']>['incrementalScans']>[number]; +type SecretScanningBypassEntry = NonNullable<SettingsData['secretScanningPushProtectionBypasses']>[string]; +export type SecurityAdvisoryEntry = NonNullable<SettingsData['securityAdvisories']>[string]; + +type RepoSettingsData = SettingsData; + +type RepoSettingsLookup = { + repo : RepoInfo; + record? : { + update : (options: { data: RepoSettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : RepoSettingsData; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ROLES: CollaboratorRole[] = ['maintainer', 'contributor', 'triager', 'viewer']; +const COLLABORATOR_AFFILIATION_FILTERS = new Set<CollaboratorAffiliationFilter>([ + 'outside', + 'direct', + 'all', +]); +const COLLABORATOR_PERMISSION_FILTERS = new Set<CollaboratorPermissionFilter>([ + 'pull', + 'triage', + 'push', + 'maintain', + 'admin', +]); +const AUTOLINK_NUM_TOKEN = '<num>'; +const INTERACTION_LIMITS = new Set(['existing_users', 'contributors_only', 'collaborators_only']); +const CODE_SCANNING_ALERT_STATES = new Set(['open', 'closed', 'dismissed', 'fixed']); +const CODE_SCANNING_UPDATE_STATES = new Set(['dismissed', 'open']); +const CODE_SCANNING_DISMISSAL_REASONS = new Set(['false positive', 'won\'t fix', 'used in tests']); +const CODE_SCANNING_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'warning', 'note', 'error']); +const CODE_SCANNING_SORTS = new Set(['created', 'updated']); +const DEPENDABOT_ALERT_STATES = new Set(['auto_dismissed', 'dismissed', 'fixed', 'open']); +const DEPENDABOT_ALERT_UPDATE_STATES = new Set(['dismissed', 'open']); +const DEPENDABOT_DISMISSAL_REASONS = new Set(['fix_started', 'inaccurate', 'no_bandwidth', 'not_used', 'tolerable_risk']); +const DEPENDABOT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']); +const DEPENDABOT_CLASSIFICATIONS = new Set(['malware', 'general']); +const DEPENDABOT_SCOPES = new Set(['development', 'runtime']); +const DEPENDABOT_SORTS = new Set(['created', 'updated', 'epss_percentage']); +const SECRET_SCANNING_STATES = new Set(['open', 'resolved']); +const SECRET_SCANNING_RESOLUTIONS = new Set(['false_positive', 'wont_fix', 'revoked', 'pattern_edited', 'pattern_deleted', 'used_in_tests']); +const SECRET_SCANNING_UPDATE_RESOLUTIONS = new Set(['false_positive', 'wont_fix', 'revoked', 'used_in_tests']); +const SECRET_SCANNING_VALIDITIES = new Set(['active', 'inactive', 'unknown']); +const SECRET_SCANNING_UPDATE_VALIDITIES = new Set(['active', 'inactive']); +const SECRET_SCANNING_SORTS = new Set(['created', 'updated']); +const SECRET_SCANNING_BYPASS_REASONS = new Set(['false_positive', 'used_in_tests', 'will_fix_later']); +const SECURITY_ADVISORY_STATES = new Set(['triage', 'draft', 'published', 'closed']); +const SECURITY_ADVISORY_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'unknown']); +const SECURITY_ADVISORY_SORTS = new Set(['created', 'updated', 'published']); +const RULE_SUITE_EVALUATE_STATUSES = new Set(['all', 'active', 'evaluate']); +const RULE_SUITE_RESULTS = new Set(['pass', 'fail', 'bypass', 'all']); +const RULE_SUITE_TIME_PERIOD_MS: Record<string, number> = { + hour : 60 * 60 * 1000, + day : 24 * 60 * 60 * 1000, + week : 7 * 24 * 60 * 60 * 1000, + month : 30 * 24 * 60 * 60 * 1000, +}; +const RULESET_TARGETS = new Set(['branch', 'tag', 'push']); +const RULESET_ENFORCEMENTS = new Set(['disabled', 'active', 'evaluate']); +const SHA256_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const DEFAULT_ISSUE_TYPE_TEMPLATES = [ + { name: 'Bug', description: 'An unexpected problem or behavior' }, + { name: 'Task', description: 'A specific piece of work' }, + { name: 'Feature', description: 'A request for new functionality' }, +] as const; +const INTERACTION_EXPIRY_DAYS: Record<string, number> = { + one_day : 1, + three_days : 3, + one_week : 7, + one_month : 30, + six_months : 180, +}; + +const ROLE_RANK: Record<CollaboratorRole, number> = { + maintainer : 3, + contributor : 2, + triager : 1, + viewer : 0, +}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function isPlainObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeTopicName(name: unknown): string | null { + if (typeof name !== 'string') { + return null; + } + const normalized = name.trim().toLowerCase(); + if (!normalized || normalized.length > 50) { + return null; + } + if (!/^[a-z0-9][a-z0-9._-]*$/.test(normalized)) { + return null; + } + return normalized; +} + +function roleFromPermission(permission: unknown): CollaboratorRole | null { + const value = typeof permission === 'string' ? permission.toLowerCase() : 'push'; + if (value === 'admin' || value === 'maintain') { + return 'maintainer'; + } + if (value === 'push' || value === 'write') { + return 'contributor'; + } + if (value === 'triage') { + return 'triager'; + } + if (value === 'pull') { + return 'viewer'; + } + return null; +} + +function permissionFromRole(role: CollaboratorRole): string { + if (role === 'maintainer') { + return 'admin'; + } + if (role === 'contributor') { + return 'write'; + } + if (role === 'viewer') { + return 'pull'; + } + return 'triage'; +} + +function buildPermissions(role: CollaboratorRole): Record<string, boolean> { + return { + admin : role === 'maintainer', + maintain : role === 'maintainer', + push : role === 'maintainer' || role === 'contributor', + triage : role === 'maintainer' || role === 'contributor' || role === 'triager', + pull : true, + }; +} + +function collaboratorAffiliationFilter(value: string | null): CollaboratorAffiliationFilter | null { + if (!value) { + return 'all'; + } + const normalized = value.toLowerCase(); + return COLLABORATOR_AFFILIATION_FILTERS.has(normalized as CollaboratorAffiliationFilter) + ? normalized as CollaboratorAffiliationFilter + : null; +} + +function collaboratorPermissionFilter(value: string | null): CollaboratorPermissionFilter | null { + if (!value) { + return null; + } + const normalized = value.toLowerCase(); + return COLLABORATOR_PERMISSION_FILTERS.has(normalized as CollaboratorPermissionFilter) + ? normalized as CollaboratorPermissionFilter + : null; +} + +function collaboratorHasPermission(collab: CollaboratorInfo, permission: CollaboratorPermissionFilter): boolean { + return buildPermissions(collab.role)[permission] === true; +} + +function collaboratorMatchesAffiliation( + collab: CollaboratorInfo, targetDid: string, affiliation: CollaboratorAffiliationFilter, +): boolean { + if (affiliation === 'outside') { + return collab.did !== targetDid; + } + return true; +} + +function buildCollaboratorUser(collab: CollaboratorInfo, baseUrl: string): Record<string, unknown> { + const user = buildOwner(collab.did, baseUrl); + return { + ...user, + node_id : collab.recordId, + gravatar_id : '', + followers_url : `${baseUrl}/users/${collab.did}/followers`, + following_url : `${baseUrl}/users/${collab.did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${collab.did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${collab.did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${collab.did}/subscriptions`, + organizations_url : `${baseUrl}/users/${collab.did}/orgs`, + repos_url : `${baseUrl}/users/${collab.did}/repos`, + events_url : `${baseUrl}/users/${collab.did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${collab.did}/received_events`, + site_admin : false, + ...(collab.alias ? { name: collab.alias } : {}), + }; +} + +function buildCollaboratorResponse(collab: CollaboratorInfo, baseUrl: string): Record<string, unknown> { + return { + ...buildCollaboratorUser(collab, baseUrl), + permissions : buildPermissions(collab.role), + role_name : collab.role, + }; +} + +function buildPermissionResponse(collab: CollaboratorInfo, baseUrl: string): Record<string, unknown> { + return { + permission : permissionFromRole(collab.role), + role_name : collab.role, + user : buildCollaboratorUser(collab, baseUrl), + }; +} + +function defaultIssueTypesForRepo(repo: RepoInfo): IssueTypeEntry[] { + const createdAt = toISODate(repo.dateCreated); + const updatedAt = toISODate(repo.timestamp); + return DEFAULT_ISSUE_TYPE_TEMPLATES.map(template => ({ + id : numericId(`issue-type:${template.name.toLowerCase()}`) || 1, + name : template.name, + description : template.description, + createdAt, + updatedAt, + })); +} + +function issueTypeEntries(settings: RepoSettingsData, repo: RepoInfo): IssueTypeEntry[] { + if (settings.issueTypes !== undefined) { + return Object.values(settings.issueTypes) + .filter(entry => entry.isEnabled !== false) + .sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id); + } + return defaultIssueTypesForRepo(repo); +} + +function buildIssueTypeResponse(entry: IssueTypeEntry): Record<string, unknown> { + return { + id : entry.id, + node_id : `IT_${entry.id}`, + name : entry.name, + description : entry.description ?? null, + created_at : entry.createdAt, + updated_at : entry.updatedAt, + }; +} + +async function listTopicRecords(ctx: AgentContext, targetDid: string, repo: RepoInfo): Promise<TopicRecord[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/topic' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const topics: TopicRecord[] = []; + for (const record of records) { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + let data: Record<string, unknown> = {}; + try { + data = await record.data.json(); + } catch { + data = {}; + } + const name = normalizeTopicName(data.name ?? tags.name); + if (name) { + topics.push({ record, name }); + } + } + + topics.sort((a, b) => a.name.localeCompare(b.name)); + return topics; +} + +export async function listTopicNames(ctx: AgentContext, targetDid: string, repo: RepoInfo): Promise<string[]> { + const topics = await listTopicRecords(ctx, targetDid, repo); + return [...new Set(topics.map((topic) => topic.name))]; +} + +async function listCollaborators(ctx: AgentContext, targetDid: string, repo: RepoInfo): Promise<CollaboratorInfo[]> { + const from = fromOpt(ctx, targetDid); + const bestByDid = new Map<string, CollaboratorInfo>(); + + for (const role of ROLES) { + const { records } = await ctx.repo.records.query(`repo/${role}` as any, { + from, + filter: { contextId: repo.contextId }, + }); + + for (const record of records) { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + let data: Record<string, unknown> = {}; + try { + data = await record.data.json(); + } catch { + data = {}; + } + + const did = typeof data.did === 'string' ? data.did : tags.did; + if (!did) { + continue; + } + + const collab: CollaboratorInfo = { + did, + alias : typeof data.alias === 'string' ? data.alias : '', + role, + recordId : record.id ?? `${did}:${role}`, + }; + + const existing = bestByDid.get(did); + if (!existing || ROLE_RANK[collab.role] > ROLE_RANK[existing.role]) { + bestByDid.set(did, collab); + } + } + } + + return [...bestByDid.values()].sort((a, b) => a.did.localeCompare(b.did)); +} + +async function findCollaborator( + ctx: AgentContext, targetDid: string, repo: RepoInfo, did: string, +): Promise<CollaboratorInfo | null> { + const collaborators = await listCollaborators(ctx, targetDid, repo); + return collaborators.find((collab) => collab.did === did) ?? null; +} + +async function deleteCollaboratorRoles( + ctx: AgentContext, repo: RepoInfo, did: string, preserveRecordId?: string, +): Promise<number> { + let deleted = 0; + for (const role of ROLES) { + const { records } = await ctx.repo.records.query(`repo/${role}` as any, { + filter: { contextId: repo.contextId, tags: { did } }, + }); + for (const record of records) { + if (preserveRecordId && record.id === preserveRecordId) { + continue; + } + const { status } = await record.delete(); + if (status.code < 300) { + deleted++; + } + } + } + return deleted; +} + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoSettingsLookup | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + if (records.length === 0) { + return { repo, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<RepoSettingsData> } }; + const settings = await record.data.json(); + return { repo, record, settings: settings ?? {} }; +} + +async function saveRepoSettings( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: RepoSettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function deployKeyEntries(settings: RepoSettingsData): DeployKeyEntry[] { + return Object.values(settings.deployKeys ?? {}) + .filter((entry): entry is DeployKeyEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function deployKeyRecordKey(id: number): string { + return String(id); +} + +function nextDeployKeyId(settings: RepoSettingsData): number { + return deployKeyEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function settingsWithDeployKey( + settings: RepoSettingsData, key: string, deployKey: DeployKeyEntry | null, +): RepoSettingsData { + const deployKeys = { ...(settings.deployKeys ?? {}) }; + if (deployKey) { + deployKeys[key] = deployKey; + } else { + delete deployKeys[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(deployKeys).length > 0) { + next.deployKeys = deployKeys; + } else { + delete next.deployKeys; + } + return next; +} + +function autolinkEntries(settings: RepoSettingsData): AutolinkEntry[] { + return Object.values(settings.autolinks ?? {}) + .filter((entry): entry is AutolinkEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function autolinkRecordKey(id: number): string { + return String(id); +} + +function nextAutolinkId(settings: RepoSettingsData): number { + return autolinkEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function settingsWithAutolink( + settings: RepoSettingsData, key: string, autolink: AutolinkEntry | null, +): RepoSettingsData { + const autolinks = { ...(settings.autolinks ?? {}) }; + if (autolink) { + autolinks[key] = autolink; + } else { + delete autolinks[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(autolinks).length > 0) { + next.autolinks = autolinks; + } else { + delete next.autolinks; + } + return next; +} + +function buildAutolinkResponse(autolink: AutolinkEntry): Record<string, unknown> { + return { + id : autolink.id, + key_prefix : autolink.keyPrefix, + url_template : autolink.urlTemplate, + is_alphanumeric : autolink.isAlphanumeric, + }; +} + +function validateAutolinkBody(body: Record<string, unknown>): AutolinkEntry | JsonResponse { + const keyPrefix = typeof body.key_prefix === 'string' ? body.key_prefix.trim() : ''; + if (!keyPrefix) { + return jsonValidationError('Validation Failed: key_prefix is required.'); + } + + const urlTemplate = typeof body.url_template === 'string' ? body.url_template.trim() : ''; + if (!urlTemplate) { + return jsonValidationError('Validation Failed: url_template is required.'); + } + if (!urlTemplate.includes(AUTOLINK_NUM_TOKEN)) { + return jsonValidationError('Validation Failed: url_template must contain <num>.'); + } + try { + const parsed = new URL(urlTemplate.replaceAll(AUTOLINK_NUM_TOKEN, '1')); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return jsonValidationError('Validation Failed: url_template must use http or https.'); + } + } catch { + return jsonValidationError('Validation Failed: url_template must be a valid URL.'); + } + + if (body.is_alphanumeric !== undefined && typeof body.is_alphanumeric !== 'boolean') { + return jsonValidationError('Validation Failed: is_alphanumeric must be a boolean.'); + } + + return { + id : 0, + keyPrefix, + urlTemplate, + isAlphanumeric : body.is_alphanumeric !== false, + }; +} + +async function findAutolink( + ctx: AgentContext, targetDid: string, repoName: string, autolinkId: string, +): Promise<{ lookup: RepoSettingsLookup; autolink: AutolinkEntry; key: string } | JsonResponse> { + const id = parseInt(autolinkId, 10); + if (!Number.isInteger(id) || id < 1) { + return jsonNotFound(`Autolink '${autolinkId}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + for (const [key, autolink] of Object.entries(lookup.settings.autolinks ?? {})) { + if (autolink.id === id) { + return { lookup, autolink, key }; + } + } + return jsonNotFound(`Autolink '${autolinkId}' not found.`); +} + +function activeInteractionLimit(settings: RepoSettingsData): InteractionLimitEntry | null { + const entry = settings.interactionLimit; + if (!entry) { + return null; + } + if (Date.parse(entry.expiresAt) <= Date.now()) { + return null; + } + return entry; +} + +function buildInteractionLimitResponse(entry: InteractionLimitEntry): Record<string, unknown> { + return { + limit : entry.limit, + origin : 'repository', + expires_at : toISODate(entry.expiresAt), + }; +} + +function validateInteractionLimitBody(body: Record<string, unknown>): InteractionLimitEntry | JsonResponse { + if (typeof body.limit !== 'string' || !INTERACTION_LIMITS.has(body.limit)) { + return jsonValidationError('Validation Failed: limit must be existing_users, contributors_only, or collaborators_only.'); + } + + const expiry = typeof body.expiry === 'string' ? body.expiry : 'one_day'; + const days = INTERACTION_EXPIRY_DAYS[expiry]; + if (!days) { + return jsonValidationError('Validation Failed: expiry must be one_day, three_days, one_week, one_month, or six_months.'); + } + + const expiresAt = new Date(Date.now() + (days * 24 * 60 * 60 * 1000)).toISOString(); + return { + limit: body.limit as InteractionLimitEntry['limit'], + expiresAt, + }; +} + +function rulesetEntries(settings: RepoSettingsData): RulesetEntry[] { + return Object.values(settings.rulesets ?? {}) + .filter((entry): entry is RulesetEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function rulesetRecordKey(id: number): string { + return String(id); +} + +function nextRulesetId(settings: RepoSettingsData): number { + return rulesetEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function settingsWithRuleset( + settings: RepoSettingsData, key: string, ruleset: RulesetEntry | null, +): RepoSettingsData { + const rulesets = { ...(settings.rulesets ?? {}) }; + if (ruleset) { + rulesets[key] = ruleset; + } else { + delete rulesets[key]; + } + + const next: RepoSettingsData = { ...settings }; + if (Object.keys(rulesets).length > 0) { + next.rulesets = rulesets; + } else { + delete next.rulesets; + } + return next; +} + +function rulesetSource(targetDid: string, repoName: string): string { + return `${targetDid}/${repoName}`; +} + +function buildRulesetNodeId(targetDid: string, repoName: string, rulesetId: number): string { + return `RRS_${numericId(`${targetDid}:${repoName}:${rulesetId}`).toString(36)}`; +} + +function buildRulesetLinks( + ruleset: RepositoryRulesetStateData, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const base = `${baseUrl}/repos/${targetDid}/${repoName}`; + return { + self : { href: `${base}/rulesets/${ruleset.id}` }, + html : { href: `${base}/rules/${ruleset.id}` }, + }; +} + +function copyRulesetState(ruleset: RepositoryRulesetStateData): RepositoryRulesetStateData { + return { + id : ruleset.id, + name : ruleset.name, + target : ruleset.target, + enforcement : ruleset.enforcement, + ...(ruleset.bypassActors ? { bypassActors: ruleset.bypassActors.map(actor => ({ ...actor })) } : {}), + ...(ruleset.conditions ? { conditions: { ...ruleset.conditions } } : {}), + rules : ruleset.rules.map(rule => ({ ...rule })), + versionId : ruleset.versionId, + createdAt : ruleset.createdAt, + updatedAt : ruleset.updatedAt, + }; +} + +function buildRulesetSummaryResponse( + ruleset: RulesetEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + id : ruleset.id, + name : ruleset.name, + target : ruleset.target, + source_type : 'Repository', + source : rulesetSource(targetDid, repoName), + enforcement : ruleset.enforcement, + node_id : buildRulesetNodeId(targetDid, repoName, ruleset.id), + _links : buildRulesetLinks(ruleset, targetDid, repoName, baseUrl), + created_at : toISODate(ruleset.createdAt), + updated_at : toISODate(ruleset.updatedAt), + }; +} + +function buildRulesetResponse( + ruleset: RepositoryRulesetStateData, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + ...buildRulesetSummaryResponse(ruleset as RulesetEntry, targetDid, repoName, baseUrl), + bypass_actors : ruleset.bypassActors ?? [], + conditions : ruleset.conditions ?? {}, + rules : ruleset.rules, + }; +} + +function buildRulesetHistoryEntry(entry: NonNullable<RepositoryRulesetData['history']>[string]): Record<string, unknown> { + return { + version_id : entry.versionId, + actor : { + id : numericId(entry.actorDid), + type : 'User', + }, + updated_at: toISODate(entry.updatedAt), + }; +} + +function validateRulesetRules(rules: unknown): Array<Record<string, unknown> & { type: string }> | JsonResponse { + if (!Array.isArray(rules)) { + return jsonValidationError('Validation Failed: rules must be an array.'); + } + + const parsed: Array<Record<string, unknown> & { type: string }> = []; + for (const rule of rules) { + if (!isPlainObject(rule) || typeof rule.type !== 'string' || rule.type.trim().length === 0) { + return jsonValidationError('Validation Failed: each rule must be an object with a type.'); + } + parsed.push({ ...rule, type: rule.type.trim() }); + } + return parsed; +} + +function validateRulesetBody( + body: Record<string, unknown>, existing?: RulesetEntry, +): Partial<RulesetEntry> | JsonResponse { + const parsed: Partial<RulesetEntry> = {}; + + if (body.name !== undefined) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { + return jsonValidationError('Validation Failed: name is required.'); + } + parsed.name = body.name.trim(); + } else if (!existing) { + return jsonValidationError('Validation Failed: name is required.'); + } + + const target = body.target ?? (existing ? undefined : 'branch'); + if (target !== undefined) { + if (typeof target !== 'string' || !RULESET_TARGETS.has(target)) { + return jsonValidationError('Validation Failed: target must be branch, tag, or push.'); + } + parsed.target = target as RulesetEntry['target']; + } + + if (body.enforcement !== undefined) { + if (typeof body.enforcement !== 'string' || !RULESET_ENFORCEMENTS.has(body.enforcement)) { + return jsonValidationError('Validation Failed: enforcement must be disabled, active, or evaluate.'); + } + parsed.enforcement = body.enforcement as RulesetEntry['enforcement']; + } else if (!existing) { + return jsonValidationError('Validation Failed: enforcement is required.'); + } + + if (body.bypass_actors !== undefined) { + if (!Array.isArray(body.bypass_actors) || !body.bypass_actors.every(isPlainObject)) { + return jsonValidationError('Validation Failed: bypass_actors must be an array of objects.'); + } + parsed.bypassActors = body.bypass_actors.map(actor => ({ ...actor })); + } + + if (body.conditions !== undefined) { + if (!isPlainObject(body.conditions)) { + return jsonValidationError('Validation Failed: conditions must be an object.'); + } + parsed.conditions = { ...body.conditions }; + } + + if (body.rules !== undefined) { + const rules = validateRulesetRules(body.rules); + if ('status' in rules) { return rules; } + parsed.rules = rules; + } else if (!existing) { + parsed.rules = []; + } + + return parsed; +} + +function customPropertyEntries(settings: RepoSettingsData): Array<{ property_name: string; value: CustomPropertyValue }> { + return Object.entries(settings.customProperties ?? {}) + .filter((entry): entry is [string, CustomPropertyValue] => typeof entry[0] === 'string') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([propertyName, value]) => ({ + property_name: propertyName, + value, + })); +} + +function validateCustomPropertiesBody(body: Record<string, unknown>): { values: Record<string, CustomPropertyValue | null> } | JsonResponse { + if (!Array.isArray(body.properties)) { + return jsonValidationError('Validation Failed: properties must be an array.'); + } + + const parsed: Record<string, CustomPropertyValue | null> = {}; + for (const property of body.properties) { + if (!isPlainObject(property)) { + return jsonValidationError('Validation Failed: each property must be an object.'); + } + + const propertyName = typeof property.property_name === 'string' ? property.property_name.trim() : ''; + if (!propertyName) { + return jsonValidationError('Validation Failed: property_name is required.'); + } + + if (!Object.prototype.hasOwnProperty.call(property, 'value')) { + return jsonValidationError('Validation Failed: value is required.'); + } + + if (property.value === null) { + parsed[propertyName] = null; + } else if (typeof property.value === 'string') { + parsed[propertyName] = property.value; + } else if (Array.isArray(property.value) && property.value.every(item => typeof item === 'string')) { + parsed[propertyName] = [...property.value]; + } else { + return jsonValidationError('Validation Failed: value must be null, a string, or an array of strings.'); + } + } + return { values: parsed }; +} + +function validateRepositoryDispatchBody(body: Record<string, unknown>): JsonResponse | undefined { + if (typeof body.event_type !== 'string' || body.event_type.trim().length === 0 || body.event_type.length > 100) { + return jsonValidationError('Validation Failed: event_type is required and must be 100 characters or fewer.'); + } + + if (body.client_payload === undefined) { + return undefined; + } + + if (!isPlainObject(body.client_payload)) { + return jsonValidationError('Validation Failed: client_payload must be an object.'); + } + if (Object.keys(body.client_payload).length > 10) { + return jsonValidationError('Validation Failed: client_payload can contain at most 10 top-level properties.'); + } + if (new TextEncoder().encode(JSON.stringify(body.client_payload)).length >= 64 * 1024) { + return jsonValidationError('Validation Failed: client_payload must be less than 64KB.'); + } + + return undefined; +} + +export function dependabotAlertEntries(settings: RepoSettingsData): DependabotAlertEntry[] { + return Object.values(settings.dependabotAlerts ?? {}) + .filter((entry): entry is DependabotAlertEntry => ( + Boolean(entry) + && Number.isInteger(entry.number) + && DEPENDABOT_ALERT_STATES.has(entry.state) + )) + .sort((a, b) => a.number - b.number); +} + +function csvQueryValues(url: URL, name: string): string[] { + return url.searchParams.getAll(name) + .flatMap(value => value.split(',')) + .map(value => value.trim()) + .filter(Boolean); +} + +function invalidCsvValues(values: string[], allowed: Set<string>): string[] { + return values.filter(value => !allowed.has(value)); +} + +export function codeScanningAlertEntries(settings: RepoSettingsData): CodeScanningAlertEntry[] { + return Object.values(settings.codeScanningAlerts ?? {}) + .filter((entry): entry is CodeScanningAlertEntry => ( + Boolean(entry) + && Number.isInteger(entry.number) + && CODE_SCANNING_ALERT_STATES.has(entry.state) + && typeof entry.rule?.id === 'string' + && typeof entry.tool?.name === 'string' + )) + .sort((a, b) => a.number - b.number); +} + +function codeScanningAlertInstances(alert: CodeScanningAlertEntry): CodeScanningInstanceEntry[] { + const instances = alert.instances && alert.instances.length > 0 ? alert.instances : [alert.mostRecentInstance]; + return instances.filter((instance): instance is CodeScanningInstanceEntry => ( + Boolean(instance) + && typeof instance.ref === 'string' + && typeof instance.analysisKey === 'string' + && typeof instance.commitSha === 'string' + )); +} + +function codeScanningSeverity(alert: CodeScanningAlertEntry): string { + if (typeof alert.rule.security_severity_level === 'string') { + return alert.rule.security_severity_level; + } + return typeof alert.rule.severity === 'string' ? alert.rule.severity : ''; +} + +function codeScanningRefMatches(instanceRef: string, queryRef: string): boolean { + if (instanceRef === queryRef) { + return true; + } + if (queryRef.startsWith('refs/')) { + return false; + } + return instanceRef === `refs/heads/${queryRef}` || instanceRef === `refs/tags/${queryRef}`; +} + +function codeScanningAlertMatchesRef(alert: CodeScanningAlertEntry, ref: string): boolean { + return codeScanningAlertInstances(alert).some(instance => codeScanningRefMatches(instance.ref, ref)); +} + +function codeScanningAlertMatchesPullRequest(alert: CodeScanningAlertEntry, pr: number): boolean { + return codeScanningAlertInstances(alert).some(instance => instance.ref === `refs/pull/${pr}/merge`); +} + +function codeScanningAssigneesMatch(assigned: string[], assignees: string[]): boolean { + if (assignees.length === 0) { return true; } + if (assignees.includes('*')) { return assigned.length > 0; } + if (assignees.includes('none')) { return assigned.length === 0; } + return assignees.some(assignee => assigned.includes(assignee)); +} + +function parseCodeScanningPullRequest(url: URL): number | null | JsonResponse { + const pr = url.searchParams.get('pr'); + if (pr === null) { return null; } + const number = parseInt(pr, 10); + if (!Number.isInteger(number) || number < 1) { + return jsonValidationError('Validation Failed: pr must be a positive integer.'); + } + return number; +} + +export function filterCodeScanningAlerts(alerts: CodeScanningAlertEntry[], url: URL): CodeScanningAlertEntry[] | JsonResponse { + const toolName = url.searchParams.get('tool_name'); + const toolGuid = url.searchParams.get('tool_guid'); + if (toolName && toolGuid) { + return jsonValidationError('Validation Failed: tool_name and tool_guid cannot both be specified.'); + } + + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const sort = url.searchParams.get('sort') ?? 'created'; + if (!CODE_SCANNING_SORTS.has(sort)) { + return jsonValidationError('Validation Failed: sort must be created or updated.'); + } + + const state = csvQueryValues(url, 'state'); + const invalidState = invalidCsvValues(state, CODE_SCANNING_ALERT_STATES); + if (invalidState.length > 0) { + return jsonValidationError('Validation Failed: state must be open, closed, dismissed, or fixed.'); + } + + const severity = csvQueryValues(url, 'severity'); + const invalidSeverity = invalidCsvValues(severity, CODE_SCANNING_SEVERITIES); + if (invalidSeverity.length > 0) { + return jsonValidationError('Validation Failed: severity must be critical, high, medium, low, warning, note, or error.'); + } + + const pr = parseCodeScanningPullRequest(url); + if (pr && typeof pr !== 'number') { return pr; } + + const ref = url.searchParams.get('ref'); + const assignees = csvQueryValues(url, 'assignees'); + const filtered = alerts + .filter(alert => !toolName || alert.tool.name === toolName) + .filter(alert => !toolGuid || alert.tool.guid === toolGuid) + .filter(alert => !ref || codeScanningAlertMatchesRef(alert, ref)) + .filter(alert => pr === null || codeScanningAlertMatchesPullRequest(alert, pr)) + .filter(alert => state.length === 0 || state.includes(alert.state)) + .filter(alert => severity.length === 0 || severity.includes(codeScanningSeverity(alert))) + .filter(alert => codeScanningAssigneesMatch(alert.assignees ?? [], assignees)); + + return filtered.sort((a, b) => { + const left = Date.parse(sort === 'updated' ? a.updatedAt : a.createdAt); + const right = Date.parse(sort === 'updated' ? b.updatedAt : b.createdAt); + const comparison = left - right || a.number - b.number; + return direction === 'asc' ? comparison : -comparison; + }); +} + +function filterCodeScanningInstances(alert: CodeScanningAlertEntry, url: URL): CodeScanningInstanceEntry[] | JsonResponse { + const pr = parseCodeScanningPullRequest(url); + if (pr && typeof pr !== 'number') { return pr; } + + const ref = url.searchParams.get('ref'); + return codeScanningAlertInstances(alert) + .filter(instance => !ref || codeScanningRefMatches(instance.ref, ref)) + .filter(instance => pr === null || instance.ref === `refs/pull/${pr}/merge`); +} + +function buildCodeScanningInstanceResponse(instance: CodeScanningInstanceEntry): Record<string, unknown> { + return { + ref : instance.ref, + analysis_key : instance.analysisKey, + environment : instance.environment ?? '', + category : instance.category, + state : instance.state, + commit_sha : instance.commitSha, + message : instance.message ?? null, + location : instance.location ?? null, + classifications : instance.classifications ?? [], + }; +} + +export function buildCodeScanningAlertResponse( + alert: CodeScanningAlertEntry, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const repoBase = `${baseUrl}/repos/${targetDid}/${repo.name}`; + return { + number : alert.number, + created_at : toISODate(alert.createdAt), + url : `${repoBase}/code-scanning/alerts/${alert.number}`, + html_url : `${repoBase}/code-scanning/${alert.number}`, + state : alert.state, + fixed_at : alert.fixedAt ? toISODate(alert.fixedAt) : null, + dismissed_by : alert.dismissedBy ? buildOwner(alert.dismissedBy, baseUrl) : null, + dismissed_at : alert.dismissedAt ? toISODate(alert.dismissedAt) : null, + dismissed_reason : alert.dismissedReason ?? null, + dismissed_comment : alert.dismissedComment ?? null, + rule : { ...alert.rule }, + tool : { ...alert.tool }, + most_recent_instance : buildCodeScanningInstanceResponse(alert.mostRecentInstance), + instances_url : `${repoBase}/code-scanning/alerts/${alert.number}/instances`, + assignees : (alert.assignees ?? []).map(assignee => buildOwner(assignee, baseUrl)), + }; +} + +async function findCodeScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, +): Promise<{ lookup: RepoSettingsLookup; alert: CodeScanningAlertEntry; key: string } | JsonResponse> { + const number = parseInt(alertNumber, 10); + if (!Number.isInteger(number) || number < 1) { + return jsonNotFound(`Code scanning alert '${alertNumber}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = String(number); + const alert = lookup.settings.codeScanningAlerts?.[key] + ?? codeScanningAlertEntries(lookup.settings).find(entry => entry.number === number); + if (!alert) { + return jsonNotFound(`Code scanning alert '${alertNumber}' not found.`); + } + + return { lookup, alert, key: String(alert.number) }; +} + +function updatedCodeScanningInstances( + alert: CodeScanningAlertEntry, state: CodeScanningAlertEntry['state'], +): CodeScanningInstanceEntry[] | undefined { + if (!alert.instances) { return undefined; } + return alert.instances.map((instance) => ( + instance.ref === alert.mostRecentInstance.ref && instance.commitSha === alert.mostRecentInstance.commitSha + ? { ...instance, state } + : instance + )); +} + +function validateCodeScanningAlertUpdate( + body: Record<string, unknown>, existing: CodeScanningAlertEntry, actorDid: string, +): CodeScanningAlertEntry | JsonResponse { + const hasState = Object.prototype.hasOwnProperty.call(body, 'state'); + const hasAssignees = Object.prototype.hasOwnProperty.call(body, 'assignees'); + if (!hasState && !hasAssignees) { + return jsonValidationError('Validation Failed: state or assignees is required.'); + } + + let updated: CodeScanningAlertEntry = { ...existing, updatedAt: new Date().toISOString() }; + + if (hasAssignees) { + if (!Array.isArray(body.assignees) || !body.assignees.every(assignee => typeof assignee === 'string')) { + return jsonValidationError('Validation Failed: assignees must be an array of strings.'); + } + updated = { ...updated, assignees: [...new Set(body.assignees)] }; + } + + if (body.create_request !== undefined && typeof body.create_request !== 'boolean') { + return jsonValidationError('Validation Failed: create_request must be a boolean.'); + } + + if (!hasState) { + return updated; + } + + if (typeof body.state !== 'string' || !CODE_SCANNING_UPDATE_STATES.has(body.state)) { + return jsonValidationError('Validation Failed: state must be open or dismissed.'); + } + + if (body.state === 'open') { + return { + ...updated, + state : 'open', + mostRecentInstance : { ...updated.mostRecentInstance, state: 'open' }, + instances : updatedCodeScanningInstances(updated, 'open'), + dismissedAt : null, + dismissedBy : null, + dismissedReason : null, + dismissedComment : null, + }; + } + + if (typeof body.dismissed_reason !== 'string' || !CODE_SCANNING_DISMISSAL_REASONS.has(body.dismissed_reason)) { + return jsonValidationError('Validation Failed: dismissed_reason must be false positive, won\'t fix, or used in tests.'); + } + if (body.dismissed_comment !== undefined && body.dismissed_comment !== null && typeof body.dismissed_comment !== 'string') { + return jsonValidationError('Validation Failed: dismissed_comment must be a string.'); + } + + return { + ...updated, + state : 'dismissed', + mostRecentInstance : { ...updated.mostRecentInstance, state: 'dismissed' }, + instances : updatedCodeScanningInstances(updated, 'dismissed'), + dismissedAt : updated.updatedAt, + dismissedBy : actorDid, + dismissedReason : body.dismissed_reason as CodeScanningAlertData['dismissedReason'], + dismissedComment : typeof body.dismissed_comment === 'string' ? body.dismissed_comment : null, + }; +} + +function settingsWithCodeScanningAlert( + settings: RepoSettingsData, key: string, alert: CodeScanningAlertEntry, +): RepoSettingsData { + return { + ...settings, + codeScanningAlerts: { + ...(settings.codeScanningAlerts ?? {}), + [key]: alert, + }, + }; +} + +function dependabotAlertSeverity(alert: DependabotAlertEntry): string { + const vulnerability = alert.securityVulnerability; + if (isPlainObject(vulnerability) && typeof vulnerability.severity === 'string') { + return vulnerability.severity; + } + return typeof alert.securityAdvisory.severity === 'string' ? alert.securityAdvisory.severity : ''; +} + +function dependabotAlertClassification(alert: DependabotAlertEntry): string { + return typeof alert.securityAdvisory.classification === 'string' ? alert.securityAdvisory.classification : 'general'; +} + +function dependabotAlertEpssPercentage(alert: DependabotAlertEntry): number { + const epss = isPlainObject(alert.securityAdvisory.epss) ? alert.securityAdvisory.epss : null; + return typeof epss?.percentage === 'number' ? epss.percentage : 0; +} + +function parseDependabotEpssThreshold(value: string): number | null { + const threshold = Number(value); + return Number.isFinite(threshold) && threshold >= 0 && threshold <= 1 ? threshold : null; +} + +function dependabotEpssFilterMatches(percentage: number, filter: string): boolean | null { + const rangeMatch = filter.match(/^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)$/); + if (rangeMatch) { + const start = parseDependabotEpssThreshold(rangeMatch[1]); + const end = parseDependabotEpssThreshold(rangeMatch[2]); + if (start === null || end === null || start > end) { return null; } + return percentage >= start && percentage <= end; + } + + const comparatorMatch = filter.match(/^(>=|<=|>|<)?(\d+(?:\.\d+)?)$/); + if (!comparatorMatch) { return null; } + const threshold = parseDependabotEpssThreshold(comparatorMatch[2]); + if (threshold === null) { return null; } + + switch (comparatorMatch[1]) { + case '>': return percentage > threshold; + case '>=': return percentage >= threshold; + case '<': return percentage < threshold; + case '<=': return percentage <= threshold; + default: return percentage === threshold; + } +} + +function dependabotAlertHasPatch(alert: DependabotAlertEntry): boolean { + const vulnerability = alert.securityVulnerability; + if (!isPlainObject(vulnerability)) { + return false; + } + const firstPatched = vulnerability.first_patched_version; + return isPlainObject(firstPatched) && typeof firstPatched.identifier === 'string' && firstPatched.identifier.length > 0; +} + +export function filterDependabotAlerts(alerts: DependabotAlertEntry[], url: URL): DependabotAlertEntry[] | JsonResponse { + const classification = csvQueryValues(url, 'classification'); + const invalidClassification = invalidCsvValues(classification, DEPENDABOT_CLASSIFICATIONS); + if (invalidClassification.length > 0) { + return jsonValidationError('Validation Failed: classification must be malware or general.'); + } + + const state = csvQueryValues(url, 'state'); + const invalidState = invalidCsvValues(state, DEPENDABOT_ALERT_STATES); + if (invalidState.length > 0) { + return jsonValidationError('Validation Failed: state must be auto_dismissed, dismissed, fixed, or open.'); + } + + const severity = csvQueryValues(url, 'severity'); + const invalidSeverity = invalidCsvValues(severity, DEPENDABOT_SEVERITIES); + if (invalidSeverity.length > 0) { + return jsonValidationError('Validation Failed: severity must be low, medium, high, or critical.'); + } + + const scope = csvQueryValues(url, 'scope'); + const invalidScope = invalidCsvValues(scope, DEPENDABOT_SCOPES); + if (invalidScope.length > 0) { + return jsonValidationError('Validation Failed: scope must be development or runtime.'); + } + + const has = csvQueryValues(url, 'has'); + if (has.some(value => value !== 'patch')) { + return jsonValidationError('Validation Failed: has only supports patch.'); + } + + const epssPercentage = csvQueryValues(url, 'epss_percentage'); + if (epssPercentage.some(value => dependabotEpssFilterMatches(0, value) === null)) { + return jsonValidationError('Validation Failed: epss_percentage must be a number from 0.0 to 1.0, comparator, or range.'); + } + + const sort = url.searchParams.get('sort') ?? 'created'; + if (!DEPENDABOT_SORTS.has(sort)) { + return jsonValidationError('Validation Failed: sort must be created, updated, or epss_percentage.'); + } + + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const ecosystems = csvQueryValues(url, 'ecosystem'); + const packages = csvQueryValues(url, 'package'); + const manifests = csvQueryValues(url, 'manifest'); + const assignees = csvQueryValues(url, 'assignee'); + + const filtered = alerts + .filter(alert => classification.length === 0 || classification.includes(dependabotAlertClassification(alert))) + .filter(alert => state.length === 0 || state.includes(alert.state)) + .filter(alert => severity.length === 0 || severity.includes(dependabotAlertSeverity(alert))) + .filter(alert => ecosystems.length === 0 || ecosystems.includes(alert.dependency.package.ecosystem)) + .filter(alert => packages.length === 0 || packages.includes(alert.dependency.package.name)) + .filter(alert => manifests.length === 0 || manifests.includes(alert.dependency.manifestPath)) + .filter(alert => scope.length === 0 || scope.includes(alert.dependency.scope ?? 'runtime')) + .filter(alert => has.length === 0 || dependabotAlertHasPatch(alert)) + .filter(alert => ( + epssPercentage.length === 0 + || epssPercentage.some(value => dependabotEpssFilterMatches(dependabotAlertEpssPercentage(alert), value) === true) + )) + .filter((alert) => { + if (assignees.length === 0) { return true; } + const assigned = alert.assignees ?? []; + if (assignees.includes('*')) { return assigned.length > 0; } + if (assignees.includes('none')) { return assigned.length === 0; } + return assignees.some(assignee => assigned.includes(assignee)); + }); + + return filtered.sort((a, b) => { + const left = sort === 'epss_percentage' ? dependabotAlertEpssPercentage(a) : Date.parse(sort === 'updated' ? a.updatedAt : a.createdAt); + const right = sort === 'epss_percentage' ? dependabotAlertEpssPercentage(b) : Date.parse(sort === 'updated' ? b.updatedAt : b.createdAt); + const comparison = left - right || a.number - b.number; + return direction === 'asc' ? comparison : -comparison; + }); +} + +function buildDependabotRepositoryResponse( + repo: RepoInfo, targetDid: string, baseUrl: string, +): Record<string, unknown> { + const fullName = `${targetDid}/${repo.name}`; + return { + id : numericId(repo.contextId || `${targetDid}/${repo.name}`), + name : repo.name, + full_name : fullName, + owner : buildOwner(targetDid, baseUrl), + private : repo.visibility !== 'public', + html_url : `${baseUrl}/repos/${fullName}`, + description : repo.description || null, + fork : Boolean(repo.forkedFromDid), + url : `${baseUrl}/repos/${fullName}`, + }; +} + +export function buildDependabotAlertResponse( + alert: DependabotAlertEntry, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const repoBase = `${baseUrl}/repos/${targetDid}/${repo.name}`; + return { + number : alert.number, + state : alert.state, + dependency : { + package : { ...alert.dependency.package }, + manifest_path : alert.dependency.manifestPath, + scope : alert.dependency.scope ?? 'runtime', + }, + security_advisory : { ...alert.securityAdvisory }, + security_vulnerability : alert.securityVulnerability ? { ...alert.securityVulnerability } : null, + url : `${repoBase}/dependabot/alerts/${alert.number}`, + html_url : `${repoBase}/security/dependabot/${alert.number}`, + created_at : toISODate(alert.createdAt), + updated_at : toISODate(alert.updatedAt), + dismissed_at : alert.dismissedAt ? toISODate(alert.dismissedAt) : null, + dismissed_by : alert.dismissedBy ? buildOwner(alert.dismissedBy, baseUrl) : null, + dismissed_reason : alert.dismissedReason ?? null, + dismissed_comment : alert.dismissedComment ?? null, + fixed_at : alert.fixedAt ? toISODate(alert.fixedAt) : null, + assignees : (alert.assignees ?? []).map(assignee => buildOwner(assignee, baseUrl)), + repository : buildDependabotRepositoryResponse(repo, targetDid, baseUrl), + }; +} + +async function findDependabotAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, +): Promise<{ lookup: RepoSettingsLookup; alert: DependabotAlertEntry; key: string } | JsonResponse> { + const number = parseInt(alertNumber, 10); + if (!Number.isInteger(number) || number < 1) { + return jsonNotFound(`Dependabot alert '${alertNumber}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = String(number); + const alert = lookup.settings.dependabotAlerts?.[key] + ?? dependabotAlertEntries(lookup.settings).find(entry => entry.number === number); + if (!alert) { + return jsonNotFound(`Dependabot alert '${alertNumber}' not found.`); + } + + return { lookup, alert, key: String(alert.number) }; +} + +function validateDependabotAlertUpdate( + body: Record<string, unknown>, existing: DependabotAlertEntry, actorDid: string, +): DependabotAlertEntry | JsonResponse { + const hasState = Object.prototype.hasOwnProperty.call(body, 'state'); + const hasAssignees = Object.prototype.hasOwnProperty.call(body, 'assignees'); + if (!hasState && !hasAssignees) { + return jsonValidationError('Validation Failed: state or assignees is required.'); + } + + let updated: DependabotAlertEntry = { ...existing, updatedAt: new Date().toISOString() }; + + if (hasAssignees) { + if (!Array.isArray(body.assignees) || !body.assignees.every(assignee => typeof assignee === 'string')) { + return jsonValidationError('Validation Failed: assignees must be an array of strings.'); + } + updated = { ...updated, assignees: [...new Set(body.assignees)] }; + } + + if (!hasState) { + return updated; + } + + if (typeof body.state !== 'string' || !DEPENDABOT_ALERT_UPDATE_STATES.has(body.state)) { + return jsonValidationError('Validation Failed: state must be dismissed or open.'); + } + + if (body.state === 'open') { + return { + ...updated, + state : 'open', + dismissedAt : null, + dismissedBy : null, + dismissedReason : null, + dismissedComment : null, + }; + } + + if (typeof body.dismissed_reason !== 'string' || !DEPENDABOT_DISMISSAL_REASONS.has(body.dismissed_reason)) { + return jsonValidationError('Validation Failed: dismissed_reason must be fix_started, inaccurate, no_bandwidth, not_used, or tolerable_risk.'); + } + if (body.dismissed_comment !== undefined && body.dismissed_comment !== null && typeof body.dismissed_comment !== 'string') { + return jsonValidationError('Validation Failed: dismissed_comment must be a string.'); + } + + return { + ...updated, + state : 'dismissed', + dismissedAt : updated.updatedAt, + dismissedBy : actorDid, + dismissedReason : body.dismissed_reason as DependabotAlertData['dismissedReason'], + dismissedComment : typeof body.dismissed_comment === 'string' ? body.dismissed_comment : null, + }; +} + +function settingsWithDependabotAlert( + settings: RepoSettingsData, key: string, alert: DependabotAlertEntry, +): RepoSettingsData { + return { + ...settings, + dependabotAlerts: { + ...(settings.dependabotAlerts ?? {}), + [key]: alert, + }, + }; +} + +export function secretScanningAlertEntries(settings: RepoSettingsData): SecretScanningAlertEntry[] { + return Object.values(settings.secretScanningAlerts ?? {}) + .filter((entry): entry is SecretScanningAlertEntry => ( + Boolean(entry) + && Number.isInteger(entry.number) + && SECRET_SCANNING_STATES.has(entry.state) + && typeof entry.secretType === 'string' + && typeof entry.secret === 'string' + )) + .sort((a, b) => a.number - b.number); +} + +export function parseBooleanQuery(url: URL, name: string): boolean | null | JsonResponse { + const value = url.searchParams.get(name); + if (value === null) { return null; } + if (value === 'true') { return true; } + if (value === 'false') { return false; } + return jsonValidationError(`Validation Failed: ${name} must be true or false.`); +} + +function secretScanningAssigneeMatches(assignedTo: string | null | undefined, assignees: string[]): boolean { + if (assignees.length === 0) { return true; } + if (assignees.includes('*')) { return Boolean(assignedTo); } + if (assignees.includes('none')) { return !assignedTo; } + return Boolean(assignedTo && assignees.includes(assignedTo)); +} + +export function filterSecretScanningAlerts(alerts: SecretScanningAlertEntry[], url: URL): SecretScanningAlertEntry[] | JsonResponse { + const secretTypes = csvQueryValues(url, 'secret_type'); + const excludedSecretTypes = csvQueryValues(url, 'exclude_secret_types'); + if (secretTypes.length > 0 && excludedSecretTypes.length > 0) { + return jsonValidationError('Validation Failed: secret_type and exclude_secret_types cannot both be specified.'); + } + + const providers = csvQueryValues(url, 'providers'); + const excludedProviders = csvQueryValues(url, 'exclude_providers'); + if (providers.length > 0 && excludedProviders.length > 0) { + return jsonValidationError('Validation Failed: providers and exclude_providers cannot both be specified.'); + } + + const state = csvQueryValues(url, 'state'); + const invalidState = invalidCsvValues(state, SECRET_SCANNING_STATES); + if (invalidState.length > 0) { + return jsonValidationError('Validation Failed: state must be open or resolved.'); + } + + const resolution = csvQueryValues(url, 'resolution'); + const invalidResolution = invalidCsvValues(resolution, SECRET_SCANNING_RESOLUTIONS); + if (invalidResolution.length > 0) { + return jsonValidationError('Validation Failed: resolution must be false_positive, wont_fix, revoked, pattern_edited, pattern_deleted, or used_in_tests.'); + } + + const validity = csvQueryValues(url, 'validity'); + const invalidValidity = invalidCsvValues(validity, SECRET_SCANNING_VALIDITIES); + if (invalidValidity.length > 0) { + return jsonValidationError('Validation Failed: validity must be active, inactive, or unknown.'); + } + + const sort = url.searchParams.get('sort') ?? 'created'; + if (!SECRET_SCANNING_SORTS.has(sort)) { + return jsonValidationError('Validation Failed: sort must be created or updated.'); + } + + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + const publiclyLeaked = parseBooleanQuery(url, 'is_publicly_leaked'); + if (publiclyLeaked !== null && typeof publiclyLeaked !== 'boolean') { return publiclyLeaked; } + + const multiRepo = parseBooleanQuery(url, 'is_multi_repo'); + if (multiRepo !== null && typeof multiRepo !== 'boolean') { return multiRepo; } + + const bypassed = parseBooleanQuery(url, 'is_bypassed'); + if (bypassed !== null && typeof bypassed !== 'boolean') { return bypassed; } + + const assignees = csvQueryValues(url, 'assignee'); + const filtered = alerts + .filter(alert => state.length === 0 || state.includes(alert.state)) + .filter(alert => secretTypes.length === 0 || secretTypes.includes(alert.secretType)) + .filter(alert => excludedSecretTypes.length === 0 || !excludedSecretTypes.includes(alert.secretType)) + .filter(alert => providers.length === 0 || providers.includes(alert.providerSlug ?? '')) + .filter(alert => excludedProviders.length === 0 || !excludedProviders.includes(alert.providerSlug ?? '')) + .filter(alert => ( + resolution.length === 0 + || (alert.resolution !== null && alert.resolution !== undefined && resolution.includes(alert.resolution)) + )) + .filter(alert => validity.length === 0 || (alert.validity !== undefined && validity.includes(alert.validity))) + .filter(alert => publiclyLeaked === null || Boolean(alert.publiclyLeaked) === publiclyLeaked) + .filter(alert => multiRepo === null || Boolean(alert.multiRepo) === multiRepo) + .filter(alert => bypassed === null || Boolean(alert.pushProtectionBypassed) === bypassed) + .filter(alert => secretScanningAssigneeMatches(alert.assignedTo, assignees)); + + return filtered.sort((a, b) => { + const left = Date.parse(sort === 'updated' ? a.updatedAt : a.createdAt); + const right = Date.parse(sort === 'updated' ? b.updatedAt : b.createdAt); + const comparison = left - right || a.number - b.number; + return direction === 'asc' ? comparison : -comparison; + }); +} + +function buildSecretScanningLocationResponse(location: SecretScanningLocationEntry): Record<string, unknown> { + return { + type : location.type, + details : { ...location.details }, + }; +} + +export function buildSecretScanningAlertResponse( + alert: SecretScanningAlertEntry, targetDid: string, repo: RepoInfo, baseUrl: string, hideSecret = false, +): Record<string, unknown> { + const repoBase = `${baseUrl}/repos/${targetDid}/${repo.name}`; + const bypassRequestReviewer = alert.pushProtectionBypassRequestReviewer + ? buildOwner(alert.pushProtectionBypassRequestReviewer, baseUrl) + : null; + return { + number : alert.number, + created_at : toISODate(alert.createdAt), + url : `${repoBase}/secret-scanning/alerts/${alert.number}`, + html_url : `${repoBase}/security/secret-scanning/${alert.number}`, + locations_url : `${repoBase}/secret-scanning/alerts/${alert.number}/locations`, + state : alert.state, + resolution : alert.resolution ?? null, + resolved_at : alert.resolvedAt ? toISODate(alert.resolvedAt) : null, + resolved_by : alert.resolvedBy ? buildOwner(alert.resolvedBy, baseUrl) : null, + secret_type : alert.secretType, + secret_type_display_name : alert.secretTypeDisplayName ?? alert.secretType, + secret : hideSecret ? '********' : alert.secret, + provider : alert.provider ?? null, + provider_slug : alert.providerSlug ?? null, + push_protection_bypassed_by : alert.pushProtectionBypassedBy ? buildOwner(alert.pushProtectionBypassedBy, baseUrl) : null, + push_protection_bypassed : Boolean(alert.pushProtectionBypassed), + push_protection_bypassed_at : alert.pushProtectionBypassedAt ? toISODate(alert.pushProtectionBypassedAt) : null, + push_protection_bypass_request_reviewer : bypassRequestReviewer, + push_protection_bypass_request_reviewer_comment : alert.pushProtectionBypassRequestReviewerComment ?? null, + push_protection_bypass_request_comment : alert.pushProtectionBypassRequestComment ?? null, + push_protection_bypass_request_html_url : alert.pushProtectionBypassRequestHtmlUrl ?? null, + resolution_comment : alert.resolutionComment ?? null, + validity : alert.validity ?? 'unknown', + publicly_leaked : Boolean(alert.publiclyLeaked), + multi_repo : Boolean(alert.multiRepo), + is_base64_encoded : Boolean(alert.isBase64Encoded), + first_location_detected : alert.firstLocationDetected ?? null, + has_more_locations : alert.hasMoreLocations ?? ((alert.locations?.length ?? 0) > 1), + assigned_to : alert.assignedTo ? buildOwner(alert.assignedTo, baseUrl) : null, + repository : buildDependabotRepositoryResponse(repo, targetDid, baseUrl), + }; +} + +async function findSecretScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, +): Promise<{ lookup: RepoSettingsLookup; alert: SecretScanningAlertEntry; key: string } | JsonResponse> { + const number = parseInt(alertNumber, 10); + if (!Number.isInteger(number) || number < 1) { + return jsonNotFound(`Secret scanning alert '${alertNumber}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = String(number); + const alert = lookup.settings.secretScanningAlerts?.[key] + ?? secretScanningAlertEntries(lookup.settings).find(entry => entry.number === number); + if (!alert) { + return jsonNotFound(`Secret scanning alert '${alertNumber}' not found.`); + } + + return { lookup, alert, key: String(alert.number) }; +} + +function validateSecretScanningAlertUpdate( + body: Record<string, unknown>, existing: SecretScanningAlertEntry, actorDid: string, +): SecretScanningAlertEntry | JsonResponse { + const hasState = Object.prototype.hasOwnProperty.call(body, 'state'); + const hasResolution = Object.prototype.hasOwnProperty.call(body, 'resolution'); + const hasResolutionComment = Object.prototype.hasOwnProperty.call(body, 'resolution_comment'); + const hasAssignee = Object.prototype.hasOwnProperty.call(body, 'assignee'); + const hasValidity = Object.prototype.hasOwnProperty.call(body, 'validity'); + if (!hasState && !hasResolution && !hasResolutionComment && !hasAssignee && !hasValidity) { + return jsonValidationError('Validation Failed: state, resolution, resolution_comment, assignee, or validity is required.'); + } + + let updated: SecretScanningAlertEntry = { ...existing, updatedAt: new Date().toISOString() }; + + if (hasAssignee) { + if (body.assignee !== null && typeof body.assignee !== 'string') { + return jsonValidationError('Validation Failed: assignee must be a string or null.'); + } + updated = { ...updated, assignedTo: body.assignee === null ? null : body.assignee as string }; + } + + if (hasValidity) { + if (body.validity === null) { + const { validity: _validity, ...withoutValidity } = updated; + updated = withoutValidity; + } else if (typeof body.validity === 'string' && SECRET_SCANNING_UPDATE_VALIDITIES.has(body.validity)) { + updated = { ...updated, validity: body.validity as SecretScanningAlertEntry['validity'] }; + } else { + return jsonValidationError('Validation Failed: validity must be active, inactive, or null.'); + } + } + + if (hasResolutionComment && body.resolution_comment !== null && typeof body.resolution_comment !== 'string') { + return jsonValidationError('Validation Failed: resolution_comment must be a string or null.'); + } + + if (!hasState) { + if (hasResolution) { + return jsonValidationError('Validation Failed: state is required when resolution is provided.'); + } + return updated; + } + + if (typeof body.state !== 'string' || !SECRET_SCANNING_STATES.has(body.state)) { + return jsonValidationError('Validation Failed: state must be open or resolved.'); + } + + if (body.state === 'open') { + if (hasResolution && body.resolution !== null) { + return jsonValidationError('Validation Failed: resolution must be null when state is open.'); + } + return { + ...updated, + state : 'open', + resolution : null, + resolvedAt : null, + resolvedBy : null, + resolutionComment : typeof body.resolution_comment === 'string' ? body.resolution_comment : null, + }; + } + + if (typeof body.resolution !== 'string' || !SECRET_SCANNING_UPDATE_RESOLUTIONS.has(body.resolution)) { + return jsonValidationError('Validation Failed: resolution must be false_positive, wont_fix, revoked, or used_in_tests when state is resolved.'); + } + + return { + ...updated, + state : 'resolved', + resolution : body.resolution as SecretScanningAlertEntry['resolution'], + resolvedAt : updated.updatedAt, + resolvedBy : actorDid, + resolutionComment : typeof body.resolution_comment === 'string' ? body.resolution_comment : null, + }; +} + +function settingsWithSecretScanningAlert( + settings: RepoSettingsData, key: string, alert: SecretScanningAlertEntry, +): RepoSettingsData { + return { + ...settings, + secretScanningAlerts: { + ...(settings.secretScanningAlerts ?? {}), + [key]: alert, + }, + }; +} + +function validateSecretScanningPushProtectionBypass( + body: Record<string, unknown>, +): { placeholderId: string; reason: SecretScanningBypassEntry['reason'] } | JsonResponse { + if (typeof body.reason !== 'string' || !SECRET_SCANNING_BYPASS_REASONS.has(body.reason)) { + return jsonValidationError('Validation Failed: reason must be false_positive, used_in_tests, or will_fix_later.'); + } + + if (typeof body.placeholder_id !== 'string' || body.placeholder_id.trim().length === 0) { + return jsonValidationError('Validation Failed: placeholder_id is required.'); + } + + return { + placeholderId : body.placeholder_id.trim(), + reason : body.reason as SecretScanningBypassEntry['reason'], + }; +} + +function secretScanningPushProtectionBypassResponse(bypass: SecretScanningBypassEntry): Record<string, unknown> { + return { + reason : bypass.reason, + expire_at : bypass.expireAt ? toISODate(bypass.expireAt) : null, + token_type : bypass.tokenType, + }; +} + +function settingsWithSecretScanningPushProtectionBypass( + settings: RepoSettingsData, key: string, bypass: SecretScanningBypassEntry, +): RepoSettingsData { + const next: RepoSettingsData = { + ...settings, + secretScanningPushProtectionBypasses: { + ...(settings.secretScanningPushProtectionBypasses ?? {}), + [key]: bypass, + }, + }; + + if (!bypass.alertNumber) { + return next; + } + + const alerts = { ...(settings.secretScanningAlerts ?? {}) }; + const alert = alerts[String(bypass.alertNumber)] + ?? secretScanningAlertEntries(settings).find(entry => entry.number === bypass.alertNumber); + if (!alert) { + return next; + } + + alerts[String(alert.number)] = { + ...alert, + pushProtectionBypassed : true, + pushProtectionBypassedBy : bypass.createdBy ?? null, + pushProtectionBypassedAt : bypass.createdAt ?? null, + updatedAt : bypass.createdAt ?? alert.updatedAt, + }; + next.secretScanningAlerts = alerts; + return next; +} + +function buildSecretScanningScanResponse(scan: SecretScanningScanEntry): Record<string, unknown> { + return { + type : scan.type, + status : scan.status, + ...(scan.startedAt ? { started_at: toISODate(scan.startedAt) } : {}), + ...(scan.completedAt ? { completed_at: toISODate(scan.completedAt) } : {}), + ...(scan.patternSlug ? { pattern_slug: scan.patternSlug } : {}), + ...(scan.patternScope ? { pattern_scope: scan.patternScope } : {}), + }; +} + +function buildSecretScanningScanHistoryResponse(settings: RepoSettingsData, repo: RepoInfo): Record<string, unknown> { + const history = settings.secretScanningScanHistory ?? { + incrementalScans: [{ + type : 'git', + status : 'completed', + completedAt : toISODate(repo.timestamp), + }], + }; + + return { + incremental_scans : (history.incrementalScans ?? []).map(buildSecretScanningScanResponse), + backfill_scans : (history.backfillScans ?? []).map(buildSecretScanningScanResponse), + pattern_update_scans : (history.patternUpdateScans ?? []).map(buildSecretScanningScanResponse), + custom_pattern_backfill_scans : (history.customPatternBackfillScans ?? []).map(buildSecretScanningScanResponse), + generic_secrets_backfill_scans : (history.genericSecretsBackfillScans ?? []).map(buildSecretScanningScanResponse), + }; +} + +export function securityAdvisoryEntries(settings: RepoSettingsData): SecurityAdvisoryEntry[] { + return Object.values(settings.securityAdvisories ?? {}) + .filter((entry): entry is SecurityAdvisoryEntry => ( + Boolean(entry) + && typeof entry.ghsaId === 'string' + && typeof entry.summary === 'string' + && typeof entry.description === 'string' + && SECURITY_ADVISORY_STATES.has(entry.state) + && typeof entry.authorDid === 'string' + )) + .sort((a, b) => a.ghsaId.localeCompare(b.ghsaId)); +} + +function securityAdvisoryRecordKey(ghsaId: string): string { + return ghsaId.toUpperCase(); +} + +function nextSecurityAdvisoryGhsaId(settings: RepoSettingsData, repo: RepoInfo, now: string): string { + const used = new Set(securityAdvisoryEntries(settings).map(advisory => advisory.ghsaId.toUpperCase())); + for (let i = used.size + 1; i < used.size + 1000; i++) { + const suffix = numericId(`${repo.contextId}:${now}:${i}`).toString(36).padStart(12, '0').slice(0, 12); + const ghsaId = `GHSA-${suffix.slice(0, 4)}-${suffix.slice(4, 8)}-${suffix.slice(8, 12)}`.toUpperCase(); + if (!used.has(ghsaId)) { + return ghsaId; + } + } + return `GHSA-${Date.now().toString(36).padStart(12, '0').slice(0, 4)}-TEMP-FALL`.toUpperCase(); +} + +function validateSecurityAdvisoryList(url: URL): { state: string[]; sort: string; direction: 'asc' | 'desc' } | JsonResponse { + const state = csvQueryValues(url, 'state'); + const invalidState = invalidCsvValues(state, SECURITY_ADVISORY_STATES); + if (invalidState.length > 0) { + return jsonValidationError('Validation Failed: state must be triage, draft, published, or closed.'); + } + + const sort = url.searchParams.get('sort') ?? 'created'; + if (!SECURITY_ADVISORY_SORTS.has(sort)) { + return jsonValidationError('Validation Failed: sort must be created, updated, or published.'); + } + + const direction = url.searchParams.get('direction') ?? 'desc'; + if (direction !== 'asc' && direction !== 'desc') { + return jsonValidationError('Validation Failed: direction must be asc or desc.'); + } + + return { state, sort, direction }; +} + +export function filterSecurityAdvisories(advisories: SecurityAdvisoryEntry[], url: URL): SecurityAdvisoryEntry[] | JsonResponse { + const parsed = validateSecurityAdvisoryList(url); + if ('status' in parsed) { return parsed; } + + const filtered = advisories.filter(advisory => parsed.state.length === 0 || parsed.state.includes(advisory.state)); + return filtered.sort((a, b) => { + const left = Date.parse(parsed.sort === 'published' ? a.publishedAt ?? '' : parsed.sort === 'updated' ? a.updatedAt : a.createdAt); + const right = Date.parse(parsed.sort === 'published' ? b.publishedAt ?? '' : parsed.sort === 'updated' ? b.updatedAt : b.createdAt); + const comparison = (Number.isNaN(left) ? 0 : left) - (Number.isNaN(right) ? 0 : right) || a.ghsaId.localeCompare(b.ghsaId); + return parsed.direction === 'asc' ? comparison : -comparison; + }); +} + +function securityAdvisoryCreditDetails( + advisory: SecurityAdvisoryEntry, baseUrl: string, +): Array<Record<string, unknown>> { + if (advisory.creditsDetailed) { + return advisory.creditsDetailed.map(credit => ({ ...credit })); + } + return (advisory.credits ?? []).map((credit) => ({ + user : typeof credit.login === 'string' ? buildOwner(credit.login, baseUrl) : null, + type : typeof credit.type === 'string' ? credit.type : 'other', + state : typeof credit.state === 'string' ? credit.state : 'accepted', + })); +} + +function buildSecurityAdvisoryPrivateForkResponse( + advisory: SecurityAdvisoryEntry, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> | null { + if (!advisory.privateFork) { + return null; + } + const fork = advisory.privateFork; + const ownerDid = typeof fork.ownerDid === 'string' ? fork.ownerDid : targetDid; + const name = typeof fork.name === 'string' ? fork.name : `${repo.name}-${advisory.ghsaId.toLowerCase()}`; + const fullName = typeof fork.fullName === 'string' ? fork.fullName : `${ownerDid}/${name}`; + const repoBase = `${baseUrl}/repos/${fullName}`; + return { + id : numericId(fullName), + node_id : `R_${numericId(fullName).toString(36)}`, + name, + full_name : fullName, + owner : buildOwner(ownerDid, baseUrl), + private : true, + html_url : repoBase, + description : null, + fork : false, + url : repoBase, + archive_url : `${repoBase}/{archive_format}{/ref}`, + assignees_url : `${repoBase}/assignees{/user}`, + branches_url : `${repoBase}/branches{/branch}`, + collaborators_url : `${repoBase}/collaborators{/collaborator}`, + contents_url : `${repoBase}/contents/{+path}`, + forks_url : `${repoBase}/forks`, + pulls_url : `${repoBase}/pulls{/number}`, + teams_url : `${repoBase}/teams`, + }; +} + +export function buildSecurityAdvisoryResponse( + advisory: SecurityAdvisoryEntry, targetDid: string, repo: RepoInfo, baseUrl: string, +): Record<string, unknown> { + const repoBase = `${baseUrl}/repos/${targetDid}/${repo.name}`; + const identifiers = [ + { type: 'GHSA', value: advisory.ghsaId }, + ...(advisory.cveId ? [{ type: 'CVE', value: advisory.cveId }] : []), + ]; + return { + ghsa_id : advisory.ghsaId, + cve_id : advisory.cveId ?? null, + url : `${repoBase}/security-advisories/${advisory.ghsaId}`, + html_url : `${repoBase}/security/advisories/${advisory.ghsaId}`, + summary : advisory.summary, + description : advisory.description, + severity : advisory.severity ?? 'unknown', + author : buildOwner(advisory.authorDid, baseUrl), + publisher : advisory.publisherDid ? buildOwner(advisory.publisherDid, baseUrl) : null, + identifiers, + state : advisory.state, + created_at : toISODate(advisory.createdAt), + updated_at : toISODate(advisory.updatedAt), + published_at : advisory.publishedAt ? toISODate(advisory.publishedAt) : null, + closed_at : advisory.closedAt ? toISODate(advisory.closedAt) : null, + withdrawn_at : advisory.withdrawnAt ? toISODate(advisory.withdrawnAt) : null, + submission : advisory.submission ?? null, + vulnerabilities : advisory.vulnerabilities ?? [], + cvss_severities : advisory.cvssSeverities ?? {}, + cwes : advisory.cwes ?? (advisory.cweIds ?? []).map(cweId => ({ cwe_id: cweId, name: cweId })), + cwe_ids : advisory.cweIds ?? [], + credits : advisory.credits ?? [], + credits_detailed : securityAdvisoryCreditDetails(advisory, baseUrl), + collaborating_users : (advisory.collaboratingUsers ?? []).map(did => buildOwner(did, baseUrl)), + collaborating_teams : advisory.collaboratingTeams ?? [], + private_fork : buildSecurityAdvisoryPrivateForkResponse(advisory, targetDid, repo, baseUrl), + }; +} + +function validateSecurityAdvisoryVulnerabilities(value: unknown, required: boolean): Array<Record<string, unknown>> | null | JsonResponse { + if (value === undefined || value === null) { + if (required) { + return jsonValidationError('Validation Failed: vulnerabilities is required.'); + } + return null; + } + if (!Array.isArray(value) || !value.every(item => isPlainObject(item))) { + return jsonValidationError('Validation Failed: vulnerabilities must be an array of objects.'); + } + return value.map(item => ({ ...item })); +} + +function validateOptionalStringArray(value: unknown, name: string): string[] | null | JsonResponse { + if (value === undefined || value === null) { + return null; + } + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + return jsonValidationError(`Validation Failed: ${name} must be an array of strings.`); + } + return [...value]; +} + +function validateOptionalObjectArray(value: unknown, name: string): Array<Record<string, unknown>> | null | JsonResponse { + if (value === undefined || value === null) { + return null; + } + if (!Array.isArray(value) || !value.every(item => isPlainObject(item))) { + return jsonValidationError(`Validation Failed: ${name} must be an array of objects.`); + } + return value.map(item => ({ ...item })); +} + +function validateSecurityAdvisorySeverity(body: Record<string, unknown>): SecurityAdvisoryEntry['severity'] | JsonResponse { + if (body.severity !== undefined && body.severity !== null && body.cvss_vector_string !== undefined && body.cvss_vector_string !== null) { + return jsonValidationError('Validation Failed: specify either severity or cvss_vector_string, not both.'); + } + if (body.severity === undefined || body.severity === null) { + return undefined; + } + if (typeof body.severity !== 'string' || !SECURITY_ADVISORY_SEVERITIES.has(body.severity)) { + return jsonValidationError('Validation Failed: severity must be critical, high, medium, low, or unknown.'); + } + return body.severity as SecurityAdvisoryEntry['severity']; +} + +function securityAdvisoryPrivateForkSeed(advisory: SecurityAdvisoryEntry, targetDid: string, repo: RepoInfo): Record<string, unknown> { + const name = `${repo.name}-${advisory.ghsaId.toLowerCase()}`; + return { + ownerDid : targetDid, + name, + fullName : `${targetDid}/${name}`, + createdAt : new Date().toISOString(), + }; +} + +function buildSecurityAdvisoryFromBody( + body: Record<string, unknown>, settings: RepoSettingsData, repo: RepoInfo, actorDid: string, targetDid: string, report: boolean, +): SecurityAdvisoryEntry | JsonResponse { + if (typeof body.summary !== 'string' || body.summary.trim().length === 0) { + return jsonValidationError('Validation Failed: summary is required.'); + } + if (typeof body.description !== 'string' || body.description.trim().length === 0) { + return jsonValidationError('Validation Failed: description is required.'); + } + + const vulnerabilities = validateSecurityAdvisoryVulnerabilities(body.vulnerabilities, !report); + if (vulnerabilities && 'status' in vulnerabilities) { return vulnerabilities; } + + const cweIds = validateOptionalStringArray(body.cwe_ids, 'cwe_ids'); + if (cweIds && 'status' in cweIds) { return cweIds; } + + const credits = validateOptionalObjectArray(body.credits, 'credits'); + if (credits && 'status' in credits) { return credits; } + + const severity = validateSecurityAdvisorySeverity(body); + if (severity && typeof severity !== 'string') { return severity; } + + if (body.start_private_fork !== undefined && typeof body.start_private_fork !== 'boolean') { + return jsonValidationError('Validation Failed: start_private_fork must be a boolean.'); + } + if (body.cve_id !== undefined && body.cve_id !== null && typeof body.cve_id !== 'string') { + return jsonValidationError('Validation Failed: cve_id must be a string or null.'); + } + + const now = new Date().toISOString(); + const advisorySeverity = (typeof severity === 'string' ? severity : 'unknown') as SecurityAdvisoryEntry['severity']; + const advisory: SecurityAdvisoryEntry = { + ghsaId : nextSecurityAdvisoryGhsaId(settings, repo, now), + cveId : body.cve_id === undefined ? null : body.cve_id as string | null, + summary : body.summary.trim(), + description : body.description.trim(), + severity : advisorySeverity, + state : report ? 'triage' : 'draft', + authorDid : actorDid, + publisherDid : null, + createdAt : now, + updatedAt : now, + publishedAt : null, + closedAt : null, + withdrawnAt : null, + submission : report ? { accepted: false } : null, + vulnerabilities : vulnerabilities ?? [], + cweIds : cweIds ?? [], + credits : credits ?? [], + }; + + if (body.cvss_vector_string !== undefined && body.cvss_vector_string !== null) { + if (typeof body.cvss_vector_string !== 'string' || body.cvss_vector_string.trim().length === 0) { + return jsonValidationError('Validation Failed: cvss_vector_string must be a string.'); + } + advisory.cvssSeverities = { + cvss_v3: { vector_string: body.cvss_vector_string.trim(), score: null }, + }; + } + + if (body.start_private_fork === true) { + advisory.privateFork = securityAdvisoryPrivateForkSeed(advisory, targetDid, repo); + } + + return advisory; +} + +async function findSecurityAdvisory( + ctx: AgentContext, targetDid: string, repoName: string, ghsaId: string, +): Promise<{ lookup: RepoSettingsLookup; advisory: SecurityAdvisoryEntry; key: string } | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const normalized = securityAdvisoryRecordKey(ghsaId); + const advisory = lookup.settings.securityAdvisories?.[normalized] + ?? securityAdvisoryEntries(lookup.settings).find(entry => securityAdvisoryRecordKey(entry.ghsaId) === normalized); + if (!advisory) { + return jsonNotFound(`Repository security advisory '${ghsaId}' not found.`); + } + return { lookup, advisory, key: securityAdvisoryRecordKey(advisory.ghsaId) }; +} + +function updateSecurityAdvisoryFromBody( + existing: SecurityAdvisoryEntry, body: Record<string, unknown>, actorDid: string, +): SecurityAdvisoryEntry | JsonResponse { + if (Object.keys(body).length === 0) { + return jsonValidationError('Validation Failed: at least one field is required.'); + } + + let updated: SecurityAdvisoryEntry = { ...existing, updatedAt: new Date().toISOString() }; + + if (body.summary !== undefined) { + if (typeof body.summary !== 'string' || body.summary.trim().length === 0) { + return jsonValidationError('Validation Failed: summary must be a non-empty string.'); + } + updated = { ...updated, summary: body.summary.trim() }; + } + if (body.description !== undefined) { + if (typeof body.description !== 'string' || body.description.trim().length === 0) { + return jsonValidationError('Validation Failed: description must be a non-empty string.'); + } + updated = { ...updated, description: body.description.trim() }; + } + if (body.cve_id !== undefined) { + if (body.cve_id !== null && typeof body.cve_id !== 'string') { + return jsonValidationError('Validation Failed: cve_id must be a string or null.'); + } + updated = { ...updated, cveId: body.cve_id as string | null }; + } + + const vulnerabilities = validateSecurityAdvisoryVulnerabilities(body.vulnerabilities, false); + if (vulnerabilities && 'status' in vulnerabilities) { return vulnerabilities; } + if (vulnerabilities) { updated = { ...updated, vulnerabilities }; } + + const cweIds = validateOptionalStringArray(body.cwe_ids, 'cwe_ids'); + if (cweIds && 'status' in cweIds) { return cweIds; } + if (cweIds) { updated = { ...updated, cweIds }; } + + const credits = validateOptionalObjectArray(body.credits, 'credits'); + if (credits && 'status' in credits) { return credits; } + if (credits) { updated = { ...updated, credits }; } + + const collaboratingUsers = validateOptionalStringArray(body.collaborating_users, 'collaborating_users'); + if (collaboratingUsers && 'status' in collaboratingUsers) { return collaboratingUsers; } + if (collaboratingUsers) { updated = { ...updated, collaboratingUsers }; } + + const severity = validateSecurityAdvisorySeverity(body); + if (severity && typeof severity !== 'string') { return severity; } + if (typeof severity === 'string') { updated = { ...updated, severity }; } + + if (body.cvss_vector_string !== undefined && body.cvss_vector_string !== null) { + if (typeof body.cvss_vector_string !== 'string' || body.cvss_vector_string.trim().length === 0) { + return jsonValidationError('Validation Failed: cvss_vector_string must be a string.'); + } + updated = { + ...updated, + cvssSeverities: { + ...(updated.cvssSeverities ?? {}), + cvss_v3: { vector_string: body.cvss_vector_string.trim(), score: null }, + }, + }; + } + + if (body.state !== undefined) { + if (typeof body.state !== 'string' || !SECURITY_ADVISORY_STATES.has(body.state)) { + return jsonValidationError('Validation Failed: state must be triage, draft, published, or closed.'); + } + updated = { ...updated, state: body.state as SecurityAdvisoryEntry['state'] }; + if (body.state === 'published' && !updated.publishedAt) { + updated = { ...updated, publishedAt: updated.updatedAt, publisherDid: actorDid }; + } + if (body.state === 'closed' && !updated.closedAt) { + updated = { ...updated, closedAt: updated.updatedAt }; + } + } + + return updated; +} + +function settingsWithSecurityAdvisory( + settings: RepoSettingsData, key: string, advisory: SecurityAdvisoryEntry, +): RepoSettingsData { + return { + ...settings, + securityAdvisories: { + ...(settings.securityAdvisories ?? {}), + [key]: advisory, + }, + }; +} + +function attestationEntries(settings: RepoSettingsData): AttestationEntry[] { + return Object.values(settings.attestations ?? {}) + .filter((entry): entry is AttestationEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => a.id - b.id); +} + +function nextAttestationId(settings: RepoSettingsData): number { + return attestationEntries(settings).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; +} + +function normalizeSubjectDigest(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const normalized = value.trim().toLowerCase(); + return SHA256_DIGEST_PATTERN.test(normalized) ? normalized : null; +} + +function decodeBase64Json(value: string): Record<string, unknown> | null { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); + const text = new TextDecoder().decode(bytes); + const parsed = JSON.parse(text) as unknown; + return isPlainObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +function extractAttestationStatement(bundle: Record<string, unknown>): Record<string, unknown> | null { + const dsseEnvelope = isPlainObject(bundle.dsseEnvelope) ? bundle.dsseEnvelope : null; + const payload = typeof dsseEnvelope?.payload === 'string' ? dsseEnvelope.payload : null; + return payload ? decodeBase64Json(payload) : null; +} + +function extractAttestationSubjectDigest(bundle: Record<string, unknown>, body: Record<string, unknown>): string | null { + const explicit = normalizeSubjectDigest(body.subject_digest); + if (explicit) { + return explicit; + } + + const statement = extractAttestationStatement(bundle); + const subjects = Array.isArray(statement?.subject) ? statement.subject : []; + for (const subject of subjects) { + if (!isPlainObject(subject) || !isPlainObject(subject.digest)) { + continue; + } + const digest = normalizeSubjectDigest(`sha256:${subject.digest.sha256 ?? ''}`); + if (digest) { + return digest; + } + } + return null; +} + +function extractAttestationPredicateType(bundle: Record<string, unknown>): string | undefined { + const statement = extractAttestationStatement(bundle); + return typeof statement?.predicateType === 'string' && statement.predicateType.trim() + ? statement.predicateType.trim() + : undefined; +} + +function settingsWithAttestation( + settings: RepoSettingsData, attestation: AttestationEntry, +): RepoSettingsData { + return { + ...settings, + attestations: { + ...(settings.attestations ?? {}), + [String(attestation.id)]: attestation, + }, + }; +} + +function buildAttestationResponse( + attestation: AttestationEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + return { + id : attestation.id, + repository_id : numericId(`${targetDid}/${repoName}`), + repository_url : `${baseUrl}/repos/${targetDid}/${repoName}`, + subject_digest : attestation.subjectDigest, + predicate_type : attestation.predicateType ?? null, + bundle : attestation.bundle, + created_at : toISODate(attestation.createdAt), + }; +} + +async function findRuleset( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, +): Promise<{ lookup: RepoSettingsLookup; ruleset: RulesetEntry; key: string } | JsonResponse> { + const id = parseInt(rulesetId, 10); + if (!Number.isInteger(id) || id < 1) { + return jsonNotFound(`Ruleset '${rulesetId}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const key = rulesetRecordKey(id); + const ruleset = lookup.settings.rulesets?.[key]; + if (!ruleset) { + return jsonNotFound(`Ruleset '${rulesetId}' not found.`); + } + + return { lookup, ruleset, key }; +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`); +} + +function branchPatternMatches(pattern: string, branch: string, defaultBranch: string): boolean { + if (pattern === '~ALL') { + return true; + } + if (pattern === '~DEFAULT_BRANCH') { + return branch === defaultBranch; + } + + const branchRef = `refs/heads/${branch}`; + if (pattern === branch || pattern === branchRef) { + return true; + } + + if (!pattern.includes('*')) { + return false; + } + + return globToRegExp(pattern).test(branch) || globToRegExp(pattern).test(branchRef); +} + +function rulesetMatchesBranch(ruleset: RulesetEntry, branch: string, defaultBranch: string): boolean { + if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') { + return false; + } + + const refName = isPlainObject(ruleset.conditions?.ref_name) ? ruleset.conditions.ref_name : null; + if (!refName) { + return true; + } + + const include = Array.isArray(refName.include) ? refName.include.filter((item): item is string => typeof item === 'string') : []; + const exclude = Array.isArray(refName.exclude) ? refName.exclude.filter((item): item is string => typeof item === 'string') : []; + const included = include.length === 0 || include.some(pattern => branchPatternMatches(pattern, branch, defaultBranch)); + const excluded = exclude.some(pattern => branchPatternMatches(pattern, branch, defaultBranch)); + return included && !excluded; +} + +export function rulesetProtectsBranch(settings: RepoSettingsData, branch: string, defaultBranch: string): boolean { + return rulesetEntries(settings) + .some(ruleset => ruleset.rules.length > 0 && rulesetMatchesBranch(ruleset, branch, defaultBranch)); +} + +function buildBranchRuleResponse( + rule: Record<string, unknown> & { type: string }, ruleset: RulesetEntry, targetDid: string, repoName: string, +): Record<string, unknown> { + return { + ...rule, + ruleset_source_type : 'Repository', + ruleset_source : rulesetSource(targetDid, repoName), + ruleset_id : ruleset.id, + }; +} + +function ruleSuiteEntries(settings: RepoSettingsData): RuleSuiteEntry[] { + return Object.values(settings.ruleSuites ?? {}) + .filter((entry): entry is RuleSuiteEntry => Boolean(entry) && Number.isInteger(entry.id)) + .sort((a, b) => Date.parse(b.pushedAt) - Date.parse(a.pushedAt) || b.id - a.id); +} + +function ruleSuiteRefMatches(suiteRef: string, queryRef: string): boolean { + if (suiteRef === queryRef) { + return true; + } + if (queryRef.startsWith('refs/heads/') || queryRef.startsWith('refs/tags/')) { + return false; + } + return suiteRef === `refs/heads/${queryRef}` || suiteRef === `refs/tags/${queryRef}`; +} + +function ruleSuiteMatchesEvaluateStatus(suite: RuleSuiteEntry, evaluateStatus: string): boolean { + if (evaluateStatus === 'all') { + return true; + } + const evaluations = suite.ruleEvaluations ?? []; + const hasEvaluateRule = evaluations.some(evaluation => evaluation.enforcement === 'evaluate'); + return evaluateStatus === 'evaluate' ? hasEvaluateRule : !hasEvaluateRule; +} + +function filterRuleSuites(suites: RuleSuiteEntry[], url: URL): RuleSuiteEntry[] | JsonResponse { + const ref = url.searchParams.get('ref'); + if (ref?.includes('*')) { + return jsonValidationError('Validation Failed: ref cannot contain wildcard characters.'); + } + + const timePeriod = url.searchParams.get('time_period') ?? 'day'; + const timeWindowMs = RULE_SUITE_TIME_PERIOD_MS[timePeriod]; + if (!timeWindowMs) { + return jsonValidationError('Validation Failed: time_period must be hour, day, week, or month.'); + } + + const ruleSuiteResult = url.searchParams.get('rule_suite_result') ?? 'all'; + if (!RULE_SUITE_RESULTS.has(ruleSuiteResult)) { + return jsonValidationError('Validation Failed: rule_suite_result must be pass, fail, bypass, or all.'); + } + + const evaluateStatus = url.searchParams.get('evaluate_status') ?? 'all'; + if (!RULE_SUITE_EVALUATE_STATUSES.has(evaluateStatus)) { + return jsonValidationError('Validation Failed: evaluate_status must be all, active, or evaluate.'); + } + + const actorName = url.searchParams.get('actor_name'); + const since = Date.now() - timeWindowMs; + return suites + .filter(suite => !ref || ruleSuiteRefMatches(suite.ref, ref)) + .filter(suite => !actorName || suite.actorName === actorName) + .filter(suite => ruleSuiteResult === 'all' || suite.result === ruleSuiteResult) + .filter(suite => ruleSuiteMatchesEvaluateStatus(suite, evaluateStatus)) + .filter((suite) => { + const pushedAt = Date.parse(suite.pushedAt); + return Number.isFinite(pushedAt) && pushedAt >= since; + }); +} + +function buildRuleSuiteSummaryResponse( + suite: RuleSuiteEntry, targetDid: string, repoName: string, +): Record<string, unknown> { + return { + id : suite.id, + actor_id : suite.actorId ?? numericId(suite.actorName), + actor_name : suite.actorName, + before_sha : suite.beforeSha, + after_sha : suite.afterSha, + ref : suite.ref, + repository_id : suite.repositoryId ?? numericId(`${targetDid}/${repoName}`), + repository_name : suite.repositoryName ?? repoName, + pushed_at : toISODate(suite.pushedAt), + result : suite.result, + ...(suite.evaluationResult ? { evaluation_result: suite.evaluationResult } : {}), + }; +} + +function buildRuleEvaluationResponse( + evaluation: NonNullable<RuleSuiteEntry['ruleEvaluations']>[number], +): Record<string, unknown> { + return { + rule_source : { ...evaluation.ruleSource }, + enforcement : evaluation.enforcement, + result : evaluation.result, + rule_type : evaluation.ruleType, + ...(evaluation.details ? { details: evaluation.details } : {}), + }; +} + +function buildRuleSuiteDetailResponse( + suite: RuleSuiteEntry, targetDid: string, repoName: string, +): Record<string, unknown> { + return { + ...buildRuleSuiteSummaryResponse(suite, targetDid, repoName), + rule_evaluations: (suite.ruleEvaluations ?? []).map(buildRuleEvaluationResponse), + }; +} + +async function findRuleSuite( + ctx: AgentContext, targetDid: string, repoName: string, ruleSuiteId: string, +): Promise<{ lookup: RepoSettingsLookup; suite: RuleSuiteEntry } | JsonResponse> { + const id = parseInt(ruleSuiteId, 10); + if (!Number.isInteger(id) || id < 1) { + return jsonNotFound(`Rule suite '${ruleSuiteId}' not found.`); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const suite = lookup.settings.ruleSuites?.[String(id)]; + if (!suite) { + return jsonNotFound(`Rule suite '${ruleSuiteId}' not found.`); + } + + return { lookup, suite }; +} + +function buildDeployKeyResponse( + deployKey: DeployKeyEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const base = `${baseUrl}/repos/${targetDid}/${repoName}`; + return { + id : deployKey.id, + key : deployKey.key, + url : `${base}/keys/${deployKey.id}`, + title : deployKey.title, + verified : deployKey.verified ?? true, + created_at : toISODate(deployKey.createdAt), + read_only : deployKey.readOnly, + added_by : deployKey.addedBy ?? null, + last_used : deployKey.lastUsed ? toISODate(deployKey.lastUsed) : null, + enabled : deployKey.enabled ?? true, + }; +} + +async function findDeployKey( + ctx: AgentContext, targetDid: string, repoName: string, keyId: string, +): Promise<{ lookup: RepoSettingsLookup; deployKey: DeployKeyEntry; key: string } | JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const id = parseInt(keyId, 10); + const key = deployKeyRecordKey(id); + const deployKey = lookup.settings.deployKeys?.[key]; + if (!deployKey) { + return jsonNotFound(`Deploy key ${keyId} not found.`); + } + + return { lookup, deployKey, key }; +} + +// --------------------------------------------------------------------------- +// GET/PUT /repos/:did/:repo/topics +// --------------------------------------------------------------------------- + +export async function handleGetTopics( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + return jsonOk({ names: await listTopicNames(ctx, targetDid, repo) }); +} + +export async function handleReplaceTopics( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const names = reqBody.names; + if (!Array.isArray(names)) { + return jsonValidationError('Validation Failed: names must be an array.'); + } + if (names.length > 20) { + return jsonValidationError('Validation Failed: no more than 20 topics are allowed.'); + } + + const normalized = new Set<string>(); + for (const name of names) { + const topic = normalizeTopicName(name); + if (!topic) { + return jsonValidationError('Validation Failed: topic names must be 1-50 characters and contain only letters, numbers, dots, hyphens, or underscores.'); + } + normalized.add(topic); + } + + const existing = await listTopicRecords(ctx, targetDid, repo); + for (const topic of existing) { + const { status } = await topic.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete topic '${topic.name}': ${status.detail}`); + } + } + + for (const name of normalized) { + const { status } = await ctx.repo.records.create('repo/topic' as any, { + data : { name }, + tags : { name }, + parentContextId : repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create topic '${name}': ${status.detail}`); + } + } + + return jsonOk({ names: [...normalized].sort((a, b) => a.localeCompare(b)) }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/languages +// --------------------------------------------------------------------------- + +export async function handleGetLanguages( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + if (!repo.language) { + return jsonOk({}); + } + return jsonOk({ [repo.language]: 0 }); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/issue-types +// --------------------------------------------------------------------------- + +export async function handleListRepositoryIssueTypes( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk(issueTypeEntries(lookup.settings, lookup.repo).map(buildIssueTypeResponse)); +} + +// --------------------------------------------------------------------------- +// GET/PATCH /repos/:did/:repo/code-scanning/alerts +// --------------------------------------------------------------------------- + +export async function handleListCodeScanningAlerts( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const filtered = filterCodeScanningAlerts(codeScanningAlertEntries(lookup.settings), url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/code-scanning/alerts`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(alert => buildCodeScanningAlertResponse(alert, targetDid, lookup.repo, baseUrl)), + extraHeaders, + ); +} + +export async function handleGetCodeScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, url: URL, +): Promise<JsonResponse> { + const result = await findCodeScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + return jsonOk(buildCodeScanningAlertResponse(result.alert, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleUpdateCodeScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await findCodeScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const updated = validateCodeScanningAlertUpdate(reqBody, result.alert, ctx.did); + if ('status' in updated) { return updated; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithCodeScanningAlert(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildCodeScanningAlertResponse(updated, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleListCodeScanningAlertInstances( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, url: URL, +): Promise<JsonResponse> { + const result = await findCodeScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const filtered = filterCodeScanningInstances(result.alert, url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${result.lookup.repo.name}/code-scanning/alerts/${result.alert.number}/instances`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildCodeScanningInstanceResponse), extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET/PATCH /repos/:did/:repo/dependabot/alerts +// --------------------------------------------------------------------------- + +export async function handleListDependabotAlerts( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const filtered = filterDependabotAlerts(dependabotAlertEntries(lookup.settings), url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/dependabot/alerts`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(alert => buildDependabotAlertResponse(alert, targetDid, lookup.repo, baseUrl)), + extraHeaders, + ); +} + +export async function handleGetDependabotAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, url: URL, +): Promise<JsonResponse> { + const result = await findDependabotAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + return jsonOk(buildDependabotAlertResponse(result.alert, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleUpdateDependabotAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await findDependabotAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const updated = validateDependabotAlertUpdate(reqBody, result.alert, ctx.did); + if ('status' in updated) { return updated; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithDependabotAlert(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildDependabotAlertResponse(updated, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET/PATCH /repos/:did/:repo/secret-scanning/alerts +// --------------------------------------------------------------------------- + +export async function handleListSecretScanningAlerts( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const hideSecret = parseBooleanQuery(url, 'hide_secret'); + if (hideSecret !== null && typeof hideSecret !== 'boolean') { return hideSecret; } + + const filtered = filterSecretScanningAlerts(secretScanningAlertEntries(lookup.settings), url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/secret-scanning/alerts`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(alert => buildSecretScanningAlertResponse(alert, targetDid, lookup.repo, baseUrl, hideSecret === true)), + extraHeaders, + ); +} + +export async function handleGetSecretScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, url: URL, +): Promise<JsonResponse> { + const result = await findSecretScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const hideSecret = parseBooleanQuery(url, 'hide_secret'); + if (hideSecret !== null && typeof hideSecret !== 'boolean') { return hideSecret; } + + return jsonOk(buildSecretScanningAlertResponse( + result.alert, targetDid, result.lookup.repo, buildApiUrl(url), hideSecret === true, + )); +} + +export async function handleUpdateSecretScanningAlert( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await findSecretScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const updated = validateSecretScanningAlertUpdate(reqBody, result.alert, ctx.did); + if ('status' in updated) { return updated; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithSecretScanningAlert(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildSecretScanningAlertResponse(updated, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleListSecretScanningAlertLocations( + ctx: AgentContext, targetDid: string, repoName: string, alertNumber: string, url: URL, +): Promise<JsonResponse> { + const result = await findSecretScanningAlert(ctx, targetDid, repoName, alertNumber); + if ('status' in result) { return result; } + + const locations = result.alert.locations ?? []; + const pagination = parsePagination(url); + const paged = paginate(locations, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${result.lookup.repo.name}/secret-scanning/alerts/${result.alert.number}/locations`, + pagination.page, pagination.perPage, locations.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildSecretScanningLocationResponse), extraHeaders); +} + +export async function handleGetSecretScanningScanHistory( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildSecretScanningScanHistoryResponse(lookup.settings, lookup.repo)); +} + +export async function handleCreateSecretScanningPushProtectionBypass( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = validateSecretScanningPushProtectionBypass(reqBody); + if ('status' in parsed) { return parsed; } + + const existing = lookup.settings.secretScanningPushProtectionBypasses?.[parsed.placeholderId]; + if (!existing) { + return jsonNotFound(`Push protection bypass placeholder '${parsed.placeholderId}' not found.`); + } + + const now = new Date().toISOString(); + const updated: SecretScanningBypassEntry = { + ...existing, + placeholderId : parsed.placeholderId, + reason : parsed.reason, + expireAt : existing.expireAt ?? new Date(Date.now() + (3 * 60 * 60 * 1000)).toISOString(), + createdAt : now, + createdBy : ctx.did, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithSecretScanningPushProtectionBypass(lookup.settings, parsed.placeholderId, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(secretScanningPushProtectionBypassResponse(updated)); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/security-advisories +// --------------------------------------------------------------------------- + +export async function handleListSecurityAdvisories( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const filtered = filterSecurityAdvisories(securityAdvisoryEntries(lookup.settings), url); + if ('status' in filtered) { return filtered; } + + const pagination = parsePagination(url); + const paged = paginate(filtered, pagination); + const baseUrl = buildApiUrl(url); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/security-advisories`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(advisory => buildSecurityAdvisoryResponse(advisory, targetDid, lookup.repo, baseUrl)), + extraHeaders, + ); +} + +export async function handleCreateSecurityAdvisory( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const advisory = buildSecurityAdvisoryFromBody(reqBody, lookup.settings, lookup.repo, ctx.did, targetDid, false); + if ('status' in advisory) { return advisory; } + + const key = securityAdvisoryRecordKey(advisory.ghsaId); + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithSecurityAdvisory(lookup.settings, key, advisory), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildSecurityAdvisoryResponse(advisory, targetDid, lookup.repo, buildApiUrl(url))); +} + +export async function handlePrivatelyReportSecurityVulnerability( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const advisory = buildSecurityAdvisoryFromBody(reqBody, lookup.settings, lookup.repo, ctx.did, targetDid, true); + if ('status' in advisory) { return advisory; } + + const key = securityAdvisoryRecordKey(advisory.ghsaId); + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithSecurityAdvisory(lookup.settings, key, advisory), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildSecurityAdvisoryResponse(advisory, targetDid, lookup.repo, buildApiUrl(url))); +} + +export async function handleGetSecurityAdvisory( + ctx: AgentContext, targetDid: string, repoName: string, ghsaId: string, url: URL, +): Promise<JsonResponse> { + const result = await findSecurityAdvisory(ctx, targetDid, repoName, ghsaId); + if ('status' in result) { return result; } + + return jsonOk(buildSecurityAdvisoryResponse(result.advisory, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleUpdateSecurityAdvisory( + ctx: AgentContext, targetDid: string, repoName: string, ghsaId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const result = await findSecurityAdvisory(ctx, targetDid, repoName, ghsaId); + if ('status' in result) { return result; } + + const updated = updateSecurityAdvisoryFromBody(result.advisory, reqBody, ctx.did); + if ('status' in updated) { return updated; } + + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithSecurityAdvisory(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildSecurityAdvisoryResponse(updated, targetDid, result.lookup.repo, buildApiUrl(url))); +} + +export async function handleRequestSecurityAdvisoryCve( + ctx: AgentContext, targetDid: string, repoName: string, ghsaId: string, +): Promise<JsonResponse> { + const result = await findSecurityAdvisory(ctx, targetDid, repoName, ghsaId); + if ('status' in result) { return result; } + + const now = new Date().toISOString(); + const updated: SecurityAdvisoryEntry = { + ...result.advisory, + cveRequestedAt : now, + updatedAt : now, + }; + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithSecurityAdvisory(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + + return jsonAccepted({}); +} + +export async function handleCreateSecurityAdvisoryPrivateFork( + ctx: AgentContext, targetDid: string, repoName: string, ghsaId: string, +): Promise<JsonResponse> { + const result = await findSecurityAdvisory(ctx, targetDid, repoName, ghsaId); + if ('status' in result) { return result; } + + const updated: SecurityAdvisoryEntry = result.advisory.privateFork + ? result.advisory + : { + ...result.advisory, + privateFork : securityAdvisoryPrivateForkSeed(result.advisory, targetDid, result.lookup.repo), + updatedAt : new Date().toISOString(), + }; + if (updated !== result.advisory) { + const saveError = await saveRepoSettings( + ctx, + result.lookup, + settingsWithSecurityAdvisory(result.lookup.settings, result.key, updated), + ); + if (saveError) { return saveError; } + } + + return jsonAccepted({}); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/keys +// --------------------------------------------------------------------------- + +export async function handleListDeployKeys( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const deployKeys = deployKeyEntries(lookup.settings); + const paged = paginate(deployKeys, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/keys`, + pagination.page, pagination.perPage, deployKeys.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(deployKey => buildDeployKeyResponse(deployKey, targetDid, lookup.repo.name, baseUrl)), + extraHeaders, + ); +} + +export async function handleCreateDeployKey( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + if (typeof reqBody.key !== 'string' || reqBody.key.trim().length === 0) { + return jsonValidationError('Validation Failed: key is required.'); + } + if (typeof reqBody.title !== 'undefined' && typeof reqBody.title !== 'string') { + return jsonValidationError('Validation Failed: title must be a string.'); + } + if (typeof reqBody.read_only !== 'undefined' && typeof reqBody.read_only !== 'boolean') { + return jsonValidationError('Validation Failed: read_only must be a boolean.'); + } + + const key = reqBody.key.trim(); + const existing = deployKeyEntries(lookup.settings).find(deployKey => deployKey.key === key); + if (existing) { + return jsonValidationError('Validation Failed: key is already in use.'); + } + + const title = typeof reqBody.title === 'string' && reqBody.title.trim() + ? reqBody.title.trim() + : 'Deploy key'; + const id = nextDeployKeyId(lookup.settings); + const deployKey: DeployKeyEntry = { + id, + key, + title, + readOnly : typeof reqBody.read_only === 'boolean' ? reqBody.read_only : true, + verified : true, + addedBy : ctx.did, + lastUsed : null, + enabled : true, + createdAt : new Date().toISOString(), + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithDeployKey(lookup.settings, deployKeyRecordKey(id), deployKey), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildDeployKeyResponse(deployKey, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleGetDeployKey( + ctx: AgentContext, targetDid: string, repoName: string, keyId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findDeployKey(ctx, targetDid, repoName, keyId); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildDeployKeyResponse(lookup.deployKey, targetDid, lookup.lookup.repo.name, buildApiUrl(url))); +} + +export async function handleDeleteDeployKey( + ctx: AgentContext, targetDid: string, repoName: string, keyId: string, +): Promise<JsonResponse> { + const lookup = await findDeployKey(ctx, targetDid, repoName, keyId); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings( + ctx, + lookup.lookup, + settingsWithDeployKey(lookup.lookup.settings, lookup.key, null), + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/autolinks +// --------------------------------------------------------------------------- + +export async function handleListAutolinks( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk(autolinkEntries(lookup.settings).map(buildAutolinkResponse)); +} + +export async function handleCreateAutolink( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = validateAutolinkBody(reqBody); + if ('status' in parsed) { return parsed; } + + const duplicate = autolinkEntries(lookup.settings) + .find(autolink => autolink.keyPrefix.toLowerCase() === parsed.keyPrefix.toLowerCase()); + if (duplicate) { + return jsonValidationError('Validation Failed: key_prefix is already in use.'); + } + + const id = nextAutolinkId(lookup.settings); + const autolink: AutolinkEntry = { ...parsed, id }; + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithAutolink(lookup.settings, autolinkRecordKey(id), autolink), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildAutolinkResponse(autolink)); +} + +export async function handleGetAutolink( + ctx: AgentContext, targetDid: string, repoName: string, autolinkId: string, +): Promise<JsonResponse> { + const lookup = await findAutolink(ctx, targetDid, repoName, autolinkId); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildAutolinkResponse(lookup.autolink)); +} + +export async function handleDeleteAutolink( + ctx: AgentContext, targetDid: string, repoName: string, autolinkId: string, +): Promise<JsonResponse> { + const lookup = await findAutolink(ctx, targetDid, repoName, autolinkId); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings( + ctx, + lookup.lookup, + settingsWithAutolink(lookup.lookup.settings, lookup.key, null), + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/interaction-limits +// --------------------------------------------------------------------------- + +export async function handleGetInteractionLimit( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const limit = activeInteractionLimit(lookup.settings); + return jsonOk(limit ? buildInteractionLimitResponse(limit) : {}); +} + +export async function handleSetInteractionLimit( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = validateInteractionLimitBody(reqBody); + if ('status' in parsed) { return parsed; } + + const settings = { ...lookup.settings, interactionLimit: parsed }; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonOk(buildInteractionLimitResponse(parsed)); +} + +export async function handleDeleteInteractionLimit( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const settings = { ...lookup.settings }; + delete settings.interactionLimit; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/vulnerability-alerts and /automated-security-fixes +// --------------------------------------------------------------------------- + +export async function handleCheckVulnerabilityAlerts( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return lookup.settings.vulnerabilityAlertsEnabled + ? jsonNoContent() + : jsonNotFound('Vulnerability alerts are not enabled for this repository.'); +} + +export async function handleEnableVulnerabilityAlerts( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + vulnerabilityAlertsEnabled: true, + }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleDisableVulnerabilityAlerts( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const settings = { ...lookup.settings }; + delete settings.vulnerabilityAlertsEnabled; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleCheckAutomatedSecurityFixes( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return lookup.settings.automatedSecurityFixesEnabled + ? jsonOk({ enabled: true, paused: lookup.settings.automatedSecurityFixesPaused ?? false }) + : jsonNotFound('Dependabot security updates are not enabled for this repository.'); +} + +export async function handleEnableAutomatedSecurityFixes( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + automatedSecurityFixesEnabled : true, + automatedSecurityFixesPaused : false, + }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleDisableAutomatedSecurityFixes( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const settings = { ...lookup.settings }; + delete settings.automatedSecurityFixesEnabled; + delete settings.automatedSecurityFixesPaused; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// Repository custom properties, dispatches, hash metadata, and policy toggles +// --------------------------------------------------------------------------- + +export async function handleGetRepositoryCustomProperties( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk(customPropertyEntries(lookup.settings)); +} + +export async function handleUpdateRepositoryCustomProperties( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = validateCustomPropertiesBody(reqBody); + if ('status' in parsed) { return parsed; } + + const customProperties = { ...(lookup.settings.customProperties ?? {}) }; + for (const [propertyName, value] of Object.entries(parsed.values)) { + if (value === null) { + delete customProperties[propertyName]; + } else { + customProperties[propertyName] = value; + } + } + + const settings = { ...lookup.settings }; + if (Object.keys(customProperties).length > 0) { + settings.customProperties = customProperties; + } else { + delete settings.customProperties; + } + + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleCreateRepositoryDispatch( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const validationError = validateRepositoryDispatchBody(reqBody); + if (validationError) { return validationError; } + + return jsonNoContent(); +} + +export async function handleGetRepositoryHashAlgorithm( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + return jsonOk({ hash_algorithm: 'sha1' }); +} + +export async function handleListCodeownersErrors( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + return jsonOk({ errors: [] }); +} + +export async function handleCheckImmutableReleases( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return lookup.settings.immutableReleasesEnabled + ? jsonOk({ enabled: true, enforced_by_owner: false }) + : jsonNotFound('Immutable releases are not enabled for this repository.'); +} + +export async function handleEnableImmutableReleases( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + immutableReleasesEnabled: true, + }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleDisableImmutableReleases( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const settings = { ...lookup.settings }; + delete settings.immutableReleasesEnabled; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleGetPrivateVulnerabilityReporting( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + return jsonOk({ enabled: lookup.settings.privateVulnerabilityReportingEnabled ?? false }); +} + +export async function handleEnablePrivateVulnerabilityReporting( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings(ctx, lookup, { + ...lookup.settings, + privateVulnerabilityReportingEnabled: true, + }); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleDisablePrivateVulnerabilityReporting( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const settings = { ...lookup.settings }; + delete settings.privateVulnerabilityReportingEnabled; + const saveError = await saveRepoSettings(ctx, lookup, settings); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleCreateRepositoryAttestation( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + if (!isPlainObject(reqBody.bundle)) { + return jsonValidationError('Validation Failed: bundle is required.'); + } + + const subjectDigest = extractAttestationSubjectDigest(reqBody.bundle, reqBody); + if (!subjectDigest) { + return jsonValidationError('Validation Failed: bundle subject digest must include a sha256 digest.'); + } + + const predicateType = extractAttestationPredicateType(reqBody.bundle); + const attestation: AttestationEntry = { + id : nextAttestationId(lookup.settings), + subjectDigest, + ...(predicateType ? { predicateType } : {}), + bundle : reqBody.bundle, + createdAt : new Date().toISOString(), + }; + + const saveError = await saveRepoSettings(ctx, lookup, settingsWithAttestation(lookup.settings, attestation)); + if (saveError) { return saveError; } + + return jsonCreated(buildAttestationResponse(attestation, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleListRepositoryAttestations( + ctx: AgentContext, targetDid: string, repoName: string, encodedSubjectDigest: string, url: URL, +): Promise<JsonResponse> { + const subjectDigest = normalizeSubjectDigest(decodeRouteParam(encodedSubjectDigest)); + if (!subjectDigest) { + return jsonValidationError('Validation Failed: subject_digest must be sha256:<64 hex characters>.'); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const predicateType = url.searchParams.get('predicate_type'); + const attestations = attestationEntries(lookup.settings) + .filter(attestation => attestation.subjectDigest === subjectDigest) + .filter(attestation => !predicateType || attestation.predicateType === predicateType); + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(attestations, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/attestations/${encodeURIComponent(subjectDigest)}`, + pagination.page, pagination.perPage, attestations.length, + ); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + + return jsonOk({ + attestations: paged.map(attestation => buildAttestationResponse(attestation, targetDid, lookup.repo.name, baseUrl)), + }, headers); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/rules and /rulesets +// --------------------------------------------------------------------------- + +export async function handleGetRulesForBranch( + ctx: AgentContext, targetDid: string, repoName: string, encodedBranch: string, url: URL, +): Promise<JsonResponse> { + const branch = decodeRouteParam(encodedBranch); + if (branch.includes('*')) { + return jsonValidationError('Validation Failed: branch cannot contain wildcard characters.'); + } + + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const rules = rulesetEntries(lookup.settings) + .filter(ruleset => rulesetMatchesBranch(ruleset, branch, lookup.repo.defaultBranch)) + .flatMap(ruleset => ruleset.rules.map(rule => buildBranchRuleResponse(rule, ruleset, targetDid, lookup.repo.name))); + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(rules, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/rules/branches/${encodeURIComponent(branch)}`, + pagination.page, pagination.perPage, rules.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged, extraHeaders); +} + +export async function handleListRepositoryRulesets( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const targetsParam = url.searchParams.get('targets'); + const targetFilter = new Set<string>(); + if (targetsParam) { + for (const target of targetsParam.split(',').map(item => item.trim()).filter(Boolean)) { + if (!RULESET_TARGETS.has(target)) { + return jsonValidationError('Validation Failed: targets must contain only branch, tag, or push.'); + } + targetFilter.add(target); + } + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const rulesets = rulesetEntries(lookup.settings) + .filter(ruleset => targetFilter.size === 0 || targetFilter.has(ruleset.target)); + const paged = paginate(rulesets, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/rulesets`, + pagination.page, pagination.perPage, rulesets.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(ruleset => buildRulesetSummaryResponse(ruleset, targetDid, lookup.repo.name, baseUrl)), + extraHeaders, + ); +} + +export async function handleListRepositoryRuleSuites( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const suites = filterRuleSuites(ruleSuiteEntries(lookup.settings), url); + if ('status' in suites) { return suites; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(suites, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.repo.name}/rulesets/rule-suites`, + pagination.page, pagination.perPage, suites.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk( + paged.map(suite => buildRuleSuiteSummaryResponse(suite, targetDid, lookup.repo.name)), + extraHeaders, + ); +} + +export async function handleGetRepositoryRuleSuite( + ctx: AgentContext, targetDid: string, repoName: string, ruleSuiteId: string, +): Promise<JsonResponse> { + const lookup = await findRuleSuite(ctx, targetDid, repoName, ruleSuiteId); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildRuleSuiteDetailResponse(lookup.suite, targetDid, lookup.lookup.repo.name)); +} + +export async function handleCreateRepositoryRuleset( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await getRepoSettings(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const parsed = validateRulesetBody(reqBody); + if ('status' in parsed) { return parsed; } + + const now = new Date().toISOString(); + const id = nextRulesetId(lookup.settings); + const ruleset: RulesetEntry = { + id, + name : parsed.name!, + target : parsed.target!, + enforcement : parsed.enforcement!, + ...(parsed.bypassActors ? { bypassActors: parsed.bypassActors } : {}), + ...(parsed.conditions ? { conditions: parsed.conditions } : {}), + rules : parsed.rules ?? [], + versionId : 1, + createdAt : now, + updatedAt : now, + }; + ruleset.history = { + '1': { + versionId : 1, + actorDid : ctx.did, + updatedAt : now, + state : copyRulesetState(ruleset), + }, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup, + settingsWithRuleset(lookup.settings, rulesetRecordKey(id), ruleset), + ); + if (saveError) { return saveError; } + + return jsonCreated(buildRulesetResponse(ruleset, targetDid, lookup.repo.name, buildApiUrl(url))); +} + +export async function handleGetRepositoryRuleset( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRuleset(ctx, targetDid, repoName, rulesetId); + if ('status' in lookup) { return lookup; } + + return jsonOk(buildRulesetResponse(lookup.ruleset, targetDid, lookup.lookup.repo.name, buildApiUrl(url))); +} + +export async function handleUpdateRepositoryRuleset( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findRuleset(ctx, targetDid, repoName, rulesetId); + if ('status' in lookup) { return lookup; } + + const parsed = validateRulesetBody(reqBody, lookup.ruleset); + if ('status' in parsed) { return parsed; } + + const now = new Date().toISOString(); + const versionId = lookup.ruleset.versionId + 1; + const history = { ...(lookup.ruleset.history ?? {}) }; + const existingHistoryKey = rulesetRecordKey(lookup.ruleset.versionId); + if (!history[existingHistoryKey]) { + history[existingHistoryKey] = { + versionId : lookup.ruleset.versionId, + actorDid : ctx.did, + updatedAt : lookup.ruleset.updatedAt, + state : copyRulesetState(lookup.ruleset), + }; + } + + const updated: RulesetEntry = { + ...lookup.ruleset, + ...parsed, + versionId, + updatedAt: now, + }; + updated.history = { + ...history, + [rulesetRecordKey(versionId)]: { + versionId, + actorDid : ctx.did, + updatedAt : now, + state : copyRulesetState(updated), + }, + }; + + const saveError = await saveRepoSettings( + ctx, + lookup.lookup, + settingsWithRuleset(lookup.lookup.settings, lookup.key, updated), + ); + if (saveError) { return saveError; } + + return jsonOk(buildRulesetResponse(updated, targetDid, lookup.lookup.repo.name, buildApiUrl(url))); +} + +export async function handleDeleteRepositoryRuleset( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, +): Promise<JsonResponse> { + const lookup = await findRuleset(ctx, targetDid, repoName, rulesetId); + if ('status' in lookup) { return lookup; } + + const saveError = await saveRepoSettings( + ctx, + lookup.lookup, + settingsWithRuleset(lookup.lookup.settings, lookup.key, null), + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleListRepositoryRulesetHistory( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRuleset(ctx, targetDid, repoName, rulesetId); + if ('status' in lookup) { return lookup; } + + const history = Object.values(lookup.ruleset.history ?? {}) + .sort((a, b) => b.versionId - a.versionId); + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(history, pagination); + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${lookup.lookup.repo.name}/rulesets/${lookup.ruleset.id}/history`, + pagination.page, pagination.perPage, history.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildRulesetHistoryEntry), extraHeaders); +} + +export async function handleGetRepositoryRulesetVersion( + ctx: AgentContext, targetDid: string, repoName: string, rulesetId: string, versionId: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRuleset(ctx, targetDid, repoName, rulesetId); + if ('status' in lookup) { return lookup; } + + const version = parseInt(versionId, 10); + const entry = Number.isInteger(version) ? lookup.ruleset.history?.[rulesetRecordKey(version)] : undefined; + if (!entry) { + return jsonNotFound(`Ruleset version '${versionId}' not found.`); + } + + return jsonOk({ + ...buildRulesetHistoryEntry(entry), + state: buildRulesetResponse(entry.state, targetDid, lookup.lookup.repo.name, buildApiUrl(url)), + }); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/assignees +// --------------------------------------------------------------------------- + +export async function handleListAssignees( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const collaborators = await listCollaborators(ctx, targetDid, repo); + const paged = paginate(collaborators, pagination); + const items = paged.map((collab) => buildCollaboratorUser(collab, baseUrl)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/assignees`, + pagination.page, pagination.perPage, collaborators.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCheckAssignee( + ctx: AgentContext, targetDid: string, repoName: string, encodedDid: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const did = decodeRouteParam(encodedDid); + const collab = await findCollaborator(ctx, targetDid, repo, did); + return collab ? jsonNoContent() : jsonNotFound(`Assignee '${did}' not found.`); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/collaborators +// --------------------------------------------------------------------------- + +export async function handleListCollaborators( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const affiliationFilter = collaboratorAffiliationFilter(url.searchParams.get('affiliation')); + if (!affiliationFilter) { + return jsonValidationError('Validation Failed: affiliation must be one of outside, direct, or all.'); + } + + const permissionParam = url.searchParams.get('permission'); + const permissionFilter = collaboratorPermissionFilter(permissionParam); + if (permissionParam && !permissionFilter) { + return jsonValidationError('Validation Failed: permission must be one of pull, triage, push, maintain, or admin.'); + } + + const collaborators = await listCollaborators(ctx, targetDid, repo); + let filtered = collaborators.filter(collab => collaboratorMatchesAffiliation(collab, targetDid, affiliationFilter)); + if (permissionFilter) { + filtered = filtered.filter(collab => collaboratorHasPermission(collab, permissionFilter)); + } + const paged = paginate(filtered, pagination); + const items = paged.map((collab) => buildCollaboratorResponse(collab, baseUrl)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/collaborators`, + pagination.page, pagination.perPage, filtered.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +export async function handleCheckCollaborator( + ctx: AgentContext, targetDid: string, repoName: string, encodedDid: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const did = decodeRouteParam(encodedDid); + const collab = await findCollaborator(ctx, targetDid, repo, did); + return collab ? jsonNoContent() : jsonNotFound(`Collaborator '${did}' not found.`); +} + +export async function handleGetCollaboratorPermission( + ctx: AgentContext, targetDid: string, repoName: string, encodedDid: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const did = decodeRouteParam(encodedDid); + const collab = await findCollaborator(ctx, targetDid, repo, did); + if (!collab) { + return jsonNotFound(`Collaborator '${did}' not found.`); + } + + return jsonOk(buildPermissionResponse(collab, buildApiUrl(url))); +} + +export async function handleAddCollaborator( + ctx: AgentContext, targetDid: string, repoName: string, + encodedDid: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const did = decodeRouteParam(encodedDid); + if (!did.startsWith('did:')) { + return jsonValidationError('Validation Failed: collaborator must be a DID.'); + } + + const role = roleFromPermission(reqBody.permission); + if (!role) { + return jsonValidationError('Validation Failed: permission must be one of pull, triage, push, write, maintain, or admin.'); + } + + const existing = await findCollaborator(ctx, targetDid, repo, did); + + const { status, record } = await ctx.repo.records.create(`repo/${role}` as any, { + data : { did, alias: typeof reqBody.alias === 'string' ? reqBody.alias : '' }, + tags : { did }, + parentContextId : repo.contextId, + recipient : did, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to add collaborator: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create collaborator record'); } + + await deleteCollaboratorRoles(ctx, repo, did, record.id); + + if (existing) { + return jsonNoContent(); + } + + const collab: CollaboratorInfo = { + did, + role, + alias : typeof reqBody.alias === 'string' ? reqBody.alias : '', + recordId : record.id ?? `${did}:${role}`, + }; + + return jsonCreated({ + id : numericId(collab.recordId), + node_id : collab.recordId, + repository : `${targetDid}/${repoName}`, + invitee : buildCollaboratorUser(collab, buildApiUrl(url)), + permissions : permissionFromRole(role), + role_name : role, + }); +} + +export async function handleRemoveCollaborator( + ctx: AgentContext, targetDid: string, repoName: string, encodedDid: string, +): Promise<JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const did = decodeRouteParam(encodedDid); + const deleted = await deleteCollaboratorRoles(ctx, repo, did); + return deleted > 0 ? jsonNoContent() : jsonNotFound(`Collaborator '${did}' not found.`); +} diff --git a/src/github-shim/repos.ts b/src/github-shim/repos.ts index 6626ef5..7d18024 100644 --- a/src/github-shim/repos.ts +++ b/src/github-shim/repos.ts @@ -1,5 +1,5 @@ /** - * GitHub API shim — `/repos/:did/:repo` endpoint. + * GitHub API shim — repository metadata and fork endpoints. * * Maps a DWN repo record to a GitHub REST API v3 repository response. * The `:repo` URL segment is used to look up the repo by name. @@ -9,72 +9,1018 @@ import type { AgentContext } from '../cli/agent.js'; import type { JsonResponse, RepoInfo } from './helpers.js'; +import type { RepositoryTransferRequestData, SettingsData } from '../repo.js'; + +import { dirname } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdirSync, renameSync, rmSync } from 'node:fs'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { getDwnEndpoints } from '../git-server/did-service.js'; +import { GitBackend } from '../git-server/git-backend.js'; +import { listTopicNames } from './repo-metadata.js'; import { buildApiUrl, + buildLinkHeader, buildOwner, + fromOpt, getRepoRecord, + jsonAccepted, + jsonCreated, + jsonNoContent, jsonNotFound, jsonOk, + jsonValidationError, numericId, + paginate, + parsePagination, toISODate, } from './helpers.js'; // --------------------------------------------------------------------------- -// GET /repos/:did/:repo +// Types // --------------------------------------------------------------------------- +export type RepoCreateOptions = { + reposPath? : string; +}; + +export type RepoEntry = { + record : any; + repo : RepoInfo; + name : string; +}; + +type OwnedRepoEntry = RepoEntry & { + ownerDid : string; +}; + +type RepoSettingsLookup = { + entry : RepoEntry; + record? : { + update : (options: { data: SettingsData }) => Promise<{ status: { code: number; detail?: string } }>; + }; + settings : SettingsData; +}; + +type RepoListMode = 'authenticated' | 'org' | 'user'; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +export function repoInfoFromRecord(rec: any, data: any, tags: Record<string, unknown>): RepoInfo { + return { + name : data.name ?? 'unnamed', + description : data.description ?? '', + defaultBranch : data.defaultBranch ?? 'main', + homepage : data.homepage ?? '', + contextId : rec.contextId ?? '', + visibility : typeof tags.visibility === 'string' ? tags.visibility : 'public', + language : typeof tags.language === 'string' ? tags.language : '', + archived : tags.archived === true || tags.archived === 'true', + hasIssues : data.hasIssues !== false, + hasProjects : data.hasProjects === true, + hasWiki : data.hasWiki !== false, + hasDownloads : data.hasDownloads !== false, + hasPullRequests : data.hasPullRequests !== false, + isTemplate : data.isTemplate === true, + allowSquashMerge : data.allowSquashMerge !== false, + allowMergeCommit : data.allowMergeCommit !== false, + allowRebaseMerge : data.allowRebaseMerge !== false, + allowAutoMerge : data.allowAutoMerge === true, + allowForking : data.allowForking !== false, + deleteBranchOnMerge : data.deleteBranchOnMerge === true, + webCommitSignoffRequired : data.webCommitSignoffRequired === true, + pullRequestCreationPolicy : data.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' : 'all', + dateCreated : rec.dateCreated, + timestamp : rec.timestamp, + forkedFromDid : typeof data.forkedFromDid === 'string' ? data.forkedFromDid : undefined, + forkedFromRepoName : typeof data.forkedFromRepoName === 'string' ? data.forkedFromRepoName : undefined, + forkedFromRecordId : typeof data.forkedFromRecordId === 'string' ? data.forkedFromRecordId : undefined, + }; +} + +async function findRepoEntry(ctx: AgentContext, targetDid: string, repoName: string): Promise<RepoEntry | null> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo', { + from, + filter: { tags: { name: repoName } }, + }); + + const record = records[0]; + if (!record) { return null; } + + const data = await record.data.json(); + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const repo = repoInfoFromRecord(record, data, tags); + return { record, repo, name: repo.name }; +} + +export async function listRepoEntries(ctx: AgentContext, targetDid: string): Promise<RepoEntry[]> { + const { records } = await ctx.repo.records.query('repo', { + from : fromOpt(ctx, targetDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: RepoEntry[] = []; + for (const record of records) { + let data: Record<string, unknown>; + try { + data = await record.data.json(); + } catch { + continue; + } + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const repo = repoInfoFromRecord(record, data, tags); + entries.push({ + record, + repo, + name: repo.name, + }); + } + return entries; +} + +function isPrivate(repo: RepoInfo): boolean { + return repo.visibility !== 'public'; +} + +function repoResponseId(entry: RepoEntry, targetDid: string): number { + return numericId(entry.repo.contextId || `${targetDid}/repo`); +} + +function parseSinceRepositoryId(url: URL): number | JsonResponse { + const value = url.searchParams.get('since'); + if (value === null) { return 0; } + if (!/^\d+$/.test(value)) { + return jsonValidationError('Validation Failed: since must be a non-negative repository ID.'); + } + const since = Number(value); + if (!Number.isSafeInteger(since) || since < 0) { + return jsonValidationError('Validation Failed: since must be a non-negative repository ID.'); + } + return since; +} + +function publicRepositoriesLinkHeader(url: URL, nextSince: number): string { + const params = new URLSearchParams(url.searchParams); + params.set('since', String(nextSince)); + params.delete('page'); + return `<${buildApiUrl(url)}/repositories?${params.toString()}>; rel="next"`; +} + +function filterRepos(entries: RepoEntry[], url: URL, mode: RepoListMode): RepoEntry[] | JsonResponse { + const visibility = url.searchParams.get('visibility'); + const type = url.searchParams.get('type'); + const affiliation = url.searchParams.get('affiliation'); + + if (visibility && !['all', 'public', 'private'].includes(visibility)) { + return jsonValidationError('Validation Failed: visibility must be all, public, or private.'); + } + if (mode === 'authenticated' && type && (visibility || affiliation)) { + return jsonValidationError('Validation Failed: type cannot be combined with visibility or affiliation.'); + } + + let filtered = [...entries]; + const visibilityFilter = visibility && visibility !== 'all' ? visibility : null; + if (visibilityFilter === 'public') { + filtered = filtered.filter(entry => !isPrivate(entry.repo)); + } else if (visibilityFilter === 'private') { + filtered = filtered.filter(entry => isPrivate(entry.repo)); + } + + if (!type || type === 'all') { + return filtered; + } + if (type === 'owner' || type === 'sources') { + return filtered.filter(entry => !entry.repo.forkedFromDid); + } + if (type === 'public') { + return filtered.filter(entry => !isPrivate(entry.repo)); + } + if (type === 'private') { + return filtered.filter(entry => isPrivate(entry.repo)); + } + if (type === 'forks') { + return filtered.filter(entry => Boolean(entry.repo.forkedFromDid)); + } + if (type === 'member') { + return []; + } + + const allowed = mode === 'org' + ? 'all, public, private, forks, sources, or member' + : 'all, owner, public, private, or member'; + return jsonValidationError(`Validation Failed: type must be ${allowed}.`); +} + +function sortRepos(entries: RepoEntry[], url: URL, mode: RepoListMode): RepoEntry[] { + const defaultSort = mode === 'org' ? 'created' : 'full_name'; + const sort = url.searchParams.get('sort') ?? defaultSort; + const defaultDirection = sort === 'full_name' ? 'asc' : 'desc'; + const direction = url.searchParams.get('direction') ?? defaultDirection; + const multiplier = direction === 'asc' ? 1 : -1; + + return [...entries].sort((a, b) => { + if (sort === 'full_name') { + return a.name.localeCompare(b.name) * multiplier; + } + if (sort === 'updated' || sort === 'pushed') { + return (new Date(a.repo.timestamp).getTime() - new Date(b.repo.timestamp).getTime()) * multiplier; + } + return (new Date(a.repo.dateCreated).getTime() - new Date(b.repo.dateCreated).getTime()) * multiplier; + }); +} + +function filterByDateBounds(entries: RepoEntry[], url: URL): RepoEntry[] | JsonResponse { + const since = url.searchParams.get('since'); + const before = url.searchParams.get('before'); + let filtered = [...entries]; + + for (const [label, value] of [['since', since], ['before', before]] as const) { + if (!value) { continue; } + const time = Date.parse(value); + if (Number.isNaN(time)) { + return jsonValidationError(`Validation Failed: ${label} must be an ISO 8601 timestamp.`); + } + filtered = label === 'since' + ? filtered.filter(entry => new Date(entry.repo.timestamp).getTime() > time) + : filtered.filter(entry => new Date(entry.repo.timestamp).getTime() < time); + } + + return filtered; +} + +async function buildRepoList( + ctx: AgentContext, entries: RepoEntry[], targetDid: string, baseUrl: string, +): Promise<Record<string, unknown>[]> { + const repos: Record<string, unknown>[] = []; + for (const entry of entries) { + const topics = await listTopicNames(ctx, targetDid, entry.repo); + repos.push(buildRepoResponse(entry.repo, targetDid, entry.name, baseUrl, topics)); + } + return repos; +} + +function normalizeRepoName(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const name = value.trim(); + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { return null; } + return name; +} + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || value.trim().toLowerCase(); +} + +function parseTeamIds(value: unknown): number[] | JsonResponse { + if (value === undefined) { return []; } + if (!Array.isArray(value)) { + return jsonValidationError('Validation Failed: team_ids must be an array of positive integers.'); + } + + const teamIds: number[] = []; + for (const item of value) { + if (!Number.isSafeInteger(item) || item <= 0) { + return jsonValidationError('Validation Failed: team_ids must be an array of positive integers.'); + } + teamIds.push(item); + } + return teamIds; +} + +async function orgRouteExistsForTransfer(ctx: AgentContext, routeOrg: string): Promise<boolean> { + const route = decodeRouteParam(routeOrg).trim().toLowerCase(); + if (!route) { return false; } + + const { records } = await ctx.org.records.query('org' as any); + for (const record of records) { + let data: { name?: unknown }; + try { + data = await record.data.json(); + } catch { + continue; + } + if (typeof data.name !== 'string' || !data.name.trim()) { continue; } + + const name = data.name.trim().toLowerCase(); + if (route === name || route === slugify(data.name)) { + return true; + } + } + return false; +} + +async function getRepoSettingsForEntry( + ctx: AgentContext, targetDid: string, entry: RepoEntry, +): Promise<RepoSettingsLookup> { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from : fromOpt(ctx, targetDid), + filter : { contextId: entry.repo.contextId }, + }); + + if (records.length === 0) { + return { entry, settings: {} }; + } + + const record = records[0] as RepoSettingsLookup['record'] & { data: { json: () => Promise<SettingsData> } }; + const settings = await record.data.json(); + return { entry, record, settings: settings ?? {} }; +} + +async function saveRepoSettingsForEntry( + ctx: AgentContext, lookup: RepoSettingsLookup, settings: SettingsData, +): Promise<JsonResponse | undefined> { + if (lookup.record) { + const { status } = await lookup.record.update({ data: settings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + return undefined; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : lookup.entry.repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create repository settings: ${status.detail}`); + } + return undefined; +} + +function buildTransferRequestResponse(request: RepositoryTransferRequestData): Record<string, unknown> { + return { + id : request.id, + new_owner : request.newOwner, + new_name : request.newName ?? null, + team_ids : request.teamIds ?? [], + requested_by : request.requestedBy, + created_at : request.createdAt, + }; +} + +function visibilityFromBody(body: Record<string, unknown>): 'public' | 'private' | null { + if (body.visibility !== undefined) { + if (body.visibility === 'public' || body.visibility === 'private') { + return body.visibility; + } + return null; + } + return body.private === true ? 'private' : 'public'; +} + +function visibilityFromPatchBody( + body: Record<string, unknown>, currentVisibility: string, +): 'public' | 'private' | JsonResponse { + let visibility: 'public' | 'private' = currentVisibility === 'private' ? 'private' : 'public'; + if (body.private !== undefined) { + if (typeof body.private !== 'boolean') { + return jsonValidationError('Validation Failed: private must be a boolean.'); + } + visibility = body.private ? 'private' : 'public'; + } + if (body.visibility !== undefined) { + if (body.visibility !== 'public' && body.visibility !== 'private') { + return jsonValidationError('Validation Failed: visibility must be public or private.'); + } + visibility = body.visibility; + } + return visibility; +} + +const repoBooleanPatchFields = [ + ['has_issues', 'hasIssues'], + ['has_projects', 'hasProjects'], + ['has_wiki', 'hasWiki'], + ['has_downloads', 'hasDownloads'], + ['has_pull_requests', 'hasPullRequests'], + ['is_template', 'isTemplate'], + ['allow_squash_merge', 'allowSquashMerge'], + ['allow_merge_commit', 'allowMergeCommit'], + ['allow_rebase_merge', 'allowRebaseMerge'], + ['allow_auto_merge', 'allowAutoMerge'], + ['allow_forking', 'allowForking'], + ['delete_branch_on_merge', 'deleteBranchOnMerge'], + ['web_commit_signoff_required', 'webCommitSignoffRequired'], +] as const; + +function applyRepoBooleanPatchFields(body: Record<string, unknown>, data: Record<string, unknown>): JsonResponse | null { + for (const [apiKey, dataKey] of repoBooleanPatchFields) { + if (body[apiKey] === undefined) { continue; } + if (typeof body[apiKey] !== 'boolean') { + return jsonValidationError(`Validation Failed: ${apiKey} must be a boolean.`); + } + data[dataKey] = body[apiKey]; + } + return null; +} + +function updateRepoStorageName( + options: RepoCreateOptions, ownerDid: string, oldName: string, newName: string, +): JsonResponse | null { + if (!options.reposPath || oldName === newName) { return null; } + + const backend = new GitBackend({ basePath: options.reposPath }); + if (!backend.exists(ownerDid, oldName)) { return null; } + if (backend.exists(ownerDid, newName)) { + return jsonValidationError(`Validation Failed: repository storage for '${newName}' already exists.`); + } + + try { + const oldPath = backend.repoPath(ownerDid, oldName); + const newPath = backend.repoPath(ownerDid, newName); + mkdirSync(dirname(newPath), { recursive: true }); + renameSync(oldPath, newPath); + return null; + } catch (err) { + return jsonValidationError(`Failed to rename repository storage: ${(err as Error).message}`); + } +} + +function deleteRepoStorage(options: RepoCreateOptions, ownerDid: string, repoName: string): JsonResponse | null { + if (!options.reposPath) { return null; } + + const backend = new GitBackend({ basePath: options.reposPath }); + if (!backend.exists(ownerDid, repoName)) { return null; } + + try { + rmSync(backend.repoPath(ownerDid, repoName), { recursive: true, force: true }); + return null; + } catch (err) { + return jsonValidationError(`Failed to delete repository storage: ${(err as Error).message}`); + } +} + +function validateForkBody(body: Record<string, unknown>): JsonResponse | null { + if (body.organization !== undefined) { + return jsonValidationError('Validation Failed: organization forks are not supported by this local shim.'); + } + if (body.default_branch_only !== undefined && typeof body.default_branch_only !== 'boolean') { + return jsonValidationError('Validation Failed: default_branch_only must be a boolean.'); + } + return null; +} + +function forkMatches(entry: RepoEntry, sourceDid: string, sourceName: string, sourceRecordId: string): boolean { + return entry.repo.forkedFromDid === sourceDid + && entry.repo.forkedFromRepoName === sourceName + && entry.repo.forkedFromRecordId === sourceRecordId; +} + +function sortForkEntries(entries: OwnedRepoEntry[], url: URL): OwnedRepoEntry[] | JsonResponse { + const sort = url.searchParams.get('sort') ?? 'newest'; + if (!['newest', 'oldest', 'stargazers', 'watchers'].includes(sort)) { + return jsonValidationError('Validation Failed: sort must be newest, oldest, stargazers, or watchers.'); + } + + const newestFirst = sort !== 'oldest'; + return [...entries].sort((a, b) => { + const delta = new Date(a.repo.dateCreated).getTime() - new Date(b.repo.dateCreated).getTime(); + return newestFirst ? -delta : delta; + }); +} + +async function createForkStorage( + options: RepoCreateOptions, + source: RepoEntry, + sourceDid: string, + forkDid: string, + forkName: string, + defaultBranchOnly: boolean, +): Promise<JsonResponse | null> { + if (!options.reposPath) { return null; } + + const backend = new GitBackend({ basePath: options.reposPath }); + if (backend.exists(forkDid, forkName)) { + return jsonValidationError(`Validation Failed: repository storage for '${forkName}' already exists.`); + } + + try { + if (!backend.exists(sourceDid, source.name)) { + await backend.initRepo(forkDid, forkName); + return null; + } + + const sourcePath = backend.repoPath(sourceDid, source.name); + const forkPath = backend.repoPath(forkDid, forkName); + mkdirSync(dirname(forkPath), { recursive: true }); + const args = ['clone', '--bare']; + if (defaultBranchOnly) { + args.push('--single-branch', '--branch', source.repo.defaultBranch); + } + args.push(sourcePath, forkPath); + await execFileAsync('git', args); + return null; + } catch (err) { + const detail = (err as { stderr?: string }).stderr?.trim() || (err as Error).message; + return jsonValidationError(`Failed to initialize fork storage: ${detail}`); + } +} + +async function createTemplateStorage( + options: RepoCreateOptions, + source: RepoEntry, + sourceDid: string, + targetDid: string, + repoName: string, + includeAllBranches: boolean, +): Promise<JsonResponse | null> { + if (!options.reposPath) { return null; } + + const backend = new GitBackend({ basePath: options.reposPath }); + if (backend.exists(targetDid, repoName)) { + return jsonValidationError(`Validation Failed: repository storage for '${repoName}' already exists.`); + } + + try { + if (!backend.exists(sourceDid, source.name)) { + await backend.initRepo(targetDid, repoName); + return null; + } + + const sourcePath = backend.repoPath(sourceDid, source.name); + const targetPath = backend.repoPath(targetDid, repoName); + mkdirSync(dirname(targetPath), { recursive: true }); + const args = ['clone', '--bare']; + if (!includeAllBranches) { + args.push('--single-branch', '--branch', source.repo.defaultBranch); + } + args.push(sourcePath, targetPath); + await execFileAsync('git', args); + return null; + } catch (err) { + const detail = (err as { stderr?: string }).stderr?.trim() || (err as Error).message; + return jsonValidationError(`Failed to initialize template repository storage: ${detail}`); + } +} + /** Build a GitHub-style repository object from DWN data. */ export function buildRepoResponse( - repo: RepoInfo, targetDid: string, repoName: string, baseUrl: string, + repo: RepoInfo, targetDid: string, repoName: string, baseUrl: string, topics: string[] = [], ): Record<string, unknown> { const owner = buildOwner(targetDid, baseUrl); const fullName = `${targetDid}/${repoName}`; + const sourceFullName = repo.forkedFromDid && repo.forkedFromRepoName + ? `${repo.forkedFromDid}/${repo.forkedFromRepoName}` + : null; - return { - id : numericId(repo.contextId || `${targetDid}/repo`), - node_id : repo.contextId || '', - name : repoName, - full_name : fullName, - private : repo.visibility !== 'public', + const response: Record<string, unknown> = { + id : numericId(repo.contextId || `${targetDid}/repo`), + node_id : repo.contextId || '', + name : repoName, + full_name : fullName, + private : repo.visibility !== 'public', owner, - html_url : `${baseUrl}/repos/${fullName}`, - description : repo.description || null, - fork : false, - url : `${baseUrl}/repos/${fullName}`, - archive_url : `${baseUrl}/repos/${fullName}/{archive_format}{/ref}`, - issues_url : `${baseUrl}/repos/${fullName}/issues{/number}`, - pulls_url : `${baseUrl}/repos/${fullName}/pulls{/number}`, - releases_url : `${baseUrl}/repos/${fullName}/releases{/id}`, - created_at : toISODate(repo.dateCreated), - updated_at : toISODate(repo.timestamp), - pushed_at : toISODate(repo.timestamp), - git_url : `did://${targetDid}/${repoName}.git`, - clone_url : `did://${targetDid}/${repoName}.git`, - default_branch : repo.defaultBranch, - visibility : repo.visibility, + html_url : `${baseUrl}/repos/${fullName}`, + description : repo.description || null, + homepage : repo.homepage || null, + fork : Boolean(sourceFullName), + url : `${baseUrl}/repos/${fullName}`, + archive_url : `${baseUrl}/repos/${fullName}/{archive_format}{/ref}`, + forks_url : `${baseUrl}/repos/${fullName}/forks`, + issues_url : `${baseUrl}/repos/${fullName}/issues{/number}`, + pulls_url : `${baseUrl}/repos/${fullName}/pulls{/number}`, + releases_url : `${baseUrl}/repos/${fullName}/releases{/id}`, + created_at : toISODate(repo.dateCreated), + updated_at : toISODate(repo.timestamp), + pushed_at : toISODate(repo.timestamp), + git_url : `did://${targetDid}/${repoName}.git`, + clone_url : `did://${targetDid}/${repoName}.git`, + default_branch : repo.defaultBranch, + visibility : repo.visibility, // Counts — zero unless enriched by an indexer. - stargazers_count : 0, - watchers_count : 0, - forks_count : 0, - open_issues_count : 0, + stargazers_count : 0, + watchers_count : 0, + forks_count : 0, + open_issues_count : 0, // Standard GitHub fields with sensible defaults. - language : null, - has_issues : true, - has_projects : false, - has_wiki : true, - has_pages : false, - has_downloads : true, - archived : false, - disabled : false, - license : null, - topics : [], - forks : 0, - watchers : 0, - size : 0, + language : repo.language || null, + has_issues : repo.hasIssues, + has_projects : repo.hasProjects, + has_wiki : repo.hasWiki, + has_pages : false, + has_downloads : repo.hasDownloads, + has_pull_requests : repo.hasPullRequests, + is_template : repo.isTemplate, + archived : repo.archived, + disabled : false, + license : null, + topics, + forks : 0, + watchers : 0, + size : 0, + allow_squash_merge : repo.allowSquashMerge, + allow_merge_commit : repo.allowMergeCommit, + allow_rebase_merge : repo.allowRebaseMerge, + allow_auto_merge : repo.allowAutoMerge, + allow_forking : repo.allowForking, + delete_branch_on_merge : repo.deleteBranchOnMerge, + web_commit_signoff_required : repo.webCommitSignoffRequired, + pull_request_creation_policy : repo.pullRequestCreationPolicy, }; + + if (sourceFullName) { + const source = { + id : numericId(repo.forkedFromRecordId ?? sourceFullName), + name : repo.forkedFromRepoName, + full_name : sourceFullName, + owner : buildOwner(repo.forkedFromDid!, baseUrl), + url : `${baseUrl}/repos/${sourceFullName}`, + html_url : `${baseUrl}/repos/${sourceFullName}`, + }; + response.parent = source; + response.source = source; + } + + return response; +} + +export async function handleListRepos( + ctx: AgentContext, targetDid: string, url: URL, path: string, mode: RepoListMode, +): Promise<JsonResponse> { + const entries = await listRepoEntries(ctx, targetDid); + const dateFiltered = filterByDateBounds(entries, url); + if ('status' in dateFiltered) { return dateFiltered; } + + const filtered = filterRepos(dateFiltered, url, mode); + if ('status' in filtered) { return filtered; } + + const sorted = sortRepos(filtered, url, mode); + const pagination = parsePagination(url); + const paged = paginate(sorted, pagination); + const baseUrl = buildApiUrl(url); + const repos = await buildRepoList(ctx, paged, targetDid, baseUrl); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, sorted.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(repos, extraHeaders); +} + +export async function handleListAuthenticatedRepos(ctx: AgentContext, url: URL): Promise<JsonResponse> { + return handleListRepos(ctx, ctx.did, url, '/user/repos', 'authenticated'); } +export async function handleListPublicRepositories(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const since = parseSinceRepositoryId(url); + if (typeof since !== 'number') { return since; } + + const { perPage } = parsePagination(url); + const entries = (await listRepoEntries(ctx, ctx.did)) + .filter(entry => !isPrivate(entry.repo)) + .filter(entry => repoResponseId(entry, ctx.did) > since); + + const baseUrl = buildApiUrl(url); + const repos: Record<string, unknown>[] = []; + for (const entry of entries) { + const topics = await listTopicNames(ctx, ctx.did, entry.repo); + repos.push(buildRepoResponse(entry.repo, ctx.did, entry.name, baseUrl, topics)); + } + + const page = repos.slice(0, perPage); + const extraHeaders: Record<string, string> = {}; + if (repos.length > perPage) { + extraHeaders.Link = publicRepositoriesLinkHeader(url, Number(page[page.length - 1]?.id ?? since)); + } + + return jsonOk(page, extraHeaders); +} + +export async function handleListUserRepos( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + return handleListRepos(ctx, userDid, url, `/users/${userDid}/repos`, 'user'); +} + +export async function createRepoForOwner( + ctx: AgentContext, ownerDid: string, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + if (ownerDid !== ctx.did) { + return jsonValidationError('Validation Failed: repository creation is only supported for the local DID.'); + } + + const name = normalizeRepoName(body.name); + if (!name) { + return jsonValidationError('Validation Failed: name must contain only letters, digits, dots, hyphens, and underscores.'); + } + + const visibility = visibilityFromBody(body); + if (!visibility) { + return jsonValidationError('Validation Failed: visibility must be public or private.'); + } + + const existing = await getRepoRecord(ctx, ownerDid, name); + if (existing) { + return jsonValidationError(`Validation Failed: repository '${name}' already exists.`); + } + + if (options.reposPath) { + try { + await new GitBackend({ basePath: options.reposPath }).initRepo(ownerDid, name); + } catch (err) { + return jsonValidationError(`Failed to initialize repository storage: ${(err as Error).message}`); + } + } + + const defaultBranch = typeof body.default_branch === 'string' && body.default_branch.trim() + ? body.default_branch.trim() + : 'main'; + const description = typeof body.description === 'string' ? body.description : ''; + const homepage = typeof body.homepage === 'string' ? body.homepage : ''; + + const { record, status } = await ctx.repo.records.create('repo', { + data: { + name, + description, + homepage, + defaultBranch, + dwnEndpoints: getDwnEndpoints(ctx.enbox), + }, + tags: { + name, + visibility, + }, + }); + + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create repository: ${status.detail}`); + } + + const repo = repoInfoFromRecord(record, { + name, description, homepage, defaultBranch, + }, { visibility }); + return jsonCreated(buildRepoResponse(repo, ownerDid, name, buildApiUrl(url))); +} + +export async function handleListForks( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const source = await findRepoEntry(ctx, targetDid, repoName); + if (!source) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const candidates: OwnedRepoEntry[] = []; + const ownerDids = targetDid === ctx.did ? [ctx.did] : [ctx.did, targetDid]; + for (const ownerDid of ownerDids) { + const entries = await listRepoEntries(ctx, ownerDid); + for (const entry of entries) { + if (forkMatches(entry, targetDid, source.name, source.record.id)) { + candidates.push({ ...entry, ownerDid }); + } + } + } + + const sorted = sortForkEntries(candidates, url); + if ('status' in sorted) { return sorted; } + + const pagination = parsePagination(url); + const paged = paginate(sorted, pagination); + const baseUrl = buildApiUrl(url); + const forks: Record<string, unknown>[] = []; + for (const entry of paged) { + const topics = await listTopicNames(ctx, entry.ownerDid, entry.repo); + forks.push(buildRepoResponse(entry.repo, entry.ownerDid, entry.name, baseUrl, topics)); + } + + const linkHeader = buildLinkHeader(baseUrl, `/repos/${targetDid}/${repoName}/forks`, pagination.page, pagination.perPage, sorted.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(forks, extraHeaders); +} + +export async function handleCreateFork( + ctx: AgentContext, targetDid: string, repoName: string, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + const validationError = validateForkBody(body); + if (validationError) { return validationError; } + + const source = await findRepoEntry(ctx, targetDid, repoName); + if (!source) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const forkName = body.name === undefined + ? source.name + : normalizeRepoName(body.name); + if (!forkName) { + return jsonValidationError('Validation Failed: name must contain only letters, digits, dots, hyphens, and underscores.'); + } + + const existing = await getRepoRecord(ctx, ctx.did, forkName); + if (existing) { + return jsonValidationError(`Validation Failed: repository '${forkName}' already exists.`); + } + + const storageError = await createForkStorage(options, source, targetDid, ctx.did, forkName, body.default_branch_only === true); + if (storageError) { return storageError; } + + const data = { + name : forkName, + description : source.repo.description, + homepage : source.repo.homepage, + defaultBranch : source.repo.defaultBranch, + dwnEndpoints : getDwnEndpoints(ctx.enbox), + forkedFromDid : targetDid, + forkedFromRepoName : source.name, + forkedFromRecordId : source.record.id, + }; + const tags = { + name : forkName, + visibility : source.repo.visibility, + forkedFromDid : targetDid, + forkedFromRepoName : source.name, + forkedFromRecordId : source.record.id, + }; + + const { record, status } = await ctx.repo.records.create('repo', { data, tags }); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create repository fork: ${status.detail}`); + } + + const repo = repoInfoFromRecord(record, data, tags); + return jsonAccepted(buildRepoResponse(repo, ctx.did, forkName, buildApiUrl(url))); +} + +export async function handleGenerateRepoFromTemplate( + ctx: AgentContext, templateDid: string, templateName: string, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + const source = await findRepoEntry(ctx, templateDid, templateName); + if (!source) { + return jsonNotFound(`Repository '${templateName}' not found for DID '${templateDid}'.`); + } + if (!source.repo.isTemplate) { + return jsonValidationError('Validation Failed: source repository is not marked as a template.'); + } + + const ownerDid = typeof body.owner === 'string' && body.owner.trim() ? body.owner.trim() : ctx.did; + if (ownerDid !== ctx.did) { + return jsonValidationError('Validation Failed: template generation is only supported for the local DID.'); + } + + const name = normalizeRepoName(body.name); + if (!name) { + return jsonValidationError('Validation Failed: name must contain only letters, digits, dots, hyphens, and underscores.'); + } + if (body.private !== undefined && typeof body.private !== 'boolean') { + return jsonValidationError('Validation Failed: private must be a boolean.'); + } + if (body.include_all_branches !== undefined && typeof body.include_all_branches !== 'boolean') { + return jsonValidationError('Validation Failed: include_all_branches must be a boolean.'); + } + if (body.description !== undefined && typeof body.description !== 'string' && body.description !== null) { + return jsonValidationError('Validation Failed: description must be a string.'); + } + + const existing = await getRepoRecord(ctx, ownerDid, name); + if (existing) { + return jsonValidationError(`Validation Failed: repository '${name}' already exists.`); + } + + const storageError = await createTemplateStorage( + options, + source, + templateDid, + ownerDid, + name, + body.include_all_branches === true, + ); + if (storageError) { return storageError; } + + const data = { + name, + description : typeof body.description === 'string' ? body.description : source.repo.description, + homepage : source.repo.homepage, + defaultBranch : source.repo.defaultBranch, + dwnEndpoints : getDwnEndpoints(ctx.enbox), + hasIssues : source.repo.hasIssues, + hasProjects : source.repo.hasProjects, + hasWiki : source.repo.hasWiki, + hasDownloads : source.repo.hasDownloads, + hasPullRequests : source.repo.hasPullRequests, + isTemplate : false, + allowSquashMerge : source.repo.allowSquashMerge, + allowMergeCommit : source.repo.allowMergeCommit, + allowRebaseMerge : source.repo.allowRebaseMerge, + allowAutoMerge : source.repo.allowAutoMerge, + allowForking : source.repo.allowForking, + deleteBranchOnMerge : source.repo.deleteBranchOnMerge, + webCommitSignoffRequired : source.repo.webCommitSignoffRequired, + pullRequestCreationPolicy : source.repo.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' as const : 'all' as const, + }; + const tags: Record<string, string | number | boolean | string[] | number[]> = { + name, + visibility : body.private === true ? 'private' : 'public', + defaultBranch : source.repo.defaultBranch, + ...(source.repo.language ? { language: source.repo.language } : {}), + }; + + const { record, status } = await ctx.repo.records.create('repo', { data, tags }); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to generate repository from template: ${status.detail}`); + } + + const baseUrl = buildApiUrl(url); + const repo = repoInfoFromRecord(record, data, tags); + const response = buildRepoResponse(repo, ownerDid, name, baseUrl); + response.template_repository = buildRepoResponse(source.repo, templateDid, source.name, baseUrl); + return jsonCreated(response); +} + +export async function handleTransferRepo( + ctx: AgentContext, targetDid: string, repoName: string, body: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + if (targetDid !== ctx.did) { + return jsonNotFound(`Repository '${repoName}' not found for local account '${ctx.did}'.`); + } + + const entry = await findRepoEntry(ctx, targetDid, repoName); + if (!entry) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + if (typeof body.new_owner !== 'string' || !body.new_owner.trim()) { + return jsonValidationError('Validation Failed: new_owner is required.'); + } + const newOwner = body.new_owner.trim(); + const newName = body.new_name === undefined ? undefined : normalizeRepoName(body.new_name); + if (body.new_name !== undefined && !newName) { + return jsonValidationError('Validation Failed: new_name must contain only letters, digits, dots, hyphens, and underscores.'); + } + + const teamIds = parseTeamIds(body.team_ids); + if ('status' in teamIds) { return teamIds; } + + const targetName = newName ?? entry.name; + const targetIsLocalDid = newOwner.toLowerCase() === ctx.did.toLowerCase(); + const targetIsOrg = await orgRouteExistsForTransfer(ctx, newOwner); + if (teamIds.length > 0 && !targetIsOrg) { + return jsonValidationError('Validation Failed: team_ids can only be supplied when transferring to an organization.'); + } + + if (targetIsLocalDid && targetName === entry.name) { + return jsonValidationError('Validation Failed: repository is already owned by the requested owner.'); + } + + if ((targetIsLocalDid || targetIsOrg) && targetName !== entry.name) { + const existing = await getRepoRecord(ctx, ctx.did, targetName); + if (existing) { + return jsonValidationError(`Validation Failed: repository '${targetName}' already exists.`); + } + } + + const createdAt = new Date().toISOString(); + const transferRequest: RepositoryTransferRequestData = { + id : numericId(`${entry.record.id}:transfer:${newOwner}:${targetName}`) || 1, + newOwner, + ...(newName ? { newName } : {}), + ...(teamIds.length > 0 ? { teamIds } : {}), + requestedBy : ctx.did, + createdAt, + }; + + const lookup = await getRepoSettingsForEntry(ctx, targetDid, entry); + const saveError = await saveRepoSettingsForEntry(ctx, lookup, { + ...lookup.settings, + transferRequest, + }); + if (saveError) { return saveError; } + + const response = buildRepoResponse(entry.repo, targetDid, entry.name, buildApiUrl(url)); + response.transfer_request = buildTransferRequestResponse(transferRequest); + return jsonAccepted(response); +} + +export async function handleCreateUserRepo( + ctx: AgentContext, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + return createRepoForOwner(ctx, ctx.did, body, url, options); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo +// --------------------------------------------------------------------------- + /** * Handle `GET /repos/:did/:repo`. * @@ -89,5 +1035,117 @@ export async function handleGetRepo( } const baseUrl = buildApiUrl(url); - return jsonOk(buildRepoResponse(repo, targetDid, repo.name, baseUrl)); + const topics = await listTopicNames(ctx, targetDid, repo); + return jsonOk(buildRepoResponse(repo, targetDid, repo.name, baseUrl, topics)); +} + +export async function handleUpdateRepo( + ctx: AgentContext, targetDid: string, repoName: string, body: Record<string, unknown>, url: URL, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + if (targetDid !== ctx.did) { + return jsonNotFound(`Repository '${repoName}' not found for local account '${ctx.did}'.`); + } + + const entry = await findRepoEntry(ctx, targetDid, repoName); + if (!entry) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const name = body.name === undefined ? entry.name : normalizeRepoName(body.name); + if (!name) { + return jsonValidationError('Validation Failed: name must contain only letters, digits, dots, hyphens, and underscores.'); + } + + if (name !== entry.name) { + const existing = await getRepoRecord(ctx, targetDid, name); + if (existing) { + return jsonValidationError(`Validation Failed: repository '${name}' already exists.`); + } + } + + const visibility = visibilityFromPatchBody(body, entry.repo.visibility); + if (typeof visibility !== 'string') { return visibility; } + + const data = await entry.record.data.json() as Record<string, unknown>; + data.name = name; + + if (body.description !== undefined) { + if (typeof body.description !== 'string' && body.description !== null) { + return jsonValidationError('Validation Failed: description must be a string.'); + } + data.description = body.description ?? ''; + } + + if (body.homepage !== undefined) { + if (typeof body.homepage !== 'string' && body.homepage !== null) { + return jsonValidationError('Validation Failed: homepage must be a string.'); + } + data.homepage = body.homepage ?? ''; + } + + if (body.default_branch !== undefined) { + if (typeof body.default_branch !== 'string' || !body.default_branch.trim()) { + return jsonValidationError('Validation Failed: default_branch must be a non-empty string.'); + } + data.defaultBranch = body.default_branch.trim(); + } + + if (body.archived !== undefined) { + if (typeof body.archived !== 'boolean') { + return jsonValidationError('Validation Failed: archived must be a boolean.'); + } + } + + if (body.pull_request_creation_policy !== undefined) { + if (body.pull_request_creation_policy !== 'all' && body.pull_request_creation_policy !== 'collaborators_only') { + return jsonValidationError('Validation Failed: pull_request_creation_policy must be all or collaborators_only.'); + } + data.pullRequestCreationPolicy = body.pull_request_creation_policy; + } + + const booleanError = applyRepoBooleanPatchFields(body, data); + if (booleanError) { return booleanError; } + + const storageError = updateRepoStorageName(options, targetDid, entry.name, name); + if (storageError) { return storageError; } + + const tags: Record<string, unknown> = { + ...((entry.record.tags as Record<string, unknown> | undefined) ?? {}), + name, + visibility, + defaultBranch: data.defaultBranch, + }; + if (body.archived !== undefined) { tags.archived = body.archived; } + + const { status } = await entry.record.update({ data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository: ${status.detail}`); + } + + const repo = repoInfoFromRecord(entry.record, data, tags); + const topics = await listTopicNames(ctx, targetDid, repo); + return jsonOk(buildRepoResponse(repo, targetDid, name, buildApiUrl(url), topics)); +} + +export async function handleDeleteRepo( + ctx: AgentContext, targetDid: string, repoName: string, options: RepoCreateOptions = {}, +): Promise<JsonResponse> { + if (targetDid !== ctx.did) { + return jsonNotFound(`Repository '${repoName}' not found for local account '${ctx.did}'.`); + } + + const entry = await findRepoEntry(ctx, targetDid, repoName); + if (!entry) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const storageError = deleteRepoStorage(options, targetDid, entry.name); + if (storageError) { return storageError; } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete repository: ${status.detail}`); + } + + return jsonNoContent(); } diff --git a/src/github-shim/search.ts b/src/github-shim/search.ts new file mode 100644 index 0000000..f0bbd4d --- /dev/null +++ b/src/github-shim/search.ts @@ -0,0 +1,956 @@ +/** + * GitHub API shim - search endpoints. + * + * Provides local DWN-backed search envelopes compatible with GitHub REST + * search responses. This is intentionally scoped to records visible to the + * local actor; broader network discovery belongs to indexer services. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { GitObjectOptions } from './git-objects.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { basename } from 'node:path'; +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { buildRepoResponse } from './repos.js'; +import { handleListPulls } from './pulls.js'; +import { listTopicNames } from './repo-metadata.js'; +import { buildUserProfile, collectVisibleUserDids } from './users.js'; + +import { + buildApiUrl, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, +} from './helpers.js'; +import { handleGetGitBlob, handleGetGitTree, handleListRepoCommits } from './git-objects.js'; +import { handleListIssues, handleListRepoLabels } from './issues.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ParsedSearchQuery = { + terms : string[]; + qualifiers : Map<string, string[]>; +}; + +type SearchRepoEntry = { + record : any; + repo : RepoInfo; + ownerDid : string; + name : string; + topics : string[]; + response : Record<string, unknown>; +}; + +type SearchItem = Record<string, unknown> & { + score : number; +}; + +type SearchKind = 'code' | 'commits' | 'issues' | 'labels' | 'repositories' | 'topics' | 'users'; + +type TopicSearchEntry = { + name : string; + createdAt : string | null; + updatedAt : string | null; +}; + +const MAX_CODE_SEARCH_BLOB_BYTES = 256 * 1024; + +const SEARCH_QUALIFIERS = new Set([ + 'author', + 'author-date', + 'author-email', + 'author-name', + 'committer', + 'committer-date', + 'committer-email', + 'committer-name', + 'extension', + 'filename', + 'hash', + 'in', + 'is', + 'language', + 'merge', + 'org', + 'path', + 'ref', + 'repo', + 'state', + 'topic', + 'type', + 'user', +]); + +// --------------------------------------------------------------------------- +// Query parsing and shared helpers +// --------------------------------------------------------------------------- + +function parseSearchQuery(value: string | null): ParsedSearchQuery | JsonResponse { + if (!value || value.trim() === '') { + return jsonValidationError('Validation Failed: q is required.'); + } + + const terms: string[] = []; + const qualifiers = new Map<string, string[]>(); + for (const token of value.trim().split(/\s+/)) { + const match = token.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):(.+)$/); + if (!match) { + terms.push(normalizeTerm(token)); + continue; + } + + const key = match[1].toLowerCase(); + if (!SEARCH_QUALIFIERS.has(key)) { + terms.push(normalizeTerm(token)); + continue; + } + + const values = qualifiers.get(key) ?? []; + values.push(match[2].replace(/^"|"$/g, '')); + qualifiers.set(key, values); + } + + return { terms: terms.filter(Boolean), qualifiers }; +} + +function parseSearchTerms(value: string | null): string[] | JsonResponse { + if (!value || value.trim() === '') { + return jsonValidationError('Validation Failed: q is required.'); + } + return value.trim().split(/\s+/).map(normalizeTerm).filter(Boolean); +} + +function normalizeTerm(value: unknown): string { + return typeof value === 'string' + ? value.trim().toLowerCase() + : ''; +} + +function qualifierValues(query: ParsedSearchQuery, key: string): string[] { + return query.qualifiers.get(key) ?? []; +} + +function hasQualifier(query: ParsedSearchQuery, key: string, value: string): boolean { + return qualifierValues(query, key).some(item => item.toLowerCase() === value); +} + +function qualifierList(query: ParsedSearchQuery, key: string): string[] { + return qualifierValues(query, key) + .flatMap(value => value.split(',')) + .map(value => value.trim().toLowerCase()) + .filter(Boolean); +} + +function asRecord(value: unknown): Record<string, unknown> { + return value && typeof value === 'object' ? value as Record<string, unknown> : {}; +} + +function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + +function recordToRepoInfo(record: any, data: Record<string, unknown>, tags: Record<string, unknown>): RepoInfo { + return { + name : typeof data.name === 'string' ? data.name : 'unnamed', + description : typeof data.description === 'string' ? data.description : '', + defaultBranch : typeof data.defaultBranch === 'string' ? data.defaultBranch : 'main', + homepage : typeof data.homepage === 'string' ? data.homepage : '', + contextId : record.contextId ?? '', + visibility : typeof tags.visibility === 'string' ? tags.visibility : 'public', + language : typeof tags.language === 'string' ? tags.language : '', + archived : tags.archived === true || tags.archived === 'true', + hasIssues : data.hasIssues !== false, + hasProjects : data.hasProjects === true, + hasWiki : data.hasWiki !== false, + hasDownloads : data.hasDownloads !== false, + hasPullRequests : data.hasPullRequests !== false, + isTemplate : data.isTemplate === true, + allowSquashMerge : data.allowSquashMerge !== false, + allowMergeCommit : data.allowMergeCommit !== false, + allowRebaseMerge : data.allowRebaseMerge !== false, + allowAutoMerge : data.allowAutoMerge === true, + allowForking : data.allowForking !== false, + deleteBranchOnMerge : data.deleteBranchOnMerge === true, + webCommitSignoffRequired : data.webCommitSignoffRequired === true, + pullRequestCreationPolicy : data.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' : 'all', + dateCreated : record.dateCreated, + timestamp : record.timestamp, + }; +} + +function searchEnvelope(items: SearchItem[], total: number, url: URL, path: string): JsonResponse { + const pagination = parsePagination(url); + const page = paginate(items, pagination); + const linkHeader = buildSearchLinkHeader(url, path, pagination.page, pagination.perPage, total); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk({ + total_count : total, + incomplete_results : false, + items : page, + }, extraHeaders); +} + +function buildSearchLinkHeader(url: URL, path: string, page: number, perPage: number, totalItems: number): string | null { + const lastPage = Math.max(1, Math.ceil(totalItems / perPage)); + if (lastPage <= 1) { return null; } + + const baseUrl = buildApiUrl(url); + const makeUrl = (targetPage: number): string => { + const params = new URLSearchParams(url.searchParams); + params.set('page', String(targetPage)); + params.set('per_page', String(perPage)); + return `${baseUrl}${path}?${params.toString()}`; + }; + + const links: string[] = []; + if (page < lastPage) { + links.push(`<${makeUrl(page + 1)}>; rel="next"`); + links.push(`<${makeUrl(lastPage)}>; rel="last"`); + } + if (page > 1) { + links.push(`<${makeUrl(1)}>; rel="first"`); + links.push(`<${makeUrl(page - 1)}>; rel="prev"`); + } + return links.length > 0 ? links.join(', ') : null; +} + +function scoreTerms(terms: string[], values: Array<unknown>): number { + if (terms.length === 0) { return 1; } + + const haystack = values + .filter(value => value !== null && value !== undefined) + .map(value => String(value).toLowerCase()) + .join(' '); + let score = 0; + for (const term of terms) { + if (haystack.includes(term)) { score++; } + } + return score; +} + +function itemDate(item: SearchItem, field: string): number { + const commit = asRecord(item.commit); + if (field === 'author-date') { + return Date.parse(String(asRecord(commit.author).date ?? 0)); + } + if (field === 'committer-date') { + return Date.parse(String(asRecord(commit.committer).date ?? 0)); + } + return 0; +} + +function sortSearchItems(items: SearchItem[], url: URL, kind: SearchKind): SearchItem[] { + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + const multiplier = order === 'asc' ? 1 : -1; + + return [...items].sort((left, right) => { + if (kind === 'repositories' && sort === 'updated') { + return (Date.parse(String(left.updated_at ?? 0)) - Date.parse(String(right.updated_at ?? 0))) * multiplier; + } + if (kind === 'users' && sort === 'joined') { + return (Date.parse(String(left.created_at ?? 0)) - Date.parse(String(right.created_at ?? 0))) * multiplier; + } + if (kind === 'issues' && sort === 'updated') { + return (Date.parse(String(left.updated_at ?? 0)) - Date.parse(String(right.updated_at ?? 0))) * multiplier; + } + if (kind === 'issues' && sort === 'created') { + return (Date.parse(String(left.created_at ?? 0)) - Date.parse(String(right.created_at ?? 0))) * multiplier; + } + if (kind === 'commits' && (sort === 'author-date' || sort === 'committer-date')) { + return (itemDate(left, sort) - itemDate(right, sort)) * multiplier; + } + if (kind === 'labels' && (sort === 'created' || sort === 'updated')) { + const field = sort === 'created' ? 'created_at' : 'updated_at'; + return (Date.parse(String(left[field] ?? 0)) - Date.parse(String(right[field] ?? 0))) * multiplier; + } + const leftName = String(left.full_name ?? left.login ?? left.title ?? left.name); + const rightName = String(right.full_name ?? right.login ?? right.title ?? right.name); + return (right.score - left.score) || leftName.localeCompare(rightName); + }); +} + +// --------------------------------------------------------------------------- +// Repository search +// --------------------------------------------------------------------------- + +async function listLocalRepoSearchEntries(ctx: AgentContext, url: URL): Promise<SearchRepoEntry[]> { + const { records } = await ctx.repo.records.query('repo', { + dateSort: DateSort.CreatedAscending, + } as any); + + const entries: SearchRepoEntry[] = []; + const baseUrl = buildApiUrl(url); + for (const record of records) { + let data: Record<string, unknown>; + try { + data = await record.data.json(); + } catch { + continue; + } + + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const repo = recordToRepoInfo(record, data, tags); + const topics = await listTopicNames(ctx, ctx.did, repo); + entries.push({ + record, + repo, + topics, + ownerDid : ctx.did, + name : repo.name, + response : buildRepoResponse(repo, ctx.did, repo.name, baseUrl, topics), + }); + } + return entries; +} + +async function listLocalOrgNames(ctx: AgentContext): Promise<string[]> { + const { records } = await ctx.org.records.query('org', { + dateSort: DateSort.CreatedAscending, + } as any); + + const names: string[] = []; + for (const record of records) { + try { + const data = await record.data.json(); + if (typeof data.name === 'string' && data.name.trim()) { + names.push(data.name.trim().toLowerCase()); + } + } catch { /* Ignore unreadable org records. */ } + } + return names; +} + +async function filterRepoEntries(ctx: AgentContext, entries: SearchRepoEntry[], query: ParsedSearchQuery): Promise<SearchRepoEntry[]> { + let filtered = [...entries]; + + const userFilters = qualifierValues(query, 'user').map(value => value.toLowerCase()); + if (userFilters.length > 0) { + filtered = filtered.filter(entry => userFilters.includes(entry.ownerDid.toLowerCase())); + } + + const orgFilters = qualifierValues(query, 'org').map(value => value.toLowerCase()); + if (orgFilters.length > 0) { + const localOrgs = await listLocalOrgNames(ctx); + if (!orgFilters.some(org => localOrgs.includes(org))) { + filtered = []; + } + } + + const repoFilters = qualifierValues(query, 'repo').map(value => value.toLowerCase()); + if (repoFilters.length > 0) { + filtered = filtered.filter((entry) => { + const fullName = `${entry.ownerDid}/${entry.name}`.toLowerCase(); + return repoFilters.includes(fullName) || repoFilters.includes(entry.name.toLowerCase()); + }); + } + + const languageFilters = qualifierValues(query, 'language').map(value => value.toLowerCase()); + if (languageFilters.length > 0) { + filtered = filtered.filter(entry => languageFilters.includes(entry.repo.language.toLowerCase())); + } + + const topicFilters = qualifierValues(query, 'topic').map(value => value.toLowerCase()); + if (topicFilters.length > 0) { + filtered = filtered.filter(entry => topicFilters.every(topic => entry.topics.includes(topic))); + } + + if (hasQualifier(query, 'is', 'public')) { + filtered = filtered.filter(entry => entry.repo.visibility === 'public'); + } + if (hasQualifier(query, 'is', 'private')) { + filtered = filtered.filter(entry => entry.repo.visibility !== 'public'); + } + + return filtered; +} + +export async function handleSearchRepositories(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + const entries = await filterRepoEntries(ctx, await listLocalRepoSearchEntries(ctx, url), parsed); + const items = entries + .map((entry) => { + const score = scoreTerms(parsed.terms, [ + entry.name, + entry.repo.description, + entry.repo.language, + ...entry.topics, + ]); + return { ...entry.response, score }; + }) + .filter(item => item.score > 0); + const sorted = sortSearchItems(items, url, 'repositories'); + return searchEnvelope(sorted, sorted.length, url, '/search/repositories'); +} + +// --------------------------------------------------------------------------- +// Issue and pull request search +// --------------------------------------------------------------------------- + +function searchStateFilter(query: ParsedSearchQuery): 'open' | 'closed' | null { + const states = [...qualifierValues(query, 'state'), ...qualifierValues(query, 'is')].map(value => value.toLowerCase()); + if (states.includes('open')) { return 'open'; } + if (states.includes('closed') || states.includes('merged')) { return 'closed'; } + return null; +} + +function shouldIncludeIssues(query: ParsedSearchQuery): boolean { + const typeValues = [...qualifierValues(query, 'type'), ...qualifierValues(query, 'is')].map(value => value.toLowerCase()); + const hasIssueOrPullFilter = typeValues.some(value => value === 'issue' || value === 'pr' || value === 'pull-request'); + return !hasIssueOrPullFilter || typeValues.includes('issue'); +} + +function shouldIncludePulls(query: ParsedSearchQuery): boolean { + const typeValues = [...qualifierValues(query, 'type'), ...qualifierValues(query, 'is')].map(value => value.toLowerCase()); + const hasIssueOrPullFilter = typeValues.some(value => value === 'issue' || value === 'pr' || value === 'pull-request'); + return !hasIssueOrPullFilter || typeValues.includes('pr') || typeValues.includes('pull-request'); +} + +function issueSearchValues(item: Record<string, unknown>): unknown[] { + return [ + item.title, + item.body, + item.state, + item.number, + item.repository_url, + ]; +} + +function pullToIssueSearchItem(pull: Record<string, unknown>, score: number): SearchItem { + return { + id : pull.id, + node_id : pull.node_id, + url : pull.issue_url, + repository_url : String(pull.issue_url ?? '').replace(/\/issues\/\d+$/, ''), + labels_url : `${pull.issue_url}/labels{/name}`, + comments_url : pull.comments_url, + events_url : `${pull.issue_url}/events`, + html_url : pull.html_url, + number : pull.number, + title : pull.title, + body : pull.body ?? null, + state : pull.state, + locked : false, + comments : 0, + created_at : pull.created_at, + updated_at : pull.updated_at, + closed_at : pull.closed_at ?? null, + user : pull.user, + author_association : 'CONTRIBUTOR', + labels : [], + assignee : null, + assignees : [], + milestone : null, + pull_request : { + url : pull.url, + html_url : pull.html_url, + diff_url : pull.diff_url, + patch_url : pull.patch_url, + }, + score, + }; +} + +async function collectSearchIssues(ctx: AgentContext, repos: SearchRepoEntry[], query: ParsedSearchQuery, url: URL): Promise<SearchItem[]> { + const state = searchStateFilter(query); + const items: SearchItem[] = []; + const includeIssues = shouldIncludeIssues(query); + const includePulls = shouldIncludePulls(query); + + for (const repo of repos) { + if (includeIssues) { + const issueUrl = new URL(url); + issueUrl.search = ''; + issueUrl.searchParams.set('state', state ?? 'all'); + issueUrl.searchParams.set('per_page', '100'); + const response = await handleListIssues(ctx, repo.ownerDid, repo.name, issueUrl); + if (response.status === 200) { + const issueItems = JSON.parse(response.body as string) as Record<string, unknown>[]; + for (const issue of issueItems) { + const score = scoreTerms(query.terms, issueSearchValues(issue)); + if (score > 0) { items.push({ ...issue, score }); } + } + } + } + + if (includePulls) { + const pullUrl = new URL(url); + pullUrl.search = ''; + pullUrl.searchParams.set('state', state ?? 'all'); + pullUrl.searchParams.set('per_page', '100'); + const response = await handleListPulls(ctx, repo.ownerDid, repo.name, pullUrl); + if (response.status === 200) { + const pullItems = JSON.parse(response.body as string) as Record<string, unknown>[]; + for (const pull of pullItems) { + const score = scoreTerms(query.terms, issueSearchValues(pull)); + if (score > 0) { items.push(pullToIssueSearchItem(pull, score)); } + } + } + } + } + + return sortSearchItems(items, url, 'issues'); +} + +export async function handleSearchIssues(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + const repos = await filterRepoEntries(ctx, await listLocalRepoSearchEntries(ctx, url), parsed); + const items = await collectSearchIssues(ctx, repos, parsed, url); + return searchEnvelope(items, items.length, url, '/search/issues'); +} + +// --------------------------------------------------------------------------- +// Code search +// --------------------------------------------------------------------------- + +function codeSearchRef(entry: SearchRepoEntry, query: ParsedSearchQuery): string { + return qualifierValues(query, 'ref')[0] ?? entry.repo.defaultBranch ?? 'HEAD'; +} + +function codeEntryMatchesQualifiers(path: string, query: ParsedSearchQuery): boolean { + const lowerPath = path.toLowerCase(); + const fileName = basename(path).toLowerCase(); + + const pathFilters = qualifierList(query, 'path').map(value => value.replace(/^\/+|\/+$/g, '')); + if (pathFilters.length > 0 && !pathFilters.some(filter => lowerPath.includes(filter))) { + return false; + } + + const filenameFilters = qualifierList(query, 'filename'); + if (filenameFilters.length > 0 && !filenameFilters.some(filter => fileName === filter || fileName.includes(filter))) { + return false; + } + + const extensionFilters = qualifierList(query, 'extension').map(value => value.replace(/^\./, '')); + if (extensionFilters.length > 0 && !extensionFilters.some(filter => lowerPath.endsWith(`.${filter}`))) { + return false; + } + + return true; +} + +function codeSearchValues(path: string, content: string, query: ParsedSearchQuery): unknown[] { + const scopes = qualifierList(query, 'in'); + const searchPath = scopes.length === 0 || scopes.includes('path'); + const searchFile = scopes.length === 0 || scopes.includes('file') || scopes.includes('text'); + const values: unknown[] = []; + if (searchPath) { + values.push(path, basename(path)); + } + if (searchFile) { + values.push(content); + } + return values; +} + +function codeSearchNeedsContent(query: ParsedSearchQuery): boolean { + if (query.terms.length === 0) { + return false; + } + const scopes = qualifierList(query, 'in'); + return scopes.length === 0 || scopes.includes('file') || scopes.includes('text'); +} + +function decodeBlobContent(item: Record<string, unknown>): string { + if (typeof item.content !== 'string' || item.encoding !== 'base64') { + return ''; + } + try { + const content = Buffer.from(item.content, 'base64').toString('utf-8'); + return content.includes('\0') ? '' : content; + } catch { + return ''; + } +} + +function buildCodeSearchItem( + entry: SearchRepoEntry, + file: Record<string, unknown>, + ref: string, + baseUrl: string, + score: number, +): SearchItem { + const path = String(file.path ?? ''); + const sha = String(file.sha ?? ''); + const repoBase = `${baseUrl}/repos/${entry.ownerDid}/${entry.name}`; + const encodedPath = encodePath(path); + const encodedRef = encodeURIComponent(ref); + return { + name : basename(path), + path, + sha, + url : `${repoBase}/contents/${encodedPath}?ref=${encodedRef}`, + git_url : `${repoBase}/git/blobs/${sha}`, + html_url : `${repoBase}/blob/${encodedRef}/${encodedPath}`, + repository : entry.response, + score, + }; +} + +async function collectSearchCode( + ctx: AgentContext, repos: SearchRepoEntry[], query: ParsedSearchQuery, url: URL, options: GitObjectOptions, +): Promise<SearchItem[]> { + const items: SearchItem[] = []; + const baseUrl = buildApiUrl(url); + + for (const repo of repos) { + const ref = codeSearchRef(repo, query); + const treeUrl = new URL(url); + treeUrl.search = ''; + treeUrl.searchParams.set('recursive', '1'); + const treeResponse = await handleGetGitTree(ctx, repo.ownerDid, repo.name, ref, treeUrl, options); + if (treeResponse.status !== 200) { + continue; + } + + const tree = JSON.parse(treeResponse.body as string) as { tree?: Record<string, unknown>[] }; + for (const file of tree.tree ?? []) { + if (file.type !== 'blob' || typeof file.path !== 'string') { + continue; + } + if (!codeEntryMatchesQualifiers(file.path, query)) { + continue; + } + + let content = ''; + if (codeSearchNeedsContent(query) && Number(file.size ?? 0) <= MAX_CODE_SEARCH_BLOB_BYTES) { + const blobResponse = await handleGetGitBlob(ctx, repo.ownerDid, repo.name, String(file.sha ?? ''), url, options); + if (blobResponse.status === 200) { + content = decodeBlobContent(JSON.parse(blobResponse.body as string) as Record<string, unknown>); + } + } + + const score = scoreTerms(query.terms, codeSearchValues(file.path, content, query)); + if (score > 0) { + items.push(buildCodeSearchItem(repo, file, ref, baseUrl, score)); + } + } + } + + return sortSearchItems(items, url, 'code'); +} + +export async function handleSearchCode(ctx: AgentContext, url: URL, options: GitObjectOptions = {}): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + const repos = await filterRepoEntries(ctx, await listLocalRepoSearchEntries(ctx, url), parsed); + const items = await collectSearchCode(ctx, repos, parsed, url, options); + return searchEnvelope(items, items.length, url, '/search/code'); +} + +// --------------------------------------------------------------------------- +// Commit search +// --------------------------------------------------------------------------- + +function commitSearchRef(entry: SearchRepoEntry, query: ParsedSearchQuery): string { + return qualifierValues(query, 'ref')[0] ?? entry.repo.defaultBranch ?? 'HEAD'; +} + +function personMatches(person: Record<string, unknown>, filters: string[]): boolean { + if (filters.length === 0) { + return true; + } + const values = [person.name, person.email].map(value => String(value ?? '').toLowerCase()); + return filters.some(filter => values.some(value => value.includes(filter))); +} + +function dateMatches(value: unknown, filters: string[]): boolean { + if (filters.length === 0) { + return true; + } + const timestamp = Date.parse(String(value ?? '')); + if (!Number.isFinite(timestamp)) { + return false; + } + + return filters.every((filter) => { + const range = filter.split('..'); + if (range.length === 2) { + const start = range[0] ? Date.parse(range[0]) : -Infinity; + const end = range[1] ? Date.parse(range[1]) : Infinity; + return timestamp >= start && timestamp <= end; + } + if (filter.startsWith('>=')) { + return timestamp >= Date.parse(filter.slice(2)); + } + if (filter.startsWith('>')) { + return timestamp > Date.parse(filter.slice(1)); + } + if (filter.startsWith('<=')) { + return timestamp <= Date.parse(filter.slice(2)); + } + if (filter.startsWith('<')) { + return timestamp < Date.parse(filter.slice(1)); + } + return String(value ?? '').startsWith(filter); + }); +} + +function commitMatchesQualifiers(commitItem: Record<string, unknown>, query: ParsedSearchQuery): boolean { + const commit = asRecord(commitItem.commit); + const author = asRecord(commit.author); + const committer = asRecord(commit.committer); + const sha = String(commitItem.sha ?? '').toLowerCase(); + + const hashFilters = qualifierList(query, 'hash'); + if (hashFilters.length > 0 && !hashFilters.some(filter => sha.startsWith(filter))) { + return false; + } + + if (!personMatches(author, [...qualifierList(query, 'author'), ...qualifierList(query, 'author-name')])) { + return false; + } + if (!personMatches(committer, [...qualifierList(query, 'committer'), ...qualifierList(query, 'committer-name')])) { + return false; + } + if (!personMatches({ email: author.email }, qualifierList(query, 'author-email'))) { + return false; + } + if (!personMatches({ email: committer.email }, qualifierList(query, 'committer-email'))) { + return false; + } + if (!dateMatches(author.date, qualifierList(query, 'author-date'))) { + return false; + } + if (!dateMatches(committer.date, qualifierList(query, 'committer-date'))) { + return false; + } + + const mergeFilters = qualifierList(query, 'merge'); + if (mergeFilters.length > 0) { + const isMerge = Array.isArray(commitItem.parents) && commitItem.parents.length > 1; + if (mergeFilters.includes('true') && !isMerge) { + return false; + } + if (mergeFilters.includes('false') && isMerge) { + return false; + } + } + + return true; +} + +function commitSearchScore(commitItem: Record<string, unknown>, query: ParsedSearchQuery): number { + const commit = asRecord(commitItem.commit); + const author = asRecord(commit.author); + const committer = asRecord(commit.committer); + return scoreTerms(query.terms, [ + commitItem.sha, + commit.message, + author.name, + author.email, + committer.name, + committer.email, + ]); +} + +async function collectSearchCommits( + ctx: AgentContext, repos: SearchRepoEntry[], query: ParsedSearchQuery, url: URL, options: GitObjectOptions, +): Promise<SearchItem[]> { + const items: SearchItem[] = []; + + for (const repo of repos) { + const commitUrl = new URL(url); + commitUrl.search = ''; + commitUrl.searchParams.set('sha', commitSearchRef(repo, query)); + commitUrl.searchParams.set('per_page', '100'); + const response = await handleListRepoCommits(ctx, repo.ownerDid, repo.name, commitUrl, options); + if (response.status !== 200) { + continue; + } + + const commits = JSON.parse(response.body as string) as Record<string, unknown>[]; + for (const commit of commits) { + if (!commitMatchesQualifiers(commit, query)) { + continue; + } + const score = commitSearchScore(commit, query); + if (score > 0) { + items.push({ + ...commit, + repository: repo.response, + score, + }); + } + } + } + + return sortSearchItems(items, url, 'commits'); +} + +export async function handleSearchCommits(ctx: AgentContext, url: URL, options: GitObjectOptions = {}): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + const repos = await filterRepoEntries(ctx, await listLocalRepoSearchEntries(ctx, url), parsed); + const items = await collectSearchCommits(ctx, repos, parsed, url, options); + return searchEnvelope(items, items.length, url, '/search/commits'); +} + +// --------------------------------------------------------------------------- +// Label search +// --------------------------------------------------------------------------- + +function findRepoEntryById(entries: SearchRepoEntry[], value: string | null): SearchRepoEntry | JsonResponse { + if (!value || !/^\d+$/.test(value)) { + return jsonValidationError('Validation Failed: repository_id is required.'); + } + + const id = Number(value); + const entry = entries.find(candidate => Number(candidate.response.id) === id); + if (!entry) { + return jsonNotFound(`Repository with id '${value}' not found.`); + } + return entry; +} + +async function collectSearchLabels( + ctx: AgentContext, repo: SearchRepoEntry, terms: string[], url: URL, +): Promise<SearchItem[]> { + const labelsUrl = new URL(url); + labelsUrl.search = ''; + labelsUrl.searchParams.set('per_page', '100'); + const response = await handleListRepoLabels(ctx, repo.ownerDid, repo.name, labelsUrl); + if (response.status !== 200) { + return []; + } + + const labels = JSON.parse(response.body as string) as Record<string, unknown>[]; + const items = labels + .map((label) => { + const score = scoreTerms(terms, [label.name, label.description, label.color]); + return { + ...label, + created_at : null, + updated_at : null, + score, + }; + }) + .filter(item => item.score > 0); + + return sortSearchItems(items, url, 'labels'); +} + +export async function handleSearchLabels(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const terms = parseSearchTerms(url.searchParams.get('q')); + if ('status' in terms) { return terms; } + + const repo = findRepoEntryById(await listLocalRepoSearchEntries(ctx, url), url.searchParams.get('repository_id')); + if ('status' in repo) { return repo; } + + const items = await collectSearchLabels(ctx, repo, terms, url); + return searchEnvelope(items, items.length, url, '/search/labels'); +} + +// --------------------------------------------------------------------------- +// Topic search +// --------------------------------------------------------------------------- + +function displayTopicName(name: string): string { + return name + .split(/[._-]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function topicDescription(name: string): string { + return `Repositories tagged ${name}.`; +} + +function buildTopicSearchItem(entry: TopicSearchEntry, score: number): SearchItem { + return { + name : entry.name, + display_name : displayTopicName(entry.name), + short_description : topicDescription(entry.name), + description : topicDescription(entry.name), + created_by : null, + released : null, + created_at : entry.createdAt, + updated_at : entry.updatedAt, + featured : false, + curated : false, + score, + }; +} + +function localTopicEntries(repos: SearchRepoEntry[]): TopicSearchEntry[] { + const topics = new Map<string, TopicSearchEntry>(); + for (const repo of repos) { + for (const topic of repo.topics) { + const existing = topics.get(topic); + const createdAt = repo.repo.dateCreated ?? null; + const updatedAt = repo.repo.timestamp ?? repo.repo.dateCreated ?? null; + if (!existing) { + topics.set(topic, { name: topic, createdAt, updatedAt }); + continue; + } + if (createdAt && (!existing.createdAt || createdAt.localeCompare(existing.createdAt) < 0)) { + existing.createdAt = createdAt; + } + if (updatedAt && (!existing.updatedAt || updatedAt.localeCompare(existing.updatedAt) > 0)) { + existing.updatedAt = updatedAt; + } + } + } + return [...topics.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function handleSearchTopics(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + if (hasQualifier(parsed, 'is', 'featured') || hasQualifier(parsed, 'is', 'curated')) { + return searchEnvelope([], 0, url, '/search/topics'); + } + + const items = localTopicEntries(await listLocalRepoSearchEntries(ctx, url)) + .map((entry) => { + const score = scoreTerms(parsed.terms, [ + entry.name, + displayTopicName(entry.name), + topicDescription(entry.name), + ]); + return buildTopicSearchItem(entry, score); + }) + .filter(item => item.score > 0); + + const sorted = sortSearchItems(items, url, 'topics'); + return searchEnvelope(sorted, sorted.length, url, '/search/topics'); +} + +// --------------------------------------------------------------------------- +// User search +// --------------------------------------------------------------------------- + +export async function handleSearchUsers(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const parsed = parseSearchQuery(url.searchParams.get('q')); + if ('status' in parsed) { return parsed; } + + const baseUrl = buildApiUrl(url); + const allUsers = await collectVisibleUserDids(ctx); + const items = allUsers + .map((did) => { + const profile = buildUserProfile(did, baseUrl); + const score = scoreTerms(parsed.terms, [did]); + return { ...profile, score }; + }) + .filter(item => item.score > 0); + const sorted = sortSearchItems(items, url, 'users'); + return searchEnvelope(sorted, sorted.length, url, '/search/users'); +} diff --git a/src/github-shim/server.ts b/src/github-shim/server.ts index 6276e34..400de59 100644 --- a/src/github-shim/server.ts +++ b/src/github-shim/server.ts @@ -7,45 +7,1130 @@ * systems) to interact with any DWN-enabled git forge. * * Read endpoints (GET): + * GET / API root links + * GET /meta API metadata + * GET /versions Supported API versions + * GET /zen Status phrase + * GET /rate_limit Rate limit status + * GET /emojis Emoji map + * GET /gists Authenticated user's gists + * GET /gists/public Public gists visible to local actor + * GET /gists/starred Authenticated user's starred gists + * GET /gists/:gist_id Gist detail + * GET /gists/:gist_id/:sha Gist revision detail + * GET /gists/:gist_id/commits Gist commits + * GET /gists/:gist_id/comments Gist comments + * GET /gists/:gist_id/comments/:comment_id Gist comment detail + * GET /gists/:gist_id/forks Gist forks + * GET /gists/:gist_id/raw/:filename Raw gist file content + * GET /gists/:gist_id/star Check gist star + * GET /gitignore/templates Gitignore templates + * GET /gitignore/templates/:name Gitignore template + * GET /licenses Common licenses + * GET /licenses/:license License detail + * GET /events Public activity events + * GET /repositories Public repositories + * GET /networks/:did/:repo/events Network repository events * GET /repos/:did/:repo Repository info + * GET /repos/:did/:repo/events Repository events + * GET /repos/:did/:repo/activity Repository activity history + * GET /repos/:did/:repo/forks List repository forks + * GET /repos/:did/:repo/readme Repository README + * GET /repos/:did/:repo/readme/:dir Repository README for a directory + * GET /repos/:did/:repo/license Repository license + * GET /repos/:did/:repo/contents[/path] Repository git tree / metadata contents + * GET /repos/:did/:repo/raw/:ref/:path Repository raw file content + * GET /repos/:did/:repo/tarball[/:ref] Repository tar archive + * GET /repos/:did/:repo/zipball[/:ref] Repository zip archive + * GET /repos/:did/:repo/branches List branches + * GET /repos/:did/:repo/branches/:branch Branch detail + * GET /repos/:did/:repo/branches/:branch/protection Branch protection + * GET /repos/:did/:repo/branches/:branch/protection/required_status_checks Status check protection + * GET /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Status check contexts + * GET /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Pull request review protection + * GET /repos/:did/:repo/tags List tags + * GET /repos/:did/:repo/teams Repository teams + * GET /repos/:did/:repo/git/ref/:ref Git ref detail + * GET /repos/:did/:repo/git/matching-refs/:ref Matching git refs + * GET /repos/:did/:repo/git/blobs/:sha Git blob object + * GET /repos/:did/:repo/git/trees/:sha Git tree object + * GET /repos/:did/:repo/git/commits/:sha Low-level git commit object + * GET /repos/:did/:repo/git/tags/:sha Low-level git tag object + * GET /repos/:did/:repo/community/profile Community profile metrics + * GET /repos/:did/:repo/contributors List repository contributors + * GET /repos/:did/:repo/commits List repository commits + * GET /repos/:did/:repo/commits/:ref/comments Commit comments for commit + * GET /repos/:did/:repo/commits/:ref Repository commit object + * GET /repos/:did/:repo/comments List repository commit comments + * GET /repos/:did/:repo/comments/:id Commit comment detail + * GET /repos/:did/:repo/comments/:id/reactions Commit comment reactions + * GET /repos/:did/:repo/compare/:base...:head Compare commits + * GET /repos/:did/:repo/compare/:base...:head.diff Compare commit diff + * GET /repos/:did/:repo/compare/:base...:head.patch Compare commit patch + * GET /repos/:did/:repo/stats/code_frequency Weekly code frequency stats + * GET /repos/:did/:repo/stats/commit_activity Weekly commit activity stats + * GET /repos/:did/:repo/stats/contributors Contributor activity stats + * GET /repos/:did/:repo/stats/participation Weekly participation stats + * GET /repos/:did/:repo/stats/punch_card Hourly commit punch card stats + * GET /repos/:did/:repo/traffic/clones Repository clone traffic + * GET /repos/:did/:repo/traffic/popular/paths Popular repository paths + * GET /repos/:did/:repo/traffic/popular/referrers Popular repository referrers + * GET /repos/:did/:repo/traffic/views Repository view traffic + * GET /repos/:did/:repo/topics Repository topics + * GET /repos/:did/:repo/languages Repository languages + * GET /repos/:did/:repo/issue-types Repository issue types + * GET /repos/:did/:repo/properties/values Repository custom property values + * GET /repos/:did/:repo/codeowners/errors Repository CODEOWNERS syntax errors + * GET /repos/:did/:repo/hash-algorithm Repository git hash algorithm + * GET /repos/:did/:repo/attestations/:subject_digest Repository artifact attestations + * GET /repos/:did/:repo/keys List deploy keys + * GET /repos/:did/:repo/keys/:key_id Deploy key detail + * GET /repos/:did/:repo/autolinks List repository autolinks + * GET /repos/:did/:repo/autolinks/:autolink_id Repository autolink detail + * GET /repos/:did/:repo/interaction-limits Repository interaction restrictions + * GET /repos/:did/:repo/vulnerability-alerts Repository vulnerability alerts status + * GET /repos/:did/:repo/automated-security-fixes Dependabot security updates status + * GET /repos/:did/:repo/immutable-releases Repository immutable releases status + * GET /repos/:did/:repo/private-vulnerability-reporting Private vulnerability reporting status + * GET /repos/:did/:repo/security-advisories Repository security advisories + * GET /repos/:did/:repo/security-advisories/:ghsa_id Repository security advisory + * GET /repos/:did/:repo/secret-scanning/alerts Repository secret scanning alerts + * GET /repos/:did/:repo/secret-scanning/alerts/:alert_number Repository secret scanning alert + * GET /repos/:did/:repo/secret-scanning/alerts/:alert_number/locations Repository secret scanning alert locations + * GET /repos/:did/:repo/secret-scanning/scan-history Repository secret scanning scan history + * GET /repos/:did/:repo/code-scanning/alerts Repository code scanning alerts + * GET /repos/:did/:repo/code-scanning/alerts/:alert_number Repository code scanning alert + * GET /repos/:did/:repo/code-scanning/alerts/:alert_number/instances Repository code scanning alert instances + * GET /repos/:did/:repo/dependabot/alerts Repository Dependabot alerts + * GET /repos/:did/:repo/dependabot/alerts/:alert_number Repository Dependabot alert + * GET /repos/:did/:repo/dependency-graph/sbom Repository SPDX SBOM + * GET /repos/:did/:repo/dependency-graph/sbom/generate-report Request repository SBOM generation + * GET /repos/:did/:repo/dependency-graph/sbom/fetch-report/:sbom_uuid Fetch generated repository SBOM report + * GET /repos/:did/:repo/rules/branches/:branch Active branch rules + * GET /repos/:did/:repo/rulesets/rule-suites Repository rule suites + * GET /repos/:did/:repo/rulesets/rule-suites/:rule_suite_id Repository rule suite + * GET /repos/:did/:repo/rulesets Repository rulesets + * GET /repos/:did/:repo/rulesets/:ruleset_id Repository ruleset detail + * GET /repos/:did/:repo/rulesets/:ruleset_id/history Repository ruleset history + * GET /repos/:did/:repo/rulesets/:ruleset_id/history/:version_id Repository ruleset version + * GET /repos/:did/:repo/assignees List assignable users + * GET /repos/:did/:repo/assignees/:did Check assignable user + * GET /repos/:did/:repo/collaborators List collaborators + * GET /repos/:did/:repo/collaborators/:did Check collaborator + * GET /repos/:did/:repo/collaborators/:did/permission Collaborator permission + * GET /repos/:did/:repo/commits/:ref/status Combined commit status + * GET /repos/:did/:repo/commits/:ref/statuses Commit statuses + * GET /repos/:did/:repo/commits/:ref/check-suites Check suites for ref + * GET /repos/:did/:repo/commits/:ref/check-runs Check runs for ref + * GET /repos/:did/:repo/check-suites/:id Check suite detail + * GET /repos/:did/:repo/check-suites/:id/check-runs Check runs in suite + * GET /repos/:did/:repo/check-runs/:id Check run detail + * GET /repos/:did/:repo/check-runs/:id/annotations Check run annotations + * GET /repos/:did/:repo/actions/artifacts List workflow artifacts + * GET /repos/:did/:repo/actions/artifacts/:artifact_id Artifact detail + * GET /repos/:did/:repo/actions/artifacts/:artifact_id/:archive_format Download artifact + * GET /repos/:did/:repo/actions/cache/retention-limit Repository Actions cache retention limit + * GET /repos/:did/:repo/actions/cache/storage-limit Repository Actions cache storage limit + * GET /repos/:did/:repo/actions/cache/usage Repository Actions cache usage + * GET /repos/:did/:repo/actions/caches Repository Actions caches + * GET /repos/:did/:repo/actions/permissions Repository Actions permissions + * GET /repos/:did/:repo/actions/permissions/selected-actions Repository allowed Actions settings + * GET /repos/:did/:repo/actions/permissions/workflow Repository default workflow permissions + * GET /repos/:did/:repo/actions/secrets Repository Actions secrets + * GET /repos/:did/:repo/actions/secrets/public-key Repository Actions secrets public key + * GET /repos/:did/:repo/actions/secrets/:name Repository Actions secret + * GET /repos/:did/:repo/actions/variables Repository Actions variables + * GET /repos/:did/:repo/actions/variables/:name Repository Actions variable + * GET /repos/:did/:repo/actions/workflows List workflows + * GET /repos/:did/:repo/actions/workflows/:workflow_id Workflow detail + * GET /repos/:did/:repo/actions/workflows/:workflow_id/runs Workflow runs for workflow + * GET /repos/:did/:repo/actions/workflows/:workflow_id/timing Workflow usage + * GET /repos/:did/:repo/actions/runs List workflow runs + * GET /repos/:did/:repo/actions/runs/:run_id Workflow run detail + * GET /repos/:did/:repo/actions/runs/:run_id/attempts/:attempt_number Workflow run attempt + * GET /repos/:did/:repo/actions/runs/:run_id/attempts/:attempt_number/logs Workflow run attempt logs + * GET /repos/:did/:repo/actions/runs/:run_id/logs Download workflow run logs + * GET /repos/:did/:repo/actions/runs/:run_id/artifacts Workflow run artifacts + * GET /repos/:did/:repo/actions/runs/:run_id/jobs Workflow run jobs + * GET /repos/:did/:repo/actions/runs/:run_id/timing Workflow run usage + * GET /repos/:did/:repo/actions/jobs/:job_id/logs Download workflow job logs + * GET /repos/:did/:repo/actions/jobs/:job_id Workflow job detail + * GET /repos/:did/:repo/stargazers Repository stargazers + * GET /repos/:did/:repo/subscribers Repository watchers/subscribers + * GET /repos/:did/:repo/subscription Authenticated repository subscription + * GET /repos/:did/:repo/hooks Repository webhooks + * GET /repos/:did/:repo/hooks/:id Repository webhook + * GET /repos/:did/:repo/hooks/:id/config Repository webhook configuration + * GET /repos/:did/:repo/hooks/:id/deliveries Repository webhook deliveries + * GET /repos/:did/:repo/hooks/:id/deliveries/:delivery_id Repository webhook delivery + * GET /repos/:did/:repo/labels Repository labels + * GET /repos/:did/:repo/labels/:name Repository label detail + * GET /repos/:did/:repo/milestones Repository milestones + * GET /repos/:did/:repo/milestones/:number Milestone detail + * GET /repos/:did/:repo/milestones/:number/labels Labels for issues in milestone + * GET /issues Assigned issues across visible repositories + * GET /user/issues Authenticated user's issues + * GET /orgs/:org/issues Organization issues * GET /repos/:did/:repo/issues List issues + * GET /repos/:did/:repo/issues/events List repository issue events + * GET /repos/:did/:repo/issues/events/:id Issue event detail + * GET /repos/:did/:repo/issues/comments List repository issue comments + * GET /repos/:did/:repo/issues/comments/:id Issue comment detail + * GET /repos/:did/:repo/issues/comments/:id/reactions Issue comment reactions * GET /repos/:did/:repo/issues/:number Issue detail * GET /repos/:did/:repo/issues/:number/comments Issue comments + * GET /repos/:did/:repo/issues/:number/events Issue events + * GET /repos/:did/:repo/issues/:number/timeline Issue timeline + * GET /repos/:did/:repo/issues/:number/dependencies/blocked_by Issue dependencies blocking the issue + * GET /repos/:did/:repo/issues/:number/dependencies/blocking Issue dependencies blocked by the issue + * GET /repos/:did/:repo/issues/:number/parent Issue parent + * GET /repos/:did/:repo/issues/:number/sub_issues Issue sub-issues + * GET /repos/:did/:repo/issues/:number/issue-field-values Issue field values + * GET /repos/:did/:repo/issues/:number/reactions Issue reactions + * GET /repos/:did/:repo/issues/:number/labels Issue labels * GET /repos/:did/:repo/pulls List pull requests * GET /repos/:did/:repo/pulls/:number Pull request detail + * GET /repos/:did/:repo/pulls/:number.diff Pull request diff + * GET /repos/:did/:repo/pulls/:number.patch Pull request patch + * GET /repos/:did/:repo/pulls/:number/commits Pull request commits + * GET /repos/:did/:repo/pulls/:number/comments Pull request review comments * GET /repos/:did/:repo/pulls/:number/files Pull request files + * GET /repos/:did/:repo/pulls/:number/merge Check if pull request has been merged + * GET /repos/:did/:repo/pulls/:number/requested_reviewers Pull request review requests * GET /repos/:did/:repo/pulls/:number/reviews Pull request reviews + * GET /repos/:did/:repo/pulls/:number/reviews/:id Pull request review + * GET /repos/:did/:repo/pulls/:number/reviews/:id/comments Pull request review comments + * GET /repos/:did/:repo/pulls/comments Repository pull review comments + * GET /repos/:did/:repo/pulls/comments/:id Pull request review comment + * GET /repos/:did/:repo/pulls/comments/:id/reactions Pull request review comment reactions * GET /repos/:did/:repo/releases List releases + * GET /repos/:did/:repo/releases/latest Latest published full release + * GET /repos/:did/:repo/releases/:id Release detail + * GET /repos/:did/:repo/releases/:id/reactions Release reactions + * GET /repos/:did/:repo/releases/:id/assets Release assets + * GET /repos/:did/:repo/releases/assets/:id Release asset metadata + * GET /repos/:did/:repo/releases/assets/:id/download Release asset bytes + * GET /repos/:did/:repo/releases/download/:tag/:asset Release asset download * GET /repos/:did/:repo/releases/tags/:tag Release by tag + * GET /repos/:did/:repo/environments List deployment environments + * GET /repos/:did/:repo/environments/:name Deployment environment detail + * GET /repos/:did/:repo/environments/:name/secrets Deployment environment secrets + * GET /repos/:did/:repo/environments/:name/secrets/public-key Deployment environment secrets public key + * GET /repos/:did/:repo/environments/:name/secrets/:secret_name Deployment environment secret + * GET /repos/:did/:repo/environments/:name/variables Deployment environment variables + * GET /repos/:did/:repo/environments/:name/variables/:variable_name Deployment environment variable + * GET /repos/:did/:repo/deployments List deployments + * GET /repos/:did/:repo/deployments/:id Deployment detail + * GET /repos/:did/:repo/deployments/:id/statuses Deployment statuses + * GET /repos/:did/:repo/deployments/:id/statuses/:status_id Deployment status + * GET /repos/:did/:repo/pages GitHub Pages site + * GET /repos/:did/:repo/pages/builds GitHub Pages builds + * GET /repos/:did/:repo/pages/builds/latest Latest GitHub Pages build + * GET /repos/:did/:repo/pages/builds/:build_id GitHub Pages build detail + * GET /repos/:did/:repo/pages/deployments/:id GitHub Pages deployment status + * GET /repos/:did/:repo/pages/deployments/:id/status GitHub Pages deployment status URL + * GET /repos/:did/:repo/pages/health GitHub Pages DNS health check + * GET /notifications Authenticated user's notifications + * GET /notifications/threads/:id Notification thread + * GET /notifications/threads/:id/subscription Notification thread subscription + * GET /organizations Public organizations + * GET /orgs/:org Organization profile + * GET /orgs/:org/members Organization members + * GET /orgs/:org/members/:did Check organization membership + * GET /orgs/:org/memberships/:did Organization membership detail + * GET /orgs/:org/failed_invitations Failed organization invitations + * GET /orgs/:org/invitations Pending organization invitations + * GET /orgs/:org/invitations/:id/teams Organization invitation teams + * GET /orgs/:org/blocks Organization blocked users + * GET /orgs/:org/blocks/:username Check organization blocked user + * GET /orgs/:org/hooks Organization webhooks + * GET /orgs/:org/hooks/:id Organization webhook + * GET /orgs/:org/hooks/:id/config Organization webhook configuration + * GET /orgs/:org/hooks/:id/deliveries Organization webhook deliveries + * GET /orgs/:org/hooks/:id/deliveries/:delivery_id Organization webhook delivery + * GET /orgs/:org/properties/schema Organization custom property schema + * GET /orgs/:org/properties/schema/:property Organization custom property + * GET /orgs/:org/properties/values Organization repository custom property values + * GET /orgs/:org/issue-fields Organization issue fields + * GET /orgs/:org/issue-types Organization issue types + * GET /orgs/:org/outside_collaborators Outside collaborators + * GET /orgs/:org/public_members Public organization members + * GET /orgs/:org/public_members/:did Check public organization membership + * GET /orgs/:org/repos Organization repositories + * GET /orgs/:org/code-scanning/alerts Organization code scanning alerts + * GET /orgs/:org/dependabot/alerts Organization Dependabot alerts + * GET /orgs/:org/secret-scanning/alerts Organization secret scanning alerts + * GET /orgs/:org/security-advisories Organization repository security advisories + * GET /orgs/:org/teams Organization teams + * GET /orgs/:org/teams/:team_slug Organization team + * GET /orgs/:org/teams/:team_slug/teams Child organization teams + * GET /orgs/:org/teams/:team_slug/invitations Pending team invitations + * GET /orgs/:org/teams/:team_slug/members Organization team members + * GET /orgs/:org/teams/:team_slug/members/:did Check team membership + * GET /orgs/:org/teams/:team_slug/memberships/:did Get team membership + * GET /orgs/:org/teams/:team_slug/repos Team repositories + * GET /orgs/:org/teams/:team_slug/repos/:did/:repo Check team repository permission + * GET /organizations/:org_id/team/:team_id Organization team by numeric ID + * GET /organizations/:org_id/team/:team_id/teams Child organization teams by numeric ID + * GET /organizations/:org_id/team/:team_id/invitations Pending team invitations by numeric ID + * GET /organizations/:org_id/team/:team_id/members Organization team members by numeric ID + * GET /organizations/:org_id/team/:team_id/memberships/:did Get team membership by numeric ID + * GET /organizations/:org_id/team/:team_id/repos Team repositories by numeric ID + * GET /organizations/:org_id/team/:team_id/repos/:did/:repo Check team repository permission by numeric ID + * GET /teams/:team_id Legacy team by numeric ID + * GET /teams/:team_id/teams Legacy child organization teams by numeric ID + * GET /teams/:team_id/invitations Legacy pending team invitations by numeric ID + * GET /teams/:team_id/members Legacy organization team members by numeric ID + * GET /teams/:team_id/members/:did Legacy check team membership by numeric ID + * GET /teams/:team_id/memberships/:did Legacy get team membership by numeric ID + * GET /teams/:team_id/repos Legacy team repositories by numeric ID + * GET /teams/:team_id/repos/:did/:repo Legacy check team repository permission by numeric ID + * GET /repos/:did/:repo/notifications Repository notifications + * GET /search/code Search code + * GET /search/commits Search commits + * GET /search/issues Search issues and pull requests + * GET /search/labels Search repository labels + * GET /search/repositories Search repositories + * GET /search/topics Search topics + * GET /search/users Search users + * GET /user Authenticated user profile + * GET /user/repos Authenticated user's repositories + * GET /user/orgs Authenticated user's organizations + * GET /user/memberships/orgs Authenticated user's organization memberships + * GET /user/memberships/orgs/:org Authenticated user's organization membership + * GET /user/teams Authenticated user's teams + * GET /user/emails Authenticated user's email addresses + * GET /user/public_emails Authenticated user's public email addresses + * GET /user/gpg_keys Authenticated user's GPG keys + * GET /user/gpg_keys/:key_id Authenticated user's GPG key + * GET /user/social_accounts Authenticated user's social accounts + * GET /user/keys Authenticated user's SSH keys + * GET /user/keys/:key_id Authenticated user's SSH key + * GET /user/ssh_signing_keys Authenticated user's SSH signing keys + * GET /user/ssh_signing_keys/:key_id Authenticated user's SSH signing key + * GET /user/blocks Authenticated user's blocked users + * GET /user/blocks/:did Check authenticated user's blocked user + * GET /user/followers Authenticated user's followers + * GET /user/following Authenticated user's followed users + * GET /user/following/:did Check authenticated user's follow + * GET /user/starred Authenticated user's starred repos + * GET /user/starred/:did/:repo Check authenticated user's star + * GET /user/subscriptions Authenticated user's watched repos + * GET /user/:account_id User profile by numeric account ID + * GET /users/:did/followers User followers + * GET /users/:did/following User followed users + * GET /users/:did/following/:target_did Check if a user follows another + * GET /users/:did/events User events + * GET /users/:did/events/public User public events + * GET /users/:did/received_events User received events + * GET /users/:did/received_events/public User public received events + * GET /users/:did/repos User repositories + * GET /users/:did/gpg_keys User public GPG keys + * GET /users/:did/social_accounts User public social accounts + * GET /users/:did/keys User public SSH keys + * GET /users/:did/ssh_signing_keys User public SSH signing keys + * GET /users/:did/gists User public gists + * GET /users/:did/orgs User public organization memberships + * GET /users/:did/starred User's starred repos + * GET /users/:did/subscriptions User's watched repos + * GET /users/:did/hovercard User hovercard contexts + * GET /users/:did/attestations/:subject_digest User artifact attestations * GET /users/:did User profile + * GET /users Public users visible to the local actor * - * Write endpoints (POST/PATCH/PUT): + * Write endpoints (POST/PATCH/PUT/DELETE): + * POST /markdown Render Markdown + * POST /markdown/raw Render raw Markdown + * POST /gists Create a gist + * POST /gists/:gist_id/comments Create a gist comment + * POST /gists/:gist_id/forks Fork a gist + * PATCH /gists/:gist_id Update a gist + * PATCH /gists/:gist_id/comments/:comment_id Update a gist comment + * PUT /gists/:gist_id/star Star a gist + * DELETE /gists/:gist_id Delete a gist + * DELETE /gists/:gist_id/comments/:comment_id Delete a gist comment + * DELETE /gists/:gist_id/star Unstar a gist + * PATCH /user Update authenticated user profile + * POST /users/:did/attestations/bulk-list List user artifact attestations by digests + * DELETE /users/:did/attestations Delete user artifact attestations in bulk + * DELETE /users/:did/attestations/digest/:subject_digest Delete user artifact attestations by digest + * DELETE /users/:did/attestations/:attestation_id Delete user artifact attestation by ID + * PATCH /repos/:did/:repo Update repository metadata + * DELETE /repos/:did/:repo Delete repository + * POST /repos/:did/:repo/forks Create a fork + * POST /repos/:did/:repo/generate Create repository from template + * POST /repos/:did/:repo/transfer Request repository transfer + * PUT /repos/:did/:repo/contents/:path Create or update file contents + * DELETE /repos/:did/:repo/contents/:path Delete file contents + * POST /repos/:did/:repo/git/blobs Create git blob object + * POST /repos/:did/:repo/git/trees Create git tree object + * POST /repos/:did/:repo/git/commits Create git commit object + * POST /repos/:did/:repo/git/tags Create git tag object + * POST /repos/:did/:repo/git/refs Create git reference + * PATCH /repos/:did/:repo/git/refs/:ref Update git reference + * DELETE /repos/:did/:repo/git/refs/:ref Delete git reference + * POST /repos/:did/:repo/commits/:sha/comments Create commit comment + * PATCH /repos/:did/:repo/comments/:id Update commit comment + * DELETE /repos/:did/:repo/comments/:id Delete commit comment + * POST /repos/:did/:repo/comments/:id/reactions Create commit comment reaction + * DELETE /repos/:did/:repo/comments/:id/reactions/:reaction_id Delete commit comment reaction * POST /repos/:did/:repo/issues Create issue * PATCH /repos/:did/:repo/issues/:number Update issue * POST /repos/:did/:repo/issues/:number/comments Create issue comment + * PATCH /repos/:did/:repo/issues/comments/:id Update issue comment + * DELETE /repos/:did/:repo/issues/comments/:id Delete issue comment + * PUT /repos/:did/:repo/issues/comments/:id/pin Pin issue comment + * DELETE /repos/:did/:repo/issues/comments/:id/pin Unpin issue comment + * POST /repos/:did/:repo/issues/comments/:id/reactions Create issue comment reaction + * DELETE /repos/:did/:repo/issues/comments/:id/reactions/:reaction_id Delete issue comment reaction + * POST /repos/:did/:repo/issues/:number/reactions Create issue reaction + * DELETE /repos/:did/:repo/issues/:number/reactions/:reaction_id Delete issue reaction + * POST /repos/:did/:repo/issues/:number/dependencies/blocked_by Add issue dependency + * DELETE /repos/:did/:repo/issues/:number/dependencies/blocked_by/:issue_id Remove issue dependency + * POST /repos/:did/:repo/issues/:number/sub_issues Add sub-issue + * DELETE /repos/:did/:repo/issues/:number/sub_issue Remove sub-issue + * PATCH /repos/:did/:repo/issues/:number/sub_issues/priority Reprioritize sub-issue + * POST /repos/:did/:repo/issues/:number/issue-field-values Add issue field values + * PUT /repos/:did/:repo/issues/:number/issue-field-values Set issue field values + * DELETE /repos/:did/:repo/issues/:number/issue-field-values/:issue_field_id Delete issue field value + * POST /repos/:did/:repo/issues/:number/labels Add issue labels + * PUT /repos/:did/:repo/issues/:number/labels Replace issue labels + * DELETE /repos/:did/:repo/issues/:number/labels Remove all issue labels + * DELETE /repos/:did/:repo/issues/:number/labels/:name Remove issue label + * POST /repos/:did/:repo/labels Create repository label + * PATCH /repos/:did/:repo/labels/:name Update repository label + * DELETE /repos/:did/:repo/labels/:name Delete repository label + * POST /repos/:did/:repo/milestones Create milestone + * PATCH /repos/:did/:repo/milestones/:number Update milestone + * DELETE /repos/:did/:repo/milestones/:number Delete milestone + * PUT /repos/:did/:repo/issues/:number/lock Lock issue + * DELETE /repos/:did/:repo/issues/:number/lock Unlock issue + * POST /repos/:did/:repo/issues/:number/assignees Add issue assignees + * DELETE /repos/:did/:repo/issues/:number/assignees Remove issue assignees * POST /repos/:did/:repo/pulls Create pull request * PATCH /repos/:did/:repo/pulls/:number Update pull request * PUT /repos/:did/:repo/pulls/:number/merge Merge pull request + * PUT /repos/:did/:repo/pulls/:number/update-branch Update pull request branch + * POST /repos/:did/:repo/pulls/:number/comments Create pull review comment + * POST /repos/:did/:repo/pulls/:number/requested_reviewers Request pull reviewers + * DELETE /repos/:did/:repo/pulls/:number/requested_reviewers Remove pull review requests + * PATCH /repos/:did/:repo/pulls/comments/:id Update pull review comment + * DELETE /repos/:did/:repo/pulls/comments/:id Delete pull review comment + * POST /repos/:did/:repo/pulls/comments/:id/reactions Create pull review comment reaction + * DELETE /repos/:did/:repo/pulls/comments/:id/reactions/:reaction_id Delete pull review comment reaction + * POST /repos/:did/:repo/pulls/:number/comments/:id/replies Create pull review comment reply * POST /repos/:did/:repo/pulls/:number/reviews Create pull review + * PUT/PATCH /repos/:did/:repo/pulls/:number/reviews/:id Update pull review + * DELETE /repos/:did/:repo/pulls/:number/reviews/:id Delete pending pull review + * PUT /repos/:did/:repo/pulls/:number/reviews/:id/dismissals Dismiss pull review + * POST /repos/:did/:repo/pulls/:number/reviews/:id/events Submit pull review * POST /repos/:did/:repo/releases Create release + * POST /repos/:did/:repo/releases/generate-notes Generate release notes + * POST /repos/:did/:repo/releases/:id/assets Upload release asset + * PATCH /repos/:did/:repo/releases/:id Update release + * DELETE /repos/:did/:repo/releases/:id Delete release + * POST /repos/:did/:repo/releases/:id/reactions Create release reaction + * DELETE /repos/:did/:repo/releases/:id/reactions/:reaction_id Delete release reaction + * PATCH /repos/:did/:repo/releases/assets/:id Update release asset + * DELETE /repos/:did/:repo/releases/assets/:id Delete release asset + * PUT /repos/:did/:repo/environments/:name Create or update deployment environment + * DELETE /repos/:did/:repo/environments/:name Delete deployment environment + * PUT /repos/:did/:repo/environments/:name/secrets/:secret_name Create/update deployment environment secret + * DELETE /repos/:did/:repo/environments/:name/secrets/:secret_name Delete deployment environment secret + * POST /repos/:did/:repo/environments/:name/variables Create deployment environment variable + * PATCH /repos/:did/:repo/environments/:name/variables/:variable_name Update deployment environment variable + * DELETE /repos/:did/:repo/environments/:name/variables/:variable_name Delete deployment environment variable + * POST /repos/:did/:repo/deployments Create deployment + * DELETE /repos/:did/:repo/deployments/:id Delete deployment + * POST /repos/:did/:repo/deployments/:id/statuses Create deployment status + * POST /repos/:did/:repo/pages Create GitHub Pages site + * PUT /repos/:did/:repo/pages Update GitHub Pages site + * DELETE /repos/:did/:repo/pages Delete GitHub Pages site + * POST /repos/:did/:repo/pages/builds Request GitHub Pages build + * POST /repos/:did/:repo/pages/deployments Create GitHub Pages deployment + * POST /repos/:did/:repo/pages/deployments/:id/cancel Cancel GitHub Pages deployment + * POST /repos/:did/:repo/statuses/:sha Create commit status + * POST /repos/:did/:repo/check-suites Create check suite + * POST /repos/:did/:repo/check-suites/:id/rerequest Rerequest check suite + * POST /repos/:did/:repo/check-runs Create check run + * PATCH /repos/:did/:repo/check-runs/:id Update check run + * POST /repos/:did/:repo/check-runs/:id/rerequest Rerequest check run + * PUT /repos/:did/:repo/actions/cache/retention-limit Set repository Actions cache retention limit + * PUT /repos/:did/:repo/actions/cache/storage-limit Set repository Actions cache storage limit + * DELETE /repos/:did/:repo/actions/caches Delete repository Actions caches by key + * DELETE /repos/:did/:repo/actions/caches/:cache_id Delete repository Actions cache by ID + * PUT /repos/:did/:repo/actions/permissions Set repository Actions permissions + * PUT /repos/:did/:repo/actions/permissions/selected-actions Set repository allowed Actions settings + * PUT /repos/:did/:repo/actions/permissions/workflow Set repository default workflow permissions + * DELETE /repos/:did/:repo/actions/artifacts/:artifact_id Delete artifact + * PUT /repos/:did/:repo/actions/secrets/:name Create/update repository Actions secret + * DELETE /repos/:did/:repo/actions/secrets/:name Delete repository Actions secret + * POST /repos/:did/:repo/actions/variables Create repository Actions variable + * PATCH /repos/:did/:repo/actions/variables/:name Update repository Actions variable + * DELETE /repos/:did/:repo/actions/variables/:name Delete repository Actions variable + * PUT /repos/:did/:repo/actions/workflows/:workflow_id/disable Disable workflow + * POST /repos/:did/:repo/actions/workflows/:workflow_id/dispatches Dispatch workflow + * PUT /repos/:did/:repo/actions/workflows/:workflow_id/enable Enable workflow + * POST /repos/:did/:repo/actions/runs/:run_id/rerun Re-run workflow run + * POST /repos/:did/:repo/actions/runs/:run_id/rerun-failed-jobs Re-run failed workflow jobs + * POST /repos/:did/:repo/actions/runs/:run_id/cancel Cancel workflow run + * POST /repos/:did/:repo/actions/runs/:run_id/force-cancel Force cancel workflow run + * DELETE /repos/:did/:repo/actions/runs/:run_id Delete workflow run + * DELETE /repos/:did/:repo/actions/runs/:run_id/logs Delete workflow run logs + * POST /repos/:did/:repo/actions/jobs/:job_id/rerun Re-run workflow job + * PUT /repos/:did/:repo/subscription Set repository subscription + * DELETE /repos/:did/:repo/subscription Delete repository subscription + * PUT /repos/:did/:repo/branches/:branch/protection Update branch protection + * DELETE /repos/:did/:repo/branches/:branch/protection Delete branch protection + * PATCH /repos/:did/:repo/branches/:branch/protection/required_status_checks Update status check protection + * DELETE /repos/:did/:repo/branches/:branch/protection/required_status_checks Delete status check protection + * POST /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Add status check contexts + * PUT /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Set status check contexts + * DELETE /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Remove status check contexts + * PATCH /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Update pull request review protection + * DELETE /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Delete pull request review protection + * PUT /repos/:did/:repo/topics Replace topics + * POST /repos/:did/:repo/keys Create deploy key + * DELETE /repos/:did/:repo/keys/:key_id Delete deploy key + * POST /repos/:did/:repo/autolinks Create repository autolink + * DELETE /repos/:did/:repo/autolinks/:autolink_id Delete repository autolink + * PUT /repos/:did/:repo/interaction-limits Set repository interaction restrictions + * DELETE /repos/:did/:repo/interaction-limits Remove repository interaction restrictions + * PATCH /repos/:did/:repo/properties/values Update repository custom property values + * POST /repos/:did/:repo/dispatches Create repository dispatch event + * POST /repos/:did/:repo/attestations Create repository artifact attestation + * PUT /repos/:did/:repo/vulnerability-alerts Enable vulnerability alerts + * DELETE /repos/:did/:repo/vulnerability-alerts Disable vulnerability alerts + * PUT /repos/:did/:repo/automated-security-fixes Enable Dependabot security updates + * DELETE /repos/:did/:repo/automated-security-fixes Disable Dependabot security updates + * PUT /repos/:did/:repo/immutable-releases Enable immutable releases + * DELETE /repos/:did/:repo/immutable-releases Disable immutable releases + * PUT /repos/:did/:repo/private-vulnerability-reporting Enable private vulnerability reporting + * DELETE /repos/:did/:repo/private-vulnerability-reporting Disable private vulnerability reporting + * POST /repos/:did/:repo/security-advisories Create repository security advisory + * POST /repos/:did/:repo/security-advisories/reports Privately report security vulnerability + * PATCH /repos/:did/:repo/security-advisories/:ghsa_id Update repository security advisory + * POST /repos/:did/:repo/security-advisories/:ghsa_id/cve Request repository advisory CVE + * POST /repos/:did/:repo/security-advisories/:ghsa_id/forks Create temporary private fork + * POST /repos/:did/:repo/secret-scanning/push-protection-bypasses Create secret scanning push protection bypass + * PATCH /repos/:did/:repo/code-scanning/alerts/:alert_number Update repository code scanning alert + * PATCH /repos/:did/:repo/dependabot/alerts/:alert_number Update repository Dependabot alert + * PATCH /repos/:did/:repo/secret-scanning/alerts/:alert_number Update repository secret scanning alert + * POST /repos/:did/:repo/rulesets Create repository ruleset + * PUT /repos/:did/:repo/rulesets/:ruleset_id Update repository ruleset + * DELETE /repos/:did/:repo/rulesets/:ruleset_id Delete repository ruleset + * PUT /repos/:did/:repo/collaborators/:did Add collaborator + * DELETE /repos/:did/:repo/collaborators/:did Remove collaborator + * POST /repos/:did/:repo/hooks Create repository webhook + * PATCH /repos/:did/:repo/hooks/:id Update repository webhook + * DELETE /repos/:did/:repo/hooks/:id Delete repository webhook + * PATCH /repos/:did/:repo/hooks/:id/config Update repository webhook configuration + * POST /repos/:did/:repo/hooks/:id/deliveries/:delivery_id/attempts Redeliver repository webhook delivery + * POST /repos/:did/:repo/hooks/:id/pings Ping repository webhook + * POST /repos/:did/:repo/hooks/:id/tests Test repository webhook + * PUT /notifications Mark notifications as read + * PATCH /notifications/threads/:id Mark notification thread as read + * DELETE /notifications/threads/:id Mark notification thread as done + * PUT /notifications/threads/:id/subscription Set notification thread subscription + * DELETE /notifications/threads/:id/subscription Delete notification thread subscription + * PATCH /orgs/:org Update organization profile + * DELETE /orgs/:org/members/:did Remove organization member + * PUT /orgs/:org/memberships/:did Set organization membership + * DELETE /orgs/:org/memberships/:did Remove organization membership + * DELETE /orgs/:org/invitations/:id Cancel organization invitation + * PUT /orgs/:org/blocks/:username Block organization user + * DELETE /orgs/:org/blocks/:username Unblock organization user + * POST /orgs/:org/hooks Create organization webhook + * PATCH /orgs/:org/hooks/:id Update organization webhook + * DELETE /orgs/:org/hooks/:id Delete organization webhook + * PATCH /orgs/:org/hooks/:id/config Update organization webhook configuration + * POST /orgs/:org/hooks/:id/deliveries/:delivery_id/attempts Redeliver organization webhook delivery + * POST /orgs/:org/hooks/:id/pings Ping organization webhook + * PATCH /orgs/:org/properties/schema Create/update organization custom properties + * PUT /orgs/:org/properties/schema/:property Create/update organization custom property + * DELETE /orgs/:org/properties/schema/:property Delete organization custom property + * PATCH /orgs/:org/properties/values Update organization repository custom property values + * POST /orgs/:org/issue-fields Create organization issue field + * PATCH /orgs/:org/issue-fields/:issue_field_id Update organization issue field + * DELETE /orgs/:org/issue-fields/:issue_field_id Delete organization issue field + * POST /orgs/:org/issue-types Create organization issue type + * PUT /orgs/:org/issue-types/:issue_type_id Update organization issue type + * DELETE /orgs/:org/issue-types/:issue_type_id Delete organization issue type + * PUT /orgs/:org/outside_collaborators/:did Convert member to outside collaborator + * DELETE /orgs/:org/outside_collaborators/:did Remove outside collaborator + * PUT /orgs/:org/public_members/:did Set public organization membership + * DELETE /orgs/:org/public_members/:did Remove public organization membership + * POST /orgs/:org/repos Create organization repository + * POST /orgs/:org/teams Create organization team + * PATCH /orgs/:org/teams/:team_slug Update organization team + * DELETE /orgs/:org/teams/:team_slug Delete organization team + * PUT /orgs/:org/teams/:team_slug/repos/:did/:repo Add/update team repository permission + * DELETE /orgs/:org/teams/:team_slug/repos/:did/:repo Remove team repository permission + * PUT /orgs/:org/teams/:team_slug/memberships/:did Add organization team membership + * DELETE /orgs/:org/teams/:team_slug/memberships/:did Remove organization team membership + * PATCH /organizations/:org_id/team/:team_id Update organization team by numeric ID + * DELETE /organizations/:org_id/team/:team_id Delete organization team by numeric ID + * PUT /organizations/:org_id/team/:team_id/memberships/:did Add team membership by numeric ID + * DELETE /organizations/:org_id/team/:team_id/memberships/:did Remove team membership by numeric ID + * PUT /organizations/:org_id/team/:team_id/repos/:did/:repo Add/update team repository permission by numeric ID + * DELETE /organizations/:org_id/team/:team_id/repos/:did/:repo Remove team repository permission by numeric ID + * PATCH /teams/:team_id Legacy update organization team by numeric ID + * DELETE /teams/:team_id Legacy delete organization team by numeric ID + * PUT /teams/:team_id/members/:did Legacy add organization team membership by numeric ID + * DELETE /teams/:team_id/members/:did Legacy remove organization team membership by numeric ID + * PUT /teams/:team_id/memberships/:did Legacy add organization team membership by numeric ID + * DELETE /teams/:team_id/memberships/:did Legacy remove organization team membership by numeric ID + * PUT /teams/:team_id/repos/:did/:repo Legacy add/update team repository permission by numeric ID + * DELETE /teams/:team_id/repos/:did/:repo Legacy remove team repository permission by numeric ID + * PUT /repos/:did/:repo/notifications Mark repository notifications as read + * PATCH /user/memberships/orgs/:org Update authenticated organization membership + * PATCH /user/email/visibility Set primary email visibility + * POST /user/emails Add authenticated user's email addresses + * DELETE /user/emails Delete authenticated user's email addresses + * POST /user/gpg_keys Create authenticated user's GPG key + * DELETE /user/gpg_keys/:key_id Delete authenticated user's GPG key + * POST /user/social_accounts Add authenticated user's social accounts + * DELETE /user/social_accounts Delete authenticated user's social accounts + * POST /user/keys Create authenticated user's SSH key + * DELETE /user/keys/:key_id Delete authenticated user's SSH key + * POST /user/ssh_signing_keys Create authenticated user's SSH signing key + * DELETE /user/ssh_signing_keys/:key_id Delete authenticated user's SSH signing key + * POST /user/repos Create authenticated user's repository + * PUT /user/blocks/:did Block a user + * DELETE /user/blocks/:did Unblock a user + * PUT /user/following/:did Follow a user + * DELETE /user/following/:did Unfollow a user + * PUT /user/starred/:did/:repo Star repository + * DELETE /user/starred/:did/:repo Unstar repository * * @module */ import type { AgentContext } from '../cli/agent.js'; +import type { BodyMediaKind } from './body-media.js'; +import type { ContentMediaKind } from './contents.js'; +import type { GitObjectOptions } from './git-objects.js'; import type { JsonResponse } from './helpers.js'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; +import { handleAddAuthenticatedEmails } from './emails.js'; +import { handleAddAuthenticatedSocialAccounts } from './social-accounts.js'; +import { handleAddOrgTeamMembership } from './orgs.js'; +import { handleAddOrgTeamMembershipById } from './orgs.js'; +import { handleAddOrUpdateOrgTeamRepo } from './orgs.js'; +import { handleAddOrUpdateOrgTeamRepoById } from './orgs.js'; +import { handleAddOrUpdateTeamRepoById } from './orgs.js'; +import { handleAddTeamMembershipById } from './orgs.js'; +import { handleBlockOrgUser } from './orgs.js'; +import { handleBlockUser } from './follows.js'; +import { handleBulkDeleteUserAttestations } from './user-attestations.js'; +import { handleBulkListUserAttestations } from './user-attestations.js'; +import { handleCancelOrgInvitation } from './orgs.js'; +import { handleCancelPagesDeployment } from './pages.js'; +import { handleCancelWorkflowRun } from './actions.js'; +import { handleCheckBlockedUser } from './follows.js'; +import { handleCheckFollowing } from './follows.js'; +import { handleCheckGistStar } from './gists.js'; +import { handleCheckOrgBlockedUser } from './orgs.js'; +import { handleCheckOrgMember } from './orgs.js'; +import { handleCheckOrgTeamMember } from './orgs.js'; +import { handleCheckOrgTeamRepo } from './orgs.js'; +import { handleCheckOrgTeamRepoById } from './orgs.js'; +import { handleCheckPublicOrgMember } from './orgs.js'; +import { handleCheckTeamMemberById } from './orgs.js'; +import { handleCheckTeamRepoById } from './orgs.js'; +import { handleConvertOrgMemberToOutsideCollaborator } from './orgs.js'; +import { handleCreateAuthenticatedGpgKey } from './gpg-keys.js'; +import { handleCreateAuthenticatedSshKey } from './user-keys.js'; +import { handleCreateAuthenticatedSshSigningKey } from './user-keys.js'; +import { handleCreateCommitComment } from './commit-comments.js'; +import { handleCreateCommitCommentReaction } from './commit-comments.js'; +import { handleCreateDeployment } from './deployments.js'; +import { handleCreateDeploymentStatus } from './deployments.js'; +import { handleCreateEnvironmentVariable } from './actions.js'; +import { handleCreateFork } from './repos.js'; +import { handleCreateGist } from './gists.js'; +import { handleCreateGistComment } from './gists.js'; +import { handleCreateOrgIssueField } from './orgs.js'; +import { handleCreateOrgIssueType } from './orgs.js'; +import { handleCreateOrgRepo } from './orgs.js'; +import { handleCreateOrgTeam } from './orgs.js'; +import { handleCreateOrUpdateEnvironment } from './deployments.js'; +import { handleCreateOrUpdateEnvironmentSecret } from './actions.js'; +import { handleCreateOrUpdateRepositorySecret } from './actions.js'; +import { handleCreatePagesDeployment } from './pages.js'; +import { handleCreatePagesSite } from './pages.js'; +import { handleCreateRepositoryVariable } from './actions.js'; +import { handleCreateUserRepo } from './repos.js'; +import { handleCreateWorkflowDispatch } from './actions.js'; +import { handleDeleteActionsCacheById } from './actions.js'; +import { handleDeleteActionsCachesByKey } from './actions.js'; +import { handleDeleteArtifact } from './actions.js'; +import { handleDeleteAuthenticatedEmails } from './emails.js'; +import { handleDeleteAuthenticatedGpgKey } from './gpg-keys.js'; +import { handleDeleteAuthenticatedSocialAccounts } from './social-accounts.js'; +import { handleDeleteAuthenticatedSshKey } from './user-keys.js'; +import { handleDeleteAuthenticatedSshSigningKey } from './user-keys.js'; +import { handleDeleteCommitComment } from './commit-comments.js'; +import { handleDeleteCommitCommentReaction } from './commit-comments.js'; +import { handleDeleteDeployment } from './deployments.js'; +import { handleDeleteEnvironment } from './deployments.js'; +import { handleDeleteEnvironmentSecret } from './actions.js'; +import { handleDeleteEnvironmentVariable } from './actions.js'; +import { handleDeleteGist } from './gists.js'; +import { handleDeleteGistComment } from './gists.js'; +import { handleDeleteOrgCustomProperty } from './orgs.js'; +import { handleDeleteOrgIssueField } from './orgs.js'; +import { handleDeleteOrgIssueType } from './orgs.js'; +import { handleDeleteOrgTeam } from './orgs.js'; +import { handleDeleteOrgTeamById } from './orgs.js'; +import { handleDeletePagesSite } from './pages.js'; +import { handleDeleteRepo } from './repos.js'; +import { handleDeleteRepositorySecret } from './actions.js'; +import { handleDeleteRepositoryVariable } from './actions.js'; +import { handleDeleteTeamById } from './orgs.js'; +import { handleDeleteUserAttestationById } from './user-attestations.js'; +import { handleDeleteUserAttestationsBySubjectDigest } from './user-attestations.js'; +import { handleDeleteWorkflowRun } from './actions.js'; +import { handleDeleteWorkflowRunLogs } from './actions.js'; +import { handleDisableWorkflow } from './actions.js'; +import { handleDownloadArtifact } from './actions.js'; +import { handleDownloadWorkflowJobLogs } from './actions.js'; +import { handleDownloadWorkflowRunAttemptLogs } from './actions.js'; +import { handleDownloadWorkflowRunLogs } from './actions.js'; +import { handleEnableWorkflow } from './actions.js'; +import { handleExportDependencyGraphSbom } from './dependency-graph.js'; +import { handleFetchDependencyGraphSbom } from './dependency-graph.js'; +import { handleFollowUser } from './follows.js'; +import { handleForceCancelWorkflowRun } from './actions.js'; +import { handleForkGist } from './gists.js'; +import { handleGenerateDependencyGraphSbom } from './dependency-graph.js'; +import { handleGenerateRepoFromTemplate } from './repos.js'; +import { handleGetActionsCacheRetentionLimit } from './actions.js'; +import { handleGetActionsCacheStorageLimit } from './actions.js'; +import { handleGetActionsCacheUsage } from './actions.js'; +import { handleGetActionsPermissions } from './actions.js'; +import { handleGetActionsSelectedActions } from './actions.js'; +import { handleGetActionsWorkflowPermissions } from './actions.js'; +import { handleGetArtifact } from './actions.js'; +import { handleGetAuthenticatedGpgKey } from './gpg-keys.js'; +import { handleGetAuthenticatedOrgMembership } from './orgs.js'; +import { handleGetAuthenticatedSshKey } from './user-keys.js'; +import { handleGetAuthenticatedSshSigningKey } from './user-keys.js'; +import { handleGetAuthenticatedUser } from './users.js'; +import { handleGetCommitComment } from './commit-comments.js'; +import { handleGetCommunityProfile } from './metrics.js'; +import { handleGetDeployment } from './deployments.js'; +import { handleGetDeploymentStatus } from './deployments.js'; +import { handleGetEnvironment } from './deployments.js'; +import { handleGetEnvironmentSecret } from './actions.js'; +import { handleGetEnvironmentSecretsPublicKey } from './actions.js'; +import { handleGetEnvironmentVariable } from './actions.js'; +import { handleGetGist } from './gists.js'; +import { handleGetGistComment } from './gists.js'; +import { handleGetGistRawFile } from './gists.js'; +import { handleGetGistRevision } from './gists.js'; +import { handleGetLatestPagesBuild } from './pages.js'; +import { handleGetOrg } from './orgs.js'; +import { handleGetOrgCustomProperty } from './orgs.js'; +import { handleGetOrgMembership } from './orgs.js'; +import { handleGetOrgTeam } from './orgs.js'; +import { handleGetOrgTeamById } from './orgs.js'; +import { handleGetOrgTeamMembership } from './orgs.js'; +import { handleGetOrgTeamMembershipById } from './orgs.js'; +import { handleGetPagesBuild } from './pages.js'; +import { handleGetPagesDeploymentStatus } from './pages.js'; +import { handleGetPagesHealth } from './pages.js'; +import { handleGetPagesSite } from './pages.js'; import { handleGetRepo } from './repos.js'; +import { handleGetRepositorySecret } from './actions.js'; +import { handleGetRepositorySecretsPublicKey } from './actions.js'; +import { handleGetRepositoryVariable } from './actions.js'; +import { handleGetStatsCodeFrequency } from './metrics.js'; +import { handleGetStatsCommitActivity } from './metrics.js'; +import { handleGetStatsContributors } from './metrics.js'; +import { handleGetStatsParticipation } from './metrics.js'; +import { handleGetStatsPunchCard } from './metrics.js'; +import { handleGetTeamById } from './orgs.js'; +import { handleGetTeamMembershipById } from './orgs.js'; +import { handleGetTrafficClones } from './metrics.js'; +import { handleGetTrafficPopularPaths } from './metrics.js'; +import { handleGetTrafficPopularReferrers } from './metrics.js'; +import { handleGetTrafficViews } from './metrics.js'; import { handleGetUser } from './users.js'; +import { handleGetUserById } from './users.js'; +import { handleGetUserHovercard } from './users.js'; +import { handleGetWorkflow } from './actions.js'; +import { handleGetWorkflowJob } from './actions.js'; +import { handleGetWorkflowRun } from './actions.js'; +import { handleGetWorkflowRunAttempt } from './actions.js'; +import { handleGetWorkflowRunUsage } from './actions.js'; +import { handleGetWorkflowUsage } from './actions.js'; +import { handleListActionsCaches } from './actions.js'; +import { handleListArtifacts } from './actions.js'; +import { handleListAuthenticatedEmails } from './emails.js'; +import { handleListAuthenticatedGists } from './gists.js'; +import { handleListAuthenticatedGpgKeys } from './gpg-keys.js'; +import { handleListAuthenticatedOrgMemberships } from './orgs.js'; +import { handleListAuthenticatedOrgs } from './orgs.js'; +import { handleListAuthenticatedPublicEmails } from './emails.js'; +import { handleListAuthenticatedRepos } from './repos.js'; +import { handleListAuthenticatedSocialAccounts } from './social-accounts.js'; +import { handleListAuthenticatedSshKeys } from './user-keys.js'; +import { handleListAuthenticatedSshSigningKeys } from './user-keys.js'; +import { handleListAuthenticatedUserTeams } from './orgs.js'; +import { handleListBlockedUsers } from './follows.js'; +import { handleListCommitCommentReactions } from './commit-comments.js'; +import { handleListCommitComments } from './commit-comments.js'; +import { handleListCommitCommentsForSha } from './commit-comments.js'; +import { handleListDeployments } from './deployments.js'; +import { handleListDeploymentStatuses } from './deployments.js'; +import { handleListEnvironments } from './deployments.js'; +import { handleListEnvironmentSecrets } from './actions.js'; +import { handleListEnvironmentVariables } from './actions.js'; +import { handleListFailedOrgInvitations } from './orgs.js'; +import { handleListFollowers } from './follows.js'; +import { handleListFollowing } from './follows.js'; +import { handleListForks } from './repos.js'; +import { handleListGistComments } from './gists.js'; +import { handleListGistCommits } from './gists.js'; +import { handleListGistForks } from './gists.js'; +import { handleListOrganizations } from './orgs.js'; +import { handleListOrgBlockedUsers } from './orgs.js'; +import { handleListOrgCodeScanningAlerts } from './orgs.js'; +import { handleListOrgCustomProperties } from './orgs.js'; +import { handleListOrgCustomPropertyValues } from './orgs.js'; +import { handleListOrgDependabotAlerts } from './orgs.js'; +import { handleListOrgInvitations } from './orgs.js'; +import { handleListOrgInvitationTeams } from './orgs.js'; +import { handleListOrgIssueFields } from './orgs.js'; +import { handleListOrgIssueTypes } from './orgs.js'; +import { handleListOrgMembers } from './orgs.js'; +import { handleListOrgRepos } from './orgs.js'; +import { handleListOrgSecretScanningAlerts } from './orgs.js'; +import { handleListOrgSecurityAdvisories } from './orgs.js'; +import { handleListOrgTeamChildTeams } from './orgs.js'; +import { handleListOrgTeamChildTeamsById } from './orgs.js'; +import { handleListOrgTeamInvitations } from './orgs.js'; +import { handleListOrgTeamInvitationsById } from './orgs.js'; +import { handleListOrgTeamMembers } from './orgs.js'; +import { handleListOrgTeamMembersById } from './orgs.js'; +import { handleListOrgTeamRepos } from './orgs.js'; +import { handleListOrgTeamReposById } from './orgs.js'; +import { handleListOrgTeams } from './orgs.js'; +import { handleListOutsideCollaborators } from './orgs.js'; +import { handleListPagesBuilds } from './pages.js'; +import { handleListPublicEvents } from './activity.js'; +import { handleListPublicGists } from './gists.js'; +import { handleListPublicRepositories } from './repos.js'; +import { handleListReceivedEvents } from './activity.js'; +import { handleListRepoActivity } from './activity.js'; +import { handleListRepoEvents } from './activity.js'; +import { handleListRepositorySecrets } from './actions.js'; +import { handleListRepositoryVariables } from './actions.js'; +import { handleListRepoTeams } from './orgs.js'; +import { handleListStarredGists } from './gists.js'; +import { handleListTeamChildTeamsById } from './orgs.js'; +import { handleListTeamInvitationsById } from './orgs.js'; +import { handleListTeamMembersById } from './orgs.js'; +import { handleListTeamReposById } from './orgs.js'; +import { handleListUserAttestations } from './user-attestations.js'; +import { handleListUserEvents } from './activity.js'; +import { handleListUserGists } from './gists.js'; +import { handleListUserGpgKeys } from './gpg-keys.js'; +import { handleListUserOrgs } from './orgs.js'; +import { handleListUserRepos } from './repos.js'; +import { handleListUsers } from './users.js'; +import { handleListUserSocialAccounts } from './social-accounts.js'; +import { handleListUserSshKeys } from './user-keys.js'; +import { handleListUserSshSigningKeys } from './user-keys.js'; +import { handleListWorkflowRunArtifacts } from './actions.js'; +import { handleListWorkflowRunJobs } from './actions.js'; +import { handleListWorkflowRuns } from './actions.js'; +import { handleListWorkflowRunsForWorkflow } from './actions.js'; +import { handleListWorkflows } from './actions.js'; +import { handlePutOrgCustomProperty } from './orgs.js'; +import { handleRemoveOrgMembership } from './orgs.js'; +import { handleRemoveOrgTeamMembership } from './orgs.js'; +import { handleRemoveOrgTeamMembershipById } from './orgs.js'; +import { handleRemoveOrgTeamRepo } from './orgs.js'; +import { handleRemoveOrgTeamRepoById } from './orgs.js'; +import { handleRemoveOutsideCollaborator } from './orgs.js'; +import { handleRemovePublicOrgMembership } from './orgs.js'; +import { handleRemoveTeamMembershipById } from './orgs.js'; +import { handleRemoveTeamRepoById } from './orgs.js'; +import { handleRequestPagesBuild } from './pages.js'; +import { handleRerunFailedWorkflowJobs } from './actions.js'; +import { handleRerunWorkflowJob } from './actions.js'; +import { handleRerunWorkflowRun } from './actions.js'; +import { handleSetActionsCacheRetentionLimit } from './actions.js'; +import { handleSetActionsCacheStorageLimit } from './actions.js'; +import { handleSetActionsPermissions } from './actions.js'; +import { handleSetActionsSelectedActions } from './actions.js'; +import { handleSetActionsWorkflowPermissions } from './actions.js'; +import { handleSetOrgMembership } from './orgs.js'; +import { handleSetPrimaryEmailVisibility } from './emails.js'; +import { handleSetPublicOrgMembership } from './orgs.js'; +import { handleStarGist } from './gists.js'; +import { handleTransferRepo } from './repos.js'; +import { handleUnblockOrgUser } from './orgs.js'; +import { handleUnblockUser } from './follows.js'; +import { handleUnfollowUser } from './follows.js'; +import { handleUnstarGist } from './gists.js'; +import { handleUpdateAuthenticatedOrgMembership } from './orgs.js'; +import { handleUpdateAuthenticatedUser } from './users.js'; +import { handleUpdateCommitComment } from './commit-comments.js'; +import { handleUpdateEnvironmentVariable } from './actions.js'; +import { handleUpdateGist } from './gists.js'; +import { handleUpdateGistComment } from './gists.js'; +import { handleUpdateOrg } from './orgs.js'; +import { handleUpdateOrgCustomPropertyValues } from './orgs.js'; +import { handleUpdateOrgIssueField } from './orgs.js'; +import { handleUpdateOrgIssueType } from './orgs.js'; +import { handleUpdateOrgTeam } from './orgs.js'; +import { handleUpdateOrgTeamById } from './orgs.js'; +import { handleUpdatePagesSite } from './pages.js'; +import { handleUpdateRepo } from './repos.js'; +import { handleUpdateRepositoryVariable } from './actions.js'; +import { handleUpdateTeamById } from './orgs.js'; +import { handleUpsertOrgCustomProperties } from './orgs.js'; import { baseHeaders, jsonMethodNotAllowed, jsonNotFound, jsonUnauthorized, validateBearerToken } from './helpers.js'; -import { handleCreateIssue, handleCreateIssueComment, handleGetIssue, handleListIssueComments, handleListIssues, handleUpdateIssue } from './issues.js'; -import { handleCreatePull, handleCreatePullReview, handleGetPull, handleListPullFiles, handleListPullReviews, handleListPulls, handleMergePull, handleUpdatePull } from './pulls.js'; -import { handleCreateRelease, handleGetReleaseByTag, handleListReleases } from './releases.js'; +import { + handleAddBranchAccessRestrictionActors, + handleAddStatusCheckContexts, + handleCreateCommitSignatureProtection, + handleDeleteAdminBranchProtection, + handleDeleteBranchAccessRestrictions, + handleDeleteBranchProtection, + handleDeleteCommitSignatureProtection, + handleDeletePullRequestReviewProtection, + handleDeleteRequiredStatusChecksProtection, + handleGetAdminBranchProtection, + handleGetBranch, + handleGetBranchAccessRestrictions, + handleGetBranchProtection, + handleGetCommitSignatureProtection, + handleGetGitRef, + handleGetPullRequestReviewProtection, + handleGetRequiredStatusChecksProtection, + handleListBranchAccessRestrictionActors, + handleListBranches, + handleListMatchingGitRefs, + handleListStatusCheckContexts, + handleListTags, + handleRemoveBranchAccessRestrictionActors, + handleRemoveStatusCheckContexts, + handleSetAdminBranchProtection, + handleSetBranchAccessRestrictionActors, + handleSetStatusCheckContexts, + handleUpdateBranchProtection, + handleUpdatePullRequestReviewProtection, + handleUpdateRequiredStatusChecksProtection, +} from './git-refs.js'; +import { + handleAddCollaborator, + handleCheckAssignee, + handleCheckAutomatedSecurityFixes, + handleCheckCollaborator, + handleCheckImmutableReleases, + handleCheckVulnerabilityAlerts, + handleCreateAutolink, + handleCreateDeployKey, + handleCreateRepositoryAttestation, + handleCreateRepositoryDispatch, + handleCreateRepositoryRuleset, + handleCreateSecretScanningPushProtectionBypass, + handleCreateSecurityAdvisory, + handleCreateSecurityAdvisoryPrivateFork, + handleDeleteAutolink, + handleDeleteDeployKey, + handleDeleteInteractionLimit, + handleDeleteRepositoryRuleset, + handleDisableAutomatedSecurityFixes, + handleDisableImmutableReleases, + handleDisablePrivateVulnerabilityReporting, + handleDisableVulnerabilityAlerts, + handleEnableAutomatedSecurityFixes, + handleEnableImmutableReleases, + handleEnablePrivateVulnerabilityReporting, + handleEnableVulnerabilityAlerts, + handleGetAutolink, + handleGetCodeScanningAlert, + handleGetCollaboratorPermission, + handleGetDependabotAlert, + handleGetDeployKey, + handleGetInteractionLimit, + handleGetLanguages, + handleGetPrivateVulnerabilityReporting, + handleGetRepositoryCustomProperties, + handleGetRepositoryHashAlgorithm, + handleGetRepositoryRuleset, + handleGetRepositoryRulesetVersion, + handleGetRepositoryRuleSuite, + handleGetRulesForBranch, + handleGetSecretScanningAlert, + handleGetSecretScanningScanHistory, + handleGetSecurityAdvisory, + handleGetTopics, + handleListAssignees, + handleListAutolinks, + handleListCodeownersErrors, + handleListCodeScanningAlertInstances, + handleListCodeScanningAlerts, + handleListCollaborators, + handleListDependabotAlerts, + handleListDeployKeys, + handleListRepositoryAttestations, + handleListRepositoryIssueTypes, + handleListRepositoryRulesetHistory, + handleListRepositoryRulesets, + handleListRepositoryRuleSuites, + handleListSecretScanningAlertLocations, + handleListSecretScanningAlerts, + handleListSecurityAdvisories, + handlePrivatelyReportSecurityVulnerability, + handleRemoveCollaborator, + handleReplaceTopics, + handleRequestSecurityAdvisoryCve, + handleSetInteractionLimit, + handleUpdateCodeScanningAlert, + handleUpdateDependabotAlert, + handleUpdateRepositoryCustomProperties, + handleUpdateRepositoryRuleset, + handleUpdateSecretScanningAlert, + handleUpdateSecurityAdvisory, +} from './repo-metadata.js'; +import { + handleAddIssueAssignees, + handleAddIssueDependencyBlockedBy, + handleAddIssueFieldValues, + handleAddIssueLabels, + handleAddIssueSubIssue, + handleCheckIssueAssignee, + handleCreateIssue, + handleCreateIssueComment, + handleCreateIssueCommentReaction, + handleCreateIssueReaction, + handleCreateMilestone, + handleCreateRepoLabel, + handleDeleteIssueComment, + handleDeleteIssueCommentReaction, + handleDeleteIssueFieldValue, + handleDeleteIssueReaction, + handleDeleteMilestone, + handleDeleteRepoLabel, + handleGetIssue, + handleGetIssueComment, + handleGetIssueEvent, + handleGetIssueParent, + handleGetMilestone, + handleGetRepoLabel, + handleListAssignedIssues, + handleListAuthenticatedUserIssues, + handleListIssueCommentReactions, + handleListIssueComments, + handleListIssueDependenciesBlockedBy, + handleListIssueDependenciesBlocking, + handleListIssueEvents, + handleListIssueFieldValues, + handleListIssueLabels, + handleListIssueReactions, + handleListIssues, + handleListIssueSubIssues, + handleListIssueTimeline, + handleListMilestoneLabels, + handleListMilestones, + handleListOrgIssues, + handleListRepoIssueComments, + handleListRepoIssueEvents, + handleListRepoLabels, + handleLockIssue, + handlePinIssueComment, + handleRemoveAllIssueLabels, + handleRemoveIssueAssignees, + handleRemoveIssueDependencyBlockedBy, + handleRemoveIssueLabel, + handleRemoveIssueSubIssue, + handleReplaceIssueLabels, + handleReprioritizeIssueSubIssue, + handleSetIssueFieldValues, + handleUnlockIssue, + handleUnpinIssueComment, + handleUpdateIssue, + handleUpdateIssueComment, + handleUpdateMilestone, + handleUpdateRepoLabel, +} from './issues.js'; +import { + handleCheckPullMerged, + handleCreatePull, + handleCreatePullReview, + handleCreatePullReviewComment, + handleCreatePullReviewCommentReaction, + handleCreatePullReviewCommentReply, + handleDeletePendingPullReview, + handleDeletePullReviewComment, + handleDeletePullReviewCommentReaction, + handleDismissPullReview, + handleGetPull, + handleGetPullReview, + handleGetPullReviewComment, + handleGetPullText, + handleListPullCommits, + handleListPullFiles, + handleListPullRequestedReviewers, + handleListPullReviewCommentReactions, + handleListPullReviewComments, + handleListPullReviewCommentsForReview, + handleListPullReviews, + handleListPulls, + handleListRepoPullReviewComments, + handleMergePull, + handleRemovePullRequestedReviewers, + handleRequestPullReviewers, + handleSubmitPullReview, + handleUpdatePull, + handleUpdatePullBranch, + handleUpdatePullReview, + handleUpdatePullReviewComment, +} from './pulls.js'; +import { handleCheckStarredRepo, handleDeleteRepoSubscription, handleGetRepoSubscription, handleListStargazers, handleListStarredRepos, handleListSubscribers, handleListSubscriptions, handleSetRepoSubscription, handleStarRepo, handleUnstarRepo } from './stars.js'; +import { handleCompareCommits, handleCompareCommitText, handleCreateGitBlob, handleCreateGitCommit, handleCreateGitRef, handleCreateGitTag, handleCreateGitTree, handleDeleteGitContents, handleDeleteGitRef, handleDownloadArchive, handleGetGitBlob, handleGetGitCommit, handleGetGitTag, handleGetGitTree, handleGetRepoCommit, handleGetRepoCommitMedia, handleListContributors, handleListRepoCommits, handlePutGitContents, handleUpdateGitRef, tryGetGitContents, tryGetGitLicense, tryGetGitRawContent, tryGetGitReadme, tryGetGitReadmeInDirectory } from './git-objects.js'; +import { handleCreateCheckRun, handleCreateCheckSuite, handleCreateCommitStatus, handleGetCheckRun, handleGetCheckSuite, handleGetCombinedStatus, handleListCheckRunAnnotations, handleListCheckRunsForRef, handleListCheckRunsForSuite, handleListCheckSuitesForRef, handleListCommitStatuses, handleRerequestCheckRun, handleRerequestCheckSuite, handleUpdateCheckRun } from './checks.js'; +import { + handleCreateOrgWebhook, + handleCreateRepoWebhook, + handleDeleteOrgWebhook, + handleDeleteRepoWebhook, + handleGetOrgWebhook, + handleGetOrgWebhookConfig, + handleGetOrgWebhookDelivery, + handleGetRepoWebhook, + handleGetRepoWebhookConfig, + handleGetRepoWebhookDelivery, + handleListOrgWebhookDeliveries, + handleListOrgWebhooks, + handleListRepoWebhookDeliveries, + handleListRepoWebhooks, + handlePingOrgWebhook, + handlePingRepoWebhook, + handleRedeliverOrgWebhookDelivery, + handleRedeliverRepoWebhookDelivery, + handleTestRepoWebhook, + handleUpdateOrgWebhook, + handleUpdateOrgWebhookConfig, + handleUpdateRepoWebhook, + handleUpdateRepoWebhookConfig, +} from './webhooks.js'; +import { handleCreateRelease, handleCreateReleaseReaction, handleDeleteRelease, handleDeleteReleaseAsset, handleDeleteReleaseReaction, handleDownloadReleaseAsset, handleDownloadReleaseAssetByName, handleGenerateReleaseNotes, handleGetLatestRelease, handleGetReleaseAsset, handleGetReleaseById, handleGetReleaseByTag, handleListReleaseAssets, handleListReleaseReactions, handleListReleases, handleUpdateRelease, handleUpdateReleaseAsset, handleUploadReleaseAsset } from './releases.js'; +import { handleDeleteNotificationThread, handleDeleteNotificationThreadSubscription, handleGetNotificationThread, handleGetNotificationThreadSubscription, handleListNotifications, handleListRepoNotifications, handleMarkNotificationsRead, handleMarkNotificationThreadRead, handleMarkRepoNotificationsRead, handleSetNotificationThreadSubscription } from './notifications.js'; +import { handleGetApiVersions, handleGetEmojis, handleGetGitignoreTemplate, handleGetLicense as handleGetLicenseTemplate, handleGetMeta, handleGetRateLimit, handleGetZen, handleGitHubApiRoot, handleListGitignoreTemplates, handleListLicenses, handleRenderMarkdown, handleRenderRawMarkdown } from './meta.js'; +import { handleGetContents, handleGetLicense, handleGetRawContent, handleGetReadme, handleGetReadmeInDirectory } from './contents.js'; +import { handleSearchCode, handleSearchCommits, handleSearchIssues, handleSearchLabels, handleSearchRepositories, handleSearchTopics, handleSearchUsers } from './search.js'; // --------------------------------------------------------------------------- // Types @@ -54,6 +1139,13 @@ import { handleCreateRelease, handleGetReleaseByTag, handleListReleases } from ' export type ShimServerOptions = { ctx : AgentContext; port : number; + reposPath? : string; +}; + +export type ShimRequestOptions = GitObjectOptions & { + rawBody? : Uint8Array; + contentType? : string; + accept? : string; }; // --------------------------------------------------------------------------- @@ -65,6 +1157,9 @@ const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIO /** Maximum JSON request body size (1 MB). */ const MAX_JSON_BODY = 1 * 1024 * 1024; +/** Maximum buffered binary request body size for release asset uploads (100 MB). */ +const MAX_BINARY_BODY = 100 * 1024 * 1024; + // --------------------------------------------------------------------------- // DID extraction regex // --------------------------------------------------------------------------- @@ -74,7 +1169,228 @@ const MAX_JSON_BODY = 1 * 1024 * 1024; * DID and the remaining path segments. */ const REPOS_RE = /^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([^/]+)(\/.*)?$/; +const NETWORKS_EVENTS_RE = /^\/networks\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([^/]+)\/events$/; +const GISTS_RE = /^\/gists\/([^/]+)$/; +const GISTS_COMMITS_RE = /^\/gists\/([^/]+)\/commits$/; +const GISTS_COMMENTS_RE = /^\/gists\/([^/]+)\/comments$/; +const GISTS_COMMENT_RE = /^\/gists\/([^/]+)\/comments\/(\d+)$/; +const GISTS_FORKS_RE = /^\/gists\/([^/]+)\/forks$/; +const GISTS_RAW_RE = /^\/gists\/([^/]+)\/raw\/(.+)$/; +const GISTS_REVISION_RE = /^\/gists\/([^/]+)\/([0-9a-f]{7,40})$/i; +const GISTS_STAR_RE = /^\/gists\/([^/]+)\/star$/; const USERS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/; +const USERS_ATTESTATIONS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/attestations$/; +const USERS_ATTESTATIONS_BULK_LIST_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/attestations\/bulk-list$/; +const USERS_ATTESTATIONS_DIGEST_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/attestations\/digest\/(.+)$/; +const USERS_ATTESTATION_ID_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/attestations\/(\d+)$/; +const USERS_ATTESTATIONS_SUBJECT_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/attestations\/(.+)$/; +const USERS_EVENTS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/events$/; +const USERS_EVENTS_PUBLIC_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/events\/public$/; +const USERS_FOLLOWERS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/followers$/; +const USERS_FOLLOWING_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/following$/; +const USERS_FOLLOWING_TARGET_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/following\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/; +const USERS_GISTS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/gists$/; +const USERS_GPG_KEYS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/gpg_keys$/; +const USERS_HOVERCARD_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/hovercard$/; +const USERS_KEYS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/keys$/; +const USERS_ORGS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/orgs$/; +const USERS_RECEIVED_EVENTS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/received_events$/; +const USERS_RECEIVED_EVENTS_PUBLIC_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/received_events\/public$/; +const USERS_SOCIAL_ACCOUNTS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/social_accounts$/; +const USERS_SSH_SIGNING_KEYS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/ssh_signing_keys$/; +const USERS_STARRED_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/starred$/; +const USERS_SUBSCRIPTIONS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/subscriptions$/; + +const RELEASE_ASSET_UPLOAD_RE = /^\/repos\/did:[a-z0-9]+:[a-zA-Z0-9._:%-]+\/[^/]+\/releases\/\d+\/assets$/; +const MARKDOWN_RAW_RE = /^\/markdown\/raw$/; + +function githubTextMediaKind(accept: string | undefined): 'diff' | 'patch' | null { + const parts = (accept ?? '').split(',').map(part => part.trim().toLowerCase()); + for (const part of parts) { + const media = part.split(';')[0]?.trim(); + if (media === 'application/vnd.github.diff' || media === 'application/vnd.github.v3.diff') { + return 'diff'; + } + if (media === 'application/vnd.github.patch' || media === 'application/vnd.github.v3.patch') { + return 'patch'; + } + } + return null; +} + +function githubCommitMediaKind(accept: string | undefined): 'diff' | 'patch' | 'sha' | null { + const textKind = githubTextMediaKind(accept); + if (textKind) { + return textKind; + } + const parts = (accept ?? '').split(',').map(part => part.trim().toLowerCase()); + for (const part of parts) { + const media = part.split(';')[0]?.trim(); + if (media === 'application/vnd.github.sha' || media === 'application/vnd.github.v3.sha') { + return 'sha'; + } + } + return null; +} + +function githubContentMediaKind(accept: string | undefined): ContentMediaKind | null { + const parts = (accept ?? '').split(',').map(part => part.trim().toLowerCase()); + for (const part of parts) { + const media = part.split(';')[0]?.trim(); + if (!media) { + continue; + } + const normalized = media.endsWith('+json') ? media.slice(0, -5) : media; + if (normalized === 'application/vnd.github.raw' || normalized === 'application/vnd.github.v3.raw') { + return 'raw'; + } + if (normalized === 'application/vnd.github.html' || normalized === 'application/vnd.github.v3.html') { + return 'html'; + } + if (normalized === 'application/vnd.github.object' || normalized === 'application/vnd.github.v3.object') { + return 'object'; + } + } + return null; +} + +function githubReadmeMediaKind(accept: string | undefined): ContentMediaKind | null { + const mediaKind = githubContentMediaKind(accept); + return mediaKind === 'raw' || mediaKind === 'html' ? mediaKind : null; +} + +function githubLicenseMediaKind(accept: string | undefined): ContentMediaKind | null { + return githubReadmeMediaKind(accept); +} + +function githubBlobMediaKind(accept: string | undefined): 'raw' | null { + return githubContentMediaKind(accept) === 'raw' ? 'raw' : null; +} + +function githubReleaseAssetMediaKind(accept: string | undefined): 'binary' | null { + const parts = (accept ?? '').split(',').map(part => part.trim().toLowerCase()); + for (const part of parts) { + const media = part.split(';')[0]?.trim(); + if (media === 'application/octet-stream') { + return 'binary'; + } + } + return null; +} + +function githubBodyMediaKindFor(accept: string | undefined, vendorSubtype: string): BodyMediaKind | null { + const parts = (accept ?? '').split(',').map(part => part.trim().toLowerCase()); + for (const part of parts) { + const media = part.split(';')[0]?.trim(); + if (!media) { + continue; + } + const normalized = media.endsWith('+json') ? media.slice(0, -5) : media; + if (normalized === `application/vnd.${vendorSubtype}.raw` + || normalized === `application/vnd.${vendorSubtype}.v3.raw`) { + return 'raw'; + } + if (normalized === `application/vnd.${vendorSubtype}.text` + || normalized === `application/vnd.${vendorSubtype}.v3.text`) { + return 'text'; + } + if (normalized === `application/vnd.${vendorSubtype}.html` + || normalized === `application/vnd.${vendorSubtype}.v3.html`) { + return 'html'; + } + if (normalized === `application/vnd.${vendorSubtype}.full` + || normalized === `application/vnd.${vendorSubtype}.v3.full`) { + return 'full'; + } + } + return null; +} + +function githubBodyMediaKind(accept: string | undefined): BodyMediaKind | null { + return githubBodyMediaKindFor(accept, 'github'); +} + +function githubCommitCommentBodyMediaKind(accept: string | undefined): BodyMediaKind | null { + return githubBodyMediaKindFor(accept, 'github-commitcomment'); +} + +const USER_ACCOUNT_RE = /^\/user\/(\d+)$/; +const USER_BLOCK_RE = /^\/user\/blocks\/([^/]+)$/; +const USER_FOLLOWING_RE = /^\/user\/following\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/; +const USER_GPG_KEY_RE = /^\/user\/gpg_keys\/(\d+)$/; +const USER_KEY_RE = /^\/user\/keys\/(\d+)$/; +const USER_MEMBERSHIP_ORG_RE = /^\/user\/memberships\/orgs\/([^/]+)$/; +const USER_SSH_SIGNING_KEY_RE = /^\/user\/ssh_signing_keys\/(\d+)$/; +const USER_STARRED_RE = /^\/user\/starred\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([^/]+)$/; +const ORGS_RE = /^\/orgs\/([^/]+)$/; +const ORGS_MEMBERS_RE = /^\/orgs\/([^/]+)\/members$/; +const ORGS_MEMBER_RE = /^\/orgs\/([^/]+)\/members\/([^/]+)$/; +const ORGS_MEMBERSHIP_RE = /^\/orgs\/([^/]+)\/memberships\/([^/]+)$/; +const ORGS_FAILED_INVITATIONS_RE = /^\/orgs\/([^/]+)\/failed_invitations$/; +const ORGS_INVITATIONS_RE = /^\/orgs\/([^/]+)\/invitations$/; +const ORGS_INVITATION_RE = /^\/orgs\/([^/]+)\/invitations\/(\d+)$/; +const ORGS_INVITATION_TEAMS_RE = /^\/orgs\/([^/]+)\/invitations\/(\d+)\/teams$/; +const ORGS_BLOCKS_RE = /^\/orgs\/([^/]+)\/blocks$/; +const ORGS_BLOCK_RE = /^\/orgs\/([^/]+)\/blocks\/([^/]+)$/; +const ORGS_HOOKS_RE = /^\/orgs\/([^/]+)\/hooks$/; +const ORGS_HOOK_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)$/; +const ORGS_HOOK_CONFIG_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)\/config$/; +const ORGS_HOOK_DELIVERIES_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)\/deliveries$/; +const ORGS_HOOK_DELIVERY_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)\/deliveries\/(\d+)$/; +const ORGS_HOOK_DELIVERY_ATTEMPTS_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)\/deliveries\/(\d+)\/attempts$/; +const ORGS_HOOK_PING_RE = /^\/orgs\/([^/]+)\/hooks\/(\d+)\/pings$/; +const ORGS_CUSTOM_PROPERTIES_SCHEMA_RE = /^\/orgs\/([^/]+)\/properties\/schema$/; +const ORGS_CUSTOM_PROPERTY_SCHEMA_RE = /^\/orgs\/([^/]+)\/properties\/schema\/([^/]+)$/; +const ORGS_CUSTOM_PROPERTY_VALUES_RE = /^\/orgs\/([^/]+)\/properties\/values$/; +const ORGS_ISSUE_FIELDS_RE = /^\/orgs\/([^/]+)\/issue-fields$/; +const ORGS_ISSUE_FIELD_RE = /^\/orgs\/([^/]+)\/issue-fields\/(\d+)$/; +const ORGS_ISSUE_TYPES_RE = /^\/orgs\/([^/]+)\/issue-types$/; +const ORGS_ISSUE_TYPE_RE = /^\/orgs\/([^/]+)\/issue-types\/(\d+)$/; +const ORGS_OUTSIDE_COLLABORATORS_RE = /^\/orgs\/([^/]+)\/outside_collaborators$/; +const ORGS_OUTSIDE_COLLABORATOR_RE = /^\/orgs\/([^/]+)\/outside_collaborators\/([^/]+)$/; +const ORGS_PUBLIC_MEMBERS_RE = /^\/orgs\/([^/]+)\/public_members$/; +const ORGS_PUBLIC_MEMBER_RE = /^\/orgs\/([^/]+)\/public_members\/([^/]+)$/; +const ORGS_ISSUES_RE = /^\/orgs\/([^/]+)\/issues$/; +const ORGS_REPOS_RE = /^\/orgs\/([^/]+)\/repos$/; +const ORGS_CODE_SCANNING_ALERTS_RE = /^\/orgs\/([^/]+)\/code-scanning\/alerts$/; +const ORGS_DEPENDABOT_ALERTS_RE = /^\/orgs\/([^/]+)\/dependabot\/alerts$/; +const ORGS_SECRET_SCANNING_ALERTS_RE = /^\/orgs\/([^/]+)\/secret-scanning\/alerts$/; +const ORGS_SECURITY_ADVISORIES_RE = /^\/orgs\/([^/]+)\/security-advisories$/; +const ORGS_TEAMS_RE = /^\/orgs\/([^/]+)\/teams$/; +const ORGS_TEAM_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)$/; +const ORGS_TEAM_CHILD_TEAMS_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/teams$/; +const ORGS_TEAM_INVITATIONS_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/invitations$/; +const ORGS_TEAM_MEMBERS_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/members$/; +const ORGS_TEAM_MEMBER_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/; +const ORGS_TEAM_MEMBERSHIP_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/memberships\/([^/]+)$/; +const ORGS_TEAM_REPOS_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/repos$/; +const ORGS_TEAM_REPO_RE = /^\/orgs\/([^/]+)\/teams\/([^/]+)\/repos\/([^/]+)\/([^/]+)$/; +const ORGANIZATIONS_TEAM_RE = /^\/organizations\/(\d+)\/team\/(\d+)$/; +const ORGANIZATIONS_TEAM_CHILD_TEAMS_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/teams$/; +const ORGANIZATIONS_TEAM_INVITATIONS_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/invitations$/; +const ORGANIZATIONS_TEAM_MEMBERS_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/members$/; +const ORGANIZATIONS_TEAM_MEMBERSHIP_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/memberships\/([^/]+)$/; +const ORGANIZATIONS_TEAM_REPOS_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/repos$/; +const ORGANIZATIONS_TEAM_REPO_RE = /^\/organizations\/(\d+)\/team\/(\d+)\/repos\/([^/]+)\/([^/]+)$/; +const TEAMS_RE = /^\/teams\/(\d+)$/; +const TEAMS_CHILD_TEAMS_RE = /^\/teams\/(\d+)\/teams$/; +const TEAMS_INVITATIONS_RE = /^\/teams\/(\d+)\/invitations$/; +const TEAMS_MEMBERS_RE = /^\/teams\/(\d+)\/members$/; +const TEAMS_MEMBER_RE = /^\/teams\/(\d+)\/members\/([^/]+)$/; +const TEAMS_MEMBERSHIP_RE = /^\/teams\/(\d+)\/memberships\/([^/]+)$/; +const TEAMS_REPOS_RE = /^\/teams\/(\d+)\/repos$/; +const TEAMS_REPO_RE = /^\/teams\/(\d+)\/repos\/([^/]+)\/([^/]+)$/; +const USERS_REPOS_RE = /^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/repos$/; +const NOTIFICATION_THREAD_RE = /^\/notifications\/threads\/(\d+)$/; +const NOTIFICATION_THREAD_SUBSCRIPTION_RE = /^\/notifications\/threads\/(\d+)\/subscription$/; + +function requiresWriteAuth(method: string, path: string): boolean { + if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT' && method !== 'DELETE') { + return false; + } + + return path !== '/markdown' && path !== '/markdown/raw'; +} // --------------------------------------------------------------------------- // Router @@ -96,76 +1412,2594 @@ export async function handleShimRequest( method: string = 'GET', reqBody: Record<string, unknown> = {}, authHeader: string | null = null, + options: ShimRequestOptions = {}, ): Promise<JsonResponse> { + const path = url.pathname; + // Authenticate mutating requests when GITD_API_TOKEN is configured. - if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { + if (requiresWriteAuth(method, path)) { if (!validateBearerToken(authHeader)) { return jsonUnauthorized('Valid Bearer token required for write operations.'); } } - const path = url.pathname; - // ------------------------------------------------------------------------- - // GET /users/:did - // ------------------------------------------------------------------------- - const userMatch = path.match(USERS_RE); - if (userMatch) { - if (method !== 'GET') { - return jsonMethodNotAllowed(`${method} is not allowed on /users endpoints.`); + // ------------------------------------------------------------------------- + // Global GitHub utility endpoints + // ------------------------------------------------------------------------- + if (path === '/') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /.`); } + return handleGitHubApiRoot(url); + } + + if (path === '/meta') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /meta.`); } + return handleGetMeta(url); + } + + if (path === '/versions') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /versions.`); } + return handleGetApiVersions(); + } + + if (path === '/zen') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /zen.`); } + return handleGetZen(); + } + + if (path === '/rate_limit') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /rate_limit.`); } + return handleGetRateLimit(); + } + + if (path === '/emojis') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /emojis.`); } + return handleGetEmojis(); + } + + if (path === '/gitignore/templates') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /gitignore/templates.`); } + return handleListGitignoreTemplates(); + } + + const gitignoreTemplateMatch = path.match(/^\/gitignore\/templates\/([^/]+)$/); + if (gitignoreTemplateMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /gitignore/templates/:name.`); } + return handleGetGitignoreTemplate(gitignoreTemplateMatch[1]); + } + + if (path === '/licenses') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /licenses.`); } + return handleListLicenses(url); + } + + const licenseMatch = path.match(/^\/licenses\/([^/]+)$/); + if (licenseMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /licenses/:license.`); } + return handleGetLicenseTemplate(licenseMatch[1], url); + } + + if (path === '/events') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /events.`); } + return handleListPublicEvents(ctx, url); + } + + if (path === '/repositories') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} is not allowed on /repositories.`); } + return handleListPublicRepositories(ctx, url); + } + + const networkEventsMatch = path.match(NETWORKS_EVENTS_RE); + if (networkEventsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /networks/:owner/:repo/events.`); + } + return handleListRepoEvents(ctx, networkEventsMatch[1], networkEventsMatch[2], url, 'networks'); + } + + if (path === '/markdown') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} is not allowed on /markdown.`); } + return handleRenderMarkdown(reqBody); + } + + if (path === '/markdown/raw') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} is not allowed on /markdown/raw.`); } + return handleRenderRawMarkdown(options); + } + + // ------------------------------------------------------------------------- + // /notifications and /notifications/threads/:thread_id + // ------------------------------------------------------------------------- + if (path === '/notifications') { + if (method === 'GET') { return handleListNotifications(ctx, url); } + if (method === 'PUT') { return handleMarkNotificationsRead(ctx, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /notifications.`); + } + + if (path === '/issues') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /issues.`); + } + return handleListAssignedIssues(ctx, url, githubBodyMediaKind(options.accept)); + } + + const notificationThreadSubscriptionMatch = path.match(NOTIFICATION_THREAD_SUBSCRIPTION_RE); + if (notificationThreadSubscriptionMatch) { + if (method === 'GET') { return handleGetNotificationThreadSubscription(ctx, notificationThreadSubscriptionMatch[1], url); } + if (method === 'PUT') { return handleSetNotificationThreadSubscription(ctx, notificationThreadSubscriptionMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteNotificationThreadSubscription(ctx, notificationThreadSubscriptionMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /notifications/threads/:thread_id/subscription.`); + } + + const notificationThreadMatch = path.match(NOTIFICATION_THREAD_RE); + if (notificationThreadMatch) { + if (method === 'GET') { return handleGetNotificationThread(ctx, notificationThreadMatch[1], url); } + if (method === 'PATCH') { return handleMarkNotificationThreadRead(ctx, notificationThreadMatch[1]); } + if (method === 'DELETE') { return handleDeleteNotificationThread(ctx, notificationThreadMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /notifications/threads/:thread_id.`); + } + + if (path === '/organizations') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /organizations.`); + } + return handleListOrganizations(ctx, url); + } + + if (path === '/search/repositories') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/repositories.`); + } + return handleSearchRepositories(ctx, url); + } + + if (path === '/search/code') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/code.`); + } + return handleSearchCode(ctx, url, options); + } + + if (path === '/search/commits') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/commits.`); + } + return handleSearchCommits(ctx, url, options); + } + + if (path === '/search/issues') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/issues.`); + } + return handleSearchIssues(ctx, url); + } + + if (path === '/search/labels') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/labels.`); + } + return handleSearchLabels(ctx, url); + } + + if (path === '/search/topics') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/topics.`); + } + return handleSearchTopics(ctx, url); + } + + if (path === '/search/users') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /search/users.`); + } + return handleSearchUsers(ctx, url); + } + + const organizationTeamRepoMatch = path.match(ORGANIZATIONS_TEAM_REPO_RE); + if (organizationTeamRepoMatch) { + if (method === 'GET') { + return handleCheckOrgTeamRepoById( + ctx, + organizationTeamRepoMatch[1], + organizationTeamRepoMatch[2], + organizationTeamRepoMatch[3], + organizationTeamRepoMatch[4], + url, + ); + } + if (method === 'PUT') { + return handleAddOrUpdateOrgTeamRepoById( + ctx, + organizationTeamRepoMatch[1], + organizationTeamRepoMatch[2], + organizationTeamRepoMatch[3], + organizationTeamRepoMatch[4], + reqBody, + ); + } + if (method === 'DELETE') { + return handleRemoveOrgTeamRepoById( + ctx, + organizationTeamRepoMatch[1], + organizationTeamRepoMatch[2], + organizationTeamRepoMatch[3], + organizationTeamRepoMatch[4], + ); + } + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/repos/:owner/:repo.`); + } + + const organizationTeamReposMatch = path.match(ORGANIZATIONS_TEAM_REPOS_RE); + if (organizationTeamReposMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/repos.`); + } + return handleListOrgTeamReposById(ctx, organizationTeamReposMatch[1], organizationTeamReposMatch[2], url); + } + + const organizationTeamInvitationsMatch = path.match(ORGANIZATIONS_TEAM_INVITATIONS_RE); + if (organizationTeamInvitationsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/invitations.`); + } + return handleListOrgTeamInvitationsById(ctx, organizationTeamInvitationsMatch[1], organizationTeamInvitationsMatch[2], url); + } + + const organizationTeamMembershipMatch = path.match(ORGANIZATIONS_TEAM_MEMBERSHIP_RE); + if (organizationTeamMembershipMatch) { + if (method === 'GET') { + return handleGetOrgTeamMembershipById( + ctx, + organizationTeamMembershipMatch[1], + organizationTeamMembershipMatch[2], + organizationTeamMembershipMatch[3], + url, + ); + } + if (method === 'PUT') { + return handleAddOrgTeamMembershipById( + ctx, + organizationTeamMembershipMatch[1], + organizationTeamMembershipMatch[2], + organizationTeamMembershipMatch[3], + reqBody, + url, + ); + } + if (method === 'DELETE') { + return handleRemoveOrgTeamMembershipById( + ctx, + organizationTeamMembershipMatch[1], + organizationTeamMembershipMatch[2], + organizationTeamMembershipMatch[3], + ); + } + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/memberships/:username.`); + } + + const organizationTeamMembersMatch = path.match(ORGANIZATIONS_TEAM_MEMBERS_RE); + if (organizationTeamMembersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/members.`); + } + return handleListOrgTeamMembersById(ctx, organizationTeamMembersMatch[1], organizationTeamMembersMatch[2], url); + } + + const organizationTeamChildTeamsMatch = path.match(ORGANIZATIONS_TEAM_CHILD_TEAMS_RE); + if (organizationTeamChildTeamsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id/teams.`); + } + return handleListOrgTeamChildTeamsById(ctx, organizationTeamChildTeamsMatch[1], organizationTeamChildTeamsMatch[2], url); + } + + const organizationTeamMatch = path.match(ORGANIZATIONS_TEAM_RE); + if (organizationTeamMatch) { + if (method === 'GET') { + return handleGetOrgTeamById(ctx, organizationTeamMatch[1], organizationTeamMatch[2], url); + } + if (method === 'PATCH') { + return handleUpdateOrgTeamById(ctx, organizationTeamMatch[1], organizationTeamMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleDeleteOrgTeamById(ctx, organizationTeamMatch[1], organizationTeamMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /organizations/:org_id/team/:team_id.`); + } + + const teamRepoMatch = path.match(TEAMS_REPO_RE); + if (teamRepoMatch) { + if (method === 'GET') { + return handleCheckTeamRepoById(ctx, teamRepoMatch[1], teamRepoMatch[2], teamRepoMatch[3], url); + } + if (method === 'PUT') { + return handleAddOrUpdateTeamRepoById(ctx, teamRepoMatch[1], teamRepoMatch[2], teamRepoMatch[3], reqBody); + } + if (method === 'DELETE') { + return handleRemoveTeamRepoById(ctx, teamRepoMatch[1], teamRepoMatch[2], teamRepoMatch[3]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/repos/:owner/:repo.`); + } + + const teamReposMatch = path.match(TEAMS_REPOS_RE); + if (teamReposMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/repos.`); + } + return handleListTeamReposById(ctx, teamReposMatch[1], url); + } + + const teamInvitationsMatch = path.match(TEAMS_INVITATIONS_RE); + if (teamInvitationsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/invitations.`); + } + return handleListTeamInvitationsById(ctx, teamInvitationsMatch[1], url); + } + + const teamMembershipMatch = path.match(TEAMS_MEMBERSHIP_RE); + if (teamMembershipMatch) { + if (method === 'GET') { + return handleGetTeamMembershipById(ctx, teamMembershipMatch[1], teamMembershipMatch[2], url); + } + if (method === 'PUT') { + return handleAddTeamMembershipById(ctx, teamMembershipMatch[1], teamMembershipMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleRemoveTeamMembershipById(ctx, teamMembershipMatch[1], teamMembershipMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/memberships/:username.`); + } + + const teamMemberMatch = path.match(TEAMS_MEMBER_RE); + if (teamMemberMatch) { + if (method === 'GET') { + return handleCheckTeamMemberById(ctx, teamMemberMatch[1], teamMemberMatch[2]); + } + if (method === 'PUT') { + return handleAddTeamMembershipById(ctx, teamMemberMatch[1], teamMemberMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleRemoveTeamMembershipById(ctx, teamMemberMatch[1], teamMemberMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/members/:username.`); + } + + const teamMembersMatch = path.match(TEAMS_MEMBERS_RE); + if (teamMembersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/members.`); + } + return handleListTeamMembersById(ctx, teamMembersMatch[1], url); + } + + const teamChildTeamsMatch = path.match(TEAMS_CHILD_TEAMS_RE); + if (teamChildTeamsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id/teams.`); + } + return handleListTeamChildTeamsById(ctx, teamChildTeamsMatch[1], url); + } + + const teamMatch = path.match(TEAMS_RE); + if (teamMatch) { + if (method === 'GET') { + return handleGetTeamById(ctx, teamMatch[1], url); + } + if (method === 'PATCH') { + return handleUpdateTeamById(ctx, teamMatch[1], reqBody, url); + } + if (method === 'DELETE') { + return handleDeleteTeamById(ctx, teamMatch[1]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /teams/:team_id.`); + } + + // ------------------------------------------------------------------------- + // /orgs/:org, /orgs/:org/members, and /orgs/:org/teams + // ------------------------------------------------------------------------- + const orgTeamRepoMatch = path.match(ORGS_TEAM_REPO_RE); + if (orgTeamRepoMatch) { + if (method === 'GET') { + return handleCheckOrgTeamRepo(ctx, orgTeamRepoMatch[1], orgTeamRepoMatch[2], orgTeamRepoMatch[3], orgTeamRepoMatch[4], url); + } + if (method === 'PUT') { + return handleAddOrUpdateOrgTeamRepo(ctx, orgTeamRepoMatch[1], orgTeamRepoMatch[2], orgTeamRepoMatch[3], orgTeamRepoMatch[4], reqBody); + } + if (method === 'DELETE') { + return handleRemoveOrgTeamRepo(ctx, orgTeamRepoMatch[1], orgTeamRepoMatch[2], orgTeamRepoMatch[3], orgTeamRepoMatch[4]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/repos/:owner/:repo.`); + } + + const orgTeamReposMatch = path.match(ORGS_TEAM_REPOS_RE); + if (orgTeamReposMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/repos.`); + } + return handleListOrgTeamRepos(ctx, orgTeamReposMatch[1], orgTeamReposMatch[2], url); + } + + const orgTeamInvitationsMatch = path.match(ORGS_TEAM_INVITATIONS_RE); + if (orgTeamInvitationsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/invitations.`); + } + return handleListOrgTeamInvitations(ctx, orgTeamInvitationsMatch[1], orgTeamInvitationsMatch[2], url); + } + + const orgTeamMembershipMatch = path.match(ORGS_TEAM_MEMBERSHIP_RE); + if (orgTeamMembershipMatch) { + if (method === 'GET') { + return handleGetOrgTeamMembership(ctx, orgTeamMembershipMatch[1], orgTeamMembershipMatch[2], orgTeamMembershipMatch[3], url); + } + if (method === 'PUT') { + return handleAddOrgTeamMembership(ctx, orgTeamMembershipMatch[1], orgTeamMembershipMatch[2], orgTeamMembershipMatch[3], reqBody, url); + } + if (method === 'DELETE') { + return handleRemoveOrgTeamMembership(ctx, orgTeamMembershipMatch[1], orgTeamMembershipMatch[2], orgTeamMembershipMatch[3]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/memberships/:username.`); + } + + const orgTeamMemberMatch = path.match(ORGS_TEAM_MEMBER_RE); + if (orgTeamMemberMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/members/:username.`); + } + return handleCheckOrgTeamMember(ctx, orgTeamMemberMatch[1], orgTeamMemberMatch[2], orgTeamMemberMatch[3]); + } + + const orgTeamMembersMatch = path.match(ORGS_TEAM_MEMBERS_RE); + if (orgTeamMembersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/members.`); + } + return handleListOrgTeamMembers(ctx, orgTeamMembersMatch[1], orgTeamMembersMatch[2], url); + } + + const orgTeamChildTeamsMatch = path.match(ORGS_TEAM_CHILD_TEAMS_RE); + if (orgTeamChildTeamsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug/teams.`); + } + return handleListOrgTeamChildTeams(ctx, orgTeamChildTeamsMatch[1], orgTeamChildTeamsMatch[2], url); + } + + const orgTeamMatch = path.match(ORGS_TEAM_RE); + if (orgTeamMatch) { + if (method === 'GET') { return handleGetOrgTeam(ctx, orgTeamMatch[1], orgTeamMatch[2], url); } + if (method === 'PATCH') { return handleUpdateOrgTeam(ctx, orgTeamMatch[1], orgTeamMatch[2], reqBody, url); } + if (method === 'DELETE') { return handleDeleteOrgTeam(ctx, orgTeamMatch[1], orgTeamMatch[2]); } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams/:team_slug.`); + } + + const orgTeamsMatch = path.match(ORGS_TEAMS_RE); + if (orgTeamsMatch) { + if (method === 'GET') { return handleListOrgTeams(ctx, orgTeamsMatch[1], url); } + if (method === 'POST') { return handleCreateOrgTeam(ctx, orgTeamsMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/teams.`); + } + + const orgFailedInvitationsMatch = path.match(ORGS_FAILED_INVITATIONS_RE); + if (orgFailedInvitationsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/failed_invitations.`); + } + return handleListFailedOrgInvitations(ctx, orgFailedInvitationsMatch[1], url); + } + + const orgInvitationTeamsMatch = path.match(ORGS_INVITATION_TEAMS_RE); + if (orgInvitationTeamsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/invitations/:invitation_id/teams.`); + } + return handleListOrgInvitationTeams(ctx, orgInvitationTeamsMatch[1], orgInvitationTeamsMatch[2], url); + } + + const orgInvitationMatch = path.match(ORGS_INVITATION_RE); + if (orgInvitationMatch) { + if (method !== 'DELETE') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/invitations/:invitation_id.`); + } + return handleCancelOrgInvitation(ctx, orgInvitationMatch[1], orgInvitationMatch[2]); + } + + const orgInvitationsMatch = path.match(ORGS_INVITATIONS_RE); + if (orgInvitationsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/invitations.`); + } + return handleListOrgInvitations(ctx, orgInvitationsMatch[1], url); + } + + const orgBlockMatch = path.match(ORGS_BLOCK_RE); + if (orgBlockMatch) { + if (method === 'GET') { return handleCheckOrgBlockedUser(ctx, orgBlockMatch[1], orgBlockMatch[2]); } + if (method === 'PUT') { return handleBlockOrgUser(ctx, orgBlockMatch[1], orgBlockMatch[2]); } + if (method === 'DELETE') { return handleUnblockOrgUser(ctx, orgBlockMatch[1], orgBlockMatch[2]); } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/blocks/:username.`); + } + + const orgBlocksMatch = path.match(ORGS_BLOCKS_RE); + if (orgBlocksMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/blocks.`); + } + return handleListOrgBlockedUsers(ctx, orgBlocksMatch[1], url); + } + + const orgHookDeliveryAttemptsMatch = path.match(ORGS_HOOK_DELIVERY_ATTEMPTS_RE); + if (orgHookDeliveryAttemptsMatch) { + if (method !== 'POST') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id/deliveries/:delivery_id/attempts.`); + } + return handleRedeliverOrgWebhookDelivery( + ctx, + orgHookDeliveryAttemptsMatch[1], + orgHookDeliveryAttemptsMatch[2], + orgHookDeliveryAttemptsMatch[3], + ); + } + + const orgHookDeliveryMatch = path.match(ORGS_HOOK_DELIVERY_RE); + if (orgHookDeliveryMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id/deliveries/:delivery_id.`); + } + return handleGetOrgWebhookDelivery( + ctx, + orgHookDeliveryMatch[1], + orgHookDeliveryMatch[2], + orgHookDeliveryMatch[3], + ); + } + + const orgHookDeliveriesMatch = path.match(ORGS_HOOK_DELIVERIES_RE); + if (orgHookDeliveriesMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id/deliveries.`); + } + return handleListOrgWebhookDeliveries(ctx, orgHookDeliveriesMatch[1], orgHookDeliveriesMatch[2], url); + } + + const orgHookConfigMatch = path.match(ORGS_HOOK_CONFIG_RE); + if (orgHookConfigMatch) { + if (method === 'GET') { + return handleGetOrgWebhookConfig(ctx, orgHookConfigMatch[1], orgHookConfigMatch[2]); + } + if (method === 'PATCH') { + return handleUpdateOrgWebhookConfig(ctx, orgHookConfigMatch[1], orgHookConfigMatch[2], reqBody); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id/config.`); + } + + const orgHookPingMatch = path.match(ORGS_HOOK_PING_RE); + if (orgHookPingMatch) { + if (method !== 'POST') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id/pings.`); + } + return handlePingOrgWebhook(ctx, orgHookPingMatch[1], orgHookPingMatch[2]); + } + + const orgHookMatch = path.match(ORGS_HOOK_RE); + if (orgHookMatch) { + if (method === 'GET') { + return handleGetOrgWebhook(ctx, orgHookMatch[1], orgHookMatch[2], url); + } + if (method === 'PATCH') { + return handleUpdateOrgWebhook(ctx, orgHookMatch[1], orgHookMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleDeleteOrgWebhook(ctx, orgHookMatch[1], orgHookMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks/:hook_id.`); + } + + const orgHooksMatch = path.match(ORGS_HOOKS_RE); + if (orgHooksMatch) { + if (method === 'GET') { + return handleListOrgWebhooks(ctx, orgHooksMatch[1], url); + } + if (method === 'POST') { + return handleCreateOrgWebhook(ctx, orgHooksMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/hooks.`); + } + + const orgCustomPropertySchemaMatch = path.match(ORGS_CUSTOM_PROPERTY_SCHEMA_RE); + if (orgCustomPropertySchemaMatch) { + if (method === 'GET') { + return handleGetOrgCustomProperty(ctx, orgCustomPropertySchemaMatch[1], orgCustomPropertySchemaMatch[2], url); + } + if (method === 'PUT') { + return handlePutOrgCustomProperty(ctx, orgCustomPropertySchemaMatch[1], orgCustomPropertySchemaMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleDeleteOrgCustomProperty(ctx, orgCustomPropertySchemaMatch[1], orgCustomPropertySchemaMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/properties/schema/:custom_property_name.`); + } + + const orgCustomPropertiesSchemaMatch = path.match(ORGS_CUSTOM_PROPERTIES_SCHEMA_RE); + if (orgCustomPropertiesSchemaMatch) { + if (method === 'GET') { + return handleListOrgCustomProperties(ctx, orgCustomPropertiesSchemaMatch[1], url); + } + if (method === 'PATCH') { + return handleUpsertOrgCustomProperties(ctx, orgCustomPropertiesSchemaMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/properties/schema.`); + } + + const orgCustomPropertyValuesMatch = path.match(ORGS_CUSTOM_PROPERTY_VALUES_RE); + if (orgCustomPropertyValuesMatch) { + if (method === 'GET') { + return handleListOrgCustomPropertyValues(ctx, orgCustomPropertyValuesMatch[1], url); + } + if (method === 'PATCH') { + return handleUpdateOrgCustomPropertyValues(ctx, orgCustomPropertyValuesMatch[1], reqBody); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/properties/values.`); + } + + const orgIssueFieldMatch = path.match(ORGS_ISSUE_FIELD_RE); + if (orgIssueFieldMatch) { + if (method === 'PATCH') { + return handleUpdateOrgIssueField(ctx, orgIssueFieldMatch[1], orgIssueFieldMatch[2], reqBody); + } + if (method === 'DELETE') { + return handleDeleteOrgIssueField(ctx, orgIssueFieldMatch[1], orgIssueFieldMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/issue-fields/:issue_field_id.`); + } + + const orgIssueFieldsMatch = path.match(ORGS_ISSUE_FIELDS_RE); + if (orgIssueFieldsMatch) { + if (method === 'GET') { + return handleListOrgIssueFields(ctx, orgIssueFieldsMatch[1], url); + } + if (method === 'POST') { + return handleCreateOrgIssueField(ctx, orgIssueFieldsMatch[1], reqBody); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/issue-fields.`); + } + + const orgIssueTypeMatch = path.match(ORGS_ISSUE_TYPE_RE); + if (orgIssueTypeMatch) { + if (method === 'PUT') { + return handleUpdateOrgIssueType(ctx, orgIssueTypeMatch[1], orgIssueTypeMatch[2], reqBody); + } + if (method === 'DELETE') { + return handleDeleteOrgIssueType(ctx, orgIssueTypeMatch[1], orgIssueTypeMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/issue-types/:issue_type_id.`); + } + + const orgIssueTypesMatch = path.match(ORGS_ISSUE_TYPES_RE); + if (orgIssueTypesMatch) { + if (method === 'GET') { + return handleListOrgIssueTypes(ctx, orgIssueTypesMatch[1], url); + } + if (method === 'POST') { + return handleCreateOrgIssueType(ctx, orgIssueTypesMatch[1], reqBody); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/issue-types.`); + } + + const orgOutsideCollaboratorMatch = path.match(ORGS_OUTSIDE_COLLABORATOR_RE); + if (orgOutsideCollaboratorMatch) { + if (method === 'PUT') { + return handleConvertOrgMemberToOutsideCollaborator(ctx, orgOutsideCollaboratorMatch[1], orgOutsideCollaboratorMatch[2], reqBody); + } + if (method === 'DELETE') { + return handleRemoveOutsideCollaborator(ctx, orgOutsideCollaboratorMatch[1], orgOutsideCollaboratorMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/outside_collaborators/:username.`); + } + + const orgOutsideCollaboratorsMatch = path.match(ORGS_OUTSIDE_COLLABORATORS_RE); + if (orgOutsideCollaboratorsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/outside_collaborators.`); + } + return handleListOutsideCollaborators(ctx, orgOutsideCollaboratorsMatch[1], url); + } + + const orgMembershipMatch = path.match(ORGS_MEMBERSHIP_RE); + if (orgMembershipMatch) { + if (method === 'GET') { + return handleGetOrgMembership(ctx, orgMembershipMatch[1], orgMembershipMatch[2], url); + } + if (method === 'PUT') { + return handleSetOrgMembership(ctx, orgMembershipMatch[1], orgMembershipMatch[2], reqBody, url); + } + if (method === 'DELETE') { + return handleRemoveOrgMembership(ctx, orgMembershipMatch[1], orgMembershipMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/memberships/:username.`); + } + + const orgMemberMatch = path.match(ORGS_MEMBER_RE); + if (orgMemberMatch) { + if (method === 'GET') { + return handleCheckOrgMember(ctx, orgMemberMatch[1], orgMemberMatch[2]); + } + if (method === 'DELETE') { + return handleRemoveOrgMembership(ctx, orgMemberMatch[1], orgMemberMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/members/:username.`); + } + + const orgMembersMatch = path.match(ORGS_MEMBERS_RE); + if (orgMembersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/members.`); + } + return handleListOrgMembers(ctx, orgMembersMatch[1], url); + } + + const orgPublicMemberMatch = path.match(ORGS_PUBLIC_MEMBER_RE); + if (orgPublicMemberMatch) { + if (method === 'GET') { + return handleCheckPublicOrgMember(ctx, orgPublicMemberMatch[1], orgPublicMemberMatch[2]); + } + if (method === 'PUT') { + return handleSetPublicOrgMembership(ctx, orgPublicMemberMatch[1], orgPublicMemberMatch[2]); + } + if (method === 'DELETE') { + return handleRemovePublicOrgMembership(ctx, orgPublicMemberMatch[1], orgPublicMemberMatch[2]); + } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/public_members/:username.`); + } + + const orgPublicMembersMatch = path.match(ORGS_PUBLIC_MEMBERS_RE); + if (orgPublicMembersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/public_members.`); + } + return handleListOrgMembers(ctx, orgPublicMembersMatch[1], url, true); + } + + const orgReposMatch = path.match(ORGS_REPOS_RE); + if (orgReposMatch) { + if (method === 'GET') { return handleListOrgRepos(ctx, orgReposMatch[1], url); } + if (method === 'POST') { return handleCreateOrgRepo(ctx, orgReposMatch[1], reqBody, url, options); } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/repos.`); + } + + const orgCodeScanningAlertsMatch = path.match(ORGS_CODE_SCANNING_ALERTS_RE); + if (orgCodeScanningAlertsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/code-scanning/alerts.`); + } + return handleListOrgCodeScanningAlerts(ctx, orgCodeScanningAlertsMatch[1], url); + } + + const orgDependabotAlertsMatch = path.match(ORGS_DEPENDABOT_ALERTS_RE); + if (orgDependabotAlertsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/dependabot/alerts.`); + } + return handleListOrgDependabotAlerts(ctx, orgDependabotAlertsMatch[1], url); + } + + const orgSecretScanningAlertsMatch = path.match(ORGS_SECRET_SCANNING_ALERTS_RE); + if (orgSecretScanningAlertsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/secret-scanning/alerts.`); + } + return handleListOrgSecretScanningAlerts(ctx, orgSecretScanningAlertsMatch[1], url); + } + + const orgSecurityAdvisoriesMatch = path.match(ORGS_SECURITY_ADVISORIES_RE); + if (orgSecurityAdvisoriesMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/security-advisories.`); + } + return handleListOrgSecurityAdvisories(ctx, orgSecurityAdvisoriesMatch[1], url); + } + + const orgIssuesMatch = path.match(ORGS_ISSUES_RE); + if (orgIssuesMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org/issues.`); + } + return handleListOrgIssues(ctx, orgIssuesMatch[1], url, githubBodyMediaKind(options.accept)); + } + + const orgMatch = path.match(ORGS_RE); + if (orgMatch) { + if (method === 'GET') { return handleGetOrg(ctx, orgMatch[1], url); } + if (method === 'PATCH') { return handleUpdateOrg(ctx, orgMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /orgs/:org.`); + } + + if (path === '/gists') { + if (method === 'GET') { return handleListAuthenticatedGists(ctx, url); } + if (method === 'POST') { return handleCreateGist(ctx, reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists.`); + } + + if (path === '/gists/public') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /gists/public.`); + } + return handleListPublicGists(ctx, url); + } + + if (path === '/gists/starred') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /gists/starred.`); + } + return handleListStarredGists(ctx, url); + } + + const gistCommitsMatch = path.match(GISTS_COMMITS_RE); + if (gistCommitsMatch) { + if (method === 'GET') { return handleListGistCommits(ctx, gistCommitsMatch[1], url); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/commits.`); + } + + const gistCommentsMatch = path.match(GISTS_COMMENTS_RE); + if (gistCommentsMatch) { + if (method === 'GET') { return handleListGistComments(ctx, gistCommentsMatch[1], url); } + if (method === 'POST') { return handleCreateGistComment(ctx, gistCommentsMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/comments.`); + } + + const gistCommentMatch = path.match(GISTS_COMMENT_RE); + if (gistCommentMatch) { + if (method === 'GET') { return handleGetGistComment(ctx, gistCommentMatch[1], gistCommentMatch[2], url); } + if (method === 'PATCH') { return handleUpdateGistComment(ctx, gistCommentMatch[1], gistCommentMatch[2], reqBody, url); } + if (method === 'DELETE') { return handleDeleteGistComment(ctx, gistCommentMatch[1], gistCommentMatch[2]); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/comments/:comment_id.`); + } + + const gistForksMatch = path.match(GISTS_FORKS_RE); + if (gistForksMatch) { + if (method === 'GET') { return handleListGistForks(ctx, gistForksMatch[1], url); } + if (method === 'POST') { return handleForkGist(ctx, gistForksMatch[1], url); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/forks.`); + } + + const gistStarMatch = path.match(GISTS_STAR_RE); + if (gistStarMatch) { + if (method === 'GET') { return handleCheckGistStar(ctx, gistStarMatch[1]); } + if (method === 'PUT') { return handleStarGist(ctx, gistStarMatch[1]); } + if (method === 'DELETE') { return handleUnstarGist(ctx, gistStarMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/star.`); + } + + const gistRawMatch = path.match(GISTS_RAW_RE); + if (gistRawMatch) { + if (method === 'GET') { return handleGetGistRawFile(ctx, gistRawMatch[1], gistRawMatch[2]); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/raw/:filename.`); + } + + const gistRevisionMatch = path.match(GISTS_REVISION_RE); + if (gistRevisionMatch) { + if (method === 'GET') { return handleGetGistRevision(ctx, gistRevisionMatch[1], gistRevisionMatch[2], url); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id/:sha.`); + } + + const gistMatch = path.match(GISTS_RE); + if (gistMatch) { + if (method === 'GET') { return handleGetGist(ctx, gistMatch[1], url); } + if (method === 'PATCH') { return handleUpdateGist(ctx, gistMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteGist(ctx, gistMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /gists/:gist_id.`); + } + + // ------------------------------------------------------------------------- + // /user/followers, /user/following, and /user/following/:did + // ------------------------------------------------------------------------- + if (path === '/user') { + if (method === 'GET') { return handleGetAuthenticatedUser(ctx, url); } + if (method === 'PATCH') { return handleUpdateAuthenticatedUser(ctx, url, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /user.`); + } + + const userAccountMatch = path.match(USER_ACCOUNT_RE); + if (userAccountMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/:account_id.`); + } + return handleGetUserById(ctx, userAccountMatch[1], url); + } + + if (path === '/user/repos') { + if (method === 'GET') { return handleListAuthenticatedRepos(ctx, url); } + if (method === 'POST') { return handleCreateUserRepo(ctx, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/repos.`); + } + + if (path === '/users') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users.`); + } + return handleListUsers(ctx, url); + } + + if (path === '/user/issues') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/issues.`); + } + return handleListAuthenticatedUserIssues(ctx, url, githubBodyMediaKind(options.accept)); + } + + if (path === '/user/orgs') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/orgs.`); + } + return handleListAuthenticatedOrgs(ctx, url); + } + + if (path === '/user/memberships/orgs') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/memberships/orgs.`); + } + return handleListAuthenticatedOrgMemberships(ctx, url); + } + + const userMembershipOrgMatch = path.match(USER_MEMBERSHIP_ORG_RE); + if (userMembershipOrgMatch) { + if (method === 'GET') { return handleGetAuthenticatedOrgMembership(ctx, userMembershipOrgMatch[1], url); } + if (method === 'PATCH') { return handleUpdateAuthenticatedOrgMembership(ctx, userMembershipOrgMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/memberships/orgs/:org.`); + } + + if (path === '/user/teams') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/teams.`); + } + return handleListAuthenticatedUserTeams(ctx, url); + } + + if (path === '/user/emails') { + if (method === 'GET') { return handleListAuthenticatedEmails(ctx, url); } + if (method === 'POST') { return handleAddAuthenticatedEmails(ctx, reqBody); } + if (method === 'DELETE') { return handleDeleteAuthenticatedEmails(ctx, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/emails.`); + } + + if (path === '/user/public_emails') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/public_emails.`); + } + return handleListAuthenticatedPublicEmails(ctx, url); + } + + if (path === '/user/email/visibility') { + if (method !== 'PATCH') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/email/visibility.`); + } + return handleSetPrimaryEmailVisibility(ctx, reqBody); + } + + if (path === '/user/gpg_keys') { + if (method === 'GET') { return handleListAuthenticatedGpgKeys(ctx, url); } + if (method === 'POST') { return handleCreateAuthenticatedGpgKey(ctx, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/gpg_keys.`); + } + + const userGpgKeyMatch = path.match(USER_GPG_KEY_RE); + if (userGpgKeyMatch) { + if (method === 'GET') { return handleGetAuthenticatedGpgKey(ctx, userGpgKeyMatch[1]); } + if (method === 'DELETE') { return handleDeleteAuthenticatedGpgKey(ctx, userGpgKeyMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/gpg_keys/:gpg_key_id.`); + } + + if (path === '/user/social_accounts') { + if (method === 'GET') { return handleListAuthenticatedSocialAccounts(ctx, url); } + if (method === 'POST') { return handleAddAuthenticatedSocialAccounts(ctx, reqBody); } + if (method === 'DELETE') { return handleDeleteAuthenticatedSocialAccounts(ctx, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/social_accounts.`); + } + + if (path === '/user/keys') { + if (method === 'GET') { return handleListAuthenticatedSshKeys(ctx, url); } + if (method === 'POST') { return handleCreateAuthenticatedSshKey(ctx, reqBody, url); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/keys.`); + } + + const userKeyMatch = path.match(USER_KEY_RE); + if (userKeyMatch) { + if (method === 'GET') { return handleGetAuthenticatedSshKey(ctx, userKeyMatch[1], url); } + if (method === 'DELETE') { return handleDeleteAuthenticatedSshKey(ctx, userKeyMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/keys/:key_id.`); + } + + if (path === '/user/ssh_signing_keys') { + if (method === 'GET') { return handleListAuthenticatedSshSigningKeys(ctx, url); } + if (method === 'POST') { return handleCreateAuthenticatedSshSigningKey(ctx, reqBody); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/ssh_signing_keys.`); + } + + const userSshSigningKeyMatch = path.match(USER_SSH_SIGNING_KEY_RE); + if (userSshSigningKeyMatch) { + if (method === 'GET') { return handleGetAuthenticatedSshSigningKey(ctx, userSshSigningKeyMatch[1]); } + if (method === 'DELETE') { return handleDeleteAuthenticatedSshSigningKey(ctx, userSshSigningKeyMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/ssh_signing_keys/:ssh_signing_key_id.`); + } + + if (path === '/user/blocks') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/blocks.`); + } + return handleListBlockedUsers(ctx, url); + } + + const userBlockMatch = path.match(USER_BLOCK_RE); + if (userBlockMatch) { + if (method === 'GET') { return handleCheckBlockedUser(ctx, userBlockMatch[1]); } + if (method === 'PUT') { return handleBlockUser(ctx, userBlockMatch[1]); } + if (method === 'DELETE') { return handleUnblockUser(ctx, userBlockMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/blocks/:username.`); + } + + if (path === '/user/followers') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/followers.`); + } + return handleListFollowers(ctx, ctx.did, url); + } + + if (path === '/user/following') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/following.`); + } + return handleListFollowing(ctx, ctx.did, url); + } + + const userFollowingMatch = path.match(USER_FOLLOWING_RE); + if (userFollowingMatch) { + if (method === 'GET') { return handleCheckFollowing(ctx, ctx.did, userFollowingMatch[1]); } + if (method === 'PUT') { return handleFollowUser(ctx, userFollowingMatch[1]); } + if (method === 'DELETE') { return handleUnfollowUser(ctx, userFollowingMatch[1]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/following/:username.`); + } + + // ------------------------------------------------------------------------- + // /user/starred and /user/starred/:did/:repo + // ------------------------------------------------------------------------- + if (path === '/user/starred') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/starred.`); + } + return handleListStarredRepos(ctx, ctx.did, url); + } + + if (path === '/user/subscriptions') { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /user/subscriptions.`); + } + return handleListSubscriptions(ctx, ctx.did, url); + } + + const userStarredMatch = path.match(USER_STARRED_RE); + if (userStarredMatch) { + if (method === 'GET') { return handleCheckStarredRepo(ctx, userStarredMatch[1], userStarredMatch[2]); } + if (method === 'PUT') { return handleStarRepo(ctx, userStarredMatch[1], userStarredMatch[2]); } + if (method === 'DELETE') { return handleUnstarRepo(ctx, userStarredMatch[1], userStarredMatch[2]); } + return jsonMethodNotAllowed(`${method} is not allowed on /user/starred/:owner/:repo.`); + } + + // ------------------------------------------------------------------------- + // GET /users/:did/followers, /following, /following/:target_did + // ------------------------------------------------------------------------- + const userReposMatch = path.match(USERS_REPOS_RE); + if (userReposMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/repos.`); + } + return handleListUserRepos(ctx, userReposMatch[1], url); + } + + const userGistsMatch = path.match(USERS_GISTS_RE); + if (userGistsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/gists.`); + } + return handleListUserGists(ctx, userGistsMatch[1], url); + } + + const userGpgKeysMatch = path.match(USERS_GPG_KEYS_RE); + if (userGpgKeysMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/gpg_keys.`); + } + return handleListUserGpgKeys(ctx, userGpgKeysMatch[1], url); + } + + const userSocialAccountsMatch = path.match(USERS_SOCIAL_ACCOUNTS_RE); + if (userSocialAccountsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/social_accounts.`); + } + return handleListUserSocialAccounts(ctx, userSocialAccountsMatch[1], url); + } + + const userKeysMatch = path.match(USERS_KEYS_RE); + if (userKeysMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/keys.`); + } + return handleListUserSshKeys(ctx, userKeysMatch[1], url); + } + + const userSshSigningKeysMatch = path.match(USERS_SSH_SIGNING_KEYS_RE); + if (userSshSigningKeysMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/ssh_signing_keys.`); + } + return handleListUserSshSigningKeys(ctx, userSshSigningKeysMatch[1], url); + } + + const userOrgsMatch = path.match(USERS_ORGS_RE); + if (userOrgsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/orgs.`); + } + return handleListUserOrgs(ctx, userOrgsMatch[1], url); + } + + const userHovercardMatch = path.match(USERS_HOVERCARD_RE); + if (userHovercardMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/hovercard.`); + } + return handleGetUserHovercard(ctx, userHovercardMatch[1], url); + } + + const userAttestationsBulkListMatch = path.match(USERS_ATTESTATIONS_BULK_LIST_RE); + if (userAttestationsBulkListMatch) { + if (method !== 'POST') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/attestations/bulk-list.`); + } + return handleBulkListUserAttestations(ctx, userAttestationsBulkListMatch[1], reqBody, url); + } + + const userAttestationsDigestMatch = path.match(USERS_ATTESTATIONS_DIGEST_RE); + if (userAttestationsDigestMatch) { + if (method !== 'DELETE') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/attestations/digest/:subject_digest.`); + } + return handleDeleteUserAttestationsBySubjectDigest( + ctx, userAttestationsDigestMatch[1], userAttestationsDigestMatch[2], url, + ); + } + + const userAttestationIdMatch = path.match(USERS_ATTESTATION_ID_RE); + if (userAttestationIdMatch) { + if (method !== 'DELETE') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/attestations/:attestation_id.`); + } + return handleDeleteUserAttestationById(ctx, userAttestationIdMatch[1], userAttestationIdMatch[2], url); + } + + const userAttestationsSubjectMatch = path.match(USERS_ATTESTATIONS_SUBJECT_RE); + if (userAttestationsSubjectMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/attestations/:subject_digest.`); + } + return handleListUserAttestations(ctx, userAttestationsSubjectMatch[1], userAttestationsSubjectMatch[2], url); + } + + const userAttestationsMatch = path.match(USERS_ATTESTATIONS_RE); + if (userAttestationsMatch) { + if (method !== 'DELETE') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/attestations.`); + } + return handleBulkDeleteUserAttestations(ctx, userAttestationsMatch[1], reqBody, url); + } + + const userFollowsTargetMatch = path.match(USERS_FOLLOWING_TARGET_RE); + if (userFollowsTargetMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/following/:target_did.`); + } + return handleCheckFollowing(ctx, userFollowsTargetMatch[1], userFollowsTargetMatch[2]); + } + + const userEventsPublicMatch = path.match(USERS_EVENTS_PUBLIC_RE); + if (userEventsPublicMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/events/public.`); + } + return handleListUserEvents(ctx, userEventsPublicMatch[1], url, true); + } + + const userEventsMatch = path.match(USERS_EVENTS_RE); + if (userEventsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/events.`); + } + return handleListUserEvents(ctx, userEventsMatch[1], url); + } + + const userReceivedEventsPublicMatch = path.match(USERS_RECEIVED_EVENTS_PUBLIC_RE); + if (userReceivedEventsPublicMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/received_events/public.`); + } + return handleListReceivedEvents(ctx, userReceivedEventsPublicMatch[1], url, true); + } + + const userReceivedEventsMatch = path.match(USERS_RECEIVED_EVENTS_RE); + if (userReceivedEventsMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/received_events.`); + } + return handleListReceivedEvents(ctx, userReceivedEventsMatch[1], url); + } + + const userFollowersMatch = path.match(USERS_FOLLOWERS_RE); + if (userFollowersMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/followers.`); + } + return handleListFollowers(ctx, userFollowersMatch[1], url); + } + + const userFollowingListMatch = path.match(USERS_FOLLOWING_RE); + if (userFollowingListMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/following.`); + } + return handleListFollowing(ctx, userFollowingListMatch[1], url); + } + + // ------------------------------------------------------------------------- + // GET /users/:did/starred + // ------------------------------------------------------------------------- + const userStarredListMatch = path.match(USERS_STARRED_RE); + if (userStarredListMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/starred.`); + } + return handleListStarredRepos(ctx, userStarredListMatch[1], url); + } + + const userSubscriptionsListMatch = path.match(USERS_SUBSCRIPTIONS_RE); + if (userSubscriptionsListMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users/:did/subscriptions.`); + } + return handleListSubscriptions(ctx, userSubscriptionsListMatch[1], url); + } + + // ------------------------------------------------------------------------- + // GET /users/:did + // ------------------------------------------------------------------------- + const userMatch = path.match(USERS_RE); + if (userMatch) { + if (method !== 'GET') { + return jsonMethodNotAllowed(`${method} is not allowed on /users endpoints.`); + } + return handleGetUser(ctx, userMatch[1], url); + } + + // ------------------------------------------------------------------------- + // /repos/:did/:repo/... + // ------------------------------------------------------------------------- + const repoMatch = path.match(REPOS_RE); + if (!repoMatch) { + return jsonNotFound('Not found'); + } + + const targetDid = repoMatch[1]; + const repoName = repoMatch[2]; + const rest = repoMatch[3] ?? ''; + + // Try/catch — DID resolution failures should return 502. + try { + return await dispatchRepoRoute(ctx, targetDid, repoName, rest, url, method, reqBody, options); + } catch (err) { + const msg = (err as Error).message ?? 'Unknown error'; + return { + status : 502, + headers : baseHeaders(), + body : JSON.stringify({ message: `DWN error: ${msg}` }), + }; + } +} + +/** + * Dispatch to the correct handler within the `/repos/:did/:repo/...` + * namespace. Considers both the URL path and the HTTP method. + */ +async function dispatchRepoRoute( + ctx: AgentContext, targetDid: string, repoName: string, + rest: string, url: URL, method: string, reqBody: Record<string, unknown>, options: ShimRequestOptions, +): Promise<JsonResponse> { + // GET/PATCH/DELETE /repos/:did/:repo + if (rest === '' || rest === '/') { + if (method === 'GET') { return handleGetRepo(ctx, targetDid, repoName, url); } + if (method === 'PATCH') { return handleUpdateRepo(ctx, targetDid, repoName, reqBody, url, options); } + if (method === 'DELETE') { return handleDeleteRepo(ctx, targetDid, repoName, options); } + return jsonMethodNotAllowed(`${method} not allowed on /repos/:did/:repo.`); + } + + // /repos/:did/:repo/forks + if (rest === '/forks') { + if (method === 'GET') { return handleListForks(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateFork(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /forks.`); + } + + // POST /repos/:did/:repo/generate + if (rest === '/generate') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /generate.`); } + return handleGenerateRepoFromTemplate(ctx, targetDid, repoName, reqBody, url, options); + } + + // POST /repos/:did/:repo/transfer + if (rest === '/transfer') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /transfer.`); } + return handleTransferRepo(ctx, targetDid, repoName, reqBody, url); + } + + // GET /repos/:did/:repo/events + if (rest === '/events') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /events.`); } + return handleListRepoEvents(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/activity + if (rest === '/activity') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /activity.`); } + return handleListRepoActivity(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/readme + if (rest === '/readme') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /readme.`); } + const mediaKind = githubReadmeMediaKind(options.accept); + const gitResponse = await tryGetGitReadme(ctx, targetDid, repoName, url, options, mediaKind); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetReadme(ctx, targetDid, repoName, url, mediaKind); + } + + // GET /repos/:did/:repo/readme/:dir + const readmeDirectoryMatch = rest.match(/^\/readme\/(.+)$/); + if (readmeDirectoryMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /readme/:dir.`); } + const mediaKind = githubReadmeMediaKind(options.accept); + const gitResponse = await tryGetGitReadmeInDirectory( + ctx, targetDid, repoName, readmeDirectoryMatch[1], url, options, mediaKind, + ); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetReadmeInDirectory(ctx, targetDid, repoName, readmeDirectoryMatch[1]); + } + + // GET /repos/:did/:repo/license + if (rest === '/license') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /license.`); } + const mediaKind = githubLicenseMediaKind(options.accept); + const gitResponse = await tryGetGitLicense(ctx, targetDid, repoName, url, options, mediaKind); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetLicense(ctx, targetDid, repoName, url, mediaKind); + } + + // GET /repos/:did/:repo/contents[/path] + if (rest === '/contents' || rest === '/contents/') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /contents.`); } + const mediaKind = githubContentMediaKind(options.accept); + const gitResponse = await tryGetGitContents(ctx, targetDid, repoName, null, url, options, mediaKind); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetContents(ctx, targetDid, repoName, null, url, mediaKind); + } + + const contentsMatch = rest.match(/^\/contents\/(.+)$/); + if (contentsMatch) { + if (method === 'GET') { + const mediaKind = githubContentMediaKind(options.accept); + const gitResponse = await tryGetGitContents(ctx, targetDid, repoName, contentsMatch[1], url, options, mediaKind); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetContents(ctx, targetDid, repoName, contentsMatch[1], url, mediaKind); + } + if (method === 'PUT') { + return handlePutGitContents(ctx, targetDid, repoName, contentsMatch[1], reqBody, url, options); + } + if (method === 'DELETE') { + return handleDeleteGitContents(ctx, targetDid, repoName, contentsMatch[1], reqBody, url, options); + } + return jsonMethodNotAllowed(`${method} not allowed on /contents/:path.`); + } + + // GET /repos/:did/:repo/raw/:ref/:path + const rawContentMatch = rest.match(/^\/raw\/([^/]+)\/(.+)$/); + if (rawContentMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /raw/:ref/:path.`); } + const gitResponse = await tryGetGitRawContent(ctx, targetDid, repoName, rawContentMatch[1], rawContentMatch[2], options); + if (gitResponse.kind === 'response') { return gitResponse.response; } + return handleGetRawContent(ctx, targetDid, repoName, rawContentMatch[1], rawContentMatch[2]); + } + + // GET /repos/:did/:repo/tarball[/:ref] + if (rest === '/tarball') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /tarball.`); } + return handleDownloadArchive(ctx, targetDid, repoName, 'tarball', null, url, options); + } + const tarballMatch = rest.match(/^\/tarball\/(.+)$/); + if (tarballMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /tarball/:ref.`); } + return handleDownloadArchive(ctx, targetDid, repoName, 'tarball', tarballMatch[1], url, options); + } + + // GET /repos/:did/:repo/zipball[/:ref] + if (rest === '/zipball') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /zipball.`); } + return handleDownloadArchive(ctx, targetDid, repoName, 'zipball', null, url, options); + } + const zipballMatch = rest.match(/^\/zipball\/(.+)$/); + if (zipballMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /zipball/:ref.`); } + return handleDownloadArchive(ctx, targetDid, repoName, 'zipball', zipballMatch[1], url, options); + } + + // GET /repos/:did/:repo/branches + if (rest === '/branches') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /branches.`); } + return handleListBranches(ctx, targetDid, repoName, url); + } + + // /repos/:did/:repo/branches/:branch/protection/enforce_admins + const enforceAdminsMatch = rest.match(/^\/branches\/(.+)\/protection\/enforce_admins$/); + if (enforceAdminsMatch) { + if (method === 'GET') { return handleGetAdminBranchProtection(ctx, targetDid, repoName, enforceAdminsMatch[1], url); } + if (method === 'POST') { return handleSetAdminBranchProtection(ctx, targetDid, repoName, enforceAdminsMatch[1], url); } + if (method === 'DELETE') { return handleDeleteAdminBranchProtection(ctx, targetDid, repoName, enforceAdminsMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/enforce_admins.`); + } + + // /repos/:did/:repo/branches/:branch/protection/required_signatures + const requiredSignaturesMatch = rest.match(/^\/branches\/(.+)\/protection\/required_signatures$/); + if (requiredSignaturesMatch) { + if (method === 'GET') { return handleGetCommitSignatureProtection(ctx, targetDid, repoName, requiredSignaturesMatch[1], url); } + if (method === 'POST') { return handleCreateCommitSignatureProtection(ctx, targetDid, repoName, requiredSignaturesMatch[1], url); } + if (method === 'DELETE') { return handleDeleteCommitSignatureProtection(ctx, targetDid, repoName, requiredSignaturesMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/required_signatures.`); + } + + // /repos/:did/:repo/branches/:branch/protection/restrictions/:kind + const branchRestrictionActorsMatch = rest.match(/^\/branches\/(.+)\/protection\/restrictions\/(users|teams|apps)$/); + if (branchRestrictionActorsMatch) { + const kind = branchRestrictionActorsMatch[2] as 'apps' | 'teams' | 'users'; + if (method === 'GET') { + return handleListBranchAccessRestrictionActors(ctx, targetDid, repoName, branchRestrictionActorsMatch[1], kind, url); + } + if (method === 'POST') { + return handleAddBranchAccessRestrictionActors(ctx, targetDid, repoName, branchRestrictionActorsMatch[1], kind, reqBody, url); + } + if (method === 'PUT') { + return handleSetBranchAccessRestrictionActors(ctx, targetDid, repoName, branchRestrictionActorsMatch[1], kind, reqBody, url); + } + if (method === 'DELETE') { + return handleRemoveBranchAccessRestrictionActors(ctx, targetDid, repoName, branchRestrictionActorsMatch[1], kind, reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/restrictions/:kind.`); + } + + // /repos/:did/:repo/branches/:branch/protection/restrictions + const branchRestrictionsMatch = rest.match(/^\/branches\/(.+)\/protection\/restrictions$/); + if (branchRestrictionsMatch) { + if (method === 'GET') { return handleGetBranchAccessRestrictions(ctx, targetDid, repoName, branchRestrictionsMatch[1], url); } + if (method === 'DELETE') { return handleDeleteBranchAccessRestrictions(ctx, targetDid, repoName, branchRestrictionsMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/restrictions.`); + } + + // /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts + const statusCheckContextsMatch = rest.match(/^\/branches\/(.+)\/protection\/required_status_checks\/contexts$/); + if (statusCheckContextsMatch) { + if (method === 'GET') { return handleListStatusCheckContexts(ctx, targetDid, repoName, statusCheckContextsMatch[1]); } + if (method === 'POST') { return handleAddStatusCheckContexts(ctx, targetDid, repoName, statusCheckContextsMatch[1], reqBody); } + if (method === 'PUT') { return handleSetStatusCheckContexts(ctx, targetDid, repoName, statusCheckContextsMatch[1], reqBody); } + if (method === 'DELETE') { return handleRemoveStatusCheckContexts(ctx, targetDid, repoName, statusCheckContextsMatch[1], reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/required_status_checks/contexts.`); + } + + // /repos/:did/:repo/branches/:branch/protection/required_status_checks + const requiredStatusChecksMatch = rest.match(/^\/branches\/(.+)\/protection\/required_status_checks$/); + if (requiredStatusChecksMatch) { + if (method === 'GET') { return handleGetRequiredStatusChecksProtection(ctx, targetDid, repoName, requiredStatusChecksMatch[1], url); } + if (method === 'PATCH') { return handleUpdateRequiredStatusChecksProtection(ctx, targetDid, repoName, requiredStatusChecksMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteRequiredStatusChecksProtection(ctx, targetDid, repoName, requiredStatusChecksMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/required_status_checks.`); + } + + // /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews + const pullRequestReviewsProtectionMatch = rest.match(/^\/branches\/(.+)\/protection\/required_pull_request_reviews$/); + if (pullRequestReviewsProtectionMatch) { + if (method === 'GET') { return handleGetPullRequestReviewProtection(ctx, targetDid, repoName, pullRequestReviewsProtectionMatch[1], url); } + if (method === 'PATCH') { return handleUpdatePullRequestReviewProtection(ctx, targetDid, repoName, pullRequestReviewsProtectionMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeletePullRequestReviewProtection(ctx, targetDid, repoName, pullRequestReviewsProtectionMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection/required_pull_request_reviews.`); + } + + // /repos/:did/:repo/branches/:branch/protection + const branchProtectionMatch = rest.match(/^\/branches\/(.+)\/protection$/); + if (branchProtectionMatch) { + if (method === 'GET') { return handleGetBranchProtection(ctx, targetDid, repoName, branchProtectionMatch[1], url); } + if (method === 'PUT') { return handleUpdateBranchProtection(ctx, targetDid, repoName, branchProtectionMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteBranchProtection(ctx, targetDid, repoName, branchProtectionMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch/protection.`); + } + + // GET /repos/:did/:repo/branches/:branch + const branchMatch = rest.match(/^\/branches\/(.+)$/); + if (branchMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /branches/:branch.`); } + return handleGetBranch(ctx, targetDid, repoName, branchMatch[1], url); + } + + // GET /repos/:did/:repo/tags + if (rest === '/tags') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /tags.`); } + return handleListTags(ctx, targetDid, repoName, url, options); + } + + // GET /repos/:did/:repo/teams + if (rest === '/teams') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /teams.`); } + return handleListRepoTeams(ctx, targetDid, repoName, url); + } + + // POST /repos/:did/:repo/git/refs + if (rest === '/git/refs') { + if (method === 'POST') { return handleCreateGitRef(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/refs.`); + } + + // GET/PATCH/DELETE /repos/:did/:repo/git/ref/:ref (and /git/refs/:ref for tolerance) + const gitRefMatch = rest.match(/^\/git\/refs?\/(.+)$/); + if (gitRefMatch) { + if (method === 'GET') { return handleGetGitRef(ctx, targetDid, repoName, gitRefMatch[1], url); } + if (method === 'PATCH') { return handleUpdateGitRef(ctx, targetDid, repoName, gitRefMatch[1], reqBody, url, options); } + if (method === 'DELETE') { return handleDeleteGitRef(ctx, targetDid, repoName, gitRefMatch[1], options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/ref/:ref.`); + } + + // GET /repos/:did/:repo/git/matching-refs/:ref + const gitMatchingRefsMatch = rest.match(/^\/git\/matching-refs\/(.+)$/); + if (gitMatchingRefsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /git/matching-refs/:ref.`); } + return handleListMatchingGitRefs(ctx, targetDid, repoName, gitMatchingRefsMatch[1], url); + } + + // POST /repos/:did/:repo/git/blobs + if (rest === '/git/blobs') { + if (method === 'POST') { return handleCreateGitBlob(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/blobs.`); + } + + // GET /repos/:did/:repo/git/blobs/:sha + const gitBlobMatch = rest.match(/^\/git\/blobs\/([^/]+)$/); + if (gitBlobMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /git/blobs/:sha.`); } + return handleGetGitBlob(ctx, targetDid, repoName, gitBlobMatch[1], url, options, githubBlobMediaKind(options.accept)); + } + + // POST /repos/:did/:repo/git/trees + if (rest === '/git/trees') { + if (method === 'POST') { return handleCreateGitTree(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/trees.`); + } + + // GET /repos/:did/:repo/git/trees/:sha + const gitTreeMatch = rest.match(/^\/git\/trees\/([^/]+)$/); + if (gitTreeMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /git/trees/:sha.`); } + return handleGetGitTree(ctx, targetDid, repoName, gitTreeMatch[1], url, options); + } + + // POST /repos/:did/:repo/git/commits + if (rest === '/git/commits') { + if (method === 'POST') { return handleCreateGitCommit(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/commits.`); + } + + // GET /repos/:did/:repo/git/commits/:sha + const gitCommitMatch = rest.match(/^\/git\/commits\/([^/]+)$/); + if (gitCommitMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /git/commits/:sha.`); } + return handleGetGitCommit(ctx, targetDid, repoName, gitCommitMatch[1], url, options); + } + + // POST /repos/:did/:repo/git/tags + if (rest === '/git/tags') { + if (method === 'POST') { return handleCreateGitTag(ctx, targetDid, repoName, reqBody, url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /git/tags.`); + } + + // GET /repos/:did/:repo/git/tags/:sha + const gitTagMatch = rest.match(/^\/git\/tags\/([^/]+)$/); + if (gitTagMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /git/tags/:sha.`); } + return handleGetGitTag(ctx, targetDid, repoName, gitTagMatch[1], url, options); + } + + // GET /repos/:did/:repo/commits + if (rest === '/commits') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits.`); } + return handleListRepoCommits(ctx, targetDid, repoName, url, options); + } + + // /repos/:did/:repo/commits/:sha/comments + const commitCommentsMatch = rest.match(/^\/commits\/(.+)\/comments$/); + if (commitCommentsMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'GET') { + return handleListCommitCommentsForSha(ctx, targetDid, repoName, commitCommentsMatch[1], url, bodyMediaKind); + } + if (method === 'POST') { + return handleCreateCommitComment(ctx, targetDid, repoName, commitCommentsMatch[1], reqBody, url, bodyMediaKind); + } + return jsonMethodNotAllowed(`${method} not allowed on /commits/:sha/comments.`); + } + + // GET /repos/:did/:repo/contributors + if (rest === '/contributors') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /contributors.`); } + return handleListContributors(ctx, targetDid, repoName, url, options); + } + + // GET /repos/:did/:repo/community/profile + if (rest === '/community/profile') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /community/profile.`); } + return handleGetCommunityProfile(ctx, targetDid, repoName, url, options); + } + + // GET /repos/:did/:repo/stats/code_frequency + if (rest === '/stats/code_frequency') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stats/code_frequency.`); } + return handleGetStatsCodeFrequency(ctx, targetDid, repoName, options); + } + + // GET /repos/:did/:repo/stats/commit_activity + if (rest === '/stats/commit_activity') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stats/commit_activity.`); } + return handleGetStatsCommitActivity(ctx, targetDid, repoName, options); + } + + // GET /repos/:did/:repo/stats/contributors + if (rest === '/stats/contributors') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stats/contributors.`); } + return handleGetStatsContributors(ctx, targetDid, repoName, url, options); + } + + // GET /repos/:did/:repo/stats/participation + if (rest === '/stats/participation') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stats/participation.`); } + return handleGetStatsParticipation(ctx, targetDid, repoName, options); + } + + // GET /repos/:did/:repo/stats/punch_card + if (rest === '/stats/punch_card') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stats/punch_card.`); } + return handleGetStatsPunchCard(ctx, targetDid, repoName, options); + } + + // GET /repos/:did/:repo/traffic/clones + if (rest === '/traffic/clones') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /traffic/clones.`); } + return handleGetTrafficClones(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/traffic/popular/paths + if (rest === '/traffic/popular/paths') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /traffic/popular/paths.`); } + return handleGetTrafficPopularPaths(ctx, targetDid, repoName); + } + + // GET /repos/:did/:repo/traffic/popular/referrers + if (rest === '/traffic/popular/referrers') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /traffic/popular/referrers.`); } + return handleGetTrafficPopularReferrers(ctx, targetDid, repoName); + } + + // GET /repos/:did/:repo/traffic/views + if (rest === '/traffic/views') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /traffic/views.`); } + return handleGetTrafficViews(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/compare/:base...:head.{diff,patch} + const compareTextMatch = rest.match(/^\/compare\/(.+)\.\.\.(.+)\.(diff|patch)$/); + if (compareTextMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /compare/:base...:head.:format.`); } + return handleCompareCommitText( + ctx, targetDid, repoName, compareTextMatch[1], compareTextMatch[2], + compareTextMatch[3] as 'diff' | 'patch', + options, + ); + } + + // GET /repos/:did/:repo/compare/:base...:head + const compareMatch = rest.match(/^\/compare\/(.+)\.\.\.(.+)$/); + if (compareMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /compare/:base...:head.`); } + const textKind = githubTextMediaKind(options.accept); + if (textKind) { + return handleCompareCommitText(ctx, targetDid, repoName, compareMatch[1], compareMatch[2], textKind, options); + } + return handleCompareCommits(ctx, targetDid, repoName, compareMatch[1], compareMatch[2], url, options); + } + + // GET /repos/:did/:repo/commits/:ref/status + const combinedStatusMatch = rest.match(/^\/commits\/(.+)\/status$/); + if (combinedStatusMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits/:ref/status.`); } + return handleGetCombinedStatus(ctx, targetDid, repoName, combinedStatusMatch[1], url); + } + + // GET /repos/:did/:repo/commits/:ref/statuses + const commitStatusesMatch = rest.match(/^\/commits\/(.+)\/statuses$/); + if (commitStatusesMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits/:ref/statuses.`); } + return handleListCommitStatuses(ctx, targetDid, repoName, commitStatusesMatch[1], url); + } + + // GET /repos/:did/:repo/commits/:ref/check-suites + const commitCheckSuitesMatch = rest.match(/^\/commits\/(.+)\/check-suites$/); + if (commitCheckSuitesMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits/:ref/check-suites.`); } + return handleListCheckSuitesForRef(ctx, targetDid, repoName, commitCheckSuitesMatch[1], url); + } + + // GET /repos/:did/:repo/commits/:ref/check-runs + const commitCheckRunsMatch = rest.match(/^\/commits\/(.+)\/check-runs$/); + if (commitCheckRunsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits/:ref/check-runs.`); } + return handleListCheckRunsForRef(ctx, targetDid, repoName, commitCheckRunsMatch[1], url); + } + + // GET /repos/:did/:repo/commits/:ref + const repoCommitMatch = rest.match(/^\/commits\/(.+)$/); + if (repoCommitMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /commits/:ref.`); } + const mediaKind = githubCommitMediaKind(options.accept); + if (mediaKind) { + return handleGetRepoCommitMedia(ctx, targetDid, repoName, repoCommitMatch[1], mediaKind, options); + } + return handleGetRepoCommit(ctx, targetDid, repoName, repoCommitMatch[1], url, options); + } + + // /repos/:did/:repo/topics + if (rest === '/topics') { + if (method === 'GET') { return handleGetTopics(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleReplaceTopics(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /topics.`); + } + + // GET /repos/:did/:repo/languages + if (rest === '/languages') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /languages.`); } + return handleGetLanguages(ctx, targetDid, repoName); + } + + // GET /repos/:did/:repo/issue-types + if (rest === '/issue-types') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issue-types.`); } + return handleListRepositoryIssueTypes(ctx, targetDid, repoName); + } + + // /repos/:did/:repo/properties/values + if (rest === '/properties/values') { + if (method === 'GET') { return handleGetRepositoryCustomProperties(ctx, targetDid, repoName); } + if (method === 'PATCH') { return handleUpdateRepositoryCustomProperties(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /properties/values.`); + } + + // POST /repos/:did/:repo/dispatches + if (rest === '/dispatches') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /dispatches.`); } + return handleCreateRepositoryDispatch(ctx, targetDid, repoName, reqBody); + } + + // GET /repos/:did/:repo/codeowners/errors + if (rest === '/codeowners/errors') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /codeowners/errors.`); } + return handleListCodeownersErrors(ctx, targetDid, repoName); + } + + // GET /repos/:did/:repo/hash-algorithm + if (rest === '/hash-algorithm') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /hash-algorithm.`); } + return handleGetRepositoryHashAlgorithm(ctx, targetDid, repoName); + } + + // /repos/:did/:repo/attestations + if (rest === '/attestations') { + if (method === 'POST') { return handleCreateRepositoryAttestation(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /attestations.`); + } + + // GET /repos/:did/:repo/attestations/:subject_digest + const attestationMatch = rest.match(/^\/attestations\/(.+)$/); + if (attestationMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /attestations/:subject_digest.`); } + return handleListRepositoryAttestations(ctx, targetDid, repoName, attestationMatch[1], url); + } + + // /repos/:did/:repo/keys + if (rest === '/keys') { + if (method === 'GET') { return handleListDeployKeys(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateDeployKey(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /keys.`); + } + + // /repos/:did/:repo/keys/:key_id + const deployKeyMatch = rest.match(/^\/keys\/(\d+)$/); + if (deployKeyMatch) { + if (method === 'GET') { return handleGetDeployKey(ctx, targetDid, repoName, deployKeyMatch[1], url); } + if (method === 'DELETE') { return handleDeleteDeployKey(ctx, targetDid, repoName, deployKeyMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /keys/:key_id.`); + } + + // /repos/:did/:repo/autolinks + if (rest === '/autolinks') { + if (method === 'GET') { return handleListAutolinks(ctx, targetDid, repoName); } + if (method === 'POST') { return handleCreateAutolink(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /autolinks.`); + } + + // /repos/:did/:repo/autolinks/:autolink_id + const autolinkMatch = rest.match(/^\/autolinks\/(\d+)$/); + if (autolinkMatch) { + if (method === 'GET') { return handleGetAutolink(ctx, targetDid, repoName, autolinkMatch[1]); } + if (method === 'DELETE') { return handleDeleteAutolink(ctx, targetDid, repoName, autolinkMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /autolinks/:autolink_id.`); + } + + // /repos/:did/:repo/interaction-limits + if (rest === '/interaction-limits') { + if (method === 'GET') { return handleGetInteractionLimit(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleSetInteractionLimit(ctx, targetDid, repoName, reqBody); } + if (method === 'DELETE') { return handleDeleteInteractionLimit(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /interaction-limits.`); + } + + // /repos/:did/:repo/vulnerability-alerts + if (rest === '/vulnerability-alerts') { + if (method === 'GET') { return handleCheckVulnerabilityAlerts(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleEnableVulnerabilityAlerts(ctx, targetDid, repoName); } + if (method === 'DELETE') { return handleDisableVulnerabilityAlerts(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /vulnerability-alerts.`); + } + + // /repos/:did/:repo/automated-security-fixes + if (rest === '/automated-security-fixes') { + if (method === 'GET') { return handleCheckAutomatedSecurityFixes(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleEnableAutomatedSecurityFixes(ctx, targetDid, repoName); } + if (method === 'DELETE') { return handleDisableAutomatedSecurityFixes(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /automated-security-fixes.`); + } + + // /repos/:did/:repo/immutable-releases + if (rest === '/immutable-releases') { + if (method === 'GET') { return handleCheckImmutableReleases(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleEnableImmutableReleases(ctx, targetDid, repoName); } + if (method === 'DELETE') { return handleDisableImmutableReleases(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /immutable-releases.`); + } + + // /repos/:did/:repo/private-vulnerability-reporting + if (rest === '/private-vulnerability-reporting') { + if (method === 'GET') { return handleGetPrivateVulnerabilityReporting(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleEnablePrivateVulnerabilityReporting(ctx, targetDid, repoName); } + if (method === 'DELETE') { return handleDisablePrivateVulnerabilityReporting(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /private-vulnerability-reporting.`); + } + + // GET/POST /repos/:did/:repo/security-advisories + if (rest === '/security-advisories') { + if (method === 'GET') { return handleListSecurityAdvisories(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateSecurityAdvisory(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /security-advisories.`); + } + + // POST /repos/:did/:repo/security-advisories/reports + if (rest === '/security-advisories/reports') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /security-advisories/reports.`); } + return handlePrivatelyReportSecurityVulnerability(ctx, targetDid, repoName, reqBody, url); + } + + // POST /repos/:did/:repo/security-advisories/:ghsa_id/cve + const securityAdvisoryCveMatch = rest.match(/^\/security-advisories\/([^/]+)\/cve$/); + if (securityAdvisoryCveMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /security-advisories/:ghsa_id/cve.`); } + return handleRequestSecurityAdvisoryCve(ctx, targetDid, repoName, securityAdvisoryCveMatch[1]); + } + + // POST /repos/:did/:repo/security-advisories/:ghsa_id/forks + const securityAdvisoryForksMatch = rest.match(/^\/security-advisories\/([^/]+)\/forks$/); + if (securityAdvisoryForksMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /security-advisories/:ghsa_id/forks.`); } + return handleCreateSecurityAdvisoryPrivateFork(ctx, targetDid, repoName, securityAdvisoryForksMatch[1]); + } + + // GET/PATCH /repos/:did/:repo/security-advisories/:ghsa_id + const securityAdvisoryMatch = rest.match(/^\/security-advisories\/([^/]+)$/); + if (securityAdvisoryMatch) { + if (method === 'GET') { return handleGetSecurityAdvisory(ctx, targetDid, repoName, securityAdvisoryMatch[1], url); } + if (method === 'PATCH') { + return handleUpdateSecurityAdvisory(ctx, targetDid, repoName, securityAdvisoryMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /security-advisories/:ghsa_id.`); + } + + // GET /repos/:did/:repo/secret-scanning/alerts + if (rest === '/secret-scanning/alerts') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /secret-scanning/alerts.`); } + return handleListSecretScanningAlerts(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/secret-scanning/scan-history + if (rest === '/secret-scanning/scan-history') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /secret-scanning/scan-history.`); } + return handleGetSecretScanningScanHistory(ctx, targetDid, repoName); + } + + // POST /repos/:did/:repo/secret-scanning/push-protection-bypasses + if (rest === '/secret-scanning/push-protection-bypasses') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /secret-scanning/push-protection-bypasses.`); } + return handleCreateSecretScanningPushProtectionBypass(ctx, targetDid, repoName, reqBody); + } + + // GET /repos/:did/:repo/secret-scanning/alerts/:alert_number/locations + const secretScanningAlertLocationsMatch = rest.match(/^\/secret-scanning\/alerts\/(\d+)\/locations$/); + if (secretScanningAlertLocationsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /secret-scanning/alerts/:alert_number/locations.`); } + return handleListSecretScanningAlertLocations(ctx, targetDid, repoName, secretScanningAlertLocationsMatch[1], url); + } + + // GET/PATCH /repos/:did/:repo/secret-scanning/alerts/:alert_number + const secretScanningAlertMatch = rest.match(/^\/secret-scanning\/alerts\/(\d+)$/); + if (secretScanningAlertMatch) { + if (method === 'GET') { return handleGetSecretScanningAlert(ctx, targetDid, repoName, secretScanningAlertMatch[1], url); } + if (method === 'PATCH') { + return handleUpdateSecretScanningAlert(ctx, targetDid, repoName, secretScanningAlertMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /secret-scanning/alerts/:alert_number.`); + } + + // GET /repos/:did/:repo/code-scanning/alerts + if (rest === '/code-scanning/alerts') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /code-scanning/alerts.`); } + return handleListCodeScanningAlerts(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/code-scanning/alerts/:alert_number/instances + const codeScanningAlertInstancesMatch = rest.match(/^\/code-scanning\/alerts\/(\d+)\/instances$/); + if (codeScanningAlertInstancesMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /code-scanning/alerts/:alert_number/instances.`); } + return handleListCodeScanningAlertInstances(ctx, targetDid, repoName, codeScanningAlertInstancesMatch[1], url); + } + + // GET/PATCH /repos/:did/:repo/code-scanning/alerts/:alert_number + const codeScanningAlertMatch = rest.match(/^\/code-scanning\/alerts\/(\d+)$/); + if (codeScanningAlertMatch) { + if (method === 'GET') { return handleGetCodeScanningAlert(ctx, targetDid, repoName, codeScanningAlertMatch[1], url); } + if (method === 'PATCH') { + return handleUpdateCodeScanningAlert(ctx, targetDid, repoName, codeScanningAlertMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /code-scanning/alerts/:alert_number.`); + } + + // GET /repos/:did/:repo/dependabot/alerts + if (rest === '/dependabot/alerts') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /dependabot/alerts.`); } + return handleListDependabotAlerts(ctx, targetDid, repoName, url); + } + + // GET/PATCH /repos/:did/:repo/dependabot/alerts/:alert_number + const dependabotAlertMatch = rest.match(/^\/dependabot\/alerts\/(\d+)$/); + if (dependabotAlertMatch) { + if (method === 'GET') { return handleGetDependabotAlert(ctx, targetDid, repoName, dependabotAlertMatch[1], url); } + if (method === 'PATCH') { + return handleUpdateDependabotAlert(ctx, targetDid, repoName, dependabotAlertMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /dependabot/alerts/:alert_number.`); + } + + // GET /repos/:did/:repo/dependency-graph/sbom + if (rest === '/dependency-graph/sbom') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /dependency-graph/sbom.`); } + return handleExportDependencyGraphSbom(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/dependency-graph/sbom/generate-report + if (rest === '/dependency-graph/sbom/generate-report') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /dependency-graph/sbom/generate-report.`); } + return handleGenerateDependencyGraphSbom(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/dependency-graph/sbom/fetch-report/:sbom_uuid + const dependencyGraphSbomFetchMatch = rest.match(/^\/dependency-graph\/sbom\/fetch-report\/([^/]+)$/); + if (dependencyGraphSbomFetchMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /dependency-graph/sbom/fetch-report/:sbom_uuid.`); } + return handleFetchDependencyGraphSbom(ctx, targetDid, repoName, dependencyGraphSbomFetchMatch[1], url); + } + + // GET /repos/:did/:repo/rules/branches/:branch + const branchRulesMatch = rest.match(/^\/rules\/branches\/(.+)$/); + if (branchRulesMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /rules/branches/:branch.`); } + return handleGetRulesForBranch(ctx, targetDid, repoName, branchRulesMatch[1], url); + } + + // /repos/:did/:repo/rulesets + if (rest === '/rulesets') { + if (method === 'GET') { return handleListRepositoryRulesets(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateRepositoryRuleset(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /rulesets.`); + } + + // GET /repos/:did/:repo/rulesets/rule-suites + if (rest === '/rulesets/rule-suites') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /rulesets/rule-suites.`); } + return handleListRepositoryRuleSuites(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/rulesets/rule-suites/:rule_suite_id + const ruleSuiteMatch = rest.match(/^\/rulesets\/rule-suites\/(\d+)$/); + if (ruleSuiteMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /rulesets/rule-suites/:rule_suite_id.`); } + return handleGetRepositoryRuleSuite(ctx, targetDid, repoName, ruleSuiteMatch[1]); + } + + // GET /repos/:did/:repo/rulesets/:ruleset_id/history/:version_id + const rulesetVersionMatch = rest.match(/^\/rulesets\/(\d+)\/history\/(\d+)$/); + if (rulesetVersionMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /rulesets/:ruleset_id/history/:version_id.`); } + return handleGetRepositoryRulesetVersion(ctx, targetDid, repoName, rulesetVersionMatch[1], rulesetVersionMatch[2], url); + } + + // GET /repos/:did/:repo/rulesets/:ruleset_id/history + const rulesetHistoryMatch = rest.match(/^\/rulesets\/(\d+)\/history$/); + if (rulesetHistoryMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /rulesets/:ruleset_id/history.`); } + return handleListRepositoryRulesetHistory(ctx, targetDid, repoName, rulesetHistoryMatch[1], url); + } + + // /repos/:did/:repo/rulesets/:ruleset_id + const rulesetMatch = rest.match(/^\/rulesets\/(\d+)$/); + if (rulesetMatch) { + if (method === 'GET') { return handleGetRepositoryRuleset(ctx, targetDid, repoName, rulesetMatch[1], url); } + if (method === 'PUT') { return handleUpdateRepositoryRuleset(ctx, targetDid, repoName, rulesetMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteRepositoryRuleset(ctx, targetDid, repoName, rulesetMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /rulesets/:ruleset_id.`); + } + + // GET /repos/:did/:repo/assignees + if (rest === '/assignees') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /assignees.`); } + return handleListAssignees(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/assignees/:did + const assigneeMatch = rest.match(/^\/assignees\/(.+)$/); + if (assigneeMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /assignees/:did.`); } + return handleCheckAssignee(ctx, targetDid, repoName, assigneeMatch[1]); + } + + // GET /repos/:did/:repo/collaborators + if (rest === '/collaborators') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /collaborators.`); } + return handleListCollaborators(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/collaborators/:did/permission + const collaboratorPermissionMatch = rest.match(/^\/collaborators\/(.+)\/permission$/); + if (collaboratorPermissionMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /collaborators/:did/permission.`); } + return handleGetCollaboratorPermission(ctx, targetDid, repoName, collaboratorPermissionMatch[1], url); + } + + // /repos/:did/:repo/collaborators/:did + const collaboratorMatch = rest.match(/^\/collaborators\/(.+)$/); + if (collaboratorMatch) { + if (method === 'GET') { return handleCheckCollaborator(ctx, targetDid, repoName, collaboratorMatch[1]); } + if (method === 'PUT') { return handleAddCollaborator(ctx, targetDid, repoName, collaboratorMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleRemoveCollaborator(ctx, targetDid, repoName, collaboratorMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /collaborators/:did.`); + } + + // POST /repos/:did/:repo/statuses/:sha + const createStatusMatch = rest.match(/^\/statuses\/([^/]+)$/); + if (createStatusMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /statuses/:sha.`); } + return handleCreateCommitStatus(ctx, targetDid, repoName, createStatusMatch[1], reqBody, url); + } + + // POST /repos/:did/:repo/check-suites + if (rest === '/check-suites') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /check-suites.`); } + return handleCreateCheckSuite(ctx, targetDid, repoName, reqBody, url); + } + + // GET /repos/:did/:repo/check-suites/:id/check-runs + const suiteRunsMatch = rest.match(/^\/check-suites\/(\d+)\/check-runs$/); + if (suiteRunsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /check-suites/:id/check-runs.`); } + return handleListCheckRunsForSuite(ctx, targetDid, repoName, suiteRunsMatch[1], url); + } + + // POST /repos/:did/:repo/check-suites/:id/rerequest + const rerequestSuiteMatch = rest.match(/^\/check-suites\/(\d+)\/rerequest$/); + if (rerequestSuiteMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /check-suites/:id/rerequest.`); } + return handleRerequestCheckSuite(ctx, targetDid, repoName, rerequestSuiteMatch[1]); + } + + // GET /repos/:did/:repo/check-suites/:id + const checkSuiteMatch = rest.match(/^\/check-suites\/(\d+)$/); + if (checkSuiteMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /check-suites/:id.`); } + return handleGetCheckSuite(ctx, targetDid, repoName, checkSuiteMatch[1], url); + } + + // POST /repos/:did/:repo/check-runs + if (rest === '/check-runs') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /check-runs.`); } + return handleCreateCheckRun(ctx, targetDid, repoName, reqBody, url); + } + + // GET /repos/:did/:repo/check-runs/:id/annotations + const checkRunAnnotationsMatch = rest.match(/^\/check-runs\/(\d+)\/annotations$/); + if (checkRunAnnotationsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /check-runs/:id/annotations.`); } + return handleListCheckRunAnnotations(ctx, targetDid, repoName, checkRunAnnotationsMatch[1], url); + } + + // POST /repos/:did/:repo/check-runs/:id/rerequest + const rerequestRunMatch = rest.match(/^\/check-runs\/(\d+)\/rerequest$/); + if (rerequestRunMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /check-runs/:id/rerequest.`); } + return handleRerequestCheckRun(ctx, targetDid, repoName, rerequestRunMatch[1]); + } + + // /repos/:did/:repo/check-runs/:id + const checkRunMatch = rest.match(/^\/check-runs\/(\d+)$/); + if (checkRunMatch) { + if (method === 'PATCH') { return handleUpdateCheckRun(ctx, targetDid, repoName, checkRunMatch[1], reqBody, url); } + if (method === 'GET') { return handleGetCheckRun(ctx, targetDid, repoName, checkRunMatch[1], url); } + return jsonMethodNotAllowed(`${method} not allowed on /check-runs/:id.`); + } + + // GET /repos/:did/:repo/actions/artifacts + if (rest === '/actions/artifacts') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/artifacts.`); } + return handleListArtifacts(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/actions/artifacts/:artifact_id/:archive_format + const artifactDownloadMatch = rest.match(/^\/actions\/artifacts\/(\d+)\/([^/]+)$/); + if (artifactDownloadMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/artifacts/:artifact_id/:archive_format.`); } + return handleDownloadArtifact(ctx, targetDid, repoName, artifactDownloadMatch[1], artifactDownloadMatch[2], url); + } + + // GET/DELETE /repos/:did/:repo/actions/artifacts/:artifact_id + const artifactMatch = rest.match(/^\/actions\/artifacts\/(\d+)$/); + if (artifactMatch) { + if (method === 'GET') { return handleGetArtifact(ctx, targetDid, repoName, artifactMatch[1], url); } + if (method === 'DELETE') { return handleDeleteArtifact(ctx, targetDid, repoName, artifactMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/artifacts/:artifact_id.`); + } + + // GET/PUT /repos/:did/:repo/actions/cache/retention-limit + if (rest === '/actions/cache/retention-limit') { + if (method === 'GET') { return handleGetActionsCacheRetentionLimit(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleSetActionsCacheRetentionLimit(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/cache/retention-limit.`); + } + + // GET/PUT /repos/:did/:repo/actions/cache/storage-limit + if (rest === '/actions/cache/storage-limit') { + if (method === 'GET') { return handleGetActionsCacheStorageLimit(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleSetActionsCacheStorageLimit(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/cache/storage-limit.`); + } + + // GET /repos/:did/:repo/actions/cache/usage + if (rest === '/actions/cache/usage') { + if (method === 'GET') { return handleGetActionsCacheUsage(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/cache/usage.`); + } + + // GET/DELETE /repos/:did/:repo/actions/caches + if (rest === '/actions/caches') { + if (method === 'GET') { return handleListActionsCaches(ctx, targetDid, repoName, url); } + if (method === 'DELETE') { return handleDeleteActionsCachesByKey(ctx, targetDid, repoName, url); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/caches.`); + } + + // DELETE /repos/:did/:repo/actions/caches/:cache_id + const actionsCacheMatch = rest.match(/^\/actions\/caches\/(\d+)$/); + if (actionsCacheMatch) { + if (method === 'DELETE') { return handleDeleteActionsCacheById(ctx, targetDid, repoName, actionsCacheMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/caches/:cache_id.`); + } + + // GET/PUT /repos/:did/:repo/actions/permissions/selected-actions + if (rest === '/actions/permissions/selected-actions') { + if (method === 'GET') { return handleGetActionsSelectedActions(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleSetActionsSelectedActions(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/permissions/selected-actions.`); + } + + // GET/PUT /repos/:did/:repo/actions/permissions/workflow + if (rest === '/actions/permissions/workflow') { + if (method === 'GET') { return handleGetActionsWorkflowPermissions(ctx, targetDid, repoName); } + if (method === 'PUT') { return handleSetActionsWorkflowPermissions(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/permissions/workflow.`); + } + + // GET/PUT /repos/:did/:repo/actions/permissions + if (rest === '/actions/permissions') { + if (method === 'GET') { return handleGetActionsPermissions(ctx, targetDid, repoName, url); } + if (method === 'PUT') { return handleSetActionsPermissions(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/permissions.`); + } + + // GET /repos/:did/:repo/actions/secrets/public-key + if (rest === '/actions/secrets/public-key') { + if (method === 'GET') { return handleGetRepositorySecretsPublicKey(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/secrets/public-key.`); + } + + // GET /repos/:did/:repo/actions/secrets + if (rest === '/actions/secrets') { + if (method === 'GET') { return handleListRepositorySecrets(ctx, targetDid, repoName, url); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/secrets.`); + } + + // GET/PUT/DELETE /repos/:did/:repo/actions/secrets/:secret_name + const repositorySecretMatch = rest.match(/^\/actions\/secrets\/([^/]+)$/); + if (repositorySecretMatch) { + if (method === 'GET') { + return handleGetRepositorySecret(ctx, targetDid, repoName, repositorySecretMatch[1]); + } + if (method === 'PUT') { + return handleCreateOrUpdateRepositorySecret(ctx, targetDid, repoName, repositorySecretMatch[1], reqBody); + } + if (method === 'DELETE') { + return handleDeleteRepositorySecret(ctx, targetDid, repoName, repositorySecretMatch[1]); + } + return jsonMethodNotAllowed(`${method} not allowed on /actions/secrets/:secret_name.`); + } + + // GET/POST /repos/:did/:repo/actions/variables + if (rest === '/actions/variables') { + if (method === 'GET') { return handleListRepositoryVariables(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateRepositoryVariable(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/variables.`); + } + + // GET/PATCH/DELETE /repos/:did/:repo/actions/variables/:name + const repositoryVariableMatch = rest.match(/^\/actions\/variables\/([^/]+)$/); + if (repositoryVariableMatch) { + if (method === 'GET') { + return handleGetRepositoryVariable(ctx, targetDid, repoName, repositoryVariableMatch[1]); + } + if (method === 'PATCH') { + return handleUpdateRepositoryVariable(ctx, targetDid, repoName, repositoryVariableMatch[1], reqBody); + } + if (method === 'DELETE') { + return handleDeleteRepositoryVariable(ctx, targetDid, repoName, repositoryVariableMatch[1]); + } + return jsonMethodNotAllowed(`${method} not allowed on /actions/variables/:name.`); + } + + // GET /repos/:did/:repo/actions/workflows + if (rest === '/actions/workflows') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows.`); } + return handleListWorkflows(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/actions/workflows/:workflow_id/runs + const workflowRunsMatch = rest.match(/^\/actions\/workflows\/([^/]+)\/runs$/); + if (workflowRunsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id/runs.`); } + return handleListWorkflowRunsForWorkflow(ctx, targetDid, repoName, workflowRunsMatch[1], url); + } + + // GET /repos/:did/:repo/actions/workflows/:workflow_id/timing + const workflowTimingMatch = rest.match(/^\/actions\/workflows\/([^/]+)\/timing$/); + if (workflowTimingMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id/timing.`); } + return handleGetWorkflowUsage(ctx, targetDid, repoName, workflowTimingMatch[1]); + } + + // PUT /repos/:did/:repo/actions/workflows/:workflow_id/disable + const workflowDisableMatch = rest.match(/^\/actions\/workflows\/([^/]+)\/disable$/); + if (workflowDisableMatch) { + if (method !== 'PUT') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id/disable.`); } + return handleDisableWorkflow(ctx, targetDid, repoName, workflowDisableMatch[1]); + } + + // POST /repos/:did/:repo/actions/workflows/:workflow_id/dispatches + const workflowDispatchMatch = rest.match(/^\/actions\/workflows\/([^/]+)\/dispatches$/); + if (workflowDispatchMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id/dispatches.`); } + return handleCreateWorkflowDispatch(ctx, targetDid, repoName, workflowDispatchMatch[1], reqBody, url); + } + + // PUT /repos/:did/:repo/actions/workflows/:workflow_id/enable + const workflowEnableMatch = rest.match(/^\/actions\/workflows\/([^/]+)\/enable$/); + if (workflowEnableMatch) { + if (method !== 'PUT') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id/enable.`); } + return handleEnableWorkflow(ctx, targetDid, repoName, workflowEnableMatch[1]); + } + + // GET /repos/:did/:repo/actions/workflows/:workflow_id + const workflowMatch = rest.match(/^\/actions\/workflows\/([^/]+)$/); + if (workflowMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/workflows/:workflow_id.`); } + return handleGetWorkflow(ctx, targetDid, repoName, workflowMatch[1], url); + } + + // GET /repos/:did/:repo/actions/runs + if (rest === '/actions/runs') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs.`); } + return handleListWorkflowRuns(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/actions/runs/:run_id/attempts/:attempt_number/logs + const workflowRunAttemptLogsMatch = rest.match(/^\/actions\/runs\/(\d+)\/attempts\/(\d+)\/logs$/); + if (workflowRunAttemptLogsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/attempts/:attempt_number/logs.`); } + return handleDownloadWorkflowRunAttemptLogs( + ctx, targetDid, repoName, workflowRunAttemptLogsMatch[1], workflowRunAttemptLogsMatch[2], url, + ); + } + + // GET /repos/:did/:repo/actions/runs/:run_id/attempts/:attempt_number + const workflowRunAttemptMatch = rest.match(/^\/actions\/runs\/(\d+)\/attempts\/(\d+)$/); + if (workflowRunAttemptMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/attempts/:attempt_number.`); } + return handleGetWorkflowRunAttempt(ctx, targetDid, repoName, workflowRunAttemptMatch[1], workflowRunAttemptMatch[2], url); + } + + // GET/DELETE /repos/:did/:repo/actions/runs/:run_id/logs + const workflowRunLogsMatch = rest.match(/^\/actions\/runs\/(\d+)\/logs$/); + if (workflowRunLogsMatch) { + if (method === 'GET') { return handleDownloadWorkflowRunLogs(ctx, targetDid, repoName, workflowRunLogsMatch[1], url); } + if (method === 'DELETE') { return handleDeleteWorkflowRunLogs(ctx, targetDid, repoName, workflowRunLogsMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/logs.`); + } + + // GET /repos/:did/:repo/actions/runs/:run_id/timing + const workflowRunTimingMatch = rest.match(/^\/actions\/runs\/(\d+)\/timing$/); + if (workflowRunTimingMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/timing.`); } + return handleGetWorkflowRunUsage(ctx, targetDid, repoName, workflowRunTimingMatch[1]); + } + + // GET /repos/:did/:repo/actions/runs/:run_id/artifacts + const workflowRunArtifactsMatch = rest.match(/^\/actions\/runs\/(\d+)\/artifacts$/); + if (workflowRunArtifactsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/artifacts.`); } + return handleListWorkflowRunArtifacts(ctx, targetDid, repoName, workflowRunArtifactsMatch[1], url); + } + + // GET /repos/:did/:repo/actions/runs/:run_id/jobs + const workflowRunJobsMatch = rest.match(/^\/actions\/runs\/(\d+)\/jobs$/); + if (workflowRunJobsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/jobs.`); } + return handleListWorkflowRunJobs(ctx, targetDid, repoName, workflowRunJobsMatch[1], url); + } + + // POST /repos/:did/:repo/actions/runs/:run_id/rerun-failed-jobs + const workflowRunFailedRerunMatch = rest.match(/^\/actions\/runs\/(\d+)\/rerun-failed-jobs$/); + if (workflowRunFailedRerunMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/rerun-failed-jobs.`); } + return handleRerunFailedWorkflowJobs(ctx, targetDid, repoName, workflowRunFailedRerunMatch[1]); + } + + // POST /repos/:did/:repo/actions/runs/:run_id/rerun + const workflowRunRerunMatch = rest.match(/^\/actions\/runs\/(\d+)\/rerun$/); + if (workflowRunRerunMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/rerun.`); } + return handleRerunWorkflowRun(ctx, targetDid, repoName, workflowRunRerunMatch[1]); + } + + // POST /repos/:did/:repo/actions/runs/:run_id/cancel + const workflowRunCancelMatch = rest.match(/^\/actions\/runs\/(\d+)\/cancel$/); + if (workflowRunCancelMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/cancel.`); } + return handleCancelWorkflowRun(ctx, targetDid, repoName, workflowRunCancelMatch[1]); + } + + // POST /repos/:did/:repo/actions/runs/:run_id/force-cancel + const workflowRunForceCancelMatch = rest.match(/^\/actions\/runs\/(\d+)\/force-cancel$/); + if (workflowRunForceCancelMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id/force-cancel.`); } + return handleForceCancelWorkflowRun(ctx, targetDid, repoName, workflowRunForceCancelMatch[1]); + } + + // GET/DELETE /repos/:did/:repo/actions/runs/:run_id + const workflowRunMatch = rest.match(/^\/actions\/runs\/(\d+)$/); + if (workflowRunMatch) { + if (method === 'GET') { return handleGetWorkflowRun(ctx, targetDid, repoName, workflowRunMatch[1], url); } + if (method === 'DELETE') { return handleDeleteWorkflowRun(ctx, targetDid, repoName, workflowRunMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /actions/runs/:run_id.`); + } + + // GET /repos/:did/:repo/actions/jobs/:job_id/logs + const workflowJobLogsMatch = rest.match(/^\/actions\/jobs\/(\d+)\/logs$/); + if (workflowJobLogsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/jobs/:job_id/logs.`); } + return handleDownloadWorkflowJobLogs(ctx, targetDid, repoName, workflowJobLogsMatch[1], url); + } + + // POST /repos/:did/:repo/actions/jobs/:job_id/rerun + const workflowJobRerunMatch = rest.match(/^\/actions\/jobs\/(\d+)\/rerun$/); + if (workflowJobRerunMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /actions/jobs/:job_id/rerun.`); } + return handleRerunWorkflowJob(ctx, targetDid, repoName, workflowJobRerunMatch[1]); + } + + // GET /repos/:did/:repo/actions/jobs/:job_id + const workflowJobMatch = rest.match(/^\/actions\/jobs\/(\d+)$/); + if (workflowJobMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /actions/jobs/:job_id.`); } + return handleGetWorkflowJob(ctx, targetDid, repoName, workflowJobMatch[1], url); + } + + // GET /repos/:did/:repo/stargazers + if (rest === '/stargazers') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /stargazers.`); } + return handleListStargazers(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/subscribers + if (rest === '/subscribers') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /subscribers.`); } + return handleListSubscribers(ctx, targetDid, repoName, url); + } + + // /repos/:did/:repo/subscription + if (rest === '/subscription') { + if (method === 'GET') { return handleGetRepoSubscription(ctx, targetDid, repoName, url); } + if (method === 'PUT') { return handleSetRepoSubscription(ctx, targetDid, repoName, reqBody, url); } + if (method === 'DELETE') { return handleDeleteRepoSubscription(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /subscription.`); + } + + // /repos/:did/:repo/hooks + if (rest === '/hooks') { + if (method === 'GET') { return handleListRepoWebhooks(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateRepoWebhook(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /hooks.`); + } + + // /repos/:did/:repo/hooks/:hook_id/config + const webhookConfigMatch = rest.match(/^\/hooks\/(\d+)\/config$/); + if (webhookConfigMatch) { + if (method === 'GET') { return handleGetRepoWebhookConfig(ctx, targetDid, repoName, webhookConfigMatch[1]); } + if (method === 'PATCH') { return handleUpdateRepoWebhookConfig(ctx, targetDid, repoName, webhookConfigMatch[1], reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/config.`); + } + + // POST /repos/:did/:repo/hooks/:hook_id/deliveries/:delivery_id/attempts + const webhookDeliveryAttemptsMatch = rest.match(/^\/hooks\/(\d+)\/deliveries\/(\d+)\/attempts$/); + if (webhookDeliveryAttemptsMatch) { + if (method === 'POST') { + return handleRedeliverRepoWebhookDelivery( + ctx, + targetDid, + repoName, + webhookDeliveryAttemptsMatch[1], + webhookDeliveryAttemptsMatch[2], + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/deliveries/:delivery_id/attempts.`); + } + + // GET /repos/:did/:repo/hooks/:hook_id/deliveries/:delivery_id + const webhookDeliveryMatch = rest.match(/^\/hooks\/(\d+)\/deliveries\/(\d+)$/); + if (webhookDeliveryMatch) { + if (method === 'GET') { + return handleGetRepoWebhookDelivery(ctx, targetDid, repoName, webhookDeliveryMatch[1], webhookDeliveryMatch[2]); } - return handleGetUser(userMatch[1], url); + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/deliveries/:delivery_id.`); } - // ------------------------------------------------------------------------- - // /repos/:did/:repo/... - // ------------------------------------------------------------------------- - const repoMatch = path.match(REPOS_RE); - if (!repoMatch) { - return jsonNotFound('Not found'); + // GET /repos/:did/:repo/hooks/:hook_id/deliveries + const webhookDeliveriesMatch = rest.match(/^\/hooks\/(\d+)\/deliveries$/); + if (webhookDeliveriesMatch) { + if (method === 'GET') { + return handleListRepoWebhookDeliveries(ctx, targetDid, repoName, webhookDeliveriesMatch[1], url); + } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/deliveries.`); } - const targetDid = repoMatch[1]; - const repoName = repoMatch[2]; - const rest = repoMatch[3] ?? ''; + // POST /repos/:did/:repo/hooks/:hook_id/pings + const webhookPingMatch = rest.match(/^\/hooks\/(\d+)\/pings$/); + if (webhookPingMatch) { + if (method === 'POST') { return handlePingRepoWebhook(ctx, targetDid, repoName, webhookPingMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/pings.`); + } - // Try/catch — DID resolution failures should return 502. - try { - return await dispatchRepoRoute(ctx, targetDid, repoName, rest, url, method, reqBody); - } catch (err) { - const msg = (err as Error).message ?? 'Unknown error'; - return { - status : 502, - headers : baseHeaders(), - body : JSON.stringify({ message: `DWN error: ${msg}` }), - }; + // POST /repos/:did/:repo/hooks/:hook_id/tests (and legacy /test) + const webhookTestMatch = rest.match(/^\/hooks\/(\d+)\/tests?$/); + if (webhookTestMatch) { + if (method === 'POST') { return handleTestRepoWebhook(ctx, targetDid, repoName, webhookTestMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id/tests.`); } -} -/** - * Dispatch to the correct handler within the `/repos/:did/:repo/...` - * namespace. Considers both the URL path and the HTTP method. - */ -async function dispatchRepoRoute( - ctx: AgentContext, targetDid: string, repoName: string, - rest: string, url: URL, method: string, reqBody: Record<string, unknown>, -): Promise<JsonResponse> { - // GET /repos/:did/:repo - if (rest === '' || rest === '/') { - if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /repos/:did/:repo.`); } - return handleGetRepo(ctx, targetDid, repoName, url); + // /repos/:did/:repo/hooks/:hook_id + const webhookMatch = rest.match(/^\/hooks\/(\d+)$/); + if (webhookMatch) { + if (method === 'GET') { return handleGetRepoWebhook(ctx, targetDid, repoName, webhookMatch[1], url); } + if (method === 'PATCH') { return handleUpdateRepoWebhook(ctx, targetDid, repoName, webhookMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteRepoWebhook(ctx, targetDid, repoName, webhookMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /hooks/:hook_id.`); + } + + // /repos/:did/:repo/notifications + if (rest === '/notifications') { + if (method === 'GET') { return handleListRepoNotifications(ctx, targetDid, repoName, url); } + if (method === 'PUT') { return handleMarkRepoNotificationsRead(ctx, targetDid, repoName, reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /notifications.`); + } + + // GET/POST /repos/:did/:repo/labels + if (rest === '/labels') { + if (method === 'GET') { return handleListRepoLabels(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateRepoLabel(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /labels.`); + } + + // GET/PATCH/DELETE /repos/:did/:repo/labels/:name + const repoLabelMatch = rest.match(/^\/labels\/(.+)$/); + if (repoLabelMatch) { + if (method === 'GET') { return handleGetRepoLabel(ctx, targetDid, repoName, repoLabelMatch[1], url); } + if (method === 'PATCH') { return handleUpdateRepoLabel(ctx, targetDid, repoName, repoLabelMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteRepoLabel(ctx, targetDid, repoName, repoLabelMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /labels/:name.`); + } + + // GET/POST /repos/:did/:repo/milestones + if (rest === '/milestones') { + if (method === 'GET') { return handleListMilestones(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateMilestone(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /milestones.`); + } + + // GET /repos/:did/:repo/milestones/:number/labels + const milestoneLabelsMatch = rest.match(/^\/milestones\/(\d+)\/labels$/); + if (milestoneLabelsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /milestones/:number/labels.`); } + return handleListMilestoneLabels(ctx, targetDid, repoName, milestoneLabelsMatch[1], url); + } + + // GET/PATCH/DELETE /repos/:did/:repo/milestones/:number + const milestoneMatch = rest.match(/^\/milestones\/(\d+)$/); + if (milestoneMatch) { + if (method === 'GET') { return handleGetMilestone(ctx, targetDid, repoName, milestoneMatch[1], url); } + if (method === 'PATCH') { return handleUpdateMilestone(ctx, targetDid, repoName, milestoneMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteMilestone(ctx, targetDid, repoName, milestoneMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /milestones/:number.`); } // /repos/:did/:repo/issues[/...] if (rest === '/issues') { - if (method === 'POST') { return handleCreateIssue(ctx, targetDid, repoName, reqBody, url); } - if (method === 'GET') { return handleListIssues(ctx, targetDid, repoName, url); } + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'POST') { return handleCreateIssue(ctx, targetDid, repoName, reqBody, url, bodyMediaKind); } + if (method === 'GET') { return handleListIssues(ctx, targetDid, repoName, url, bodyMediaKind); } return jsonMethodNotAllowed(`${method} not allowed on /issues.`); } // /repos/:did/:repo/pulls[/...] if (rest === '/pulls') { - if (method === 'POST') { return handleCreatePull(ctx, targetDid, repoName, reqBody, url); } - if (method === 'GET') { return handleListPulls(ctx, targetDid, repoName, url); } + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'POST') { return handleCreatePull(ctx, targetDid, repoName, reqBody, url, bodyMediaKind); } + if (method === 'GET') { return handleListPulls(ctx, targetDid, repoName, url, bodyMediaKind); } return jsonMethodNotAllowed(`${method} not allowed on /pulls.`); } @@ -176,6 +4010,81 @@ async function dispatchRepoRoute( return jsonMethodNotAllowed(`${method} not allowed on /releases.`); } + // POST /repos/:did/:repo/releases/generate-notes + if (rest === '/releases/generate-notes') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /releases/generate-notes.`); } + return handleGenerateReleaseNotes(ctx, targetDid, repoName, reqBody); + } + + // GET /repos/:did/:repo/releases/latest + if (rest === '/releases/latest') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /releases/latest.`); } + return handleGetLatestRelease(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/releases/assets/:id/download + const releaseAssetDownloadMatch = rest.match(/^\/releases\/assets\/(\d+)\/download$/); + if (releaseAssetDownloadMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /releases/assets/:id/download.`); } + return handleDownloadReleaseAsset(ctx, targetDid, repoName, releaseAssetDownloadMatch[1], url); + } + + // /repos/:did/:repo/releases/assets/:id + const releaseAssetMatch = rest.match(/^\/releases\/assets\/(\d+)$/); + if (releaseAssetMatch) { + if (method === 'GET') { + return handleGetReleaseAsset( + ctx, targetDid, repoName, releaseAssetMatch[1], url, githubReleaseAssetMediaKind(options.accept), + ); + } + if (method === 'PATCH') { return handleUpdateReleaseAsset(ctx, targetDid, repoName, releaseAssetMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteReleaseAsset(ctx, targetDid, repoName, releaseAssetMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /releases/assets/:id.`); + } + + // GET /repos/:did/:repo/releases/download/:tag/:asset + const releaseAssetByNameMatch = rest.match(/^\/releases\/download\/([^/]+)\/(.+)$/); + if (releaseAssetByNameMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /releases/download/:tag/:asset.`); } + return handleDownloadReleaseAssetByName(ctx, targetDid, repoName, releaseAssetByNameMatch[1], releaseAssetByNameMatch[2]); + } + + // GET/POST /repos/:did/:repo/releases/:id/assets + const releaseAssetsMatch = rest.match(/^\/releases\/(\d+)\/assets$/); + if (releaseAssetsMatch) { + if (method === 'GET') { return handleListReleaseAssets(ctx, targetDid, repoName, releaseAssetsMatch[1], url); } + if (method === 'POST') { return handleUploadReleaseAsset(ctx, targetDid, repoName, releaseAssetsMatch[1], url, options); } + return jsonMethodNotAllowed(`${method} not allowed on /releases/:id/assets.`); + } + + // /repos/:did/:repo/releases/:id/reactions + const releaseReactionsMatch = rest.match(/^\/releases\/(\d+)\/reactions$/); + if (releaseReactionsMatch) { + if (method === 'GET') { return handleListReleaseReactions(ctx, targetDid, repoName, releaseReactionsMatch[1], url); } + if (method === 'POST') { + return handleCreateReleaseReaction(ctx, targetDid, repoName, releaseReactionsMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /releases/:id/reactions.`); + } + + // DELETE /repos/:did/:repo/releases/:id/reactions/:reaction_id + const releaseReactionMatch = rest.match(/^\/releases\/(\d+)\/reactions\/(\d+)$/); + if (releaseReactionMatch) { + if (method === 'DELETE') { + return handleDeleteReleaseReaction(ctx, targetDid, repoName, releaseReactionMatch[1], releaseReactionMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /releases/:id/reactions/:reaction_id.`); + } + + // /repos/:did/:repo/releases/:id + const releaseIdMatch = rest.match(/^\/releases\/(\d+)$/); + if (releaseIdMatch) { + if (method === 'GET') { return handleGetReleaseById(ctx, targetDid, repoName, releaseIdMatch[1], url); } + if (method === 'PATCH') { return handleUpdateRelease(ctx, targetDid, repoName, releaseIdMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteRelease(ctx, targetDid, repoName, releaseIdMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /releases/:id.`); + } + // GET /repos/:did/:repo/releases/tags/:tag const releaseTagMatch = rest.match(/^\/releases\/tags\/(.+)$/); if (releaseTagMatch) { @@ -183,29 +4092,534 @@ async function dispatchRepoRoute( return handleGetReleaseByTag(ctx, targetDid, repoName, releaseTagMatch[1], url); } + // /repos/:did/:repo/environments + if (rest === '/environments') { + if (method === 'GET') { return handleListEnvironments(ctx, targetDid, repoName, url); } + return jsonMethodNotAllowed(`${method} not allowed on /environments.`); + } + + // GET /repos/:did/:repo/environments/:environment_name/secrets/public-key + const environmentSecretsPublicKeyMatch = rest.match(/^\/environments\/([^/]+)\/secrets\/public-key$/); + if (environmentSecretsPublicKeyMatch) { + if (method === 'GET') { + return handleGetEnvironmentSecretsPublicKey(ctx, targetDid, repoName, environmentSecretsPublicKeyMatch[1]); + } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name/secrets/public-key.`); + } + + // GET /repos/:did/:repo/environments/:environment_name/secrets + const environmentSecretsMatch = rest.match(/^\/environments\/([^/]+)\/secrets$/); + if (environmentSecretsMatch) { + if (method === 'GET') { + return handleListEnvironmentSecrets(ctx, targetDid, repoName, environmentSecretsMatch[1], url); + } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name/secrets.`); + } + + // GET/PUT/DELETE /repos/:did/:repo/environments/:environment_name/secrets/:secret_name + const environmentSecretMatch = rest.match(/^\/environments\/([^/]+)\/secrets\/([^/]+)$/); + if (environmentSecretMatch) { + if (method === 'GET') { + return handleGetEnvironmentSecret(ctx, targetDid, repoName, environmentSecretMatch[1], environmentSecretMatch[2]); + } + if (method === 'PUT') { + return handleCreateOrUpdateEnvironmentSecret( + ctx, targetDid, repoName, environmentSecretMatch[1], environmentSecretMatch[2], reqBody, + ); + } + if (method === 'DELETE') { + return handleDeleteEnvironmentSecret(ctx, targetDid, repoName, environmentSecretMatch[1], environmentSecretMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name/secrets/:secret_name.`); + } + + // GET/POST /repos/:did/:repo/environments/:environment_name/variables + const environmentVariablesMatch = rest.match(/^\/environments\/([^/]+)\/variables$/); + if (environmentVariablesMatch) { + if (method === 'GET') { + return handleListEnvironmentVariables(ctx, targetDid, repoName, environmentVariablesMatch[1], url); + } + if (method === 'POST') { + return handleCreateEnvironmentVariable(ctx, targetDid, repoName, environmentVariablesMatch[1], reqBody); + } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name/variables.`); + } + + // GET/PATCH/DELETE /repos/:did/:repo/environments/:environment_name/variables/:name + const environmentVariableMatch = rest.match(/^\/environments\/([^/]+)\/variables\/([^/]+)$/); + if (environmentVariableMatch) { + if (method === 'GET') { + return handleGetEnvironmentVariable(ctx, targetDid, repoName, environmentVariableMatch[1], environmentVariableMatch[2]); + } + if (method === 'PATCH') { + return handleUpdateEnvironmentVariable( + ctx, targetDid, repoName, environmentVariableMatch[1], environmentVariableMatch[2], reqBody, + ); + } + if (method === 'DELETE') { + return handleDeleteEnvironmentVariable( + ctx, targetDid, repoName, environmentVariableMatch[1], environmentVariableMatch[2], + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name/variables/:name.`); + } + + // /repos/:did/:repo/environments/:environment_name + const environmentMatch = rest.match(/^\/environments\/([^/]+)$/); + if (environmentMatch) { + if (method === 'GET') { return handleGetEnvironment(ctx, targetDid, repoName, environmentMatch[1], url); } + if (method === 'PUT') { return handleCreateOrUpdateEnvironment(ctx, targetDid, repoName, environmentMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleDeleteEnvironment(ctx, targetDid, repoName, environmentMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /environments/:environment_name.`); + } + + // /repos/:did/:repo/deployments + if (rest === '/deployments') { + if (method === 'GET') { return handleListDeployments(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreateDeployment(ctx, targetDid, repoName, reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /deployments.`); + } + + // /repos/:did/:repo/deployments/:id/statuses + const deploymentStatusesMatch = rest.match(/^\/deployments\/(\d+)\/statuses$/); + if (deploymentStatusesMatch) { + if (method === 'GET') { return handleListDeploymentStatuses(ctx, targetDid, repoName, deploymentStatusesMatch[1], url); } + if (method === 'POST') { return handleCreateDeploymentStatus(ctx, targetDid, repoName, deploymentStatusesMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /deployments/:id/statuses.`); + } + + // GET /repos/:did/:repo/deployments/:id/statuses/:status_id + const deploymentStatusMatch = rest.match(/^\/deployments\/(\d+)\/statuses\/(\d+)$/); + if (deploymentStatusMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /deployments/:id/statuses/:status_id.`); } + return handleGetDeploymentStatus(ctx, targetDid, repoName, deploymentStatusMatch[1], deploymentStatusMatch[2], url); + } + + // /repos/:did/:repo/deployments/:id + const deploymentMatch = rest.match(/^\/deployments\/(\d+)$/); + if (deploymentMatch) { + if (method === 'GET') { return handleGetDeployment(ctx, targetDid, repoName, deploymentMatch[1], url); } + if (method === 'DELETE') { return handleDeleteDeployment(ctx, targetDid, repoName, deploymentMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /deployments/:id.`); + } + + // GET /repos/:did/:repo/pages/builds/latest + if (rest === '/pages/builds/latest') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pages/builds/latest.`); } + return handleGetLatestPagesBuild(ctx, targetDid, repoName, url); + } + + // GET/POST /repos/:did/:repo/pages/builds + if (rest === '/pages/builds') { + if (method === 'GET') { return handleListPagesBuilds(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleRequestPagesBuild(ctx, targetDid, repoName, url); } + return jsonMethodNotAllowed(`${method} not allowed on /pages/builds.`); + } + + // GET /repos/:did/:repo/pages/builds/:build_id + const pagesBuildMatch = rest.match(/^\/pages\/builds\/(\d+)$/); + if (pagesBuildMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pages/builds/:build_id.`); } + return handleGetPagesBuild(ctx, targetDid, repoName, pagesBuildMatch[1], url); + } + + // POST /repos/:did/:repo/pages/deployments/:pages_deployment_id/cancel + const pagesDeploymentCancelMatch = rest.match(/^\/pages\/deployments\/([^/]+)\/cancel$/); + if (pagesDeploymentCancelMatch) { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /pages/deployments/:pages_deployment_id/cancel.`); } + return handleCancelPagesDeployment(ctx, targetDid, repoName, pagesDeploymentCancelMatch[1]); + } + + // GET /repos/:did/:repo/pages/deployments/:pages_deployment_id/status + const pagesDeploymentStatusMatch = rest.match(/^\/pages\/deployments\/([^/]+)\/status$/); + if (pagesDeploymentStatusMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pages/deployments/:pages_deployment_id/status.`); } + return handleGetPagesDeploymentStatus(ctx, targetDid, repoName, pagesDeploymentStatusMatch[1]); + } + + // GET /repos/:did/:repo/pages/deployments/:pages_deployment_id + const pagesDeploymentMatch = rest.match(/^\/pages\/deployments\/([^/]+)$/); + if (pagesDeploymentMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pages/deployments/:pages_deployment_id.`); } + return handleGetPagesDeploymentStatus(ctx, targetDid, repoName, pagesDeploymentMatch[1]); + } + + // POST /repos/:did/:repo/pages/deployments + if (rest === '/pages/deployments') { + if (method !== 'POST') { return jsonMethodNotAllowed(`${method} not allowed on /pages/deployments.`); } + return handleCreatePagesDeployment(ctx, targetDid, repoName, reqBody, url); + } + + // GET /repos/:did/:repo/pages/health + if (rest === '/pages/health') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pages/health.`); } + return handleGetPagesHealth(ctx, targetDid, repoName); + } + + // GET/POST/PUT/DELETE /repos/:did/:repo/pages + if (rest === '/pages') { + if (method === 'GET') { return handleGetPagesSite(ctx, targetDid, repoName, url); } + if (method === 'POST') { return handleCreatePagesSite(ctx, targetDid, repoName, reqBody, url); } + if (method === 'PUT') { return handleUpdatePagesSite(ctx, targetDid, repoName, reqBody); } + if (method === 'DELETE') { return handleDeletePagesSite(ctx, targetDid, repoName); } + return jsonMethodNotAllowed(`${method} not allowed on /pages.`); + } + + // GET /repos/:did/:repo/comments + if (rest === '/comments') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /comments.`); } + return handleListCommitComments(ctx, targetDid, repoName, url, githubCommitCommentBodyMediaKind(options.accept)); + } + + // /repos/:did/:repo/comments/:id/reactions + const commitCommentReactionsMatch = rest.match(/^\/comments\/(\d+)\/reactions$/); + if (commitCommentReactionsMatch) { + if (method === 'GET') { + return handleListCommitCommentReactions(ctx, targetDid, repoName, commitCommentReactionsMatch[1], url); + } + if (method === 'POST') { + return handleCreateCommitCommentReaction(ctx, targetDid, repoName, commitCommentReactionsMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /comments/:id/reactions.`); + } + + // DELETE /repos/:did/:repo/comments/:id/reactions/:reaction_id + const commitCommentReactionMatch = rest.match(/^\/comments\/(\d+)\/reactions\/(\d+)$/); + if (commitCommentReactionMatch) { + if (method === 'DELETE') { + return handleDeleteCommitCommentReaction(ctx, targetDid, repoName, commitCommentReactionMatch[1], commitCommentReactionMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /comments/:id/reactions/:reaction_id.`); + } + + // /repos/:did/:repo/comments/:id + const commitCommentMatch = rest.match(/^\/comments\/(\d+)$/); + if (commitCommentMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'GET') { return handleGetCommitComment(ctx, targetDid, repoName, commitCommentMatch[1], url, bodyMediaKind); } + if (method === 'PATCH') { + return handleUpdateCommitComment(ctx, targetDid, repoName, commitCommentMatch[1], reqBody, url, bodyMediaKind); + } + if (method === 'DELETE') { return handleDeleteCommitComment(ctx, targetDid, repoName, commitCommentMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /comments/:id.`); + } + + // GET /repos/:did/:repo/issues/comments + if (rest === '/issues/comments') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issues/comments.`); } + return handleListRepoIssueComments(ctx, targetDid, repoName, url, githubBodyMediaKind(options.accept)); + } + + // GET /repos/:did/:repo/issues/events + if (rest === '/issues/events') { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issues/events.`); } + return handleListRepoIssueEvents(ctx, targetDid, repoName, url); + } + + // GET /repos/:did/:repo/issues/events/:id + const issueEventMatch = rest.match(/^\/issues\/events\/(\d+)$/); + if (issueEventMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issues/events/:id.`); } + return handleGetIssueEvent(ctx, targetDid, repoName, issueEventMatch[1], url); + } + + // PUT/DELETE /repos/:did/:repo/issues/comments/:id/pin + const issueCommentPinMatch = rest.match(/^\/issues\/comments\/(\d+)\/pin$/); + if (issueCommentPinMatch) { + if (method === 'PUT') { + return handlePinIssueComment( + ctx, targetDid, repoName, issueCommentPinMatch[1], url, githubBodyMediaKind(options.accept), + ); + } + if (method === 'DELETE') { return handleUnpinIssueComment(ctx, targetDid, repoName, issueCommentPinMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/comments/:id/pin.`); + } + + // /repos/:did/:repo/issues/comments/:id/reactions + const issueCommentReactionsMatch = rest.match(/^\/issues\/comments\/(\d+)\/reactions$/); + if (issueCommentReactionsMatch) { + if (method === 'GET') { return handleListIssueCommentReactions(ctx, targetDid, repoName, issueCommentReactionsMatch[1], url); } + if (method === 'POST') { return handleCreateIssueCommentReaction(ctx, targetDid, repoName, issueCommentReactionsMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/comments/:id/reactions.`); + } + + // DELETE /repos/:did/:repo/issues/comments/:id/reactions/:reaction_id + const issueCommentReactionMatch = rest.match(/^\/issues\/comments\/(\d+)\/reactions\/(\d+)$/); + if (issueCommentReactionMatch) { + if (method === 'DELETE') { + return handleDeleteIssueCommentReaction(ctx, targetDid, repoName, issueCommentReactionMatch[1], issueCommentReactionMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/comments/:id/reactions/:reaction_id.`); + } + + // /repos/:did/:repo/issues/comments/:id + const issueCommentMatch = rest.match(/^\/issues\/comments\/(\d+)$/); + if (issueCommentMatch) { + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'GET') { return handleGetIssueComment(ctx, targetDid, repoName, issueCommentMatch[1], url, bodyMediaKind); } + if (method === 'PATCH') { + return handleUpdateIssueComment(ctx, targetDid, repoName, issueCommentMatch[1], reqBody, url, bodyMediaKind); + } + if (method === 'DELETE') { return handleDeleteIssueComment(ctx, targetDid, repoName, issueCommentMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/comments/:id.`); + } + // /repos/:did/:repo/issues/:number/comments const issueCommentsMatch = rest.match(/^\/issues\/(\d+)\/comments$/); if (issueCommentsMatch) { - if (method === 'POST') { return handleCreateIssueComment(ctx, targetDid, repoName, issueCommentsMatch[1], reqBody, url); } - if (method === 'GET') { return handleListIssueComments(ctx, targetDid, repoName, issueCommentsMatch[1], url); } + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'POST') { + return handleCreateIssueComment(ctx, targetDid, repoName, issueCommentsMatch[1], reqBody, url, bodyMediaKind); + } + if (method === 'GET') { + return handleListIssueComments(ctx, targetDid, repoName, issueCommentsMatch[1], url, bodyMediaKind); + } return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/comments.`); } + // GET /repos/:did/:repo/issues/:number/events + const issueEventsMatch = rest.match(/^\/issues\/(\d+)\/events$/); + if (issueEventsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/events.`); } + return handleListIssueEvents(ctx, targetDid, repoName, issueEventsMatch[1], url); + } + + // GET /repos/:did/:repo/issues/:number/timeline + const issueTimelineMatch = rest.match(/^\/issues\/(\d+)\/timeline$/); + if (issueTimelineMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/timeline.`); } + return handleListIssueTimeline(ctx, targetDid, repoName, issueTimelineMatch[1], url); + } + + // /repos/:did/:repo/issues/:number/reactions + const issueReactionsMatch = rest.match(/^\/issues\/(\d+)\/reactions$/); + if (issueReactionsMatch) { + if (method === 'GET') { return handleListIssueReactions(ctx, targetDid, repoName, issueReactionsMatch[1], url); } + if (method === 'POST') { return handleCreateIssueReaction(ctx, targetDid, repoName, issueReactionsMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/reactions.`); + } + + // DELETE /repos/:did/:repo/issues/:number/reactions/:reaction_id + const issueReactionMatch = rest.match(/^\/issues\/(\d+)\/reactions\/(\d+)$/); + if (issueReactionMatch) { + if (method === 'DELETE') { + return handleDeleteIssueReaction(ctx, targetDid, repoName, issueReactionMatch[1], issueReactionMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/reactions/:reaction_id.`); + } + + // PUT/DELETE /repos/:did/:repo/issues/:number/lock + const issueLockMatch = rest.match(/^\/issues\/(\d+)\/lock$/); + if (issueLockMatch) { + if (method === 'PUT') { return handleLockIssue(ctx, targetDid, repoName, issueLockMatch[1], reqBody); } + if (method === 'DELETE') { return handleUnlockIssue(ctx, targetDid, repoName, issueLockMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/lock.`); + } + + // GET /repos/:did/:repo/issues/:number/assignees/:did + const issueAssigneeCheckMatch = rest.match(/^\/issues\/(\d+)\/assignees\/(.+)$/); + if (issueAssigneeCheckMatch) { + if (method === 'GET') { + return handleCheckIssueAssignee(ctx, targetDid, repoName, issueAssigneeCheckMatch[1], issueAssigneeCheckMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/assignees/:did.`); + } + + // /repos/:did/:repo/issues/:number/assignees + const issueAssigneesMatch = rest.match(/^\/issues\/(\d+)\/assignees$/); + if (issueAssigneesMatch) { + if (method === 'POST') { return handleAddIssueAssignees(ctx, targetDid, repoName, issueAssigneesMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleRemoveIssueAssignees(ctx, targetDid, repoName, issueAssigneesMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/assignees.`); + } + + // /repos/:did/:repo/issues/:number/dependencies/blocked_by + const issueDependenciesBlockedByMatch = rest.match(/^\/issues\/(\d+)\/dependencies\/blocked_by$/); + if (issueDependenciesBlockedByMatch) { + if (method === 'GET') { + return handleListIssueDependenciesBlockedBy(ctx, targetDid, repoName, issueDependenciesBlockedByMatch[1], url); + } + if (method === 'POST') { + return handleAddIssueDependencyBlockedBy(ctx, targetDid, repoName, issueDependenciesBlockedByMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/dependencies/blocked_by.`); + } + + // DELETE /repos/:did/:repo/issues/:number/dependencies/blocked_by/:issue_id + const issueDependencyBlockedByMatch = rest.match(/^\/issues\/(\d+)\/dependencies\/blocked_by\/(\d+)$/); + if (issueDependencyBlockedByMatch) { + if (method === 'DELETE') { + return handleRemoveIssueDependencyBlockedBy( + ctx, targetDid, repoName, issueDependencyBlockedByMatch[1], issueDependencyBlockedByMatch[2], url, + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/dependencies/blocked_by/:issue_id.`); + } + + // GET /repos/:did/:repo/issues/:number/dependencies/blocking + const issueDependenciesBlockingMatch = rest.match(/^\/issues\/(\d+)\/dependencies\/blocking$/); + if (issueDependenciesBlockingMatch) { + if (method === 'GET') { + return handleListIssueDependenciesBlocking(ctx, targetDid, repoName, issueDependenciesBlockingMatch[1], url); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/dependencies/blocking.`); + } + + // GET /repos/:did/:repo/issues/:number/parent + const issueParentMatch = rest.match(/^\/issues\/(\d+)\/parent$/); + if (issueParentMatch) { + if (method === 'GET') { return handleGetIssueParent(ctx, targetDid, repoName, issueParentMatch[1], url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/parent.`); + } + + // PATCH /repos/:did/:repo/issues/:number/sub_issues/priority + const issueSubIssuesPriorityMatch = rest.match(/^\/issues\/(\d+)\/sub_issues\/priority$/); + if (issueSubIssuesPriorityMatch) { + if (method === 'PATCH') { + return handleReprioritizeIssueSubIssue(ctx, targetDid, repoName, issueSubIssuesPriorityMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/sub_issues/priority.`); + } + + // GET/POST /repos/:did/:repo/issues/:number/sub_issues + const issueSubIssuesMatch = rest.match(/^\/issues\/(\d+)\/sub_issues$/); + if (issueSubIssuesMatch) { + if (method === 'GET') { return handleListIssueSubIssues(ctx, targetDid, repoName, issueSubIssuesMatch[1], url); } + if (method === 'POST') { return handleAddIssueSubIssue(ctx, targetDid, repoName, issueSubIssuesMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/sub_issues.`); + } + + // DELETE /repos/:did/:repo/issues/:number/sub_issue + const issueSubIssueMatch = rest.match(/^\/issues\/(\d+)\/sub_issue$/); + if (issueSubIssueMatch) { + if (method === 'DELETE') { return handleRemoveIssueSubIssue(ctx, targetDid, repoName, issueSubIssueMatch[1], reqBody, url); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/sub_issue.`); + } + + // GET/POST/PUT /repos/:did/:repo/issues/:number/issue-field-values + const issueFieldValuesMatch = rest.match(/^\/issues\/(\d+)\/issue-field-values$/); + if (issueFieldValuesMatch) { + if (method === 'GET') { return handleListIssueFieldValues(ctx, targetDid, repoName, issueFieldValuesMatch[1], url); } + if (method === 'POST') { return handleAddIssueFieldValues(ctx, targetDid, repoName, issueFieldValuesMatch[1], reqBody); } + if (method === 'PUT') { return handleSetIssueFieldValues(ctx, targetDid, repoName, issueFieldValuesMatch[1], reqBody); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/issue-field-values.`); + } + + // DELETE /repos/:did/:repo/issues/:number/issue-field-values/:issue_field_id + const issueFieldValueMatch = rest.match(/^\/issues\/(\d+)\/issue-field-values\/(\d+)$/); + if (issueFieldValueMatch) { + if (method === 'DELETE') { + return handleDeleteIssueFieldValue(ctx, targetDid, repoName, issueFieldValueMatch[1], issueFieldValueMatch[2]); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/issue-field-values/:issue_field_id.`); + } + + // /repos/:did/:repo/issues/:number/labels + const issueLabelsMatch = rest.match(/^\/issues\/(\d+)\/labels$/); + if (issueLabelsMatch) { + if (method === 'GET') { return handleListIssueLabels(ctx, targetDid, repoName, issueLabelsMatch[1], url); } + if (method === 'POST') { return handleAddIssueLabels(ctx, targetDid, repoName, issueLabelsMatch[1], reqBody, url); } + if (method === 'PUT') { return handleReplaceIssueLabels(ctx, targetDid, repoName, issueLabelsMatch[1], reqBody, url); } + if (method === 'DELETE') { return handleRemoveAllIssueLabels(ctx, targetDid, repoName, issueLabelsMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/labels.`); + } + + // DELETE /repos/:did/:repo/issues/:number/labels/:name + const issueLabelMatch = rest.match(/^\/issues\/(\d+)\/labels\/(.+)$/); + if (issueLabelMatch) { + if (method === 'DELETE') { + return handleRemoveIssueLabel(ctx, targetDid, repoName, issueLabelMatch[1], issueLabelMatch[2], url); + } + return jsonMethodNotAllowed(`${method} not allowed on /issues/:number/labels/:name.`); + } + + // /repos/:did/:repo/pulls/comments + if (rest === '/pulls/comments') { + if (method === 'GET') { + return handleListRepoPullReviewComments( + ctx, targetDid, repoName, url, githubCommitCommentBodyMediaKind(options.accept), + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/comments.`); + } + + // /repos/:did/:repo/pulls/comments/:id/reactions + const pullReviewCommentReactionsMatch = rest.match(/^\/pulls\/comments\/(\d+)\/reactions$/); + if (pullReviewCommentReactionsMatch) { + if (method === 'GET') { + return handleListPullReviewCommentReactions(ctx, targetDid, repoName, pullReviewCommentReactionsMatch[1], url); + } + if (method === 'POST') { + return handleCreatePullReviewCommentReaction(ctx, targetDid, repoName, pullReviewCommentReactionsMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/comments/:id/reactions.`); + } + + // DELETE /repos/:did/:repo/pulls/comments/:id/reactions/:reaction_id + const pullReviewCommentReactionMatch = rest.match(/^\/pulls\/comments\/(\d+)\/reactions\/(\d+)$/); + if (pullReviewCommentReactionMatch) { + if (method === 'DELETE') { + return handleDeletePullReviewCommentReaction( + ctx, targetDid, repoName, pullReviewCommentReactionMatch[1], pullReviewCommentReactionMatch[2], + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/comments/:id/reactions/:reaction_id.`); + } + + // /repos/:did/:repo/pulls/comments/:id + const pullReviewCommentMatch = rest.match(/^\/pulls\/comments\/(\d+)$/); + if (pullReviewCommentMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'GET') { + return handleGetPullReviewComment(ctx, targetDid, repoName, pullReviewCommentMatch[1], url, bodyMediaKind); + } + if (method === 'PATCH') { + return handleUpdatePullReviewComment( + ctx, targetDid, repoName, pullReviewCommentMatch[1], reqBody, url, bodyMediaKind, + ); + } + if (method === 'DELETE') { return handleDeletePullReviewComment(ctx, targetDid, repoName, pullReviewCommentMatch[1]); } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/comments/:id.`); + } + + // /repos/:did/:repo/pulls/:number.{diff,patch} + const pullTextMatch = rest.match(/^\/pulls\/(\d+)\.(diff|patch)$/); + if (pullTextMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number.:format.`); } + return handleGetPullText(ctx, targetDid, repoName, pullTextMatch[1], pullTextMatch[2] as 'diff' | 'patch', options); + } + // /repos/:did/:repo/issues/:number const issueMatch = rest.match(/^\/issues\/(\d+)$/); if (issueMatch) { - if (method === 'PATCH') { return handleUpdateIssue(ctx, targetDid, repoName, issueMatch[1], reqBody, url); } - if (method === 'GET') { return handleGetIssue(ctx, targetDid, repoName, issueMatch[1], url); } + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'PATCH') { return handleUpdateIssue(ctx, targetDid, repoName, issueMatch[1], reqBody, url, bodyMediaKind); } + if (method === 'GET') { return handleGetIssue(ctx, targetDid, repoName, issueMatch[1], url, bodyMediaKind); } return jsonMethodNotAllowed(`${method} not allowed on /issues/:number.`); } // /repos/:did/:repo/pulls/:number/merge const pullMergeMatch = rest.match(/^\/pulls\/(\d+)\/merge$/); if (pullMergeMatch) { + if (method === 'GET') { return handleCheckPullMerged(ctx, targetDid, repoName, pullMergeMatch[1]); } if (method === 'PUT') { return handleMergePull(ctx, targetDid, repoName, pullMergeMatch[1], reqBody, url); } return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/merge.`); } + // /repos/:did/:repo/pulls/:number/update-branch + const pullUpdateBranchMatch = rest.match(/^\/pulls\/(\d+)\/update-branch$/); + if (pullUpdateBranchMatch) { + if (method !== 'PUT') { return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/update-branch.`); } + return handleUpdatePullBranch(ctx, targetDid, repoName, pullUpdateBranchMatch[1], reqBody, url); + } + + // /repos/:did/:repo/pulls/:number/commits + const pullCommitsMatch = rest.match(/^\/pulls\/(\d+)\/commits$/); + if (pullCommitsMatch) { + if (method !== 'GET') { return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/commits.`); } + return handleListPullCommits(ctx, targetDid, repoName, pullCommitsMatch[1], url, options); + } + // /repos/:did/:repo/pulls/:number/files const pullFilesMatch = rest.match(/^\/pulls\/(\d+)\/files$/); if (pullFilesMatch) { @@ -213,19 +4627,128 @@ async function dispatchRepoRoute( return handleListPullFiles(ctx, targetDid, repoName, pullFilesMatch[1], url); } + // /repos/:did/:repo/pulls/:number/requested_reviewers + const pullRequestedReviewersMatch = rest.match(/^\/pulls\/(\d+)\/requested_reviewers$/); + if (pullRequestedReviewersMatch) { + if (method === 'GET') { + return handleListPullRequestedReviewers(ctx, targetDid, repoName, pullRequestedReviewersMatch[1], url); + } + if (method === 'POST') { + return handleRequestPullReviewers(ctx, targetDid, repoName, pullRequestedReviewersMatch[1], reqBody, url); + } + if (method === 'DELETE') { + return handleRemovePullRequestedReviewers(ctx, targetDid, repoName, pullRequestedReviewersMatch[1], reqBody, url); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/requested_reviewers.`); + } + + // /repos/:did/:repo/pulls/:number/comments/:comment_id/replies + const pullReviewCommentRepliesMatch = rest.match(/^\/pulls\/(\d+)\/comments\/(\d+)\/replies$/); + if (pullReviewCommentRepliesMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'POST') { + return handleCreatePullReviewCommentReply( + ctx, targetDid, repoName, pullReviewCommentRepliesMatch[1], pullReviewCommentRepliesMatch[2], + reqBody, url, bodyMediaKind, + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/comments/:comment_id/replies.`); + } + + // /repos/:did/:repo/pulls/:number/comments + const pullReviewCommentsMatch = rest.match(/^\/pulls\/(\d+)\/comments$/); + if (pullReviewCommentsMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'POST') { + return handleCreatePullReviewComment(ctx, targetDid, repoName, pullReviewCommentsMatch[1], reqBody, url, bodyMediaKind); + } + if (method === 'GET') { + return handleListPullReviewComments(ctx, targetDid, repoName, pullReviewCommentsMatch[1], url, bodyMediaKind); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/comments.`); + } + // /repos/:did/:repo/pulls/:number/reviews const pullReviewsMatch = rest.match(/^\/pulls\/(\d+)\/reviews$/); if (pullReviewsMatch) { - if (method === 'POST') { return handleCreatePullReview(ctx, targetDid, repoName, pullReviewsMatch[1], reqBody, url); } - if (method === 'GET') { return handleListPullReviews(ctx, targetDid, repoName, pullReviewsMatch[1], url); } + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'POST') { + return handleCreatePullReview(ctx, targetDid, repoName, pullReviewsMatch[1], reqBody, url, bodyMediaKind); + } + if (method === 'GET') { return handleListPullReviews(ctx, targetDid, repoName, pullReviewsMatch[1], url, bodyMediaKind); } return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/reviews.`); } + // /repos/:did/:repo/pulls/:number/reviews/:review_id/comments + const pullReviewCommentsForReviewMatch = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)\/comments$/); + if (pullReviewCommentsForReviewMatch) { + if (method === 'GET') { + return handleListPullReviewCommentsForReview( + ctx, targetDid, repoName, pullReviewCommentsForReviewMatch[1], pullReviewCommentsForReviewMatch[2], url, + githubCommitCommentBodyMediaKind(options.accept), + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/reviews/:review_id/comments.`); + } + + // /repos/:did/:repo/pulls/:number/reviews/:review_id/dismissals + const pullReviewDismissalsMatch = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)\/dismissals$/); + if (pullReviewDismissalsMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'PUT') { + return handleDismissPullReview( + ctx, targetDid, repoName, pullReviewDismissalsMatch[1], pullReviewDismissalsMatch[2], reqBody, url, + bodyMediaKind, + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/reviews/:review_id/dismissals.`); + } + + // /repos/:did/:repo/pulls/:number/reviews/:review_id/events + const pullReviewEventsMatch = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)\/events$/); + if (pullReviewEventsMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'POST') { + return handleSubmitPullReview( + ctx, targetDid, repoName, pullReviewEventsMatch[1], pullReviewEventsMatch[2], reqBody, url, + bodyMediaKind, + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/reviews/:review_id/events.`); + } + + // /repos/:did/:repo/pulls/:number/reviews/:review_id + const pullReviewMatch = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)$/); + if (pullReviewMatch) { + const bodyMediaKind = githubCommitCommentBodyMediaKind(options.accept); + if (method === 'GET') { + return handleGetPullReview(ctx, targetDid, repoName, pullReviewMatch[1], pullReviewMatch[2], url, bodyMediaKind); + } + if (method === 'PUT' || method === 'PATCH') { + return handleUpdatePullReview( + ctx, targetDid, repoName, pullReviewMatch[1], pullReviewMatch[2], reqBody, url, bodyMediaKind, + ); + } + if (method === 'DELETE') { + return handleDeletePendingPullReview( + ctx, targetDid, repoName, pullReviewMatch[1], pullReviewMatch[2], url, bodyMediaKind, + ); + } + return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number/reviews/:review_id.`); + } + // /repos/:did/:repo/pulls/:number const pullMatch = rest.match(/^\/pulls\/(\d+)$/); if (pullMatch) { - if (method === 'PATCH') { return handleUpdatePull(ctx, targetDid, repoName, pullMatch[1], reqBody, url); } - if (method === 'GET') { return handleGetPull(ctx, targetDid, repoName, pullMatch[1], url); } + const bodyMediaKind = githubBodyMediaKind(options.accept); + if (method === 'PATCH') { return handleUpdatePull(ctx, targetDid, repoName, pullMatch[1], reqBody, url, bodyMediaKind); } + if (method === 'GET') { + const textKind = githubTextMediaKind(options.accept); + if (textKind) { + return handleGetPullText(ctx, targetDid, repoName, pullMatch[1], textKind, options); + } + return handleGetPull(ctx, targetDid, repoName, pullMatch[1], url, bodyMediaKind); + } return jsonMethodNotAllowed(`${method} not allowed on /pulls/:number.`); } @@ -236,9 +4759,13 @@ async function dispatchRepoRoute( // Server // --------------------------------------------------------------------------- +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + /** Start the GitHub API shim server. Returns the server instance. */ export function startShimServer(options: ShimServerOptions): Server { - const { ctx, port } = options; + const { ctx, port, reposPath } = options; const server = createServer(async (req, res) => { const method = req.method ?? 'GET'; @@ -271,17 +4798,22 @@ export function startShimServer(options: ShimServerOptions): Server { try { const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const isReleaseAssetUpload = method === 'POST' && RELEASE_ASSET_UPLOAD_RE.test(url.pathname); + const isRawMarkdownRender = method === 'POST' && MARKDOWN_RAW_RE.test(url.pathname); + const isRawBodyRequest = isReleaseAssetUpload || isRawMarkdownRender; // Parse request body for mutating methods (with size limit). let reqBody: Record<string, unknown> = {}; + let rawBody: Uint8Array | undefined; if (method === 'POST' || method === 'PATCH' || method === 'PUT') { const chunks: Buffer[] = []; let totalSize = 0; let tooLarge = false; + const maxBodySize = isReleaseAssetUpload ? MAX_BINARY_BODY : MAX_JSON_BODY; for await (const chunk of req) { const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; totalSize += buf.length; - if (totalSize > MAX_JSON_BODY) { tooLarge = true; break; } + if (totalSize > maxBodySize) { tooLarge = true; break; } chunks.push(buf); } if (tooLarge) { @@ -289,20 +4821,37 @@ export function startShimServer(options: ShimServerOptions): Server { res.end(JSON.stringify({ message: 'Payload Too Large' })); return; } - const raw = Buffer.concat(chunks).toString('utf-8'); - if (raw.length > 0) { - try { - reqBody = JSON.parse(raw); - } catch { - res.writeHead(400, baseHeaders()); - res.end(JSON.stringify({ message: 'Invalid JSON in request body.' })); - return; + const raw = Buffer.concat(chunks); + if (isRawBodyRequest) { + rawBody = raw; + } else { + const rawText = raw.toString('utf-8'); + if (rawText.length > 0) { + try { + reqBody = JSON.parse(rawText); + } catch { + res.writeHead(400, baseHeaders()); + res.end(JSON.stringify({ message: 'Invalid JSON in request body.' })); + return; + } } } } const authHeader = req.headers.authorization ?? null; - const result = await handleShimRequest(ctx, url, method, reqBody, authHeader); + const accept = headerValue(req.headers.accept); + const contentType = headerValue(req.headers['content-type']); + const requestOptions: ShimRequestOptions = { reposPath }; + if (accept) { + requestOptions.accept = accept; + } + if (rawBody) { + requestOptions.rawBody = rawBody; + } + if (isReleaseAssetUpload && contentType) { + requestOptions.contentType = contentType; + } + const result = await handleShimRequest(ctx, url, method, reqBody, authHeader, requestOptions); res.writeHead(result.status, result.headers); res.end(result.body); @@ -316,26 +4865,595 @@ export function startShimServer(options: ShimServerOptions): Server { server.listen(port, () => { console.log(`[github-shim] GitHub API compatibility shim running at http://localhost:${port}`); console.log('[github-shim] Read endpoints (GET):'); + console.log(' GET / API root links'); + console.log(' GET /meta API metadata'); + console.log(' GET /versions Supported API versions'); + console.log(' GET /zen Status phrase'); + console.log(' GET /rate_limit Rate limit status'); + console.log(' GET /emojis Emoji map'); + console.log(' GET /gists Authenticated user gists'); + console.log(' GET /gists/public Public gists visible to local actor'); + console.log(' GET /gists/starred Authenticated user starred gists'); + console.log(' GET /gists/:gist_id Gist detail'); + console.log(' GET /gists/:gist_id/:sha Gist revision detail'); + console.log(' GET /gists/:gist_id/commits Gist commits'); + console.log(' GET /gists/:gist_id/comments Gist comments'); + console.log(' GET /gists/:gist_id/comments/:comment_id Gist comment detail'); + console.log(' GET /gists/:gist_id/forks Gist forks'); + console.log(' GET /gists/:gist_id/raw/:filename Raw gist file content'); + console.log(' GET /gists/:gist_id/star Check gist star'); + console.log(' GET /gitignore/templates Gitignore templates'); + console.log(' GET /gitignore/templates/:name Gitignore template'); + console.log(' GET /licenses Common licenses'); + console.log(' GET /licenses/:license License detail'); + console.log(' GET /events Public activity events'); + console.log(' GET /repositories Public repositories'); + console.log(' GET /networks/:did/:repo/events Network repository events'); console.log(' GET /repos/:did/:repo Repository info'); + console.log(' GET /repos/:did/:repo/events Repository events'); + console.log(' GET /repos/:did/:repo/activity Repository activity history'); + console.log(' GET /repos/:did/:repo/forks Repository forks'); + console.log(' GET /repos/:did/:repo/readme Repository README'); + console.log(' GET /repos/:did/:repo/readme/:dir Repository README for a directory'); + console.log(' GET /repos/:did/:repo/license Repository license'); + console.log(' GET /repos/:did/:repo/contents[/path] Repository git tree / metadata contents'); + console.log(' GET /repos/:did/:repo/raw/:ref/:path Repository raw file content'); + console.log(' GET /repos/:did/:repo/tarball[/:ref] Repository tar archive'); + console.log(' GET /repos/:did/:repo/zipball[/:ref] Repository zip archive'); + console.log(' GET /repos/:did/:repo/branches List branches'); + console.log(' GET /repos/:did/:repo/branches/:branch Branch detail'); + console.log(' GET /repos/:did/:repo/branches/:branch/protection Branch protection'); + console.log(' GET /repos/:did/:repo/branches/:branch/protection/required_status_checks Status check protection'); + console.log(' GET /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Status check contexts'); + console.log(' GET /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Pull request review protection'); + console.log(' GET /repos/:did/:repo/tags List tags'); + console.log(' GET /repos/:did/:repo/teams Repository teams'); + console.log(' GET /repos/:did/:repo/git/ref/:ref Git ref detail'); + console.log(' GET /repos/:did/:repo/git/matching-refs/:ref Matching git refs'); + console.log(' GET /repos/:did/:repo/git/blobs/:sha Git blob object'); + console.log(' GET /repos/:did/:repo/git/trees/:sha Git tree object'); + console.log(' GET /repos/:did/:repo/git/commits/:sha Low-level git commit object'); + console.log(' GET /repos/:did/:repo/git/tags/:sha Low-level git tag object'); + console.log(' GET /repos/:did/:repo/community/profile Community profile metrics'); + console.log(' GET /repos/:did/:repo/contributors List contributors'); + console.log(' GET /repos/:did/:repo/commits List repository commits'); + console.log(' GET /repos/:did/:repo/commits/:ref/comments Commit comments for commit'); + console.log(' GET /repos/:did/:repo/commits/:ref Repository commit object'); + console.log(' GET /repos/:did/:repo/comments Repository commit comments'); + console.log(' GET /repos/:did/:repo/comments/:id Commit comment detail'); + console.log(' GET /repos/:did/:repo/comments/:id/reactions Commit comment reactions'); + console.log(' GET /repos/:did/:repo/compare/:base...:head Compare commits'); + console.log(' GET /repos/:did/:repo/compare/:base...:head.diff Compare commit diff'); + console.log(' GET /repos/:did/:repo/compare/:base...:head.patch Compare commit patch'); + console.log(' GET /repos/:did/:repo/stats/code_frequency Code frequency stats'); + console.log(' GET /repos/:did/:repo/stats/commit_activity Commit activity stats'); + console.log(' GET /repos/:did/:repo/stats/contributors Contributor activity stats'); + console.log(' GET /repos/:did/:repo/stats/participation Participation stats'); + console.log(' GET /repos/:did/:repo/stats/punch_card Commit punch card stats'); + console.log(' GET /repos/:did/:repo/traffic/clones Repository clone traffic'); + console.log(' GET /repos/:did/:repo/traffic/popular/paths Popular repository paths'); + console.log(' GET /repos/:did/:repo/traffic/popular/referrers Popular referrers'); + console.log(' GET /repos/:did/:repo/traffic/views Repository view traffic'); + console.log(' GET /repos/:did/:repo/topics Repository topics'); + console.log(' GET /repos/:did/:repo/languages Repository languages'); + console.log(' GET /repos/:did/:repo/issue-types Repository issue types'); + console.log(' GET /repos/:did/:repo/properties/values Repository custom property values'); + console.log(' GET /repos/:did/:repo/codeowners/errors Repository CODEOWNERS errors'); + console.log(' GET /repos/:did/:repo/hash-algorithm Repository hash algorithm'); + console.log(' GET /repos/:did/:repo/attestations/:digest Repository artifact attestations'); + console.log(' GET /repos/:did/:repo/keys List deploy keys'); + console.log(' GET /repos/:did/:repo/keys/:key_id Deploy key detail'); + console.log(' GET /repos/:did/:repo/autolinks Repository autolinks'); + console.log(' GET /repos/:did/:repo/autolinks/:id Repository autolink detail'); + console.log(' GET /repos/:did/:repo/interaction-limits Repository interaction restrictions'); + console.log(' GET /repos/:did/:repo/vulnerability-alerts Repository vulnerability alerts status'); + console.log(' GET /repos/:did/:repo/automated-security-fixes Dependabot security updates status'); + console.log(' GET /repos/:did/:repo/immutable-releases Repository immutable releases status'); + console.log(' GET /repos/:did/:repo/private-vulnerability-reporting Private vulnerability reporting status'); + console.log(' GET /repos/:did/:repo/security-advisories Repository security advisories'); + console.log(' GET /repos/:did/:repo/security-advisories/:ghsa_id Repository security advisory'); + console.log(' GET /repos/:did/:repo/secret-scanning/alerts Repository secret scanning alerts'); + console.log(' GET /repos/:did/:repo/secret-scanning/alerts/:number Repository secret scanning alert'); + console.log(' GET /repos/:did/:repo/secret-scanning/alerts/:number/locations Repository secret scanning alert locations'); + console.log(' GET /repos/:did/:repo/secret-scanning/scan-history Repository secret scanning scan history'); + console.log(' GET /repos/:did/:repo/code-scanning/alerts Repository code scanning alerts'); + console.log(' GET /repos/:did/:repo/code-scanning/alerts/:number Repository code scanning alert'); + console.log(' GET /repos/:did/:repo/code-scanning/alerts/:number/instances Repository code scanning alert instances'); + console.log(' GET /repos/:did/:repo/dependabot/alerts Repository Dependabot alerts'); + console.log(' GET /repos/:did/:repo/dependabot/alerts/:number Repository Dependabot alert'); + console.log(' GET /repos/:did/:repo/dependency-graph/sbom Repository SPDX SBOM'); + console.log(' GET /repos/:did/:repo/dependency-graph/sbom/generate-report Request repository SBOM generation'); + console.log(' GET /repos/:did/:repo/dependency-graph/sbom/fetch-report/:uuid Fetch generated repository SBOM report'); + console.log(' GET /repos/:did/:repo/rules/branches/:branch Active branch rules'); + console.log(' GET /repos/:did/:repo/rulesets/rule-suites Repository rule suites'); + console.log(' GET /repos/:did/:repo/rulesets/rule-suites/:id Repository rule suite'); + console.log(' GET /repos/:did/:repo/rulesets Repository rulesets'); + console.log(' GET /repos/:did/:repo/rulesets/:id Repository ruleset detail'); + console.log(' GET /repos/:did/:repo/rulesets/:id/history Repository ruleset history'); + console.log(' GET /repos/:did/:repo/rulesets/:id/history/:version Repository ruleset version'); + console.log(' GET /repos/:did/:repo/assignees List assignable users'); + console.log(' GET /repos/:did/:repo/assignees/:did Check assignable user'); + console.log(' GET /repos/:did/:repo/collaborators List collaborators'); + console.log(' GET /repos/:did/:repo/collaborators/:did Check collaborator'); + console.log(' GET /repos/:did/:repo/collaborators/:did/permission Collaborator permission'); + console.log(' GET /repos/:did/:repo/commits/:ref/status Combined status'); + console.log(' GET /repos/:did/:repo/commits/:ref/statuses Commit statuses'); + console.log(' GET /repos/:did/:repo/commits/:ref/check-suites Check suites'); + console.log(' GET /repos/:did/:repo/commits/:ref/check-runs Check runs'); + console.log(' GET /repos/:did/:repo/check-suites/:id Check suite detail'); + console.log(' GET /repos/:did/:repo/check-suites/:id/check-runs Check suite runs'); + console.log(' GET /repos/:did/:repo/check-runs/:id Check run detail'); + console.log(' GET /repos/:did/:repo/check-runs/:id/annotations Check run annotations'); + console.log(' GET /repos/:did/:repo/actions/artifacts Workflow artifacts'); + console.log(' GET /repos/:did/:repo/actions/artifacts/:id Artifact detail'); + console.log(' GET /repos/:did/:repo/actions/artifacts/:id/zip Download artifact'); + console.log(' GET /repos/:did/:repo/actions/cache/retention-limit Actions cache retention limit'); + console.log(' GET /repos/:did/:repo/actions/cache/storage-limit Actions cache storage limit'); + console.log(' GET /repos/:did/:repo/actions/cache/usage Actions cache usage'); + console.log(' GET /repos/:did/:repo/actions/caches Actions caches'); + console.log(' GET /repos/:did/:repo/actions/permissions Actions permissions'); + console.log(' GET /repos/:did/:repo/actions/permissions/selected-actions Allowed Actions settings'); + console.log(' GET /repos/:did/:repo/actions/permissions/workflow Default workflow permissions'); + console.log(' GET /repos/:did/:repo/actions/secrets Repository Actions secrets'); + console.log(' GET /repos/:did/:repo/actions/secrets/public-key Repository secret public key'); + console.log(' GET /repos/:did/:repo/actions/secrets/:name Repository Actions secret'); + console.log(' GET /repos/:did/:repo/actions/variables Repository Actions variables'); + console.log(' GET /repos/:did/:repo/actions/variables/:name Repository Actions variable'); + console.log(' GET /repos/:did/:repo/actions/workflows Workflow definitions'); + console.log(' GET /repos/:did/:repo/actions/workflows/:id Workflow definition detail'); + console.log(' GET /repos/:did/:repo/actions/workflows/:id/runs Workflow runs for workflow'); + console.log(' GET /repos/:did/:repo/actions/workflows/:id/timing Workflow usage'); + console.log(' GET /repos/:did/:repo/actions/runs Workflow runs'); + console.log(' GET /repos/:did/:repo/actions/runs/:id Workflow run detail'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/attempts/:attempt Workflow run attempt'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/attempts/:attempt/logs Workflow run attempt logs'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/logs Workflow run logs'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/artifacts Workflow run artifacts'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/jobs Workflow run jobs'); + console.log(' GET /repos/:did/:repo/actions/runs/:id/timing Workflow run usage'); + console.log(' GET /repos/:did/:repo/actions/jobs/:id/logs Workflow job logs'); + console.log(' GET /repos/:did/:repo/actions/jobs/:id Workflow job detail'); + console.log(' GET /repos/:did/:repo/stargazers Repository stargazers'); + console.log(' GET /repos/:did/:repo/subscribers Repository watchers/subscribers'); + console.log(' GET /repos/:did/:repo/subscription Authenticated repository subscription'); + console.log(' GET /repos/:did/:repo/hooks Repository webhooks'); + console.log(' GET /repos/:did/:repo/hooks/:id Repository webhook'); + console.log(' GET /repos/:did/:repo/hooks/:id/config Repository webhook configuration'); + console.log(' GET /repos/:did/:repo/hooks/:id/deliveries Repository webhook deliveries'); + console.log(' GET /repos/:did/:repo/hooks/:id/deliveries/:delivery_id Repository webhook delivery'); + console.log(' GET /repos/:did/:repo/labels Repository labels'); + console.log(' GET /repos/:did/:repo/labels/:name Repository label detail'); + console.log(' GET /repos/:did/:repo/milestones Repository milestones'); + console.log(' GET /repos/:did/:repo/milestones/:n Milestone detail'); + console.log(' GET /repos/:did/:repo/milestones/:n/labels Milestone labels'); + console.log(' GET /issues Assigned issues'); + console.log(' GET /user/issues Authenticated user issues'); + console.log(' GET /orgs/:org/issues Organization issues'); console.log(' GET /repos/:did/:repo/issues List issues'); + console.log(' GET /repos/:did/:repo/issues/events List repository issue events'); + console.log(' GET /repos/:did/:repo/issues/events/:id Issue event detail'); + console.log(' GET /repos/:did/:repo/issues/comments List repository issue comments'); + console.log(' GET /repos/:did/:repo/issues/comments/:id Issue comment detail'); + console.log(' GET /repos/:did/:repo/issues/comments/:id/reactions Issue comment reactions'); console.log(' GET /repos/:did/:repo/issues/:number Issue detail'); console.log(' GET /repos/:did/:repo/issues/:n/comments Issue comments'); + console.log(' GET /repos/:did/:repo/issues/:n/events Issue events'); + console.log(' GET /repos/:did/:repo/issues/:n/timeline Issue timeline'); + console.log(' GET /repos/:did/:repo/issues/:n/dependencies/blocked_by Issue blocked-by dependencies'); + console.log(' GET /repos/:did/:repo/issues/:n/dependencies/blocking Issue blocking dependencies'); + console.log(' GET /repos/:did/:repo/issues/:n/parent Parent issue'); + console.log(' GET /repos/:did/:repo/issues/:n/sub_issues Sub-issues'); + console.log(' GET /repos/:did/:repo/issues/:n/issue-field-values Issue field values'); + console.log(' GET /repos/:did/:repo/issues/:n/reactions Issue reactions'); + console.log(' GET /repos/:did/:repo/issues/:n/labels Issue labels'); console.log(' GET /repos/:did/:repo/pulls List pull requests'); console.log(' GET /repos/:did/:repo/pulls/:number Pull request detail'); + console.log(' GET /repos/:did/:repo/pulls/:n.diff Pull request diff'); + console.log(' GET /repos/:did/:repo/pulls/:n.patch Pull request patch'); + console.log(' GET /repos/:did/:repo/pulls/:n/commits Pull request commits'); + console.log(' GET /repos/:did/:repo/pulls/:n/comments Pull request review comments'); console.log(' GET /repos/:did/:repo/pulls/:n/files Pull request files'); console.log(' GET /repos/:did/:repo/pulls/:n/reviews Pull request reviews'); + console.log(' GET /repos/:did/:repo/pulls/:n/reviews/:id Pull request review'); + console.log(' GET /repos/:did/:repo/pulls/:n/reviews/:id/comments Pull request review comments'); + console.log(' GET /repos/:did/:repo/pulls/comments Repository pull review comments'); + console.log(' GET /repos/:did/:repo/pulls/comments/:id Pull request review comment'); + console.log(' GET /repos/:did/:repo/pulls/comments/:id/reactions Pull request review comment reactions'); console.log(' GET /repos/:did/:repo/releases List releases'); + console.log(' GET /repos/:did/:repo/releases/latest Latest release'); + console.log(' GET /repos/:did/:repo/releases/:id Release detail'); + console.log(' GET /repos/:did/:repo/releases/:id/reactions Release reactions'); + console.log(' GET /repos/:did/:repo/releases/:id/assets Release assets'); + console.log(' GET /repos/:did/:repo/releases/assets/:id Release asset metadata'); + console.log(' GET /repos/:did/:repo/releases/assets/:id/download Release asset bytes'); + console.log(' GET /repos/:did/:repo/releases/download/:tag/:asset Release asset download'); console.log(' GET /repos/:did/:repo/releases/tags/:t Release by tag'); + console.log(' GET /repos/:did/:repo/environments Deployment environments'); + console.log(' GET /repos/:did/:repo/environments/:name Deployment environment detail'); + console.log(' GET /repos/:did/:repo/environments/:name/secrets Environment secrets'); + console.log(' GET /repos/:did/:repo/environments/:name/secrets/public-key Environment secret public key'); + console.log(' GET /repos/:did/:repo/environments/:name/secrets/:secret Environment secret'); + console.log(' GET /repos/:did/:repo/environments/:name/variables Environment variables'); + console.log(' GET /repos/:did/:repo/environments/:name/variables/:var Environment variable'); + console.log(' GET /repos/:did/:repo/deployments List deployments'); + console.log(' GET /repos/:did/:repo/deployments/:id Deployment detail'); + console.log(' GET /repos/:did/:repo/deployments/:id/statuses Deployment statuses'); + console.log(' GET /repos/:did/:repo/deployments/:id/statuses/:status Deployment status'); + console.log(' GET /repos/:did/:repo/pages GitHub Pages site'); + console.log(' GET /repos/:did/:repo/pages/builds GitHub Pages builds'); + console.log(' GET /repos/:did/:repo/pages/builds/latest Latest GitHub Pages build'); + console.log(' GET /repos/:did/:repo/pages/builds/:id GitHub Pages build detail'); + console.log(' GET /repos/:did/:repo/pages/deployments/:id GitHub Pages deployment status'); + console.log(' GET /repos/:did/:repo/pages/deployments/:id/status GitHub Pages deployment status URL'); + console.log(' GET /repos/:did/:repo/pages/health GitHub Pages DNS health'); + console.log(' GET /notifications Authenticated notifications'); + console.log(' GET /notifications/threads/:id Notification thread'); + console.log(' GET /notifications/threads/:id/subscription Notification thread subscription'); + console.log(' GET /organizations Public organizations'); + console.log(' GET /orgs/:org Organization profile'); + console.log(' GET /orgs/:org/members Organization members'); + console.log(' GET /orgs/:org/members/:did Check organization membership'); + console.log(' GET /orgs/:org/memberships/:did Organization membership detail'); + console.log(' GET /orgs/:org/failed_invitations Failed organization invitations'); + console.log(' GET /orgs/:org/invitations Pending organization invitations'); + console.log(' GET /orgs/:org/invitations/:id/teams Organization invitation teams'); + console.log(' GET /orgs/:org/blocks Organization blocked users'); + console.log(' GET /orgs/:org/blocks/:username Check organization blocked user'); + console.log(' GET /orgs/:org/hooks Organization webhooks'); + console.log(' GET /orgs/:org/hooks/:id Organization webhook'); + console.log(' GET /orgs/:org/hooks/:id/config Organization webhook configuration'); + console.log(' GET /orgs/:org/hooks/:id/deliveries Organization webhook deliveries'); + console.log(' GET /orgs/:org/hooks/:id/deliveries/:delivery_id Organization webhook delivery'); + console.log(' GET /orgs/:org/properties/schema Organization custom property schema'); + console.log(' GET /orgs/:org/properties/schema/:property Organization custom property'); + console.log(' GET /orgs/:org/properties/values Organization repository custom property values'); + console.log(' GET /orgs/:org/issue-fields Organization issue fields'); + console.log(' GET /orgs/:org/issue-types Organization issue types'); + console.log(' GET /orgs/:org/outside_collaborators Outside collaborators'); + console.log(' GET /orgs/:org/public_members Public organization members'); + console.log(' GET /orgs/:org/public_members/:did Check public organization membership'); + console.log(' GET /orgs/:org/repos Organization repositories'); + console.log(' GET /orgs/:org/code-scanning/alerts Organization code scanning alerts'); + console.log(' GET /orgs/:org/dependabot/alerts Organization Dependabot alerts'); + console.log(' GET /orgs/:org/secret-scanning/alerts Organization secret scanning alerts'); + console.log(' GET /orgs/:org/security-advisories Organization repository security advisories'); + console.log(' GET /orgs/:org/teams Organization teams'); + console.log(' GET /orgs/:org/teams/:team Organization team'); + console.log(' GET /orgs/:org/teams/:team/teams Child organization teams'); + console.log(' GET /orgs/:org/teams/:team/invitations Pending team invitations'); + console.log(' GET /orgs/:org/teams/:team/members Organization team members'); + console.log(' GET /orgs/:org/teams/:team/members/:did Check team membership'); + console.log(' GET /orgs/:org/teams/:team/memberships/:did Get team membership'); + console.log(' GET /orgs/:org/teams/:team/repos Team repositories'); + console.log(' GET /orgs/:org/teams/:team/repos/:did/:repo Check team repository permission'); + console.log(' GET /organizations/:org_id/team/:team_id Organization team by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/teams Child organization teams by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/invitations Pending team invitations by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/members Organization team members by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/memberships/:did Get team membership by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/repos Team repositories by numeric ID'); + console.log(' GET /organizations/:org_id/team/:team_id/repos/:did/:repo Check team repository permission by numeric ID'); + console.log(' GET /teams/:team_id Legacy team by numeric ID'); + console.log(' GET /teams/:team_id/teams Legacy child organization teams by numeric ID'); + console.log(' GET /teams/:team_id/invitations Legacy pending team invitations by numeric ID'); + console.log(' GET /teams/:team_id/members Legacy organization team members by numeric ID'); + console.log(' GET /teams/:team_id/members/:did Legacy check team membership by numeric ID'); + console.log(' GET /teams/:team_id/memberships/:did Legacy get team membership by numeric ID'); + console.log(' GET /teams/:team_id/repos Legacy team repositories by numeric ID'); + console.log(' GET /teams/:team_id/repos/:did/:repo Legacy check team repository permission by numeric ID'); + console.log(' GET /repos/:did/:repo/notifications Repository notifications'); + console.log(' GET /search/code Search code'); + console.log(' GET /search/commits Search commits'); + console.log(' GET /search/issues Search issues and pull requests'); + console.log(' GET /search/labels Search repository labels'); + console.log(' GET /search/repositories Search repositories'); + console.log(' GET /search/topics Search topics'); + console.log(' GET /search/users Search users'); + console.log(' GET /user Authenticated user profile'); + console.log(' GET /user/repos Authenticated repositories'); + console.log(' GET /user/orgs Authenticated organizations'); + console.log(' GET /user/memberships/orgs Authenticated organization memberships'); + console.log(' GET /user/memberships/orgs/:org Authenticated organization membership'); + console.log(' GET /user/teams Authenticated teams'); + console.log(' GET /user/emails Authenticated email addresses'); + console.log(' GET /user/public_emails Authenticated public email addresses'); + console.log(' GET /user/gpg_keys Authenticated GPG keys'); + console.log(' GET /user/gpg_keys/:id Authenticated GPG key'); + console.log(' GET /user/social_accounts Authenticated social accounts'); + console.log(' GET /user/keys Authenticated SSH keys'); + console.log(' GET /user/keys/:key_id Authenticated SSH key'); + console.log(' GET /user/ssh_signing_keys Authenticated SSH signing keys'); + console.log(' GET /user/ssh_signing_keys/:id Authenticated SSH signing key'); + console.log(' GET /user/blocks Authenticated blocked users'); + console.log(' GET /user/blocks/:did Check authenticated blocked user'); + console.log(' GET /user/followers Authenticated followers'); + console.log(' GET /user/following Authenticated following'); + console.log(' GET /user/following/:did Check authenticated follow'); + console.log(' GET /user/starred Authenticated starred repos'); + console.log(' GET /user/starred/:did/:repo Check authenticated user star'); + console.log(' GET /user/subscriptions Authenticated watched repos'); + console.log(' GET /user/:account_id User profile by numeric ID'); + console.log(' GET /users/:did/followers User followers'); + console.log(' GET /users/:did/following User following'); + console.log(' GET /users/:did/following/:target Check if user follows target'); + console.log(' GET /users/:did/events User events'); + console.log(' GET /users/:did/events/public User public events'); + console.log(' GET /users/:did/received_events User received events'); + console.log(' GET /users/:did/received_events/public User public received events'); + console.log(' GET /users/:did/repos User repositories'); + console.log(' GET /users/:did/gists User public gists'); + console.log(' GET /users/:did/gpg_keys User public GPG keys'); + console.log(' GET /users/:did/social_accounts User public social accounts'); + console.log(' GET /users/:did/keys User public SSH keys'); + console.log(' GET /users/:did/ssh_signing_keys User public SSH signing keys'); + console.log(' GET /users/:did/orgs User public organizations'); + console.log(' GET /users/:did/starred User starred repos'); + console.log(' GET /users/:did/subscriptions User watched repos'); + console.log(' GET /users/:did/hovercard User hovercard contexts'); + console.log(' GET /users/:did/attestations/:digest User artifact attestations'); console.log(' GET /users/:did User profile'); - console.log('[github-shim] Write endpoints (POST/PATCH/PUT):'); + console.log(' GET /users Public users visible to local actor'); + console.log('[github-shim] Write endpoints (POST/PATCH/PUT/DELETE):'); + console.log(' POST /markdown Render Markdown'); + console.log(' POST /markdown/raw Render raw Markdown'); + console.log(' POST /gists Create a gist'); + console.log(' POST /gists/:gist_id/comments Create a gist comment'); + console.log(' POST /gists/:gist_id/forks Fork a gist'); + console.log(' PATCH /gists/:gist_id Update a gist'); + console.log(' PATCH /gists/:gist_id/comments/:comment_id Update a gist comment'); + console.log(' PUT /gists/:gist_id/star Star a gist'); + console.log(' DELETE /gists/:gist_id Delete a gist'); + console.log(' DELETE /gists/:gist_id/comments/:comment_id Delete a gist comment'); + console.log(' DELETE /gists/:gist_id/star Unstar a gist'); + console.log(' PATCH /repos/:did/:repo Update repository metadata'); + console.log(' DELETE /repos/:did/:repo Delete repository'); + console.log(' POST /repos/:did/:repo/forks Create a fork'); + console.log(' POST /repos/:did/:repo/generate Create repository from template'); + console.log(' POST /repos/:did/:repo/transfer Request repository transfer'); + console.log(' PUT /repos/:did/:repo/contents/:path Create or update file contents'); + console.log(' DELETE /repos/:did/:repo/contents/:path Delete file contents'); + console.log(' POST /repos/:did/:repo/git/blobs Create git blob object'); + console.log(' POST /repos/:did/:repo/git/trees Create git tree object'); + console.log(' POST /repos/:did/:repo/git/commits Create git commit object'); + console.log(' POST /repos/:did/:repo/git/tags Create git tag object'); + console.log(' POST /repos/:did/:repo/git/refs Create git reference'); + console.log(' PATCH /repos/:did/:repo/git/refs/:ref Update git reference'); + console.log(' DELETE /repos/:did/:repo/git/refs/:ref Delete git reference'); + console.log(' POST /repos/:did/:repo/commits/:sha/comments Create commit comment'); + console.log(' PATCH /repos/:did/:repo/comments/:id Update commit comment'); + console.log(' DELETE /repos/:did/:repo/comments/:id Delete commit comment'); + console.log(' POST /repos/:did/:repo/comments/:id/reactions Create commit comment reaction'); + console.log(' DELETE /repos/:did/:repo/comments/:id/reactions/:rid Delete commit comment reaction'); console.log(' POST /repos/:did/:repo/issues Create issue'); console.log(' PATCH /repos/:did/:repo/issues/:number Update issue'); console.log(' POST /repos/:did/:repo/issues/:n/comments Create comment'); + console.log(' PATCH /repos/:did/:repo/issues/comments/:id Update issue comment'); + console.log(' DELETE /repos/:did/:repo/issues/comments/:id Delete issue comment'); + console.log(' PUT /repos/:did/:repo/issues/comments/:id/pin Pin issue comment'); + console.log(' DELETE /repos/:did/:repo/issues/comments/:id/pin Unpin issue comment'); + console.log(' POST /repos/:did/:repo/issues/comments/:id/reactions Create issue comment reaction'); + console.log(' DELETE /repos/:did/:repo/issues/comments/:id/reactions/:rid Delete issue comment reaction'); + console.log(' POST /repos/:did/:repo/issues/:n/reactions Create issue reaction'); + console.log(' DELETE /repos/:did/:repo/issues/:n/reactions/:rid Delete issue reaction'); + console.log(' POST /repos/:did/:repo/issues/:n/dependencies/blocked_by Add issue dependency'); + console.log(' DELETE /repos/:did/:repo/issues/:n/dependencies/blocked_by/:iid Remove issue dependency'); + console.log(' POST /repos/:did/:repo/issues/:n/sub_issues Add sub-issue'); + console.log(' DELETE /repos/:did/:repo/issues/:n/sub_issue Remove sub-issue'); + console.log(' PATCH /repos/:did/:repo/issues/:n/sub_issues/priority Reprioritize sub-issue'); + console.log(' POST /repos/:did/:repo/issues/:n/issue-field-values Add issue field values'); + console.log(' PUT /repos/:did/:repo/issues/:n/issue-field-values Set issue field values'); + console.log(' DELETE /repos/:did/:repo/issues/:n/issue-field-values/:fid Delete issue field value'); + console.log(' POST /repos/:did/:repo/issues/:n/labels Add labels'); + console.log(' PUT /repos/:did/:repo/issues/:n/labels Replace labels'); + console.log(' DELETE /repos/:did/:repo/issues/:n/labels Remove all labels'); + console.log(' DELETE /repos/:did/:repo/issues/:n/labels/:name Remove label'); + console.log(' POST /repos/:did/:repo/labels Create repository label'); + console.log(' PATCH /repos/:did/:repo/labels/:name Update repository label'); + console.log(' DELETE /repos/:did/:repo/labels/:name Delete repository label'); + console.log(' POST /repos/:did/:repo/milestones Create milestone'); + console.log(' PATCH /repos/:did/:repo/milestones/:n Update milestone'); + console.log(' DELETE /repos/:did/:repo/milestones/:n Delete milestone'); + console.log(' PUT /repos/:did/:repo/issues/:n/lock Lock issue'); + console.log(' DELETE /repos/:did/:repo/issues/:n/lock Unlock issue'); + console.log(' POST /repos/:did/:repo/issues/:n/assignees Add assignees'); + console.log(' DELETE /repos/:did/:repo/issues/:n/assignees Remove assignees'); console.log(' POST /repos/:did/:repo/pulls Create pull request'); console.log(' PATCH /repos/:did/:repo/pulls/:number Update pull request'); console.log(' PUT /repos/:did/:repo/pulls/:n/merge Merge pull request'); + console.log(' POST /repos/:did/:repo/pulls/:n/comments Create review comment'); + console.log(' PATCH /repos/:did/:repo/pulls/comments/:id Update review comment'); + console.log(' DELETE /repos/:did/:repo/pulls/comments/:id Delete review comment'); + console.log(' POST /repos/:did/:repo/pulls/comments/:id/reactions Create review comment reaction'); + console.log(' DELETE /repos/:did/:repo/pulls/comments/:id/reactions/:rid Delete review comment reaction'); + console.log(' POST /repos/:did/:repo/pulls/:n/comments/:id/replies Create review comment reply'); console.log(' POST /repos/:did/:repo/pulls/:n/reviews Create review'); + console.log(' PATCH /repos/:did/:repo/pulls/:n/reviews/:id Update review'); + console.log(' DELETE /repos/:did/:repo/pulls/:n/reviews/:id Delete pending review'); + console.log(' PUT /repos/:did/:repo/pulls/:n/reviews/:id/dismissals Dismiss review'); + console.log(' POST /repos/:did/:repo/pulls/:n/reviews/:id/events Submit review'); console.log(' POST /repos/:did/:repo/releases Create release'); + console.log(' POST /repos/:did/:repo/releases/generate-notes Generate release notes'); + console.log(' POST /repos/:did/:repo/releases/:id/assets Upload release asset'); + console.log(' PATCH /repos/:did/:repo/releases/:id Update release'); + console.log(' DELETE /repos/:did/:repo/releases/:id Delete release'); + console.log(' POST /repos/:did/:repo/releases/:id/reactions Create release reaction'); + console.log(' DELETE /repos/:did/:repo/releases/:id/reactions/:rid Delete release reaction'); + console.log(' PATCH /repos/:did/:repo/releases/assets/:id Update release asset'); + console.log(' DELETE /repos/:did/:repo/releases/assets/:id Delete release asset'); + console.log(' PUT /repos/:did/:repo/environments/:name Create/update deployment environment'); + console.log(' DELETE /repos/:did/:repo/environments/:name Delete deployment environment'); + console.log(' PUT /repos/:did/:repo/environments/:name/secrets/:secret Create/update environment secret'); + console.log(' DELETE /repos/:did/:repo/environments/:name/secrets/:secret Delete environment secret'); + console.log(' POST /repos/:did/:repo/environments/:name/variables Create environment variable'); + console.log(' PATCH /repos/:did/:repo/environments/:name/variables/:var Update environment variable'); + console.log(' DELETE /repos/:did/:repo/environments/:name/variables/:var Delete environment variable'); + console.log(' POST /repos/:did/:repo/deployments Create deployment'); + console.log(' DELETE /repos/:did/:repo/deployments/:id Delete deployment'); + console.log(' POST /repos/:did/:repo/deployments/:id/statuses Create deployment status'); + console.log(' POST /repos/:did/:repo/pages Create GitHub Pages site'); + console.log(' PUT /repos/:did/:repo/pages Update GitHub Pages site'); + console.log(' DELETE /repos/:did/:repo/pages Delete GitHub Pages site'); + console.log(' POST /repos/:did/:repo/pages/builds Request GitHub Pages build'); + console.log(' POST /repos/:did/:repo/pages/deployments Create GitHub Pages deployment'); + console.log(' POST /repos/:did/:repo/pages/deployments/:id/cancel Cancel GitHub Pages deployment'); + console.log(' POST /repos/:did/:repo/statuses/:sha Create commit status'); + console.log(' POST /repos/:did/:repo/check-suites Create check suite'); + console.log(' POST /repos/:did/:repo/check-suites/:id/rerequest Rerequest check suite'); + console.log(' POST /repos/:did/:repo/check-runs Create check run'); + console.log(' PATCH /repos/:did/:repo/check-runs/:id Update check run'); + console.log(' POST /repos/:did/:repo/check-runs/:id/rerequest Rerequest check run'); + console.log(' PUT /repos/:did/:repo/actions/cache/retention-limit Set Actions cache retention limit'); + console.log(' PUT /repos/:did/:repo/actions/cache/storage-limit Set Actions cache storage limit'); + console.log(' DELETE /repos/:did/:repo/actions/caches Delete Actions caches by key'); + console.log(' DELETE /repos/:did/:repo/actions/caches/:id Delete Actions cache by ID'); + console.log(' PUT /repos/:did/:repo/actions/permissions Set Actions permissions'); + console.log(' PUT /repos/:did/:repo/actions/permissions/selected-actions Set allowed Actions settings'); + console.log(' PUT /repos/:did/:repo/actions/permissions/workflow Set default workflow permissions'); + console.log(' DELETE /repos/:did/:repo/actions/artifacts/:id Delete artifact'); + console.log(' PUT /repos/:did/:repo/actions/secrets/:name Create/update repository secret'); + console.log(' DELETE /repos/:did/:repo/actions/secrets/:name Delete repository secret'); + console.log(' POST /repos/:did/:repo/actions/variables Create repository Actions variable'); + console.log(' PATCH /repos/:did/:repo/actions/variables/:name Update repository Actions variable'); + console.log(' DELETE /repos/:did/:repo/actions/variables/:name Delete repository Actions variable'); + console.log(' PUT /repos/:did/:repo/actions/workflows/:id/disable Disable workflow'); + console.log(' POST /repos/:did/:repo/actions/workflows/:id/dispatches Dispatch workflow'); + console.log(' PUT /repos/:did/:repo/actions/workflows/:id/enable Enable workflow'); + console.log(' POST /repos/:did/:repo/actions/runs/:id/rerun Re-run workflow run'); + console.log(' POST /repos/:did/:repo/actions/runs/:id/rerun-failed-jobs Re-run failed workflow jobs'); + console.log(' POST /repos/:did/:repo/actions/runs/:id/cancel Cancel workflow run'); + console.log(' POST /repos/:did/:repo/actions/runs/:id/force-cancel Force cancel workflow run'); + console.log(' DELETE /repos/:did/:repo/actions/runs/:id Delete workflow run'); + console.log(' DELETE /repos/:did/:repo/actions/runs/:id/logs Delete workflow run logs'); + console.log(' POST /repos/:did/:repo/actions/jobs/:id/rerun Re-run workflow job'); + console.log(' POST /users/:did/attestations/bulk-list List user artifact attestations by digests'); + console.log(' DELETE /users/:did/attestations Delete user artifact attestations in bulk'); + console.log(' DELETE /users/:did/attestations/digest/:digest Delete user artifact attestations by digest'); + console.log(' DELETE /users/:did/attestations/:id Delete user artifact attestation by ID'); + console.log(' PUT /repos/:did/:repo/subscription Set repository subscription'); + console.log(' DELETE /repos/:did/:repo/subscription Delete repository subscription'); + console.log(' PUT /repos/:did/:repo/branches/:branch/protection Update branch protection'); + console.log(' DELETE /repos/:did/:repo/branches/:branch/protection Delete branch protection'); + console.log(' PATCH /repos/:did/:repo/branches/:branch/protection/required_status_checks Update status check protection'); + console.log(' DELETE /repos/:did/:repo/branches/:branch/protection/required_status_checks Delete status check protection'); + console.log(' POST /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Add status check contexts'); + console.log(' PUT /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Set status check contexts'); + console.log(' DELETE /repos/:did/:repo/branches/:branch/protection/required_status_checks/contexts Remove status check contexts'); + console.log(' PATCH /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Update pull request review protection'); + console.log(' DELETE /repos/:did/:repo/branches/:branch/protection/required_pull_request_reviews Delete pull request review protection'); + console.log(' PUT /repos/:did/:repo/topics Replace topics'); + console.log(' POST /repos/:did/:repo/keys Create deploy key'); + console.log(' DELETE /repos/:did/:repo/keys/:key_id Delete deploy key'); + console.log(' POST /repos/:did/:repo/autolinks Create repository autolink'); + console.log(' DELETE /repos/:did/:repo/autolinks/:id Delete repository autolink'); + console.log(' PUT /repos/:did/:repo/interaction-limits Set repository interaction restrictions'); + console.log(' DELETE /repos/:did/:repo/interaction-limits Remove repository interaction restrictions'); + console.log(' PATCH /repos/:did/:repo/properties/values Update repository custom properties'); + console.log(' POST /repos/:did/:repo/dispatches Create repository dispatch event'); + console.log(' POST /repos/:did/:repo/attestations Create repository artifact attestation'); + console.log(' PUT /repos/:did/:repo/vulnerability-alerts Enable vulnerability alerts'); + console.log(' DELETE /repos/:did/:repo/vulnerability-alerts Disable vulnerability alerts'); + console.log(' PUT /repos/:did/:repo/automated-security-fixes Enable Dependabot security updates'); + console.log(' DELETE /repos/:did/:repo/automated-security-fixes Disable Dependabot security updates'); + console.log(' PUT /repos/:did/:repo/immutable-releases Enable immutable releases'); + console.log(' DELETE /repos/:did/:repo/immutable-releases Disable immutable releases'); + console.log(' PUT /repos/:did/:repo/private-vulnerability-reporting Enable private vulnerability reporting'); + console.log(' DELETE /repos/:did/:repo/private-vulnerability-reporting Disable private vulnerability reporting'); + console.log(' POST /repos/:did/:repo/security-advisories Create repository security advisory'); + console.log(' POST /repos/:did/:repo/security-advisories/reports Privately report security vulnerability'); + console.log(' PATCH /repos/:did/:repo/security-advisories/:ghsa_id Update repository security advisory'); + console.log(' POST /repos/:did/:repo/security-advisories/:ghsa_id/cve Request repository advisory CVE'); + console.log(' POST /repos/:did/:repo/security-advisories/:ghsa_id/forks Create temporary private fork'); + console.log(' POST /repos/:did/:repo/secret-scanning/push-protection-bypasses Create secret scanning push protection bypass'); + console.log(' PATCH /repos/:did/:repo/code-scanning/alerts/:number Update repository code scanning alert'); + console.log(' PATCH /repos/:did/:repo/dependabot/alerts/:number Update repository Dependabot alert'); + console.log(' PATCH /repos/:did/:repo/secret-scanning/alerts/:number Update repository secret scanning alert'); + console.log(' POST /repos/:did/:repo/rulesets Create repository ruleset'); + console.log(' PUT /repos/:did/:repo/rulesets/:id Update repository ruleset'); + console.log(' DELETE /repos/:did/:repo/rulesets/:id Delete repository ruleset'); + console.log(' PUT /repos/:did/:repo/collaborators/:did Add collaborator'); + console.log(' DELETE /repos/:did/:repo/collaborators/:did Remove collaborator'); + console.log(' POST /repos/:did/:repo/hooks Create repository webhook'); + console.log(' PATCH /repos/:did/:repo/hooks/:id Update repository webhook'); + console.log(' DELETE /repos/:did/:repo/hooks/:id Delete repository webhook'); + console.log(' PATCH /repos/:did/:repo/hooks/:id/config Update repository webhook configuration'); + console.log(' POST /repos/:did/:repo/hooks/:id/deliveries/:delivery_id/attempts Redeliver repository webhook delivery'); + console.log(' POST /repos/:did/:repo/hooks/:id/pings Ping repository webhook'); + console.log(' POST /repos/:did/:repo/hooks/:id/tests Test repository webhook'); + console.log(' PUT /notifications Mark notifications as read'); + console.log(' PATCH /notifications/threads/:id Mark notification thread as read'); + console.log(' DELETE /notifications/threads/:id Mark notification thread as done'); + console.log(' PUT /notifications/threads/:id/subscription Set notification thread subscription'); + console.log(' DELETE /notifications/threads/:id/subscription Delete notification thread subscription'); + console.log(' PATCH /orgs/:org Update organization profile'); + console.log(' DELETE /orgs/:org/members/:did Remove organization member'); + console.log(' PUT /orgs/:org/memberships/:did Set organization membership'); + console.log(' DELETE /orgs/:org/memberships/:did Remove organization membership'); + console.log(' DELETE /orgs/:org/invitations/:id Cancel organization invitation'); + console.log(' PUT /orgs/:org/blocks/:username Block organization user'); + console.log(' DELETE /orgs/:org/blocks/:username Unblock organization user'); + console.log(' POST /orgs/:org/hooks Create organization webhook'); + console.log(' PATCH /orgs/:org/hooks/:id Update organization webhook'); + console.log(' DELETE /orgs/:org/hooks/:id Delete organization webhook'); + console.log(' PATCH /orgs/:org/hooks/:id/config Update organization webhook configuration'); + console.log(' POST /orgs/:org/hooks/:id/deliveries/:delivery_id/attempts Redeliver organization webhook delivery'); + console.log(' POST /orgs/:org/hooks/:id/pings Ping organization webhook'); + console.log(' PATCH /orgs/:org/properties/schema Create/update organization custom properties'); + console.log(' PUT /orgs/:org/properties/schema/:property Create/update organization custom property'); + console.log(' DELETE /orgs/:org/properties/schema/:property Delete organization custom property'); + console.log(' PATCH /orgs/:org/properties/values Update organization repository custom property values'); + console.log(' POST /orgs/:org/issue-fields Create organization issue field'); + console.log(' PATCH /orgs/:org/issue-fields/:field Update organization issue field'); + console.log(' DELETE /orgs/:org/issue-fields/:field Delete organization issue field'); + console.log(' POST /orgs/:org/issue-types Create organization issue type'); + console.log(' PUT /orgs/:org/issue-types/:type Update organization issue type'); + console.log(' DELETE /orgs/:org/issue-types/:type Delete organization issue type'); + console.log(' PUT /orgs/:org/outside_collaborators/:did Convert member to outside collaborator'); + console.log(' DELETE /orgs/:org/outside_collaborators/:did Remove outside collaborator'); + console.log(' PUT /orgs/:org/public_members/:did Set public organization membership'); + console.log(' DELETE /orgs/:org/public_members/:did Remove public organization membership'); + console.log(' POST /orgs/:org/repos Create organization repository'); + console.log(' POST /orgs/:org/teams Create organization team'); + console.log(' PATCH /orgs/:org/teams/:team Update organization team'); + console.log(' DELETE /orgs/:org/teams/:team Delete organization team'); + console.log(' PUT /orgs/:org/teams/:team/repos/:did/:repo Add/update team repository permission'); + console.log(' DELETE /orgs/:org/teams/:team/repos/:did/:repo Remove team repository permission'); + console.log(' PUT /orgs/:org/teams/:team/memberships/:did Add organization team membership'); + console.log(' DELETE /orgs/:org/teams/:team/memberships/:did Remove organization team membership'); + console.log(' PATCH /organizations/:org_id/team/:team_id Update organization team by numeric ID'); + console.log(' DELETE /organizations/:org_id/team/:team_id Delete organization team by numeric ID'); + console.log(' PUT /organizations/:org_id/team/:team_id/memberships/:did Add team membership by numeric ID'); + console.log(' DELETE /organizations/:org_id/team/:team_id/memberships/:did Remove team membership by numeric ID'); + console.log(' PUT /organizations/:org_id/team/:team_id/repos/:did/:repo Add/update team repository permission by numeric ID'); + console.log(' DELETE /organizations/:org_id/team/:team_id/repos/:did/:repo Remove team repository permission by numeric ID'); + console.log(' PATCH /teams/:team_id Legacy update organization team by numeric ID'); + console.log(' DELETE /teams/:team_id Legacy delete organization team by numeric ID'); + console.log(' PUT /teams/:team_id/members/:did Legacy add organization team membership by numeric ID'); + console.log(' DELETE /teams/:team_id/members/:did Legacy remove organization team membership by numeric ID'); + console.log(' PUT /teams/:team_id/memberships/:did Legacy add organization team membership by numeric ID'); + console.log(' DELETE /teams/:team_id/memberships/:did Legacy remove organization team membership by numeric ID'); + console.log(' PUT /teams/:team_id/repos/:did/:repo Legacy add/update team repository permission by numeric ID'); + console.log(' DELETE /teams/:team_id/repos/:did/:repo Legacy remove team repository permission by numeric ID'); + console.log(' PUT /repos/:did/:repo/notifications Mark repository notifications as read'); + console.log(' PATCH /user Update authenticated user profile'); + console.log(' PATCH /user/memberships/orgs/:org Update authenticated organization membership'); + console.log(' PATCH /user/email/visibility Set primary email visibility'); + console.log(' POST /user/emails Add authenticated email addresses'); + console.log(' DELETE /user/emails Delete authenticated email addresses'); + console.log(' POST /user/gpg_keys Create authenticated GPG key'); + console.log(' DELETE /user/gpg_keys/:id Delete authenticated GPG key'); + console.log(' POST /user/social_accounts Add authenticated social accounts'); + console.log(' DELETE /user/social_accounts Delete authenticated social accounts'); + console.log(' POST /user/keys Create authenticated SSH key'); + console.log(' DELETE /user/keys/:key_id Delete authenticated SSH key'); + console.log(' POST /user/ssh_signing_keys Create authenticated SSH signing key'); + console.log(' DELETE /user/ssh_signing_keys/:id Delete authenticated SSH signing key'); + console.log(' POST /user/repos Create authenticated repository'); + console.log(' PUT /user/blocks/:did Block a user'); + console.log(' DELETE /user/blocks/:did Unblock a user'); + console.log(' PUT /user/following/:did Follow a user'); + console.log(' DELETE /user/following/:did Unfollow a user'); + console.log(' PUT /user/starred/:did/:repo Star repository'); + console.log(' DELETE /user/starred/:did/:repo Unstar repository'); console.log(''); }); diff --git a/src/github-shim/social-accounts.ts b/src/github-shim/social-accounts.ts new file mode 100644 index 0000000..89ec2c7 --- /dev/null +++ b/src/github-shim/social-accounts.ts @@ -0,0 +1,216 @@ +/** + * GitHub API shim - authenticated user social account endpoints. + * + * Maps forge-social `socialAccount` records to GitHub REST API v3 social + * account responses. The canonical social account URL is stored as a DWN tag + * so add/delete operations can detect duplicates consistently. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; +import type { SocialAccountData } from '../social.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + jsonCreated, + jsonNoContent, + jsonOk, + jsonValidationError, + paginate, + parsePagination, +} from './helpers.js'; + +type SocialAccountEntry = { + record : any; + data : SocialAccountData; +}; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeSocialUrl(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + + try { + const url = new URL(value.trim()); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { return null; } + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +function providerFromUrl(accountUrl: string): string { + const host = new URL(accountUrl).hostname.toLowerCase().replace(/^www\./, ''); + if (host === 'x.com') { return 'x'; } + if (host.endsWith('twitter.com')) { return 'twitter'; } + if (host.endsWith('github.com')) { return 'github'; } + if (host.endsWith('youtube.com') || host === 'youtu.be') { return 'youtube'; } + if (host.endsWith('linkedin.com')) { return 'linkedin'; } + if (host.endsWith('mastodon.social')) { return 'mastodon'; } + return host.split('.')[0] || 'web'; +} + +function normalizeSocialAccountData(data: unknown, tags?: Record<string, unknown>): SocialAccountData | null { + if (!isObject(data)) { return null; } + + const url = normalizeSocialUrl(typeof tags?.url === 'string' ? tags.url : data.url); + if (!url) { return null; } + + const provider = typeof data.provider === 'string' && data.provider.trim() !== '' + ? data.provider.trim().toLowerCase() + : providerFromUrl(url); + + return { + provider, + url, + createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, + }; +} + +async function normalizeSocialAccountEntry(record: any): Promise<SocialAccountEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeSocialAccountData(await record.data.json(), tags); + if (!data) { return null; } + return { record, data }; +} + +async function listSocialAccountEntries(ctx: AgentContext, userDid: string): Promise<SocialAccountEntry[]> { + const { records } = await ctx.social.records.query('socialAccount' as any, { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: SocialAccountEntry[] = []; + for (const record of records) { + const entry = await normalizeSocialAccountEntry(record); + if (entry) { entries.push(entry); } + } + return entries; +} + +function buildSocialAccountResponse(entry: SocialAccountEntry): Record<string, unknown> { + return { + provider : entry.data.provider, + url : entry.data.url, + }; +} + +function parseAccountUrls(body: unknown): string[] | JsonResponse { + if (!isObject(body) || !Array.isArray(body.account_urls)) { + return jsonValidationError('Validation Failed: account_urls must be an array of full social account URLs.'); + } + + const accountUrls = body.account_urls.map(normalizeSocialUrl); + if (accountUrls.length === 0 || accountUrls.some(accountUrl => !accountUrl)) { + return jsonValidationError('Validation Failed: account_urls must contain at least one valid HTTP or HTTPS URL.'); + } + + const uniqueAccountUrls = [...new Set(accountUrls as string[])]; + if (uniqueAccountUrls.length !== accountUrls.length) { + return jsonValidationError('Validation Failed: duplicate social account URLs are not allowed.'); + } + return uniqueAccountUrls; +} + +function pagedSocialAccounts(entries: SocialAccountEntry[], url: URL, path: string): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, entries.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(buildSocialAccountResponse), extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/social_accounts +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedSocialAccounts(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = await listSocialAccountEntries(ctx, ctx.did); + return pagedSocialAccounts(entries, url, '/user/social_accounts'); +} + +// --------------------------------------------------------------------------- +// POST /user/social_accounts +// --------------------------------------------------------------------------- + +export async function handleAddAuthenticatedSocialAccounts(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + const parsed = parseAccountUrls(body); + if (!Array.isArray(parsed)) { return parsed; } + + const existing = await listSocialAccountEntries(ctx, ctx.did); + const existingUrls = new Set(existing.map(entry => entry.data.url)); + if (parsed.some(accountUrl => existingUrls.has(accountUrl))) { + return jsonValidationError('Validation Failed: social account URL already exists.'); + } + + const created: SocialAccountEntry[] = []; + for (const accountUrl of parsed) { + const data: SocialAccountData = { + provider : providerFromUrl(accountUrl), + url : accountUrl, + createdAt : new Date().toISOString(), + }; + const { record, status } = await ctx.social.records.create('socialAccount' as any, { + data, + tags: { url: accountUrl }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to add social account URL: ${status.detail}`); + } + + const entry = await normalizeSocialAccountEntry(record); + if (entry) { created.push(entry); } + } + + return jsonCreated(created.map(buildSocialAccountResponse)); +} + +// --------------------------------------------------------------------------- +// DELETE /user/social_accounts +// --------------------------------------------------------------------------- + +export async function handleDeleteAuthenticatedSocialAccounts(ctx: AgentContext, body: unknown): Promise<JsonResponse> { + const parsed = parseAccountUrls(body); + if (!Array.isArray(parsed)) { return parsed; } + + const entries = await listSocialAccountEntries(ctx, ctx.did); + const entriesByUrl = new Map(entries.map(entry => [entry.data.url, entry])); + for (const accountUrl of parsed) { + const entry = entriesByUrl.get(accountUrl); + if (!entry) { + return jsonValidationError(`Validation Failed: social account URL '${accountUrl}' is not associated with this account.`); + } + } + + for (const accountUrl of parsed) { + const entry = entriesByUrl.get(accountUrl); + if (!entry) { continue; } + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete social account URL: ${status.detail}`); + } + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /users/:did/social_accounts +// --------------------------------------------------------------------------- + +export async function handleListUserSocialAccounts(ctx: AgentContext, userDid: string, url: URL): Promise<JsonResponse> { + const entries = await listSocialAccountEntries(ctx, userDid); + return pagedSocialAccounts(entries, url, `/users/${userDid}/social_accounts`); +} diff --git a/src/github-shim/stars.ts b/src/github-shim/stars.ts new file mode 100644 index 0000000..694cdbd --- /dev/null +++ b/src/github-shim/stars.ts @@ -0,0 +1,528 @@ +/** + * GitHub API shim — repository starring and watching endpoints. + * + * Maps forge-social `star` records to GitHub REST API v3 starring + * responses. Stars live on the actor's DWN, so these handlers expose the + * authenticated actor's stars plus any readable star records for a user DID. + * + * Endpoints: + * GET /repos/:did/:repo/stargazers + * GET /repos/:did/:repo/subscribers + * GET /repos/:did/:repo/subscription + * PUT /repos/:did/:repo/subscription + * DELETE /repos/:did/:repo/subscription + * GET /user/starred + * GET /user/starred/:did/:repo + * PUT /user/starred/:did/:repo + * DELETE /user/starred/:did/:repo + * GET /user/subscriptions + * GET /users/:did/starred + * GET /users/:did/subscriptions + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { buildRepoResponse } from './repos.js'; + +import { + buildApiUrl, + buildLinkHeader, + buildOwner, + fromOpt, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type RepoLookup = { + repo : RepoInfo; + repoName : string; + recordId : string; +}; + +type StarEntry = { + rec : any; + data : any; + tags : Record<string, string>; + repoDid : string; + repoRecordId : string; + repoName : string | undefined; +}; + +function repoInfoFromRecord(rec: any, data: any, tags: Record<string, unknown>): RepoInfo { + return { + name : data.name ?? 'unnamed', + description : data.description ?? '', + defaultBranch : data.defaultBranch ?? 'main', + homepage : data.homepage ?? '', + contextId : rec.contextId ?? '', + visibility : typeof tags.visibility === 'string' ? tags.visibility : 'public', + language : typeof tags.language === 'string' ? tags.language : '', + archived : tags.archived === true || tags.archived === 'true', + hasIssues : data.hasIssues !== false, + hasProjects : data.hasProjects === true, + hasWiki : data.hasWiki !== false, + hasDownloads : data.hasDownloads !== false, + hasPullRequests : data.hasPullRequests !== false, + isTemplate : data.isTemplate === true, + allowSquashMerge : data.allowSquashMerge !== false, + allowMergeCommit : data.allowMergeCommit !== false, + allowRebaseMerge : data.allowRebaseMerge !== false, + allowAutoMerge : data.allowAutoMerge === true, + allowForking : data.allowForking !== false, + deleteBranchOnMerge : data.deleteBranchOnMerge === true, + webCommitSignoffRequired : data.webCommitSignoffRequired === true, + pullRequestCreationPolicy : data.pullRequestCreationPolicy === 'collaborators_only' ? 'collaborators_only' : 'all', + dateCreated : rec.dateCreated, + timestamp : rec.timestamp, + forkedFromDid : typeof data.forkedFromDid === 'string' ? data.forkedFromDid : undefined, + forkedFromRepoName : typeof data.forkedFromRepoName === 'string' ? data.forkedFromRepoName : undefined, + forkedFromRecordId : typeof data.forkedFromRecordId === 'string' ? data.forkedFromRecordId : undefined, + }; +} + +async function findRepoByName( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<RepoLookup | JsonResponse> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo', { + from, + filter: { tags: { name: repoName } }, + }); + + const rec = records[0]; + if (!rec) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; + return { + repo : repoInfoFromRecord(rec, data, tags), + repoName : data.name ?? repoName, + recordId : rec.id, + }; +} + +async function findRepoByRecordId( + ctx: AgentContext, targetDid: string, recordId: string, +): Promise<RepoLookup | null> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo', { from }); + const rec = records.find(item => item.id === recordId); + if (!rec) { return null; } + + const data = await rec.data.json(); + const tags = (rec.tags as Record<string, unknown> | undefined) ?? {}; + return { + repo : repoInfoFromRecord(rec, data, tags), + repoName : data.name ?? (typeof tags.name === 'string' ? tags.name : 'unnamed'), + recordId : rec.id, + }; +} + +function normalizeStarEntry(rec: any): Promise<StarEntry | null> { + return rec.data.json().then((data: any) => { + const tags = (rec.tags as Record<string, string> | undefined) ?? {}; + const repoDid = tags.repoDid ?? data.repoDid; + const repoRecordId = tags.repoRecordId ?? data.repoRecordId; + if (typeof repoDid !== 'string' || typeof repoRecordId !== 'string') { + return null; + } + + return { + rec, + data, + tags, + repoDid, + repoRecordId, + repoName: typeof data.repoName === 'string' ? data.repoName : undefined, + }; + }); +} + +async function listStarEntries( + ctx: AgentContext, userDid: string, direction: 'asc' | 'desc' = 'desc', +): Promise<StarEntry[]> { + const { records } = await ctx.social.records.query('star', { + from : fromOpt(ctx, userDid), + dateSort : direction === 'asc' ? DateSort.CreatedAscending : DateSort.CreatedDescending, + } as any); + + const entries: StarEntry[] = []; + for (const rec of records) { + const entry = await normalizeStarEntry(rec); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function findLocalStar( + ctx: AgentContext, repoDid: string, repoRecordId: string, +): Promise<any | undefined> { + const { records } = await ctx.social.records.query('star', { + filter: { tags: { repoDid, repoRecordId } }, + } as any); + return records[0]; +} + +function buildUserResponse(did: string, baseUrl: string): Record<string, unknown> { + return { + ...buildOwner(did, baseUrl), + node_id : did, + gravatar_id : '', + followers_url : `${baseUrl}/users/${did}/followers`, + following_url : `${baseUrl}/users/${did}/following{/other_user}`, + gists_url : `${baseUrl}/users/${did}/gists{/gist_id}`, + starred_url : `${baseUrl}/users/${did}/starred{/owner}{/repo}`, + subscriptions_url : `${baseUrl}/users/${did}/subscriptions`, + organizations_url : `${baseUrl}/users/${did}/orgs`, + repos_url : `${baseUrl}/users/${did}/repos`, + events_url : `${baseUrl}/users/${did}/events{/privacy}`, + received_events_url : `${baseUrl}/users/${did}/received_events`, + site_admin : false, + }; +} + +async function buildStarredRepoResponse( + ctx: AgentContext, entry: StarEntry, baseUrl: string, +): Promise<Record<string, unknown> | null> { + const lookup = await findRepoByRecordId(ctx, entry.repoDid, entry.repoRecordId); + if (!lookup) { return null; } + + return buildRepoResponse(lookup.repo, entry.repoDid, lookup.repoName, baseUrl); +} + +function starAuthorDid(rec: any, fallbackDid: string): string { + return rec.author ?? fallbackDid; +} + +function normalizeBool(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') { return value; } + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') { return true; } + if (value.toLowerCase() === 'false') { return false; } + } + return fallback; +} + +function subscriptionState(data: Record<string, unknown>): { ignored: boolean; subscribed: boolean } { + const ignored = normalizeBool(data.ignored, false); + const subscribed = normalizeBool(data.subscribed, !ignored); + return { ignored, subscribed }; +} + +function buildSubscriptionResponse( + star: any, data: Record<string, unknown>, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const { ignored, subscribed } = subscriptionState(data); + return { + subscribed, + ignored, + reason : null, + created_at : toISODate(star?.dateCreated), + url : `${baseUrl}/repos/${targetDid}/${repoName}/subscription`, + repository_url : `${baseUrl}/repos/${targetDid}/${repoName}`, + }; +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/stargazers +// --------------------------------------------------------------------------- + +export async function handleListStargazers( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const { records } = await ctx.social.records.query('star', { + filter : { tags: { repoDid: targetDid, repoRecordId: lookup.recordId } }, + dateSort : DateSort.CreatedDescending, + } as any); + const paged = paginate(records, pagination); + const items = paged.map(rec => buildUserResponse(starAuthorDid(rec, ctx.did), baseUrl)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/stargazers`, + pagination.page, pagination.perPage, records.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/subscribers +// --------------------------------------------------------------------------- + +export async function handleListSubscribers( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const { records } = await ctx.social.records.query('star', { + filter : { tags: { repoDid: targetDid, repoRecordId: lookup.recordId } }, + dateSort : DateSort.CreatedDescending, + } as any); + const subscribers = []; + for (const record of records) { + const entry = await normalizeStarEntry(record); + if (!entry) { continue; } + if (subscriptionState(entry.data).subscribed) { subscribers.push(record); } + } + + const paged = paginate(subscribers, pagination); + const items = paged.map(rec => buildUserResponse(starAuthorDid(rec, ctx.did), baseUrl)); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repoName}/subscribers`, + pagination.page, pagination.perPage, subscribers.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(items, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/starred and GET /users/:did/starred +// --------------------------------------------------------------------------- + +export async function handleListStarredRepos( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const direction = url.searchParams.get('direction') === 'asc' ? 'asc' : 'desc'; + const entries = await listStarEntries(ctx, userDid, direction); + + const repos: Record<string, unknown>[] = []; + for (const entry of entries) { + const repo = await buildStarredRepoResponse(ctx, entry, baseUrl); + if (repo) { repos.push(repo); } + } + + const pagination = parsePagination(url); + const paged = paginate(repos, pagination); + const path = userDid === ctx.did ? '/user/starred' : `/users/${userDid}/starred`; + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, repos.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/subscriptions and GET /users/:did/subscriptions +// --------------------------------------------------------------------------- + +export async function handleListSubscriptions( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const entries = await listStarEntries(ctx, userDid); + + const repos: Record<string, unknown>[] = []; + for (const entry of entries) { + const { ignored, subscribed } = subscriptionState(entry.data); + if (!subscribed && !ignored) { continue; } + + const repo = await buildStarredRepoResponse(ctx, entry, baseUrl); + if (repo) { repos.push(repo); } + } + + const pagination = parsePagination(url); + const paged = paginate(repos, pagination); + const path = userDid === ctx.did ? '/user/subscriptions' : `/users/${userDid}/subscriptions`; + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, repos.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged, extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/starred/:did/:repo +// --------------------------------------------------------------------------- + +export async function handleCheckStarredRepo( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const star = await findLocalStar(ctx, targetDid, lookup.recordId); + if (!star) { + return jsonNotFound(`Repository '${repoName}' is not starred by '${ctx.did}'.`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /repos/:did/:repo/subscription +// --------------------------------------------------------------------------- + +export async function handleGetRepoSubscription( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const star = await findLocalStar(ctx, targetDid, lookup.recordId); + if (!star) { + return jsonNotFound(`Repository '${repoName}' is not watched by '${ctx.did}'.`); + } + + const entry = await normalizeStarEntry(star); + if (!entry) { + return jsonNotFound(`Repository '${repoName}' is not watched by '${ctx.did}'.`); + } + + return jsonOk(buildSubscriptionResponse(star, entry.data, targetDid, lookup.repoName, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// PUT /user/starred/:did/:repo +// --------------------------------------------------------------------------- + +export async function handleStarRepo( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const existing = await findLocalStar(ctx, targetDid, lookup.recordId); + if (existing) { return jsonNoContent(); } + + const { status } = await ctx.social.records.create('star', { + data : { repoDid: targetDid, repoRecordId: lookup.recordId, repoName: lookup.repoName }, + tags : { repoDid: targetDid, repoRecordId: lookup.recordId }, + }); + + if (status.code >= 300) { + return jsonValidationError(`Failed to star repository: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// PUT /repos/:did/:repo/subscription +// --------------------------------------------------------------------------- + +export async function handleSetRepoSubscription( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + if (reqBody.subscribed !== undefined && typeof reqBody.subscribed !== 'boolean') { + return jsonValidationError('Validation Failed: subscribed must be a boolean.'); + } + if (reqBody.ignored !== undefined && typeof reqBody.ignored !== 'boolean') { + return jsonValidationError('Validation Failed: ignored must be a boolean.'); + } + + const ignored = reqBody.ignored === true; + const subscribed = reqBody.subscribed === false ? false : !ignored; + const existing = await findLocalStar(ctx, targetDid, lookup.recordId); + if (!subscribed && !ignored) { + if (existing) { + const { status } = await existing.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete repository subscription: ${status.detail}`); + } + } + + return jsonOk(buildSubscriptionResponse( + { dateCreated: new Date().toISOString() }, + { ignored: false, subscribed: false }, + targetDid, + lookup.repoName, + buildApiUrl(url), + )); + } + + const data = { + repoDid : targetDid, + repoRecordId : lookup.recordId, + repoName : lookup.repoName, + subscribed, + ignored, + }; + const tags = { repoDid: targetDid, repoRecordId: lookup.recordId }; + + if (existing) { + const { status } = await existing.update({ data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository subscription: ${status.detail}`); + } + + return jsonOk(buildSubscriptionResponse(existing, data, targetDid, lookup.repoName, buildApiUrl(url))); + } + + const { record, status } = await ctx.social.records.create('star', { data, tags } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to set repository subscription: ${status.detail}`); + } + + return jsonOk(buildSubscriptionResponse(record, data, targetDid, lookup.repoName, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// DELETE /user/starred/:did/:repo +// --------------------------------------------------------------------------- + +export async function handleUnstarRepo( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const star = await findLocalStar(ctx, targetDid, lookup.recordId); + if (!star) { return jsonNoContent(); } + + const { status } = await star.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to unstar repository: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// DELETE /repos/:did/:repo/subscription +// --------------------------------------------------------------------------- + +export async function handleDeleteRepoSubscription( + ctx: AgentContext, targetDid: string, repoName: string, +): Promise<JsonResponse> { + const lookup = await findRepoByName(ctx, targetDid, repoName); + if ('status' in lookup) { return lookup; } + + const star = await findLocalStar(ctx, targetDid, lookup.recordId); + if (!star) { return jsonNoContent(); } + + const { status } = await star.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete repository subscription: ${status.detail}`); + } + + return jsonNoContent(); +} diff --git a/src/github-shim/user-attestations.ts b/src/github-shim/user-attestations.ts new file mode 100644 index 0000000..f21a237 --- /dev/null +++ b/src/github-shim/user-attestations.ts @@ -0,0 +1,294 @@ +/** + * GitHub API shim — user-scoped artifact attestation endpoints. + * + * Aggregates repository attestation records for repositories owned by a DID. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; +import type { RepoEntry } from './repos.js'; +import type { RepositoryAttestationData, SettingsData } from '../repo.js'; + +import { listRepoEntries } from './repos.js'; +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Types and constants +// --------------------------------------------------------------------------- + +type SettingsRecord = { + update : (options: { data: SettingsData }) => Promise<{ status: { code: number; detail?: string } }>; +}; + +type RepoSettingsLookup = { + entry : RepoEntry; + record? : SettingsRecord; + settings : SettingsData; +}; + +type StoredAttestation = RepoSettingsLookup & { + key : string; + attestation : RepositoryAttestationData; +}; + +const SHA256_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeSubjectDigest(value: unknown): string | null { + if (typeof value !== 'string') { return null; } + const normalized = value.trim().toLowerCase(); + return SHA256_DIGEST_PATTERN.test(normalized) ? normalized : null; +} + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseSubjectDigests(body: unknown): string[] | JsonResponse { + if (!isObject(body) || !Array.isArray(body.subject_digests) || body.subject_digests.length === 0) { + return jsonValidationError('Validation Failed: subject_digests must be a non-empty array.'); + } + + const digests: string[] = []; + for (const value of body.subject_digests) { + const digest = normalizeSubjectDigest(value); + if (!digest) { + return jsonValidationError('Validation Failed: subject_digests must contain sha256:<64 hex characters> values.'); + } + digests.push(digest); + } + return [...new Set(digests)]; +} + +function attestationEntries(settings: SettingsData): Array<[string, RepositoryAttestationData]> { + return Object.entries(settings.attestations ?? {}) + .filter((entry): entry is [string, RepositoryAttestationData] => { + const attestation = entry[1]; + return Boolean(attestation) && Number.isInteger(attestation.id); + }) + .sort((left, right) => left[1].id - right[1].id || left[0].localeCompare(right[0])); +} + +async function listOwnerRepos(ctx: AgentContext, targetDid: string): Promise<RepoEntry[]> { + try { + return await listRepoEntries(ctx, targetDid); + } catch { + return []; + } +} + +async function getRepoSettings( + ctx: AgentContext, targetDid: string, entry: RepoEntry, +): Promise<RepoSettingsLookup> { + try { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + from : fromOpt(ctx, targetDid), + filter : { contextId: entry.repo.contextId }, + } as any); + const record = records[0] as (SettingsRecord & { data: { json: () => Promise<SettingsData> } }) | undefined; + if (!record) { return { entry, settings: {} }; } + const settings = await record.data.json(); + return { entry, record, settings: settings ?? {} }; + } catch { + return { entry, settings: {} }; + } +} + +async function listOwnerSettings(ctx: AgentContext, targetDid: string): Promise<RepoSettingsLookup[]> { + const lookups: RepoSettingsLookup[] = []; + for (const entry of await listOwnerRepos(ctx, targetDid)) { + lookups.push(await getRepoSettings(ctx, targetDid, entry)); + } + return lookups; +} + +async function collectUserAttestations(ctx: AgentContext, targetDid: string): Promise<StoredAttestation[]> { + const stored: StoredAttestation[] = []; + for (const lookup of await listOwnerSettings(ctx, targetDid)) { + for (const [key, attestation] of attestationEntries(lookup.settings)) { + stored.push({ ...lookup, key, attestation }); + } + } + return stored.sort((left, right) => ( + left.attestation.id - right.attestation.id + || left.entry.name.localeCompare(right.entry.name) + )); +} + +function matchesPredicate(attestation: RepositoryAttestationData, predicateType: string | null): boolean { + return !predicateType || attestation.predicateType === predicateType; +} + +function buildAttestationResponse( + stored: StoredAttestation, targetDid: string, baseUrl: string, +): Record<string, unknown> { + return { + id : stored.attestation.id, + repository_id : numericId(`${targetDid}/${stored.entry.name}`), + repository_url : `${baseUrl}/repos/${targetDid}/${stored.entry.name}`, + subject_digest : stored.attestation.subjectDigest, + predicate_type : stored.attestation.predicateType ?? null, + bundle : stored.attestation.bundle, + created_at : toISODate(stored.attestation.createdAt), + }; +} + +function attestationsEnvelope( + attestations: StoredAttestation[], targetDid: string, url: URL, path: string, +): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(attestations, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, attestations.length); + const headers: Record<string, string> = {}; + if (linkHeader) { headers.Link = linkHeader; } + return jsonOk({ + attestations: paged.map(attestation => buildAttestationResponse(attestation, targetDid, baseUrl)), + }, headers); +} + +async function deleteMatchingAttestations( + ctx: AgentContext, targetDid: string, matches: (attestation: RepositoryAttestationData) => boolean, +): Promise<StoredAttestation[] | JsonResponse> { + const deleted: StoredAttestation[] = []; + for (const lookup of await listOwnerSettings(ctx, targetDid)) { + const entries = attestationEntries(lookup.settings).filter(([, attestation]) => matches(attestation)); + if (entries.length === 0) { continue; } + if (!lookup.record) { + return jsonValidationError('Failed to update repository settings: attestation settings record is missing.'); + } + + const attestations = { ...(lookup.settings.attestations ?? {}) }; + for (const [key, attestation] of entries) { + deleted.push({ ...lookup, key, attestation }); + delete attestations[key]; + } + + const nextSettings: SettingsData = { ...lookup.settings, attestations }; + if (Object.keys(attestations).length === 0) { + delete nextSettings.attestations; + } + + const { status } = await lookup.record.update({ data: nextSettings }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update repository settings: ${status.detail}`); + } + } + return deleted; +} + +function deletedAttestationsResponse(deleted: StoredAttestation[], targetDid: string, url: URL): JsonResponse { + if (deleted.length === 0) { + return jsonNotFound('Artifact attestations not found.'); + } + + const baseUrl = buildApiUrl(url); + return jsonOk({ + attestations: deleted.map(attestation => buildAttestationResponse(attestation, targetDid, baseUrl)), + }); +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleBulkListUserAttestations( + ctx: AgentContext, targetDid: string, body: unknown, url: URL, +): Promise<JsonResponse> { + const subjectDigests = parseSubjectDigests(body); + if ('status' in subjectDigests) { return subjectDigests; } + + const wanted = new Set(subjectDigests); + const predicateType = isObject(body) && typeof body.predicate_type === 'string' ? body.predicate_type : null; + const attestations = (await collectUserAttestations(ctx, targetDid)) + .filter(stored => wanted.has(stored.attestation.subjectDigest)) + .filter(stored => matchesPredicate(stored.attestation, predicateType)); + + return attestationsEnvelope(attestations, targetDid, url, `/users/${targetDid}/attestations/bulk-list`); +} + +export async function handleBulkDeleteUserAttestations( + ctx: AgentContext, targetDid: string, body: unknown, url: URL, +): Promise<JsonResponse> { + const subjectDigests = parseSubjectDigests(body); + if ('status' in subjectDigests) { return subjectDigests; } + + const wanted = new Set(subjectDigests); + const deleted = await deleteMatchingAttestations( + ctx, targetDid, attestation => wanted.has(attestation.subjectDigest), + ); + if ('status' in deleted) { return deleted; } + return deletedAttestationsResponse(deleted, targetDid, url); +} + +export async function handleDeleteUserAttestationsBySubjectDigest( + ctx: AgentContext, targetDid: string, encodedSubjectDigest: string, url: URL, +): Promise<JsonResponse> { + const subjectDigest = normalizeSubjectDigest(decodeRouteParam(encodedSubjectDigest)); + if (!subjectDigest) { + return jsonValidationError('Validation Failed: subject_digest must be sha256:<64 hex characters>.'); + } + + const deleted = await deleteMatchingAttestations( + ctx, targetDid, attestation => attestation.subjectDigest === subjectDigest, + ); + if ('status' in deleted) { return deleted; } + return deletedAttestationsResponse(deleted, targetDid, url); +} + +export async function handleDeleteUserAttestationById( + ctx: AgentContext, targetDid: string, attestationId: string, url: URL, +): Promise<JsonResponse> { + const id = Number.parseInt(attestationId, 10); + if (!Number.isSafeInteger(id) || id <= 0) { + return jsonNotFound(`Artifact attestation '${attestationId}' not found.`); + } + + const deleted = await deleteMatchingAttestations( + ctx, targetDid, attestation => attestation.id === id, + ); + if ('status' in deleted) { return deleted; } + return deletedAttestationsResponse(deleted, targetDid, url); +} + +export async function handleListUserAttestations( + ctx: AgentContext, targetDid: string, encodedSubjectDigest: string, url: URL, +): Promise<JsonResponse> { + const subjectDigest = normalizeSubjectDigest(decodeRouteParam(encodedSubjectDigest)); + if (!subjectDigest) { + return jsonValidationError('Validation Failed: subject_digest must be sha256:<64 hex characters>.'); + } + + const predicateType = url.searchParams.get('predicate_type'); + const attestations = (await collectUserAttestations(ctx, targetDid)) + .filter(stored => stored.attestation.subjectDigest === subjectDigest) + .filter(stored => matchesPredicate(stored.attestation, predicateType)); + + return attestationsEnvelope( + attestations, targetDid, url, `/users/${targetDid}/attestations/${encodeURIComponent(subjectDigest)}`, + ); +} diff --git a/src/github-shim/user-keys.ts b/src/github-shim/user-keys.ts new file mode 100644 index 0000000..1bd9160 --- /dev/null +++ b/src/github-shim/user-keys.ts @@ -0,0 +1,423 @@ +/** + * GitHub API shim - user SSH key endpoints. + * + * Maps forge-social `sshKey` records to GitHub REST API v3 user key + * responses. Authenticated key management writes to the local actor's DWN; + * public key listing reads from the target user's DWN. + * + * @module + */ + +import type { AgentContext } from '../cli/agent.js'; +import type { JsonResponse } from './helpers.js'; +import type { SshKeyData, SshSigningKeyData } from '../social.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type SshKeyEntry = { + record : any; + ownerDid : string; + data : SshKeyData; +}; + +type SshSigningKeyEntry = { + record : any; + ownerDid : string; + data : SshSigningKeyData; +}; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function sshKeyNumericId(entry: SshKeyEntry): number { + return numericId(entry.record.contextId ?? entry.record.id); +} + +function sshSigningKeyNumericId(entry: SshSigningKeyEntry): number { + return numericId(entry.record.contextId ?? entry.record.id); +} + +function normalizeSshKeyData(data: unknown, tags?: Record<string, unknown>): SshKeyData | null { + if (!isObject(data)) { return null; } + + const key = typeof tags?.key === 'string' ? tags.key : data.key; + if (typeof key !== 'string' || key.trim() === '') { return null; } + + return { + key : key.trim(), + title : typeof data.title === 'string' ? data.title : undefined, + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + verified : typeof data.verified === 'boolean' ? data.verified : undefined, + readOnly : typeof data.readOnly === 'boolean' ? data.readOnly : undefined, + }; +} + +function normalizeSshSigningKeyData(data: unknown, tags?: Record<string, unknown>): SshSigningKeyData | null { + if (!isObject(data)) { return null; } + + const key = typeof tags?.key === 'string' ? tags.key : data.key; + if (typeof key !== 'string' || key.trim() === '') { return null; } + + return { + key : key.trim(), + title : typeof data.title === 'string' ? data.title : undefined, + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + }; +} + +async function normalizeSshKeyEntry(record: any, fallbackOwnerDid: string): Promise<SshKeyEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeSshKeyData(await record.data.json(), tags); + if (!data) { return null; } + + return { + record, + ownerDid: typeof record.author === 'string' ? record.author : fallbackOwnerDid, + data, + }; +} + +async function normalizeSshSigningKeyEntry(record: any, fallbackOwnerDid: string): Promise<SshSigningKeyEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeSshSigningKeyData(await record.data.json(), tags); + if (!data) { return null; } + + return { + record, + ownerDid: typeof record.author === 'string' ? record.author : fallbackOwnerDid, + data, + }; +} + +async function listSshKeyEntries(ctx: AgentContext, userDid: string): Promise<SshKeyEntry[]> { + const { records } = await ctx.social.records.query('sshKey' as any, { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: SshKeyEntry[] = []; + for (const record of records) { + const entry = await normalizeSshKeyEntry(record, userDid); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function listSshSigningKeyEntries(ctx: AgentContext, userDid: string): Promise<SshSigningKeyEntry[]> { + const { records } = await ctx.social.records.query('sshSigningKey' as any, { + from : fromOpt(ctx, userDid), + dateSort : DateSort.CreatedAscending, + } as any); + + const entries: SshSigningKeyEntry[] = []; + for (const record of records) { + const entry = await normalizeSshSigningKeyEntry(record, userDid); + if (entry) { entries.push(entry); } + } + return entries; +} + +async function findSshKey(ctx: AgentContext, userDid: string, keyId: string): Promise<SshKeyEntry | null> { + const id = Number.parseInt(keyId, 10); + if (!Number.isInteger(id) || id <= 0) { return null; } + + const entries = await listSshKeyEntries(ctx, userDid); + return entries.find(entry => sshKeyNumericId(entry) === id) ?? null; +} + +async function findSshSigningKey(ctx: AgentContext, userDid: string, keyId: string): Promise<SshSigningKeyEntry | null> { + const id = Number.parseInt(keyId, 10); + if (!Number.isInteger(id) || id <= 0) { return null; } + + const entries = await listSshSigningKeyEntries(ctx, userDid); + return entries.find(entry => sshSigningKeyNumericId(entry) === id) ?? null; +} + +function defaultSshKeyTitle(key: string): string { + const parts = key.trim().split(/\s+/); + if (parts.length >= 2) { + return `${parts[0]} ${parts[1].slice(0, 16)}`; + } + return parts[0] || 'SSH key'; +} + +function sshKeyCreatedAt(entry: SshKeyEntry): string { + return toISODate(entry.data.createdAt ?? entry.record.dateCreated ?? entry.record.timestamp); +} + +function sshSigningKeyCreatedAt(entry: SshSigningKeyEntry): string { + return toISODate(entry.data.createdAt ?? entry.record.dateCreated ?? entry.record.timestamp); +} + +function buildAuthenticatedSshKeyResponse(entry: SshKeyEntry, baseUrl: string): Record<string, unknown> { + const id = sshKeyNumericId(entry); + return { + key : entry.data.key, + id, + url : `${baseUrl}/user/keys/${id}`, + title : entry.data.title ?? defaultSshKeyTitle(entry.data.key), + created_at : sshKeyCreatedAt(entry), + verified : entry.data.verified === true, + read_only : entry.data.readOnly === true, + }; +} + +function buildSshSigningKeyResponse(entry: SshSigningKeyEntry): Record<string, unknown> { + return { + id : sshSigningKeyNumericId(entry), + key : entry.data.key, + title : entry.data.title ?? defaultSshKeyTitle(entry.data.key), + created_at : sshSigningKeyCreatedAt(entry), + }; +} + +function buildPublicSshKeyResponse(entry: SshKeyEntry): Record<string, unknown> { + return { + id : sshKeyNumericId(entry), + key : entry.data.key, + }; +} + +function parseCreateSshSigningKeyInput(body: unknown): SshSigningKeyData | JsonResponse { + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const key = body.key; + if (typeof key !== 'string' || key.trim() === '') { + return jsonValidationError('Validation Failed: key is required.'); + } + + const title = body.title; + if (title !== undefined && (typeof title !== 'string' || title.trim() === '')) { + return jsonValidationError('Validation Failed: title must be a non-empty string.'); + } + + const trimmedKey = key.trim(); + return { + key : trimmedKey, + title : typeof title === 'string' ? title.trim() : defaultSshKeyTitle(trimmedKey), + createdAt : new Date().toISOString(), + }; +} + +function parseCreateSshKeyInput(body: unknown): SshKeyData | JsonResponse { + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const key = body.key; + if (typeof key !== 'string' || key.trim() === '') { + return jsonValidationError('Validation Failed: key is required.'); + } + + const title = body.title; + if (title !== undefined && (typeof title !== 'string' || title.trim() === '')) { + return jsonValidationError('Validation Failed: title must be a non-empty string.'); + } + + const trimmedKey = key.trim(); + return { + key : trimmedKey, + title : typeof title === 'string' ? title.trim() : defaultSshKeyTitle(trimmedKey), + createdAt : new Date().toISOString(), + verified : false, + readOnly : false, + }; +} + +async function hasDuplicateSshKey(ctx: AgentContext, key: string): Promise<boolean> { + const entries = await listSshKeyEntries(ctx, ctx.did); + return entries.some(entry => entry.data.key === key); +} + +async function hasDuplicateSshSigningKey(ctx: AgentContext, key: string): Promise<boolean> { + const entries = await listSshSigningKeyEntries(ctx, ctx.did); + return entries.some(entry => entry.data.key === key); +} + +function pagedSshKeys<T>( + entries: T[], url: URL, path: string, mapper: (entry: T) => Record<string, unknown>, +): JsonResponse { + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const paged = paginate(entries, pagination); + const linkHeader = buildLinkHeader(baseUrl, path, pagination.page, pagination.perPage, entries.length); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders.Link = linkHeader; } + + return jsonOk(paged.map(mapper), extraHeaders); +} + +// --------------------------------------------------------------------------- +// GET /user/keys +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedSshKeys(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const entries = await listSshKeyEntries(ctx, ctx.did); + return pagedSshKeys(entries, url, '/user/keys', entry => buildAuthenticatedSshKeyResponse(entry, baseUrl)); +} + +// --------------------------------------------------------------------------- +// POST /user/keys +// --------------------------------------------------------------------------- + +export async function handleCreateAuthenticatedSshKey( + ctx: AgentContext, body: unknown, url: URL, +): Promise<JsonResponse> { + const parsed = parseCreateSshKeyInput(body); + if ('status' in parsed) { return parsed; } + + if (await hasDuplicateSshKey(ctx, parsed.key)) { + return jsonValidationError('Validation Failed: key already exists.'); + } + + const { record, status } = await ctx.social.records.create('sshKey' as any, { + data : parsed, + tags : { key: parsed.key }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create SSH key: ${status.detail}`); + } + + const entry = await normalizeSshKeyEntry(record, ctx.did); + if (!entry) { + return jsonValidationError('Failed to create SSH key.'); + } + + return jsonCreated(buildAuthenticatedSshKeyResponse(entry, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// GET /user/keys/:key_id +// --------------------------------------------------------------------------- + +export async function handleGetAuthenticatedSshKey( + ctx: AgentContext, keyId: string, url: URL, +): Promise<JsonResponse> { + const entry = await findSshKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('Public key not found.'); } + return jsonOk(buildAuthenticatedSshKeyResponse(entry, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// DELETE /user/keys/:key_id +// --------------------------------------------------------------------------- + +export async function handleDeleteAuthenticatedSshKey(ctx: AgentContext, keyId: string): Promise<JsonResponse> { + const entry = await findSshKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('Public key not found.'); } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete SSH key: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /users/:did/keys +// --------------------------------------------------------------------------- + +export async function handleListUserSshKeys( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const entries = await listSshKeyEntries(ctx, userDid); + return pagedSshKeys(entries, url, `/users/${userDid}/keys`, buildPublicSshKeyResponse); +} + +// --------------------------------------------------------------------------- +// GET /user/ssh_signing_keys +// --------------------------------------------------------------------------- + +export async function handleListAuthenticatedSshSigningKeys(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const entries = await listSshSigningKeyEntries(ctx, ctx.did); + return pagedSshKeys(entries, url, '/user/ssh_signing_keys', buildSshSigningKeyResponse); +} + +// --------------------------------------------------------------------------- +// POST /user/ssh_signing_keys +// --------------------------------------------------------------------------- + +export async function handleCreateAuthenticatedSshSigningKey( + ctx: AgentContext, body: unknown, +): Promise<JsonResponse> { + const parsed = parseCreateSshSigningKeyInput(body); + if ('status' in parsed) { return parsed; } + + if (await hasDuplicateSshSigningKey(ctx, parsed.key)) { + return jsonValidationError('Validation Failed: key already exists.'); + } + + const { record, status } = await ctx.social.records.create('sshSigningKey' as any, { + data : parsed, + tags : { key: parsed.key }, + } as any); + if (status.code >= 300 || !record) { + return jsonValidationError(`Failed to create SSH signing key: ${status.detail}`); + } + + const entry = await normalizeSshSigningKeyEntry(record, ctx.did); + if (!entry) { + return jsonValidationError('Failed to create SSH signing key.'); + } + + return jsonCreated(buildSshSigningKeyResponse(entry)); +} + +// --------------------------------------------------------------------------- +// GET /user/ssh_signing_keys/:ssh_signing_key_id +// --------------------------------------------------------------------------- + +export async function handleGetAuthenticatedSshSigningKey( + ctx: AgentContext, keyId: string, +): Promise<JsonResponse> { + const entry = await findSshSigningKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('SSH signing key not found.'); } + return jsonOk(buildSshSigningKeyResponse(entry)); +} + +// --------------------------------------------------------------------------- +// DELETE /user/ssh_signing_keys/:ssh_signing_key_id +// --------------------------------------------------------------------------- + +export async function handleDeleteAuthenticatedSshSigningKey(ctx: AgentContext, keyId: string): Promise<JsonResponse> { + const entry = await findSshSigningKey(ctx, ctx.did, keyId); + if (!entry) { return jsonNotFound('SSH signing key not found.'); } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete SSH signing key: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// GET /users/:did/ssh_signing_keys +// --------------------------------------------------------------------------- + +export async function handleListUserSshSigningKeys( + ctx: AgentContext, userDid: string, url: URL, +): Promise<JsonResponse> { + const entries = await listSshSigningKeyEntries(ctx, userDid); + return pagedSshKeys(entries, url, `/users/${userDid}/ssh_signing_keys`, buildSshSigningKeyResponse); +} diff --git a/src/github-shim/users.ts b/src/github-shim/users.ts index 3462d48..af2aab1 100644 --- a/src/github-shim/users.ts +++ b/src/github-shim/users.ts @@ -1,38 +1,185 @@ /** * GitHub API shim — `/users/:did` endpoint. * - * Synthesizes a GitHub-style user object from a DID. Since DIDs are - * self-sovereign identifiers, we don't have profile data beyond the - * DID itself — fields like `name`, `bio`, etc. are left empty. + * Synthesizes a GitHub-style user object from a DID and optional + * forge-social `profile` metadata. * * @module */ +import type { AgentContext } from '../cli/agent.js'; import type { JsonResponse } from './helpers.js'; +import type { ProfileData } from '../social.js'; + +import { DateSort } from '@enbox/dwn-sdk-js'; import { buildApiUrl, + fromOpt, + jsonNotFound, jsonOk, + jsonValidationError, numericId, toISODate, } from './helpers.js'; // --------------------------------------------------------------------------- -// GET /users/:did +// Shared helpers // --------------------------------------------------------------------------- -/** - * Handle `GET /users/:did`. - * - * Returns a GitHub-style user profile JSON response. - */ -export async function handleGetUser( - targetDid: string, url: URL, -): Promise<JsonResponse> { - const baseUrl = buildApiUrl(url); +type ProfileEntry = { + record : any; + data : ProfileData; +}; + +type ProfilePatch = Partial<Omit<ProfileData, 'did' | 'createdAt' | 'updatedAt'>>; + +type HovercardContext = { + message : string; + octicon : string; +}; + +type HovercardSubject = { + subjectType?: string; + subjectId?: string; +}; + +const HOVERCARD_SUBJECT_TYPES = new Set(['organization', 'repository', 'issue', 'pull_request']); + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function storedNullableString(value: unknown): string | null | undefined { + if (value === null) { return null; } + return typeof value === 'string' ? value : undefined; +} + +function normalizeProfileData(data: unknown, tags?: Record<string, unknown>): ProfileData | null { + if (!isObject(data)) { return null; } + + const did = typeof tags?.did === 'string' ? tags.did : data.did; + if (typeof did !== 'string' || did.trim() === '') { return null; } + + return { + did : did.trim(), + name : storedNullableString(data.name), + email : storedNullableString(data.email), + blog : typeof data.blog === 'string' ? data.blog : undefined, + twitterUsername : storedNullableString(data.twitterUsername), + company : storedNullableString(data.company), + location : storedNullableString(data.location), + hireable : typeof data.hireable === 'boolean' || data.hireable === null ? data.hireable : undefined, + bio : storedNullableString(data.bio), + createdAt : typeof data.createdAt === 'string' ? data.createdAt : undefined, + updatedAt : typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + }; +} + +async function normalizeProfileEntry(record: any): Promise<ProfileEntry | null> { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + const data = normalizeProfileData(await record.data.json(), tags); + if (!data) { return null; } + return { record, data }; +} + +async function getUserProfileEntry(ctx: AgentContext, targetDid: string): Promise<ProfileEntry | null> { + let records: any[]; + try { + const result = await ctx.social.records.query('profile' as any, { + from : fromOpt(ctx, targetDid), + filter : { tags: { did: targetDid } }, + dateSort : DateSort.CreatedDescending, + } as any); + records = result.records; + } catch { + return null; + } + + for (const record of records) { + const entry = await normalizeProfileEntry(record); + if (entry?.data.did === targetDid) { return entry; } + } + return null; +} + +function parseNullableString(value: unknown, field: string): string | null | undefined | JsonResponse { + if (value === undefined) { return undefined; } + if (value === null) { return null; } + if (typeof value !== 'string') { + return jsonValidationError(`Validation Failed: ${field} must be a string or null.`); + } + return value.trim(); +} + +function parseEmail(value: unknown): string | null | undefined | JsonResponse { + const parsed = parseNullableString(value, 'email'); + if (typeof parsed !== 'string' || parsed === '') { return parsed; } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(parsed)) { + return jsonValidationError('Validation Failed: email must be a valid email address.'); + } + return parsed.toLowerCase(); +} + +function parseTwitterUsername(value: unknown): string | null | undefined | JsonResponse { + const parsed = parseNullableString(value, 'twitter_username'); + if (typeof parsed !== 'string' || parsed === '') { return parsed; } + return parsed.replace(/^@/, ''); +} + +function parseProfilePatch(body: unknown): ProfilePatch | JsonResponse { + if (!isObject(body)) { + return jsonValidationError('Validation Failed: request body must be an object.'); + } + + const next: ProfilePatch = {}; + const name = parseNullableString(body.name, 'name'); + if (typeof name === 'object' && name !== null && 'status' in name) { return name; } + if (name !== undefined) { next.name = name; } + + const email = parseEmail(body.email); + if (typeof email === 'object' && email !== null && 'status' in email) { return email; } + if (email !== undefined) { next.email = email; } + + const blog = parseNullableString(body.blog, 'blog'); + if (typeof blog === 'object' && blog !== null && 'status' in blog) { return blog; } + if (blog !== undefined) { next.blog = blog ?? ''; } + + const twitterUsername = parseTwitterUsername(body.twitter_username); + if (typeof twitterUsername === 'object' && twitterUsername !== null && 'status' in twitterUsername) { return twitterUsername; } + if (twitterUsername !== undefined) { next.twitterUsername = twitterUsername; } + + const company = parseNullableString(body.company, 'company'); + if (typeof company === 'object' && company !== null && 'status' in company) { return company; } + if (company !== undefined) { next.company = company; } + + const location = parseNullableString(body.location, 'location'); + if (typeof location === 'object' && location !== null && 'status' in location) { return location; } + if (location !== undefined) { next.location = location; } + + if (body.hireable !== undefined) { + if (body.hireable !== null && typeof body.hireable !== 'boolean') { + return jsonValidationError('Validation Failed: hireable must be a boolean or null.'); + } + next.hireable = body.hireable; + } + + const bio = parseNullableString(body.bio, 'bio'); + if (typeof bio === 'object' && bio !== null && 'status' in bio) { return bio; } + if (bio !== undefined) { next.bio = bio; } + + return next; +} + +export function buildUserProfile( + targetDid: string, + baseUrl: string, + privateFields: Record<string, unknown> = {}, + profile?: ProfileData | null, +): Record<string, unknown> { const id = numericId(targetDid); - const user = { + return { login : targetDid, id, node_id : targetDid, @@ -43,21 +190,320 @@ export async function handleGetUser( repos_url : `${baseUrl}/users/${targetDid}/repos`, type : 'User', site_admin : false, - name : null, - company : null, - blog : '', - location : null, - email : null, - hireable : null, - bio : null, - twitter_username : null, + name : profile?.name ?? null, + company : profile?.company ?? null, + blog : profile?.blog ?? '', + location : profile?.location ?? null, + email : profile?.email ?? null, + hireable : profile?.hireable ?? null, + bio : profile?.bio ?? null, + twitter_username : profile?.twitterUsername ?? null, public_repos : 0, public_gists : 0, followers : 0, following : 0, - created_at : toISODate(undefined), - updated_at : toISODate(undefined), + created_at : toISODate(profile?.createdAt), + updated_at : toISODate(profile?.updatedAt ?? profile?.createdAt), + ...privateFields, }; +} + +async function countLocalRepos(ctx: AgentContext): Promise<{ publicRepos: number; privateRepos: number }> { + const { records } = await ctx.repo.records.query('repo'); + let publicRepos = 0; + let privateRepos = 0; + for (const record of records) { + const tags = (record.tags as Record<string, unknown> | undefined) ?? {}; + if (tags.visibility === 'private') { + privateRepos++; + } else { + publicRepos++; + } + } + return { publicRepos, privateRepos }; +} + +async function didFromRecord(record: any, dataField: string, tagField: string = dataField): Promise<string | undefined> { + const tags = (record.tags as Record<string, string> | undefined) ?? {}; + try { + const data = await record.data.json(); + const did = typeof data[dataField] === 'string' ? data[dataField] : tags[tagField]; + return did || undefined; + } catch { + return tags[tagField] || undefined; + } +} + +export async function collectVisibleUserDids(ctx: AgentContext): Promise<string[]> { + const dids = new Set<string>([ctx.did]); + + const { records: repos } = await ctx.repo.records.query('repo', { + dateSort: DateSort.CreatedAscending, + } as any); + for (const repo of repos) { + for (const type of ['repo/maintainer', 'repo/contributor', 'repo/triager', 'repo/viewer']) { + const { records } = await ctx.repo.records.query(type as any, { + filter: { contextId: repo.contextId ?? '' }, + } as any); + for (const record of records) { + const did = await didFromRecord(record, 'did'); + if (did) { dids.add(did); } + } + } + } + + const { records: orgs } = await ctx.org.records.query('org', { + dateSort: DateSort.CreatedAscending, + } as any); + for (const org of orgs) { + for (const type of ['org/owner', 'org/member']) { + const { records } = await ctx.org.records.query(type as any, { + filter: { contextId: org.contextId ?? '' }, + } as any); + for (const record of records) { + const did = await didFromRecord(record, 'did'); + if (did) { dids.add(did); } + } + } + + const { records: teams } = await ctx.org.records.query('org/team' as any, { + filter: { contextId: org.contextId ?? '' }, + } as any); + for (const team of teams) { + const { records } = await ctx.org.records.query('org/team/teamMember' as any, { + filter: { contextId: team.contextId ?? '' }, + } as any); + for (const record of records) { + const did = await didFromRecord(record, 'did'); + if (did) { dids.add(did); } + } + } + } + + const { records: follows } = await ctx.social.records.query('follow', { + dateSort: DateSort.CreatedAscending, + } as any); + for (const record of follows) { + const did = await didFromRecord(record, 'targetDid'); + if (did) { dids.add(did); } + } + + return [...dids].sort((a, b) => a.localeCompare(b)); +} + +async function buildAuthenticatedUserProfile(ctx: AgentContext, url: URL, profile?: ProfileData | null): Promise<Record<string, unknown>> { + const baseUrl = buildApiUrl(url); + const { publicRepos, privateRepos } = await countLocalRepos(ctx); + return buildUserProfile(ctx.did, baseUrl, { + public_repos : publicRepos, + private_gists : 0, + total_private_repos : privateRepos, + owned_private_repos : privateRepos, + disk_usage : 0, + collaborators : 0, + two_factor_authentication : false, + plan : { + name : 'DWN', + space : 0, + private_repos : privateRepos, + collaborators : 0, + }, + }, profile); +} + +function parseSince(url: URL): number { + const since = Number.parseInt(url.searchParams.get('since') ?? '0', 10); + return Number.isFinite(since) && since > 0 ? since : 0; +} + +function parsePerPage(url: URL): number { + const perPage = Number.parseInt(url.searchParams.get('per_page') ?? '30', 10); + return Math.min(100, Math.max(1, Number.isFinite(perPage) ? perPage : 30)); +} + +function buildSinceLinkHeader(baseUrl: string, since: number, perPage: number): string { + const nextUrl = new URL(`${baseUrl}/users`); + nextUrl.searchParams.set('since', String(since)); + nextUrl.searchParams.set('per_page', String(perPage)); + return `<${nextUrl.toString()}>; rel="next"`; +} + +async function getVisibleUserByNumericId(ctx: AgentContext, accountId: string): Promise<string | null> { + const id = Number.parseInt(accountId, 10); + if (!Number.isSafeInteger(id) || id <= 0) { return null; } + const dids = await collectVisibleUserDids(ctx); + return dids.find(did => numericId(did) === id) ?? null; +} + +async function isVisibleUser(ctx: AgentContext, targetDid: string): Promise<boolean> { + if (targetDid === ctx.did) { return true; } + return (await collectVisibleUserDids(ctx)).includes(targetDid); +} + +function parseHovercardSubject(url: URL): JsonResponse | HovercardSubject { + const subjectType = url.searchParams.get('subject_type') ?? undefined; + const subjectId = url.searchParams.get('subject_id') ?? undefined; + + if (subjectType && !HOVERCARD_SUBJECT_TYPES.has(subjectType)) { + return jsonValidationError('Validation Failed: subject_type must be one of organization, repository, issue, pull_request.'); + } + if (subjectType && !subjectId) { + return jsonValidationError('Validation Failed: subject_id is required when subject_type is provided.'); + } + if (subjectId && !subjectType) { + return jsonValidationError('Validation Failed: subject_type is required when subject_id is provided.'); + } + + return { subjectType, subjectId }; +} + +function uniqueHovercardContexts(contexts: HovercardContext[]): HovercardContext[] { + const seen = new Set<string>(); + return contexts.filter(context => { + const key = `${context.octicon}:${context.message}`; + if (seen.has(key)) { return false; } + seen.add(key); + return true; + }); +} + +async function buildHovercardContexts( + ctx: AgentContext, targetDid: string, subject: HovercardSubject, +): Promise<HovercardContext[]> { + const contexts: HovercardContext[] = [{ + message : targetDid === ctx.did ? 'Authenticated user' : 'Visible decentralized identity', + octicon : 'person', + }]; + + if (targetDid === ctx.did) { + const { publicRepos, privateRepos } = await countLocalRepos(ctx); + if (publicRepos + privateRepos > 0) { + contexts.push({ + message : 'Owns repositories visible to this forge', + octicon : 'repo', + }); + } + } + + if (subject.subjectType === 'repository') { + contexts.push({ + message : targetDid === ctx.did ? 'Owns this repository' : 'Visible through this repository', + octicon : 'repo', + }); + } else if (subject.subjectType === 'organization') { + contexts.push({ message: 'Visible through this organization', octicon: 'organization' }); + } else if (subject.subjectType === 'issue') { + contexts.push({ message: 'Visible through this issue', octicon: 'issue-opened' }); + } else if (subject.subjectType === 'pull_request') { + contexts.push({ message: 'Visible through this pull request', octicon: 'git-pull-request' }); + } + + return uniqueHovercardContexts(contexts); +} + +// --------------------------------------------------------------------------- +// GET /user, PATCH /user, GET /user/:account_id, GET /users, GET /users/:did, +// and GET /users/:did/hovercard +// --------------------------------------------------------------------------- + +export async function handleGetAuthenticatedUser( + ctx: AgentContext, url: URL, +): Promise<JsonResponse> { + const profile = await getUserProfileEntry(ctx, ctx.did); + return jsonOk(await buildAuthenticatedUserProfile(ctx, url, profile?.data)); +} + +export async function handleUpdateAuthenticatedUser( + ctx: AgentContext, url: URL, body: unknown, +): Promise<JsonResponse> { + const parsed = parseProfilePatch(body); + if ('status' in parsed) { return parsed; } + + const existing = await getUserProfileEntry(ctx, ctx.did); + const now = new Date().toISOString(); + const nextData: ProfileData = { + ...(existing?.data ?? { did: ctx.did, createdAt: now }), + ...parsed, + did : ctx.did, + createdAt : existing?.data.createdAt ?? now, + updatedAt : now, + }; + + if (existing) { + const { status } = await existing.record.update({ + data : nextData, + tags : { did: ctx.did }, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update user profile: ${status.detail}`); + } + } else { + const { status } = await ctx.social.records.create('profile' as any, { + data : nextData, + tags : { did: ctx.did }, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to update user profile: ${status.detail}`); + } + } + + return jsonOk(await buildAuthenticatedUserProfile(ctx, url, nextData)); +} + +export async function handleListUsers(ctx: AgentContext, url: URL): Promise<JsonResponse> { + const baseUrl = buildApiUrl(url); + const since = parseSince(url); + const perPage = parsePerPage(url); + const entries = (await collectVisibleUserDids(ctx)) + .map(did => ({ did, id: numericId(did) })) + .sort((left, right) => left.id - right.id) + .filter(entry => entry.id > since); + const page = entries.slice(0, perPage); + + const users: Record<string, unknown>[] = []; + for (const entry of page) { + const profile = await getUserProfileEntry(ctx, entry.did); + users.push(buildUserProfile(entry.did, baseUrl, {}, profile?.data)); + } + + const headers: Record<string, string> = {}; + if (entries.length > perPage && page.length > 0) { + headers.Link = buildSinceLinkHeader(baseUrl, page[page.length - 1].id, perPage); + } + + return jsonOk(users, headers); +} + +export async function handleGetUserById( + ctx: AgentContext, accountId: string, url: URL, +): Promise<JsonResponse> { + const did = await getVisibleUserByNumericId(ctx, accountId); + if (!did) { return jsonNotFound('User not found'); } + const profile = await getUserProfileEntry(ctx, did); + return jsonOk(buildUserProfile(did, buildApiUrl(url), {}, profile?.data)); +} + +/** + * Handle `GET /users/:did`. + * + * Returns a GitHub-style user profile JSON response. + */ +export async function handleGetUser( + ctx: AgentContext, targetDid: string, url: URL, +): Promise<JsonResponse> { + const profile = await getUserProfileEntry(ctx, targetDid); + return jsonOk(buildUserProfile(targetDid, buildApiUrl(url), {}, profile?.data)); +} + +export async function handleGetUserHovercard( + ctx: AgentContext, targetDid: string, url: URL, +): Promise<JsonResponse> { + const subject = parseHovercardSubject(url); + if ('status' in subject) { return subject; } + + if (!(await isVisibleUser(ctx, targetDid))) { + return jsonNotFound('User not found'); + } - return jsonOk(user); + return jsonOk({ contexts: await buildHovercardContexts(ctx, targetDid, subject) }); } diff --git a/src/github-shim/webhooks.ts b/src/github-shim/webhooks.ts new file mode 100644 index 0000000..bddedec --- /dev/null +++ b/src/github-shim/webhooks.ts @@ -0,0 +1,1241 @@ +/** + * GitHub API shim — repository webhook endpoints. + * + * Maps owner-only encrypted forge repo webhook records to GitHub REST API v3 + * repository webhook responses. + * + * @module + */ + +import { createHmac, randomUUID } from 'node:crypto'; + +import type { AgentContext } from '../cli/agent.js'; +import type { OrgData } from '../org.js'; +import type { JsonResponse, RepoInfo } from './helpers.js'; +import type { WebhookData as RepoWebhookData, WebhookDeliveryData } from '../repo.js'; + +import { + buildApiUrl, + buildLinkHeader, + fromOpt, + getRepoRecord, + jsonAccepted, + jsonCreated, + jsonNoContent, + jsonNotFound, + jsonOk, + jsonValidationError, + numericId, + paginate, + parsePagination, + toISODate, +} from './helpers.js'; + +type WebhookData = RepoWebhookData; + +type WebhookEntry = { + record : any; + data : WebhookData; +}; + +type OrgEntry = { + record : any; + data : Pick<OrgData, 'name'>; + slug : string; + routeLogin : string; +}; + +function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || value.trim().toLowerCase(); +} + +function normalizeOrgRoute(value: string): string { + return decodeRouteParam(value).trim(); +} + +function orgMatchesRoute(org: OrgEntry, routeOrg: string, ctxDid: string): boolean { + const route = normalizeOrgRoute(routeOrg).toLowerCase(); + if (route === ctxDid.toLowerCase()) { return true; } + if (route === org.data.name.toLowerCase()) { return true; } + return route === org.slug.toLowerCase(); +} + +function routeOrgPath(org: OrgEntry): string { + return encodeURIComponent(org.routeLogin); +} + +function normalizeWebhookUrl(value: unknown): string | JsonResponse { + if (typeof value !== 'string' || value.trim() === '') { + return jsonValidationError('Validation Failed: config.url must be a URL string.'); + } + + const url = value.trim(); + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return jsonValidationError('Validation Failed: config.url must use http or https.'); + } + } catch { + return jsonValidationError('Validation Failed: config.url must be a URL string.'); + } + + return url; +} + +function normalizeSecret(value: unknown): string | JsonResponse { + if (value === undefined || value === null) { return ''; } + if (typeof value !== 'string') { + return jsonValidationError('Validation Failed: config.secret must be a string.'); + } + return value; +} + +function normalizeEvents(value: unknown, fieldName: string, fallback: string[] = ['push']): string[] | JsonResponse { + if (value === undefined || value === null) { return fallback; } + if (!Array.isArray(value)) { + return jsonValidationError(`Validation Failed: ${fieldName} must be an array of strings.`); + } + + const normalized: string[] = []; + for (const event of value) { + if (typeof event !== 'string' || event.trim() === '') { + return jsonValidationError(`Validation Failed: ${fieldName} must be an array of strings.`); + } + const name = event.trim(); + if (!normalized.includes(name)) { + normalized.push(name); + } + } + return normalized; +} + +function validateContentType(value: unknown): JsonResponse | undefined { + if (value === undefined || value === null) { return undefined; } + if (value !== 'json' && value !== 'form') { + return jsonValidationError('Validation Failed: config.content_type must be json or form.'); + } + return undefined; +} + +function validateInsecureSsl(value: unknown): JsonResponse | undefined { + if (value === undefined || value === null) { return undefined; } + if (value === '0' || value === '1' || value === 0 || value === 1) { + return undefined; + } + return jsonValidationError('Validation Failed: config.insecure_ssl must be 0 or 1.'); +} + +function normalizeActive(value: unknown, fallback: boolean): boolean | JsonResponse { + if (value === undefined || value === null) { return fallback; } + if (typeof value !== 'boolean') { + return jsonValidationError('Validation Failed: active must be a boolean.'); + } + return value; +} + +function parseCreateWebhook(reqBody: Record<string, unknown>): WebhookData | JsonResponse { + if (reqBody.name !== undefined && reqBody.name !== 'web') { + return jsonValidationError('Validation Failed: name must be web.'); + } + if (!isObject(reqBody.config)) { + return jsonValidationError('Validation Failed: config is required.'); + } + const config = reqBody.config; + + const contentTypeError = validateContentType(config.content_type); + if (contentTypeError) { return contentTypeError; } + const insecureSslError = validateInsecureSsl(config.insecure_ssl); + if (insecureSslError) { return insecureSslError; } + + const url = normalizeWebhookUrl(config.url); + if (typeof url !== 'string') { return url; } + const secret = normalizeSecret(config.secret); + if (typeof secret !== 'string') { return secret; } + const events = normalizeEvents(reqBody.events, 'events'); + if (!Array.isArray(events)) { return events; } + const active = normalizeActive(reqBody.active, true); + if (typeof active !== 'boolean') { return active; } + + return { url, secret, events, active }; +} + +function applyEventPatch(current: string[], reqBody: Record<string, unknown>): string[] | JsonResponse { + const replacement = normalizeEvents(reqBody.events, 'events', current); + if (!Array.isArray(replacement)) { return replacement; } + + const addEvents = normalizeEvents(reqBody.add_events, 'add_events', []); + if (!Array.isArray(addEvents)) { return addEvents; } + const removeEvents = normalizeEvents(reqBody.remove_events, 'remove_events', []); + if (!Array.isArray(removeEvents)) { return removeEvents; } + + let updated = [...replacement]; + for (const event of addEvents) { + if (!updated.includes(event)) { + updated.push(event); + } + } + if (removeEvents.length > 0) { + updated = updated.filter(event => !removeEvents.includes(event)); + } + + return updated; +} + +function parseHookUpdate(current: WebhookData, reqBody: Record<string, unknown>): WebhookData | JsonResponse { + let next: WebhookData = { ...current, events: [...current.events] }; + + if (reqBody.config !== undefined) { + if (!isObject(reqBody.config)) { + return jsonValidationError('Validation Failed: config must be an object.'); + } + const config = reqBody.config; + const contentTypeError = validateContentType(config.content_type); + if (contentTypeError) { return contentTypeError; } + const insecureSslError = validateInsecureSsl(config.insecure_ssl); + if (insecureSslError) { return insecureSslError; } + + if (config.url !== undefined) { + const url = normalizeWebhookUrl(config.url); + if (typeof url !== 'string') { return url; } + next = { ...next, url }; + } + if (config.secret === undefined) { + next = { ...next, secret: '' }; + } else { + const secret = normalizeSecret(config.secret); + if (typeof secret !== 'string') { return secret; } + next = { ...next, secret }; + } + } + + const events = applyEventPatch(next.events, reqBody); + if (!Array.isArray(events)) { return events; } + const active = normalizeActive(reqBody.active, next.active); + if (typeof active !== 'boolean') { return active; } + + return { ...next, events, active }; +} + +function parseConfigUpdate(current: WebhookData, reqBody: Record<string, unknown>): WebhookData | JsonResponse { + const contentTypeError = validateContentType(reqBody.content_type); + if (contentTypeError) { return contentTypeError; } + const insecureSslError = validateInsecureSsl(reqBody.insecure_ssl); + if (insecureSslError) { return insecureSslError; } + + let next = { ...current, events: [...current.events] }; + if (reqBody.url !== undefined) { + const url = normalizeWebhookUrl(reqBody.url); + if (typeof url !== 'string') { return url; } + next = { ...next, url }; + } + if (reqBody.secret !== undefined) { + const secret = normalizeSecret(reqBody.secret); + if (typeof secret !== 'string') { return secret; } + next = { ...next, secret }; + } + return next; +} + +function normalizeDeliveryMap(value: unknown): Record<string, WebhookDeliveryData> | undefined { + if (!isObject(value)) { + return undefined; + } + + const deliveries: Record<string, WebhookDeliveryData> = {}; + for (const [key, raw] of Object.entries(value)) { + if (!isObject(raw)) { continue; } + + const id = Number.isInteger(raw.id) ? raw.id as number : parseInt(key, 10); + if (!Number.isInteger(id) || id < 1) { continue; } + + const guid = typeof raw.guid === 'string' && raw.guid ? raw.guid : `delivery-${id}`; + const deliveredAt = typeof raw.deliveredAt === 'string' ? raw.deliveredAt : new Date(0).toISOString(); + const status = typeof raw.status === 'string' ? raw.status : 'OK'; + const event = typeof raw.event === 'string' && raw.event ? raw.event : 'ping'; + + deliveries[String(id)] = { + id, + guid, + deliveredAt, + redelivery : typeof raw.redelivery === 'boolean' ? raw.redelivery : false, + duration : typeof raw.duration === 'number' ? raw.duration : 0, + status, + statusCode : typeof raw.statusCode === 'number' ? raw.statusCode : 200, + event, + action : typeof raw.action === 'string' || raw.action === null ? raw.action : null, + installationId : typeof raw.installationId === 'number' || raw.installationId === null ? raw.installationId : null, + repositoryId : typeof raw.repositoryId === 'number' || raw.repositoryId === null ? raw.repositoryId : null, + throttledAt : typeof raw.throttledAt === 'string' || raw.throttledAt === null ? raw.throttledAt : null, + request : isObject(raw.request) ? raw.request as WebhookDeliveryData['request'] : undefined, + response : isObject(raw.response) ? raw.response as WebhookDeliveryData['response'] : undefined, + }; + } + + return Object.keys(deliveries).length > 0 ? deliveries : undefined; +} + +function normalizeWebhookData(data: Record<string, unknown>): WebhookData { + const deliveries = normalizeDeliveryMap(data.deliveries); + return { + url : typeof data.url === 'string' ? data.url : '', + secret : typeof data.secret === 'string' ? data.secret : '', + events : Array.isArray(data.events) ? data.events.filter((event: unknown) => typeof event === 'string') : ['push'], + active : typeof data.active === 'boolean' ? data.active : true, + ...(deliveries ? { deliveries } : {}), + }; +} + +function deliveryEntries(data: WebhookData): WebhookDeliveryData[] { + return Object.values(data.deliveries ?? {}) + .filter((delivery): delivery is WebhookDeliveryData => Boolean(delivery) && Number.isInteger(delivery.id)) + .sort((a, b) => b.deliveredAt.localeCompare(a.deliveredAt) || b.id - a.id); +} + +function nextDeliveryId(data: WebhookData): number { + return deliveryEntries(data).reduce((max, delivery) => Math.max(max, delivery.id), 0) + 1; +} + +function webhookDeliveryKey(id: number): string { + return String(id); +} + +function withDelivery(data: WebhookData, delivery: WebhookDeliveryData): WebhookData { + return { + ...data, + deliveries: { + ...(data.deliveries ?? {}), + [webhookDeliveryKey(delivery.id)]: delivery, + }, + }; +} + +async function saveWebhookData( + entry: WebhookEntry, data: WebhookData, failureMessage: string, +): Promise<JsonResponse | undefined> { + const { status } = await entry.record.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`${failureMessage}: ${status.detail}`); + } + entry.data = data; + return undefined; +} + +function deliverySignature(algorithm: 'sha1' | 'sha256', secret: string, payload: unknown): string { + return createHmac(algorithm, secret).update(JSON.stringify(payload)).digest('hex'); +} + +function deliveryRequestHeaders( + entry: WebhookEntry, event: string, guid: string, targetId: number, targetType: 'organization' | 'repository', + payload: unknown, +): Record<string, string> { + const hookId = numericId(entry.record.id ?? ''); + return { + 'X-GitHub-Delivery' : guid, + 'X-Hub-Signature-256' : `sha256=${deliverySignature('sha256', entry.data.secret, payload)}`, + Accept : '*/*', + 'X-GitHub-Hook-ID' : String(hookId), + 'User-Agent' : 'GitHub-Hookshot/gitd', + 'X-GitHub-Event' : event, + 'X-GitHub-Hook-Installation-Target-ID' : String(targetId), + 'X-GitHub-Hook-Installation-Target-Type' : targetType, + 'content-type' : 'application/json', + 'X-Hub-Signature' : `sha1=${deliverySignature('sha1', entry.data.secret, payload)}`, + }; +} + +function createSyntheticDelivery( + entry: WebhookEntry, + options: { + event : string; + action? : string | null; + payload : Record<string, unknown>; + redelivery? : boolean; + guid? : string; + targetId : number; + targetType : 'organization' | 'repository'; + repositoryId? : number | null; + }, +): WebhookDeliveryData { + const id = nextDeliveryId(entry.data); + const guid = options.guid ?? randomUUID(); + const now = new Date().toISOString(); + return { + id, + guid, + deliveredAt : now, + redelivery : options.redelivery ?? false, + duration : 0, + status : 'OK', + statusCode : 200, + event : options.event, + action : options.action ?? null, + installationId : null, + repositoryId : options.repositoryId ?? null, + throttledAt : null, + request : { + headers : deliveryRequestHeaders(entry, options.event, guid, options.targetId, options.targetType, options.payload), + payload : options.payload, + }, + response: { + headers : { 'Content-Type': 'application/json' }, + payload : 'ok', + }, + }; +} + +function buildDeliverySummary(delivery: WebhookDeliveryData): Record<string, unknown> { + return { + id : delivery.id, + guid : delivery.guid, + delivered_at : toISODate(delivery.deliveredAt), + redelivery : delivery.redelivery, + duration : delivery.duration, + status : delivery.status, + status_code : delivery.statusCode, + event : delivery.event, + action : delivery.action ?? null, + installation_id : delivery.installationId ?? null, + repository_id : delivery.repositoryId ?? null, + throttled_at : delivery.throttledAt ? toISODate(delivery.throttledAt) : null, + }; +} + +function buildDeliveryDetail(delivery: WebhookDeliveryData, hook: WebhookData): Record<string, unknown> { + return { + ...buildDeliverySummary(delivery), + url : hook.url, + request : delivery.request ?? { headers: {}, payload: {} }, + response : delivery.response ?? { headers: {}, payload: null }, + }; +} + +function filterDeliveries(data: WebhookData, url: URL): WebhookDeliveryData[] | JsonResponse { + const status = url.searchParams.get('status'); + if (status && status !== 'success' && status !== 'failure') { + return jsonValidationError('Validation Failed: status must be success or failure.'); + } + + const deliveries = deliveryEntries(data); + if (!status) { + return deliveries; + } + return deliveries.filter((delivery) => { + const successful = delivery.statusCode >= 200 && delivery.statusCode <= 399; + return status === 'success' ? successful : !successful; + }); +} + +function deliveryPerPage(url: URL): number { + const raw = parseInt(url.searchParams.get('per_page') ?? '30', 10); + if (!Number.isInteger(raw) || raw < 1) { return 30; } + return Math.min(raw, 100); +} + +function deliveryListResponse( + hook: WebhookData, url: URL, baseUrl: string, path: string, +): JsonResponse { + const deliveries = filterDeliveries(hook, url); + if ('status' in deliveries) { return deliveries; } + + const perPage = deliveryPerPage(url); + const cursor = url.searchParams.get('cursor'); + const cursorId = cursor ? parseInt(cursor, 10) : NaN; + const start = Number.isInteger(cursorId) + ? Math.max(0, deliveries.findIndex(delivery => delivery.id === cursorId)) + : 0; + const pageStart = start < 0 ? 0 : start; + const paged = deliveries.slice(pageStart, pageStart + perPage); + + const extraHeaders: Record<string, string> = {}; + const next = deliveries[pageStart + perPage]; + if (next) { + const params = new URLSearchParams(); + params.set('per_page', String(perPage)); + params.set('cursor', String(next.id)); + const status = url.searchParams.get('status'); + if (status) { params.set('status', status); } + extraHeaders.Link = `<${baseUrl}${path}?${params.toString()}>; rel="next"`; + } + + return jsonOk(paged.map(buildDeliverySummary), extraHeaders); +} + +function findDelivery(data: WebhookData, deliveryId: string): WebhookDeliveryData | null { + const id = parseInt(deliveryId, 10); + if (!Number.isInteger(id)) { return null; } + return data.deliveries?.[webhookDeliveryKey(id)] ?? null; +} + +function repoWebhookPayload( + ctx: AgentContext, repo: RepoInfo, targetDid: string, event: string, action: string | null, +): Record<string, unknown> { + return { + ...(action ? { action } : {}), + zen : 'Design for decentralization.', + hook_id : numericId(`${targetDid}/${repo.name}/${event}`), + repository : { + id : numericId(repo.contextId || `${targetDid}/${repo.name}`), + name : repo.name, + full_name : `${targetDid}/${repo.name}`, + default_branch : repo.defaultBranch, + private : repo.visibility !== 'public', + }, + sender: { + login : ctx.did, + id : numericId(ctx.did), + type : 'User', + }, + }; +} + +function orgWebhookPayload( + ctx: AgentContext, org: OrgEntry, event: string, action: string | null, +): Record<string, unknown> { + return { + ...(action ? { action } : {}), + zen : 'Design for decentralization.', + hook_id : numericId(`${org.routeLogin}/${event}`), + organization : { + id : numericId(org.record.id ?? org.slug), + login : org.routeLogin, + }, + sender: { + login : ctx.did, + id : numericId(ctx.did), + type : 'User', + }, + }; +} + +async function listWebhookEntries(ctx: AgentContext, targetDid: string, repo: RepoInfo): Promise<WebhookEntry[]> { + const from = fromOpt(ctx, targetDid); + const { records } = await ctx.repo.records.query('repo/webhook' as any, { + from, + filter: { contextId: repo.contextId }, + }); + + const entries: WebhookEntry[] = []; + for (const record of records) { + const data = await record.data.json(); + entries.push({ + record, + data: normalizeWebhookData(data), + }); + } + + entries.sort((a, b) => a.record.dateCreated.localeCompare(b.record.dateCreated)); + return entries; +} + +async function findWebhookEntry( + ctx: AgentContext, targetDid: string, repo: RepoInfo, hookId: string, +): Promise<WebhookEntry | null> { + const requested = parseInt(hookId, 10); + if (!Number.isInteger(requested)) { return null; } + + const entries = await listWebhookEntries(ctx, targetDid, repo); + return entries.find(entry => numericId(entry.record.id ?? '') === requested) ?? null; +} + +async function listOrgEntries(ctx: AgentContext): Promise<OrgEntry[]> { + const { records } = await ctx.org.records.query('org' as any, {}); + + const entries: OrgEntry[] = []; + for (const record of records) { + let data: OrgData; + try { + data = await record.data.json(); + } catch { + continue; + } + if (!isNonEmptyString(data.name)) { continue; } + + entries.push({ + record, + data : { name: data.name }, + slug : slugify(data.name), + routeLogin : data.name, + }); + } + return entries; +} + +async function findOrg(ctx: AgentContext, routeOrg: string): Promise<OrgEntry | null> { + const orgs = await listOrgEntries(ctx); + return orgs.find(org => orgMatchesRoute(org, routeOrg, ctx.did)) ?? null; +} + +async function listOrgWebhookEntries(ctx: AgentContext, org: OrgEntry): Promise<WebhookEntry[]> { + const { records } = await ctx.org.records.query('org/webhook' as any, { + filter: { contextId: org.record.contextId }, + }); + + const entries: WebhookEntry[] = []; + for (const record of records) { + const data = await record.data.json(); + entries.push({ + record, + data: normalizeWebhookData(data), + }); + } + + entries.sort((a, b) => a.record.dateCreated.localeCompare(b.record.dateCreated)); + return entries; +} + +async function findOrgWebhookEntry( + ctx: AgentContext, org: OrgEntry, hookId: string, +): Promise<WebhookEntry | null> { + const requested = parseInt(hookId, 10); + if (!Number.isInteger(requested)) { return null; } + + const entries = await listOrgWebhookEntries(ctx, org); + return entries.find(entry => numericId(entry.record.id ?? '') === requested) ?? null; +} + +function buildWebhookConfig(data: WebhookData, includeSecret: boolean): Record<string, unknown> { + return { + content_type : 'json', + insecure_ssl : '0', + ...(includeSecret ? { secret: data.secret ? '********' : '' } : {}), + url : data.url, + }; +} + +function buildOrgWebhookResponse( + entry: WebhookEntry, org: OrgEntry, baseUrl: string, +): Record<string, unknown> { + const id = numericId(entry.record.id ?? ''); + const url = `${baseUrl}/orgs/${routeOrgPath(org)}/hooks/${id}`; + const created = toISODate(entry.record.dateCreated); + const updated = toISODate(entry.record.timestamp ?? entry.record.dateCreated); + + return { + type : 'Organization', + id, + name : 'web', + active : entry.data.active, + events : entry.data.events, + config : buildWebhookConfig(entry.data, false), + updated_at : updated, + created_at : created, + url, + ping_url : `${url}/pings`, + deliveries_url : `${url}/deliveries`, + }; +} + +function buildWebhookResponse( + entry: WebhookEntry, targetDid: string, repoName: string, baseUrl: string, +): Record<string, unknown> { + const id = numericId(entry.record.id ?? ''); + const url = `${baseUrl}/repos/${targetDid}/${repoName}/hooks/${id}`; + const created = toISODate(entry.record.dateCreated); + const updated = toISODate(entry.record.timestamp ?? entry.record.dateCreated); + + return { + type : 'Repository', + id, + name : 'web', + active : entry.data.active, + events : entry.data.events, + config : buildWebhookConfig(entry.data, false), + updated_at : updated, + created_at : created, + url, + test_url : `${url}/tests`, + ping_url : `${url}/pings`, + deliveries_url : `${url}/deliveries`, + last_response : { code: null, status: 'unused', message: null }, + }; +} + +async function getRepoOr404(ctx: AgentContext, targetDid: string, repoName: string): Promise<RepoInfo | JsonResponse> { + const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return jsonNotFound(`Repository '${repoName}' not found for DID '${targetDid}'.`); + } + return repo; +} + +async function getOrgOr404(ctx: AgentContext, routeOrg: string): Promise<OrgEntry | JsonResponse> { + const org = await findOrg(ctx, routeOrg); + if (!org) { + return jsonNotFound(`Organization '${decodeRouteParam(routeOrg)}' not found.`); + } + return org; +} + +// --------------------------------------------------------------------------- +// /orgs/:org/hooks +// --------------------------------------------------------------------------- + +export async function handleListOrgWebhooks( + ctx: AgentContext, routeOrg: string, url: URL, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const hooks = await listOrgWebhookEntries(ctx, org); + const paged = paginate(hooks, pagination); + + const linkHeader = buildLinkHeader( + baseUrl, `/orgs/${routeOrgPath(org)}/hooks`, + pagination.page, pagination.perPage, hooks.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(paged.map(hook => buildOrgWebhookResponse(hook, org, baseUrl)), extraHeaders); +} + +export async function handleCreateOrgWebhook( + ctx: AgentContext, routeOrg: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const data = parseCreateWebhook(reqBody); + if ('status' in data) { return data; } + + const { status, record } = await ctx.org.records.create('org/webhook' as any, { + data, + parentContextId: org.record.contextId ?? '', + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create organization webhook: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create organization webhook record'); } + + return jsonCreated(buildOrgWebhookResponse({ record, data }, org, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /orgs/:org/hooks/:hook_id +// --------------------------------------------------------------------------- + +export async function handleGetOrgWebhook( + ctx: AgentContext, routeOrg: string, hookId: string, url: URL, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + return jsonOk(buildOrgWebhookResponse(entry, org, buildApiUrl(url))); +} + +export async function handleUpdateOrgWebhook( + ctx: AgentContext, routeOrg: string, hookId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const data = parseHookUpdate(entry.data, reqBody); + if ('status' in data) { return data; } + + const { status } = await entry.record.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update organization webhook: ${status.detail}`); + } + + return jsonOk(buildOrgWebhookResponse({ record: entry.record, data }, org, buildApiUrl(url))); +} + +export async function handleDeleteOrgWebhook( + ctx: AgentContext, routeOrg: string, hookId: string, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete organization webhook: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /orgs/:org/hooks/:hook_id/config +// --------------------------------------------------------------------------- + +export async function handleGetOrgWebhookConfig( + ctx: AgentContext, routeOrg: string, hookId: string, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + return jsonOk(buildWebhookConfig(entry.data, true)); +} + +export async function handleUpdateOrgWebhookConfig( + ctx: AgentContext, routeOrg: string, hookId: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const data = parseConfigUpdate(entry.data, reqBody); + if ('status' in data) { return data; } + + const { status } = await entry.record.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update organization webhook configuration: ${status.detail}`); + } + + return jsonOk(buildWebhookConfig(data, true)); +} + +// --------------------------------------------------------------------------- +// /orgs/:org/hooks/:hook_id/pings +// --------------------------------------------------------------------------- + +export async function handlePingOrgWebhook( + ctx: AgentContext, routeOrg: string, hookId: string, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const delivery = createSyntheticDelivery(entry, { + event : 'ping', + action : 'ping', + payload : orgWebhookPayload(ctx, org, 'ping', 'ping'), + targetId : numericId(org.record.id ?? org.slug), + targetType : 'organization', + repositoryId : null, + }); + const saveError = await saveWebhookData( + entry, + withDelivery(entry.data, delivery), + 'Failed to record organization webhook delivery', + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /orgs/:org/hooks/:hook_id/deliveries +// --------------------------------------------------------------------------- + +export async function handleListOrgWebhookDeliveries( + ctx: AgentContext, routeOrg: string, hookId: string, url: URL, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const baseUrl = buildApiUrl(url); + return deliveryListResponse( + entry.data, + url, + baseUrl, + `/orgs/${routeOrgPath(org)}/hooks/${numericId(entry.record.id ?? '')}/deliveries`, + ); +} + +export async function handleGetOrgWebhookDelivery( + ctx: AgentContext, routeOrg: string, hookId: string, deliveryId: string, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const delivery = findDelivery(entry.data, deliveryId); + if (!delivery) { + return jsonNotFound(`Webhook delivery '${deliveryId}' not found.`); + } + + return jsonOk(buildDeliveryDetail(delivery, entry.data)); +} + +export async function handleRedeliverOrgWebhookDelivery( + ctx: AgentContext, routeOrg: string, hookId: string, deliveryId: string, +): Promise<JsonResponse> { + const org = await getOrgOr404(ctx, routeOrg); + if ('status' in org) { return org; } + + const entry = await findOrgWebhookEntry(ctx, org, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const original = findDelivery(entry.data, deliveryId); + if (!original) { + return jsonNotFound(`Webhook delivery '${deliveryId}' not found.`); + } + + const payload = isObject(original.request?.payload) + ? original.request.payload as Record<string, unknown> + : orgWebhookPayload(ctx, org, original.event, original.action ?? null); + const delivery = createSyntheticDelivery(entry, { + event : original.event, + action : original.action ?? null, + payload, + redelivery : true, + guid : original.guid, + targetId : numericId(org.record.id ?? org.slug), + targetType : 'organization', + repositoryId : original.repositoryId ?? null, + }); + const saveError = await saveWebhookData( + entry, + withDelivery(entry.data, delivery), + 'Failed to record organization webhook redelivery', + ); + if (saveError) { return saveError; } + + return jsonAccepted({}); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/hooks +// --------------------------------------------------------------------------- + +export async function handleListRepoWebhooks( + ctx: AgentContext, targetDid: string, repoName: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const baseUrl = buildApiUrl(url); + const pagination = parsePagination(url); + const hooks = await listWebhookEntries(ctx, targetDid, repo); + const paged = paginate(hooks, pagination); + + const linkHeader = buildLinkHeader( + baseUrl, `/repos/${targetDid}/${repo.name}/hooks`, + pagination.page, pagination.perPage, hooks.length, + ); + const extraHeaders: Record<string, string> = {}; + if (linkHeader) { extraHeaders['Link'] = linkHeader; } + + return jsonOk(paged.map(hook => buildWebhookResponse(hook, targetDid, repo.name, baseUrl)), extraHeaders); +} + +export async function handleCreateRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const data = parseCreateWebhook(reqBody); + if ('status' in data) { return data; } + + const { status, record } = await ctx.repo.records.create('repo/webhook' as any, { + data : data, + parentContextId : repo.contextId, + } as any); + if (status.code >= 300) { + return jsonValidationError(`Failed to create webhook: ${status.detail}`); + } + if (!record) { throw new Error('Failed to create webhook record'); } + + return jsonCreated(buildWebhookResponse({ record, data }, targetDid, repo.name, buildApiUrl(url))); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/hooks/:hook_id +// --------------------------------------------------------------------------- + +export async function handleGetRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + return jsonOk(buildWebhookResponse(entry, targetDid, repo.name, buildApiUrl(url))); +} + +export async function handleUpdateRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, reqBody: Record<string, unknown>, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const data = parseHookUpdate(entry.data, reqBody); + if ('status' in data) { return data; } + + const { status } = await entry.record.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update webhook: ${status.detail}`); + } + + return jsonOk(buildWebhookResponse({ record: entry.record, data }, targetDid, repo.name, buildApiUrl(url))); +} + +export async function handleDeleteRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const { status } = await entry.record.delete(); + if (status.code >= 300) { + return jsonValidationError(`Failed to delete webhook: ${status.detail}`); + } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/hooks/:hook_id/config +// --------------------------------------------------------------------------- + +export async function handleGetRepoWebhookConfig( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + return jsonOk(buildWebhookConfig(entry.data, true)); +} + +export async function handleUpdateRepoWebhookConfig( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, reqBody: Record<string, unknown>, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const data = parseConfigUpdate(entry.data, reqBody); + if ('status' in data) { return data; } + + const { status } = await entry.record.update({ data }); + if (status.code >= 300) { + return jsonValidationError(`Failed to update webhook configuration: ${status.detail}`); + } + + return jsonOk(buildWebhookConfig(data, true)); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/hooks/:hook_id/pings and /tests +// --------------------------------------------------------------------------- + +export async function handlePingRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const delivery = createSyntheticDelivery(entry, { + event : 'ping', + action : 'ping', + payload : repoWebhookPayload(ctx, repo, targetDid, 'ping', 'ping'), + targetId : numericId(repo.contextId || `${targetDid}/${repo.name}`), + targetType : 'repository', + repositoryId : numericId(repo.contextId || `${targetDid}/${repo.name}`), + }); + const saveError = await saveWebhookData( + entry, + withDelivery(entry.data, delivery), + 'Failed to record webhook delivery', + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +export async function handleTestRepoWebhook( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + if (!entry.data.events.includes('push')) { + return jsonNoContent(); + } + + const delivery = createSyntheticDelivery(entry, { + event : 'push', + action : null, + payload : repoWebhookPayload(ctx, repo, targetDid, 'push', null), + targetId : numericId(repo.contextId || `${targetDid}/${repo.name}`), + targetType : 'repository', + repositoryId : numericId(repo.contextId || `${targetDid}/${repo.name}`), + }); + const saveError = await saveWebhookData( + entry, + withDelivery(entry.data, delivery), + 'Failed to record webhook test delivery', + ); + if (saveError) { return saveError; } + + return jsonNoContent(); +} + +// --------------------------------------------------------------------------- +// /repos/:did/:repo/hooks/:hook_id/deliveries +// --------------------------------------------------------------------------- + +export async function handleListRepoWebhookDeliveries( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, url: URL, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const baseUrl = buildApiUrl(url); + return deliveryListResponse( + entry.data, + url, + baseUrl, + `/repos/${targetDid}/${repo.name}/hooks/${numericId(entry.record.id ?? '')}/deliveries`, + ); +} + +export async function handleGetRepoWebhookDelivery( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, deliveryId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const delivery = findDelivery(entry.data, deliveryId); + if (!delivery) { + return jsonNotFound(`Webhook delivery '${deliveryId}' not found.`); + } + + return jsonOk(buildDeliveryDetail(delivery, entry.data)); +} + +export async function handleRedeliverRepoWebhookDelivery( + ctx: AgentContext, targetDid: string, repoName: string, hookId: string, deliveryId: string, +): Promise<JsonResponse> { + const repo = await getRepoOr404(ctx, targetDid, repoName); + if ('status' in repo) { return repo; } + + const entry = await findWebhookEntry(ctx, targetDid, repo, hookId); + if (!entry) { + return jsonNotFound(`Webhook '${hookId}' not found.`); + } + + const original = findDelivery(entry.data, deliveryId); + if (!original) { + return jsonNotFound(`Webhook delivery '${deliveryId}' not found.`); + } + + const payload = isObject(original.request?.payload) + ? original.request.payload as Record<string, unknown> + : repoWebhookPayload(ctx, repo, targetDid, original.event, original.action ?? null); + const repositoryId = numericId(repo.contextId || `${targetDid}/${repo.name}`); + const delivery = createSyntheticDelivery(entry, { + event : original.event, + action : original.action ?? null, + payload, + redelivery : true, + guid : original.guid, + targetId : repositoryId, + targetType : 'repository', + repositoryId, + }); + const saveError = await saveWebhookData( + entry, + withDelivery(entry.data, delivery), + 'Failed to record webhook redelivery', + ); + if (saveError) { return saveError; } + + return jsonAccepted({}); +} diff --git a/src/indexer/api.ts b/src/indexer/api.ts index 1dc1909..3da4174 100644 --- a/src/indexer/api.ts +++ b/src/indexer/api.ts @@ -7,9 +7,26 @@ * GET /api/repos List all repos (sorted by stars) * GET /api/repos/search?q=<query> Search repos by name/topic/language * GET /api/repos/trending Trending repos (recent star activity) - * GET /api/repos/:did Repo detail for a specific DID - * GET /api/repos/:did/stars Stars for a specific repo + * GET /api/repos/:did First repo detail for a DID (compatibility) + * GET /api/repos/:did/stars Stars for the first repo for a DID (compatibility) + * GET /api/repos/:did/:repo Repo detail for a specific DID/repo + * GET /api/repos/:did/:repo/issues Issue summaries for a specific DID/repo + * GET /api/repos/:did/:repo/issues/:recordId Issue detail for a specific DID/repo + * GET /api/repos/:did/:repo/patches Patch summaries for a specific DID/repo + * GET /api/repos/:did/:repo/patches/:recordId Patch detail for a specific DID/repo + * GET /api/repos/:did/:repo/releases Release summaries for a specific DID/repo + * GET /api/repos/:did/:repo/releases/:recordId Release detail for a specific DID/repo + * GET /api/repos/:did/:repo/submissions/issues External issue submissions for a specific DID/repo + * GET /api/repos/:did/:repo/submissions/issues/:recordId External issue submission detail + * GET /api/repos/:did/:repo/submissions/patches External patch submissions for a specific DID/repo + * GET /api/repos/:did/:repo/submissions/patches/:recordId External patch submission detail + * GET /api/repos/:did/:repo/stars Stars for a specific DID/repo * GET /api/users/:did User profile summary + * GET /api/users/:did/repos Repos owned by a DID + * GET /api/users/:did/starred Repos starred by a DID + * GET /api/users/:did/followers DIDs following a DID + * GET /api/users/:did/following DIDs followed by a DID + * GET /api/users/search?q=<query> Search indexed DIDs * GET /api/stats Indexer statistics * * All responses are JSON with `Content-Type: application/json`. @@ -21,6 +38,8 @@ import type { Server } from 'node:http'; import { createServer } from 'node:http'; +import { handleExploreRequest } from './explore.js'; + import type { IndexerStore } from './store.js'; // --------------------------------------------------------------------------- @@ -78,6 +97,99 @@ export function handleApiRequest( return json(200, store.getReposWithStars()); } + // GET /api/repos/:did/:repo/(issues|patches|releases)/:recordId + const namedRepoItemMatch = path.match( + /^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/(issues|patches|releases)\/([^/]+)$/, + ); + if (namedRepoItemMatch) { + const did = namedRepoItemMatch[1]; + const name = decodeURIComponent(namedRepoItemMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + + const collection = namedRepoItemMatch[3]; + const recordId = decodeURIComponent(namedRepoItemMatch[4]); + if (collection === 'issues') { + const issue = store.getIssueByRecord(repo.did, repo.recordId, recordId); + return issue ? json(200, issue) : json(404, { error: 'Issue not found' }); + } + if (collection === 'patches') { + const patch = store.getPatchByRecord(repo.did, repo.recordId, recordId); + return patch ? json(200, patch) : json(404, { error: 'Patch not found' }); + } + const release = store.getReleaseByRecord(repo.did, repo.recordId, recordId); + return release ? json(200, release) : json(404, { error: 'Release not found' }); + } + + // GET /api/repos/:did/:repo/(issues|patches|releases) + const namedRepoItemsMatch = + path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/(issues|patches|releases)$/); + if (namedRepoItemsMatch) { + const did = namedRepoItemsMatch[1]; + const name = decodeURIComponent(namedRepoItemsMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + + const collection = namedRepoItemsMatch[3]; + if (collection === 'issues') { + return json(200, store.getIssuesForRepo(repo.did, repo.recordId)); + } + if (collection === 'patches') { + return json(200, store.getPatchesForRepo(repo.did, repo.recordId)); + } + return json(200, store.getReleasesForRepo(repo.did, repo.recordId)); + } + + // GET /api/repos/:did/:repo/submissions/(issues|patches)/:recordId + const submissionItemMatch = path.match( + /^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/submissions\/(issues|patches)\/([^/]+)$/, + ); + if (submissionItemMatch) { + const did = submissionItemMatch[1]; + const name = decodeURIComponent(submissionItemMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + + const collection = submissionItemMatch[3]; + const recordId = decodeURIComponent(submissionItemMatch[4]); + if (collection === 'issues') { + const submission = store.getIssueSubmissionByRecord(repo.did, repo.recordId, recordId); + return submission ? json(200, submission) : json(404, { error: 'Issue submission not found' }); + } + const submission = store.getPatchSubmissionByRecord(repo.did, repo.recordId, recordId); + return submission ? json(200, submission) : json(404, { error: 'Patch submission not found' }); + } + + // GET /api/repos/:did/:repo/submissions/(issues|patches) + const submissionMatch = + path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/submissions\/(issues|patches)$/); + if (submissionMatch) { + const did = submissionMatch[1]; + const name = decodeURIComponent(submissionMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + + const collection = submissionMatch[3]; + if (collection === 'issues') { + return json(200, store.getIssueSubmissionsForRepo(repo.did, repo.recordId)); + } + return json(200, store.getPatchSubmissionsForRepo(repo.did, repo.recordId)); + } + + // GET /api/repos/:did/:repo/stars + const namedStarsMatch = path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/stars$/); + if (namedStarsMatch) { + const did = namedStarsMatch[1]; + const name = decodeURIComponent(namedStarsMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + return json(200, { + repo, + starCount : store.getStarCount(repo.did, repo.recordId), + stars : store.getStarsForRepo(repo.did, repo.recordId), + }); + } + // GET /api/repos/:did/stars const starsMatch = path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/stars$/); if (starsMatch) { @@ -90,6 +202,19 @@ export function handleApiRequest( }); } + // GET /api/repos/:did/:repo + const namedRepoMatch = path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)$/); + if (namedRepoMatch) { + const did = namedRepoMatch[1]; + const name = decodeURIComponent(namedRepoMatch[2]); + const repo = store.getRepoByName(did, name); + if (!repo) { return json(404, { error: 'Repo not found' }); } + return json(200, { + ...repo, + starCount: store.getStarCount(repo.did, repo.recordId), + }); + } + // GET /api/repos/:did const repoMatch = path.match(/^\/api\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/); if (repoMatch) { @@ -102,6 +227,44 @@ export function handleApiRequest( }); } + // GET /api/users/search?q=<query> + if (path === '/api/users/search') { + const q = url.searchParams.get('q') ?? ''; + const limit = parseInt(url.searchParams.get('limit') ?? '50', 10); + if (!q) { + return json(400, { error: 'Missing query parameter: q' }); + } + return json(200, store.searchUsers(q, limit)); + } + + // GET /api/users/:did/repos + const userReposMatch = path.match(/^\/api\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/repos$/); + if (userReposMatch) { + const did = userReposMatch[1]; + return json(200, store.getReposForDid(did)); + } + + // GET /api/users/:did/starred + const userStarredMatch = path.match(/^\/api\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/starred$/); + if (userStarredMatch) { + const did = userStarredMatch[1]; + return json(200, store.getStarredReposByUser(did)); + } + + // GET /api/users/:did/followers + const userFollowersMatch = path.match(/^\/api\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/followers$/); + if (userFollowersMatch) { + const did = userFollowersMatch[1]; + return json(200, store.getFollowersForUser(did)); + } + + // GET /api/users/:did/following + const userFollowingMatch = path.match(/^\/api\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/following$/); + if (userFollowingMatch) { + const did = userFollowingMatch[1]; + return json(200, store.getFollowingForUser(did)); + } + // GET /api/users/:did const userMatch = path.match(/^\/api\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/); if (userMatch) { @@ -123,6 +286,16 @@ export function startApiServer(options: ApiServerOptions): Server { const server = createServer((req, res) => { try { const url = new URL(req.url ?? '/', `http://localhost:${port}`); + if (!url.pathname.startsWith('/api/')) { + const result = handleExploreRequest(store, url); + res.writeHead(result.status, { + 'Content-Type' : 'text/html; charset=utf-8', + 'Access-Control-Allow-Origin' : '*', + }); + res.end(result.body); + return; + } + const result = handleApiRequest(store, url); res.writeHead(result.status, { @@ -139,13 +312,34 @@ export function startApiServer(options: ApiServerOptions): Server { server.listen(port, () => { console.log(`[indexer-api] Listening on http://localhost:${port}`); + console.log('[indexer-api] Explore:'); + console.log(' GET / Browse indexed repos and users'); + console.log(' GET /repos Repository search and listing'); + console.log(' GET /users User search and listing'); console.log('[indexer-api] Endpoints:'); console.log(' GET /api/repos List all repos'); console.log(' GET /api/repos/search?q=<query> Search repos'); console.log(' GET /api/repos/trending Trending repos'); - console.log(' GET /api/repos/:did Repo detail'); - console.log(' GET /api/repos/:did/stars Star list'); + console.log(' GET /api/repos/:did/:repo Repo detail'); + console.log(' GET /api/repos/:did/:repo/issues Repo issue summaries'); + console.log(' GET /api/repos/:did/:repo/issues/:id Issue detail'); + console.log(' GET /api/repos/:did/:repo/patches Repo patch summaries'); + console.log(' GET /api/repos/:did/:repo/patches/:id Patch detail'); + console.log(' GET /api/repos/:did/:repo/releases Repo release summaries'); + console.log(' GET /api/repos/:did/:repo/releases/:id Release detail'); + console.log(' GET /api/repos/:did/:repo/submissions/issues External issue submissions'); + console.log(' GET /api/repos/:did/:repo/submissions/issues/:id External issue submission detail'); + console.log(' GET /api/repos/:did/:repo/submissions/patches External patch submissions'); + console.log(' GET /api/repos/:did/:repo/submissions/patches/:id External patch submission detail'); + console.log(' GET /api/repos/:did/:repo/stars Star list'); + console.log(' GET /api/repos/:did First repo detail (compat)'); + console.log(' GET /api/repos/:did/stars First repo stars (compat)'); console.log(' GET /api/users/:did User profile'); + console.log(' GET /api/users/:did/repos User repositories'); + console.log(' GET /api/users/:did/starred User starred repos'); + console.log(' GET /api/users/:did/followers User followers'); + console.log(' GET /api/users/:did/following User following'); + console.log(' GET /api/users/search?q=<query> Search users'); console.log(' GET /api/stats Indexer stats'); console.log(''); }); diff --git a/src/indexer/crawler.ts b/src/indexer/crawler.ts index 7f5b723..aad7b4b 100644 --- a/src/indexer/crawler.ts +++ b/src/indexer/crawler.ts @@ -36,6 +36,12 @@ export type CrawlResult = { newRepos : number; newStars : number; newFollows : number; + newIssues : number; + newPatches : number; + newReleases : number; + newIssueSubmissions : number; + newPatchSubmissions : number; + newSubmissionDecisions : number; errors : { did: string; error: string }[]; }; @@ -62,11 +68,17 @@ export class IndexerCrawler { public async crawl(options?: CrawlOptions): Promise<CrawlResult> { const dids = (options?.dids ?? this._store.getDids()).slice(0, options?.maxDids); const result: CrawlResult = { - crawledDids : 0, - newRepos : 0, - newStars : 0, - newFollows : 0, - errors : [], + crawledDids : 0, + newRepos : 0, + newStars : 0, + newFollows : 0, + newIssues : 0, + newPatches : 0, + newReleases : 0, + newIssueSubmissions : 0, + newPatchSubmissions : 0, + newSubmissionDecisions : 0, + errors : [], }; for (const did of dids) { @@ -75,6 +87,12 @@ export class IndexerCrawler { result.newRepos += counts.repos; result.newStars += counts.stars; result.newFollows += counts.follows; + result.newIssues += counts.issues; + result.newPatches += counts.patches; + result.newReleases += counts.releases; + result.newIssueSubmissions += counts.issueSubmissions; + result.newPatchSubmissions += counts.patchSubmissions; + result.newSubmissionDecisions += counts.submissionDecisions; result.crawledDids++; this._store.setCursor(did, new Date().toISOString()); } catch (err) { @@ -89,11 +107,21 @@ export class IndexerCrawler { * Crawl a single DID — queries repos, stars, follows, and repo * metadata (issues, patches, releases counts). */ - public async crawlDid(did: string): Promise<{ repos: number; stars: number; follows: number }> { + public async crawlDid(did: string): Promise<{ + repos: number; stars: number; follows: number; + issues: number; patches: number; releases: number; + issueSubmissions: number; patchSubmissions: number; submissionDecisions: number; + }> { const from = did === this._ctx.did ? undefined : did; let repos = 0; let stars = 0; let follows = 0; + let indexedIssues = 0; + let indexedPatches = 0; + let indexedReleases = 0; + let indexedIssueSubmissions = 0; + let indexedPatchSubmissions = 0; + let indexedSubmissionDecisions = 0; // --------------------------------------------------------------- // Repos @@ -105,19 +133,19 @@ export class IndexerCrawler { const tags = rec.tags as Record<string, string> | undefined; const contextId = rec.contextId ?? ''; - // Count open issues. + // Fetch issues. const { records: issues } = await this._ctx.issues.records.query('repo/issue', { from, - filter: { contextId, tags: { status: 'open' } }, + filter: { contextId }, }); - // Count open patches. + // Fetch patches. const { records: patches } = await this._ctx.patches.records.query('repo/patch', { from, - filter: { contextId, tags: { status: 'open' } }, + filter: { contextId }, }); - // Count releases. + // Fetch releases. const { records: releases } = await this._ctx.releases.records.query('repo/release' as any, { from, filter: { contextId }, @@ -134,27 +162,174 @@ export class IndexerCrawler { if (tTags?.name) { topics.push(tTags.name); } } + const { records: submissionDecisions } = await this._ctx.repo.records.query('repo/submissionDecision' as any, { + from, + filter: { contextId }, + }); + + const repoName = data.name ?? 'unnamed'; + const indexedAt = new Date().toISOString(); const indexed: IndexedRepo = { did, recordId : rec.id, contextId, - name : data.name ?? 'unnamed', + name : repoName, description : data.description ?? '', defaultBranch : data.defaultBranch ?? 'main', visibility : tags?.visibility ?? 'public', language : tags?.language ?? '', topics, - openIssues : issues.length, - openPatches : patches.length, + openIssues : issues.filter(recordHasOpenStatus).length, + openPatches : patches.filter(recordHasOpenStatus).length, releaseCount : releases.length, lastUpdated : rec.dateCreated ?? new Date().toISOString(), - indexedAt : new Date().toISOString(), + indexedAt, }; this._store.putRepo(indexed); + + for (const decision of submissionDecisions) { + const decisionTags = decision.tags as Record<string, unknown> | undefined; + const kind = submissionKind(decisionTags?.kind); + const decisionValue = stringOr(decisionTags?.decision, ''); + const submitterDid = stringOr(decisionTags?.submitterDid, ''); + const submissionRecordId = stringOr(decisionTags?.submissionRecordId, ''); + if (!kind || decisionValue !== 'ignored' || !submitterDid || !submissionRecordId) { continue; } + + const decisionData = await decision.data.json(); + this._store.putSubmissionDecision({ + ownerDid : did, + repoRecordId : rec.id, + repoName, + kind, + decision : 'ignored', + submitterDid, + submissionRecordId, + submissionContextId : stringOr(decisionTags?.submissionContextId, stringOr(decisionData.submissionContextId, '')), + reason : stringOr(decisionData.reason, ''), + dateCreated : decision.dateCreated ?? '', + indexedAt, + }); + indexedSubmissionDecisions++; + } + + for (const issue of issues) { + const issueData = await issue.data.json(); + const issueTags = issue.tags as Record<string, unknown> | undefined; + this._store.putIssue({ + did, + repoRecordId : rec.id, + repoName, + recordId : issue.id, + contextId : issue.contextId ?? '', + title : stringOr(issueData.title, 'Untitled issue'), + body : stringOr(issueData.body, ''), + status : stringOr(issueTags?.status, 'open'), + dateCreated : issue.dateCreated ?? '', + indexedAt, + }); + indexedIssues++; + } + + for (const patch of patches) { + const patchData = await patch.data.json(); + const patchTags = patch.tags as Record<string, unknown> | undefined; + this._store.putPatch({ + did, + repoRecordId : rec.id, + repoName, + recordId : patch.id, + contextId : patch.contextId ?? '', + title : stringOr(patchData.title, 'Untitled patch'), + body : stringOr(patchData.body, ''), + status : stringOr(patchTags?.status, 'open'), + baseBranch : stringOr(patchTags?.baseBranch, ''), + headBranch : stringOr(patchTags?.headBranch, ''), + dateCreated : patch.dateCreated ?? '', + indexedAt, + }); + indexedPatches++; + } + + for (const release of releases) { + const releaseData = await release.data.json(); + const releaseTags = release.tags as Record<string, unknown> | undefined; + const tagName = stringOr(releaseTags?.tagName, stringOr(releaseData.tagName, '')); + this._store.putRelease({ + did, + repoRecordId : rec.id, + repoName, + recordId : release.id, + contextId : release.contextId ?? '', + tagName, + name : stringOr(releaseData.name, tagName || 'Untitled release'), + body : stringOr(releaseData.body, ''), + draft : booleanTag(releaseTags?.draft), + prerelease : booleanTag(releaseTags?.prerelease), + dateCreated : release.dateCreated ?? '', + indexedAt, + }); + indexedReleases++; + } + repos++; } + // --------------------------------------------------------------- + // External issue and patch submissions (on this user's DWN) + // --------------------------------------------------------------- + const submissionIndexedAt = new Date().toISOString(); + const { records: issueSubmissions } = await this._ctx.issues.records.query('repo/issue', { from }); + + for (const issue of issueSubmissions) { + const issueTags = issue.tags as Record<string, unknown> | undefined; + const target = targetRepo(issueTags); + if (!target) { continue; } + + const issueData = await issue.data.json(); + this._store.putIssueSubmission({ + submitterDid : did, + targetDid : target.did, + targetRepoRecordId : target.repoRecordId, + targetRepoName : target.name || this._store.getRepoByRecord(target.did, target.repoRecordId)?.name || '', + recordId : issue.id, + contextId : issue.contextId ?? '', + title : stringOr(issueData.title, 'Untitled issue'), + body : stringOr(issueData.body, ''), + status : stringOr(issueTags?.status, 'open'), + dateCreated : issue.dateCreated ?? '', + indexedAt : submissionIndexedAt, + }); + indexedIssueSubmissions++; + } + + const { records: patchSubmissions } = await this._ctx.patches.records.query('repo/patch', { from }); + + for (const patch of patchSubmissions) { + const patchTags = patch.tags as Record<string, unknown> | undefined; + const target = targetRepo(patchTags); + if (!target) { continue; } + + const patchData = await patch.data.json(); + this._store.putPatchSubmission({ + submitterDid : did, + targetDid : target.did, + targetRepoRecordId : target.repoRecordId, + targetRepoName : target.name || this._store.getRepoByRecord(target.did, target.repoRecordId)?.name || '', + sourceDid : stringOr(patchTags?.sourceDid, did), + recordId : patch.id, + contextId : patch.contextId ?? '', + title : stringOr(patchData.title, 'Untitled patch'), + body : stringOr(patchData.body, ''), + status : stringOr(patchTags?.status, 'open'), + baseBranch : stringOr(patchTags?.baseBranch, ''), + headBranch : stringOr(patchTags?.headBranch, ''), + dateCreated : patch.dateCreated ?? '', + indexedAt : submissionIndexedAt, + }); + indexedPatchSubmissions++; + } + // --------------------------------------------------------------- // Stars (on this user's DWN) // --------------------------------------------------------------- @@ -196,7 +371,17 @@ export class IndexerCrawler { } } - return { repos, stars, follows }; + return { + repos, + stars, + follows, + issues : indexedIssues, + patches : indexedPatches, + releases : indexedReleases, + issueSubmissions : indexedIssueSubmissions, + patchSubmissions : indexedPatchSubmissions, + submissionDecisions : indexedSubmissionDecisions, + }; } /** @@ -266,6 +451,8 @@ export class IndexerCrawler { console.log( `[indexer] Crawled ${result.crawledDids} DIDs: ` + `${result.newRepos} repos, ${result.newStars} stars, ${result.newFollows} follows` + + `, ${result.newIssues} issues, ${result.newPatches} patches, ${result.newReleases} releases` + + `, ${result.newIssueSubmissions} issue submissions, ${result.newPatchSubmissions} patch submissions` + (result.errors.length > 0 ? ` (${result.errors.length} errors)` : '') + ` | Total: ${stats.dids} DIDs, ${stats.repos} repos, ${stats.stars} stars`, ); @@ -288,3 +475,31 @@ export class IndexerCrawler { return (): void => { running = false; }; } } + +function recordHasOpenStatus(record: { tags?: unknown }): boolean { + const tags = record.tags as Record<string, unknown> | undefined; + return tags?.status === 'open'; +} + +function stringOr(value: unknown, fallback: string): string { + return typeof value === 'string' ? value : fallback; +} + +function booleanTag(value: unknown): boolean { + return value === true || value === 'true'; +} + +function targetRepo(tags: Record<string, unknown> | undefined): { did: string; repoRecordId: string; name: string } | null { + const did = stringOr(tags?.repoDid, ''); + const repoRecordId = stringOr(tags?.repoRecordId, ''); + if (!did || !repoRecordId) { return null; } + return { + did, + repoRecordId, + name: stringOr(tags?.repoName, ''), + }; +} + +function submissionKind(value: unknown): 'issue' | 'patch' | undefined { + return value === 'issue' || value === 'patch' ? value : undefined; +} diff --git a/src/indexer/explore.ts b/src/indexer/explore.ts new file mode 100644 index 0000000..4ddcd61 --- /dev/null +++ b/src/indexer/explore.ts @@ -0,0 +1,769 @@ +/** + * Server-rendered Explore UI for the indexer. + * + * The indexer API remains JSON under /api. These pages make the same + * materialized views browsable for humans. + * + * @module + */ + +import type { + IndexedIssue, + IndexedIssueSubmission, + IndexedPatch, + IndexedPatchSubmission, + IndexedRelease, + IndexedRepo, + IndexerStore, + RepoWithStars, + UserProfile, + UserSearchResult, +} from './store.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ExploreResponse = { + status : number; + body : string; +}; + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +export function handleExploreRequest(store: IndexerStore, url: URL): ExploreResponse { + const path = url.pathname; + + if (path === '/' || path === '') { + return html(200, layout('Explore', homePage(store))); + } + + if (path === '/repos') { + return html(200, layout('Repositories', reposPage(store, url))); + } + + const submissionItemMatch = path.match( + /^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/submissions\/(issues|patches)\/([^/]+)$/, + ); + if (submissionItemMatch) { + const repo = store.getRepoByName(submissionItemMatch[1], decodeURIComponent(submissionItemMatch[2])); + if (!repo) { return notFound('Repository not found'); } + + const collection = submissionItemMatch[3]; + const recordId = decodeURIComponent(submissionItemMatch[4]); + if (collection === 'issues') { + const submission = store.getIssueSubmissionByRecord(repo.did, repo.recordId, recordId); + return submission + ? html(200, layout(submission.title, issueSubmissionDetailPage(repo, submission))) + : notFound('Issue submission not found'); + } + const submission = store.getPatchSubmissionByRecord(repo.did, repo.recordId, recordId); + return submission + ? html(200, layout(submission.title, patchSubmissionDetailPage(repo, submission))) + : notFound('Patch submission not found'); + } + + const submissionCollectionMatch = path.match( + /^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/submissions\/(issues|patches)$/, + ); + if (submissionCollectionMatch) { + const repo = store.getRepoByName(submissionCollectionMatch[1], decodeURIComponent(submissionCollectionMatch[2])); + if (!repo) { return notFound('Repository not found'); } + return html(200, layout('External submissions', repoSubmissionPage(store, repo, submissionCollectionMatch[3]))); + } + + const repoItemMatch = path.match( + /^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/(issues|patches|releases)\/([^/]+)$/, + ); + if (repoItemMatch) { + const repo = store.getRepoByName(repoItemMatch[1], decodeURIComponent(repoItemMatch[2])); + if (!repo) { return notFound('Repository not found'); } + + const collection = repoItemMatch[3]; + const recordId = decodeURIComponent(repoItemMatch[4]); + if (collection === 'issues') { + const issue = store.getIssueByRecord(repo.did, repo.recordId, recordId); + return issue ? html(200, layout(issue.title, issueDetailPage(repo, issue))) : notFound('Issue not found'); + } + if (collection === 'patches') { + const patch = store.getPatchByRecord(repo.did, repo.recordId, recordId); + return patch ? html(200, layout(patch.title, patchDetailPage(repo, patch))) : notFound('Patch not found'); + } + + const release = store.getReleaseByRecord(repo.did, repo.recordId, recordId); + return release ? html(200, layout(release.name, releaseDetailPage(repo, release))) : notFound('Release not found'); + } + + const repoCollectionMatch = + path.match(/^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)\/(issues|patches|releases)$/); + if (repoCollectionMatch) { + const repo = store.getRepoByName(repoCollectionMatch[1], decodeURIComponent(repoCollectionMatch[2])); + if (!repo) { return notFound('Repository not found'); } + return html(200, layout(collectionTitle(repoCollectionMatch[3]), repoCollectionPage(store, repo, repoCollectionMatch[3]))); + } + + const repoMatch = path.match(/^\/repos\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+)$/); + if (repoMatch) { + const repo = store.getRepoByName(repoMatch[1], decodeURIComponent(repoMatch[2])); + if (!repo) { return notFound('Repository not found'); } + return html(200, layout(repo.name, repoDetailPage(store, repo))); + } + + if (path === '/users') { + return html(200, layout('Users', usersPage(store, url))); + } + + const userMatch = path.match(/^\/users\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)$/); + if (userMatch) { + return html(200, layout(shortDid(userMatch[1]), userDetailPage(store, userMatch[1]))); + } + + return notFound('Page not found'); +} + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +function homePage(store: IndexerStore): string { + const trending = store.getTrending(10); + const repos = store.getReposWithStars().slice(0, 10); + + return ` + <section> + <div class="toolbar"> + <form action="/repos" method="get"> + <label for="repo-q">Repositories</label> + <div class="search-row"> + <input id="repo-q" name="q" type="search" placeholder="repo, topic, language"> + <button type="submit">Search</button> + </div> + </form> + <form action="/users" method="get"> + <label for="user-q">Users</label> + <div class="search-row"> + <input id="user-q" name="q" type="search" placeholder="did:jwk:..."> + <button type="submit">Search</button> + </div> + </form> + </div> + </section> + <section> + <h2>Trending</h2> + ${repoTable(trending)} + </section> + <section> + <h2>Repositories</h2> + ${repoTable(repos)} + </section>`; +} + +function reposPage(store: IndexerStore, url: URL): string { + const q = url.searchParams.get('q')?.trim(); + const language = url.searchParams.get('language')?.trim(); + const topic = url.searchParams.get('topic')?.trim(); + + let repos: RepoWithStars[]; + if (q) { + repos = store.search(q); + } else if (language) { + repos = store.getReposByLanguage(language); + } else if (topic) { + repos = store.getReposByTopic(topic); + } else { + repos = store.getReposWithStars(); + } + + return ` + <section> + <div class="page-heading"> + <h1>Repositories</h1> + <form action="/repos" method="get" class="compact-search"> + <input name="q" type="search" value="${esc(q ?? '')}" placeholder="repo, topic, language"> + <button type="submit">Search</button> + </form> + </div> + ${repoTable(repos)} + </section>`; +} + +function repoDetailPage(store: IndexerStore, repo: IndexedRepo): string { + const starCount = store.getStarCount(repo.did, repo.recordId); + const stars = store.getStarsForRepo(repo.did, repo.recordId); + const issues = store.getIssuesForRepo(repo.did, repo.recordId); + const patches = store.getPatchesForRepo(repo.did, repo.recordId); + const releases = store.getReleasesForRepo(repo.did, repo.recordId); + const issueSubmissions = store.getIssueSubmissionsForRepo(repo.did, repo.recordId); + const patchSubmissions = store.getPatchSubmissionsForRepo(repo.did, repo.recordId); + + return ` + <section> + <p class="crumb"><a href="/repos">Repositories</a> / <a href="${userPath(repo.did)}">${esc(shortDid(repo.did))}</a></p> + <h1>${esc(repo.name)}</h1> + ${repo.description ? `<p>${esc(repo.description)}</p>` : ''} + <dl class="facts"> + <div><dt>DID</dt><dd><a href="${userPath(repo.did)}"><code>${esc(repo.did)}</code></a></dd></div> + <div><dt>Default branch</dt><dd>${esc(repo.defaultBranch)}</dd></div> + <div><dt>Visibility</dt><dd>${esc(repo.visibility)}</dd></div> + <div><dt>Language</dt><dd>${esc(repo.language || '-')}</dd></div> + <div><dt>Stars</dt><dd>${starCount}</dd></div> + <div><dt>Issues</dt><dd>${repo.openIssues} open</dd></div> + <div><dt>Patches</dt><dd>${repo.openPatches} open</dd></div> + <div><dt>Releases</dt><dd>${repo.releaseCount}</dd></div> + </dl> + <p><code>gitd clone ${esc(repo.did)}/${esc(repo.name)}</code></p> + </section> + <section> + <h2><a href="${repoPath(repo)}/issues">Issues</a></h2> + ${issueTable(issues.slice(0, 10), repo)} + </section> + <section> + <h2><a href="${repoPath(repo)}/patches">Patches</a></h2> + ${patchTable(patches.slice(0, 10), repo)} + </section> + <section> + <h2><a href="${repoPath(repo)}/releases">Releases</a></h2> + ${releaseTable(releases.slice(0, 10), repo)} + </section> + <section> + <h2><a href="${repoPath(repo)}/submissions/issues">External Issues</a></h2> + ${issueSubmissionTable(issueSubmissions.slice(0, 10), repo)} + </section> + <section> + <h2><a href="${repoPath(repo)}/submissions/patches">External Patches</a></h2> + ${patchSubmissionTable(patchSubmissions.slice(0, 10), repo)} + </section> + <section> + <h2>Topics</h2> + ${repo.topics.length > 0 ? topicList(repo.topics) : '<p class="empty">No topics indexed.</p>'} + </section> + <section> + <h2>Stars</h2> + ${stars.length > 0 ? userList(stars.map(star => profileFromDid(store, star.starrerDid))) : '<p class="empty">No stars indexed.</p>'} + </section>`; +} + +function repoCollectionPage(store: IndexerStore, repo: IndexedRepo, collection: string): string { + const crumb = ` + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> + </p>`; + + if (collection === 'issues') { + return `${crumb}<section><h1>Issues</h1>${issueTable(store.getIssuesForRepo(repo.did, repo.recordId), repo)}</section>`; + } + if (collection === 'patches') { + return `${crumb}<section><h1>Patches</h1>${patchTable(store.getPatchesForRepo(repo.did, repo.recordId), repo)}</section>`; + } + return `${crumb}<section><h1>Releases</h1>${releaseTable(store.getReleasesForRepo(repo.did, repo.recordId), repo)}</section>`; +} + +function repoSubmissionPage(store: IndexerStore, repo: IndexedRepo, collection: string): string { + const crumb = ` + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> + </p>`; + + if (collection === 'issues') { + const submissions = store.getIssueSubmissionsForRepo(repo.did, repo.recordId); + return `${crumb}<section><h1>External Issues</h1>${issueSubmissionTable(submissions, repo)}</section>`; + } + const submissions = store.getPatchSubmissionsForRepo(repo.did, repo.recordId); + return `${crumb}<section><h1>External Patches</h1>${patchSubmissionTable(submissions, repo)}</section>`; +} + +function issueSubmissionDetailPage(repo: IndexedRepo, submission: IndexedIssueSubmission): string { + return ` + <section> + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> / + <a href="${repoPath(repo)}/submissions/issues">External Issues</a> + </p> + <h1>${esc(submission.title)}</h1> + <dl class="facts"> + <div><dt>Status</dt><dd>${status(submission.status)}</dd></div> + <div><dt>Submitter</dt><dd> + <a href="${userPath(submission.submitterDid)}"><code>${esc(shortDid(submission.submitterDid))}</code></a> + </dd></div> + <div><dt>Created</dt><dd>${shortDate(submission.dateCreated)}</dd></div> + <div><dt>Source record</dt><dd><code>${esc(submission.recordId)}</code></dd></div> + </dl> + ${bodyBlock(submission.body)} + <div class="commands"> + <code>gitd issue accept ${esc(submission.submitterDid)} ${esc(submission.recordId)}</code> + <code>gitd issue ignore ${esc(submission.submitterDid)} ${esc(submission.recordId)}</code> + </div> + </section>`; +} + +function patchSubmissionDetailPage(repo: IndexedRepo, submission: IndexedPatchSubmission): string { + return ` + <section> + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> / + <a href="${repoPath(repo)}/submissions/patches">External Patches</a> + </p> + <h1>${esc(submission.title)}</h1> + <dl class="facts"> + <div><dt>Status</dt><dd>${status(submission.status)}</dd></div> + <div><dt>Submitter</dt><dd> + <a href="${userPath(submission.submitterDid)}"><code>${esc(shortDid(submission.submitterDid))}</code></a> + </dd></div> + <div><dt>Base</dt><dd>${esc(submission.baseBranch || '-')}</dd></div> + <div><dt>Head</dt><dd>${esc(submission.headBranch || '-')}</dd></div> + <div><dt>Created</dt><dd>${shortDate(submission.dateCreated)}</dd></div> + <div><dt>Source record</dt><dd><code>${esc(submission.recordId)}</code></dd></div> + </dl> + ${bodyBlock(submission.body)} + <div class="commands"> + <code>gitd pr accept ${esc(submission.submitterDid)} ${esc(submission.recordId)}</code> + <code>gitd pr ignore ${esc(submission.submitterDid)} ${esc(submission.recordId)}</code> + </div> + </section>`; +} + +function issueDetailPage(repo: IndexedRepo, issue: IndexedIssue): string { + return ` + <section> + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> / + <a href="${repoPath(repo)}/issues">Issues</a> + </p> + <h1>${esc(issue.title)}</h1> + <dl class="facts"> + <div><dt>Status</dt><dd>${status(issue.status)}</dd></div> + <div><dt>Created</dt><dd>${shortDate(issue.dateCreated)}</dd></div> + <div><dt>Record</dt><dd><code>${esc(issue.recordId)}</code></dd></div> + </dl> + ${bodyBlock(issue.body)} + </section>`; +} + +function patchDetailPage(repo: IndexedRepo, patch: IndexedPatch): string { + return ` + <section> + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> / + <a href="${repoPath(repo)}/patches">Patches</a> + </p> + <h1>${esc(patch.title)}</h1> + <dl class="facts"> + <div><dt>Status</dt><dd>${status(patch.status)}</dd></div> + <div><dt>Base</dt><dd>${esc(patch.baseBranch || '-')}</dd></div> + <div><dt>Head</dt><dd>${esc(patch.headBranch || '-')}</dd></div> + <div><dt>Created</dt><dd>${shortDate(patch.dateCreated)}</dd></div> + <div><dt>Record</dt><dd><code>${esc(patch.recordId)}</code></dd></div> + </dl> + ${bodyBlock(patch.body)} + </section>`; +} + +function releaseDetailPage(repo: IndexedRepo, release: IndexedRelease): string { + return ` + <section> + <p class="crumb"> + <a href="/repos">Repositories</a> / <a href="${repoPath(repo)}">${esc(repo.name)}</a> / + <a href="${repoPath(repo)}/releases">Releases</a> + </p> + <h1>${esc(release.name)}</h1> + <dl class="facts"> + <div><dt>Tag</dt><dd><code>${esc(release.tagName || '-')}</code></dd></div> + <div><dt>Status</dt><dd>${releaseState(release).map(flag => status(flag)).join(' ')}</dd></div> + <div><dt>Created</dt><dd>${shortDate(release.dateCreated)}</dd></div> + <div><dt>Record</dt><dd><code>${esc(release.recordId)}</code></dd></div> + </dl> + ${bodyBlock(release.body)} + </section>`; +} + +function usersPage(store: IndexerStore, url: URL): string { + const q = url.searchParams.get('q')?.trim(); + const users = q + ? store.searchUsers(q) + : store.getKnownDids() + .map(did => profileFromDid(store, did)) + .sort(sortProfiles); + + return ` + <section> + <div class="page-heading"> + <h1>Users</h1> + <form action="/users" method="get" class="compact-search"> + <input name="q" type="search" value="${esc(q ?? '')}" placeholder="did:jwk:..."> + <button type="submit">Search</button> + </form> + </div> + ${userList(users)} + </section>`; +} + +function userDetailPage(store: IndexerStore, did: string): string { + const profile = store.getUserProfile(did); + const repos = store.getReposForDid(did); + const starred = store.getStarredReposByUser(did).map(star => star.repo).filter(Boolean) as RepoWithStars[]; + const followers = store.getFollowersForUser(did).map(follow => profileFromDid(store, follow.followerDid)); + const following = store.getFollowingForUser(did).map(follow => profileFromDid(store, follow.targetDid)); + + return ` + <section> + <p class="crumb"><a href="/users">Users</a></p> + <h1><code>${esc(did)}</code></h1> + <dl class="facts"> + <div><dt>Repositories</dt><dd>${profile.repoCount}</dd></div> + <div><dt>Stars</dt><dd>${profile.starCount}</dd></div> + <div><dt>Followers</dt><dd>${profile.followerCount}</dd></div> + <div><dt>Following</dt><dd>${profile.followingCount}</dd></div> + </dl> + </section> + <section> + <h2>Repositories</h2> + ${repoTable(repos)} + </section> + <section> + <h2>Starred</h2> + ${repoTable(starred)} + </section> + <section> + <h2>Followers</h2> + ${userList(followers)} + </section> + <section> + <h2>Following</h2> + ${userList(following)} + </section>`; +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +function repoTable(repos: RepoWithStars[]): string { + if (repos.length === 0) { + return '<p class="empty">No repositories indexed.</p>'; + } + + const rows = repos.map(repo => ` + <tr> + <td><a href="${repoPath(repo)}">${esc(repo.name)}</a></td> + <td><a href="${userPath(repo.did)}">${esc(shortDid(repo.did))}</a></td> + <td>${esc(repo.language || '-')}</td> + <td>${repo.starCount}</td> + <td>${repo.openIssues}</td> + <td>${repo.openPatches}</td> + </tr>`).join(''); + + return ` + <table> + <thead><tr><th>Repository</th><th>Owner</th><th>Language</th><th>Stars</th><th>Issues</th><th>Patches</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function userList(users: Array<UserProfile | UserSearchResult>): string { + if (users.length === 0) { + return '<p class="empty">No users indexed.</p>'; + } + + const rows = users.map(user => ` + <tr> + <td><a href="${userPath(user.did)}"><code>${esc(user.did)}</code></a></td> + <td>${user.repoCount}</td> + <td>${user.starCount}</td> + <td>${user.followerCount}</td> + <td>${user.followingCount}</td> + </tr>`).join(''); + + return ` + <table> + <thead><tr><th>DID</th><th>Repos</th><th>Stars</th><th>Followers</th><th>Following</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function issueTable(issues: IndexedIssue[], repo?: IndexedRepo): string { + if (issues.length === 0) { + return '<p class="empty">No issues indexed.</p>'; + } + + const rows = issues.map(issue => { + const title = repo ? `<a href="${issuePath(repo, issue)}">${esc(issue.title)}</a>` : esc(issue.title); + return ` + <tr> + <td>${status(issue.status)}</td> + <td>${title}</td> + <td class="muted">${shortDate(issue.dateCreated)}</td> + </tr>`; + }).join(''); + + return ` + <table> + <thead><tr><th>Status</th><th>Issue</th><th>Created</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function patchTable(patches: IndexedPatch[], repo?: IndexedRepo): string { + if (patches.length === 0) { + return '<p class="empty">No patches indexed.</p>'; + } + + const rows = patches.map(patch => { + const title = repo ? `<a href="${patchPath(repo, patch)}">${esc(patch.title)}</a>` : esc(patch.title); + return ` + <tr> + <td>${status(patch.status)}</td> + <td>${title}</td> + <td class="muted">${esc(patch.baseBranch || '-')}</td> + <td class="muted">${esc(patch.headBranch || '-')}</td> + <td class="muted">${shortDate(patch.dateCreated)}</td> + </tr>`; + }).join(''); + + return ` + <table> + <thead><tr><th>Status</th><th>Patch</th><th>Base</th><th>Head</th><th>Created</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function releaseTable(releases: IndexedRelease[], repo?: IndexedRepo): string { + if (releases.length === 0) { + return '<p class="empty">No releases indexed.</p>'; + } + + const rows = releases.map(release => { + const name = repo ? `<a href="${releasePath(repo, release)}">${esc(release.name)}</a>` : esc(release.name); + return ` + <tr> + <td>${esc(release.tagName || '-')}</td> + <td>${name}</td> + <td>${releaseState(release).map(flag => status(flag)).join(' ')}</td> + <td class="muted">${shortDate(release.dateCreated)}</td> + </tr>`; + }).join(''); + + return ` + <table> + <thead><tr><th>Tag</th><th>Release</th><th>Status</th><th>Created</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function issueSubmissionTable(submissions: IndexedIssueSubmission[], repo?: IndexedRepo): string { + if (submissions.length === 0) { + return '<p class="empty">No external issue submissions indexed.</p>'; + } + + const rows = submissions.map(submission => { + const title = repo ? `<a href="${issueSubmissionPath(repo, submission)}">${esc(submission.title)}</a>` : esc(submission.title); + return ` + <tr> + <td>${status(submission.status)}</td> + <td>${title}</td> + <td><a href="${userPath(submission.submitterDid)}"><code>${esc(shortDid(submission.submitterDid))}</code></a></td> + <td><code>${esc(shortId(submission.recordId))}</code></td> + <td class="muted">${shortDate(submission.dateCreated)}</td> + </tr>`; + }).join(''); + + return ` + <table> + <thead><tr><th>Status</th><th>Issue</th><th>Submitter</th><th>Record</th><th>Created</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function patchSubmissionTable(submissions: IndexedPatchSubmission[], repo?: IndexedRepo): string { + if (submissions.length === 0) { + return '<p class="empty">No external patch submissions indexed.</p>'; + } + + const rows = submissions.map(submission => { + const title = repo ? `<a href="${patchSubmissionPath(repo, submission)}">${esc(submission.title)}</a>` : esc(submission.title); + return ` + <tr> + <td>${status(submission.status)}</td> + <td>${title}</td> + <td><a href="${userPath(submission.submitterDid)}"><code>${esc(shortDid(submission.submitterDid))}</code></a></td> + <td class="muted">${esc(submission.baseBranch || '-')}</td> + <td class="muted">${esc(submission.headBranch || '-')}</td> + <td><code>${esc(shortId(submission.recordId))}</code></td> + <td class="muted">${shortDate(submission.dateCreated)}</td> + </tr>`; + }).join(''); + + return ` + <table> + <thead><tr><th>Status</th><th>Patch</th><th>Submitter</th><th>Base</th><th>Head</th><th>Record</th><th>Created</th></tr></thead> + <tbody>${rows}</tbody> + </table>`; +} + +function topicList(topics: string[]): string { + return `<p>${topics.map(topic => `<a class="pill" href="/repos?topic=${encodeURIComponent(topic)}">${esc(topic)}</a>`).join(' ')}</p>`; +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +function layout(title: string, body: string): string { + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>${esc(title)} - gitd indexer + + + +
+ +
+
+
${body}
+
+ +`; +} + +function notFound(message: string): ExploreResponse { + return html(404, layout(message, `

${esc(message)}

Explore

`)); +} + +function html(status: number, body: string): ExploreResponse { + return { status, body }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function profileFromDid(store: IndexerStore, did: string): UserProfile { + return store.getUserProfile(did); +} + +function sortProfiles(a: UserProfile, b: UserProfile): number { + const activityA = a.repoCount + a.starCount + a.followerCount + a.followingCount; + const activityB = b.repoCount + b.starCount + b.followerCount + b.followingCount; + return activityB - activityA || a.did.localeCompare(b.did); +} + +function collectionTitle(collection: string): string { + return collection[0].toUpperCase() + collection.slice(1); +} + +function repoPath(repo: { did: string; name: string }): string { + return `/repos/${repo.did}/${encodeURIComponent(repo.name)}`; +} + +function issuePath(repo: IndexedRepo, issue: IndexedIssue): string { + return `${repoPath(repo)}/issues/${encodeURIComponent(issue.recordId)}`; +} + +function patchPath(repo: IndexedRepo, patch: IndexedPatch): string { + return `${repoPath(repo)}/patches/${encodeURIComponent(patch.recordId)}`; +} + +function releasePath(repo: IndexedRepo, release: IndexedRelease): string { + return `${repoPath(repo)}/releases/${encodeURIComponent(release.recordId)}`; +} + +function issueSubmissionPath(repo: IndexedRepo, submission: IndexedIssueSubmission): string { + return `${repoPath(repo)}/submissions/issues/${encodeURIComponent(submission.recordId)}`; +} + +function patchSubmissionPath(repo: IndexedRepo, submission: IndexedPatchSubmission): string { + return `${repoPath(repo)}/submissions/patches/${encodeURIComponent(submission.recordId)}`; +} + +function userPath(did: string): string { + return `/users/${did}`; +} + +function shortId(recordId: string): string { + return recordId.length > 12 ? `${recordId.slice(0, 12)}...` : recordId; +} + +function shortDid(did: string): string { + const parts = did.split(':'); + const suffix = parts.at(-1) ?? did; + return `${parts.slice(0, 2).join(':')}:${suffix.slice(0, 12)}`; +} + +function shortDate(value: string): string { + return value ? value.slice(0, 10) : '-'; +} + +function status(value: string): string { + return `${esc(value)}`; +} + +function releaseState(release: IndexedRelease): string[] { + const flags = [release.draft ? 'draft' : '', release.prerelease ? 'pre-release' : ''].filter(Boolean); + return flags.length > 0 ? flags : ['published']; +} + +function bodyBlock(body: string): string { + return body ? `
${esc(body)}
` : '

No description indexed.

'; +} + +function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/indexer/index.ts b/src/indexer/index.ts index bbb4925..703169d 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -6,6 +6,8 @@ export { handleApiRequest, startApiServer } from './api.js'; export type { ApiServerOptions } from './api.js'; +export { handleExploreRequest } from './explore.js'; +export type { ExploreResponse } from './explore.js'; export { IndexerCrawler } from './crawler.js'; export type { CrawlOptions, CrawlResult } from './crawler.js'; @@ -14,9 +16,15 @@ export { IndexerStore } from './store.js'; export type { CrawlCursor, IndexedFollow, + IndexedIssue, + IndexedPatch, + IndexedRelease, IndexedRepo, IndexedStar, + IndexerStats, RepoWithStars, SearchResult, + StarredRepo, + UserSearchResult, UserProfile, } from './store.js'; diff --git a/src/indexer/store.ts b/src/indexer/store.ts index f0b93e3..c345054 100644 --- a/src/indexer/store.ts +++ b/src/indexer/store.ts @@ -50,6 +50,100 @@ export type IndexedFollow = { dateCreated : string; }; +/** Indexed issue summary. */ +export type IndexedIssue = { + did : string; + repoRecordId : string; + repoName : string; + recordId : string; + contextId : string; + title : string; + body : string; + status : string; + dateCreated : string; + indexedAt : string; +}; + +/** Indexed patch summary. */ +export type IndexedPatch = { + did : string; + repoRecordId : string; + repoName : string; + recordId : string; + contextId : string; + title : string; + body : string; + status : string; + baseBranch : string; + headBranch : string; + dateCreated : string; + indexedAt : string; +}; + +/** Indexed release summary. */ +export type IndexedRelease = { + did : string; + repoRecordId : string; + repoName : string; + recordId : string; + contextId : string; + tagName : string; + name : string; + body : string; + draft : boolean; + prerelease : boolean; + dateCreated : string; + indexedAt : string; +}; + +/** External issue submission written on the submitter's DWN. */ +export type IndexedIssueSubmission = { + submitterDid : string; + targetDid : string; + targetRepoRecordId : string; + targetRepoName : string; + recordId : string; + contextId : string; + title : string; + body : string; + status : string; + dateCreated : string; + indexedAt : string; +}; + +/** External patch submission written on the submitter's DWN. */ +export type IndexedPatchSubmission = { + submitterDid : string; + targetDid : string; + targetRepoRecordId : string; + targetRepoName : string; + sourceDid : string; + recordId : string; + contextId : string; + title : string; + body : string; + status : string; + baseBranch : string; + headBranch : string; + dateCreated : string; + indexedAt : string; +}; + +/** Owner-side decision for an external submission. */ +export type IndexedSubmissionDecision = { + ownerDid : string; + repoRecordId : string; + repoName : string; + kind : 'issue' | 'patch'; + decision : 'ignored'; + submitterDid : string; + submissionRecordId : string; + submissionContextId : string; + reason : string; + dateCreated : string; + indexedAt : string; +}; + /** Crawl cursor — tracks progress per DID for incremental crawling. */ export type CrawlCursor = { did : string; @@ -62,6 +156,9 @@ export type RepoWithStars = IndexedRepo & { starCount: number }; /** Search result with relevance score. */ export type SearchResult = RepoWithStars & { score: number }; +/** Star record joined with the indexed repo when available. */ +export type StarredRepo = IndexedStar & { repo?: RepoWithStars }; + /** User profile summary. */ export type UserProfile = { did : string; @@ -71,6 +168,23 @@ export type UserProfile = { followingCount : number; }; +/** User search result with relevance score. */ +export type UserSearchResult = UserProfile & { score: number }; + +/** Aggregate store counts. */ +export type IndexerStats = { + dids : number; + repos : number; + stars : number; + follows : number; + issues : number; + patches : number; + releases : number; + issueSubmissions : number; + patchSubmissions : number; + submissionDecisions : number; +}; + // --------------------------------------------------------------------------- // Size limits // --------------------------------------------------------------------------- @@ -85,13 +199,31 @@ export type IndexerStoreLimits = { maxStars? : number; /** Maximum number of indexed follows. @default 500_000 */ maxFollows? : number; + /** Maximum number of indexed issues. @default 500_000 */ + maxIssues? : number; + /** Maximum number of indexed patches. @default 500_000 */ + maxPatches? : number; + /** Maximum number of indexed releases. @default 250_000 */ + maxReleases? : number; + /** Maximum number of indexed external issue submissions. @default 500_000 */ + maxIssueSubmissions? : number; + /** Maximum number of indexed external patch submissions. @default 500_000 */ + maxPatchSubmissions? : number; + /** Maximum number of indexed owner-side submission decisions. @default 500_000 */ + maxSubmissionDecisions? : number; }; const DEFAULT_LIMITS: Required = { - maxDids : 100_000, - maxRepos : 100_000, - maxStars : 500_000, - maxFollows : 500_000, + maxDids : 100_000, + maxRepos : 100_000, + maxStars : 500_000, + maxFollows : 500_000, + maxIssues : 500_000, + maxPatches : 500_000, + maxReleases : 250_000, + maxIssueSubmissions : 500_000, + maxPatchSubmissions : 500_000, + maxSubmissionDecisions : 500_000, }; // --------------------------------------------------------------------------- @@ -109,6 +241,24 @@ export class IndexerStore { /** Follows keyed by `followerDid:targetDid`. */ private _follows = new Map(); + /** Issues keyed by `did:repoRecordId:recordId`. */ + private _issues = new Map(); + + /** Patches keyed by `did:repoRecordId:recordId`. */ + private _patches = new Map(); + + /** Releases keyed by `did:repoRecordId:recordId`. */ + private _releases = new Map(); + + /** External issue submissions keyed by `targetDid:targetRepoRecordId:submitterDid:recordId`. */ + private _issueSubmissions = new Map(); + + /** External patch submissions keyed by `targetDid:targetRepoRecordId:submitterDid:recordId`. */ + private _patchSubmissions = new Map(); + + /** Owner-side submission decisions keyed by `ownerDid:repoRecordId:kind:submitterDid:recordId`. */ + private _submissionDecisions = new Map(); + /** Known DIDs to crawl. */ private _dids = new Set(); @@ -146,6 +296,23 @@ export class IndexerStore { return [...this._dids]; } + /** Get every DID currently visible in indexed repos, stars, and follows. */ + public getKnownDids(): string[] { + const dids = new Set(this._dids); + for (const repo of this._repos.values()) { + dids.add(repo.did); + } + for (const star of this._stars.values()) { + dids.add(star.starrerDid); + dids.add(star.repoDid); + } + for (const follow of this._follows.values()) { + dids.add(follow.followerDid); + dids.add(follow.targetDid); + } + return [...dids]; + } + /** Get the crawl cursor for a DID. */ public getCursor(did: string): CrawlCursor | undefined { return this._cursors.get(did); @@ -179,11 +346,190 @@ export class IndexerStore { return undefined; } + /** Get an indexed repo by owner DID and repo name. */ + public getRepoByName(did: string, name: string): IndexedRepo | undefined { + for (const repo of this._repos.values()) { + if (repo.did === did && repo.name === name) { return repo; } + } + return undefined; + } + + /** Get an indexed repo by owner DID and record ID. */ + public getRepoByRecord(did: string, recordId: string): IndexedRepo | undefined { + return this._repos.get(`${did}:${recordId}`); + } + /** Get all indexed repos. */ public getAllRepos(): IndexedRepo[] { return [...this._repos.values()]; } + // ----------------------------------------------------------------------- + // Work item operations + // ----------------------------------------------------------------------- + + /** Upsert an indexed issue summary. Evicts oldest entry if at capacity. */ + public putIssue(issue: IndexedIssue): void { + const key = workItemKey(issue.did, issue.repoRecordId, issue.recordId); + if (!this._issues.has(key) && this._issues.size >= this._limits.maxIssues) { + const oldest = this._issues.keys().next().value; + if (oldest !== undefined) { this._issues.delete(oldest); } + } + this._issues.set(key, issue); + this.addDid(issue.did); + } + + /** List issue summaries for a repo, newest first. */ + public getIssuesForRepo(did: string, repoRecordId: string): IndexedIssue[] { + return [...this._issues.values()] + .filter((issue) => issue.did === did && issue.repoRecordId === repoRecordId) + .sort(newestFirst); + } + + /** Get one issue summary by repo and record ID. */ + public getIssueByRecord(did: string, repoRecordId: string, recordId: string): IndexedIssue | undefined { + return this._issues.get(workItemKey(did, repoRecordId, recordId)); + } + + /** Upsert an indexed patch summary. Evicts oldest entry if at capacity. */ + public putPatch(patch: IndexedPatch): void { + const key = workItemKey(patch.did, patch.repoRecordId, patch.recordId); + if (!this._patches.has(key) && this._patches.size >= this._limits.maxPatches) { + const oldest = this._patches.keys().next().value; + if (oldest !== undefined) { this._patches.delete(oldest); } + } + this._patches.set(key, patch); + this.addDid(patch.did); + } + + /** List patch summaries for a repo, newest first. */ + public getPatchesForRepo(did: string, repoRecordId: string): IndexedPatch[] { + return [...this._patches.values()] + .filter((patch) => patch.did === did && patch.repoRecordId === repoRecordId) + .sort(newestFirst); + } + + /** Get one patch summary by repo and record ID. */ + public getPatchByRecord(did: string, repoRecordId: string, recordId: string): IndexedPatch | undefined { + return this._patches.get(workItemKey(did, repoRecordId, recordId)); + } + + /** Upsert an indexed release summary. Evicts oldest entry if at capacity. */ + public putRelease(release: IndexedRelease): void { + const key = workItemKey(release.did, release.repoRecordId, release.recordId); + if (!this._releases.has(key) && this._releases.size >= this._limits.maxReleases) { + const oldest = this._releases.keys().next().value; + if (oldest !== undefined) { this._releases.delete(oldest); } + } + this._releases.set(key, release); + this.addDid(release.did); + } + + /** List release summaries for a repo, newest first. */ + public getReleasesForRepo(did: string, repoRecordId: string): IndexedRelease[] { + return [...this._releases.values()] + .filter((release) => release.did === did && release.repoRecordId === repoRecordId) + .sort(newestFirst); + } + + /** Get one release summary by repo and record ID. */ + public getReleaseByRecord(did: string, repoRecordId: string, recordId: string): IndexedRelease | undefined { + return this._releases.get(workItemKey(did, repoRecordId, recordId)); + } + + /** Upsert an external issue submission. Evicts oldest entry if at capacity. */ + public putIssueSubmission(submission: IndexedIssueSubmission): void { + const key = submissionKey( + submission.targetDid, + submission.targetRepoRecordId, + submission.submitterDid, + submission.recordId, + ); + if (!this._issueSubmissions.has(key) && this._issueSubmissions.size >= this._limits.maxIssueSubmissions) { + const oldest = this._issueSubmissions.keys().next().value; + if (oldest !== undefined) { this._issueSubmissions.delete(oldest); } + } + this._issueSubmissions.set(key, submission); + this.addDid(submission.submitterDid); + this.addDid(submission.targetDid); + } + + /** List external issue submissions for a repo, newest first. */ + public getIssueSubmissionsForRepo(did: string, repoRecordId: string): IndexedIssueSubmission[] { + return [...this._issueSubmissions.values()] + .filter((submission) => submission.targetDid === did && submission.targetRepoRecordId === repoRecordId) + .filter((submission) => !this.hasSubmissionDecision('issue', did, repoRecordId, submission.submitterDid, submission.recordId)) + .sort(newestFirst); + } + + /** Get one active external issue submission by target repo and record ID. */ + public getIssueSubmissionByRecord(did: string, repoRecordId: string, recordId: string): IndexedIssueSubmission | undefined { + return this.getIssueSubmissionsForRepo(did, repoRecordId) + .find((submission) => submission.recordId === recordId); + } + + /** Upsert an external patch submission. Evicts oldest entry if at capacity. */ + public putPatchSubmission(submission: IndexedPatchSubmission): void { + const key = submissionKey( + submission.targetDid, + submission.targetRepoRecordId, + submission.submitterDid, + submission.recordId, + ); + if (!this._patchSubmissions.has(key) && this._patchSubmissions.size >= this._limits.maxPatchSubmissions) { + const oldest = this._patchSubmissions.keys().next().value; + if (oldest !== undefined) { this._patchSubmissions.delete(oldest); } + } + this._patchSubmissions.set(key, submission); + this.addDid(submission.submitterDid); + this.addDid(submission.targetDid); + } + + /** List external patch submissions for a repo, newest first. */ + public getPatchSubmissionsForRepo(did: string, repoRecordId: string): IndexedPatchSubmission[] { + return [...this._patchSubmissions.values()] + .filter((submission) => submission.targetDid === did && submission.targetRepoRecordId === repoRecordId) + .filter((submission) => !this.hasSubmissionDecision('patch', did, repoRecordId, submission.submitterDid, submission.recordId)) + .sort(newestFirst); + } + + /** Get one active external patch submission by target repo and record ID. */ + public getPatchSubmissionByRecord(did: string, repoRecordId: string, recordId: string): IndexedPatchSubmission | undefined { + return this.getPatchSubmissionsForRepo(did, repoRecordId) + .find((submission) => submission.recordId === recordId); + } + + /** Upsert an owner-side decision for an external submission. */ + public putSubmissionDecision(decision: IndexedSubmissionDecision): void { + const key = submissionDecisionKey( + decision.ownerDid, + decision.repoRecordId, + decision.kind, + decision.submitterDid, + decision.submissionRecordId, + ); + if (!this._submissionDecisions.has(key) && this._submissionDecisions.size >= this._limits.maxSubmissionDecisions) { + const oldest = this._submissionDecisions.keys().next().value; + if (oldest !== undefined) { this._submissionDecisions.delete(oldest); } + } + this._submissionDecisions.set(key, decision); + this.addDid(decision.ownerDid); + this.addDid(decision.submitterDid); + } + + /** Return whether an external submission has an owner-side decision. */ + public hasSubmissionDecision( + kind: 'issue' | 'patch', + ownerDid: string, + repoRecordId: string, + submitterDid: string, + submissionRecordId: string, + ): boolean { + return this._submissionDecisions.has( + submissionDecisionKey(ownerDid, repoRecordId, kind, submitterDid, submissionRecordId), + ); + } + // ----------------------------------------------------------------------- // Star operations // ----------------------------------------------------------------------- @@ -231,6 +577,18 @@ export class IndexerStore { return result; } + /** Get starred repos for a user, joined with repo metadata when indexed. */ + public getStarredReposByUser(did: string): StarredRepo[] { + return this.getStarredByUser(did).map((star) => { + const repo = this.getRepoByRecord(star.repoDid, star.repoRecordId); + if (!repo) { return star; } + return { + ...star, + repo: { ...repo, starCount: this.getStarCount(repo.did, repo.recordId) }, + }; + }); + } + // ----------------------------------------------------------------------- // Follow operations // ----------------------------------------------------------------------- @@ -261,6 +619,15 @@ export class IndexerStore { return count; } + /** Get follow records where the user is the target. */ + public getFollowersForUser(did: string): IndexedFollow[] { + const result: IndexedFollow[] = []; + for (const f of this._follows.values()) { + if (f.targetDid === did) { result.push(f); } + } + return result; + } + /** Get following count for a user. */ public getFollowingCount(did: string): number { let count = 0; @@ -270,6 +637,15 @@ export class IndexerStore { return count; } + /** Get follow records created by the user. */ + public getFollowingForUser(did: string): IndexedFollow[] { + const result: IndexedFollow[] = []; + for (const f of this._follows.values()) { + if (f.followerDid === did) { result.push(f); } + } + return result; + } + // ----------------------------------------------------------------------- // Aggregation queries // ----------------------------------------------------------------------- @@ -281,6 +657,11 @@ export class IndexerStore { .sort((a, b) => b.starCount - a.starCount); } + /** Get repos owned by a DID, sorted by star count descending. */ + public getReposForDid(did: string): RepoWithStars[] { + return this.getReposWithStars().filter((r) => r.did === did); + } + /** * Trending repos — sorted by recent star activity. * @@ -369,6 +750,35 @@ export class IndexerStore { }; } + /** + * Search known DIDs. + * + * Returns profiles sorted by DID match strength, then by visible activity. + */ + public searchUsers(query: string, limit: number = 50): UserSearchResult[] { + const q = query.toLowerCase(); + const results: UserSearchResult[] = []; + + for (const did of this.getKnownDids()) { + const normalized = did.toLowerCase(); + let score = 0; + + if (normalized === q) { score += 10; } + else if (normalized.startsWith(q)) { score += 5; } + else if (normalized.includes(q)) { score += 3; } + + if (score === 0) { continue; } + + const profile = this.getUserProfile(did); + const activity = profile.repoCount + profile.starCount + profile.followerCount + profile.followingCount; + results.push({ ...profile, score: score + Math.min(activity * 0.1, 5) }); + } + + return results + .sort((a, b) => b.score - a.score || a.did.localeCompare(b.did)) + .slice(0, limit); + } + /** * List repos by language, sorted by star count. */ @@ -388,12 +798,18 @@ export class IndexerStore { } /** Get store statistics. */ - public getStats(): { dids: number; repos: number; stars: number; follows: number } { + public getStats(): IndexerStats { return { - dids : this._dids.size, - repos : this._repos.size, - stars : this._stars.size, - follows : this._follows.size, + dids : this._dids.size, + repos : this._repos.size, + stars : this._stars.size, + follows : this._follows.size, + issues : this._issues.size, + patches : this._patches.size, + releases : this._releases.size, + issueSubmissions : this._issueSubmissions.size, + patchSubmissions : this._patchSubmissions.size, + submissionDecisions : this._submissionDecisions.size, }; } @@ -402,7 +818,35 @@ export class IndexerStore { this._repos.clear(); this._stars.clear(); this._follows.clear(); + this._issues.clear(); + this._patches.clear(); + this._releases.clear(); + this._issueSubmissions.clear(); + this._patchSubmissions.clear(); + this._submissionDecisions.clear(); this._dids.clear(); this._cursors.clear(); } } + +function newestFirst(a: T, b: T): number { + return b.dateCreated.localeCompare(a.dateCreated); +} + +function workItemKey(did: string, repoRecordId: string, recordId: string): string { + return `${did}:${repoRecordId}:${recordId}`; +} + +function submissionKey(targetDid: string, repoRecordId: string, submitterDid: string, recordId: string): string { + return `${targetDid}:${repoRecordId}:${submitterDid}:${recordId}`; +} + +function submissionDecisionKey( + ownerDid: string, + repoRecordId: string, + kind: 'issue' | 'patch', + submitterDid: string, + recordId: string, +): string { + return `${ownerDid}:${repoRecordId}:${kind}:${submitterDid}:${recordId}`; +} diff --git a/src/issues.ts b/src/issues.ts index 51d749f..0622e72 100644 --- a/src/issues.ts +++ b/src/issues.ts @@ -30,7 +30,7 @@ export type CommentData = { body: string; }; -/** Data shape for a reaction on a comment. */ +/** Data shape for a reaction on an issue or comment. */ export type ReactionData = { emoji: string; }; @@ -52,6 +52,28 @@ export type AssignmentData = { alias? : string; }; +/** Data shape for an issue dependency edge. */ +export type IssueDependencyData = { + /** GitHub-style numeric id of the issue that blocks the parent issue. */ + issueId : number; +}; + +/** Data shape for an ordered sub-issue edge. */ +export type IssueSubIssueData = { + /** GitHub-style numeric id of the child issue. */ + issueId : number; + /** 1-based ordering position under the parent issue. */ + priority : number; +}; + +/** Data shape for a custom issue field value. */ +export type IssueFieldValueData = { + /** GitHub-style numeric id of the custom issue field. */ + fieldId : number; + dataType : 'text' | 'single_select' | 'number' | 'date' | 'multi_select'; + value : string | number | string[]; +}; + // --------------------------------------------------------------------------- // Schema map // --------------------------------------------------------------------------- @@ -64,6 +86,9 @@ export type ForgeIssuesSchemaMap = { label : LabelData; statusChange : StatusChangeData; assignment : AssignmentData; + issueDependency : IssueDependencyData; + subIssue : IssueSubIssueData; + issueFieldValue : IssueFieldValueData; }; // --------------------------------------------------------------------------- @@ -101,6 +126,18 @@ export const ForgeIssuesDefinition = { schema : 'https://enbox.org/schemas/forge/assignment', dataFormats : ['application/json'], }, + issueDependency: { + schema : 'https://enbox.org/schemas/forge/issue-dependency', + dataFormats : ['application/json'], + }, + subIssue: { + schema : 'https://enbox.org/schemas/forge/issue-sub-issue', + dataFormats : ['application/json'], + }, + issueFieldValue: { + schema : 'https://enbox.org/schemas/forge/issue-field-value', + dataFormats : ['application/json'], + }, }, structure: { repo: { @@ -108,7 +145,7 @@ export const ForgeIssuesDefinition = { issue: { $actions: [ - { who: 'anyone', can: ['create', 'read'] }, + { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'read', 'co-update'] }, @@ -120,13 +157,34 @@ export const ForgeIssuesDefinition = { status : { type: 'string', enum: ['open', 'closed'] }, priority : { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, milestone : { type: 'string' }, + locked : { type: 'string', enum: ['true', 'false'] }, + lockReason : { type: 'string', enum: ['off-topic', 'too heated', 'resolved', 'spam'] }, + repoDid : { type: 'string' }, + repoRecordId : { type: 'string' }, + repoName : { type: 'string' }, + submitterDid : { type: 'string' }, + submissionRecordId : { type: 'string' }, + submissionContextId : { type: 'string' }, + }, + + reaction: { + $actions: [ + { role: 'repo:repo/contributor', can: ['create', 'read', 'delete'] }, + { role: 'repo:repo/maintainer', can: ['create', 'read', 'delete'] }, + ], + $tags: { + $requiredTags : ['emoji'], + $allowUndefinedTags : false, + emoji : { type: 'string', maxLength: 10 }, + }, }, comment: { $actions: [ - { who: 'anyone', can: ['create', 'read'] }, + { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'delete'] }, + { role: 'repo:repo/triager', can: ['create', 'read'] }, { who: 'author', of: 'repo/issue/comment', can: ['create', 'update', 'delete'] }, ], @@ -186,6 +244,46 @@ export const ForgeIssuesDefinition = { assigneeDid : { type: 'string' }, }, }, + + issueDependency: { + $immutable : true, + $actions : [ + { role: 'repo:repo/contributor', can: ['read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'delete'] }, + { role: 'repo:repo/triager', can: ['create', 'delete'] }, + ], + $tags: { + $requiredTags : ['issueId'], + $allowUndefinedTags : false, + issueId : { type: 'string' }, + }, + }, + + subIssue: { + $actions: [ + { role: 'repo:repo/contributor', can: ['read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, + { role: 'repo:repo/triager', can: ['create', 'update', 'delete'] }, + ], + $tags: { + $requiredTags : ['issueId'], + $allowUndefinedTags : false, + issueId : { type: 'string' }, + }, + }, + + issueFieldValue: { + $actions: [ + { role: 'repo:repo/contributor', can: ['read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, + { role: 'repo:repo/triager', can: ['create', 'update', 'delete'] }, + ], + $tags: { + $requiredTags : ['fieldId'], + $allowUndefinedTags : false, + fieldId : { type: 'string' }, + }, + }, }, }, }, diff --git a/src/org.ts b/src/org.ts index 077874a..e0c843b 100644 --- a/src/org.ts +++ b/src/org.ts @@ -28,6 +28,14 @@ export type OrgData = { export type OrgMemberData = { did : string; alias? : string; + public? : boolean; +}; + +/** Data shape for a user blocked by an organization. */ +export type OrgBlockedUserData = { + did : string; + blockedAt? : string; + blockedBy? : string; }; /** Data shape for a team within an organization. */ @@ -35,12 +43,86 @@ export type TeamData = { name : string; description? : string; privacy : 'visible' | 'secret'; + repositories? : Record; }; /** Data shape for a team member. */ export type TeamMemberData = { did : string; alias? : string; + role? : 'member' | 'maintainer'; + state? : 'active' | 'pending'; +}; + +/** Data shape for an organization webhook configuration. */ +export type OrgWebhookDeliveryData = { + id : number; + guid : string; + deliveredAt : string; + redelivery : boolean; + duration : number; + status : string; + statusCode : number; + event : string; + action? : string | null; + installationId? : number | null; + repositoryId? : number | null; + throttledAt? : string | null; + request? : { + headers? : Record; + payload? : unknown; + }; + response? : { + headers? : Record; + payload? : unknown; + }; +}; + +export type OrgWebhookData = { + url : string; + secret : string; + events : string[]; + active : boolean; + deliveries? : Record; +}; + +export type OrgIssueFieldOptionData = { + id? : number; + name : string; + description? : string | null; + color : 'gray' | 'blue' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'purple'; + priority : number; +}; + +/** Data shape for an organization-level custom issue field. */ +export type OrgIssueFieldData = { + name : string; + description? : string | null; + dataType : 'text' | 'single_select' | 'number' | 'date' | 'multi_select'; + visibility? : 'organization_members_only' | 'all'; + options? : OrgIssueFieldOptionData[]; +}; + +export type OrgIssueTypeData = { + name : string; + description? : string | null; + color? : 'gray' | 'blue' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'purple' | null; + isEnabled : boolean; +}; + +export type OrgCustomPropertyData = { + propertyName : string; + valueType : 'string' | 'single_select' | 'multi_select' | 'true_false' | 'url'; + required? : boolean; + defaultValue? : string | string[] | null; + description? : string | null; + allowedValues? : string[] | null; + valuesEditableBy? : 'org_actors' | 'org_and_repo_actors' | null; + requireExplicitValues? : boolean; }; // --------------------------------------------------------------------------- @@ -52,8 +134,13 @@ export type ForgeOrgSchemaMap = { org : OrgData; owner : OrgMemberData; member : OrgMemberData; + blockedUser : OrgBlockedUserData; team : TeamData; teamMember : TeamMemberData; + webhook : OrgWebhookData; + issueField : OrgIssueFieldData; + issueType : OrgIssueTypeData; + customProperty : OrgCustomPropertyData; }; // --------------------------------------------------------------------------- @@ -76,6 +163,10 @@ export const ForgeOrgDefinition = { schema : 'https://enbox.org/schemas/forge/org-member', dataFormats : ['application/json'], }, + blockedUser: { + schema : 'https://enbox.org/schemas/forge/org-blocked-user', + dataFormats : ['application/json'], + }, team: { schema : 'https://enbox.org/schemas/forge/team', dataFormats : ['application/json'], @@ -84,6 +175,23 @@ export const ForgeOrgDefinition = { schema : 'https://enbox.org/schemas/forge/team-member', dataFormats : ['application/json'], }, + webhook: { + schema : 'https://enbox.org/schemas/forge/org-webhook', + dataFormats : ['application/json'], + encryptionRequired : true, + }, + issueField: { + schema : 'https://enbox.org/schemas/forge/org-issue-field', + dataFormats : ['application/json'], + }, + issueType: { + schema : 'https://enbox.org/schemas/forge/org-issue-type', + dataFormats : ['application/json'], + }, + customProperty: { + schema : 'https://enbox.org/schemas/forge/org-custom-property', + dataFormats : ['application/json'], + }, }, structure: { org: { @@ -113,6 +221,18 @@ export const ForgeOrgDefinition = { }, }, + blockedUser: { + $actions: [ + { who: 'anyone', can: ['read'] }, + { role: 'org/owner', can: ['create', 'delete'] }, + ], + $tags: { + $requiredTags : ['did'], + $allowUndefinedTags : false, + did : { type: 'string' }, + }, + }, + team: { $actions: [ { who: 'anyone', can: ['read'] }, @@ -132,6 +252,49 @@ export const ForgeOrgDefinition = { }, }, }, + + webhook: { + // Owner-only, encrypted at rest (webhook secrets are sensitive) + }, + + issueField: { + $actions: [ + { who: 'anyone', can: ['read'] }, + { role: 'org/owner', can: ['create', 'update', 'delete'] }, + ], + $tags: { + $requiredTags : ['name', 'dataType'], + $allowUndefinedTags : false, + name : { type: 'string' }, + dataType : { type: 'string', enum: ['text', 'single_select', 'number', 'date', 'multi_select'] }, + }, + }, + + issueType: { + $actions: [ + { who: 'anyone', can: ['read'] }, + { role: 'org/owner', can: ['create', 'update', 'delete'] }, + ], + $tags: { + $requiredTags : ['name', 'isEnabled'], + $allowUndefinedTags : false, + name : { type: 'string' }, + isEnabled : { type: 'boolean' }, + }, + }, + + customProperty: { + $actions: [ + { who: 'anyone', can: ['read'] }, + { role: 'org/owner', can: ['create', 'update', 'delete'] }, + ], + $tags: { + $requiredTags : ['propertyName', 'valueType'], + $allowUndefinedTags : false, + propertyName : { type: 'string' }, + valueType : { type: 'string', enum: ['string', 'single_select', 'multi_select', 'true_false', 'url'] }, + }, + }, }, }, } as const satisfies ProtocolDefinition; diff --git a/src/patches.ts b/src/patches.ts index 5d12a24..3e4c982 100644 --- a/src/patches.ts +++ b/src/patches.ts @@ -121,7 +121,8 @@ export const ForgePatchesDefinition = { patch: { $actions: [ - { who: 'anyone', can: ['create', 'read'] }, + { who: 'anyone', can: ['read'] }, + { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, { who: 'author', of: 'repo/patch', can: ['create', 'update'] }, ], @@ -132,6 +133,12 @@ export const ForgePatchesDefinition = { baseBranch : { type: 'string' }, headBranch : { type: 'string' }, sourceDid : { type: 'string' }, + repoDid : { type: 'string' }, + repoRecordId : { type: 'string' }, + repoName : { type: 'string' }, + submitterDid : { type: 'string' }, + submissionRecordId : { type: 'string' }, + submissionContextId : { type: 'string' }, }, revision: { @@ -169,7 +176,8 @@ export const ForgePatchesDefinition = { review: { $immutable : true, $actions : [ - { who: 'anyone', can: ['create', 'read'] }, + { who: 'anyone', can: ['read'] }, + { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read'] }, ], $tags: { @@ -181,8 +189,10 @@ export const ForgePatchesDefinition = { reviewComment: { $actions: [ - { who: 'anyone', can: ['create', 'read'] }, - { role: 'repo:repo/maintainer', can: ['create', 'read'] }, + { who: 'anyone', can: ['read'] }, + { role: 'repo:repo/contributor', can: ['create', 'read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, + { who: 'author', of: 'repo/patch/review/reviewComment', can: ['create', 'update', 'delete'] }, ], $tags: { $allowUndefinedTags : true, diff --git a/src/releases.ts b/src/releases.ts index b4677d0..f67d812 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,6 +19,15 @@ import { defineProtocol } from '@enbox/api'; export type ReleaseData = { name : string; body? : string; + discussionCategoryName? : string; + publishedAt? : string; + makeLatest? : 'true' | 'false' | 'legacy'; + reactions? : Record; }; /** Data shape for a release asset metadata. */ diff --git a/src/repo.ts b/src/repo.ts index 7dd52ab..c28ef7e 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -2,7 +2,7 @@ * Forge Repository Protocol — foundational protocol for repository management. * * Defines repository metadata, collaborator roles (maintainer, triager, - * contributor), and repo-level resources (readme, license, topics, settings, + * contributor, viewer), and repo-level resources (readme, license, topics, settings, * webhooks). Other forge protocols compose with this via `uses` to leverage * role-based authorization. * @@ -25,9 +25,26 @@ export type RepoData = { homepage? : string; dwnEndpoints : string[]; gitEndpoints? : string[]; + hasIssues? : boolean; + hasProjects? : boolean; + hasWiki? : boolean; + hasDownloads? : boolean; + hasPullRequests? : boolean; + isTemplate? : boolean; + allowSquashMerge? : boolean; + allowMergeCommit? : boolean; + allowRebaseMerge? : boolean; + allowAutoMerge? : boolean; + allowForking? : boolean; + deleteBranchOnMerge? : boolean; + webCommitSignoffRequired? : boolean; + pullRequestCreationPolicy? : 'all' | 'collaborators_only'; + forkedFromDid? : string; + forkedFromRepoName? : string; + forkedFromRecordId? : string; }; -/** Data shape for a collaborator role record (maintainer, triager, contributor). */ +/** Data shape for a collaborator role record (maintainer, triager, contributor, viewer). */ export type CollaboratorData = { did : string; alias? : string; @@ -38,19 +55,480 @@ export type TopicData = { name: string; }; +/** Owner-side decision for an external issue or patch submission. */ +export type SubmissionDecisionData = { + kind : 'issue' | 'patch'; + decision : 'ignored'; + submitterDid : string; + submissionRecordId : string; + submissionContextId? : string; + reason? : string; + decidedBy : string; + decidedAt : string; +}; + +export type RepositoryRulesetStateData = { + id: number; + name: string; + target: 'branch' | 'tag' | 'push'; + enforcement: 'disabled' | 'active' | 'evaluate'; + bypassActors?: Record[]; + conditions?: Record; + rules: Array & { type: string }>; + versionId: number; + createdAt: string; + updatedAt: string; +}; + +export type RepositoryRulesetData = RepositoryRulesetStateData & { + history?: Record; +}; + +export type RepositoryRuleSuiteData = { + id: number; + actorId?: number; + actorName: string; + beforeSha: string; + afterSha: string; + ref: string; + repositoryId?: number; + repositoryName?: string; + pushedAt: string; + result: 'pass' | 'fail' | 'bypass'; + evaluationResult?: 'pass' | 'fail' | 'bypass' | null; + ruleEvaluations?: Array<{ + ruleSource: Record & { type: string; id?: number; name?: string }; + enforcement: 'disabled' | 'active' | 'evaluate'; + result: 'pass' | 'fail' | 'bypass'; + ruleType: string; + details?: string; + }>; +}; + +export type RepositoryCustomPropertyValue = string | string[]; + +export type RepositoryAttestationData = { + id: number; + subjectDigest: string; + predicateType?: string; + bundle: Record; + createdAt: string; +}; + +export type RepositoryIssueTypeData = { + id: number; + name: string; + description?: string | null; + color?: string | null; + isEnabled?: boolean; + createdAt: string; + updatedAt: string; +}; + +export type RepositoryTransferRequestData = { + id: number; + newOwner: string; + newName?: string; + teamIds?: number[]; + requestedBy: string; + createdAt: string; +}; + +export type CodeScanningInstanceData = { + ref: string; + analysisKey: string; + category: string; + environment?: string; + state: 'open' | 'closed' | 'dismissed' | 'fixed'; + commitSha: string; + message?: Record; + location?: Record; + classifications?: string[]; +}; + +export type CodeScanningAlertData = { + number: number; + state: 'open' | 'closed' | 'dismissed' | 'fixed'; + rule: Record & { id: string; severity?: string; name?: string; description?: string }; + tool: Record & { name: string; guid?: string | null; version?: string | null }; + mostRecentInstance: CodeScanningInstanceData; + instances?: CodeScanningInstanceData[]; + createdAt: string; + updatedAt: string; + dismissedAt?: string | null; + dismissedBy?: string | null; + dismissedReason?: 'false positive' | 'won\'t fix' | 'used in tests' | null; + dismissedComment?: string | null; + fixedAt?: string | null; + assignees?: string[]; +}; + +export type SecretScanningLocationData = { + type: string; + details: Record; +}; + +export type SecretScanningScanData = { + type: string; + status: string; + startedAt?: string; + completedAt?: string; + patternSlug?: string; + patternScope?: string; +}; + +export type SecretScanningScanHistoryData = { + incrementalScans?: SecretScanningScanData[]; + backfillScans?: SecretScanningScanData[]; + patternUpdateScans?: SecretScanningScanData[]; + customPatternBackfillScans?: SecretScanningScanData[]; + genericSecretsBackfillScans?: SecretScanningScanData[]; +}; + +export type SecretScanningPushProtectionBypassData = { + placeholderId: string; + tokenType: string; + reason?: 'false_positive' | 'used_in_tests' | 'will_fix_later'; + expireAt?: string; + createdAt?: string; + createdBy?: string; + alertNumber?: number; +}; + +export type SecretScanningAlertData = { + number: number; + state: 'open' | 'resolved'; + secretType: string; + secretTypeDisplayName?: string; + secret: string; + provider?: string; + providerSlug?: string; + createdAt: string; + updatedAt: string; + resolution?: 'false_positive' | 'wont_fix' | 'revoked' | 'pattern_edited' | 'pattern_deleted' | 'used_in_tests' | null; + resolvedAt?: string | null; + resolvedBy?: string | null; + resolutionComment?: string | null; + validity?: 'active' | 'inactive' | 'unknown'; + publiclyLeaked?: boolean; + multiRepo?: boolean; + isBase64Encoded?: boolean; + pushProtectionBypassed?: boolean; + pushProtectionBypassedBy?: string | null; + pushProtectionBypassedAt?: string | null; + pushProtectionBypassRequestReviewer?: string | null; + pushProtectionBypassRequestReviewerComment?: string | null; + pushProtectionBypassRequestComment?: string | null; + pushProtectionBypassRequestHtmlUrl?: string | null; + firstLocationDetected?: Record | null; + hasMoreLocations?: boolean; + assignedTo?: string | null; + locations?: SecretScanningLocationData[]; +}; + +export type RepositorySecurityAdvisoryData = { + ghsaId: string; + cveId?: string | null; + summary: string; + description: string; + severity?: 'critical' | 'high' | 'medium' | 'low' | 'unknown'; + state: 'triage' | 'draft' | 'published' | 'closed'; + authorDid: string; + publisherDid?: string | null; + createdAt: string; + updatedAt: string; + publishedAt?: string | null; + closedAt?: string | null; + withdrawnAt?: string | null; + submission?: Record | null; + vulnerabilities?: Array>; + cvssSeverities?: Record; + cweIds?: string[]; + cwes?: Array>; + credits?: Array>; + creditsDetailed?: Array>; + collaboratingUsers?: string[]; + collaboratingTeams?: Array>; + privateFork?: Record | null; + cveRequestedAt?: string | null; +}; + +export type DependabotAlertData = { + number: number; + state: 'auto_dismissed' | 'dismissed' | 'fixed' | 'open'; + dependency: { + package: { + ecosystem: string; + name: string; + }; + manifestPath: string; + scope?: 'development' | 'runtime' | string; + }; + securityAdvisory: Record; + securityVulnerability?: Record; + createdAt: string; + updatedAt: string; + dismissedAt?: string | null; + dismissedBy?: string | null; + dismissedReason?: 'fix_started' | 'inaccurate' | 'no_bandwidth' | 'not_used' | 'tolerable_risk' | null; + dismissedComment?: string | null; + fixedAt?: string | null; + assignees?: string[]; +}; + +export type BranchProtectionRestrictionsData = { + apps: string[]; + teams: string[]; + users: string[]; +}; + +export type BranchProtectionRuleData = { + allowDeletions?: boolean; + allowForcePushes?: boolean; + allowForkSyncing?: boolean; + blockCreations?: boolean; + dismissStaleReviews?: boolean; + reviewBypassAllowances?: BranchProtectionRestrictionsData; + reviewDismissalRestrictions?: BranchProtectionRestrictionsData; + enforceAdmins?: boolean; + lockBranch?: boolean; + restrictions?: BranchProtectionRestrictionsData; + requiredConversationResolution?: boolean; + requiredChecksStrict?: boolean; + requiredLinearHistory?: boolean; + requireCodeOwnerReviews?: boolean; + requireLastPushApproval?: boolean; + requiredSignatures?: boolean; + requiredReviews?: number; + requiredCheckApps?: Record; + requiredChecks?: string[]; +}; + /** Data shape for repository settings. */ export type SettingsData = { - branchProtection? : Record; + branchProtection? : Record; + labels? : Record; + milestones? : Record; + commitComments? : Record; + deployments? : Record; + }>; + environments? : Record; + deploymentBranchPolicy?: { + protectedBranches: boolean; + customBranchPolicies: boolean; + } | null; + variables?: Record; + secrets?: Record; + }>; + deployKeys? : Record; + autolinks? : Record; + interactionLimit? : { + limit: 'existing_users' | 'contributors_only' | 'collaborators_only'; + expiresAt: string; + }; + vulnerabilityAlertsEnabled? : boolean; + automatedSecurityFixesEnabled? : boolean; + automatedSecurityFixesPaused? : boolean; + privateVulnerabilityReportingEnabled? : boolean; + immutableReleasesEnabled? : boolean; + customProperties? : Record; + attestations? : Record; + issueTypes? : Record; + codeScanningAlerts? : Record; + dependabotAlerts? : Record; + secretScanningAlerts? : Record; + secretScanningScanHistory? : SecretScanningScanHistoryData; + secretScanningPushProtectionBypasses? : Record; + securityAdvisories? : Record; + transferRequest? : RepositoryTransferRequestData; + rulesets? : Record; + ruleSuites? : Record; + actionsWorkflows? : Record; + actionsVariables? : Record; + actionsSecrets? : Record; + actionsCaches? : Record; + actionsCacheRetentionLimitDays? : number; + actionsCacheStorageLimitGb? : number; + actionsPermissions? : { + enabled: boolean; + allowedActions: 'all' | 'local_only' | 'selected'; + shaPinningRequired: boolean; + selectedActions: { + githubOwnedAllowed: boolean; + verifiedAllowed: boolean; + patternsAllowed: string[]; + }; + defaultWorkflowPermissions: 'read' | 'write'; + canApprovePullRequestReviews: boolean; + }; + pages? : { + status: 'queued' | 'building' | 'built' | 'errored'; + cname: string | null; + custom404: boolean; + source: { branch: string; path: '/' | '/docs' } | null; + buildType: 'legacy' | 'workflow'; + public: boolean; + httpsEnforced: boolean; + createdAt: string; + updatedAt: string; + builds?: Record; + deployments?: Record; + }; mergeStrategies? : ('merge' | 'squash' | 'rebase')[]; autoDeleteBranch? : boolean; }; /** Data shape for a webhook configuration. */ +export type WebhookDeliveryData = { + id : number; + guid : string; + deliveredAt : string; + redelivery : boolean; + duration : number; + status : string; + statusCode : number; + event : string; + action? : string | null; + installationId? : number | null; + repositoryId? : number | null; + throttledAt? : string | null; + request? : { + headers? : Record; + payload? : unknown; + }; + response? : { + headers? : Record; + payload? : unknown; + }; +}; + export type WebhookData = { url : string; secret : string; events : string[]; active : boolean; + deliveries? : Record; }; /** @@ -75,7 +553,9 @@ export type ForgeRepoSchemaMap = { maintainer : CollaboratorData; triager : CollaboratorData; contributor : CollaboratorData; + viewer : CollaboratorData; topic : TopicData; + submissionDecision : SubmissionDecisionData; webhook : WebhookData; }; @@ -113,10 +593,18 @@ export const ForgeRepoDefinition = { schema : 'https://enbox.org/schemas/forge/collaborator', dataFormats : ['application/json'], }, + viewer: { + schema : 'https://enbox.org/schemas/forge/collaborator', + dataFormats : ['application/json'], + }, topic: { schema : 'https://enbox.org/schemas/forge/topic', dataFormats : ['application/json'], }, + submissionDecision: { + schema : 'https://enbox.org/schemas/forge/submission-decision', + dataFormats : ['application/json'], + }, bundle: { dataFormats: ['application/x-git-bundle'], }, @@ -137,6 +625,9 @@ export const ForgeRepoDefinition = { defaultBranch : { type: 'string' }, language : { type: 'string' }, archived : { type: 'boolean' }, + forkedFromDid : { type: 'string' }, + forkedFromRepoName : { type: 'string' }, + forkedFromRecordId : { type: 'string' }, }, maintainer: { @@ -169,6 +660,16 @@ export const ForgeRepoDefinition = { }, }, + viewer: { + $role : true, + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['did'], + $allowUndefinedTags : false, + did : { type: 'string' }, + }, + }, + bundle: { $squash : true, $actions : [ @@ -210,6 +711,23 @@ export const ForgeRepoDefinition = { }, }, + submissionDecision: { + $immutable : true, + $actions : [ + { who: 'anyone', can: ['read'] }, + { role: 'repo/maintainer', can: ['create'] }, + ], + $tags: { + $requiredTags : ['kind', 'decision', 'submitterDid', 'submissionRecordId'], + $allowUndefinedTags : false, + kind : { type: 'string', enum: ['issue', 'patch'] }, + decision : { type: 'string', enum: ['ignored'] }, + submitterDid : { type: 'string' }, + submissionRecordId : { type: 'string' }, + submissionContextId : { type: 'string' }, + }, + }, + settings: { $recordLimit: { max: 1, strategy: 'reject' }, // Owner-only: no $actions = only the DWN tenant can read/write diff --git a/src/social.ts b/src/social.ts index 46d1a77..83537e7 100644 --- a/src/social.ts +++ b/src/social.ts @@ -21,6 +21,8 @@ export type StarData = { repoDid : string; repoRecordId : string; repoName? : string; + subscribed? : boolean; + ignored? : boolean; }; /** Data shape for a follow record (lives on the follower's DWN). */ @@ -29,13 +31,148 @@ export type FollowData = { alias? : string; }; +/** Data shape for a user blocked by the local actor. */ +export type BlockData = { + targetDid : string; + blockedAt? : string; +}; + +/** Data shape for an SSH public key owned by the local actor. */ +export type SshKeyData = { + title? : string; + key : string; + createdAt? : string; + verified? : boolean; + readOnly? : boolean; +}; + +/** Data shape for an SSH signing public key owned by the local actor. */ +export type SshSigningKeyData = { + title? : string; + key : string; + createdAt? : string; +}; + +/** Email address attached to a GPG public key. */ +export type GpgKeyEmailData = { + email : string; + verified? : boolean; +}; + +/** GPG subkey metadata. */ +export type GpgSubkeyData = { + id? : number; + primaryKeyId? : number; + keyId? : string; + publicKey : string; + emails? : GpgKeyEmailData[]; + canSign? : boolean; + canEncryptComms? : boolean; + canEncryptStorage? : boolean; + canCertify? : boolean; + createdAt? : string; + expiresAt? : string | null; + revoked? : boolean; +}; + +/** Data shape for a GPG public key owned by the local actor. */ +export type GpgKeyData = { + name? : string; + armoredPublicKey : string; + publicKey? : string; + keyId? : string; + emails? : GpgKeyEmailData[]; + subkeys? : GpgSubkeyData[]; + canSign? : boolean; + canEncryptComms? : boolean; + canEncryptStorage? : boolean; + canCertify? : boolean; + createdAt? : string; + expiresAt? : string | null; + revoked? : boolean; +}; + +/** Data shape for an email address owned by the local actor. */ +export type EmailData = { + email : string; + primary? : boolean; + verified? : boolean; + visibility? : 'public' | 'private' | null; + createdAt? : string; +}; + +/** Public profile metadata owned by the local actor. */ +export type ProfileData = { + did : string; + name? : string | null; + email? : string | null; + blog? : string; + twitterUsername? : string | null; + company? : string | null; + location? : string | null; + hireable? : boolean | null; + bio? : string | null; + createdAt? : string; + updatedAt? : string; +}; + +/** Data shape for a social account profile URL owned by the local actor. */ +export type SocialAccountData = { + provider : string; + url : string; + createdAt? : string; +}; + +/** File content embedded in a gist record. */ +export type GistFileData = { + filename : string; + content : string; + type? : string; + language? : string | null; +}; + +/** Data shape for a GitHub-compatible gist owned by the local actor. */ +export type GistData = { + description? : string; + public? : boolean; + files : Record; + forkOfOwnerDid? : string; + forkOfGistId? : string; + forkedAt? : string; + createdAt? : string; + updatedAt? : string; +}; + +/** Data shape for a comment on a GitHub-compatible gist. */ +export type GistCommentData = { + gistId : string; + body : string; + userDid? : string; + createdAt? : string; + updatedAt? : string; +}; + +/** Data shape for a gist star owned by the local actor. */ +export type GistStarData = { + ownerDid : string; + gistId : string; + createdAt? : string; +}; + /** Data shape for an activity feed entry. */ export type ActivityData = { type : string; repoDid? : string; repoRecordId? : string; + repoName? : string; recordId? : string; summary? : string; + ref? : string; + head? : string; + before? : string; + tagName? : string; + public? : boolean; + payload? : Record; }; // --------------------------------------------------------------------------- @@ -46,6 +183,16 @@ export type ActivityData = { export type ForgeSocialSchemaMap = { star : StarData; follow : FollowData; + block : BlockData; + sshKey : SshKeyData; + sshSigningKey : SshSigningKeyData; + gpgKey : GpgKeyData; + email : EmailData; + profile : ProfileData; + socialAccount : SocialAccountData; + gist : GistData; + gistComment : GistCommentData; + gistStar : GistStarData; activity : ActivityData; }; @@ -65,6 +212,46 @@ export const ForgeSocialDefinition = { schema : 'https://enbox.org/schemas/forge/follow', dataFormats : ['application/json'], }, + block: { + schema : 'https://enbox.org/schemas/forge/block', + dataFormats : ['application/json'], + }, + sshKey: { + schema : 'https://enbox.org/schemas/forge/ssh-key', + dataFormats : ['application/json'], + }, + sshSigningKey: { + schema : 'https://enbox.org/schemas/forge/ssh-signing-key', + dataFormats : ['application/json'], + }, + gpgKey: { + schema : 'https://enbox.org/schemas/forge/gpg-key', + dataFormats : ['application/json'], + }, + email: { + schema : 'https://enbox.org/schemas/forge/email', + dataFormats : ['application/json'], + }, + profile: { + schema : 'https://enbox.org/schemas/forge/profile', + dataFormats : ['application/json'], + }, + socialAccount: { + schema : 'https://enbox.org/schemas/forge/social-account', + dataFormats : ['application/json'], + }, + gist: { + schema : 'https://enbox.org/schemas/forge/gist', + dataFormats : ['application/json'], + }, + gistComment: { + schema : 'https://enbox.org/schemas/forge/gist-comment', + dataFormats : ['application/json'], + }, + gistStar: { + schema : 'https://enbox.org/schemas/forge/gist-star', + dataFormats : ['application/json'], + }, activity: { schema : 'https://enbox.org/schemas/forge/activity', dataFormats : ['application/json'], @@ -90,6 +277,99 @@ export const ForgeSocialDefinition = { targetDid : { type: 'string' }, }, }, + block: { + // Blocks live on the BLOCKER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['targetDid'], + $allowUndefinedTags : false, + targetDid : { type: 'string' }, + }, + }, + sshKey: { + // SSH keys live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['key'], + $allowUndefinedTags : false, + key : { type: 'string' }, + }, + }, + sshSigningKey: { + // SSH signing keys live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['key'], + $allowUndefinedTags : false, + key : { type: 'string' }, + }, + }, + gpgKey: { + // GPG keys live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['keyId'], + $allowUndefinedTags : false, + keyId : { type: 'string' }, + }, + }, + email: { + // Email records live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['email'], + $allowUndefinedTags : false, + email : { type: 'string' }, + }, + }, + profile: { + // Profile records live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['did'], + $allowUndefinedTags : false, + did : { type: 'string' }, + }, + }, + socialAccount: { + // Social account records live on the OWNER's DWN — owner-only write, public read + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['url'], + $allowUndefinedTags : false, + url : { type: 'string' }, + }, + }, + gist: { + // Gists live on the OWNER's DWN — public read, owner-only write. + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['visibility'], + $allowUndefinedTags : false, + visibility : { type: 'string', enum: ['public', 'secret'] }, + forkOfOwnerDid : { type: 'string' }, + forkOfGistId : { type: 'string' }, + }, + }, + gistComment: { + // Gist comments live on the COMMENTER's DWN — owner-only write, public read. + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['gistId'], + $allowUndefinedTags : false, + gistId : { type: 'string' }, + }, + }, + gistStar: { + // Gist stars live on the STARRER's DWN — owner-only write, public read. + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['ownerDid', 'gistId'], + $allowUndefinedTags : false, + ownerDid : { type: 'string' }, + gistId : { type: 'string' }, + }, + }, activity: { // Activity feed on the actor's DWN — public read $actions : [{ who: 'anyone', can: ['read'] }], diff --git a/src/web/routes.ts b/src/web/routes.ts index da359b9..04fdf27 100644 --- a/src/web/routes.ts +++ b/src/web/routes.ts @@ -426,9 +426,14 @@ export async function wikiListPage(ctx: AgentContext, targetDid: string, repoNam const from = fromOpt(ctx, targetDid); const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { + return layout('Wiki', repoTitle(repo), '

No wiki pages yet.

', basePath); + } + const { records } = await ctx.wiki.records.query('repo/page' as any, { from, - dateSort: DateSort.CreatedDescending, + filter : { contextId: repo.contextId }, + dateSort : DateSort.CreatedDescending, }); if (records.length === 0) { @@ -468,9 +473,11 @@ export async function wikiDetailPage( const from = fromOpt(ctx, targetDid); const repo = await getRepoRecord(ctx, targetDid, repoName); + if (!repo) { return null; } + const { records } = await ctx.wiki.records.query('repo/page' as any, { from, - filter: { tags: { slug } }, + filter: { contextId: repo.contextId, tags: { slug } }, }); if (records.length === 0) { return null; } diff --git a/tests/bundle-restore.spec.ts b/tests/bundle-restore.spec.ts index 4e75662..a4b114f 100644 --- a/tests/bundle-restore.spec.ts +++ b/tests/bundle-restore.spec.ts @@ -11,6 +11,7 @@ import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; import { existsSync, rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -54,13 +55,10 @@ describe('restoreFromBundles', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Restore Test' }, - }); + identity = await createTestIdentity(agent, 'Restore Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); repoHandle = enbox.using(ForgeRepoProtocol); // Skip encryption: true — the test DID (did:jwk Ed25519) lacks X25519. @@ -114,8 +112,9 @@ describe('restoreFromBundles', () => { const restoredRepoPath = `${RESTORE_PATH}/restored.git`; const result = await restoreFromBundles({ - repo : repoHandle as any, - repoPath : restoredRepoPath, + repo : repoHandle as any, + repoPath : restoredRepoPath, + repoContextId : repoContextId, }); expect(result.success).toBe(true); @@ -130,8 +129,9 @@ describe('restoreFromBundles', () => { const restoredRepoPath = `${RESTORE_PATH}/restored-full.git`; const result = await restoreFromBundles({ - repo : repoHandle as any, - repoPath : restoredRepoPath, + repo : repoHandle as any, + repoPath : restoredRepoPath, + repoContextId : repoContextId, }); expect(result.success).toBe(true); @@ -149,8 +149,9 @@ describe('restoreFromBundles', () => { const clonePath = `${RESTORE_PATH}/restored-clone`; await restoreFromBundles({ - repo : repoHandle as any, - repoPath : restoredRepoPath, + repo : repoHandle as any, + repoPath : restoredRepoPath, + repoContextId : repoContextId, }); // Clone from the restored bare repo to verify content. @@ -175,13 +176,10 @@ describe('restoreFromBundles', () => { const identities = await freshAgent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await freshAgent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Fresh Test' }, - }); + identity = await createTestIdentity(freshAgent, 'Fresh Test'); } - const freshEnbox = Enbox.connect({ agent: freshAgent, connectedDid: identity.did.uri }); + const freshEnbox = new Enbox({ agent: freshAgent, connectedDid: identity.did.uri }); const freshRepo = freshEnbox.using(ForgeRepoProtocol); await freshRepo.configure(); @@ -202,7 +200,7 @@ describe('restoreFromBundles', () => { expect(existsSync(`${RESTORE_PATH}/should-not-exist.git`)).toBe(false); rmSync(freshDataPath, { recursive: true, force: true }); - }); + }, 30_000); it('should restore the tip commit matching the original', async () => { const restoredRepoPath = `${RESTORE_PATH}/restored-tip.git`; @@ -211,8 +209,9 @@ describe('restoreFromBundles', () => { const { stdout: originalTip } = await exec('git rev-parse main', { cwd: repoPath }); const result = await restoreFromBundles({ - repo : repoHandle as any, - repoPath : restoredRepoPath, + repo : repoHandle as any, + repoPath : restoredRepoPath, + repoContextId : repoContextId, }); expect(result.success).toBe(true); diff --git a/tests/bundle-sync.spec.ts b/tests/bundle-sync.spec.ts index dd9150b..683cc4b 100644 --- a/tests/bundle-sync.spec.ts +++ b/tests/bundle-sync.spec.ts @@ -11,6 +11,7 @@ import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { DateSort } from '@enbox/dwn-sdk-js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -186,13 +187,10 @@ describe('createBundleSyncer (DWN integration)', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Bundle Test' }, - }); + identity = await createTestIdentity(agent, 'Bundle Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); repoHandle = enbox.using(ForgeRepoProtocol); // Skip encryption: true — the test DID (did:jwk Ed25519) lacks X25519. @@ -238,7 +236,8 @@ describe('createBundleSyncer (DWN integration)', () => { // Query bundle records. const { records } = await (repoHandle as any).records.query('repo/bundle', { - dateSort: DateSort.CreatedDescending, + filter : { contextId: repoContextId }, + dateSort : DateSort.CreatedDescending, }); expect(records.length).toBe(1); @@ -268,11 +267,11 @@ describe('createBundleSyncer (DWN integration)', () => { // Should now have 2 bundle records: 1 full + 1 incremental. const { records: fullRecords } = await (repoHandle as any).records.query('repo/bundle', { - filter : { tags: { isFull: true } }, + filter : { contextId: repoContextId, tags: { isFull: true } }, dateSort : DateSort.CreatedDescending, }); const { records: incRecords } = await (repoHandle as any).records.query('repo/bundle', { - filter : { tags: { isFull: false } }, + filter : { contextId: repoContextId, tags: { isFull: false } }, dateSort : DateSort.CreatedDescending, }); @@ -304,7 +303,8 @@ describe('createBundleSyncer (DWN integration)', () => { // Query all bundles — squash should have purged older ones. const { records } = await (repoHandle as any).records.query('repo/bundle', { - dateSort: DateSort.CreatedDescending, + filter : { contextId: repoContextId }, + dateSort : DateSort.CreatedDescending, }); // After squash, only the squash bundle (full) should remain. @@ -318,7 +318,7 @@ describe('createBundleSyncer (DWN integration)', () => { it('should store retrievable bundle data', async () => { const { records } = await (repoHandle as any).records.query('repo/bundle', { - filter : { tags: { isFull: true } }, + filter : { contextId: repoContextId, tags: { isFull: true } }, dateSort : DateSort.CreatedDescending, }); diff --git a/tests/cli.spec.ts b/tests/cli.spec.ts index 5fb1ba4..a4114c3 100644 --- a/tests/cli.spec.ts +++ b/tests/cli.spec.ts @@ -1,7 +1,7 @@ /** * CLI command tests — exercises command functions against a real Enbox agent. * - * Uses `Enbox.connect()` to create an ephemeral agent, + * Uses a directly constructed `Enbox` instance with an ephemeral agent, * then tests each command function directly. The agent's data directory * (`__TESTDATA__/cli`) is cleaned before and after the suite. */ @@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -82,6 +83,104 @@ function captureError(fn: () => Promise): Promise<{ errors: string[]; exit })(); } +function fakeJsonRecord( + id: string, + contextId: string, + data: Record, + tags: Record, +): any { + return { + id, + contextId, + dateCreated : '2026-06-22T00:00:00.000Z', + tags, + data : { + json: async () => data, + }, + }; +} + +function fakeBlobRecord( + id: string, + contextId: string, + bytes: Uint8Array, + tags: Record, +): any { + return { + id, + contextId, + dateCreated : '2026-06-22T00:00:00.000Z', + tags, + data : { + blob: async () => new Blob([bytes]), + }, + }; +} + +function matchingContext(records: any[], options?: any): any[] { + const contextId = options?.filter?.contextId; + if (typeof contextId !== 'string') { return records; } + return records.filter((record) => record.contextId === contextId); +} + +function withExternalIssueRecords( + ctx: AgentContext, + submitterDid: string, + records: any[], + commentRecords: any[] = [], + statusChangeRecords: any[] = [], +): AgentContext { + return { + ...ctx, + issues: { + ...ctx.issues, + records: { + ...ctx.issues.records, + query: async (path: string, options?: any) => { + if (options?.from === submitterDid) { + if (path === 'repo/issue') { return { records: matchingContext(records, options) }; } + if (path === 'repo/issue/comment') { return { records: matchingContext(commentRecords, options) }; } + if (path === 'repo/issue/statusChange') { return { records: matchingContext(statusChangeRecords, options) }; } + } + return (ctx.issues.records.query as any)(path, options); + }, + }, + }, + } as unknown as AgentContext; +} + +function withExternalPatchRecords( + ctx: AgentContext, + submitterDid: string, + patchRecords: any[], + revisionRecords: any[], + bundleRecords: any[], + reviewRecords: any[] = [], + reviewCommentRecords: any[] = [], + statusChangeRecords: any[] = [], +): AgentContext { + return { + ...ctx, + patches: { + ...ctx.patches, + records: { + ...ctx.patches.records, + query: async (path: string, options?: any) => { + if (options?.from === submitterDid) { + if (path === 'repo/patch') { return { records: matchingContext(patchRecords, options) }; } + if (path === 'repo/patch/revision') { return { records: revisionRecords }; } + if (path === 'repo/patch/revision/revisionBundle') { return { records: bundleRecords }; } + if (path === 'repo/patch/review') { return { records: matchingContext(reviewRecords, options) }; } + if (path === 'repo/patch/review/reviewComment') { return { records: matchingContext(reviewCommentRecords, options) }; } + if (path === 'repo/patch/statusChange') { return { records: matchingContext(statusChangeRecords, options) }; } + } + return (ctx.patches.records.query as any)(path, options); + }, + }, + }, + } as unknown as AgentContext; +} + // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- @@ -101,17 +200,14 @@ describe('gitd CLI commands', () => { await agent.initialize({ password: 'test-password' }); await agent.start({ password: 'test-password' }); - // Create an identity (Enbox.connect normally does this). + // Create an identity for the directly constructed Enbox instance. const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'CLI Test' }, - }); + identity = await createTestIdentity(agent, 'CLI Test'); } - enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + enbox = new Enbox({ agent, connectedDid: identity.did.uri }); did = identity.did.uri; const repo = enbox.using(ForgeRepoProtocol); @@ -142,7 +238,7 @@ describe('gitd CLI commands', () => { did, repo, refs, issues, patches, ci, releases, registry, social, notifications, wiki, org, enbox, }; - }); + }, 30_000); afterAll(() => { delete process.env.GITD_REPO; @@ -549,6 +645,14 @@ describe('gitd CLI commands', () => { expect(logs.some((l) => l.includes('Added contributor'))).toBe(true); }); + it('should add a viewer collaborator', async () => { + const { repoCommand } = await import('../src/cli/commands/repo.js'); + const logs = await captureLog(() => + repoCommand(ctx, ['add-collaborator', 'did:jwk:viewer456', 'viewer', '--alias', 'Read Only']), + ); + expect(logs.some((l) => l.includes('Added viewer'))).toBe(true); + }); + it('should list collaborators in repo info', async () => { const { repoCommand } = await import('../src/cli/commands/repo.js'); const logs = await captureLog(() => repoCommand(ctx, ['info'])); @@ -673,6 +777,126 @@ describe('gitd CLI commands', () => { expect(exitCode).toBe(1); expect(errors[0]).toContain('not found'); }); + + it('should accept an external issue submission into the repo', async () => { + const { issueCommand } = await import('../src/cli/commands/issue.js'); + const externalDid = 'did:jwk:external-cli-issue'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const repo = repos[0]; + const externalIssue = fakeJsonRecord( + 'external-cli-issue-record', + 'external-cli-issue-context', + { title: 'External CLI bug', body: 'Reported from a submitter DWN.' }, + { + status : 'open', + repoDid : ctx.did, + repoRecordId : repo.id, + repoName : 'my-test-repo', + }, + ); + const externalComment = fakeJsonRecord( + 'external-cli-issue-comment-record', + externalIssue.contextId, + { body: 'I can reproduce this on the latest release.' }, + {}, + ); + const externalStatusChange = fakeJsonRecord( + 'external-cli-issue-status-record', + externalIssue.contextId, + { reason: 'Reporter closed as fixed upstream.' }, + { from: 'open', to: 'closed' }, + ); + + const acceptCtx = withExternalIssueRecords( + ctx, + externalDid, + [externalIssue], + [externalComment], + [externalStatusChange], + ); + const logs = await captureLog(() => issueCommand(acceptCtx, ['accept', externalDid, externalIssue.id])); + const allOutput = logs.join('\n'); + expect(allOutput).toContain('Accepted external issue'); + expect(allOutput).toContain('External CLI bug'); + expect(allOutput).toContain(externalDid); + expect(allOutput).toContain('Copied: 1 comment, 1 status change'); + + const { records: issues } = await ctx.issues.records.query('repo/issue', { + filter: { contextId: repo.contextId }, + }); + const accepted = issues.find( + (record) => (record.tags as Record | undefined)?.submissionRecordId === externalIssue.id, + ); + expect(accepted).toBeDefined(); + const acceptedData = await accepted!.data.json(); + const acceptedTags = accepted!.tags as Record | undefined; + expect(acceptedData.title).toBe('External CLI bug'); + expect(acceptedTags?.submitterDid).toBe(externalDid); + expect(acceptedTags?.submissionRecordId).toBe(externalIssue.id); + expect(acceptedTags?.repoDid).toBeUndefined(); + + const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + filter: { contextId: accepted!.contextId }, + }); + expect(comments).toHaveLength(1); + expect((await comments[0].data.json()).body).toBe('I can reproduce this on the latest release.'); + + const { records: statusChanges } = await ctx.issues.records.query('repo/issue/statusChange' as any, { + filter: { contextId: accepted!.contextId }, + }); + expect(statusChanges).toHaveLength(1); + expect((await statusChanges[0].data.json()).reason).toBe('Reporter closed as fixed upstream.'); + }); + + it('should ignore an external issue submission with an owner-side decision', async () => { + const { issueCommand } = await import('../src/cli/commands/issue.js'); + const externalDid = 'did:jwk:external-cli-issue-ignore'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const repo = repos[0]; + const externalIssue = fakeJsonRecord( + 'external-cli-issue-ignore-record', + 'external-cli-issue-ignore-context', + { title: 'External noisy bug', body: 'Not actionable.' }, + { + status : 'open', + repoDid : ctx.did, + repoRecordId : repo.id, + repoName : 'my-test-repo', + }, + ); + + const ignoreCtx = withExternalIssueRecords(ctx, externalDid, [externalIssue]); + const logs = await captureLog(() => issueCommand( + ignoreCtx, + ['ignore', externalDid, externalIssue.id, '--reason', 'not reproducible'], + )); + const allOutput = logs.join('\n'); + expect(allOutput).toContain('Ignored external issue'); + expect(allOutput).toContain(externalDid); + + const { records: decisions } = await ctx.repo.records.query('repo/submissionDecision' as any, { + filter: { + contextId : repo.contextId, + tags : { + kind : 'issue', + decision : 'ignored', + submitterDid : externalDid, + submissionRecordId : externalIssue.id, + }, + }, + }); + expect(decisions).toHaveLength(1); + const decisionData = await decisions[0].data.json(); + expect(decisionData.reason).toBe('not reproducible'); + expect(decisionData.decidedBy).toBe(ctx.did); + + const secondLogs = await captureLog(() => issueCommand(ignoreCtx, ['ignore', externalDid, externalIssue.id])); + expect(secondLogs.join('\n')).toContain('already ignored'); + }); }); // ========================================================================= @@ -873,6 +1097,182 @@ describe('gitd CLI commands', () => { expect(errors[0]).toContain('not found'); }); + it('should accept an external PR submission with revision bundle into the repo', async () => { + const { prCommand } = await import('../src/cli/commands/pr.js'); + const externalDid = 'did:jwk:external-cli-pr'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const repo = repos[0]; + const externalPatch = fakeJsonRecord( + 'external-cli-patch-record', + 'external-cli-patch-context', + { title: 'External CLI patch', body: 'Patch submitted from another DWN.' }, + { + status : 'open', + baseBranch : 'main', + headBranch : 'external/fix', + sourceDid : externalDid, + repoDid : ctx.did, + repoRecordId : repo.id, + repoName : 'my-test-repo', + }, + ); + const externalRevision = fakeJsonRecord( + 'external-cli-revision-record', + 'external-cli-revision-context', + { + description : 'v1: 1 commit', + diffStat : { additions: 1, deletions: 0, filesChanged: 1 }, + }, + { + headCommit : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + baseCommit : 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + commitCount : 1, + }, + ); + const externalBundle = fakeBlobRecord( + 'external-cli-bundle-record', + 'external-cli-bundle-context', + new Uint8Array([1, 2, 3, 4]), + { + headCommit : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + baseCommit : 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + refCount : 1, + size : 4, + }, + ); + const externalReview = fakeJsonRecord( + 'external-cli-review-record', + externalPatch.contextId, + { body: 'This is ready to land.' }, + { verdict: 'approve', revisionRecordId: externalRevision.id }, + ); + const externalReviewComment = fakeJsonRecord( + 'external-cli-review-comment-record', + externalReview.contextId, + { body: 'This line is the important fix.', diffHunk: '@@ -1 +1 @@' }, + { path: 'src/widget.ts', line: 12, side: 'right' }, + ); + const externalStatusChange = fakeJsonRecord( + 'external-cli-patch-status-record', + externalPatch.contextId, + { reason: 'Submitter marked ready for review.' }, + { from: 'draft', to: 'open' }, + ); + + const acceptCtx = withExternalPatchRecords( + ctx, + externalDid, + [externalPatch], + [externalRevision], + [externalBundle], + [externalReview], + [externalReviewComment], + [externalStatusChange], + ); + const logs = await captureLog(() => prCommand(acceptCtx, ['accept', externalDid, externalPatch.id])); + const allOutput = logs.join('\n'); + expect(allOutput).toContain('Accepted external PR'); + expect(allOutput).toContain('External CLI patch'); + expect(allOutput).toContain('Copied: 1 revision, 1 bundle'); + expect(allOutput).toContain('1 review, 1 review comment, 1 status change'); + + const { records: patches } = await ctx.patches.records.query('repo/patch', { + filter: { contextId: repo.contextId }, + }); + const accepted = patches.find( + (record) => (record.tags as Record | undefined)?.submissionRecordId === externalPatch.id, + ); + expect(accepted).toBeDefined(); + const acceptedData = await accepted!.data.json(); + const acceptedTags = accepted!.tags as Record | undefined; + expect(acceptedData.title).toBe('External CLI patch'); + expect(acceptedTags?.submitterDid).toBe(externalDid); + expect(acceptedTags?.sourceDid).toBe(externalDid); + expect(acceptedTags?.headBranch).toBe('external/fix'); + expect(acceptedTags?.repoDid).toBeUndefined(); + + const { records: revisions } = await ctx.patches.records.query('repo/patch/revision' as any, { + filter: { contextId: accepted!.contextId }, + }); + expect(revisions).toHaveLength(1); + const { records: bundles } = await ctx.patches.records.query('repo/patch/revision/revisionBundle' as any, { + filter: { contextId: revisions[0].contextId }, + }); + expect(bundles).toHaveLength(1); + + const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { + filter: { contextId: accepted!.contextId }, + }); + expect(reviews).toHaveLength(1); + expect((await reviews[0].data.json()).body).toBe('This is ready to land.'); + expect((reviews[0].tags as Record | undefined)?.revisionRecordId).toBe(revisions[0].id); + + const { records: reviewComments } = await ctx.patches.records.query('repo/patch/review/reviewComment' as any, { + filter: { contextId: reviews[0].contextId }, + }); + expect(reviewComments).toHaveLength(1); + expect((await reviewComments[0].data.json()).body).toBe('This line is the important fix.'); + + const { records: statusChanges } = await ctx.patches.records.query('repo/patch/statusChange' as any, { + filter: { contextId: accepted!.contextId }, + }); + expect(statusChanges).toHaveLength(1); + expect((await statusChanges[0].data.json()).reason).toBe('Submitter marked ready for review.'); + }); + + it('should ignore an external PR submission with an owner-side decision', async () => { + const { prCommand } = await import('../src/cli/commands/pr.js'); + const externalDid = 'did:jwk:external-cli-pr-ignore'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const repo = repos[0]; + const externalPatch = fakeJsonRecord( + 'external-cli-patch-ignore-record', + 'external-cli-patch-ignore-context', + { title: 'External noisy patch', body: 'Not the direction for this repo.' }, + { + status : 'open', + baseBranch : 'main', + headBranch : 'external/noisy', + sourceDid : externalDid, + repoDid : ctx.did, + repoRecordId : repo.id, + repoName : 'my-test-repo', + }, + ); + + const ignoreCtx = withExternalPatchRecords(ctx, externalDid, [externalPatch], [], []); + const logs = await captureLog(() => prCommand( + ignoreCtx, + ['ignore', externalDid, externalPatch.id, '--reason', 'out of scope'], + )); + const allOutput = logs.join('\n'); + expect(allOutput).toContain('Ignored external PR'); + expect(allOutput).toContain(externalDid); + + const { records: decisions } = await ctx.repo.records.query('repo/submissionDecision' as any, { + filter: { + contextId : repo.contextId, + tags : { + kind : 'patch', + decision : 'ignored', + submitterDid : externalDid, + submissionRecordId : externalPatch.id, + }, + }, + }); + expect(decisions).toHaveLength(1); + const decisionData = await decisions[0].data.json(); + expect(decisionData.reason).toBe('out of scope'); + expect(decisionData.decidedBy).toBe(ctx.did); + + const secondLogs = await captureLog(() => prCommand(ignoreCtx, ['ignore', externalDid, externalPatch.id])); + expect(secondLogs.join('\n')).toContain('already ignored'); + }); + it('should create PR with revision and bundle from git context', async () => { const { prCommand } = await import('../src/cli/commands/pr.js'); const tmpRepo = resolve('__TESTDATA__/pr-bundle-repo'); @@ -1263,6 +1663,29 @@ describe('gitd CLI commands', () => { // ========================================================================= describe('clone', () => { + it('should infer default clone directory from repo name', async () => { + const { inferCloneDirectory } = await import('../src/cli/commands/clone.js'); + expect(inferCloneDirectory('my-repo', [])).toBe('my-repo'); + }); + + it('should infer explicit clone directory after options', async () => { + const { inferCloneDirectory } = await import('../src/cli/commands/clone.js'); + expect(inferCloneDirectory('my-repo', ['--depth', '1', '--branch', 'main', 'worktree'])).toBe('worktree'); + }); + + it('should not treat clone option values as destination directories', async () => { + const { inferCloneDirectory } = await import('../src/cli/commands/clone.js'); + expect(inferCloneDirectory('my-repo', ['--depth', '1'])).toBe('my-repo'); + expect(inferCloneDirectory('my-repo', ['--reference', '../cache'])).toBe('my-repo'); + }); + + it('should accept the legacy separator before git clone args', async () => { + const { gitCloneArgs, inferCloneDirectory } = await import('../src/cli/commands/clone.js'); + const args = gitCloneArgs(['--', '--depth', '1', 'worktree']); + expect(args).toEqual(['--depth', '1', 'worktree']); + expect(inferCloneDirectory('my-repo', args)).toBe('worktree'); + }); + it('should fail without arguments', async () => { const { cloneCommand } = await import('../src/cli/commands/clone.js'); const { errors, exitCode } = await captureError(() => cloneCommand([])); diff --git a/tests/daemon-lifecycle.spec.ts b/tests/daemon-lifecycle.spec.ts index bb519f2..b7d014f 100644 --- a/tests/daemon-lifecycle.spec.ts +++ b/tests/daemon-lifecycle.spec.ts @@ -41,6 +41,22 @@ describe('lockfile version field', () => { expect(lock!.version).toBeUndefined(); removeLockfile(); }); + + it('should write ownerDid to lockfile when provided', () => { + writeLockfile(9418, '1.0.0', 'did:dht:owner123'); + const lock = readLockfile(); + expect(lock).not.toBeNull(); + expect(lock!.ownerDid).toBe('did:dht:owner123'); + removeLockfile(); + }); + + it('should omit ownerDid when not provided', () => { + writeLockfile(9418, '1.0.0'); + const lock = readLockfile(); + expect(lock).not.toBeNull(); + expect(lock!.ownerDid).toBeUndefined(); + removeLockfile(); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/daemon.spec.ts b/tests/daemon.spec.ts index 9467bbb..178cbfe 100644 --- a/tests/daemon.spec.ts +++ b/tests/daemon.spec.ts @@ -12,6 +12,7 @@ import type { Server } from 'node:http'; import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -85,13 +86,10 @@ describe('Unified daemon', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Daemon Test' }, - }); + identity = await createTestIdentity(agent, 'Daemon Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; testDid = did; @@ -129,7 +127,7 @@ describe('Unified daemon', () => { data : { name: 'daemon-test', description: 'Test repo', defaultBranch: 'main', dwnEndpoints: [] }, tags : { name: 'daemon-test', visibility: 'public' }, }); - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); diff --git a/tests/e2e-collaboration.spec.ts b/tests/e2e-collaboration.spec.ts index db62b0c..7943206 100644 --- a/tests/e2e-collaboration.spec.ts +++ b/tests/e2e-collaboration.spec.ts @@ -28,6 +28,8 @@ import { promisify } from 'node:util'; import { tmpdir } from 'node:os'; import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cachePortableDid } from './helpers/identity.js'; +import { createTestIdentity } from './helpers/identity.js'; import { DataStream } from '@enbox/dwn-sdk-js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -73,8 +75,8 @@ const BOB_CLONE_PATH = `${BASE}/bob-clone`; // `DidDht.create({ publish: true })`. Instead, we: // 1. Create the agent (optionally injecting a shared DWN) // 2. Assign `agent.agentDid` directly with `publish: false` -// 3. Create an identity DID with `did:jwk` (purely local) -// 4. Connect via `Enbox.connect({ agent })` — skips vault flow +// 3. Create an identity DID with offline DID:DHT keys +// 4. Construct `Enbox` directly with the selected identity // // --------------------------------------------------------------------------- @@ -82,6 +84,8 @@ async function createOfflineAgent(dataPath: string): Promise<{ agent: EnboxUserAgent; enbox: InstanceType; did: string; + didDocument: any; + portableDid: any; privateKey: Record; }> { const agent = await EnboxUserAgent.create({ dataPath }); @@ -107,21 +111,27 @@ async function createOfflineAgent(dataPath: string): Promise<{ tenant : agentBearerDid.uri, }); - // Create an identity DID (did:jwk — offline, no network). - const identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: `Test (${dataPath})` }, - didOptions : { algorithm: 'Ed25519' }, - }); + const identity = await createTestIdentity(agent, `Test (${dataPath})`); - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; // Extract the private key for push credential signing. const portableDid = await identity.did.export(); const privateKey = portableDid.privateKeys![0] as Record; - return { agent, enbox, did, privateKey }; + return { + agent, + enbox, + did, + didDocument : identity.did.document, + portableDid : { + uri : portableDid.uri, + document : portableDid.document, + metadata : portableDid.metadata, + }, + privateKey, + }; } // --------------------------------------------------------------------------- @@ -131,6 +141,7 @@ async function createOfflineAgent(dataPath: string): Promise<{ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Alice's state let aliceDid: string; + let aliceDidDocument: any; let alicePrivateKey: Record; let aliceAgent: EnboxUserAgent; let aliceRepo: AgentContext['repo']; @@ -140,6 +151,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Bob's state let bobDid: string; + let bobDidDocument: any; let bobPrivateKey: Record; let bobPatches: AgentContext['patches']; @@ -157,6 +169,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // ----- Alice (maintainer) ----- const alice = await createOfflineAgent(ALICE_DATA); aliceDid = alice.did; + aliceDidDocument = alice.didDocument; alicePrivateKey = alice.privateKey; aliceAgent = alice.agent; @@ -170,8 +183,12 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // ----- Bob (contributor) ----- const bob = await createOfflineAgent(BOB_DATA); bobDid = bob.did; + bobDidDocument = bob.didDocument; bobPrivateKey = bob.privateKey; + await cachePortableDid(alice.agent, bob.portableDid); + await cachePortableDid(bob.agent, alice.portableDid); + // Bob must install ForgeRepoProtocol before ForgePatchesProtocol // because the patches definition `uses` the repo protocol ($ref). const bobRepo = bob.enbox.using(ForgeRepoProtocol); @@ -212,7 +229,9 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const backend = new GitBackend({ basePath: REPOS_PATH }); await backend.initRepo(aliceDid, 'collab-repo'); - const verifySignature = createDidSignatureVerifier(); + const verifySignature = createDidSignatureVerifier({ + didDocuments: [aliceDidDocument, bobDidDocument], + }); const authorizePush = createDwnPushAuthorizer({ repo : aliceRepo, ownerDid : aliceDid, @@ -335,7 +354,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const helper = await credentialHelper(aliceDid, alicePrivateKey); await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: ALICE_CLONE_PATH }); await exec('GIT_TERMINAL_PROMPT=0 git push -u origin main', { cwd: ALICE_CLONE_PATH }); - }); + }, 15_000); it('Phase 1c: Alice\'s commits are in the bare repo', async () => { const repoPath = server.backend.repoPath(aliceDid, 'collab-repo'); @@ -348,7 +367,9 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Wait for async onPushComplete await new Promise((r) => setTimeout(r, 500)); - const { records: refRecords } = await aliceRefs.records.query('repo/ref' as any); + const { records: refRecords } = await aliceRefs.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + }); // Manually sync if timing is tight if (refRecords.length === 0) { @@ -357,13 +378,13 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { await syncer(aliceDid, 'collab-repo', repoPath); } - const { records: finalRefs } = await aliceRefs.records.query('repo/ref' as any); + const { records: finalRefs } = await aliceRefs.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + }); expect(finalRefs.length).toBeGreaterThanOrEqual(1); - const mainRef = finalRefs.find(async (r: any) => { - const d = await r.data.json(); - return d.name === 'refs/heads/main'; - }); + const refEntries = await Promise.all(finalRefs.map(async (r: any) => r.data.json())); + const mainRef = refEntries.find((d: any) => d.name === 'refs/heads/main'); expect(mainRef).toBeDefined(); }); @@ -426,7 +447,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Bob creates records with `store: false` (signed by Bob, not persisted // locally) and then sends them to Alice's DWN via the shared multi-tenant // DWN node. In production this would use `record.send(aliceDid)` over - // HTTP. The protocol allows anyone to create: `{ who: 'anyone', can: ['create'] }`. + // HTTP. Bob invokes the contributor role Alice granted earlier. // ========================================================================= let patchRecordId: string; @@ -478,7 +499,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // targeting Alice's DID on the shared multi-tenant DWN node. // // In production, this would use record.send(aliceDid) over HTTP. - // The protocol allows it: `{ who: 'anyone', can: ['create'] }`. + // Bob must invoke Alice's contributor grant for direct writes. const { bundlePath, baseCommit, headCommit } = (globalThis as any).__collab_bundle; @@ -512,8 +533,9 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { sourceDid : bobDid, }, parentContextId : repoContextId, + protocolRole : 'repo:repo/contributor', store : false, - }, + } as any, ); await sendToAlice(patchRecord); patchRecordId = patchRecord.id; @@ -560,7 +582,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Clean up temp bundle file try { rmSync(bundlePath); } catch { /* ok */ } - }); + }, 15_000); it('Phase 3c: Bob\'s PR is visible in Alice\'s DWN', async () => { const { records } = await alicePatches.records.query('repo/patch', { @@ -709,7 +731,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const repoPath = server.backend.repoPath(aliceDid, 'collab-repo'); const { stdout } = await exec('git log --oneline -5 main', { cwd: repoPath }); expect(stdout).toContain('Merge PR'); - }); + }, 15_000); it('Phase 4f: Alice records the merge result in DWN', async () => { // Update the patch status to merged @@ -839,7 +861,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const repoPath = server.backend.repoPath(aliceDid, 'collab-repo'); const { stdout } = await exec('git branch -a', { cwd: repoPath }); expect(stdout).toContain('feat/add-multiply'); - }); + }, 15_000); it('Phase 6b: Unauthorized DID cannot push', async () => { // Create a stranger DID (no contributor role) diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index 6d36872..6a98fbf 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -17,6 +17,8 @@ import { promisify } from 'node:util'; import { resolve } from 'node:path'; import { existsSync, rmSync } from 'node:fs'; +import { cacheAgentDid } from './helpers/identity.js'; +import { createTestIdentity } from './helpers/identity.js'; import { DidJwk } from '@enbox/dids'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -75,17 +77,15 @@ describe('E2E: init → serve → clone → push → verify', () => { const agent = await EnboxUserAgent.create({ dataPath: DATA_PATH }); await agent.initialize({ password: 'e2e-test' }); await agent.start({ password: 'e2e-test' }); + await cacheAgentDid(agent); const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'E2E Test' }, - }); + identity = await createTestIdentity(agent, 'E2E Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); did = identity.did.uri; const repoHandle = enbox.using(ForgeRepoProtocol); @@ -172,14 +172,18 @@ describe('E2E: init → serve → clone → push → verify', () => { await new Promise((r) => setTimeout(r, 500)); // Check if ref sync happened automatically. - let { records: refRecords } = await refs.records.query('repo/ref' as any); + let { records: refRecords } = await refs.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + }); if (refRecords.length === 0) { // onPushComplete may not have fired (timing issue). Invoke manually. const repoPath = server.backend.repoPath(did, 'e2e-test-repo'); const syncer = createRefSyncer({ refs, repoContextId }); await syncer(did, 'e2e-test-repo', repoPath); - ({ records: refRecords } = await refs.records.query('repo/ref' as any)); + ({ records: refRecords } = await refs.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + })); } expect(refRecords.length).toBeGreaterThanOrEqual(1); @@ -244,17 +248,15 @@ describe('E2E: push → bundle sync → cold start → clone via restore', () => const agent = await EnboxUserAgent.create({ dataPath: BUNDLE_DATA_PATH }); await agent.initialize({ password: 'bundle-e2e' }); await agent.start({ password: 'bundle-e2e' }); + await cacheAgentDid(agent); const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Bundle E2E Test' }, - }); + identity = await createTestIdentity(agent, 'Bundle E2E Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); did = identity.did.uri; repoHandle = enbox.using(ForgeRepoProtocol); @@ -326,7 +328,9 @@ describe('E2E: push → bundle sync → cold start → clone via restore', () => }); it('should have a bundle record in the DWN', async () => { - const { records } = await repoHandle.records.query('repo/bundle', {}); + const { records } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: repoContextId }, + }); expect(records.length).toBeGreaterThanOrEqual(1); // Verify the bundle metadata. @@ -398,17 +402,15 @@ describe('E2E: authenticated push with DID-signed tokens', () => { const agent = await EnboxUserAgent.create({ dataPath: AUTH_DATA_PATH }); await agent.initialize({ password: 'auth-e2e' }); await agent.start({ password: 'auth-e2e' }); + await cacheAgentDid(agent); const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Auth E2E Test' }, - }); + identity = await createTestIdentity(agent, 'Auth E2E Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); ownerDid = identity.did.uri; // Extract the owner's Ed25519 private key for signing push tokens. @@ -438,7 +440,9 @@ describe('E2E: authenticated push with DID-signed tokens', () => { // protection. Nonce replay is incompatible with git's HTTP push flow: // git reuses the same Basic auth credentials for both the ref discovery // GET and the receive-pack POST within a single push operation. - const verifySignature = createDidSignatureVerifier(); + const verifySignature = createDidSignatureVerifier({ + didDocuments: [identity.did.document], + }); const authorizePush = createDwnPushAuthorizer({ repo : repoHandle, ownerDid : ownerDid, @@ -544,7 +548,7 @@ describe('E2E: authenticated push with DID-signed tokens', () => { }); expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('application/x-git-receive-pack-advertisement'); - }); + }, 10_000); it('should push via git with DID-signed credentials', async () => { // Prepare a commit in the clone. @@ -571,7 +575,7 @@ describe('E2E: authenticated push with DID-signed tokens', () => { await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: AUTH_CLONE_PATH }); await exec('GIT_TERMINAL_PROMPT=0 git push -u origin main', { cwd: AUTH_CLONE_PATH }); - }); + }, 15_000); it('should have the pushed commit in the bare repo', async () => { const repoPath = server.backend.repoPath(ownerDid, 'auth-test-repo'); @@ -583,14 +587,18 @@ describe('E2E: authenticated push with DID-signed tokens', () => { // Wait for the async onPushComplete to finish. await new Promise((r) => setTimeout(r, 500)); - let { records: refRecords } = await refsHandle.records.query('repo/ref' as any); + let { records: refRecords } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + }); if (refRecords.length === 0) { // Manually invoke ref syncer if timing caused it to not fire. const repoPath = server.backend.repoPath(ownerDid, 'auth-test-repo'); const syncer = createRefSyncer({ refs: refsHandle, repoContextId }); await syncer(ownerDid, 'auth-test-repo', repoPath); - ({ records: refRecords } = await refsHandle.records.query('repo/ref' as any)); + ({ records: refRecords } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + })); } expect(refRecords.length).toBeGreaterThanOrEqual(1); @@ -690,15 +698,11 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', const agent = await EnboxUserAgent.create({ dataPath }); await agent.initialize({ password: 'profile-e2e' }); await agent.start({ password: 'profile-e2e' }); + await cacheAgentDid(agent); - // Create identity (using did:jwk for offline-friendly tests). - const identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Profile E2E Test' }, - didOptions : { algorithm: 'Ed25519' }, - }); + const identity = await createTestIdentity(agent, 'Profile E2E Test'); - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); profileDid = identity.did.uri; // Extract private key for credential signing. @@ -730,7 +734,9 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', await backend.initRepo(profileDid, 'profile-e2e-repo'); // --- Step 6: Start authenticated server with bundle + ref sync --- - const verifySignature = createDidSignatureVerifier(); + const verifySignature = createDidSignatureVerifier({ + didDocuments: [identity.did.document], + }); const authorizePush = createDwnPushAuthorizer({ repo : repoHandle, ownerDid : profileDid, @@ -844,7 +850,7 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: PROFILE_CLONE_PATH }); await exec('GIT_TERMINAL_PROMPT=0 git push -u origin main', { cwd: PROFILE_CLONE_PATH }); - }); + }, 15_000); it('should have the pushed commit in the bare repo', async () => { const repoPath = server.backend.repoPath(profileDid, 'profile-e2e-repo'); @@ -855,13 +861,17 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', it('should sync refs to DWN after profile-signed push', async () => { await new Promise((r) => setTimeout(r, 500)); - let { records: refRecords } = await refsHandle.records.query('repo/ref' as any); + let { records: refRecords } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + }); if (refRecords.length === 0) { const repoPath = server.backend.repoPath(profileDid, 'profile-e2e-repo'); const syncer = createRefSyncer({ refs: refsHandle, repoContextId }); await syncer(profileDid, 'profile-e2e-repo', repoPath); - ({ records: refRecords } = await refsHandle.records.query('repo/ref' as any)); + ({ records: refRecords } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: repoContextId }, + })); } expect(refRecords.length).toBeGreaterThanOrEqual(1); @@ -874,7 +884,9 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', it('should sync bundles to DWN after profile-signed push', async () => { await new Promise((r) => setTimeout(r, 500)); - const { records } = await repoHandle.records.query('repo/bundle', {}); + const { records } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: repoContextId }, + }); expect(records.length).toBeGreaterThanOrEqual(1); const tags = records[0].tags as Record; @@ -951,17 +963,15 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' const agent = await EnboxUserAgent.create({ dataPath: MR_DATA_PATH }); await agent.initialize({ password: 'multi-repo-e2e' }); await agent.start({ password: 'multi-repo-e2e' }); + await cacheAgentDid(agent); const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Multi-Repo E2E Test' }, - }); + identity = await createTestIdentity(agent, 'Multi-Repo E2E Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); did = identity.did.uri; repoHandle = enbox.using(ForgeRepoProtocol); @@ -1080,16 +1090,27 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' const { stdout: betaSha } = await exec('git rev-parse main', { cwd: betaPath }); // If onPushComplete didn't fire yet, manually invoke syncers. - const { records: allRefs } = await refsHandle.records.query('repo/ref' as any); - if (allRefs.length < 2) { + const { records: alphaRefsBefore } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: alphaCtx.contextId }, + }); + const { records: betaRefsBefore } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: betaCtx.contextId }, + }); + if (alphaRefsBefore.length === 0 || betaRefsBefore.length === 0) { const alphaSync = createRefSyncer({ refs: refsHandle, repoContextId: alphaCtx.contextId }); const betaSync = createRefSyncer({ refs: refsHandle, repoContextId: betaCtx.contextId }); await alphaSync(did, 'repo-alpha', alphaPath); await betaSync(did, 'repo-beta', betaPath); } - // Query all refs and partition by target SHA to verify isolation. - const { records: refRecords } = await refsHandle.records.query('repo/ref' as any); + // Query each repo's refs and partition by target SHA to verify isolation. + const { records: alphaRefs } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: alphaCtx.contextId }, + }); + const { records: betaRefs } = await refsHandle.records.query('repo/ref' as any, { + filter: { contextId: betaCtx.contextId }, + }); + const refRecords = [...alphaRefs, ...betaRefs]; expect(refRecords.length).toBeGreaterThanOrEqual(2); const refEntries = await Promise.all( @@ -1112,8 +1133,13 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' it('should have scoped bundle records per repo in DWN', async () => { // If the async bundle sync didn't fire for both repos, manually invoke. - const { records: existingBundles } = await repoHandle.records.query('repo/bundle', {}); - if (existingBundles.length < 2) { + const { records: existingAlphaBundles } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: alphaCtx.contextId }, + }); + const { records: existingBetaBundles } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: betaCtx.contextId }, + }); + if (existingAlphaBundles.length === 0 || existingBetaBundles.length === 0) { const alphaPath = server.backend.repoPath(did, 'repo-alpha'); const betaPath = server.backend.repoPath(did, 'repo-beta'); const alphaSync = createBundleSyncer({ repo: repoHandle, repoContextId: alphaCtx.contextId, visibility: 'public' }); @@ -1122,13 +1148,15 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' await betaSync(did, 'repo-beta', betaPath); } - const { records: allBundles } = await repoHandle.records.query('repo/bundle', {}); + const { records: alphaBundles } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: alphaCtx.contextId }, + }); + const { records: betaBundles } = await repoHandle.records.query('repo/bundle', { + filter: { contextId: betaCtx.contextId }, + }); + const allBundles = [...alphaBundles, ...betaBundles]; expect(allBundles.length).toBeGreaterThanOrEqual(2); - // Partition bundles by contextId. - const alphaBundles = allBundles.filter((r: any) => r.contextId?.startsWith(alphaCtx.contextId)); - const betaBundles = allBundles.filter((r: any) => r.contextId?.startsWith(betaCtx.contextId)); - expect(alphaBundles.length).toBeGreaterThanOrEqual(1); expect(betaBundles.length).toBeGreaterThanOrEqual(1); diff --git a/tests/git-remote.spec.ts b/tests/git-remote.spec.ts index 5eb354d..0b1c84c 100644 --- a/tests/git-remote.spec.ts +++ b/tests/git-remote.spec.ts @@ -462,7 +462,7 @@ describe('resolveGitEndpoint with local daemon', () => { server.close(); }); - it('should resolve via local daemon when lockfile exists', async () => { + it('should resolve via local daemon when lockfile has no ownerDid (backwards compat)', async () => { const result = await resolveGitEndpoint('did:dht:abc123', 'my-repo'); expect(result.source).toBe('LocalDaemon'); expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123/my-repo`); @@ -474,3 +474,48 @@ describe('resolveGitEndpoint with local daemon', () => { expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123`); }); }); + +// --------------------------------------------------------------------------- +// Local daemon DID ownership check +// --------------------------------------------------------------------------- + +describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { + let server: ReturnType; + let port: number; + + const ownerDid = 'did:dht:localowner'; + const remoteDid = 'did:dht:remoteuser'; + + beforeAll(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + }); + await new Promise((resolve) => { + server.listen(0, () => { + port = (server.address() as any).port; + resolve(); + }); + }); + // Write a lockfile with ownerDid set. + writeLockfile(port, '1.0.0', ownerDid); + }); + + afterAll(() => { + removeLockfile(); + server.close(); + }); + + it('should use local daemon when requested DID matches ownerDid', async () => { + const result = await resolveGitEndpoint(ownerDid, 'my-repo'); + expect(result.source).toBe('LocalDaemon'); + expect(result.url).toBe(`http://localhost:${port}/${ownerDid}/my-repo`); + }); + + it('should skip local daemon when requested DID differs from ownerDid', async () => { + // The remote DID is not resolvable, so this should throw after + // skipping the local daemon — confirming it didn't short-circuit. + await expect(resolveGitEndpoint(remoteDid, 'their-repo')) + .rejects.toThrow(); + }); +}); diff --git a/tests/github-shim.spec.ts b/tests/github-shim.spec.ts index 08606d1..1bfa8e2 100644 --- a/tests/github-shim.spec.ts +++ b/tests/github-shim.spec.ts @@ -11,7 +11,10 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; -import { rmSync } from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -19,6 +22,7 @@ import { EnboxUserAgent } from '@enbox/agent'; import type { AgentContext } from '../src/cli/agent.js'; import type { JsonResponse } from '../src/github-shim/helpers.js'; +import { canMutateIssueMetadata } from '../src/github-shim/issues.js'; import { ForgeCiProtocol } from '../src/ci.js'; import { ForgeIssuesProtocol } from '../src/issues.js'; import { ForgeNotificationsProtocol } from '../src/notifications.js'; @@ -30,6 +34,7 @@ import { ForgeReleasesProtocol } from '../src/releases.js'; import { ForgeRepoProtocol } from '../src/repo.js'; import { ForgeSocialProtocol } from '../src/social.js'; import { ForgeWikiProtocol } from '../src/wiki.js'; +import { GitBackend } from '../src/git-server/git-backend.js'; import { handleShimRequest } from '../src/github-shim/server.js'; import { numericId } from '../src/github-shim/helpers.js'; @@ -38,13 +43,46 @@ import { numericId } from '../src/github-shim/helpers.js'; // --------------------------------------------------------------------------- const DATA_PATH = '__TESTDATA__/github-shim-agent'; +const REPOS_PATH = '__TESTDATA__/github-shim-repos'; +const WORK_PATH = '__TESTDATA__/github-shim-work'; const BASE = 'http://localhost:8181'; +const API_CREATED_REPO_NAME = 'api-created-repo'; +const CONTRIBUTORS_REPO_NAME = 'contributors-repo'; +const EMPTY_REPO_NAME = 'empty-repo'; +const CONTENT_WRITE_REPO_NAME = 'zz-content-write-repo'; +const FORKED_REPO_NAME = 'forked-test-repo'; +const GENERATED_TEMPLATE_REPO_NAME = 'generated-template-repo'; +const REPO_DELETE_REPO_NAME = 'repo-delete-target'; +const REPO_LIFECYCLE_REPO_NAME = 'repo-lifecycle-target'; +const REPO_LIFECYCLE_RENAMED_REPO_NAME = 'repo-lifecycle-renamed'; +const TRANSFERRED_REPO_NAME = 'transferred-test-repo'; +const MAIN_SHA = '1111111111111111111111111111111111111111'; +const FEATURE_SHA = '2222222222222222222222222222222222222222'; +const TAG_SHA = '3333333333333333333333333333333333333333'; +const MAINTAINER_DID = 'did:jwk:maintainer123'; +const TRIAGER_DID = 'did:jwk:triager123'; +const NEW_COLLABORATOR_DID = 'did:jwk:newcollab123'; +const FOLLOW_TARGET_DID = 'did:jwk:followtarget123'; +const FOLLOW_TARGET_TWO_DID = 'did:jwk:followtarget456'; +const ORG_MEMBER_DID = 'did:jwk:orgmember123'; +const ORG_NAME = 'enbox'; +const ORG_API_REPO_NAME = 'org-api-repo'; +const ORG_REMOVED_MEMBER_DID = 'did:jwk:orgremoved123'; +const ORG_TEAM_MEMBER_DID = 'did:jwk:orgteammember123'; +const RELEASE_ASSET_NAME = 'gitd-linux-amd64.tar.gz'; +const RELEASE_ASSET_BYTES = Buffer.from('release asset bytes\n', 'utf-8'); +const RELEASE_ASSET_DIGEST = `sha256:${createHash('sha256').update(RELEASE_ASSET_BYTES).digest('hex')}`; +const MILESTONE_TITLE = 'v1.0'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- let testDid: string; +let gitBaseSha: string; +let gitMainSha: string; +let gitTreeSha: string; +let gitReadmeSha: string; function url(path: string): URL { return new URL(path, BASE); @@ -54,8 +92,121 @@ function repoUrl(subPath: string): URL { return url(`/repos/${testDid}/test-repo${subPath}`); } +function contentWriteRepoUrl(subPath: string): URL { + return url(`/repos/${testDid}/${CONTENT_WRITE_REPO_NAME}${subPath}`); +} + +function orgUrl(subPath: string): URL { + return url(`/orgs/${ORG_NAME}${subPath}`); +} + function parse(res: JsonResponse): any { - return JSON.parse(res.body); + const body = typeof res.body === 'string' + ? res.body + : Buffer.from(res.body).toString('utf-8'); + return JSON.parse(body); +} + +function bodyBuffer(res: JsonResponse): Buffer { + return typeof res.body === 'string' + ? Buffer.from(res.body, 'utf-8') + : Buffer.from(res.body); +} + +function shimOptions(): { reposPath: string } { + return { reposPath: REPOS_PATH }; +} + +function githubMilestoneNumber(title: string): number { + return numericId(`milestone:${title.toLocaleLowerCase()}`) || 1; +} + +function git(args: string[], cwd?: string): string { + return execFileSync('git', args, { + cwd, + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +async function seedLocalGitRepo(did: string): Promise { + const backend = new GitBackend({ basePath: REPOS_PATH }); + const barePath = await backend.initRepo(did, 'test-repo'); + + rmSync(WORK_PATH, { recursive: true, force: true }); + git(['clone', barePath, WORK_PATH]); + git(['config', 'user.email', 'gitd@example.test'], WORK_PATH); + git(['config', 'user.name', 'Gitd Test'], WORK_PATH); + git(['checkout', '-b', 'main'], WORK_PATH); + + mkdirSync(`${WORK_PATH}/src`, { recursive: true }); + writeFileSync(`${WORK_PATH}/README.md`, '# Local Git Repo\n\nReadme from local git objects.\n', 'utf-8'); + writeFileSync(`${WORK_PATH}/LICENSE`, 'MIT\n', 'utf-8'); + writeFileSync(`${WORK_PATH}/src/index.ts`, 'export const answer = 42;\n', 'utf-8'); + + git(['add', '.'], WORK_PATH); + git(['commit', '-m', 'Add local git contents'], WORK_PATH); + gitBaseSha = git(['rev-parse', 'HEAD'], WORK_PATH); + + writeFileSync(`${WORK_PATH}/src/feature.ts`, 'export const feature = "compare";\n', 'utf-8'); + git(['add', 'src/feature.ts'], WORK_PATH); + git(['commit', '-m', 'Add feature file'], WORK_PATH); + git(['push', '-u', 'origin', 'main'], WORK_PATH); + + gitMainSha = git(['rev-parse', 'HEAD'], WORK_PATH); + gitTreeSha = git(['rev-parse', 'HEAD^{tree}'], WORK_PATH); + gitReadmeSha = git(['rev-parse', 'HEAD:README.md'], WORK_PATH); +} + +async function seedContributorGitRepo(did: string): Promise { + const backend = new GitBackend({ basePath: REPOS_PATH }); + const barePath = await backend.initRepo(did, CONTRIBUTORS_REPO_NAME); + + rmSync(WORK_PATH, { recursive: true, force: true }); + git(['clone', barePath, WORK_PATH]); + git(['checkout', '-b', 'main'], WORK_PATH); + + git(['config', 'user.email', 'alice@example.test'], WORK_PATH); + git(['config', 'user.name', 'Alice Author'], WORK_PATH); + writeFileSync(`${WORK_PATH}/alpha.txt`, 'alpha\n', 'utf-8'); + git(['add', 'alpha.txt'], WORK_PATH); + git(['commit', '-m', 'Alice first contribution'], WORK_PATH); + + writeFileSync(`${WORK_PATH}/alpha.txt`, 'alpha\nsecond\n', 'utf-8'); + git(['add', 'alpha.txt'], WORK_PATH); + git(['commit', '-m', 'Alice second contribution'], WORK_PATH); + + git(['config', 'user.email', 'bob@example.test'], WORK_PATH); + git(['config', 'user.name', 'Bob Builder'], WORK_PATH); + writeFileSync(`${WORK_PATH}/bob.txt`, 'bob\n', 'utf-8'); + git(['add', 'bob.txt'], WORK_PATH); + git(['commit', '-m', 'Bob contribution'], WORK_PATH); + + git(['push', '-u', 'origin', 'main'], WORK_PATH); +} + +async function seedEmptyGitRepo(did: string): Promise { + const backend = new GitBackend({ basePath: REPOS_PATH }); + await backend.initRepo(did, EMPTY_REPO_NAME); +} + +async function seedContentWriteGitRepo(did: string): Promise { + const backend = new GitBackend({ basePath: REPOS_PATH }); + const barePath = await backend.initRepo(did, CONTENT_WRITE_REPO_NAME); + + rmSync(WORK_PATH, { recursive: true, force: true }); + git(['clone', barePath, WORK_PATH]); + git(['config', 'user.email', 'gitd@example.test'], WORK_PATH); + git(['config', 'user.name', 'Gitd Test'], WORK_PATH); + git(['checkout', '-b', 'main'], WORK_PATH); + + mkdirSync(`${WORK_PATH}/docs`, { recursive: true }); + writeFileSync(`${WORK_PATH}/README.md`, '# Content Write Fixture\n', 'utf-8'); + writeFileSync(`${WORK_PATH}/docs/README.md`, '# Docs README\n\nDirectory-specific documentation.\n', 'utf-8'); + git(['add', 'README.md'], WORK_PATH); + git(['add', 'docs/README.md'], WORK_PATH); + git(['commit', '-m', 'Seed content write fixture'], WORK_PATH); + git(['push', '-u', 'origin', 'main'], WORK_PATH); } // --------------------------------------------------------------------------- @@ -65,27 +216,49 @@ function parse(res: JsonResponse): any { describe('GitHub API compatibility shim', () => { let ctx: AgentContext; let issueRecId: string; + let closedIssueRecId: string; + let issueEventRecId: string; let patchRecId: string; let mergedPatchRecId: string; + let reviewRecId: string; + let reviewCommentRecId: string; + let releaseRecId: string; + let releaseAssetRecId: string; + let checkSuiteRecId: string; + let checkRunRecId: string; + let repoRecordId: string; + let repoContextId: string; + + async function mergeRepoSettings(settings: Record): Promise { + const { records } = await ctx.repo.records.query('repo/settings' as any, { + filter: { contextId: repoContextId }, + }); + if (records.length > 0) { + const record = records[0] as any; + const existing = await record.data.json(); + const { status } = await record.update({ data: { ...existing, ...settings } }); + expect(status.code).toBeLessThan(300); + return; + } + + const { status } = await ctx.repo.records.create('repo/settings' as any, { + data : settings, + parentContextId : repoContextId, + } as any); + expect(status.code).toBeLessThan(300); + } beforeAll(async () => { rmSync(DATA_PATH, { recursive: true, force: true }); + rmSync(REPOS_PATH, { recursive: true, force: true }); + rmSync(WORK_PATH, { recursive: true, force: true }); const agent = await EnboxUserAgent.create({ dataPath: DATA_PATH }); await agent.initialize({ password: 'test-password' }); await agent.start({ password: 'test-password' }); - const identities = await agent.identity.list(); - let identity = identities[0]; - if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'GitHub Shim Test' }, - }); - } - - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); - const did = identity.did.uri; + const did = agent.agentDid.uri; + const enbox = new Enbox({ agent, connectedDid: did }); testDid = did; const repo = enbox.using(ForgeRepoProtocol); @@ -100,7 +273,7 @@ describe('GitHub API compatibility shim', () => { const wiki = enbox.using(ForgeWikiProtocol); const org = enbox.using(ForgeOrgProtocol); - await repo.configure(); + await repo.configure({ encryption: true }); await refs.configure(); await issues.configure(); await patches.configure(); @@ -124,14 +297,175 @@ describe('GitHub API compatibility shim', () => { // 1. Create a repo record. const { record: repoRec } = await ctx.repo.records.create('repo', { data : { name: 'test-repo', description: 'A test repository', defaultBranch: 'main', dwnEndpoints: [] }, - tags : { name: 'test-repo', visibility: 'public' }, + tags : { name: 'test-repo', visibility: 'public', language: 'TypeScript' }, + }); + repoRecordId = repoRec!.id; + repoContextId = repoRec!.contextId ?? ''; + + await seedLocalGitRepo(testDid); + + await ctx.repo.records.create('repo', { + data : { name: CONTRIBUTORS_REPO_NAME, description: 'Contributor fixture repository', defaultBranch: 'main', dwnEndpoints: [] }, + tags : { name: CONTRIBUTORS_REPO_NAME, visibility: 'public', language: 'Text' }, + }); + await seedContributorGitRepo(testDid); + + await ctx.repo.records.create('repo', { + data : { name: EMPTY_REPO_NAME, description: 'Empty fixture repository', defaultBranch: 'main', dwnEndpoints: [] }, + tags : { name: EMPTY_REPO_NAME, visibility: 'public' }, + }); + await seedEmptyGitRepo(testDid); + + await ctx.repo.records.create('repo', { + data : { name: CONTENT_WRITE_REPO_NAME, description: 'Contents write fixture repository', defaultBranch: 'main', dwnEndpoints: [] }, + tags : { name: CONTENT_WRITE_REPO_NAME, visibility: 'public', language: 'Markdown' }, }); - const repoContextId = repoRec!.contextId ?? ''; + await seedContentWriteGitRepo(testDid); + + // 1a. Create repo metadata files and topics exposed via GitHub metadata endpoints. + await ctx.repo.records.create('repo/readme' as any, { + data : '# Test Repo\n\nReadme from DWN metadata.\n', + parentContextId : repoContextId, + } as any); + + await ctx.repo.records.create('repo/license' as any, { + data : 'Apache-2.0\n', + parentContextId : repoContextId, + } as any); + + await ctx.repo.records.create('repo/topic' as any, { + data : { name: 'decentralized' }, + tags : { name: 'decentralized' }, + parentContextId : repoContextId, + } as any); + + await ctx.repo.records.create('repo/topic' as any, { + data : { name: 'git' }, + tags : { name: 'git' }, + parentContextId : repoContextId, + } as any); + + await ctx.repo.records.create('repo/maintainer' as any, { + data : { did: MAINTAINER_DID, alias: 'Alice' }, + tags : { did: MAINTAINER_DID }, + parentContextId : repoContextId, + recipient : MAINTAINER_DID, + } as any); + + await ctx.repo.records.create('repo/triager' as any, { + data : { did: TRIAGER_DID, alias: 'Triage Bot' }, + tags : { did: TRIAGER_DID }, + parentContextId : repoContextId, + recipient : TRIAGER_DID, + } as any); + + // 1b. Create mirrored git refs. + await ctx.refs.records.create('repo/ref' as any, { + data : { name: 'refs/heads/main', target: MAIN_SHA, type: 'branch' }, + tags : { name: 'refs/heads/main', target: MAIN_SHA, type: 'branch' }, + parentContextId : repoContextId, + } as any); + + await ctx.refs.records.create('repo/ref' as any, { + data : { name: 'refs/heads/feature/demo', target: FEATURE_SHA, type: 'branch' }, + tags : { name: 'refs/heads/feature/demo', target: FEATURE_SHA, type: 'branch' }, + parentContextId : repoContextId, + } as any); + + await ctx.refs.records.create('repo/ref' as any, { + data : { name: 'refs/tags/v1.0.0', target: TAG_SHA, type: 'tag' }, + tags : { name: 'refs/tags/v1.0.0', target: TAG_SHA, type: 'tag' }, + parentContextId : repoContextId, + } as any); + + // 1c. Create social activity records exposed through GitHub activity events. + await ctx.social.records.create('activity' as any, { + data: { + type : 'push', + summary : 'Pushed to main', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + ref : 'refs/heads/main', + head : MAIN_SHA, + before : FEATURE_SHA, + }, + tags: { + type : 'push', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + }, + } as any); + + await ctx.social.records.create('activity' as any, { + data: { + type : 'star', + summary : 'Starred test-repo', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + }, + tags: { + type : 'star', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + }, + } as any); + + await ctx.social.records.create('activity' as any, { + data: { + type : 'release', + summary : 'Private release activity', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + public : false, + tagName : 'v-private', + }, + tags: { + type : 'release', + repoDid : testDid, + repoRecordId, + repoName : 'test-repo', + }, + } as any); + + await ctx.social.records.create('activity' as any, { + data: { + type : 'fork', + summary : 'Forked empty-repo', + repoDid : testDid, + repoName : EMPTY_REPO_NAME, + repoRecordId : 'empty-repo-record', + }, + tags: { + type : 'fork', + repoDid : testDid, + repoName : EMPTY_REPO_NAME, + }, + } as any); + + // 1d. Create CI records exposed through status/check endpoints. + const { record: checkSuiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, { + data : { app: 'gitd-ci', headBranch: 'main' }, + tags : { commitSha: MAIN_SHA, status: 'completed', conclusion: 'success', branch: 'main' }, + parentContextId : repoContextId, + } as any); + checkSuiteRecId = checkSuiteRec!.id; + + const { record: checkRunRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, { + data : { output: { title: 'Lint', summary: 'All clean.' } }, + tags : { name: 'lint', status: 'completed', conclusion: 'success' }, + parentContextId : checkSuiteRec!.contextId ?? '', + } as any); + checkRunRecId = checkRunRec!.id; // 2. Create an open issue. const { record: issueRec } = await ctx.issues.records.create('repo/issue', { data : { title: 'Fix the widget', body: 'The widget is broken.' }, - tags : { status: 'open' }, + tags : { status: 'open', milestone: MILESTONE_TITLE }, parentContextId : repoContextId, }); issueRecId = issueRec!.id; @@ -148,12 +482,44 @@ describe('GitHub API compatibility shim', () => { parentContextId : issueRec!.contextId ?? '', } as any); - // 5. Create a closed issue. - await ctx.issues.records.create('repo/issue', { + // 5. Create a closed issue with stable repository label fixtures. + const { record: closedIssueRec } = await ctx.issues.records.create('repo/issue', { data : { title: 'Old bug', body: 'Already fixed.' }, - tags : { status: 'closed' }, + tags : { status: 'closed', milestone: MILESTONE_TITLE }, parentContextId : repoContextId, }); + closedIssueRecId = closedIssueRec!.id; + + await ctx.issues.records.create('repo/issue/label' as any, { + data : { name: 'bug', color: 'd73a4a', description: 'Bug reports' }, + tags : { name: 'bug', color: 'd73a4a' }, + parentContextId : closedIssueRec!.contextId ?? '', + } as any); + + await ctx.issues.records.create('repo/issue/label' as any, { + data : { name: 'enhancement', color: 'a2eeef', description: 'New feature or request' }, + tags : { name: 'enhancement', color: 'a2eeef' }, + parentContextId : closedIssueRec!.contextId ?? '', + } as any); + + const { record: closedEventRec } = await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : { reason: 'Fixed before import' }, + tags : { from: 'open', to: 'closed' }, + parentContextId : closedIssueRec!.contextId ?? '', + } as any); + issueEventRecId = closedEventRec!.id; + + await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : { reason: 'Regression reproduced' }, + tags : { from: 'closed', to: 'open' }, + parentContextId : closedIssueRec!.contextId ?? '', + } as any); + + await ctx.issues.records.create('repo/issue/statusChange' as any, { + data : { reason: 'Fixed again' }, + tags : { from: 'open', to: 'closed' }, + parentContextId : closedIssueRec!.contextId ?? '', + } as any); // 6. Create an open patch with a revision. const { record: patchRec } = await ctx.patches.records.create('repo/patch', { @@ -170,19 +536,28 @@ describe('GitHub API compatibility shim', () => { diffStat : { additions: 42, deletions: 7, filesChanged: 5 }, }, tags: { - headCommit : 'abc1234567890abcdef1234567890abcdef123456', - baseCommit : 'def0987654321fedcba0987654321fedcba098765', + headCommit : 'abc1234567890abcdef1234567890abcdef12345', + baseCommit : 'def0987654321fedcba0987654321fedcba09876', commitCount : 3, }, parentContextId: patchRec!.contextId ?? '', } as any); // 7. Create a review on the patch. - await ctx.patches.records.create('repo/patch/review' as any, { + const { record: approveReviewRec } = await ctx.patches.records.create('repo/patch/review' as any, { data : { body: 'Looks good to me.' }, tags : { verdict: 'approve' }, parentContextId : patchRec!.contextId ?? '', } as any); + reviewRecId = approveReviewRec!.id; + + // 7a. Create an inline review comment. + const { record: reviewCommentRec } = await ctx.patches.records.create('repo/patch/review/reviewComment' as any, { + data : { body: 'Please keep this helper small.', diffHunk: '@@ -1 +1 @@' }, + tags : { path: 'src/widget.ts', line: 12, side: 'right', commitId: 'abc1234567890abcdef1234567890abcdef12345' }, + parentContextId : approveReviewRec!.contextId ?? '', + } as any); + reviewCommentRecId = reviewCommentRec!.id; // 8. Create a second review. await ctx.patches.records.create('repo/patch/review' as any, { @@ -207,11 +582,21 @@ describe('GitHub API compatibility shim', () => { } as any); // 10. Create a release. - await ctx.releases.records.create('repo/release' as any, { + const { record: stableReleaseRec } = await ctx.releases.records.create('repo/release' as any, { data : { name: 'v1.0.0', body: 'Initial release.' }, tags : { tagName: 'v1.0.0' }, parentContextId : repoContextId, } as any); + releaseRecId = stableReleaseRec!.id; + + // 10a. Create an immutable release asset. + const { record: stableAssetRec } = await ctx.releases.records.create('repo/release/asset' as any, { + data : new Uint8Array(RELEASE_ASSET_BYTES), + dataFormat : 'application/gzip', + tags : { filename: RELEASE_ASSET_NAME, contentType: 'application/gzip', size: RELEASE_ASSET_BYTES.byteLength }, + parentContextId : stableReleaseRec!.contextId, + } as any); + releaseAssetRecId = stableAssetRec!.id; // 11. Create a pre-release. await ctx.releases.records.create('repo/release' as any, { @@ -219,10 +604,57 @@ describe('GitHub API compatibility shim', () => { tags : { tagName: 'v2.0.0-beta', prerelease: true }, parentContextId : repoContextId, } as any); - }); + + // 12. Create organization, membership, and team fixtures. + const { record: orgRec } = await ctx.org.records.create('org', { + data: { + name : ORG_NAME, + description : 'Decentralized forge organization', + homepage : 'https://enbox.org', + avatar : 'https://enbox.org/avatar.png', + }, + }); + const orgContextId = orgRec!.contextId ?? ''; + + await ctx.org.records.create('org/owner' as any, { + data : { did: testDid, alias: 'Local Owner' }, + tags : { did: testDid }, + parentContextId : orgContextId, + recipient : testDid, + } as any); + + await ctx.org.records.create('org/member' as any, { + data : { did: ORG_MEMBER_DID, alias: 'Org Member' }, + tags : { did: ORG_MEMBER_DID }, + parentContextId : orgContextId, + recipient : ORG_MEMBER_DID, + } as any); + + await ctx.org.records.create('org/member' as any, { + data : { did: ORG_REMOVED_MEMBER_DID, alias: 'Removed Member' }, + tags : { did: ORG_REMOVED_MEMBER_DID }, + parentContextId : orgContextId, + recipient : ORG_REMOVED_MEMBER_DID, + } as any); + + const { record: teamRec } = await ctx.org.records.create('org/team' as any, { + data : { name: 'Core Team', description: 'Core maintainers', privacy: 'visible' }, + parentContextId : orgContextId, + } as any); + const teamContextId = teamRec!.contextId ?? ''; + + await ctx.org.records.create('org/team/teamMember' as any, { + data : { did: ORG_MEMBER_DID, alias: 'Org Member' }, + tags : { did: ORG_MEMBER_DID }, + parentContextId : teamContextId, + recipient : ORG_MEMBER_DID, + } as any); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); + rmSync(REPOS_PATH, { recursive: true, force: true }); + rmSync(WORK_PATH, { recursive: true, force: true }); }); // ========================================================================= @@ -259,6 +691,108 @@ describe('GitHub API compatibility shim', () => { }); }); + // ========================================================================= + // Global GitHub utility endpoints + // ========================================================================= + + describe('global utility endpoints', () => { + it('should expose GitHub API root links, metadata, versions, zen, and rate limits', async () => { + const rootRes = await handleShimRequest(ctx, url('/')); + expect(rootRes.status).toBe(200); + const root = parse(rootRes); + expect(root.current_user_url).toBe(`${BASE}/user`); + expect(root.repository_url).toBe(`${BASE}/repos/{owner}/{repo}`); + expect(root.rate_limit_url).toBe(`${BASE}/rate_limit`); + + const metaRes = await handleShimRequest(ctx, url('/meta')); + expect(metaRes.status).toBe(200); + const meta = parse(metaRes); + expect(meta.verifiable_password_authentication).toBe(false); + expect(meta.domains.api).toEqual(['localhost:8181']); + + const versionsRes = await handleShimRequest(ctx, url('/versions')); + expect(versionsRes.status).toBe(200); + expect(parse(versionsRes)).toContain('2026-03-10'); + + const zenRes = await handleShimRequest(ctx, url('/zen')); + expect(zenRes.status).toBe(200); + expect(zenRes.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(zenRes.body).toContain('decentralization'); + + const rateRes = await handleShimRequest(ctx, url('/rate_limit')); + expect(rateRes.status).toBe(200); + const rate = parse(rateRes); + expect(rate.resources.core.limit).toBe(5000); + expect(rate.rate).toEqual(rate.resources.core); + }); + + it('should return a GitHub-compatible emoji map', async () => { + const res = await handleShimRequest(ctx, url('/emojis')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data['+1']).toContain('/1f44d.png'); + expect(data.rocket).toContain('/1f680.png'); + }); + + it('should list and return gitignore templates', async () => { + const listRes = await handleShimRequest(ctx, url('/gitignore/templates')); + expect(listRes.status).toBe(200); + expect(parse(listRes)).toContain('Node'); + + const templateRes = await handleShimRequest(ctx, url('/gitignore/templates/Node')); + expect(templateRes.status).toBe(200); + const template = parse(templateRes); + expect(template.name).toBe('Node'); + expect(template.source).toContain('node_modules/'); + + const missingRes = await handleShimRequest(ctx, url('/gitignore/templates/Missing')); + expect(missingRes.status).toBe(404); + }); + + it('should list and return license templates', async () => { + const listRes = await handleShimRequest(ctx, url('/licenses')); + expect(listRes.status).toBe(200); + const licenses = parse(listRes); + expect(licenses.some((license: any) => license.key === 'mit')).toBe(true); + + const licenseRes = await handleShimRequest(ctx, url('/licenses/mit')); + expect(licenseRes.status).toBe(200); + const license = parse(licenseRes); + expect(license.key).toBe('mit'); + expect(license.spdx_id).toBe('MIT'); + expect(license.body).toContain('Permission is hereby granted'); + + const missingRes = await handleShimRequest(ctx, url('/licenses/missing')); + expect(missingRes.status).toBe(404); + }); + + it('should render Markdown JSON requests as HTML', async () => { + const res = await handleShimRequest(ctx, url('/markdown'), 'POST', { + text : 'Hello **world**', + mode : 'gfm', + }); + expect(res.status).toBe(200); + expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8'); + expect(res.body).toBe('

Hello world

'); + + const invalidRes = await handleShimRequest(ctx, url('/markdown'), 'POST', { + mode: 'gfm', + }); + expect(invalidRes.status).toBe(422); + }); + + it('should render raw Markdown body requests as HTML', async () => { + const raw = Buffer.from('# Release Notes\n\n- Add **asset** upload\n', 'utf-8'); + const res = await handleShimRequest(ctx, url('/markdown/raw'), 'POST', {}, null, { + rawBody : raw, + contentType : 'text/plain', + }); + expect(res.status).toBe(200); + expect(res.body).toContain('

Release Notes

'); + expect(res.body).toContain('
  • Add asset upload
  • '); + }); + }); + // ========================================================================= // GET /repos/:did/:repo // ========================================================================= @@ -302,6 +836,8 @@ describe('GitHub API compatibility shim', () => { expect(data.has_wiki).toBe(true); expect(data.archived).toBe(false); expect(data.disabled).toBe(false); + expect(data.language).toBe('TypeScript'); + expect(data.topics).toEqual(['decentralized', 'git']); }); it('should include date fields', async () => { @@ -320,581 +856,10732 @@ describe('GitHub API compatibility shim', () => { }); // ========================================================================= - // GET /repos/:did/:repo/issues + // Repository listing and creation endpoints // ========================================================================= - describe('GET /repos/:did/:repo/issues', () => { - it('should return open issues by default', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues')); + describe('repository listing and creation endpoints', () => { + it('should list repositories for the authenticated user with pagination', async () => { + const res = await handleShimRequest(ctx, url('/user/repos?sort=full_name&per_page=2')); expect(res.status).toBe(200); const data = parse(res); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(1); - expect(data[0].title).toBe('Fix the widget'); - expect(data[0].state).toBe('open'); + expect(data.map((repo: any) => repo.name)).toEqual([CONTRIBUTORS_REPO_NAME, EMPTY_REPO_NAME]); + expect(res.headers.Link).toContain('rel="next"'); + expect(data[0].owner.login).toBe(testDid); }); - it('should filter by state=closed', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=closed')); + it('should list repositories for a user DID', async () => { + const res = await handleShimRequest(ctx, url(`/users/${testDid}/repos?type=owner`)); expect(res.status).toBe(200); - const data = parse(res); - expect(data.length).toBe(1); - expect(data[0].title).toBe('Old bug'); - expect(data[0].state).toBe('closed'); + const names = parse(res).map((repo: any) => repo.name); + expect(names).toContain('test-repo'); + expect(names).toContain(CONTRIBUTORS_REPO_NAME); + expect(names).toContain(EMPTY_REPO_NAME); }); - it('should return all issues with state=all', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=all')); + it('should list repositories for an organization route', async () => { + const res = await handleShimRequest(ctx, orgUrl('/repos?type=public&sort=created&direction=asc')); expect(res.status).toBe(200); - const data = parse(res); - expect(data.length).toBe(2); + const names = parse(res).map((repo: any) => repo.name); + expect(names).toContain('test-repo'); + expect(names).toContain(CONTRIBUTORS_REPO_NAME); + + const missing = await handleShimRequest(ctx, url('/orgs/missing-org/repos')); + expect(missing.status).toBe(404); }); - it('should include GitHub-style issue fields', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=all')); - const data = parse(res); - const issueNum = numericId(issueRecId); - const issue = data.find((i: any) => i.number === issueNum); - expect(issue).toBeDefined(); - expect(issue.title).toBe('Fix the widget'); - expect(issue.body).toBe('The widget is broken.'); - expect(issue.user.login).toBe(testDid); - expect(issue.url).toContain(`/issues/${issueNum}`); - expect(issue.comments_url).toContain(`/issues/${issueNum}/comments`); - expect(issue.labels).toEqual([]); - expect(issue.locked).toBe(false); + it('should list public repositories through the global repository feed', async () => { + const firstPage = await handleShimRequest(ctx, url('/repositories?per_page=2')); + expect(firstPage.status).toBe(200); + const pageRepos = parse(firstPage); + expect(pageRepos.map((repo: any) => repo.name)).toEqual(['test-repo', CONTRIBUTORS_REPO_NAME]); + expect(pageRepos.every((repo: any) => repo.private === false)).toBe(true); + expect(firstPage.headers.Link).toContain('/repositories?per_page=2&since='); + expect(firstPage.headers.Link).toContain('rel="next"'); + + const allRepos = parse(await handleShimRequest(ctx, url('/repositories?per_page=100'))); + expect(allRepos.map((repo: any) => repo.name)).toContain(EMPTY_REPO_NAME); + expect(allRepos.map((repo: any) => repo.name)).toContain(CONTENT_WRITE_REPO_NAME); + + const maxRepoId = Math.max(...allRepos.map((repo: any) => repo.id)); + const afterMax = await handleShimRequest(ctx, url(`/repositories?since=${maxRepoId}`)); + expect(afterMax.status).toBe(200); + expect(parse(afterMax)).toEqual([]); + + const invalidSince = await handleShimRequest(ctx, url('/repositories?since=not-a-number')); + expect(invalidSince.status).toBe(422); }); - it('should set closed_at for closed issues', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=closed')); - const data = parse(res); - expect(data[0].closed_at).toBeDefined(); - expect(data[0].closed_at).not.toBeNull(); + it('should create a repository for the authenticated user', async () => { + const res = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name : API_CREATED_REPO_NAME, + description : 'Created through the GitHub API shim', + private : true, + default_branch : 'develop', + }, null, shimOptions()); + expect(res.status).toBe(201); + const created = parse(res); + expect(created.name).toBe(API_CREATED_REPO_NAME); + expect(created.private).toBe(true); + expect(created.visibility).toBe('private'); + expect(created.default_branch).toBe('develop'); + + const fetched = await handleShimRequest(ctx, url(`/repos/${testDid}/${API_CREATED_REPO_NAME}`)); + expect(fetched.status).toBe(200); + expect(parse(fetched).description).toBe('Created through the GitHub API shim'); }); - it('should set closed_at to null for open issues', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues')); - const data = parse(res); - expect(data[0].closed_at).toBeNull(); + it('should create a repository through an organization route', async () => { + const res = await handleShimRequest(ctx, orgUrl('/repos'), 'POST', { + name : ORG_API_REPO_NAME, + description : 'Organization route repository', + homepage : 'https://enbox.org/repos/org-api-repo', + visibility : 'public', + }, null, shimOptions()); + expect(res.status).toBe(201); + const created = parse(res); + expect(created.name).toBe(ORG_API_REPO_NAME); + expect(created.homepage).toBe('https://enbox.org/repos/org-api-repo'); + expect(created.visibility).toBe('public'); + + const list = parse(await handleShimRequest(ctx, orgUrl('/repos?type=public'))); + expect(list.map((repo: any) => repo.name)).toContain(ORG_API_REPO_NAME); }); - it('should support pagination with per_page', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=all&per_page=1')); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.length).toBe(1); + it('should generate a repository from a template repository', async () => { + const markTemplate = await handleShimRequest(ctx, repoUrl(''), 'PATCH', { + is_template: true, + }, null, shimOptions()); + expect(markTemplate.status).toBe(200); + expect(parse(markTemplate).is_template).toBe(true); + + const generate = await handleShimRequest(ctx, repoUrl('/generate'), 'POST', { + name : GENERATED_TEMPLATE_REPO_NAME, + description : 'Generated from test-repo template', + include_all_branches : false, + private : true, + }, null, shimOptions()); + expect(generate.status).toBe(201); + const generated = parse(generate); + expect(generated.name).toBe(GENERATED_TEMPLATE_REPO_NAME); + expect(generated.full_name).toBe(`${testDid}/${GENERATED_TEMPLATE_REPO_NAME}`); + expect(generated.private).toBe(true); + expect(generated.description).toBe('Generated from test-repo template'); + expect(generated.is_template).toBe(false); + expect(generated.template_repository.name).toBe('test-repo'); + expect(generated.template_repository.is_template).toBe(true); + + const backend = new GitBackend({ basePath: REPOS_PATH }); + expect(backend.exists(testDid, GENERATED_TEMPLATE_REPO_NAME)).toBe(true); + + const readme = await handleShimRequest( + ctx, + url(`/repos/${testDid}/${GENERATED_TEMPLATE_REPO_NAME}/contents/README.md`), + 'GET', + {}, + null, + shimOptions(), + ); + expect(readme.status).toBe(200); + expect(Buffer.from(parse(readme).content, 'base64').toString('utf-8')).toContain('Local Git Repo'); + + const duplicate = await handleShimRequest(ctx, repoUrl('/generate'), 'POST', { + name: GENERATED_TEMPLATE_REPO_NAME, + }, null, shimOptions()); + expect(duplicate.status).toBe(422); + + const missingName = await handleShimRequest(ctx, repoUrl('/generate'), 'POST', {}, null, shimOptions()); + expect(missingName.status).toBe(422); + + const remoteOwner = await handleShimRequest(ctx, repoUrl('/generate'), 'POST', { + name : 'remote-template-target', + owner : 'did:jwk:remoteowner', + }, null, shimOptions()); + expect(remoteOwner.status).toBe(422); + + const nonTemplate = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}/generate`), 'POST', { + name: 'from-non-template', + }, null, shimOptions()); + expect(nonTemplate.status).toBe(422); }); - it('should include Link header for paginated results', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues?state=all&per_page=1')); - expect(res.headers['Link']).toBeDefined(); - expect(res.headers['Link']).toContain('rel="next"'); - expect(res.headers['Link']).toContain('rel="last"'); + it('should reject invalid or duplicate repository create payloads', async () => { + const invalidName = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name: '../bad', + }); + expect(invalidName.status).toBe(422); + + const invalidVisibility = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name : 'invalid-visibility-repo', + visibility : 'internal', + }); + expect(invalidVisibility.status).toBe(422); + + const duplicate = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name: API_CREATED_REPO_NAME, + }); + expect(duplicate.status).toBe(422); }); }); // ========================================================================= - // GET /repos/:did/:repo/issues/:number + // Repository lifecycle endpoints // ========================================================================= - describe('GET /repos/:did/:repo/issues/:number', () => { - it('should return issue detail', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.number).toBe(issueNum); - expect(data.title).toBe('Fix the widget'); - expect(data.body).toBe('The widget is broken.'); - expect(data.state).toBe('open'); + describe('repository lifecycle endpoints', () => { + it('should update repository metadata and rename local storage', async () => { + const create = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name : REPO_LIFECYCLE_REPO_NAME, + description : 'Lifecycle target', + }, null, shimOptions()); + expect(create.status).toBe(201); + + const update = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_LIFECYCLE_REPO_NAME}`), 'PATCH', { + allow_auto_merge : true, + allow_forking : false, + allow_merge_commit : false, + allow_rebase_merge : false, + allow_squash_merge : false, + archived : true, + default_branch : 'trunk', + delete_branch_on_merge : true, + description : 'Updated through PATCH /repos', + has_downloads : false, + has_issues : false, + has_projects : true, + has_pull_requests : false, + has_wiki : false, + homepage : 'https://enbox.org/repos/repo-lifecycle-renamed', + is_template : true, + name : REPO_LIFECYCLE_RENAMED_REPO_NAME, + private : false, + pull_request_creation_policy : 'collaborators_only', + web_commit_signoff_required : true, + }, null, shimOptions()); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.name).toBe(REPO_LIFECYCLE_RENAMED_REPO_NAME); + expect(updated.full_name).toBe(`${testDid}/${REPO_LIFECYCLE_RENAMED_REPO_NAME}`); + expect(updated.description).toBe('Updated through PATCH /repos'); + expect(updated.homepage).toBe('https://enbox.org/repos/repo-lifecycle-renamed'); + expect(updated.private).toBe(false); + expect(updated.visibility).toBe('public'); + expect(updated.default_branch).toBe('trunk'); + expect(updated.archived).toBe(true); + expect(updated.has_issues).toBe(false); + expect(updated.has_projects).toBe(true); + expect(updated.has_wiki).toBe(false); + expect(updated.has_downloads).toBe(false); + expect(updated.has_pull_requests).toBe(false); + expect(updated.is_template).toBe(true); + expect(updated.allow_squash_merge).toBe(false); + expect(updated.allow_merge_commit).toBe(false); + expect(updated.allow_rebase_merge).toBe(false); + expect(updated.allow_auto_merge).toBe(true); + expect(updated.allow_forking).toBe(false); + expect(updated.delete_branch_on_merge).toBe(true); + expect(updated.pull_request_creation_policy).toBe('collaborators_only'); + expect(updated.web_commit_signoff_required).toBe(true); + + const oldRoute = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_LIFECYCLE_REPO_NAME}`)); + expect(oldRoute.status).toBe(404); + + const fetched = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_LIFECYCLE_RENAMED_REPO_NAME}`)); + expect(fetched.status).toBe(200); + expect(parse(fetched).description).toBe('Updated through PATCH /repos'); + + const backend = new GitBackend({ basePath: REPOS_PATH }); + expect(backend.exists(testDid, REPO_LIFECYCLE_REPO_NAME)).toBe(false); + expect(backend.exists(testDid, REPO_LIFECYCLE_RENAMED_REPO_NAME)).toBe(true); }); - it('should include comment count', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); - const data = parse(res); - expect(data.comments).toBe(2); - }); + it('should reject invalid repository update requests', async () => { + const invalidName = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}`), 'PATCH', { + name: '../bad', + }); + expect(invalidName.status).toBe(422); - it('should return 404 for non-existent issue', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/999')); - expect(res.status).toBe(404); - const data = parse(res); - expect(data.message).toContain('not found'); - }); + const invalidVisibility = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}`), 'PATCH', { + visibility: 'internal', + }); + expect(invalidVisibility.status).toBe(422); - it('should include documentation_url in 404 response', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/999')); - const data = parse(res); - expect(data.documentation_url).toBeDefined(); + const invalidBoolean = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}`), 'PATCH', { + has_issues: 'yes', + }); + expect(invalidBoolean.status).toBe(422); + + const invalidPolicy = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}`), 'PATCH', { + pull_request_creation_policy: 'owners_only', + }); + expect(invalidPolicy.status).toBe(422); + + const duplicateName = await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}`), 'PATCH', { + name: 'test-repo', + }); + expect(duplicateName.status).toBe(422); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo`), 'PATCH', { + description: 'Missing', + }); + expect(missingRepo.status).toBe(404); }); - }); - // ========================================================================= - // GET /repos/:did/:repo/issues/:number/comments - // ========================================================================= + it('should request repository transfer asynchronously', async () => { + const transfer = await handleShimRequest(ctx, repoUrl('/transfer'), 'POST', { + new_owner : ORG_NAME, + new_name : TRANSFERRED_REPO_NAME, + team_ids : [12, 345], + }); + expect(transfer.status).toBe(202); + const transferred = parse(transfer); + expect(transferred.name).toBe('test-repo'); + expect(transferred.full_name).toBe(`${testDid}/test-repo`); + expect(transferred.owner.login).toBe(testDid); + expect(transferred.transfer_request.new_owner).toBe(ORG_NAME); + expect(transferred.transfer_request.new_name).toBe(TRANSFERRED_REPO_NAME); + expect(transferred.transfer_request.team_ids).toEqual([12, 345]); + expect(transferred.transfer_request.requested_by).toBe(testDid); + + const fetched = await handleShimRequest(ctx, repoUrl('')); + expect(fetched.status).toBe(200); + expect(parse(fetched).full_name).toBe(`${testDid}/test-repo`); + + const { records } = await ctx.repo.records.query('repo/settings' as any, { + filter: { contextId: repoContextId }, + }); + const settings = await (records[0] as any).data.json(); + expect(settings.transferRequest.newOwner).toBe(ORG_NAME); + expect(settings.transferRequest.newName).toBe(TRANSFERRED_REPO_NAME); + expect(settings.transferRequest.teamIds).toEqual([12, 345]); - describe('GET /repos/:did/:repo/issues/:number/comments', () => { - it('should return issue comments', async () => { - const issueNum = numericId(issueRecId); + const missingOwner = await handleShimRequest(ctx, repoUrl('/transfer'), 'POST', {}); + expect(missingOwner.status).toBe(422); + + const invalidName = await handleShimRequest(ctx, repoUrl('/transfer'), 'POST', { + new_owner : ORG_NAME, + new_name : '../bad', + }); + expect(invalidName.status).toBe(422); + + const invalidTeamIds = await handleShimRequest(ctx, repoUrl('/transfer'), 'POST', { + new_owner : ORG_NAME, + team_ids : [0], + }); + expect(invalidTeamIds.status).toBe(422); + + const personalTeamIds = await handleShimRequest(ctx, repoUrl('/transfer'), 'POST', { + new_owner : FOLLOW_TARGET_DID, + team_ids : [12], + }); + expect(personalTeamIds.status).toBe(422); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/transfer`), 'POST', { + new_owner: ORG_NAME, + }); + expect(missingRepo.status).toBe(404); + }); + + it('should delete repository metadata and local storage', async () => { + const create = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name: REPO_DELETE_REPO_NAME, + }, null, shimOptions()); + expect(create.status).toBe(201); + + const backend = new GitBackend({ basePath: REPOS_PATH }); + expect(backend.exists(testDid, REPO_DELETE_REPO_NAME)).toBe(true); + + const deleted = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_DELETE_REPO_NAME}`), 'DELETE', {}, null, shimOptions()); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + expect(backend.exists(testDid, REPO_DELETE_REPO_NAME)).toBe(false); + + const fetched = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_DELETE_REPO_NAME}`)); + expect(fetched.status).toBe(404); + + const duplicateDelete = await handleShimRequest(ctx, url(`/repos/${testDid}/${REPO_DELETE_REPO_NAME}`), 'DELETE', {}, null, shimOptions()); + expect(duplicateDelete.status).toBe(404); + }); + }); + + // ========================================================================= + // Repository fork endpoints + // ========================================================================= + + describe('repository fork endpoints', () => { + it('should create and list repository forks', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/forks')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const create = await handleShimRequest(ctx, repoUrl('/forks'), 'POST', { + default_branch_only : true, + name : FORKED_REPO_NAME, + }, null, shimOptions()); + expect(create.status).toBe(202); + const fork = parse(create); + expect(fork.name).toBe(FORKED_REPO_NAME); + expect(fork.fork).toBe(true); + expect(fork.parent.full_name).toBe(`${testDid}/test-repo`); + expect(fork.source.full_name).toBe(`${testDid}/test-repo`); + + const list = await handleShimRequest(ctx, repoUrl('/forks?sort=oldest')); + expect(list.status).toBe(200); + const forks = parse(list); + expect(forks.map((repo: any) => repo.name)).toContain(FORKED_REPO_NAME); + + const fetched = await handleShimRequest(ctx, url(`/repos/${testDid}/${FORKED_REPO_NAME}`)); + expect(fetched.status).toBe(200); + expect(parse(fetched).fork).toBe(true); + + const forkRepos = parse(await handleShimRequest(ctx, url('/user/repos?type=forks'))); + expect(forkRepos.map((repo: any) => repo.name)).toContain(FORKED_REPO_NAME); + + const sourceRepos = parse(await handleShimRequest(ctx, url('/user/repos?type=sources'))); + expect(sourceRepos.map((repo: any) => repo.name)).not.toContain(FORKED_REPO_NAME); + + const copiedReadme = await handleShimRequest( + ctx, url(`/repos/${testDid}/${FORKED_REPO_NAME}/contents/README.md?ref=main`), 'GET', {}, null, shimOptions(), + ); + expect(copiedReadme.status).toBe(200); + expect(parse(copiedReadme).name).toBe('README.md'); + }); + + it('should reject invalid repository fork requests', async () => { + const invalidName = await handleShimRequest(ctx, repoUrl('/forks'), 'POST', { + name: '../bad', + }); + expect(invalidName.status).toBe(422); + + const invalidDefaultBranchOnly = await handleShimRequest(ctx, repoUrl('/forks'), 'POST', { + default_branch_only : 'yes', + name : 'fork-invalid-default-branch-only', + }); + expect(invalidDefaultBranchOnly.status).toBe(422); + + const unsupportedOrg = await handleShimRequest(ctx, repoUrl('/forks'), 'POST', { + organization : ORG_NAME, + name : 'org-fork', + }); + expect(unsupportedOrg.status).toBe(422); + + const duplicate = await handleShimRequest(ctx, repoUrl('/forks'), 'POST', { + name: FORKED_REPO_NAME, + }); + expect(duplicate.status).toBe(422); + + const invalidSort = await handleShimRequest(ctx, repoUrl('/forks?sort=random')); + expect(invalidSort.status).toBe(422); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/forks`)); + expect(missingRepo.status).toBe(404); + }); + }); + + // ========================================================================= + // Starring endpoints + // ========================================================================= + + describe('starring endpoints', () => { + it('should list no authenticated user stars initially', async () => { + const res = await handleShimRequest(ctx, url('/user/starred')); + expect(res.status).toBe(200); + expect(parse(res)).toEqual([]); + }); + + it('should star a repository idempotently and report star status', async () => { + const starPath = `/user/starred/${testDid}/test-repo`; + const starRes = await handleShimRequest(ctx, url(starPath), 'PUT'); + expect(starRes.status).toBe(204); + expect(starRes.body).toBe(''); + + const duplicateRes = await handleShimRequest(ctx, url(starPath), 'PUT'); + expect(duplicateRes.status).toBe(204); + + const checkRes = await handleShimRequest(ctx, url(starPath)); + expect(checkRes.status).toBe(204); + }); + + it('should list repositories starred by the authenticated user', async () => { + const res = await handleShimRequest(ctx, url('/user/starred')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].name).toBe('test-repo'); + expect(data[0].full_name).toBe(`${testDid}/test-repo`); + }); + + it('should list repositories starred by a user DID', async () => { + const res = await handleShimRequest(ctx, url(`/users/${testDid}/starred`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].name).toBe('test-repo'); + }); + + it('should list stargazers visible to the local actor', async () => { + const res = await handleShimRequest(ctx, repoUrl('/stargazers')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].login).toBe(testDid); + expect(data[0].starred_url).toContain('/starred'); + }); + + it('should unstar a repository and return 404 when checking it', async () => { + const starPath = `/user/starred/${testDid}/test-repo`; + const unstarRes = await handleShimRequest(ctx, url(starPath), 'DELETE'); + expect(unstarRes.status).toBe(204); + + const checkRes = await handleShimRequest(ctx, url(starPath)); + expect(checkRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest(ctx, url(`/user/starred/${testDid}/missing-repo`), 'PUT'); + expect(missingRepoRes.status).toBe(404); + }); + }); + + // ========================================================================= + // Watching endpoints + // ========================================================================= + + describe('watching endpoints', () => { + it('should set, list, update, and delete repository subscriptions', async () => { + const subscriptionPath = `/repos/${testDid}/${EMPTY_REPO_NAME}/subscription`; + const initial = await handleShimRequest(ctx, url('/user/subscriptions')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const setRes = await handleShimRequest(ctx, url(subscriptionPath), 'PUT', { + ignored : false, + subscribed : true, + }); + expect(setRes.status).toBe(200); + const setData = parse(setRes); + expect(setData.subscribed).toBe(true); + expect(setData.ignored).toBe(false); + expect(setData.url).toContain('/subscription'); + expect(setData.repository_url).toContain(`/repos/${testDid}/${EMPTY_REPO_NAME}`); + + const getRes = await handleShimRequest(ctx, url(subscriptionPath)); + expect(getRes.status).toBe(200); + expect(parse(getRes).subscribed).toBe(true); + + const subscribers = parse(await handleShimRequest(ctx, url(`/repos/${testDid}/${EMPTY_REPO_NAME}/subscribers`))); + expect(subscribers.map((item: any) => item.login)).toContain(testDid); + + const watched = parse(await handleShimRequest(ctx, url('/user/subscriptions'))); + expect(watched.map((repo: any) => repo.name)).toContain(EMPTY_REPO_NAME); + + const watchedByUser = parse(await handleShimRequest(ctx, url(`/users/${testDid}/subscriptions`))); + expect(watchedByUser.map((repo: any) => repo.name)).toContain(EMPTY_REPO_NAME); + + const ignored = await handleShimRequest(ctx, url(subscriptionPath), 'PUT', { ignored: true }); + expect(ignored.status).toBe(200); + expect(parse(ignored).subscribed).toBe(false); + expect(parse(ignored).ignored).toBe(true); + + const deleteRes = await handleShimRequest(ctx, url(subscriptionPath), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const afterDelete = await handleShimRequest(ctx, url(subscriptionPath)); + expect(afterDelete.status).toBe(404); + expect(parse(await handleShimRequest(ctx, url('/user/subscriptions')))).toEqual([]); + }); + + it('should reject invalid repository subscription requests', async () => { + const invalidSubscribed = await handleShimRequest( + ctx, + url(`/repos/${testDid}/${EMPTY_REPO_NAME}/subscription`), + 'PUT', + { subscribed: 'yes' }, + ); + expect(invalidSubscribed.status).toBe(422); + + const invalidIgnored = await handleShimRequest( + ctx, + url(`/repos/${testDid}/${EMPTY_REPO_NAME}/subscription`), + 'PUT', + { ignored: 'yes' }, + ); + expect(invalidIgnored.status).toBe(422); + + const missingRepo = await handleShimRequest( + ctx, + url(`/repos/${testDid}/missing-repo/subscription`), + 'PUT', + { subscribed: true }, + ); + expect(missingRepo.status).toBe(404); + }); + }); + + // ========================================================================= + // Following endpoints + // ========================================================================= + + describe('following endpoints', () => { + it('should list no authenticated following or followers initially', async () => { + const following = await handleShimRequest(ctx, url('/user/following')); + expect(following.status).toBe(200); + expect(parse(following)).toEqual([]); + + const followers = await handleShimRequest(ctx, url('/user/followers')); + expect(followers.status).toBe(200); + expect(parse(followers)).toEqual([]); + }); + + it('should follow a user idempotently and expose public following checks', async () => { + const followPath = `/user/following/${FOLLOW_TARGET_DID}`; + const followRes = await handleShimRequest(ctx, url(followPath), 'PUT'); + expect(followRes.status).toBe(204); + + const duplicateRes = await handleShimRequest(ctx, url(followPath), 'PUT'); + expect(duplicateRes.status).toBe(204); + + const checkRes = await handleShimRequest(ctx, url(followPath)); + expect(checkRes.status).toBe(204); + + const publicCheck = await handleShimRequest(ctx, url(`/users/${testDid}/following/${FOLLOW_TARGET_DID}`)); + expect(publicCheck.status).toBe(204); + + const following = parse(await handleShimRequest(ctx, url('/user/following'))); + expect(following.map((item: any) => item.login)).toContain(FOLLOW_TARGET_DID); + + const publicFollowing = parse(await handleShimRequest(ctx, url(`/users/${testDid}/following`))); + expect(publicFollowing.map((item: any) => item.login)).toContain(FOLLOW_TARGET_DID); + + const targetFollowers = parse(await handleShimRequest(ctx, url(`/users/${FOLLOW_TARGET_DID}/followers`))); + expect(targetFollowers.map((item: any) => item.login)).toContain(testDid); + }); + + it('should paginate following lists and reject self-follows', async () => { + const secondFollow = await handleShimRequest(ctx, url(`/user/following/${FOLLOW_TARGET_TWO_DID}`), 'PUT'); + expect(secondFollow.status).toBe(204); + + const paged = await handleShimRequest(ctx, url('/user/following?per_page=1')); + expect(paged.status).toBe(200); + expect(parse(paged)).toHaveLength(1); + expect(paged.headers.Link).toContain('rel="next"'); + + const selfFollow = await handleShimRequest(ctx, url(`/user/following/${testDid}`), 'PUT'); + expect(selfFollow.status).toBe(422); + }); + + it('should list, check, block, and unblock users for the authenticated user', async () => { + const blockedDid = 'did:jwk:userblocked123'; + const blockPath = `/user/blocks/${encodeURIComponent(blockedDid)}`; + + const initial = await handleShimRequest(ctx, url('/user/blocks')); + expect(initial.status).toBe(200); + expect(parse(initial).map((item: any) => item.login)).not.toContain(blockedDid); + + const missingCheck = await handleShimRequest(ctx, url(blockPath)); + expect(missingCheck.status).toBe(404); + + const block = await handleShimRequest(ctx, url(blockPath), 'PUT'); + expect(block.status).toBe(204); + expect(block.body).toBe(''); + + const duplicateBlock = await handleShimRequest(ctx, url(blockPath), 'PUT'); + expect(duplicateBlock.status).toBe(204); + + const blocked = parse(await handleShimRequest(ctx, url('/user/blocks'))); + const blockedUser = blocked.find((item: any) => item.login === blockedDid); + expect(blockedUser.id).toBe(numericId(blockedDid)); + expect(blockedUser.following_url).toContain(`/users/${blockedDid}/following{/other_user}`); + + const check = await handleShimRequest(ctx, url(blockPath)); + expect(check.status).toBe(204); + + const selfBlock = await handleShimRequest(ctx, url(`/user/blocks/${encodeURIComponent(testDid)}`), 'PUT'); + expect(selfBlock.status).toBe(422); + + const unblock = await handleShimRequest(ctx, url(blockPath), 'DELETE'); + expect(unblock.status).toBe(204); + expect(unblock.body).toBe(''); + + const checkAfterUnblock = await handleShimRequest(ctx, url(blockPath)); + expect(checkAfterUnblock.status).toBe(404); + + const duplicateUnblock = await handleShimRequest(ctx, url(blockPath), 'DELETE'); + expect(duplicateUnblock.status).toBe(204); + }); + + it('should unfollow a user and return 404 for follow checks afterward', async () => { + const unfollow = await handleShimRequest(ctx, url(`/user/following/${FOLLOW_TARGET_DID}`), 'DELETE'); + expect(unfollow.status).toBe(204); + + const check = await handleShimRequest(ctx, url(`/user/following/${FOLLOW_TARGET_DID}`)); + expect(check.status).toBe(404); + + const publicCheck = await handleShimRequest(ctx, url(`/users/${testDid}/following/${FOLLOW_TARGET_DID}`)); + expect(publicCheck.status).toBe(404); + + const targetFollowers = parse(await handleShimRequest(ctx, url(`/users/${FOLLOW_TARGET_DID}/followers`))); + expect(targetFollowers.map((item: any) => item.login)).not.toContain(testDid); + + const secondUnfollow = await handleShimRequest(ctx, url(`/user/following/${FOLLOW_TARGET_TWO_DID}`), 'DELETE'); + expect(secondUnfollow.status).toBe(204); + }); + }); + + // ========================================================================= + // User email endpoints + // ========================================================================= + + describe('user email endpoints', () => { + it('should add, list, publish, and delete authenticated user emails', async () => { + const initial = await handleShimRequest(ctx, url('/user/emails')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const add = await handleShimRequest(ctx, url('/user/emails'), 'POST', { + emails: ['primary@example.test', 'secondary@example.test'], + }); + expect(add.status).toBe(201); + const added = parse(add); + expect(added).toContainEqual({ + email : 'primary@example.test', + verified : false, + primary : true, + visibility : 'private', + }); + expect(added).toContainEqual({ + email : 'secondary@example.test', + verified : false, + primary : false, + visibility : null, + }); + + const duplicate = await handleShimRequest(ctx, url('/user/emails'), 'POST', { emails: ['primary@example.test'] }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, url('/user/emails'))); + expect(list.map((item: any) => item.email)).toEqual(['primary@example.test', 'secondary@example.test']); + + const initialPublic = parse(await handleShimRequest(ctx, url('/user/public_emails'))); + expect(initialPublic).toEqual([]); + + const visibility = await handleShimRequest(ctx, url('/user/email/visibility'), 'PATCH', { visibility: 'public' }); + expect(visibility.status).toBe(200); + const visibleEmails = parse(visibility); + expect(visibleEmails.find((item: any) => item.email === 'primary@example.test').visibility).toBe('public'); + + const publicEmails = parse(await handleShimRequest(ctx, url('/user/public_emails'))); + expect(publicEmails).toEqual([{ + email : 'primary@example.test', + verified : false, + primary : true, + visibility : 'public', + }]); + + const invalidVisibility = await handleShimRequest(ctx, url('/user/email/visibility'), 'PATCH', { visibility: 'friends' }); + expect(invalidVisibility.status).toBe(422); + + const invalidAdd = await handleShimRequest(ctx, url('/user/emails'), 'POST', { emails: ['not-an-email'] }); + expect(invalidAdd.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url('/user/emails'), 'DELETE', { emails: ['secondary@example.test'] }); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const afterDelete = parse(await handleShimRequest(ctx, url('/user/emails'))); + expect(afterDelete.map((item: any) => item.email)).toEqual(['primary@example.test']); + + const missingDelete = await handleShimRequest(ctx, url('/user/emails'), 'DELETE', { emails: ['missing@example.test'] }); + expect(missingDelete.status).toBe(422); + }); + }); + + // ========================================================================= + // User social account endpoints + // ========================================================================= + + describe('user social account endpoints', () => { + it('should add, list, publicly list, and delete authenticated user social accounts', async () => { + const initial = await handleShimRequest(ctx, url('/user/social_accounts')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const add = await handleShimRequest(ctx, url('/user/social_accounts'), 'POST', { + account_urls: ['https://twitter.com/gitd', 'https://www.youtube.com/@gitd'], + }); + expect(add.status).toBe(201); + expect(parse(add)).toEqual([ + { + provider : 'twitter', + url : 'https://twitter.com/gitd', + }, + { + provider : 'youtube', + url : 'https://www.youtube.com/@gitd', + }, + ]); + + const duplicate = await handleShimRequest(ctx, url('/user/social_accounts'), 'POST', { + account_urls: ['https://twitter.com/gitd/'], + }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, url('/user/social_accounts'))); + expect(list).toEqual([ + { + provider : 'twitter', + url : 'https://twitter.com/gitd', + }, + { + provider : 'youtube', + url : 'https://www.youtube.com/@gitd', + }, + ]); + + const publicList = parse(await handleShimRequest(ctx, url(`/users/${testDid}/social_accounts`))); + expect(publicList).toEqual(list); + + const invalidAdd = await handleShimRequest(ctx, url('/user/social_accounts'), 'POST', { + account_urls: ['not-a-url'], + }); + expect(invalidAdd.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url('/user/social_accounts'), 'DELETE', { + account_urls: ['https://twitter.com/gitd'], + }); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const afterDelete = parse(await handleShimRequest(ctx, url('/user/social_accounts'))); + expect(afterDelete).toEqual([ + { + provider : 'youtube', + url : 'https://www.youtube.com/@gitd', + }, + ]); + + const missingDelete = await handleShimRequest(ctx, url('/user/social_accounts'), 'DELETE', { + account_urls: ['https://twitter.com/gitd'], + }); + expect(missingDelete.status).toBe(422); + }); + }); + + // ========================================================================= + // Gist endpoints + // ========================================================================= + + describe('gist endpoints', () => { + it('should create, list, update, publicly list, and delete gists', async () => { + const invalidCreate = await handleShimRequest(ctx, url('/gists'), 'POST', { description: 'missing files' }); + expect(invalidCreate.status).toBe(422); + + const secretCreate = await handleShimRequest(ctx, url('/gists'), 'POST', { + description : 'private scratch note', + public : false, + files : { + 'secret.txt': { content: 'not listed publicly' }, + }, + }); + expect(secretCreate.status).toBe(201); + const secret = parse(secretCreate); + expect(secret.public).toBe(false); + + const create = await handleShimRequest(ctx, url('/gists'), 'POST', { + description : 'public gist', + public : true, + files : { + 'README.md' : { content: '# GitD\n' }, + 'delete.txt' : { content: 'remove me' }, + 'docs/info.txt' : { content: 'nested file' }, + }, + }); + expect(create.status).toBe(201); + const created = parse(create); + expect(created.description).toBe('public gist'); + expect(created.public).toBe(true); + expect(created.owner.login).toBe(testDid); + expect(created.files['README.md'].content).toBe('# GitD\n'); + expect(created.files['README.md'].language).toBe('Markdown'); + + const authenticatedList = parse(await handleShimRequest(ctx, url('/gists'))); + expect(authenticatedList.map((item: any) => item.id)).toContain(secret.id); + expect(authenticatedList.map((item: any) => item.id)).toContain(created.id); + + const publicList = parse(await handleShimRequest(ctx, url('/gists/public'))); + expect(publicList.map((item: any) => item.id)).toContain(created.id); + expect(publicList.map((item: any) => item.id)).not.toContain(secret.id); + + const userList = parse(await handleShimRequest(ctx, url(`/users/${testDid}/gists`))); + expect(userList.map((item: any) => item.id)).toContain(created.id); + expect(userList.map((item: any) => item.id)).not.toContain(secret.id); + + const emptyForks = parse(await handleShimRequest(ctx, url(`/gists/${created.id}/forks`))); + expect(emptyForks).toEqual([]); + + const forkRes = await handleShimRequest(ctx, url(`/gists/${created.id}/forks`), 'POST'); + expect(forkRes.status).toBe(201); + const fork = parse(forkRes); + expect(fork.id).not.toBe(created.id); + expect(fork.owner.login).toBe(testDid); + expect(fork.fork_of.id).toBe(created.id); + expect(fork.fork_of.owner.login).toBe(testDid); + expect(fork.files['README.md'].content).toBe('# GitD\n'); + + const forks = parse(await handleShimRequest(ctx, url(`/gists/${created.id}/forks`))); + expect(forks.map((item: any) => item.id)).toContain(fork.id); + + const duplicateFork = await handleShimRequest(ctx, url(`/gists/${created.id}/forks`), 'POST'); + expect(duplicateFork.status).toBe(422); + + const starredInitially = parse(await handleShimRequest(ctx, url('/gists/starred'))); + expect(starredInitially.map((item: any) => item.id)).not.toContain(created.id); + + const checkUnstarred = await handleShimRequest(ctx, url(`/gists/${created.id}/star`)); + expect(checkUnstarred.status).toBe(404); + + const star = await handleShimRequest(ctx, url(`/gists/${created.id}/star`), 'PUT'); + expect(star.status).toBe(204); + expect(star.body).toBe(''); + + const checkStarred = await handleShimRequest(ctx, url(`/gists/${created.id}/star`)); + expect(checkStarred.status).toBe(204); + expect(checkStarred.body).toBe(''); + + const starredList = parse(await handleShimRequest(ctx, url('/gists/starred'))); + expect(starredList.map((item: any) => item.id)).toContain(created.id); + + const duplicateStar = await handleShimRequest(ctx, url(`/gists/${created.id}/star`), 'PUT'); + expect(duplicateStar.status).toBe(204); + + const unstar = await handleShimRequest(ctx, url(`/gists/${created.id}/star`), 'DELETE'); + expect(unstar.status).toBe(204); + expect(unstar.body).toBe(''); + + const checkAfterUnstar = await handleShimRequest(ctx, url(`/gists/${created.id}/star`)); + expect(checkAfterUnstar.status).toBe(404); + + const get = await handleShimRequest(ctx, url(`/gists/${created.id}`)); + expect(get.status).toBe(200); + const fetched = parse(get); + expect(fetched.files['README.md'].content).toBe('# GitD\n'); + expect(fetched.history[0].version).toMatch(/^[0-9a-f]{40}$/); + + const commits = parse(await handleShimRequest(ctx, new URL(fetched.commits_url))); + expect(commits).toHaveLength(1); + expect(commits[0].version).toBe(fetched.history[0].version); + expect(commits[0].url).toContain(`/gists/${created.id}/${commits[0].version}`); + expect(commits[0].user.login).toBe(testDid); + expect(commits[0].change_status).toEqual({ additions: 3, deletions: 0, total: 3 }); + + const commitsPage2Url = new URL(fetched.commits_url); + commitsPage2Url.searchParams.set('per_page', '1'); + commitsPage2Url.searchParams.set('page', '2'); + expect(parse(await handleShimRequest(ctx, commitsPage2Url))).toEqual([]); + + const revision = parse(await handleShimRequest(ctx, new URL(commits[0].url))); + expect(revision.id).toBe(created.id); + expect(revision.files['README.md'].content).toBe('# GitD\n'); + expect(revision.history[0].version).toBe(commits[0].version); + + const missingRevision = await handleShimRequest(ctx, url(`/gists/${created.id}/deadbeef`)); + expect(missingRevision.status).toBe(404); + + const raw = await handleShimRequest(ctx, new URL(fetched.files['README.md'].raw_url)); + expect(raw.status).toBe(200); + expect(raw.headers['Content-Type']).toBe('text/markdown; charset=utf-8'); + expect(bodyBuffer(raw).toString('utf-8')).toBe('# GitD\n'); + + const nestedRaw = await handleShimRequest(ctx, new URL(fetched.files['docs/info.txt'].raw_url)); + expect(nestedRaw.status).toBe(200); + expect(nestedRaw.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(bodyBuffer(nestedRaw).toString('utf-8')).toBe('nested file'); + + const missingRaw = await handleShimRequest(ctx, url(`/gists/${created.id}/raw/missing.txt`)); + expect(missingRaw.status).toBe(404); + + const update = await handleShimRequest(ctx, url(`/gists/${created.id}`), 'PATCH', { + description : 'updated gist', + files : { + 'README.md' : { filename: 'NOTES.md', content: 'updated note' }, + 'delete.txt' : null, + 'docs/info.txt' : null, + }, + }); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.description).toBe('updated gist'); + expect(updated.files['NOTES.md'].content).toBe('updated note'); + expect(updated.files['README.md']).toBeUndefined(); + expect(updated.files['delete.txt']).toBeUndefined(); + expect(updated.files['docs/info.txt']).toBeUndefined(); + + const invalidUpdate = await handleShimRequest(ctx, url(`/gists/${created.id}`), 'PATCH', { + files: { 'NOTES.md': null }, + }); + expect(invalidUpdate.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url(`/gists/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, url(`/gists/${created.id}`)); + expect(missing.status).toBe(404); + }, 10_000); + + it('should create, list, get, update, and delete gist comments', async () => { + const createGist = await handleShimRequest(ctx, url('/gists'), 'POST', { + description : 'commented gist', + public : true, + files : { + 'README.md': { content: 'comment target' }, + }, + }); + expect(createGist.status).toBe(201); + const gist = parse(createGist); + + const emptyList = parse(await handleShimRequest(ctx, url(`/gists/${gist.id}/comments`))); + expect(emptyList).toEqual([]); + + const invalidCreate = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments`), 'POST', {}); + expect(invalidCreate.status).toBe(422); + + const createComment = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments`), 'POST', { + body: 'First comment.', + }); + expect(createComment.status).toBe(201); + const comment = parse(createComment); + expect(comment.body).toBe('First comment.'); + expect(comment.user.login).toBe(testDid); + expect(comment.url).toContain(`/gists/${gist.id}/comments/${comment.id}`); + expect(comment.author_association).toBe('OWNER'); + + const detailWithComment = parse(await handleShimRequest(ctx, url(`/gists/${gist.id}`))); + expect(detailWithComment.comments).toBe(1); + expect(detailWithComment.comments_enabled).toBe(true); + + const list = parse(await handleShimRequest(ctx, url(`/gists/${gist.id}/comments`))); + expect(list.map((item: any) => item.id)).toContain(comment.id); + + const get = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments/${comment.id}`)); + expect(get.status).toBe(200); + expect(parse(get).body).toBe('First comment.'); + + const update = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments/${comment.id}`), 'PATCH', { + body: 'Updated comment.', + }); + expect(update.status).toBe(200); + expect(parse(update).body).toBe('Updated comment.'); + + const invalidUpdate = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments/${comment.id}`), 'PATCH', {}); + expect(invalidUpdate.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments/${comment.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missingComment = await handleShimRequest(ctx, url(`/gists/${gist.id}/comments/${comment.id}`)); + expect(missingComment.status).toBe(404); + + const detailAfterDelete = parse(await handleShimRequest(ctx, url(`/gists/${gist.id}`))); + expect(detailAfterDelete.comments).toBe(0); + }); + }); + + // ========================================================================= + // User account key endpoints + // ========================================================================= + + describe('user account key endpoints', () => { + it('should create, list, get, publicly list, and delete authenticated GPG keys', async () => { + const initial = await handleShimRequest(ctx, url('/user/gpg_keys')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const armoredPublicKey = [ + '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'Version: gitd-test', + '', + 'mQENBGitdGpgTest alice@example.test', + '=test', + '-----END PGP PUBLIC KEY BLOCK-----', + ].join('\n'); + const create = await handleShimRequest(ctx, url('/user/gpg_keys'), 'POST', { + name : 'work signing key', + armored_public_key : armoredPublicKey, + }); + expect(create.status).toBe(201); + const created = parse(create); + expect(created.name).toBe('work signing key'); + expect(created.id).toBeGreaterThan(0); + expect(created.primary_key_id).toBe(created.id); + expect(created.key_id).toMatch(/^[A-F0-9]{16}$/); + expect(created.public_key).toBe(armoredPublicKey); + expect(created.raw_key).toBe(armoredPublicKey); + expect(created.emails).toContainEqual({ email: 'alice@example.test', verified: false }); + expect(created.subkeys).toEqual([]); + expect(created.can_sign).toBe(true); + expect(created.can_certify).toBe(true); + expect(created.can_encrypt_comms).toBe(false); + expect(created.revoked).toBe(false); + + const duplicate = await handleShimRequest(ctx, url('/user/gpg_keys'), 'POST', { + name : 'duplicate', + armored_public_key : armoredPublicKey, + }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, url('/user/gpg_keys'))); + expect(list.some((item: any) => item.id === created.id)).toBe(true); + + const publicList = parse(await handleShimRequest(ctx, url(`/users/${testDid}/gpg_keys`))); + const publicKey = publicList.find((item: any) => item.id === created.id); + expect(publicKey.key_id).toBe(created.key_id); + expect(publicKey.raw_key).toBe(armoredPublicKey); + + const get = await handleShimRequest(ctx, url(`/user/gpg_keys/${created.id}`)); + expect(get.status).toBe(200); + expect(parse(get).key_id).toBe(created.key_id); + + const invalid = await handleShimRequest(ctx, url('/user/gpg_keys'), 'POST', { name: 'bad' }); + expect(invalid.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url(`/user/gpg_keys/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, url(`/user/gpg_keys/${created.id}`)); + expect(missing.status).toBe(404); + }); + + it('should create, list, get, publicly list, and delete authenticated user SSH keys', async () => { + const initial = await handleShimRequest(ctx, url('/user/keys')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIuserkey1234567890 gitd@example.test'; + const create = await handleShimRequest(ctx, url('/user/keys'), 'POST', { title: 'work laptop', key }); + expect(create.status).toBe(201); + const created = parse(create); + expect(created.key).toBe(key); + expect(created.title).toBe('work laptop'); + expect(created.id).toBeGreaterThan(0); + expect(created.url).toContain(`/user/keys/${created.id}`); + expect(created.verified).toBe(false); + expect(created.read_only).toBe(false); + + const duplicate = await handleShimRequest(ctx, url('/user/keys'), 'POST', { title: 'duplicate', key }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, url('/user/keys'))); + expect(list.some((item: any) => item.id === created.id)).toBe(true); + + const publicList = parse(await handleShimRequest(ctx, url(`/users/${testDid}/keys`))); + expect(publicList).toContainEqual({ id: created.id, key }); + + const get = await handleShimRequest(ctx, url(`/user/keys/${created.id}`)); + expect(get.status).toBe(200); + expect(parse(get).key).toBe(key); + + const invalid = await handleShimRequest(ctx, url('/user/keys'), 'POST', { title: 'bad' }); + expect(invalid.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url(`/user/keys/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, url(`/user/keys/${created.id}`)); + expect(missing.status).toBe(404); + }); + + it('should create, list, get, publicly list, and delete authenticated SSH signing keys', async () => { + const initial = await handleShimRequest(ctx, url('/user/ssh_signing_keys')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIsigningkey1234567890 gitd-sign@example.test'; + const create = await handleShimRequest(ctx, url('/user/ssh_signing_keys'), 'POST', { title: 'signing laptop', key }); + expect(create.status).toBe(201); + const created = parse(create); + expect(created.key).toBe(key); + expect(created.title).toBe('signing laptop'); + expect(created.id).toBeGreaterThan(0); + expect(typeof created.created_at).toBe('string'); + expect(created.verified).toBeUndefined(); + expect(created.read_only).toBeUndefined(); + + const duplicate = await handleShimRequest(ctx, url('/user/ssh_signing_keys'), 'POST', { title: 'duplicate', key }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, url('/user/ssh_signing_keys'))); + expect(list.some((item: any) => item.id === created.id)).toBe(true); + + const publicList = parse(await handleShimRequest(ctx, url(`/users/${testDid}/ssh_signing_keys`))); + const publicKey = publicList.find((item: any) => item.id === created.id); + expect(publicKey.key).toBe(key); + expect(publicKey.title).toBe('signing laptop'); + + const get = await handleShimRequest(ctx, url(`/user/ssh_signing_keys/${created.id}`)); + expect(get.status).toBe(200); + expect(parse(get).key).toBe(key); + + const invalid = await handleShimRequest(ctx, url('/user/ssh_signing_keys'), 'POST', { title: 'bad' }); + expect(invalid.status).toBe(422); + + const deleted = await handleShimRequest(ctx, url(`/user/ssh_signing_keys/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, url(`/user/ssh_signing_keys/${created.id}`)); + expect(missing.status).toBe(404); + }); + }); + + // ========================================================================= + // Activity event endpoints + // ========================================================================= + + describe('activity event endpoints', () => { + it('should list global and user activity events with GitHub event shapes', async () => { + const global = await handleShimRequest(ctx, url('/events?per_page=2')); + expect(global.status).toBe(200); + expect(global.headers['X-Poll-Interval']).toBe('60'); + expect(global.headers.Link).toContain('/events'); + const globalEvents = parse(global); + expect(globalEvents.length).toBe(2); + expect(globalEvents[0].actor.login).toBe(testDid); + expect(globalEvents[0].repo.name).toContain(`${testDid}/`); + expect(globalEvents[0].payload).toBeDefined(); + expect(globalEvents.some((event: any) => event.repo.name === `${testDid}/test-repo`)).toBe(true); + + const userEvents = await handleShimRequest(ctx, url(`/users/${testDid}/events`)); + expect(userEvents.status).toBe(200); + const events = parse(userEvents); + expect(events.map((event: any) => event.type)).toContain('PushEvent'); + expect(events.map((event: any) => event.type)).toContain('WatchEvent'); + expect(events.map((event: any) => event.type)).toContain('ReleaseEvent'); + + const push = events.find((event: any) => event.type === 'PushEvent'); + expect(push.payload.ref).toBe('refs/heads/main'); + expect(push.payload.head).toBe(MAIN_SHA); + expect(push.public).toBe(true); + }); + + it('should filter public and received activity event feeds', async () => { + const publicEvents = await handleShimRequest(ctx, url(`/users/${testDid}/events/public`)); + expect(publicEvents.status).toBe(200); + expect(parse(publicEvents).map((event: any) => event.payload.release?.tag_name)).not.toContain('v-private'); + + const received = await handleShimRequest(ctx, url(`/users/${testDid}/received_events`)); + expect(received.status).toBe(200); + expect(parse(received).map((event: any) => event.type)).toContain('ReleaseEvent'); + + const receivedPublic = await handleShimRequest(ctx, url(`/users/${testDid}/received_events/public`)); + expect(receivedPublic.status).toBe(200); + expect(parse(receivedPublic).map((event: any) => event.payload.release?.tag_name)).not.toContain('v-private'); + }); + + it('should list repository and network activity events', async () => { + const repoEvents = await handleShimRequest(ctx, repoUrl('/events')); + expect(repoEvents.status).toBe(200); + const events = parse(repoEvents); + expect(events.map((event: any) => event.type)).toContain('PushEvent'); + expect(events.map((event: any) => event.type)).toContain('WatchEvent'); + expect(events.every((event: any) => event.repo.name === `${testDid}/test-repo`)).toBe(true); + + const networkEvents = await handleShimRequest(ctx, url(`/networks/${testDid}/test-repo/events`)); + expect(networkEvents.status).toBe(200); + expect(parse(networkEvents).map((event: any) => event.id)).toEqual(events.map((event: any) => event.id)); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/events`)); + expect(missingRepo.status).toBe(404); + }); + + it('should list repository activity history with GitHub activity filters', async () => { + const repoActivity = await handleShimRequest(ctx, repoUrl('/activity?activity_type=push&ref=main&direction=asc')); + expect(repoActivity.status).toBe(200); + const activity = parse(repoActivity); + expect(activity.length).toBe(1); + expect(activity[0].activity_type).toBe('push'); + expect(activity[0].ref).toBe('refs/heads/main'); + expect(activity[0].before).toBe(FEATURE_SHA); + expect(activity[0].after).toBe(MAIN_SHA); + expect(activity[0].actor.login).toBe(testDid); + expect(activity[0].timestamp).toBeDefined(); + + const wrongActor = parse(await handleShimRequest(ctx, repoUrl('/activity?actor=did:example:other'))); + expect(wrongActor).toEqual([]); + + const invalidType = await handleShimRequest(ctx, repoUrl('/activity?activity_type=release')); + expect(invalidType.status).toBe(422); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/activity`)); + expect(missingRepo.status).toBe(404); + }); + }); + + // ========================================================================= + // User and organization discovery endpoints + // ========================================================================= + + describe('user and organization discovery endpoints', () => { + it('should return the authenticated GitHub user profile', async () => { + const res = await handleShimRequest(ctx, url('/user')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.login).toBe(testDid); + expect(data.type).toBe('User'); + expect(data.url).toBe(`${BASE}/users/${testDid}`); + expect(data.repos_url).toBe(`${BASE}/users/${testDid}/repos`); + expect(data.public_repos).toBeGreaterThanOrEqual(3); + expect(data.total_private_repos).toBeGreaterThanOrEqual(1); + expect(data.plan.name).toBe('DWN'); + }); + + it('should update authenticated user profile metadata and expose it publicly', async () => { + const update = await handleShimRequest(ctx, url('/user'), 'PATCH', { + name : 'GitD Tester', + email : 'profile@example.test', + blog : 'https://gitd.example', + twitter_username : '@gitd', + company : 'Enbox', + location : 'Distributed Web', + hireable : true, + bio : 'Replacing GitHub-compatible workflows.', + }); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.name).toBe('GitD Tester'); + expect(updated.email).toBe('profile@example.test'); + expect(updated.blog).toBe('https://gitd.example'); + expect(updated.twitter_username).toBe('gitd'); + expect(updated.company).toBe('Enbox'); + expect(updated.location).toBe('Distributed Web'); + expect(updated.hireable).toBe(true); + expect(updated.bio).toBe('Replacing GitHub-compatible workflows.'); + expect(updated.updated_at).toBeDefined(); + + const authenticated = parse(await handleShimRequest(ctx, url('/user'))); + expect(authenticated.name).toBe('GitD Tester'); + expect(authenticated.plan.name).toBe('DWN'); + + const publicProfile = parse(await handleShimRequest(ctx, url(`/users/${testDid}`))); + expect(publicProfile.name).toBe('GitD Tester'); + expect(publicProfile.email).toBe('profile@example.test'); + expect(publicProfile.twitter_username).toBe('gitd'); + expect(publicProfile.plan).toBeUndefined(); + + const clear = await handleShimRequest(ctx, url('/user'), 'PATCH', { + blog : null, + twitter_username : null, + hireable : null, + }); + expect(clear.status).toBe(200); + const cleared = parse(clear); + expect(cleared.blog).toBe(''); + expect(cleared.twitter_username).toBeNull(); + expect(cleared.hireable).toBeNull(); + + const invalidHireable = await handleShimRequest(ctx, url('/user'), 'PATCH', { hireable: 'yes' }); + expect(invalidHireable.status).toBe(422); + + const invalidEmail = await handleShimRequest(ctx, url('/user'), 'PATCH', { email: 'not-an-email' }); + expect(invalidEmail.status).toBe(422); + }); + + it('should list visible users with since pagination', async () => { + const all = await handleShimRequest(ctx, url('/users?per_page=100')); + expect(all.status).toBe(200); + const users = parse(all); + const logins = users.map((item: any) => item.login); + expect(logins).toContain(testDid); + expect(logins).toContain(MAINTAINER_DID); + expect(logins).toContain(TRIAGER_DID); + expect(logins).toContain(ORG_MEMBER_DID); + + const ids = users.map((item: any) => item.id); + expect(ids).toEqual([...ids].sort((left, right) => left - right)); + + const firstPage = await handleShimRequest(ctx, url('/users?per_page=1')); + expect(firstPage.status).toBe(200); + expect(firstPage.headers.Link).toContain('/users?since='); + expect(firstPage.headers.Link).toContain('per_page=1'); + const firstUser = parse(firstPage)[0]; + + const afterFirst = parse(await handleShimRequest(ctx, url(`/users?since=${firstUser.id}&per_page=100`))); + expect(afterFirst.every((item: any) => item.id > firstUser.id)).toBe(true); + expect(afterFirst.map((item: any) => item.login)).not.toContain(firstUser.login); + + const afterAll = parse(await handleShimRequest(ctx, url(`/users?since=${Math.max(...ids)}&per_page=100`))); + expect(afterAll).toEqual([]); + }); + + it('should get visible users by numeric account ID and return hovercard contexts', async () => { + const users = parse(await handleShimRequest(ctx, url('/users?per_page=100'))); + const localUser = users.find((item: any) => item.login === testDid); + expect(localUser).toBeDefined(); + + const byId = await handleShimRequest(ctx, url(`/user/${localUser.id}`)); + expect(byId.status).toBe(200); + expect(parse(byId).login).toBe(testDid); + + const missing = await handleShimRequest(ctx, url('/user/999999999999')); + expect(missing.status).toBe(404); + + const hover = await handleShimRequest( + ctx, + url(`/users/${testDid}/hovercard?subject_type=repository&subject_id=${numericId(repoRecordId)}`), + ); + expect(hover.status).toBe(200); + const contexts = parse(hover).contexts; + expect(contexts).toContainEqual({ message: 'Owns this repository', octicon: 'repo' }); + expect(contexts.some((item: any) => item.octicon === 'person')).toBe(true); + + const invalidSubject = await handleShimRequest( + ctx, + url(`/users/${testDid}/hovercard?subject_type=discussion&subject_id=1`), + ); + expect(invalidSubject.status).toBe(422); + + const missingSubjectType = await handleShimRequest(ctx, url(`/users/${testDid}/hovercard?subject_id=1`)); + expect(missingSubjectType.status).toBe(422); + + const missingUserHovercard = await handleShimRequest(ctx, url('/users/did:jwk:missingvisible/hovercard')); + expect(missingUserHovercard.status).toBe(404); + }); + + it('should list public organizations using GitHub since pagination', async () => { + const res = await handleShimRequest(ctx, url('/organizations?per_page=1')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].login).toBe(ORG_NAME); + expect(data[0].members_url).toContain('/members{/member}'); + + const afterAll = await handleShimRequest(ctx, url('/organizations?since=9999999999')); + expect(afterAll.status).toBe(200); + expect(parse(afterAll)).toEqual([]); + }); + + it('should list organizations for the authenticated user', async () => { + const res = await handleShimRequest(ctx, url('/user/orgs')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.map((item: any) => item.login)).toContain(ORG_NAME); + expect(data[0].repos_url).toBe(`${BASE}/orgs/${ORG_NAME}/repos`); + }); + + it('should list public organizations for a user DID', async () => { + const ownerOrgs = await handleShimRequest(ctx, url(`/users/${testDid}/orgs`)); + expect(ownerOrgs.status).toBe(200); + expect(parse(ownerOrgs).map((item: any) => item.login)).toContain(ORG_NAME); + + const memberOrgs = await handleShimRequest(ctx, url(`/users/${ORG_MEMBER_DID}/orgs`)); + expect(memberOrgs.status).toBe(200); + expect(parse(memberOrgs).map((item: any) => item.login)).toContain(ORG_NAME); + + const empty = await handleShimRequest(ctx, url(`/users/${FOLLOW_TARGET_DID}/orgs`)); + expect(empty.status).toBe(200); + expect(parse(empty)).toEqual([]); + }); + }); + + // ========================================================================= + // Search endpoints + // ========================================================================= + + describe('search endpoints', () => { + it('should search repositories with GitHub search envelopes and pagination', async () => { + const res = await handleShimRequest(ctx, url('/search/repositories?q=repo&per_page=2')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBeGreaterThanOrEqual(4); + expect(data.incomplete_results).toBe(false); + expect(data.items).toHaveLength(2); + expect(data.items[0].full_name).toContain('/'); + expect(data.items[0].score).toBeGreaterThan(0); + expect(res.headers.Link).toContain('/search/repositories'); + + const topicRes = await handleShimRequest(ctx, url('/search/repositories?q=topic:git+decentralized')); + expect(topicRes.status).toBe(200); + expect(parse(topicRes).items.map((repo: any) => repo.name)).toContain('test-repo'); + }); + + it('should search local git code with repository and filename qualifiers', async () => { + const search = url('/search/code'); + search.searchParams.set('q', `answer repo:${testDid}/test-repo filename:index.ts`); + const res = await handleShimRequest(ctx, search, 'GET', {}, null, shimOptions()); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBe(1); + expect(data.incomplete_results).toBe(false); + expect(data.items[0].name).toBe('index.ts'); + expect(data.items[0].path).toBe('src/index.ts'); + expect(data.items[0].repository.full_name).toBe(`${testDid}/test-repo`); + expect(data.items[0].git_url).toContain('/git/blobs/'); + expect(data.items[0].score).toBeGreaterThan(0); + }); + + it('should search local git commit messages', async () => { + const search = url('/search/commits'); + search.searchParams.set('q', `feature repo:${testDid}/test-repo author-email:gitd@example.test`); + const res = await handleShimRequest(ctx, search, 'GET', {}, null, shimOptions()); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBe(1); + expect(data.items[0].sha).toBe(gitMainSha); + expect(data.items[0].commit.message).toBe('Add feature file'); + expect(data.items[0].repository.name).toBe('test-repo'); + }); + + it('should search repository labels by repository id', async () => { + const repo = parse(await handleShimRequest(ctx, repoUrl(''))); + const search = url('/search/labels'); + search.searchParams.set('repository_id', String(repo.id)); + search.searchParams.set('q', 'bug request'); + const res = await handleShimRequest(ctx, search); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBe(2); + expect(data.items.map((label: any) => label.name)).toEqual(['bug', 'enhancement']); + expect(data.items[0].score).toBeGreaterThan(0); + + const missingRepoId = await handleShimRequest(ctx, url('/search/labels?q=bug')); + expect(missingRepoId.status).toBe(422); + expect(parse(missingRepoId).message).toContain('repository_id'); + }); + + it('should search local repository topics', async () => { + const res = await handleShimRequest(ctx, url('/search/topics?q=decentralized')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBe(1); + expect(data.items[0].name).toBe('decentralized'); + expect(data.items[0].display_name).toBe('Decentralized'); + expect(data.items[0].featured).toBe(false); + + const featured = await handleShimRequest(ctx, url('/search/topics?q=is:featured')); + expect(featured.status).toBe(200); + expect(parse(featured).total_count).toBe(0); + }); + + it('should search issues within a repository qualifier', async () => { + const search = url('/search/issues'); + search.searchParams.set('q', `widget repo:${testDid}/test-repo is:issue`); + const res = await handleShimRequest(ctx, search); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBeGreaterThanOrEqual(1); + expect(data.items[0].title).toBe('Fix the widget'); + expect(data.items[0].repository_url).toBe(`${BASE}/repos/${testDid}/test-repo`); + expect(data.items[0].pull_request).toBeUndefined(); + }); + + it('should search pull requests through the issues search route', async () => { + const search = url('/search/issues'); + search.searchParams.set('q', 'feature is:pr state:open'); + const res = await handleShimRequest(ctx, search); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.items.map((item: any) => item.title)).toContain('Add feature X'); + const pull = data.items.find((item: any) => item.title === 'Add feature X'); + expect(pull.pull_request.url).toContain('/pulls/'); + }); + + it('should search visible users and reject missing search queries', async () => { + const res = await handleShimRequest(ctx, url(`/search/users?q=${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.total_count).toBeGreaterThanOrEqual(1); + expect(data.items.map((item: any) => item.login)).toContain(ORG_MEMBER_DID); + + const missing = await handleShimRequest(ctx, url('/search/repositories')); + expect(missing.status).toBe(422); + expect(parse(missing).message).toContain('q'); + }); + }); + + // ========================================================================= + // Organization and team endpoints + // ========================================================================= + + describe('organization endpoints', () => { + it('should get and update an organization profile', async () => { + const initial = await handleShimRequest(ctx, orgUrl('')); + expect(initial.status).toBe(200); + const initialData = parse(initial); + expect(initialData.login).toBe(ORG_NAME); + expect(initialData.type).toBe('Organization'); + expect(initialData.members_url).toContain('/members{/member}'); + expect(initialData.blog).toBe('https://enbox.org'); + + const updated = await handleShimRequest(ctx, orgUrl(''), 'PATCH', { + description : 'Updated decentralized forge organization', + blog : 'https://forge.enbox.org', + }); + expect(updated.status).toBe(200); + const updatedData = parse(updated); + expect(updatedData.description).toBe('Updated decentralized forge organization'); + expect(updatedData.blog).toBe('https://forge.enbox.org'); + + const missing = await handleShimRequest(ctx, url('/orgs/missing-org')); + expect(missing.status).toBe(404); + }); + + it('should list, check, block, and unblock organization users', async () => { + const blockedDid = 'did:jwk:blockeduser123'; + const blockedPath = `/blocks/${encodeURIComponent(blockedDid)}`; + + const initial = await handleShimRequest(ctx, orgUrl('/blocks')); + expect(initial.status).toBe(200); + expect(parse(initial).map((user: any) => user.login)).not.toContain(blockedDid); + + const missingCheck = await handleShimRequest(ctx, orgUrl(blockedPath)); + expect(missingCheck.status).toBe(404); + + const block = await handleShimRequest(ctx, orgUrl(blockedPath), 'PUT'); + expect(block.status).toBe(204); + expect(block.body).toBe(''); + + const duplicateBlock = await handleShimRequest(ctx, orgUrl(blockedPath), 'PUT'); + expect(duplicateBlock.status).toBe(204); + + const list = await handleShimRequest(ctx, orgUrl('/blocks')); + expect(list.status).toBe(200); + const blockedUser = parse(list).find((user: any) => user.login === blockedDid); + expect(blockedUser.id).toBe(numericId(blockedDid)); + expect(blockedUser.type).toBe('User'); + expect(blockedUser.followers_url).toContain(`/users/${blockedDid}/followers`); + + const check = await handleShimRequest(ctx, orgUrl(blockedPath)); + expect(check.status).toBe(204); + + const memberBlock = await handleShimRequest(ctx, orgUrl(`/blocks/${encodeURIComponent(ORG_MEMBER_DID)}`), 'PUT'); + expect(memberBlock.status).toBe(422); + + const missingOrg = await handleShimRequest(ctx, url(`/orgs/missing-org/blocks/${encodeURIComponent(blockedDid)}`), 'PUT'); + expect(missingOrg.status).toBe(404); + + const unblock = await handleShimRequest(ctx, orgUrl(blockedPath), 'DELETE'); + expect(unblock.status).toBe(204); + expect(unblock.body).toBe(''); + + const checkAfterUnblock = await handleShimRequest(ctx, orgUrl(blockedPath)); + expect(checkAfterUnblock.status).toBe(404); + + const duplicateUnblock = await handleShimRequest(ctx, orgUrl(blockedPath), 'DELETE'); + expect(duplicateUnblock.status).toBe(204); + }); + + it('should create, list, update, and delete organization issue fields', async () => { + const initial = await handleShimRequest(ctx, orgUrl('/issue-fields')); + expect(initial.status).toBe(200); + expect(Array.isArray(parse(initial))).toBe(true); + + const createdRes = await handleShimRequest(ctx, orgUrl('/issue-fields'), 'POST', { + name : 'Lifecycle Priority Field', + description : 'Level of importance', + data_type : 'single_select', + visibility : 'all', + options : [ + { name: 'High', description: 'High priority', color: 'red', priority: 1 }, + { name: 'Low', color: 'green', priority: 2 }, + ], + }); + expect(createdRes.status).toBe(200); + const created = parse(createdRes); + expect(created.name).toBe('Lifecycle Priority Field'); + expect(created.description).toBe('Level of importance'); + expect(created.data_type).toBe('single_select'); + expect(created.visibility).toBe('all'); + expect(created.options.map((option: any) => option.name)).toEqual(['High', 'Low']); + expect(created.options[0].id).toBeGreaterThan(0); + expect(created.options[0].created_at).toBeDefined(); + + const list = parse(await handleShimRequest(ctx, orgUrl('/issue-fields'))); + expect(list.some((field: any) => field.id === created.id)).toBe(true); + + const updatedRes = await handleShimRequest(ctx, orgUrl(`/issue-fields/${created.id}`), 'PATCH', { + name : 'Lifecycle Priority', + options : [ + { id: created.options[0].id, name: 'Urgent', description: 'Needs attention', color: 'orange', priority: 1 }, + { name: 'Low', color: 'green', priority: 2 }, + ], + }); + expect(updatedRes.status).toBe(200); + const updated = parse(updatedRes); + expect(updated.name).toBe('Lifecycle Priority'); + expect(updated.options[0].id).toBe(created.options[0].id); + expect(updated.options[0].name).toBe('Urgent'); + expect(updated.options[0].color).toBe('orange'); + expect(updated.options[1].id).toBeGreaterThan(created.options[1].id); + + const invalidCreate = await handleShimRequest(ctx, orgUrl('/issue-fields'), 'POST', { + name : 'Invalid Select Field', + data_type : 'single_select', + }); + expect(invalidCreate.status).toBe(422); + + const invalidUpdate = await handleShimRequest(ctx, orgUrl(`/issue-fields/${created.id}`), 'PATCH', { + visibility: 'private', + }); + expect(invalidUpdate.status).toBe(422); + + const missingOrg = await handleShimRequest(ctx, url('/orgs/missing-org/issue-fields')); + expect(missingOrg.status).toBe(404); + + const deleted = await handleShimRequest(ctx, orgUrl(`/issue-fields/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, orgUrl(`/issue-fields/${created.id}`), 'PATCH', { + name: 'Missing Field', + }); + expect(missing.status).toBe(404); + }); + + it('should create, list, update, and delete organization issue types', async () => { + const initial = await handleShimRequest(ctx, orgUrl('/issue-types')); + expect(initial.status).toBe(200); + expect(Array.isArray(parse(initial))).toBe(true); + + const createdRes = await handleShimRequest(ctx, orgUrl('/issue-types'), 'POST', { + name : 'Lifecycle Epic', + description : 'Multi-week tracking of work', + is_enabled : true, + color : 'green', + }); + expect(createdRes.status).toBe(200); + const created = parse(createdRes); + expect(created.name).toBe('Lifecycle Epic'); + expect(created.description).toBe('Multi-week tracking of work'); + expect(created.is_enabled).toBe(true); + expect(created.color).toBe('green'); + expect(created.id).toBeGreaterThan(0); + expect(created.created_at).toBeDefined(); + + const list = parse(await handleShimRequest(ctx, orgUrl('/issue-types'))); + expect(list.some((issueType: any) => issueType.id === created.id)).toBe(true); + + const duplicate = await handleShimRequest(ctx, orgUrl('/issue-types'), 'POST', { + name : 'Lifecycle Epic', + is_enabled : true, + }); + expect(duplicate.status).toBe(422); + + const updatedRes = await handleShimRequest(ctx, orgUrl(`/issue-types/${created.id}`), 'PUT', { + name : 'Lifecycle Initiative', + description : null, + is_enabled : false, + color : 'orange', + }); + expect(updatedRes.status).toBe(200); + const updated = parse(updatedRes); + expect(updated.name).toBe('Lifecycle Initiative'); + expect(updated.description).toBeNull(); + expect(updated.is_enabled).toBe(false); + expect(updated.color).toBe('orange'); + + const invalid = await handleShimRequest(ctx, orgUrl(`/issue-types/${created.id}`), 'PUT', { + name : 'Bad', + is_enabled : true, + color : 'teal', + }); + expect(invalid.status).toBe(422); + + const missingOrg = await handleShimRequest(ctx, url('/orgs/missing-org/issue-types')); + expect(missingOrg.status).toBe(404); + + const deleted = await handleShimRequest(ctx, orgUrl(`/issue-types/${created.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, orgUrl(`/issue-types/${created.id}`), 'PUT', { + name : 'Missing', + is_enabled : true, + }); + expect(missing.status).toBe(404); + }); + + it('should manage organization custom property schemas and repository values', async () => { + const initial = await handleShimRequest(ctx, orgUrl('/properties/schema')); + expect(initial.status).toBe(200); + expect(Array.isArray(parse(initial))).toBe(true); + + const batch = await handleShimRequest(ctx, orgUrl('/properties/schema'), 'PATCH', { + properties: [ + { + property_name : 'environment', + value_type : 'single_select', + required : true, + default_value : 'production', + description : 'Deployment environment', + allowed_values : ['production', 'development'], + values_editable_by : 'org_and_repo_actors', + require_explicit_values : true, + }, + { + property_name : 'service', + value_type : 'string', + description : 'Service owner', + }, + ], + }); + expect(batch.status).toBe(200); + const created = parse(batch); + const environment = created.find((property: any) => property.property_name === 'environment'); + expect(environment.value_type).toBe('single_select'); + expect(environment.source_type).toBe('organization'); + expect(environment.url).toContain(`/orgs/${ORG_NAME}/properties/schema/environment`); + expect(environment.allowed_values).toEqual(['production', 'development']); + expect(environment.values_editable_by).toBe('org_and_repo_actors'); + expect(environment.require_explicit_values).toBe(true); + + const fetched = await handleShimRequest(ctx, orgUrl('/properties/schema/environment')); + expect(fetched.status).toBe(200); + expect(parse(fetched).default_value).toBe('production'); + + const urlProperty = await handleShimRequest(ctx, orgUrl('/properties/schema/docs_url'), 'PUT', { + value_type : 'url', + description : 'Documentation URL', + default_value : 'https://docs.example.com', + values_editable_by : null, + }); + expect(urlProperty.status).toBe(200); + expect(parse(urlProperty).value_type).toBe('url'); + expect(parse(urlProperty).values_editable_by).toBeNull(); + + const invalidDefinition = await handleShimRequest(ctx, orgUrl('/properties/schema/bad'), 'PUT', { + value_type: 'integer', + }); + expect(invalidDefinition.status).toBe(422); + + const updateValues = await handleShimRequest(ctx, orgUrl('/properties/values'), 'PATCH', { + repository_names : ['test-repo', CONTRIBUTORS_REPO_NAME], + properties : [ + { property_name: 'environment', value: 'production' }, + { property_name: 'docs_url', value: 'https://docs.example.com/test-repo' }, + ], + }); + expect(updateValues.status).toBe(204); + + const listValues = await handleShimRequest(ctx, orgUrl('/properties/values')); + expect(listValues.status).toBe(200); + const testRepoValues = parse(listValues).find((item: any) => item.repository_name === 'test-repo'); + expect(testRepoValues.repository_full_name).toBe(`${testDid}/test-repo`); + expect(testRepoValues.properties).toContainEqual({ property_name: 'environment', value: 'production' }); + expect(testRepoValues.properties).toContainEqual({ property_name: 'docs_url', value: 'https://docs.example.com/test-repo' }); + + const filteredValues = parse(await handleShimRequest(ctx, orgUrl(`/properties/values?repository_query=${CONTRIBUTORS_REPO_NAME}`))); + expect(filteredValues.map((item: any) => item.repository_name)).toEqual([CONTRIBUTORS_REPO_NAME]); + + const invalidValue = await handleShimRequest(ctx, orgUrl('/properties/values'), 'PATCH', { + repository_names : ['test-repo'], + properties : [{ property_name: 'environment', value: 'staging' }], + }); + expect(invalidValue.status).toBe(422); + + const clearValues = await handleShimRequest(ctx, orgUrl('/properties/values'), 'PATCH', { + repository_names : ['test-repo', CONTRIBUTORS_REPO_NAME], + properties : [ + { property_name: 'environment', value: null }, + { property_name: 'docs_url', value: null }, + ], + }); + expect(clearValues.status).toBe(204); + + const afterClear = parse(await handleShimRequest(ctx, orgUrl('/properties/values'))) + .find((item: any) => item.repository_name === 'test-repo'); + expect(afterClear.properties).not.toContainEqual({ property_name: 'environment', value: 'production' }); + expect(afterClear.properties).not.toContainEqual({ property_name: 'docs_url', value: 'https://docs.example.com/test-repo' }); + + const deleteUrl = await handleShimRequest(ctx, orgUrl('/properties/schema/docs_url'), 'DELETE'); + expect(deleteUrl.status).toBe(204); + const missingUrl = await handleShimRequest(ctx, orgUrl('/properties/schema/docs_url')); + expect(missingUrl.status).toBe(404); + + const deleteEnvironment = await handleShimRequest(ctx, orgUrl('/properties/schema/environment'), 'DELETE'); + expect(deleteEnvironment.status).toBe(204); + const deleteService = await handleShimRequest(ctx, orgUrl('/properties/schema/service'), 'DELETE'); + expect(deleteService.status).toBe(204); + + const missingOrg = await handleShimRequest(ctx, url('/orgs/missing-org/properties/schema')); + expect(missingOrg.status).toBe(404); + }); + + it('should list repository security advisories for an organization', async () => { + await mergeRepoSettings({ + securityAdvisories: { + 'GHSA-ORG1-0001-0002': { + ghsaId : 'GHSA-ORG1-0001-0002', + cveId : 'CVE-2026-1001', + summary : 'Organization advisory fixture', + description : 'A published advisory visible through the organization route.', + severity : 'medium', + state : 'published', + authorDid : testDid, + publisherDid : testDid, + createdAt : '2026-01-01T00:00:00.000Z', + updatedAt : '2026-01-02T00:00:00.000Z', + publishedAt : '2026-01-02T00:00:00.000Z', + vulnerabilities : [ + { + package : { ecosystem: 'npm', name: 'org-package' }, + vulnerable_version_range : '< 2.0.0', + patched_versions : '2.0.0', + }, + ], + }, + 'GHSA-ORG1-0003-0004': { + ghsaId : 'GHSA-ORG1-0003-0004', + summary : 'Draft organization advisory fixture', + description : 'A draft advisory used to verify state filters.', + severity : 'low', + state : 'draft', + authorDid : testDid, + createdAt : '2026-01-03T00:00:00.000Z', + updatedAt : '2026-01-03T00:00:00.000Z', + }, + }, + }); + + const listRes = await handleShimRequest( + ctx, + orgUrl('/security-advisories?state=published,draft&sort=created&direction=asc&per_page=1'), + ); + expect(listRes.status).toBe(200); + expect(listRes.headers.Link).toContain('rel="next"'); + const firstPage = parse(listRes); + expect(firstPage).toHaveLength(1); + expect(firstPage[0].ghsa_id).toBe('GHSA-ORG1-0001-0002'); + expect(firstPage[0].url).toContain(`/repos/${testDid}/test-repo/security-advisories/GHSA-ORG1-0001-0002`); + expect(firstPage[0].publisher.login).toBe(testDid); + expect(firstPage[0].vulnerabilities[0].package.name).toBe('org-package'); + + const publishedRes = await handleShimRequest(ctx, orgUrl('/security-advisories?state=published')); + expect(publishedRes.status).toBe(200); + expect(parse(publishedRes).map((advisory: any) => advisory.ghsa_id)).toEqual(['GHSA-ORG1-0001-0002']); + + const invalidStateRes = await handleShimRequest(ctx, orgUrl('/security-advisories?state=invalid')); + expect(invalidStateRes.status).toBe(422); + + const missingOrgRes = await handleShimRequest(ctx, url('/orgs/missing-org/security-advisories')); + expect(missingOrgRes.status).toBe(404); + }); + + it('should list code scanning alerts for an organization', async () => { + await mergeRepoSettings({ + codeScanningAlerts: { + '11': { + number : 11, + state : 'open', + rule : { + id : 'js/path-traversal', + severity : 'error', + security_severity_level : 'high', + description : 'Path traversal from user input', + name : 'js/path-traversal', + tags : ['security', 'external/cwe/cwe-022'], + }, + tool: { + name : 'CodeQL', + guid : null, + version : '2.17.0', + }, + mostRecentInstance: { + ref : 'refs/heads/main', + analysisKey : 'codeql', + category : 'codeql', + environment : '{}', + state : 'open', + commitSha : MAIN_SHA, + message : { text: 'User-controlled path reaches the filesystem.' }, + location : { path: 'src/archive.ts', start_line: 12, end_line: 12 }, + classifications : ['source'], + }, + assignees : [TRIAGER_DID], + createdAt : '2026-05-01T00:00:00.000Z', + updatedAt : '2026-05-02T00:00:00.000Z', + }, + '12': { + number : 12, + state : 'fixed', + rule : { + id : 'ts/hardcoded-secret', + severity : 'warning', + security_severity_level : 'medium', + description : 'Hardcoded secret in configuration', + name : 'ts/hardcoded-secret', + }, + tool: { + name : 'Semgrep', + guid : 'semgrep-guid', + version : '1.70.0', + }, + mostRecentInstance: { + ref : 'refs/heads/main', + analysisKey : 'semgrep', + category : 'semgrep', + environment : '{}', + state : 'fixed', + commitSha : TAG_SHA, + message : { text: 'The secret was removed.' }, + location : { path: 'src/config.ts', start_line: 4, end_line: 4 }, + }, + createdAt : '2026-05-03T00:00:00.000Z', + updatedAt : '2026-05-04T00:00:00.000Z', + fixedAt : '2026-05-04T00:00:00.000Z', + }, + '13': { + number : 13, + state : 'open', + rule : { + id : 'js/non-default-branch', + severity : 'error', + security_severity_level : 'high', + description : 'Alert from a non-default branch', + name : 'js/non-default-branch', + }, + tool: { + name : 'CodeQL', + guid : null, + version : '2.17.0', + }, + mostRecentInstance: { + ref : 'refs/heads/feature', + analysisKey : 'codeql', + category : 'codeql', + environment : '{}', + state : 'open', + commitSha : FEATURE_SHA, + message : { text: 'Only default branch alerts are listed.' }, + location : { path: 'src/feature.ts', start_line: 2, end_line: 2 }, + }, + assignees : [TRIAGER_DID], + createdAt : '2026-05-05T00:00:00.000Z', + updatedAt : '2026-05-05T00:00:00.000Z', + }, + }, + }); + + const listRes = await handleShimRequest( + ctx, + orgUrl('/code-scanning/alerts?state=open&severity=high&tool_name=CodeQL&assignees=*&per_page=1'), + ); + expect(listRes.status).toBe(200); + const list = parse(listRes); + expect(list).toHaveLength(1); + expect(list[0].number).toBe(11); + expect(list[0].repository.full_name).toBe(`${testDid}/test-repo`); + expect(list[0].instances_url).toContain(`/repos/${testDid}/test-repo/code-scanning/alerts/11/instances`); + expect(list[0].assignees[0].login).toBe(TRIAGER_DID); + + const pagedRes = await handleShimRequest( + ctx, + orgUrl('/code-scanning/alerts?state=open,fixed&sort=created&direction=asc&per_page=1'), + ); + expect(pagedRes.status).toBe(200); + expect(pagedRes.headers.Link).toContain('rel="next"'); + expect(parse(pagedRes).map((alert: any) => alert.number)).toEqual([11]); + + const fixedRes = await handleShimRequest(ctx, orgUrl('/code-scanning/alerts?state=fixed&tool_guid=semgrep-guid')); + expect(fixedRes.status).toBe(200); + expect(parse(fixedRes).map((alert: any) => alert.number)).toEqual([12]); + + const invalidFilterRes = await handleShimRequest(ctx, orgUrl('/code-scanning/alerts?tool_name=CodeQL&tool_guid=guid')); + expect(invalidFilterRes.status).toBe(422); + + const missingOrgRes = await handleShimRequest(ctx, url('/orgs/missing-org/code-scanning/alerts')); + expect(missingOrgRes.status).toBe(404); + }); + + it('should list secret scanning alerts for an organization', async () => { + await mergeRepoSettings({ + secretScanningAlerts: { + '21': { + number : 21, + state : 'open', + secretType : 'github_personal_access_token', + secretTypeDisplayName : 'GitHub Personal Access Token', + secret : 'ghp_orgsecret', + provider : 'GitHub', + providerSlug : 'github_secret_scanning', + createdAt : '2026-06-01T00:00:00.000Z', + updatedAt : '2026-06-02T00:00:00.000Z', + validity : 'active', + publiclyLeaked : true, + multiRepo : false, + assignedTo : TRIAGER_DID, + locations : [ + { + type : 'commit', + details : { + path : 'src/secrets.ts', + start_line : 1, + end_line : 1, + blob_sha : MAIN_SHA, + commit_sha : MAIN_SHA, + }, + }, + ], + }, + '22': { + number : 22, + state : 'resolved', + secretType : 'adafruit_io_key', + secretTypeDisplayName : 'Adafruit IO Key', + secret : 'aio_orgsecret', + provider : 'Adafruit', + providerSlug : 'adafruit', + createdAt : '2026-06-03T00:00:00.000Z', + updatedAt : '2026-06-04T00:00:00.000Z', + resolution : 'false_positive', + resolvedAt : '2026-06-04T00:00:00.000Z', + resolvedBy : testDid, + validity : 'inactive', + publiclyLeaked : false, + multiRepo : true, + pushProtectionBypassed : true, + pushProtectionBypassedBy : TRIAGER_DID, + pushProtectionBypassedAt : '2026-06-03T01:00:00.000Z', + }, + }, + }); + + const listRes = await handleShimRequest( + ctx, + orgUrl('/secret-scanning/alerts?state=open&secret_type=github_personal_access_token&providers=github_secret_scanning&validity=active&is_publicly_leaked=true&assignee=*&hide_secret=true'), + ); + expect(listRes.status).toBe(200); + const list = parse(listRes); + expect(list).toHaveLength(1); + expect(list[0].number).toBe(21); + expect(list[0].secret).toBe('********'); + expect(list[0].repository.full_name).toBe(`${testDid}/test-repo`); + expect(list[0].locations_url).toContain(`/repos/${testDid}/test-repo/secret-scanning/alerts/21/locations`); + expect(list[0].assigned_to.login).toBe(TRIAGER_DID); + + const pagedRes = await handleShimRequest( + ctx, + orgUrl('/secret-scanning/alerts?state=open,resolved&sort=created&direction=asc&per_page=1'), + ); + expect(pagedRes.status).toBe(200); + expect(pagedRes.headers.Link).toContain('rel="next"'); + expect(parse(pagedRes).map((alert: any) => alert.number)).toEqual([21]); + + const bypassedRes = await handleShimRequest(ctx, orgUrl('/secret-scanning/alerts?state=resolved&resolution=false_positive&is_bypassed=true')); + expect(bypassedRes.status).toBe(200); + expect(parse(bypassedRes).map((alert: any) => alert.number)).toEqual([22]); + + const invalidFilterRes = await handleShimRequest(ctx, orgUrl('/secret-scanning/alerts?secret_type=a&exclude_secret_types=b')); + expect(invalidFilterRes.status).toBe(422); + + const invalidHideSecretRes = await handleShimRequest(ctx, orgUrl('/secret-scanning/alerts?hide_secret=maybe')); + expect(invalidHideSecretRes.status).toBe(422); + + const missingOrgRes = await handleShimRequest(ctx, url('/orgs/missing-org/secret-scanning/alerts')); + expect(missingOrgRes.status).toBe(404); + }); + + it('should list Dependabot alerts for an organization', async () => { + await mergeRepoSettings({ + dependabotAlerts: { + '31': { + number : 31, + state : 'open', + dependency : { + package : { ecosystem: 'npm', name: 'lodash' }, + manifestPath : 'package-lock.json', + scope : 'runtime', + }, + securityAdvisory: { + ghsa_id : 'GHSA-org-lodash', + cve_id : 'CVE-2026-2001', + summary : 'Prototype pollution in lodash', + description : 'A crafted payload can modify object prototypes.', + severity : 'high', + classification : 'general', + epss : { percentage: 0.82, percentile: '0.97' }, + }, + securityVulnerability: { + package : { ecosystem: 'npm', name: 'lodash' }, + severity : 'high', + vulnerable_version_range : '< 4.17.21', + first_patched_version : { identifier: '4.17.21' }, + }, + assignees : [TRIAGER_DID], + createdAt : '2026-06-05T00:00:00.000Z', + updatedAt : '2026-06-06T00:00:00.000Z', + }, + '32': { + number : 32, + state : 'fixed', + dependency : { + package : { ecosystem: 'pip', name: 'django' }, + manifestPath : 'requirements.txt', + scope : 'development', + }, + securityAdvisory: { + ghsa_id : 'GHSA-org-django', + summary : 'Information exposure in django', + description : 'A diagnostic endpoint leaks metadata.', + severity : 'medium', + classification : 'general', + epss : { percentage: 0.05, percentile: '0.20' }, + }, + securityVulnerability: { + package : { ecosystem: 'pip', name: 'django' }, + severity : 'medium', + vulnerable_version_range : '< 4.2.1', + first_patched_version : null, + }, + createdAt : '2026-06-07T00:00:00.000Z', + updatedAt : '2026-06-08T00:00:00.000Z', + fixedAt : '2026-06-08T00:00:00.000Z', + }, + }, + }); + + const filteredRes = await handleShimRequest( + ctx, + orgUrl('/dependabot/alerts?state=open&severity=high&ecosystem=npm&package=lodash&manifest=package-lock.json&has=patch&assignee=*&epss_percentage=%3E%3D0.8'), + ); + expect(filteredRes.status).toBe(200); + const filtered = parse(filteredRes); + expect(filtered).toHaveLength(1); + expect(filtered[0].number).toBe(31); + expect(filtered[0].repository.full_name).toBe(`${testDid}/test-repo`); + expect(filtered[0].security_advisory.epss.percentage).toBe(0.82); + expect(filtered[0].assignees[0].login).toBe(TRIAGER_DID); + + const pagedRes = await handleShimRequest( + ctx, + orgUrl('/dependabot/alerts?state=open,fixed&sort=created&direction=asc&per_page=1'), + ); + expect(pagedRes.status).toBe(200); + expect(pagedRes.headers.Link).toContain('rel="next"'); + expect(parse(pagedRes).map((alert: any) => alert.number)).toEqual([31]); + + const fixedRes = await handleShimRequest(ctx, orgUrl('/dependabot/alerts?state=fixed&scope=development&epss_percentage=0..0.1')); + expect(fixedRes.status).toBe(200); + expect(parse(fixedRes).map((alert: any) => alert.number)).toEqual([32]); + + const invalidFilterRes = await handleShimRequest(ctx, orgUrl('/dependabot/alerts?epss_percentage=2')); + expect(invalidFilterRes.status).toBe(422); + + const missingOrgRes = await handleShimRequest(ctx, url('/orgs/missing-org/dependabot/alerts')); + expect(missingOrgRes.status).toBe(404); + }); + + it('should create, list, get, ping, and delete organization webhooks', async () => { + const initial = await handleShimRequest(ctx, orgUrl('/hooks')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const create = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + name : 'web', + active : true, + events : ['push', 'repository'], + config : { + url : 'https://example.com/org-hook', + content_type : 'json', + insecure_ssl : '0', + secret : 'org-secret', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + expect(hook.type).toBe('Organization'); + expect(hook.name).toBe('web'); + expect(hook.active).toBe(true); + expect(hook.events).toEqual(['push', 'repository']); + expect(hook.config.url).toBe('https://example.com/org-hook'); + expect(hook.config.secret).toBeUndefined(); + expect(hook.url).toContain(`/orgs/${ORG_NAME}/hooks/`); + expect(hook.ping_url).toBe(`${hook.url}/pings`); + expect(hook.deliveries_url).toBe(`${hook.url}/deliveries`); + + const list = await handleShimRequest(ctx, orgUrl('/hooks')); + expect(parse(list).map((item: any) => item.id)).toContain(hook.id); + + const fetched = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`)); + expect(fetched.status).toBe(200); + expect(parse(fetched).id).toBe(hook.id); + + const config = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/config`)); + expect(config.status).toBe(200); + expect(parse(config).secret).toBe('********'); + + const ping = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/pings`), 'POST'); + expect(ping.status).toBe(204); + + const deleted = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + + const missing = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`)); + expect(missing.status).toBe(404); + }); + + it('should list, get, and redeliver organization webhook deliveries', async () => { + const create = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + active : true, + events : ['push'], + config : { + url : 'https://example.com/org-deliveries', + secret : 'org-delivery-secret', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + + const initial = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries`)); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const ping = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/pings`), 'POST'); + expect(ping.status).toBe(204); + + const list = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries`)); + expect(list.status).toBe(200); + const deliveries = parse(list); + expect(deliveries).toHaveLength(1); + expect(deliveries[0].event).toBe('ping'); + expect(deliveries[0].status).toBe('OK'); + + const detail = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries/${deliveries[0].id}`)); + expect(detail.status).toBe(200); + const detailed = parse(detail); + expect(detailed.url).toBe('https://example.com/org-deliveries'); + expect(detailed.request.headers['X-GitHub-Hook-Installation-Target-Type']).toBe('organization'); + expect(detailed.request.payload.organization.login).toBe(ORG_NAME); + + const redeliver = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries/${deliveries[0].id}/attempts`), 'POST'); + expect(redeliver.status).toBe(202); + + const afterRedelivery = parse(await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries`))); + expect(afterRedelivery).toHaveLength(2); + expect(afterRedelivery.find((delivery: any) => delivery.redelivery === true).guid).toBe(deliveries[0].guid); + + const missing = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/deliveries/999999/attempts`), 'POST'); + expect(missing.status).toBe(404); + + const deleted = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + }); + + it('should update organization webhooks and webhook configuration', async () => { + const create = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + active : true, + events : ['push'], + config : { + url : 'https://example.com/org-original', + secret : 'alpha', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + + const update = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`), 'PATCH', { + active : false, + add_events : ['membership'], + remove_events : ['push'], + config : { + url : 'https://example.com/org-updated', + content_type : 'json', + secret : 'beta', + }, + }); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.active).toBe(false); + expect(updated.events).toEqual(['membership']); + expect(updated.config.url).toBe('https://example.com/org-updated'); + + const config = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}/config`), 'PATCH', { + url : 'https://example.com/org-config', + secret : 'gamma', + }); + expect(config.status).toBe(200); + expect(parse(config).url).toBe('https://example.com/org-config'); + expect(parse(config).secret).toBe('********'); + + const fetched = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`)); + expect(parse(fetched).config.url).toBe('https://example.com/org-config'); + + const deleted = await handleShimRequest(ctx, orgUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + }); + + it('should reject invalid organization webhook payloads and missing hooks', async () => { + const missingConfig = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + events: ['push'], + }); + expect(missingConfig.status).toBe(422); + + const invalidName = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + name : 'email', + config : { url: 'https://example.com/org-hook' }, + }); + expect(invalidName.status).toBe(422); + + const invalidUrl = await handleShimRequest(ctx, orgUrl('/hooks'), 'POST', { + config: { url: 'not-a-url' }, + }); + expect(invalidUrl.status).toBe(422); + + const missingOrg = await handleShimRequest(ctx, url('/orgs/missing-org/hooks')); + expect(missingOrg.status).toBe(404); + + const get = await handleShimRequest(ctx, orgUrl('/hooks/999999')); + expect(get.status).toBe(404); + + const config = await handleShimRequest(ctx, orgUrl('/hooks/999999/config')); + expect(config.status).toBe(404); + + const ping = await handleShimRequest(ctx, orgUrl('/hooks/999999/pings'), 'POST'); + expect(ping.status).toBe(404); + }); + + it('should list organization members with role filters and pagination', async () => { + const paged = await handleShimRequest(ctx, orgUrl('/members?per_page=2')); + expect(paged.status).toBe(200); + expect(parse(paged)).toHaveLength(2); + expect(paged.headers.Link).toContain('rel="next"'); + + const admins = parse(await handleShimRequest(ctx, orgUrl('/members?role=admin'))); + expect(admins.map((item: any) => item.login)).toEqual([testDid]); + + const members = parse(await handleShimRequest(ctx, orgUrl('/members?role=member'))); + expect(members.map((item: any) => item.login)).toContain(ORG_MEMBER_DID); + expect(members.map((item: any) => item.login)).toContain(ORG_REMOVED_MEMBER_DID); + }); + + it('should expose public members and membership checks', async () => { + const publicMembers = await handleShimRequest(ctx, orgUrl('/public_members')); + expect(publicMembers.status).toBe(200); + expect(parse(publicMembers).map((item: any) => item.login)).toContain(ORG_MEMBER_DID); + + const publicMember = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(publicMember.status).toBe(204); + + const conceal = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(ORG_MEMBER_DID)}`), 'DELETE'); + expect(conceal.status).toBe(204); + + const concealedPublicMember = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(concealedPublicMember.status).toBe(404); + + const afterConceal = parse(await handleShimRequest(ctx, orgUrl('/public_members'))); + expect(afterConceal.map((item: any) => item.login)).not.toContain(ORG_MEMBER_DID); + + const stillMember = await handleShimRequest(ctx, orgUrl(`/members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(stillMember.status).toBe(204); + + const publicize = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(ORG_MEMBER_DID)}`), 'PUT'); + expect(publicize.status).toBe(204); + + const afterPublicize = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(afterPublicize.status).toBe(204); + + const existing = await handleShimRequest(ctx, orgUrl(`/members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(existing.status).toBe(204); + + const missing = await handleShimRequest(ctx, orgUrl('/members/did%3Ajwk%3Aunknownmember')); + expect(missing.status).toBe(404); + + const missingPublic = await handleShimRequest(ctx, orgUrl('/public_members/did%3Ajwk%3Aunknownmember')); + expect(missingPublic.status).toBe(404); + }); + + it('should list, convert, and remove outside collaborators', async () => { + const initial = parse(await handleShimRequest(ctx, orgUrl('/outside_collaborators'))); + expect(initial.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(initial.map((item: any) => item.login)).toContain(TRIAGER_DID); + expect(initial.map((item: any) => item.login)).not.toContain(ORG_MEMBER_DID); + expect(initial.find((item: any) => item.login === MAINTAINER_DID).name).toBe('Alice'); + + const twoFactorFiltered = parse(await handleShimRequest(ctx, orgUrl('/outside_collaborators?filter=2fa_disabled'))); + expect(twoFactorFiltered).toEqual([]); + + const invalidFilter = await handleShimRequest(ctx, orgUrl('/outside_collaborators?filter=unknown')); + expect(invalidFilter.status).toBe(422); + + const directDid = 'did:jwk:outside-direct'; + const encodedDirectDid = encodeURIComponent(directDid); + const addDirect = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDirectDid}`), 'PUT', { + permission : 'triage', + alias : 'Outside Direct', + }); + expect(addDirect.status).toBe(201); + + const withDirect = parse(await handleShimRequest(ctx, orgUrl('/outside_collaborators'))); + expect(withDirect.map((item: any) => item.login)).toContain(directDid); + + const removeDirect = await handleShimRequest(ctx, orgUrl(`/outside_collaborators/${encodedDirectDid}`), 'DELETE'); + expect(removeDirect.status).toBe(204); + + const directPermission = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDirectDid}/permission`)); + expect(directPermission.status).toBe(404); + + const convertDid = 'did:jwk:outside-convert'; + const encodedConvertDid = encodeURIComponent(convertDid); + const createMember = await handleShimRequest(ctx, orgUrl(`/memberships/${encodedConvertDid}`), 'PUT', { + role : 'member', + alias : 'Outside Convert', + }); + expect(createMember.status).toBe(200); + + const createTeam = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Outside Collaborator Team', + description : 'Repo access for conversion coverage', + privacy : 'closed', + }); + expect(createTeam.status).toBe(201); + const team = parse(createTeam); + + const grantRepo = await handleShimRequest( + ctx, + orgUrl(`/teams/${team.slug}/repos/${encodeURIComponent(testDid)}/test-repo`), + 'PUT', + { permission: 'push' }, + ); + expect(grantRepo.status).toBe(204); + + const addTeamMember = await handleShimRequest( + ctx, + orgUrl(`/teams/${team.slug}/memberships/${encodedConvertDid}`), + 'PUT', + { role: 'member' }, + ); + expect(addTeamMember.status).toBe(200); + expect(parse(addTeamMember).state).toBe('active'); + + const convert = await handleShimRequest(ctx, orgUrl(`/outside_collaborators/${encodedConvertDid}`), 'PUT'); + expect(convert.status).toBe(204); + + const memberCheck = await handleShimRequest(ctx, orgUrl(`/members/${encodedConvertDid}`)); + expect(memberCheck.status).toBe(404); + + const preservedTeamAccess = await handleShimRequest(ctx, orgUrl(`/teams/${team.slug}/members/${encodedConvertDid}`)); + expect(preservedTeamAccess.status).toBe(204); + + const afterConvert = parse(await handleShimRequest(ctx, orgUrl('/outside_collaborators'))); + expect(afterConvert.map((item: any) => item.login)).toContain(convertDid); + + const removeConverted = await handleShimRequest(ctx, orgUrl(`/outside_collaborators/${encodedConvertDid}`), 'DELETE'); + expect(removeConverted.status).toBe(204); + + const removedTeamAccess = await handleShimRequest(ctx, orgUrl(`/teams/${team.slug}/members/${encodedConvertDid}`)); + expect(removedTeamAccess.status).toBe(404); + + const convertLastOwner = await handleShimRequest(ctx, orgUrl(`/outside_collaborators/${encodeURIComponent(testDid)}`), 'PUT'); + expect(convertLastOwner.status).toBe(403); + + const removeMember = await handleShimRequest(ctx, orgUrl(`/outside_collaborators/${encodeURIComponent(ORG_MEMBER_DID)}`), 'DELETE'); + expect(removeMember.status).toBe(422); + + const missingOrg = await handleShimRequest(ctx, url('/orgs/missing-org/outside_collaborators')); + expect(missingOrg.status).toBe(404); + + const deleteTeam = await handleShimRequest(ctx, orgUrl(`/teams/${team.slug}`), 'DELETE'); + expect(deleteTeam.status).toBe(204); + }); + + it('should get and set organization membership details', async () => { + const ownerMembership = await handleShimRequest(ctx, orgUrl(`/memberships/${encodeURIComponent(testDid)}`)); + expect(ownerMembership.status).toBe(200); + const ownerData = parse(ownerMembership); + expect(ownerData.state).toBe('active'); + expect(ownerData.role).toBe('admin'); + expect(ownerData.direct_membership).toBe(true); + expect(ownerData.enterprise_teams_providing_indirect_membership).toEqual([]); + expect(ownerData.organization.login).toBe(ORG_NAME); + expect(ownerData.user.login).toBe(testDid); + + const missing = await handleShimRequest(ctx, orgUrl('/memberships/did%3Ajwk%3Aunknownmember')); + expect(missing.status).toBe(404); + + const invalidRole = await handleShimRequest(ctx, orgUrl('/memberships/did%3Ajwk%3Ainvalidrole'), 'PUT', { + role: 'owner', + }); + expect(invalidRole.status).toBe(422); + + const memberDid = 'did:jwk:orgmembershipwriter'; + const memberPath = `/memberships/${encodeURIComponent(memberDid)}`; + const created = await handleShimRequest(ctx, orgUrl(memberPath), 'PUT', { + role : 'member', + alias : 'Writable Member', + }); + expect(created.status).toBe(200); + expect(parse(created).role).toBe('member'); + + const concealCreated = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(memberDid)}`), 'DELETE'); + expect(concealCreated.status).toBe(204); + + const memberCheck = await handleShimRequest(ctx, orgUrl(`/members/${encodeURIComponent(memberDid)}`)); + expect(memberCheck.status).toBe(204); + + const promoted = await handleShimRequest(ctx, orgUrl(memberPath), 'PUT', { role: 'admin' }); + expect(promoted.status).toBe(200); + expect(parse(promoted).role).toBe('admin'); + + const promotedPublicCheck = await handleShimRequest(ctx, orgUrl(`/public_members/${encodeURIComponent(memberDid)}`)); + expect(promotedPublicCheck.status).toBe(404); + + const admins = parse(await handleShimRequest(ctx, orgUrl('/members?role=admin'))); + expect(admins.map((item: any) => item.login)).toContain(memberDid); + + const removed = await handleShimRequest(ctx, orgUrl(memberPath), 'DELETE'); + expect(removed.status).toBe(204); + + const afterRemove = await handleShimRequest(ctx, orgUrl(memberPath)); + expect(afterRemove.status).toBe(404); + }); + + it('should list and update authenticated user organization memberships', async () => { + const list = await handleShimRequest(ctx, url('/user/memberships/orgs')); + expect(list.status).toBe(200); + const membership = parse(list).find((item: any) => item.organization.login === ORG_NAME); + expect(membership.state).toBe('active'); + expect(membership.role).toBe('admin'); + expect(membership.user.login).toBe(testDid); + + const active = await handleShimRequest(ctx, url('/user/memberships/orgs?state=active')); + expect(active.status).toBe(200); + expect(parse(active).some((item: any) => item.organization.login === ORG_NAME)).toBe(true); + + const pending = await handleShimRequest(ctx, url('/user/memberships/orgs?state=pending')); + expect(pending.status).toBe(200); + expect(parse(pending)).toEqual([]); + + const invalidState = await handleShimRequest(ctx, url('/user/memberships/orgs?state=suspended')); + expect(invalidState.status).toBe(422); + + const detail = await handleShimRequest(ctx, url(`/user/memberships/orgs/${encodeURIComponent(ORG_NAME)}`)); + expect(detail.status).toBe(200); + expect(parse(detail).organization.login).toBe(ORG_NAME); + + const updated = await handleShimRequest(ctx, url(`/user/memberships/orgs/${encodeURIComponent(ORG_NAME)}`), 'PATCH', { + state: 'active', + }); + expect(updated.status).toBe(200); + expect(parse(updated).state).toBe('active'); + + const invalidUpdate = await handleShimRequest(ctx, url(`/user/memberships/orgs/${encodeURIComponent(ORG_NAME)}`), 'PATCH', { + state: 'pending', + }); + expect(invalidUpdate.status).toBe(422); + + const missing = await handleShimRequest(ctx, url('/user/memberships/orgs/missing-org')); + expect(missing.status).toBe(404); + }); + + it('should remove organization membership records', async () => { + const transientDid = 'did:jwk:orgmemberdeleteroute'; + const transientMembershipPath = `/memberships/${encodeURIComponent(transientDid)}`; + const transientCreate = await handleShimRequest(ctx, orgUrl(transientMembershipPath), 'PUT', { role: 'member' }); + expect(transientCreate.status).toBe(200); + + const memberDelete = await handleShimRequest(ctx, orgUrl(`/members/${encodeURIComponent(transientDid)}`), 'DELETE'); + expect(memberDelete.status).toBe(204); + + const memberDeleteCheck = await handleShimRequest(ctx, orgUrl(transientMembershipPath)); + expect(memberDeleteCheck.status).toBe(404); + + const memberPath = `/members/${encodeURIComponent(ORG_REMOVED_MEMBER_DID)}`; + const membershipPath = `/memberships/${encodeURIComponent(ORG_REMOVED_MEMBER_DID)}`; + + const removed = await handleShimRequest(ctx, orgUrl(membershipPath), 'DELETE'); + expect(removed.status).toBe(204); + + const check = await handleShimRequest(ctx, orgUrl(memberPath)); + expect(check.status).toBe(404); + }); + + it('should list, create, and get organization teams', async () => { + const list = await handleShimRequest(ctx, orgUrl('/teams')); + expect(list.status).toBe(200); + const teams = parse(list); + const core = teams.find((item: any) => item.slug === 'core-team'); + expect(core.name).toBe('Core Team'); + expect(core.privacy).toBe('closed'); + + const created = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Ops Team', + description : 'Operations automation', + privacy : 'secret', + }); + expect(created.status).toBe(201); + const createdTeam = parse(created); + expect(createdTeam.slug).toBe('ops-team'); + expect(createdTeam.privacy).toBe('secret'); + + const fetched = await handleShimRequest(ctx, orgUrl('/teams/ops-team')); + expect(fetched.status).toBe(200); + expect(parse(fetched).name).toBe('Ops Team'); + + const invalid = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Invalid Privacy Team', + privacy : 'private', + }); + expect(invalid.status).toBe(422); + }); + + it('should support numeric organization team routes', async () => { + const orgInfo = parse(await handleShimRequest(ctx, orgUrl(''))); + const create = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'ID Alias Team', + description : 'Numeric route coverage', + privacy : 'closed', + }); + expect(create.status).toBe(201); + let team = parse(create); + const orgId = orgInfo.id; + const teamId = team.id; + + const legacyGet = await handleShimRequest(ctx, url(`/teams/${teamId}`)); + expect(legacyGet.status).toBe(200); + expect(parse(legacyGet).slug).toBe(team.slug); + + const orgIdGet = await handleShimRequest(ctx, url(`/organizations/${orgId}/team/${teamId}`)); + expect(orgIdGet.status).toBe(200); + expect(parse(orgIdGet).slug).toBe(team.slug); + + const legacyUpdate = await handleShimRequest(ctx, url(`/teams/${teamId}`), 'PATCH', { + description: 'Updated through numeric route', + }); + expect(legacyUpdate.status).toBe(200); + expect(parse(legacyUpdate).description).toBe('Updated through numeric route'); + + const orgIdUpdate = await handleShimRequest(ctx, url(`/organizations/${orgId}/team/${teamId}`), 'PATCH', { + name: 'ID Alias Team Renamed', + }); + expect(orgIdUpdate.status).toBe(200); + team = parse(orgIdUpdate); + expect(team.slug).toBe('id-alias-team-renamed'); + + expect(parse(await handleShimRequest(ctx, url(`/teams/${teamId}/teams`)))).toEqual([]); + expect(parse(await handleShimRequest(ctx, url(`/organizations/${orgId}/team/${teamId}/teams`)))).toEqual([]); + expect(parse(await handleShimRequest(ctx, url(`/teams/${teamId}/invitations`)))).toEqual([]); + expect(parse(await handleShimRequest(ctx, url(`/organizations/${orgId}/team/${teamId}/invitations`)))).toEqual([]); + + const memberDid = 'did:jwk:numericteamroute'; + const encodedMemberDid = encodeURIComponent(memberDid); + const createMember = await handleShimRequest(ctx, orgUrl(`/memberships/${encodedMemberDid}`), 'PUT', { + role: 'member', + }); + expect(createMember.status).toBe(200); + + const legacyAddMember = await handleShimRequest(ctx, url(`/teams/${teamId}/members/${encodedMemberDid}`), 'PUT', { + role: 'maintainer', + }); + expect(legacyAddMember.status).toBe(200); + expect(parse(legacyAddMember).state).toBe('active'); + + const legacyMembers = parse(await handleShimRequest(ctx, url(`/teams/${teamId}/members`))); + expect(legacyMembers.map((item: any) => item.login)).toContain(memberDid); + + const legacyMemberCheck = await handleShimRequest(ctx, url(`/teams/${teamId}/members/${encodedMemberDid}`)); + expect(legacyMemberCheck.status).toBe(204); + + const orgIdMembership = await handleShimRequest( + ctx, + url(`/organizations/${orgId}/team/${teamId}/memberships/${encodedMemberDid}`), + ); + expect(orgIdMembership.status).toBe(200); + expect(parse(orgIdMembership).role).toBe('maintainer'); + + const orgIdRemoveMembership = await handleShimRequest( + ctx, + url(`/organizations/${orgId}/team/${teamId}/memberships/${encodedMemberDid}`), + 'DELETE', + ); + expect(orgIdRemoveMembership.status).toBe(204); + const afterMembershipRemove = await handleShimRequest(ctx, url(`/teams/${teamId}/members/${encodedMemberDid}`)); + expect(afterMembershipRemove.status).toBe(404); + + const orgIdAddMembership = await handleShimRequest( + ctx, + url(`/organizations/${orgId}/team/${teamId}/memberships/${encodedMemberDid}`), + 'PUT', + { role: 'member' }, + ); + expect(orgIdAddMembership.status).toBe(200); + expect(parse(orgIdAddMembership).role).toBe('member'); + + const legacyRemoveMember = await handleShimRequest(ctx, url(`/teams/${teamId}/members/${encodedMemberDid}`), 'DELETE'); + expect(legacyRemoveMember.status).toBe(204); + + const owner = encodeURIComponent(testDid); + const legacyRepoGrant = await handleShimRequest(ctx, url(`/teams/${teamId}/repos/${owner}/test-repo`), 'PUT', { + permission: 'push', + }); + expect(legacyRepoGrant.status).toBe(204); + + const orgIdRepos = parse(await handleShimRequest(ctx, url(`/organizations/${orgId}/team/${teamId}/repos`))); + expect(orgIdRepos.map((item: any) => item.name)).toContain('test-repo'); + + const legacyRepoCheck = await handleShimRequest(ctx, url(`/teams/${teamId}/repos/${owner}/test-repo`)); + expect(legacyRepoCheck.status).toBe(200); + expect(parse(legacyRepoCheck).role_name).toBe('write'); + + const orgIdRemoveRepo = await handleShimRequest( + ctx, + url(`/organizations/${orgId}/team/${teamId}/repos/${owner}/test-repo`), + 'DELETE', + ); + expect(orgIdRemoveRepo.status).toBe(204); + + const repoAfterRemove = await handleShimRequest(ctx, url(`/teams/${teamId}/repos/${owner}/test-repo`)); + expect(repoAfterRemove.status).toBe(404); + + const missingTeam = await handleShimRequest(ctx, url('/teams/999')); + expect(missingTeam.status).toBe(404); + const missingOrgTeam = await handleShimRequest(ctx, url(`/organizations/999/team/${teamId}`)); + expect(missingOrgTeam.status).toBe(404); + + const deleteTeam = await handleShimRequest(ctx, url(`/teams/${teamId}`), 'DELETE'); + expect(deleteTeam.status).toBe(204); + + const deleteMember = await handleShimRequest(ctx, orgUrl(`/memberships/${encodedMemberDid}`), 'DELETE'); + expect(deleteMember.status).toBe(204); + }); + + it('should update and delete organization teams', async () => { + const created = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Lifecycle Team', + description : 'Temporary team', + privacy : 'closed', + }); + expect(created.status).toBe(201); + + const invalid = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-team'), 'PATCH', { + privacy: 'private', + }); + expect(invalid.status).toBe(422); + + const updated = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-team'), 'PATCH', { + name : 'Lifecycle Automation', + description : 'Automation maintainers', + privacy : 'secret', + notification_setting : 'notifications_disabled', + permission : 'push', + }); + expect(updated.status).toBe(200); + const updatedTeam = parse(updated); + expect(updatedTeam.name).toBe('Lifecycle Automation'); + expect(updatedTeam.slug).toBe('lifecycle-automation'); + expect(updatedTeam.description).toBe('Automation maintainers'); + expect(updatedTeam.privacy).toBe('secret'); + + const oldSlug = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-team')); + expect(oldSlug.status).toBe(404); + + const fetched = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-automation')); + expect(fetched.status).toBe(200); + expect(parse(fetched).name).toBe('Lifecycle Automation'); + + const removed = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-automation'), 'DELETE'); + expect(removed.status).toBe(204); + expect(removed.body).toBe(''); + + const missing = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-automation')); + expect(missing.status).toBe(404); + + const missingDelete = await handleShimRequest(ctx, orgUrl('/teams/lifecycle-automation'), 'DELETE'); + expect(missingDelete.status).toBe(404); + }); + + it('should manage team repository permissions', async () => { + const owner = encodeURIComponent(testDid); + const teamRepoPath = `/teams/core-team/repos/${owner}/test-repo`; + + const add = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'PUT', { + permission: 'triage', + }); + expect(add.status).toBe(204); + expect(add.body).toBe(''); + + const check = await handleShimRequest(ctx, orgUrl(teamRepoPath)); + expect(check.status).toBe(200); + const checked = parse(check); + expect(checked.full_name).toBe(`${testDid}/test-repo`); + expect(checked.role_name).toBe('triage'); + expect(checked.permissions.pull).toBe(true); + expect(checked.permissions.triage).toBe(true); + expect(checked.permissions.push).toBe(false); + + const list = await handleShimRequest(ctx, orgUrl('/teams/core-team/repos')); + expect(list.status).toBe(200); + expect(parse(list).some((repo: any) => repo.full_name === `${testDid}/test-repo`)).toBe(true); + + const update = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'PUT', { + permission: 'admin', + }); + expect(update.status).toBe(204); + const updated = parse(await handleShimRequest(ctx, orgUrl(teamRepoPath))); + expect(updated.role_name).toBe('admin'); + expect(updated.permissions.admin).toBe(true); + expect(updated.permissions.maintain).toBe(true); + + const invalidPermission = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'PUT', { + permission: 'banana', + }); + expect(invalidPermission.status).toBe(422); + + const wrongOwner = await handleShimRequest( + ctx, + orgUrl(`/teams/core-team/repos/${encodeURIComponent('did:jwk:not-org')}/test-repo`), + 'PUT', + { permission: 'pull' }, + ); + expect(wrongOwner.status).toBe(422); + + const missingRepo = await handleShimRequest(ctx, orgUrl(`/teams/core-team/repos/${owner}/missing-repo`), 'PUT', { + permission: 'pull', + }); + expect(missingRepo.status).toBe(404); + + const remove = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'DELETE'); + expect(remove.status).toBe(204); + expect(remove.body).toBe(''); + + const missingCheck = await handleShimRequest(ctx, orgUrl(teamRepoPath)); + expect(missingCheck.status).toBe(404); + + const missingRemove = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'DELETE'); + expect(missingRemove.status).toBe(404); + }); + + it('should list teams with repository access', async () => { + const created = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Repository Access Team', + privacy : 'closed', + }); + expect(created.status).toBe(201); + const createdTeam = parse(created); + + const owner = encodeURIComponent(testDid); + const teamRepoPath = `/teams/${createdTeam.slug}/repos/${owner}/test-repo`; + const add = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'PUT', { + permission: 'maintain', + }); + expect(add.status).toBe(204); + + const list = await handleShimRequest(ctx, url(`/repos/${testDid}/test-repo/teams`)); + expect(list.status).toBe(200); + const team = parse(list).find((item: any) => item.slug === createdTeam.slug); + expect(team.name).toBe('Repository Access Team'); + expect(team.permission).toBe('maintain'); + expect(team.repositories_url).toContain(`/orgs/${ORG_NAME}/teams/${createdTeam.slug}/repos`); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/teams`)); + expect(missingRepo.status).toBe(404); + + const remove = await handleShimRequest(ctx, orgUrl(teamRepoPath), 'DELETE'); + expect(remove.status).toBe(204); + + const afterRemove = parse(await handleShimRequest(ctx, url(`/repos/${testDid}/test-repo/teams`))); + expect(afterRemove.some((item: any) => item.slug === createdTeam.slug)).toBe(false); + + const deleteTeam = await handleShimRequest(ctx, orgUrl(`/teams/${createdTeam.slug}`), 'DELETE'); + expect(deleteTeam.status).toBe(204); + }); + + it('should list child teams for an organization team', async () => { + const list = await handleShimRequest(ctx, orgUrl('/teams/core-team/teams')); + expect(list.status).toBe(200); + expect(parse(list)).toEqual([]); + + const missingTeam = await handleShimRequest(ctx, orgUrl('/teams/missing-team/teams')); + expect(missingTeam.status).toBe(404); + }); + + it('should list teams for the authenticated user', async () => { + const created = await handleShimRequest(ctx, orgUrl('/teams'), 'POST', { + name : 'Authenticated User Team', + description : 'Team visible through /user/teams', + privacy : 'closed', + }); + expect(created.status).toBe(201); + const createdTeam = parse(created); + + const membership = await handleShimRequest( + ctx, + orgUrl(`/teams/${createdTeam.slug}/memberships/${encodeURIComponent(testDid)}`), + 'PUT', + { alias: 'Local Owner' }, + ); + expect(membership.status).toBe(200); + expect(parse(membership).role).toBe('maintainer'); + + const repoGrant = await handleShimRequest( + ctx, + orgUrl(`/teams/${createdTeam.slug}/repos/${encodeURIComponent(testDid)}/test-repo`), + 'PUT', + { permission: 'push' }, + ); + expect(repoGrant.status).toBe(204); + + const list = await handleShimRequest(ctx, url('/user/teams')); + expect(list.status).toBe(200); + const team = parse(list).find((item: any) => item.slug === createdTeam.slug); + expect(team.name).toBe('Authenticated User Team'); + expect(team.description).toBe('Team visible through /user/teams'); + expect(team.members_count).toBe(1); + expect(team.repos_count).toBe(1); + expect(team.organization.login).toBe(ORG_NAME); + expect(team.created_at).toBeDefined(); + expect(team.updated_at).toBeDefined(); + + const removed = await handleShimRequest(ctx, orgUrl(`/teams/${createdTeam.slug}`), 'DELETE'); + expect(removed.status).toBe(204); + }); + + it('should get team membership details', async () => { + const existing = await handleShimRequest(ctx, orgUrl(`/teams/core-team/memberships/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(existing.status).toBe(200); + const membership = parse(existing); + expect(membership.state).toBe('active'); + expect(membership.role).toBe('member'); + expect(membership.team.slug).toBe('core-team'); + expect(membership.user.login).toBe(ORG_MEMBER_DID); + expect(membership.organization.login).toBe(ORG_NAME); + + const missing = await handleShimRequest(ctx, orgUrl('/teams/core-team/memberships/did%3Ajwk%3Aunknownmember')); + expect(missing.status).toBe(404); + }); + + it('should list, check, add, and remove team memberships', async () => { + const teamPath = '/teams/core-team'; + const members = await handleShimRequest(ctx, orgUrl(`${teamPath}/members`)); + expect(members.status).toBe(200); + expect(parse(members).map((item: any) => item.login)).toContain(ORG_MEMBER_DID); + + const existing = await handleShimRequest(ctx, orgUrl(`${teamPath}/members/${encodeURIComponent(ORG_MEMBER_DID)}`)); + expect(existing.status).toBe(204); + + const add = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'PUT', { + alias : 'Team Member', + role : 'maintainer', + }); + expect(add.status).toBe(200); + const membership = parse(add); + expect(membership.state).toBe('pending'); + expect(membership.role).toBe('maintainer'); + expect(membership.user.login).toBe(ORG_TEAM_MEMBER_DID); + expect(membership.team.slug).toBe('core-team'); + + const pendingDetail = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`))); + expect(pendingDetail.state).toBe('pending'); + + const pendingMemberCheck = await handleShimRequest(ctx, orgUrl(`${teamPath}/members/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`)); + expect(pendingMemberCheck.status).toBe(404); + + const invitations = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/invitations`))); + const invitation = invitations.find((item: any) => item.login === ORG_TEAM_MEMBER_DID); + expect(invitation.role).toBe('direct_member'); + expect(invitation.team.slug).toBe('core-team'); + + const orgInvitations = parse(await handleShimRequest(ctx, orgUrl('/invitations'))); + const orgInvitation = orgInvitations.find((item: any) => item.login === ORG_TEAM_MEMBER_DID); + expect(orgInvitation.role).toBe('direct_member'); + expect(orgInvitation.team_count).toBe(1); + expect(orgInvitation.invitation_source).toBe('member'); + + const orgInvitationTeams = parse(await handleShimRequest(ctx, orgUrl(`/invitations/${orgInvitation.id}/teams`))); + expect(orgInvitationTeams.map((item: any) => item.slug)).toContain('core-team'); + + const directInvitations = parse(await handleShimRequest(ctx, orgUrl('/invitations?role=direct_member'))); + expect(directInvitations.map((item: any) => item.login)).toContain(ORG_TEAM_MEMBER_DID); + + const adminInvitations = parse(await handleShimRequest(ctx, orgUrl('/invitations?role=admin'))); + expect(adminInvitations.map((item: any) => item.login)).not.toContain(ORG_TEAM_MEMBER_DID); + + const memberSourceInvitations = parse(await handleShimRequest(ctx, orgUrl('/invitations?invitation_source=member'))); + expect(memberSourceInvitations.map((item: any) => item.login)).toContain(ORG_TEAM_MEMBER_DID); + + const scimInvitations = parse(await handleShimRequest(ctx, orgUrl('/invitations?invitation_source=scim'))); + expect(scimInvitations.map((item: any) => item.login)).not.toContain(ORG_TEAM_MEMBER_DID); + + const invalidInvitationRole = await handleShimRequest(ctx, orgUrl('/invitations?role=owner')); + expect(invalidInvitationRole.status).toBe(422); + + const invalidInvitationSource = await handleShimRequest(ctx, orgUrl('/invitations?invitation_source=email')); + expect(invalidInvitationSource.status).toBe(422); + + const failedInvitations = parse(await handleShimRequest(ctx, orgUrl('/failed_invitations'))); + expect(failedInvitations).toEqual([]); + + const missingFailedInvitations = await handleShimRequest(ctx, url('/orgs/missing-org/failed_invitations')); + expect(missingFailedInvitations.status).toBe(404); + + const pendingMembers = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/members`))); + expect(pendingMembers.map((item: any) => item.login)).not.toContain(ORG_TEAM_MEMBER_DID); + + const cancelOrgInvitation = await handleShimRequest(ctx, orgUrl(`/invitations/${orgInvitation.id}`), 'DELETE'); + expect(cancelOrgInvitation.status).toBe(204); + + const canceledPendingDetail = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`)); + expect(canceledPendingDetail.status).toBe(404); + + const canceledInvitationTeams = await handleShimRequest(ctx, orgUrl(`/invitations/${orgInvitation.id}/teams`)); + expect(canceledInvitationTeams.status).toBe(404); + + const readd = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'PUT', { + alias : 'Team Member', + role : 'maintainer', + }); + expect(readd.status).toBe(200); + expect(parse(readd).state).toBe('pending'); + + const orgMembership = await handleShimRequest(ctx, orgUrl(`/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'PUT', { + role: 'member', + }); + expect(orgMembership.status).toBe(200); + + const activate = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'PUT', { + role: 'maintainer', + }); + expect(activate.status).toBe(200); + expect(parse(activate).state).toBe('active'); + + const afterAdd = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/members`))); + expect(afterAdd.map((item: any) => item.login)).toContain(ORG_TEAM_MEMBER_DID); + + const invitationsAfterActivate = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/invitations`))); + expect(invitationsAfterActivate.map((item: any) => item.login)).not.toContain(ORG_TEAM_MEMBER_DID); + + const maintainers = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/members?role=maintainer`))); + expect(maintainers.map((item: any) => item.login)).toContain(ORG_TEAM_MEMBER_DID); + expect(maintainers.map((item: any) => item.login)).not.toContain(ORG_MEMBER_DID); + + const memberRoleMembers = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/members?role=member`))); + expect(memberRoleMembers.map((item: any) => item.login)).toContain(ORG_MEMBER_DID); + expect(memberRoleMembers.map((item: any) => item.login)).not.toContain(ORG_TEAM_MEMBER_DID); + + const update = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'PUT', { + role: 'member', + }); + expect(update.status).toBe(200); + expect(parse(update).role).toBe('member'); + + const afterUpdateMembers = parse(await handleShimRequest(ctx, orgUrl(`${teamPath}/members?role=member`))); + expect(afterUpdateMembers.map((item: any) => item.login)).toContain(ORG_TEAM_MEMBER_DID); + + const invalidRole = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/did%3Ajwk%3Ainvalidteamrole`), 'PUT', { + role: 'admin', + }); + expect(invalidRole.status).toBe(422); + + const invalidFilter = await handleShimRequest(ctx, orgUrl(`${teamPath}/members?role=admin`)); + expect(invalidFilter.status).toBe(422); + + const removed = await handleShimRequest(ctx, orgUrl(`${teamPath}/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'DELETE'); + expect(removed.status).toBe(204); + + const removeOrgMembership = await handleShimRequest(ctx, orgUrl(`/memberships/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`), 'DELETE'); + expect(removeOrgMembership.status).toBe(204); + + const missing = await handleShimRequest(ctx, orgUrl(`${teamPath}/members/${encodeURIComponent(ORG_TEAM_MEMBER_DID)}`)); + expect(missing.status).toBe(404); + }); + }); + + // ========================================================================= + // GET/PUT /topics, GET /languages, /collaborators + // ========================================================================= + + describe('repository metadata endpoints', () => { + it('should return repository topics', async () => { + const res = await handleShimRequest(ctx, repoUrl('/topics')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.names).toEqual(['decentralized', 'git']); + }); + + it('should replace repository topics', async () => { + const res = await handleShimRequest(ctx, repoUrl('/topics'), 'PUT', { + names: ['Forge', 'p2p'], + }); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.names).toEqual(['forge', 'p2p']); + + const getRes = await handleShimRequest(ctx, repoUrl('/topics')); + expect(parse(getRes).names).toEqual(['forge', 'p2p']); + }); + + it('should reject invalid topic replacement payloads', async () => { + const res = await handleShimRequest(ctx, repoUrl('/topics'), 'PUT', { + names: ['not valid topic'], + }); + expect(res.status).toBe(422); + }); + + it('should return repository languages from repo tags', async () => { + const res = await handleShimRequest(ctx, repoUrl('/languages')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toEqual({ TypeScript: 0 }); + }); + + it('should list repository issue types', async () => { + const defaults = await handleShimRequest(ctx, repoUrl('/issue-types')); + expect(defaults.status).toBe(200); + const defaultTypes = parse(defaults); + expect(defaultTypes.map((item: any) => item.name)).toEqual(['Bug', 'Task', 'Feature']); + expect(defaultTypes[0].node_id).toContain('IT_'); + expect(defaultTypes[0].created_at).toBeDefined(); + + await mergeRepoSettings({ + issueTypes: { + bug: { + id : 901, + name : 'Bug', + description : 'Confirmed defect', + createdAt : '2026-01-01T00:00:00.000Z', + updatedAt : '2026-01-02T00:00:00.000Z', + }, + disabled: { + id : 902, + name : 'Disabled', + description : 'Hidden type', + isEnabled : false, + createdAt : '2026-01-01T00:00:00.000Z', + updatedAt : '2026-01-02T00:00:00.000Z', + }, + initiative: { + id : 903, + name : 'Initiative', + description : 'Long-running work', + color : 'orange', + isEnabled : true, + createdAt : '2026-01-03T00:00:00.000Z', + updatedAt : '2026-01-04T00:00:00.000Z', + }, + }, + }); + + const configured = await handleShimRequest(ctx, repoUrl('/issue-types')); + expect(configured.status).toBe(200); + expect(parse(configured)).toEqual([ + { + id : 901, + node_id : 'IT_901', + name : 'Bug', + description : 'Confirmed defect', + created_at : '2026-01-01T00:00:00.000Z', + updated_at : '2026-01-02T00:00:00.000Z', + }, + { + id : 903, + node_id : 'IT_903', + name : 'Initiative', + description : 'Long-running work', + created_at : '2026-01-03T00:00:00.000Z', + updated_at : '2026-01-04T00:00:00.000Z', + }, + ]); + + const missing = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/issue-types`)); + expect(missing.status).toBe(404); + }); + + it('should create, list, get, and delete repository deploy keys', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/keys')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const create = await handleShimRequest(ctx, repoUrl('/keys'), 'POST', { + title : 'CI deploy key', + key : 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGitdDeployKeyExample gitd@example', + read_only : false, + }); + expect(create.status).toBe(201); + const deployKey = parse(create); + expect(deployKey.title).toBe('CI deploy key'); + expect(deployKey.read_only).toBe(false); + expect(deployKey.verified).toBe(true); + expect(deployKey.added_by).toBe(testDid); + expect(deployKey.enabled).toBe(true); + expect(deployKey.last_used).toBeNull(); + expect(deployKey.url).toContain(`/repos/${testDid}/test-repo/keys/${deployKey.id}`); + + const duplicate = await handleShimRequest(ctx, repoUrl('/keys'), 'POST', { + key: deployKey.key, + }); + expect(duplicate.status).toBe(422); + + const list = await handleShimRequest(ctx, repoUrl('/keys')); + expect(list.status).toBe(200); + expect(parse(list).map((item: any) => item.id)).toContain(deployKey.id); + + const get = await handleShimRequest(ctx, repoUrl(`/keys/${deployKey.id}`)); + expect(get.status).toBe(200); + expect(parse(get).key).toBe(deployKey.key); + + const invalid = await handleShimRequest(ctx, repoUrl('/keys'), 'POST', { + read_only: 'yes', + }); + expect(invalid.status).toBe(422); + + const deleted = await handleShimRequest(ctx, repoUrl(`/keys/${deployKey.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + expect(deleted.body).toBe(''); + + const missing = await handleShimRequest(ctx, repoUrl(`/keys/${deployKey.id}`)); + expect(missing.status).toBe(404); + }); + + it('should create, list, get, and delete repository autolinks', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/autolinks')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const invalidMissingToken = await handleShimRequest(ctx, repoUrl('/autolinks'), 'POST', { + key_prefix : 'TICKET-', + url_template : 'https://tracker.example.test/browse/TICKET', + }); + expect(invalidMissingToken.status).toBe(422); + + const create = await handleShimRequest(ctx, repoUrl('/autolinks'), 'POST', { + key_prefix : 'TICKET-', + url_template : 'https://tracker.example.test/browse/TICKET-', + is_alphanumeric : true, + }); + expect(create.status).toBe(201); + const autolink = parse(create); + expect(autolink.key_prefix).toBe('TICKET-'); + expect(autolink.url_template).toBe('https://tracker.example.test/browse/TICKET-'); + expect(autolink.is_alphanumeric).toBe(true); + + const duplicate = await handleShimRequest(ctx, repoUrl('/autolinks'), 'POST', { + key_prefix : 'ticket-', + url_template : 'https://tracker.example.test/other/', + }); + expect(duplicate.status).toBe(422); + + const list = parse(await handleShimRequest(ctx, repoUrl('/autolinks'))); + expect(list).toEqual([autolink]); + + const get = await handleShimRequest(ctx, repoUrl(`/autolinks/${autolink.id}`)); + expect(get.status).toBe(200); + expect(parse(get)).toEqual(autolink); + + const deleted = await handleShimRequest(ctx, repoUrl(`/autolinks/${autolink.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + + const missing = await handleShimRequest(ctx, repoUrl(`/autolinks/${autolink.id}`)); + expect(missing.status).toBe(404); + }); + + it('should get, set, validate, and remove repository interaction limits', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/interaction-limits')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual({}); + + const invalidLimit = await handleShimRequest(ctx, repoUrl('/interaction-limits'), 'PUT', { + limit: 'everyone', + }); + expect(invalidLimit.status).toBe(422); + + const invalidExpiry = await handleShimRequest(ctx, repoUrl('/interaction-limits'), 'PUT', { + limit : 'contributors_only', + expiry : 'forever', + }); + expect(invalidExpiry.status).toBe(422); + + const set = await handleShimRequest(ctx, repoUrl('/interaction-limits'), 'PUT', { + limit : 'collaborators_only', + expiry : 'three_days', + }); + expect(set.status).toBe(200); + const limit = parse(set); + expect(limit.limit).toBe('collaborators_only'); + expect(limit.origin).toBe('repository'); + expect(Date.parse(limit.expires_at)).toBeGreaterThan(Date.now()); + + const get = parse(await handleShimRequest(ctx, repoUrl('/interaction-limits'))); + expect(get.limit).toBe('collaborators_only'); + expect(get.origin).toBe('repository'); + + const deleted = await handleShimRequest(ctx, repoUrl('/interaction-limits'), 'DELETE'); + expect(deleted.status).toBe(204); + + const afterDelete = await handleShimRequest(ctx, repoUrl('/interaction-limits')); + expect(parse(afterDelete)).toEqual({}); + }); + + it('should enable, check, and disable repository security toggles', async () => { + const initialAlerts = await handleShimRequest(ctx, repoUrl('/vulnerability-alerts')); + expect(initialAlerts.status).toBe(404); + + const enableAlerts = await handleShimRequest(ctx, repoUrl('/vulnerability-alerts'), 'PUT'); + expect(enableAlerts.status).toBe(204); + + const checkAlerts = await handleShimRequest(ctx, repoUrl('/vulnerability-alerts')); + expect(checkAlerts.status).toBe(204); + expect(checkAlerts.body).toBe(''); + + const disableAlerts = await handleShimRequest(ctx, repoUrl('/vulnerability-alerts'), 'DELETE'); + expect(disableAlerts.status).toBe(204); + + const alertsAfterDisable = await handleShimRequest(ctx, repoUrl('/vulnerability-alerts')); + expect(alertsAfterDisable.status).toBe(404); + + const initialFixes = await handleShimRequest(ctx, repoUrl('/automated-security-fixes')); + expect(initialFixes.status).toBe(404); + + const enableFixes = await handleShimRequest(ctx, repoUrl('/automated-security-fixes'), 'PUT'); + expect(enableFixes.status).toBe(204); + + const checkFixes = await handleShimRequest(ctx, repoUrl('/automated-security-fixes')); + expect(checkFixes.status).toBe(200); + expect(parse(checkFixes)).toEqual({ enabled: true, paused: false }); + + const disableFixes = await handleShimRequest(ctx, repoUrl('/automated-security-fixes'), 'DELETE'); + expect(disableFixes.status).toBe(204); + + const fixesAfterDisable = await handleShimRequest(ctx, repoUrl('/automated-security-fixes')); + expect(fixesAfterDisable.status).toBe(404); + }); + + it('should create, report, list, update, request CVEs for, and fork repository security advisories', async () => { + await mergeRepoSettings({ + securityAdvisories: { + 'GHSA-SEED-0001-0002': { + ghsaId : 'GHSA-SEED-0001-0002', + cveId : 'CVE-2026-0002', + summary : 'Seed advisory', + description : 'Seed advisory description.', + severity : 'high', + state : 'published', + authorDid : MAINTAINER_DID, + publisherDid : MAINTAINER_DID, + createdAt : '2026-04-01T00:00:00.000Z', + updatedAt : '2026-04-02T00:00:00.000Z', + publishedAt : '2026-04-02T00:00:00.000Z', + vulnerabilities : [ + { + package : { ecosystem: 'npm', name: 'left-pad' }, + vulnerable_version_range : '< 1.3.0', + patched_versions : '1.3.0', + vulnerable_functions : ['pad'], + }, + ], + cweIds : ['CWE-79'], + credits : [{ login: TRIAGER_DID, type: 'analyst' }], + collaboratingUsers : [TRIAGER_DID], + }, + }, + }); + + const listRes = await handleShimRequest(ctx, repoUrl('/security-advisories?state=published&sort=published&direction=asc')); + expect(listRes.status).toBe(200); + const listed = parse(listRes); + expect(listed).toHaveLength(1); + expect(listed[0].ghsa_id).toBe('GHSA-SEED-0001-0002'); + expect(listed[0].cve_id).toBe('CVE-2026-0002'); + expect(listed[0].author.login).toBe(MAINTAINER_DID); + expect(listed[0].vulnerabilities[0].package.name).toBe('left-pad'); + expect(listed[0].credits_detailed[0].user.login).toBe(TRIAGER_DID); + + const invalidListRes = await handleShimRequest(ctx, repoUrl('/security-advisories?state=bad')); + expect(invalidListRes.status).toBe(422); + + const getSeedRes = await handleShimRequest(ctx, repoUrl('/security-advisories/GHSA-SEED-0001-0002')); + expect(getSeedRes.status).toBe(200); + expect(parse(getSeedRes).state).toBe('published'); + + const createRes = await handleShimRequest(ctx, repoUrl('/security-advisories'), 'POST', { + summary : 'Unsafe archive extraction', + description : 'An archive entry can write outside the intended extraction directory.', + severity : 'low', + cwe_ids : ['CWE-22'], + credits : [{ login: TRIAGER_DID, type: 'reporter' }], + start_private_fork : true, + vulnerabilities : [ + { + package : { ecosystem: 'npm', name: 'tar-stream' }, + vulnerable_version_range : '< 3.1.0', + patched_versions : '3.1.0', + }, + ], + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.ghsa_id).toMatch(/^GHSA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/); + expect(created.state).toBe('draft'); + expect(created.private_fork.full_name).toContain(`${testDid}/test-repo-${created.ghsa_id.toLowerCase()}`); + + const invalidCreateRes = await handleShimRequest(ctx, repoUrl('/security-advisories'), 'POST', { + summary : 'Invalid advisory', + description : 'Invalid advisory description.', + severity : 'low', + cvss_vector_string : 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N', + vulnerabilities : [], + }); + expect(invalidCreateRes.status).toBe(422); + + const reportRes = await handleShimRequest(ctx, repoUrl('/security-advisories/reports'), 'POST', { + summary : 'Privately reported issue', + description : 'A reporter found a vulnerability before public disclosure.', + severity : 'medium', + }); + expect(reportRes.status).toBe(201); + const reported = parse(reportRes); + expect(reported.state).toBe('triage'); + expect(reported.submission.accepted).toBe(false); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/security-advisories/${created.ghsa_id}`), 'PATCH', { + severity : 'critical', + state : 'published', + cve_id : null, + collaborating_users : [TRIAGER_DID], + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.severity).toBe('critical'); + expect(updated.state).toBe('published'); + expect(updated.publisher.login).toBe(testDid); + expect(updated.cve_id).toBeNull(); + expect(updated.collaborating_users[0].login).toBe(TRIAGER_DID); + + const cveRes = await handleShimRequest(ctx, repoUrl(`/security-advisories/${created.ghsa_id}/cve`), 'POST'); + expect(cveRes.status).toBe(202); + + const forkRes = await handleShimRequest(ctx, repoUrl(`/security-advisories/${reported.ghsa_id}/forks`), 'POST'); + expect(forkRes.status).toBe(202); + + const getReportedRes = await handleShimRequest(ctx, repoUrl(`/security-advisories/${reported.ghsa_id}`)); + expect(getReportedRes.status).toBe(200); + expect(parse(getReportedRes).private_fork.full_name).toContain(`${testDid}/test-repo-${reported.ghsa_id.toLowerCase()}`); + + const missingAdvisoryRes = await handleShimRequest(ctx, repoUrl('/security-advisories/GHSA-MISS-0000-0000')); + expect(missingAdvisoryRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/security-advisories`)); + expect(missingRepoRes.status).toBe(404); + }); + + it('should list, filter, get, update, and list repository code scanning alert instances', async () => { + await mergeRepoSettings({ + codeScanningAlerts: { + '1': { + number : 1, + state : 'open', + rule : { + id : 'js/zipslip', + severity : 'error', + security_severity_level : 'high', + description : 'Arbitrary file write during zip extraction', + name : 'js/zipslip', + tags : ['security', 'external/cwe/cwe-022'], + }, + tool: { + name : 'CodeQL', + guid : null, + version : '2.17.0', + }, + mostRecentInstance: { + ref : 'refs/heads/main', + analysisKey : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + category : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + environment : '{}', + state : 'open', + commitSha : MAIN_SHA, + message : { text: 'This path depends on a user-provided value.' }, + location : { path: 'src/archive.ts', start_line: 42, end_line: 42 }, + classifications : ['source'], + }, + instances: [ + { + ref : 'refs/heads/main', + analysisKey : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + category : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + environment : '{}', + state : 'open', + commitSha : MAIN_SHA, + message : { text: 'This path depends on a user-provided value.' }, + location : { path: 'src/archive.ts', start_line: 42, end_line: 42 }, + classifications : ['source'], + }, + { + ref : 'refs/pull/17/merge', + analysisKey : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + category : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + environment : '{}', + state : 'fixed', + commitSha : FEATURE_SHA, + message : { text: 'The pull request path is sanitized.' }, + location : { path: 'src/archive.ts', start_line: 50, end_line: 50 }, + classifications : ['test'], + }, + ], + createdAt : '2026-02-01T00:00:00.000Z', + updatedAt : '2026-02-02T00:00:00.000Z', + }, + '2': { + number : 2, + state : 'fixed', + rule : { + id : 'ts/hardcoded-token', + severity : 'warning', + security_severity_level : 'medium', + description : 'Hardcoded token in source', + name : 'ts/hardcoded-token', + }, + tool: { + name : 'Semgrep', + guid : 'semgrep-guid', + version : '1.70.0', + }, + mostRecentInstance: { + ref : 'refs/heads/main', + analysisKey : 'semgrep', + category : 'semgrep', + environment : '{}', + state : 'fixed', + commitSha : TAG_SHA, + message : { text: 'The token was removed.' }, + location : { path: 'src/config.ts', start_line: 8, end_line: 8 }, + }, + createdAt : '2026-02-03T00:00:00.000Z', + updatedAt : '2026-02-04T00:00:00.000Z', + fixedAt : '2026-02-04T00:00:00.000Z', + }, + }, + }); + + const filteredRes = await handleShimRequest( + ctx, + repoUrl('/code-scanning/alerts?state=open&severity=high&tool_name=CodeQL&ref=main&assignees=none'), + ); + expect(filteredRes.status).toBe(200); + const filtered = parse(filteredRes); + expect(filtered).toHaveLength(1); + expect(filtered[0].number).toBe(1); + expect(filtered[0].rule.id).toBe('js/zipslip'); + expect(filtered[0].tool.name).toBe('CodeQL'); + expect(filtered[0].most_recent_instance).toMatchObject({ + ref : 'refs/heads/main', + analysis_key : '.github/workflows/codeql-analysis.yml:CodeQL-Build', + commit_sha : MAIN_SHA, + }); + expect(filtered[0].instances_url).toContain(`/repos/${testDid}/test-repo/code-scanning/alerts/1/instances`); + + const invalidFilterRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts?tool_name=CodeQL&tool_guid=guid')); + expect(invalidFilterRes.status).toBe(422); + + const getRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/2')); + expect(getRes.status).toBe(200); + expect(parse(getRes).fixed_at).toBe('2026-02-04T00:00:00.000Z'); + + const mainInstancesRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/1/instances?ref=main')); + expect(mainInstancesRes.status).toBe(200); + expect(parse(mainInstancesRes).map((instance: any) => instance.ref)).toEqual(['refs/heads/main']); + + const pullInstancesRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/1/instances?pr=17')); + expect(pullInstancesRes.status).toBe(200); + expect(parse(pullInstancesRes).map((instance: any) => instance.ref)).toEqual(['refs/pull/17/merge']); + + const invalidDismissRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/1'), 'PATCH', { + state: 'dismissed', + }); + expect(invalidDismissRes.status).toBe(422); + + const dismissedRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/1'), 'PATCH', { + state : 'dismissed', + dismissed_reason : 'false positive', + dismissed_comment : 'Sanitized before extraction.', + create_request : true, + assignees : [TRIAGER_DID], + }); + expect(dismissedRes.status).toBe(200); + const dismissed = parse(dismissedRes); + expect(dismissed.state).toBe('dismissed'); + expect(dismissed.dismissed_by.login).toBe(testDid); + expect(dismissed.dismissed_reason).toBe('false positive'); + expect(dismissed.dismissed_comment).toBe('Sanitized before extraction.'); + expect(dismissed.most_recent_instance.state).toBe('dismissed'); + expect(dismissed.assignees.map((assignee: any) => assignee.login)).toEqual([TRIAGER_DID]); + + const assigned = parse(await handleShimRequest(ctx, repoUrl(`/code-scanning/alerts?state=dismissed&assignees=${TRIAGER_DID}`))); + expect(assigned.map((alert: any) => alert.number)).toEqual([1]); + + const reopenedRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/1'), 'PATCH', { + state : 'open', + assignees : [], + }); + expect(reopenedRes.status).toBe(200); + const reopened = parse(reopenedRes); + expect(reopened.state).toBe('open'); + expect(reopened.dismissed_by).toBeNull(); + expect(reopened.dismissed_reason).toBeNull(); + expect(reopened.assignees).toEqual([]); + + const missingAlertRes = await handleShimRequest(ctx, repoUrl('/code-scanning/alerts/999')); + expect(missingAlertRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/code-scanning/alerts`)); + expect(missingRepoRes.status).toBe(404); + }); + + it('should list, filter, get, update, locate, and report repository secret scanning alerts', async () => { + await mergeRepoSettings({ + secretScanningAlerts: { + '1': { + number : 1, + state : 'open', + secretType : 'github_personal_access_token', + secretTypeDisplayName : 'GitHub Personal Access Token', + secret : 'ghp_secret', + provider : 'GitHub', + providerSlug : 'github_secret_scanning', + createdAt : '2026-03-01T00:00:00.000Z', + updatedAt : '2026-03-02T00:00:00.000Z', + validity : 'active', + publiclyLeaked : true, + multiRepo : false, + isBase64Encoded : false, + firstLocationDetected : { + path : '/src/config.ts', + start_line : 1, + end_line : 1, + start_column : 7, + end_column : 17, + blob_sha : MAIN_SHA, + commit_sha : MAIN_SHA, + }, + hasMoreLocations : true, + assignedTo : null, + locations : [ + { + type : 'commit', + details : { + path : '/src/config.ts', + start_line : 1, + end_line : 1, + start_column : 7, + end_column : 17, + blob_sha : MAIN_SHA, + commit_sha : MAIN_SHA, + }, + }, + { + type : 'issue_title', + details : { issue_number: 1, title: 'leaked token' }, + }, + ], + }, + '2': { + number : 2, + state : 'resolved', + secretType : 'slack_webhook_url', + secretTypeDisplayName : 'Slack Webhook URL', + secret : 'https://hooks.slack.test/secret', + provider : 'Slack', + providerSlug : 'slack', + createdAt : '2026-03-03T00:00:00.000Z', + updatedAt : '2026-03-04T00:00:00.000Z', + resolution : 'revoked', + resolvedAt : '2026-03-04T01:00:00.000Z', + resolvedBy : TRIAGER_DID, + validity : 'inactive', + publiclyLeaked : false, + multiRepo : true, + pushProtectionBypassed : true, + pushProtectionBypassedBy : MAINTAINER_DID, + pushProtectionBypassedAt : '2026-03-03T05:00:00.000Z', + assignedTo : TRIAGER_DID, + locations : [ + { + type : 'pull_request_body', + details : { pull_request_body_url: `${BASE}/repos/${testDid}/test-repo/pulls/7` }, + }, + ], + }, + }, + secretScanningScanHistory: { + incrementalScans: [ + { type: 'git', status: 'completed', completedAt: '2026-03-05T00:00:00.000Z' }, + ], + backfillScans: [ + { + type : 'git', + status : 'completed', + startedAt : '2026-03-01T00:00:00.000Z', + completedAt : '2026-03-01T00:05:00.000Z', + }, + ], + patternUpdateScans : [], + customPatternBackfillScans : [], + genericSecretsBackfillScans : [], + }, + }); + + const filteredRes = await handleShimRequest( + ctx, + repoUrl('/secret-scanning/alerts?state=open&secret_type=github_personal_access_token&providers=github_secret_scanning&validity=active&is_publicly_leaked=true&assignee=none&hide_secret=true'), + ); + expect(filteredRes.status).toBe(200); + const filtered = parse(filteredRes); + expect(filtered).toHaveLength(1); + expect(filtered[0].number).toBe(1); + expect(filtered[0].secret).toBe('********'); + expect(filtered[0].locations_url).toContain(`/repos/${testDid}/test-repo/secret-scanning/alerts/1/locations`); + expect(filtered[0].first_location_detected.path).toBe('/src/config.ts'); + + const invalidSecretTypeFilterRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts?secret_type=a&exclude_secret_types=b')); + expect(invalidSecretTypeFilterRes.status).toBe(422); + + const invalidProviderFilterRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts?providers=a&exclude_providers=b')); + expect(invalidProviderFilterRes.status).toBe(422); + + const getRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/2')); + expect(getRes.status).toBe(200); + const resolved = parse(getRes); + expect(resolved.resolution).toBe('revoked'); + expect(resolved.resolved_by.login).toBe(TRIAGER_DID); + expect(resolved.push_protection_bypassed_by.login).toBe(MAINTAINER_DID); + + const locationsRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/1/locations?per_page=1')); + expect(locationsRes.status).toBe(200); + expect(locationsRes.headers.Link).toContain('rel="next"'); + expect(parse(locationsRes)[0].type).toBe('commit'); + + const historyRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/scan-history')); + expect(historyRes.status).toBe(200); + expect(parse(historyRes).incremental_scans[0].completed_at).toBe('2026-03-05T00:00:00.000Z'); + + const invalidResolveRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/1'), 'PATCH', { + state: 'resolved', + }); + expect(invalidResolveRes.status).toBe(422); + + const resolvedRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/1'), 'PATCH', { + state : 'resolved', + resolution : 'false_positive', + resolution_comment : 'Token was generated for fixture coverage.', + validity : 'inactive', + assignee : TRIAGER_DID, + }); + expect(resolvedRes.status).toBe(200); + const updated = parse(resolvedRes); + expect(updated.state).toBe('resolved'); + expect(updated.resolution).toBe('false_positive'); + expect(updated.resolved_by.login).toBe(testDid); + expect(updated.resolution_comment).toBe('Token was generated for fixture coverage.'); + expect(updated.validity).toBe('inactive'); + expect(updated.assigned_to.login).toBe(TRIAGER_DID); + + const assigned = parse(await handleShimRequest(ctx, repoUrl(`/secret-scanning/alerts?resolution=false_positive&assignee=${TRIAGER_DID}`))); + expect(assigned.map((alert: any) => alert.number)).toEqual([1]); + + const reopenedRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/1'), 'PATCH', { + state : 'open', + assignee : null, + }); + expect(reopenedRes.status).toBe(200); + const reopened = parse(reopenedRes); + expect(reopened.state).toBe('open'); + expect(reopened.resolution).toBeNull(); + expect(reopened.resolved_by).toBeNull(); + expect(reopened.assigned_to).toBeNull(); + + const missingAlertRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/999')); + expect(missingAlertRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/secret-scanning/alerts`)); + expect(missingRepoRes.status).toBe(404); + }); + + it('should create repository secret scanning push protection bypasses', async () => { + await mergeRepoSettings({ + secretScanningAlerts: { + '7': { + number : 7, + state : 'open', + secretType : 'github_personal_access_token', + secretTypeDisplayName : 'GitHub Personal Access Token', + secret : 'ghp_push_protected', + provider : 'GitHub', + providerSlug : 'github_secret_scanning', + createdAt : '2026-03-07T00:00:00.000Z', + updatedAt : '2026-03-07T00:00:00.000Z', + }, + }, + secretScanningPushProtectionBypasses: { + 'placeholder-123': { + placeholderId : 'placeholder-123', + tokenType : 'github_personal_access_token', + expireAt : '2026-03-07T03:00:00.000Z', + alertNumber : 7, + }, + }, + }); + + const createRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/push-protection-bypasses'), 'POST', { + reason : 'will_fix_later', + placeholder_id : 'placeholder-123', + }); + expect(createRes.status).toBe(200); + expect(parse(createRes)).toEqual({ + reason : 'will_fix_later', + expire_at : '2026-03-07T03:00:00.000Z', + token_type : 'github_personal_access_token', + }); + + const alertRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/alerts/7')); + expect(alertRes.status).toBe(200); + const alert = parse(alertRes); + expect(alert.push_protection_bypassed).toBe(true); + expect(alert.push_protection_bypassed_by.login).toBe(testDid); + expect(alert.push_protection_bypassed_at).toBeDefined(); + + const invalidReasonRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/push-protection-bypasses'), 'POST', { + reason : 'bad_reason', + placeholder_id : 'placeholder-123', + }); + expect(invalidReasonRes.status).toBe(422); + + const missingPlaceholderRes = await handleShimRequest(ctx, repoUrl('/secret-scanning/push-protection-bypasses'), 'POST', { + reason : 'false_positive', + placeholder_id : 'missing-placeholder', + }); + expect(missingPlaceholderRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest( + ctx, + url(`/repos/${testDid}/missing-repo/secret-scanning/push-protection-bypasses`), + 'POST', + { reason: 'used_in_tests', placeholder_id: 'placeholder-123' }, + ); + expect(missingRepoRes.status).toBe(404); + }); + + it('should list, filter, get, and update repository Dependabot alerts', async () => { + await mergeRepoSettings({ + dependabotAlerts: { + '1': { + number : 1, + state : 'open', + dependency : { + package : { ecosystem: 'npm', name: 'lodash' }, + manifestPath : 'package-lock.json', + scope : 'runtime', + }, + securityAdvisory: { + ghsa_id : 'GHSA-test-lodash', + cve_id : 'CVE-2026-0001', + summary : 'Prototype pollution in lodash', + description : 'A crafted payload can modify object prototypes.', + severity : 'high', + classification : 'general', + epss : { percentage: 0.42, percentile: '0.90' }, + }, + securityVulnerability: { + package : { ecosystem: 'npm', name: 'lodash' }, + severity : 'high', + vulnerable_version_range : '< 4.17.21', + first_patched_version : { identifier: '4.17.21' }, + }, + createdAt : '2026-01-01T00:00:00.000Z', + updatedAt : '2026-01-02T00:00:00.000Z', + }, + '2': { + number : 2, + state : 'fixed', + dependency : { + package : { ecosystem: 'pip', name: 'django' }, + manifestPath : 'requirements.txt', + scope : 'development', + }, + securityAdvisory: { + ghsa_id : 'GHSA-test-django', + summary : 'Information exposure in django', + description : 'A diagnostic endpoint leaks metadata.', + severity : 'medium', + classification : 'general', + }, + securityVulnerability: { + package : { ecosystem: 'pip', name: 'django' }, + severity : 'medium', + vulnerable_version_range : '< 4.2.1', + first_patched_version : null, + }, + createdAt : '2026-01-03T00:00:00.000Z', + updatedAt : '2026-01-04T00:00:00.000Z', + fixedAt : '2026-01-04T00:00:00.000Z', + }, + }, + }); + + const filteredRes = await handleShimRequest( + ctx, + repoUrl('/dependabot/alerts?state=open&severity=high&ecosystem=npm&package=lodash&manifest=package-lock.json&has=patch'), + ); + expect(filteredRes.status).toBe(200); + const filtered = parse(filteredRes); + expect(filtered).toHaveLength(1); + expect(filtered[0].number).toBe(1); + expect(filtered[0].dependency).toEqual({ + package : { ecosystem: 'npm', name: 'lodash' }, + manifest_path : 'package-lock.json', + scope : 'runtime', + }); + expect(filtered[0].security_advisory.ghsa_id).toBe('GHSA-test-lodash'); + expect(filtered[0].security_vulnerability.first_patched_version.identifier).toBe('4.17.21'); + expect(filtered[0].url).toContain(`/repos/${testDid}/test-repo/dependabot/alerts/1`); + expect(filtered[0].repository.full_name).toBe(`${testDid}/test-repo`); + + const pagedRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts?per_page=1')); + expect(pagedRes.status).toBe(200); + expect(pagedRes.headers.Link).toContain('rel="next"'); + + const getRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts/2')); + expect(getRes.status).toBe(200); + expect(parse(getRes).fixed_at).toBe('2026-01-04T00:00:00.000Z'); + + const invalidFilterRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts?sort=bad')); + expect(invalidFilterRes.status).toBe(422); + + const invalidDismissRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts/1'), 'PATCH', { + state: 'dismissed', + }); + expect(invalidDismissRes.status).toBe(422); + + const dismissedRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts/1'), 'PATCH', { + state : 'dismissed', + dismissed_reason : 'tolerable_risk', + dismissed_comment : 'Accepted until the next dependency sweep.', + assignees : [TRIAGER_DID], + }); + expect(dismissedRes.status).toBe(200); + const dismissed = parse(dismissedRes); + expect(dismissed.state).toBe('dismissed'); + expect(dismissed.dismissed_by.login).toBe(testDid); + expect(dismissed.dismissed_reason).toBe('tolerable_risk'); + expect(dismissed.dismissed_comment).toBe('Accepted until the next dependency sweep.'); + expect(dismissed.assignees.map((assignee: any) => assignee.login)).toEqual([TRIAGER_DID]); + + const assigned = parse(await handleShimRequest(ctx, repoUrl(`/dependabot/alerts?state=dismissed&assignee=${TRIAGER_DID}`))); + expect(assigned.map((alert: any) => alert.number)).toEqual([1]); + + const reopenedRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts/1'), 'PATCH', { + state : 'open', + assignees : [], + }); + expect(reopenedRes.status).toBe(200); + const reopened = parse(reopenedRes); + expect(reopened.state).toBe('open'); + expect(reopened.dismissed_by).toBeNull(); + expect(reopened.dismissed_reason).toBeNull(); + expect(reopened.assignees).toEqual([]); + + const missingAlertRes = await handleShimRequest(ctx, repoUrl('/dependabot/alerts/999')); + expect(missingAlertRes.status).toBe(404); + + const missingRepoRes = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/dependabot/alerts`)); + expect(missingRepoRes.status).toBe(404); + }); + + it('should export and fetch repository dependency graph SBOM reports', async () => { + const exported = await handleShimRequest(ctx, repoUrl('/dependency-graph/sbom')); + expect(exported.status).toBe(200); + const body = parse(exported); + expect(body.sbom.SPDXID).toBe('SPDXRef-DOCUMENT'); + expect(body.sbom.spdxVersion).toBe('SPDX-2.3'); + expect(body.sbom.name).toBe(`${testDid}/test-repo`); + expect(body.sbom.dataLicense).toBe('CC0-1.0'); + expect(body.sbom.documentNamespace).toContain(`/repos/${testDid}/test-repo/dependency-graph/sbom/`); + expect(body.sbom.packages).toHaveLength(1); + expect(body.sbom.packages[0]).toMatchObject({ + name : `${testDid}/test-repo`, + SPDXID : 'SPDXRef-Repository', + versionInfo : 'main', + downloadLocation : `${BASE}/repos/${testDid}/test-repo`, + filesAnalyzed : false, + }); + expect(body.sbom.packages[0].externalRefs[0]).toEqual({ + referenceCategory : 'PACKAGE-MANAGER', + referenceType : 'purl', + referenceLocator : `pkg:generic/${encodeURIComponent(testDid)}/test-repo@main`, + }); + expect(body.sbom.relationships).toEqual([{ + spdxElementId : 'SPDXRef-DOCUMENT', + relationshipType : 'DESCRIBES', + relatedSpdxElement : 'SPDXRef-Repository', + }]); + + const generate = await handleShimRequest(ctx, repoUrl('/dependency-graph/sbom/generate-report')); + expect(generate.status).toBe(201); + const report = parse(generate); + expect(report.sbom_url).toContain(`/repos/${testDid}/test-repo/dependency-graph/sbom/fetch-report/`); + expect(generate.headers.Location).toBe(report.sbom_url); + + const fetch = await handleShimRequest(ctx, new URL(report.sbom_url)); + expect(fetch.status).toBe(302); + expect(fetch.body).toBe(''); + expect(fetch.headers.Location).toBe(`${BASE}/repos/${testDid}/test-repo/dependency-graph/sbom?download=1`); + + const downloaded = await handleShimRequest(ctx, new URL(fetch.headers.Location)); + expect(downloaded.status).toBe(200); + expect(parse(downloaded)).toEqual(body); + + const missingReport = await handleShimRequest(ctx, repoUrl('/dependency-graph/sbom/fetch-report/00000000-0000-4000-8000-000000000000')); + expect(missingReport.status).toBe(404); + + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/dependency-graph/sbom`)); + expect(missingRepo.status).toBe(404); + }); + + it('should manage repository custom properties, dispatches, and policy metadata', async () => { + const initialProperties = await handleShimRequest(ctx, repoUrl('/properties/values')); + expect(initialProperties.status).toBe(200); + expect(parse(initialProperties)).toEqual([]); + + const invalidProperties = await handleShimRequest(ctx, repoUrl('/properties/values'), 'PATCH', { + properties: [{ property_name: 'tier', value: 3 }], + }); + expect(invalidProperties.status).toBe(422); + + const updateProperties = await handleShimRequest(ctx, repoUrl('/properties/values'), 'PATCH', { + properties: [ + { property_name: 'runtime', value: 'bun' }, + { property_name: 'labels', value: ['dwn', 'forge'] }, + ], + }); + expect(updateProperties.status).toBe(204); + + const properties = parse(await handleShimRequest(ctx, repoUrl('/properties/values'))); + expect(properties).toEqual([ + { property_name: 'labels', value: ['dwn', 'forge'] }, + { property_name: 'runtime', value: 'bun' }, + ]); + + const removeProperty = await handleShimRequest(ctx, repoUrl('/properties/values'), 'PATCH', { + properties: [{ property_name: 'runtime', value: null }], + }); + expect(removeProperty.status).toBe(204); + expect(parse(await handleShimRequest(ctx, repoUrl('/properties/values')))).toEqual([ + { property_name: 'labels', value: ['dwn', 'forge'] }, + ]); + + const dispatch = await handleShimRequest(ctx, repoUrl('/dispatches'), 'POST', { + event_type : 'sync-requested', + client_payload : { ref: 'main' }, + }); + expect(dispatch.status).toBe(204); + + const missingDispatchType = await handleShimRequest(ctx, repoUrl('/dispatches'), 'POST', {}); + expect(missingDispatchType.status).toBe(422); + + const tooManyPayloadKeys = await handleShimRequest(ctx, repoUrl('/dispatches'), 'POST', { + event_type : 'too-many', + client_payload : Object.fromEntries(Array.from({ length: 11 }, (_, index) => [`key${index}`, index])), + }); + expect(tooManyPayloadKeys.status).toBe(422); + + const codeowners = parse(await handleShimRequest(ctx, repoUrl('/codeowners/errors'))); + expect(codeowners.errors).toEqual([]); + + const hashAlgorithm = parse(await handleShimRequest(ctx, repoUrl('/hash-algorithm'))); + expect(hashAlgorithm.hash_algorithm).toBe('sha1'); + + const initialImmutable = await handleShimRequest(ctx, repoUrl('/immutable-releases')); + expect(initialImmutable.status).toBe(404); + + const enableImmutable = await handleShimRequest(ctx, repoUrl('/immutable-releases'), 'PUT'); + expect(enableImmutable.status).toBe(204); + expect(parse(await handleShimRequest(ctx, repoUrl('/immutable-releases')))).toEqual({ + enabled : true, + enforced_by_owner : false, + }); + + const disableImmutable = await handleShimRequest(ctx, repoUrl('/immutable-releases'), 'DELETE'); + expect(disableImmutable.status).toBe(204); + expect((await handleShimRequest(ctx, repoUrl('/immutable-releases'))).status).toBe(404); + + const privateVulnerabilityInitial = parse(await handleShimRequest(ctx, repoUrl('/private-vulnerability-reporting'))); + expect(privateVulnerabilityInitial.enabled).toBe(false); + + const enablePrivateVulnerability = await handleShimRequest(ctx, repoUrl('/private-vulnerability-reporting'), 'PUT'); + expect(enablePrivateVulnerability.status).toBe(204); + expect(parse(await handleShimRequest(ctx, repoUrl('/private-vulnerability-reporting'))).enabled).toBe(true); + + const disablePrivateVulnerability = await handleShimRequest(ctx, repoUrl('/private-vulnerability-reporting'), 'DELETE'); + expect(disablePrivateVulnerability.status).toBe(204); + expect(parse(await handleShimRequest(ctx, repoUrl('/private-vulnerability-reporting'))).enabled).toBe(false); + }); + + it('should create and list repository artifact attestations', async () => { + const digest = `sha256:${'a'.repeat(64)}`; + const statement = { + _type : 'https://in-toto.io/Statement/v1', + subject : [{ name: 'dist/test-repo.tgz', digest: { sha256: 'a'.repeat(64) } }], + predicateType : 'https://slsa.dev/provenance/v1', + predicate : { buildType: 'gitd' }, + }; + const bundle = { + mediaType : 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial : {}, + dsseEnvelope : { + payload : Buffer.from(JSON.stringify(statement), 'utf8').toString('base64'), + payloadType : 'application/vnd.in-toto+json', + signatures : [], + }, + }; + + const initiallyEmpty = parse(await handleShimRequest(ctx, repoUrl(`/attestations/${digest}`))); + expect(initiallyEmpty).toEqual({ attestations: [] }); + + const invalid = await handleShimRequest(ctx, repoUrl('/attestations'), 'POST', {}); + expect(invalid.status).toBe(422); + + const create = await handleShimRequest(ctx, repoUrl('/attestations'), 'POST', { bundle }); + expect(create.status).toBe(201); + const created = parse(create); + expect(created.subject_digest).toBe(digest); + expect(created.predicate_type).toBe('https://slsa.dev/provenance/v1'); + expect(created.bundle).toEqual(bundle); + + const list = parse(await handleShimRequest(ctx, repoUrl(`/attestations/${digest}`))); + expect(list.attestations).toHaveLength(1); + expect(list.attestations[0].id).toBe(created.id); + expect(list.attestations[0].bundle.dsseEnvelope.payload).toBe(bundle.dsseEnvelope.payload); + + const filtered = parse(await handleShimRequest(ctx, repoUrl(`/attestations/${digest}?predicate_type=https://slsa.dev/provenance/v1`))); + expect(filtered.attestations).toHaveLength(1); + + const filteredOut = parse(await handleShimRequest(ctx, repoUrl(`/attestations/${digest}?predicate_type=https://spdx.dev/Document`))); + expect(filteredOut.attestations).toEqual([]); + + const invalidDigest = await handleShimRequest(ctx, repoUrl('/attestations/sha1:bad')); + expect(invalidDigest.status).toBe(422); + }); + + it('should list and delete user-scoped artifact attestations', async () => { + const makeBundle = (hex: string, predicateType: string): Record => { + const statement = { + _type : 'https://in-toto.io/Statement/v1', + subject : [{ name: `dist/${hex}.tgz`, digest: { sha256: hex.repeat(64) } }], + predicateType, + predicate : { buildType: 'gitd' }, + }; + return { + mediaType : 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial : {}, + dsseEnvelope : { + payload : Buffer.from(JSON.stringify(statement), 'utf8').toString('base64'), + payloadType : 'application/vnd.in-toto+json', + signatures : [], + }, + }; + }; + const createAttestation = async ( + hex: string, predicateType: string, + ): Promise<{ digest: string; attestation: any }> => { + const digest = `sha256:${hex.repeat(64)}`; + const create = await handleShimRequest(ctx, repoUrl('/attestations'), 'POST', { + bundle: makeBundle(hex, predicateType), + }); + expect(create.status).toBe(201); + return { digest, attestation: parse(create) }; + }; + + const first = await createAttestation('b', 'https://slsa.dev/provenance/v1'); + const second = await createAttestation('c', 'https://spdx.dev/Document'); + + const list = await handleShimRequest( + ctx, + url(`/users/${testDid}/attestations/${first.digest}?predicate_type=https://slsa.dev/provenance/v1`), + ); + expect(list.status).toBe(200); + const listed = parse(list); + expect(listed.attestations).toHaveLength(1); + expect(listed.attestations[0].id).toBe(first.attestation.id); + expect(listed.attestations[0].repository_url).toBe(`${BASE}/repos/${testDid}/test-repo`); + + const bulkList = await handleShimRequest(ctx, url(`/users/${testDid}/attestations/bulk-list`), 'POST', { + subject_digests : [first.digest, second.digest], + predicate_type : 'https://spdx.dev/Document', + }); + expect(bulkList.status).toBe(200); + const bulkListed = parse(bulkList); + expect(bulkListed.attestations).toHaveLength(1); + expect(bulkListed.attestations[0].subject_digest).toBe(second.digest); + + const invalidList = await handleShimRequest(ctx, url(`/users/${testDid}/attestations/sha1:bad`)); + expect(invalidList.status).toBe(422); + + const invalidBulk = await handleShimRequest(ctx, url(`/users/${testDid}/attestations/bulk-list`), 'POST', { + subject_digests: ['sha1:bad'], + }); + expect(invalidBulk.status).toBe(422); + + const deleteById = await handleShimRequest( + ctx, url(`/users/${testDid}/attestations/${first.attestation.id}`), 'DELETE', + ); + expect(deleteById.status).toBe(200); + expect(parse(deleteById).attestations[0].subject_digest).toBe(first.digest); + const afterDeleteById = parse(await handleShimRequest(ctx, url(`/users/${testDid}/attestations/${first.digest}`))); + expect(afterDeleteById.attestations).toEqual([]); + + const deleteByDigest = await handleShimRequest( + ctx, url(`/users/${testDid}/attestations/digest/${second.digest}`), 'DELETE', + ); + expect(deleteByDigest.status).toBe(200); + expect(parse(deleteByDigest).attestations[0].subject_digest).toBe(second.digest); + + const third = await createAttestation('d', 'https://slsa.dev/provenance/v1'); + const bulkDelete = await handleShimRequest(ctx, url(`/users/${testDid}/attestations`), 'DELETE', { + subject_digests: [third.digest], + }); + expect(bulkDelete.status).toBe(200); + expect(parse(bulkDelete).attestations[0].subject_digest).toBe(third.digest); + + const missingDelete = await handleShimRequest(ctx, url(`/users/${testDid}/attestations/99999999`), 'DELETE'); + expect(missingDelete.status).toBe(404); + }); + + it('should create, list, update, version, and delete repository rulesets', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/rulesets')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const invalidTarget = await handleShimRequest(ctx, repoUrl('/rulesets'), 'POST', { + name : 'bad target', + target : 'repository', + enforcement : 'active', + }); + expect(invalidTarget.status).toBe(422); + + const invalidRules = await handleShimRequest(ctx, repoUrl('/rulesets'), 'POST', { + name : 'bad rules', + enforcement : 'active', + rules : [{ parameters: {} }], + }); + expect(invalidRules.status).toBe(422); + + const create = await handleShimRequest(ctx, repoUrl('/rulesets'), 'POST', { + name : 'main branch rules', + target : 'branch', + enforcement : 'active', + conditions : { + ref_name: { + include : ['refs/heads/main'], + exclude : ['refs/heads/dev*'], + }, + }, + rules: [{ + type : 'commit_author_email_pattern', + parameters : { + operator : 'contains', + pattern : '@example.test', + }, + }], + }); + expect(create.status).toBe(201); + const ruleset = parse(create); + expect(ruleset.name).toBe('main branch rules'); + expect(ruleset.target).toBe('branch'); + expect(ruleset.source_type).toBe('Repository'); + expect(ruleset.source).toBe(`${testDid}/test-repo`); + expect(ruleset.enforcement).toBe('active'); + expect(ruleset.rules).toHaveLength(1); + expect(ruleset._links.self.href).toContain(`/rulesets/${ruleset.id}`); + + const list = parse(await handleShimRequest(ctx, repoUrl('/rulesets?targets=branch'))); + expect(list.map((item: any) => item.id)).toContain(ruleset.id); + + const filteredOut = parse(await handleShimRequest(ctx, repoUrl('/rulesets?targets=tag'))); + expect(filteredOut.map((item: any) => item.id)).not.toContain(ruleset.id); + + const invalidFilter = await handleShimRequest(ctx, repoUrl('/rulesets?targets=repository')); + expect(invalidFilter.status).toBe(422); + + const get = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`)); + expect(get.status).toBe(200); + expect(parse(get).conditions.ref_name.include).toEqual(['refs/heads/main']); + + const branchRules = parse(await handleShimRequest(ctx, repoUrl('/rules/branches/main'))); + expect(branchRules).toHaveLength(1); + expect(branchRules[0].type).toBe('commit_author_email_pattern'); + expect(branchRules[0].ruleset_id).toBe(ruleset.id); + expect(branchRules[0].ruleset_source_type).toBe('Repository'); + + const excludedBranchRules = parse(await handleShimRequest(ctx, repoUrl('/rules/branches/dev-demo'))); + expect(excludedBranchRules).toEqual([]); + + const wildcardBranch = await handleShimRequest(ctx, repoUrl('/rules/branches/main*')); + expect(wildcardBranch.status).toBe(422); + + const update = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`), 'PUT', { + name : 'main branch rules v2', + enforcement : 'evaluate', + rules : [{ type: 'required_signatures' }], + }); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.name).toBe('main branch rules v2'); + expect(updated.enforcement).toBe('evaluate'); + expect(updated.rules).toEqual([{ type: 'required_signatures' }]); + + const evaluatedBranchRules = parse(await handleShimRequest(ctx, repoUrl('/rules/branches/main'))); + expect(evaluatedBranchRules).toEqual([]); + + const history = parse(await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}/history`))); + expect(history.map((entry: any) => entry.version_id)).toEqual([2, 1]); + expect(history[0].actor.type).toBe('User'); + + const version = parse(await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}/history/1`))); + expect(version.version_id).toBe(1); + expect(version.state.name).toBe('main branch rules'); + expect(version.state.enforcement).toBe('active'); + + const missingVersion = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}/history/99`)); + expect(missingVersion.status).toBe(404); + + const deleted = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + + const missing = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`)); + expect(missing.status).toBe(404); + }); + + it('should list, filter, and get repository rule suites', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const nowMs = Date.now(); + await mergeRepoSettings({ + ruleSuites: { + '42': { + id : 42, + actorId : 12, + actorName : MAINTAINER_DID, + beforeSha : MAIN_SHA, + afterSha : FEATURE_SHA, + ref : 'refs/heads/main', + repositoryId : 404, + repositoryName : 'test-repo', + pushedAt : new Date(nowMs).toISOString(), + result : 'pass', + evaluationResult : 'fail', + ruleEvaluations : [{ + ruleSource : { type: 'ruleset', id: 7, name: 'main branch rules' }, + enforcement : 'evaluate', + result : 'fail', + ruleType : 'required_status_checks', + details : 'Required check failed.', + }], + }, + '43': { + id : 43, + actorName : TRIAGER_DID, + beforeSha : FEATURE_SHA, + afterSha : TAG_SHA, + ref : 'refs/tags/v1.0', + pushedAt : new Date(nowMs - 1000).toISOString(), + result : 'bypass', + ruleEvaluations : [{ + ruleSource : { type: 'protected_branch' }, + enforcement : 'active', + result : 'pass', + ruleType : 'pull_request', + }], + }, + }, + }); + + const list = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?per_page=1')); + expect(list.status).toBe(200); + expect(list.headers.Link).toContain('rel="next"'); + const suites = parse(list); + expect(suites).toHaveLength(1); + expect(suites[0]).toMatchObject({ + id : 42, + actor_id : 12, + actor_name : MAINTAINER_DID, + before_sha : MAIN_SHA, + after_sha : FEATURE_SHA, + ref : 'refs/heads/main', + repository_id : 404, + repository_name : 'test-repo', + result : 'pass', + evaluation_result : 'fail', + }); + expect(suites[0].rule_evaluations).toBeUndefined(); + + const filterPath = `/rulesets/rule-suites?ref=main&actor_name=${encodeURIComponent(MAINTAINER_DID)}` + + '&rule_suite_result=pass&evaluate_status=evaluate'; + const filtered = parse(await handleShimRequest( + ctx, + repoUrl(filterPath), + )); + expect(filtered.map((suite: any) => suite.id)).toEqual([42]); + + const activeOnly = parse(await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?evaluate_status=active'))); + expect(activeOnly.map((suite: any) => suite.id)).toEqual([43]); + + const detail = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites/42')); + expect(detail.status).toBe(200); + const suite = parse(detail); + expect(suite.rule_evaluations).toHaveLength(1); + expect(suite.rule_evaluations[0].rule_source.name).toBe('main branch rules'); + expect(suite.rule_evaluations[0].rule_type).toBe('required_status_checks'); + expect(suite.pushed_at).toBe(new Date(nowMs).toISOString()); + + const invalidRef = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?ref=main*')); + expect(invalidRef.status).toBe(422); + const invalidResult = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?rule_suite_result=skipped')); + expect(invalidResult.status).toBe(422); + const invalidEvaluateStatus = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?evaluate_status=disabled')); + expect(invalidEvaluateStatus.status).toBe(422); + const invalidTimePeriod = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites?time_period=year')); + expect(invalidTimePeriod.status).toBe(422); + + const missingSuite = await handleShimRequest(ctx, repoUrl('/rulesets/rule-suites/999')); + expect(missingSuite.status).toBe(404); + const missingRepo = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/rulesets/rule-suites`)); + expect(missingRepo.status).toBe(404); + }); + + it('should list assignable users from repo collaborators', async () => { + const res = await handleShimRequest(ctx, repoUrl('/assignees')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(data.map((item: any) => item.login)).toContain(TRIAGER_DID); + expect(data.find((item: any) => item.login === MAINTAINER_DID).name).toBe('Alice'); + }); + + it('should check whether a user can be assigned', async () => { + const assignableRes = await handleShimRequest(ctx, repoUrl(`/assignees/${encodeURIComponent(MAINTAINER_DID)}`)); + expect(assignableRes.status).toBe(204); + expect(assignableRes.body).toBe(''); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/assignees/${encodeURIComponent(NEW_COLLABORATOR_DID)}`)); + expect(missingRes.status).toBe(404); + }); + + it('should list collaborators with GitHub permission metadata', async () => { + const res = await handleShimRequest(ctx, repoUrl('/collaborators')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(data.map((item: any) => item.login)).toContain(TRIAGER_DID); + + const maintainer = data.find((item: any) => item.login === MAINTAINER_DID); + expect(maintainer.role_name).toBe('maintainer'); + expect(maintainer.permissions.admin).toBe(true); + + const triager = data.find((item: any) => item.login === TRIAGER_DID); + expect(triager.role_name).toBe('triager'); + expect(triager.permissions.triage).toBe(true); + expect(triager.permissions.push).toBe(false); + }); + + it('should filter collaborators by repository permission', async () => { + const adminRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=admin')); + expect(adminRes.status).toBe(200); + const admins = parse(adminRes); + expect(admins.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(admins.map((item: any) => item.login)).not.toContain(TRIAGER_DID); + expect(admins.every((item: any) => item.permissions.admin === true)).toBe(true); + + const pushRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=push')); + expect(pushRes.status).toBe(200); + const pushCollaborators = parse(pushRes); + expect(pushCollaborators.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(pushCollaborators.map((item: any) => item.login)).not.toContain(TRIAGER_DID); + expect(pushCollaborators.every((item: any) => item.permissions.push === true)).toBe(true); + + const triageRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=triage')); + expect(triageRes.status).toBe(200); + const triageCollaborators = parse(triageRes); + expect(triageCollaborators.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(triageCollaborators.map((item: any) => item.login)).toContain(TRIAGER_DID); + expect(triageCollaborators.every((item: any) => item.permissions.triage === true)).toBe(true); + + const pullRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=pull')); + expect(pullRes.status).toBe(200); + const pullCollaborators = parse(pullRes); + expect(pullCollaborators.length).toBeGreaterThanOrEqual(triageCollaborators.length); + expect(pullCollaborators.every((item: any) => item.permissions.pull === true)).toBe(true); + + const invalidRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=owner')); + expect(invalidRes.status).toBe(422); + expect(parse(invalidRes).message).toContain('permission'); + }); + + it('should filter collaborators by affiliation', async () => { + const outsideRes = await handleShimRequest(ctx, repoUrl('/collaborators?affiliation=outside')); + expect(outsideRes.status).toBe(200); + const outside = parse(outsideRes); + expect(outside.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(outside.map((item: any) => item.login)).toContain(TRIAGER_DID); + expect(outside.map((item: any) => item.login)).not.toContain(testDid); + + const directRes = await handleShimRequest(ctx, repoUrl('/collaborators?affiliation=direct')); + expect(directRes.status).toBe(200); + const direct = parse(directRes); + expect(direct.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(direct.map((item: any) => item.login)).toContain(TRIAGER_DID); + + const allRes = await handleShimRequest(ctx, repoUrl('/collaborators?affiliation=all')); + expect(allRes.status).toBe(200); + expect(parse(allRes).map((item: any) => item.login).sort()).toEqual(direct.map((item: any) => item.login).sort()); + + const combinedRes = await handleShimRequest(ctx, repoUrl('/collaborators?affiliation=outside&permission=admin')); + expect(combinedRes.status).toBe(200); + const combined = parse(combinedRes); + expect(combined.map((item: any) => item.login)).toContain(MAINTAINER_DID); + expect(combined.map((item: any) => item.login)).not.toContain(TRIAGER_DID); + + const invalidRes = await handleShimRequest(ctx, repoUrl('/collaborators?affiliation=members')); + expect(invalidRes.status).toBe(422); + expect(parse(invalidRes).message).toContain('affiliation'); + }); + + it('should return 204 when checking an existing collaborator', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodeURIComponent(MAINTAINER_DID)}`)); + expect(res.status).toBe(204); + expect(res.body).toBe(''); + }); + + it('should return collaborator permissions', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodeURIComponent(MAINTAINER_DID)}/permission`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.permission).toBe('admin'); + expect(data.role_name).toBe('maintainer'); + expect(data.user.login).toBe(MAINTAINER_DID); + }); + + it('should add and remove collaborators', async () => { + const encodedDid = encodeURIComponent(NEW_COLLABORATOR_DID); + const addRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`), 'PUT', { + permission : 'triage', + alias : 'New Triage', + }); + expect(addRes.status).toBe(201); + const invite = parse(addRes); + expect(invite.invitee.login).toBe(NEW_COLLABORATOR_DID); + expect(invite.role_name).toBe('triager'); + + const permissionRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}/permission`)); + expect(permissionRes.status).toBe(200); + expect(parse(permissionRes).permission).toBe('triage'); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`), 'PUT', { + permission : 'push', + alias : 'New Write', + }); + expect(updateRes.status).toBe(204); + expect(updateRes.body).toBe(''); + + const updatedPermissionRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}/permission`)); + expect(updatedPermissionRes.status).toBe(200); + const updatedPermission = parse(updatedPermissionRes); + expect(updatedPermission.permission).toBe('write'); + expect(updatedPermission.role_name).toBe('contributor'); + + const removeRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`), 'DELETE'); + expect(removeRes.status).toBe(204); + + const checkRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`)); + expect(checkRes.status).toBe(404); + }); + + it('should add and remove pull-only collaborators', async () => { + const viewerDid = 'did:jwk:viewer123'; + const encodedDid = encodeURIComponent(viewerDid); + const addRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`), 'PUT', { + permission : 'pull', + alias : 'Read Only', + }); + expect(addRes.status).toBe(201); + const invite = parse(addRes); + expect(invite.invitee.login).toBe(viewerDid); + expect(invite.role_name).toBe('viewer'); + expect(invite.permissions).toBe('pull'); + + const permissionRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}/permission`)); + expect(permissionRes.status).toBe(200); + const permission = parse(permissionRes); + expect(permission.permission).toBe('pull'); + expect(permission.role_name).toBe('viewer'); + + const pullRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=pull')); + expect(parse(pullRes).map((item: any) => item.login)).toContain(viewerDid); + + const triageRes = await handleShimRequest(ctx, repoUrl('/collaborators?permission=triage')); + expect(parse(triageRes).map((item: any) => item.login)).not.toContain(viewerDid); + + const removeRes = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodedDid}`), 'DELETE'); + expect(removeRes.status).toBe(204); + }); + + it('should reject unsupported collaborator permissions', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/collaborators/${encodeURIComponent(NEW_COLLABORATOR_DID)}`), 'PUT', { + permission: 'owner', + }); + expect(res.status).toBe(422); + }); + + it('should create, list, get, ping, test, and delete repository webhooks', async () => { + const initial = await handleShimRequest(ctx, repoUrl('/hooks')); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const create = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + name : 'web', + active : true, + events : ['push', 'pull_request'], + config : { + url : 'https://example.com/webhook', + content_type : 'json', + insecure_ssl : '0', + secret : 'top-secret', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + expect(hook.name).toBe('web'); + expect(hook.active).toBe(true); + expect(hook.events).toEqual(['push', 'pull_request']); + expect(hook.config.url).toBe('https://example.com/webhook'); + expect(hook.config.secret).toBeUndefined(); + + const list = await handleShimRequest(ctx, repoUrl('/hooks')); + expect(parse(list).map((item: any) => item.id)).toContain(hook.id); + + const fetched = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`)); + expect(fetched.status).toBe(200); + expect(parse(fetched).id).toBe(hook.id); + + const config = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/config`)); + expect(config.status).toBe(200); + expect(parse(config).secret).toBe('********'); + + const ping = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/pings`), 'POST'); + expect(ping.status).toBe(204); + + const test = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/tests`), 'POST'); + expect(test.status).toBe(204); + + const deleted = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + + const missing = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`)); + expect(missing.status).toBe(404); + }); + + it('should list, get, filter, and redeliver repository webhook deliveries', async () => { + const create = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + active : true, + events : ['push'], + config : { + url : 'https://example.com/deliveries', + secret : 'delivery-secret', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + + const initial = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries`)); + expect(initial.status).toBe(200); + expect(parse(initial)).toEqual([]); + + const ping = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/pings`), 'POST'); + expect(ping.status).toBe(204); + const test = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/tests`), 'POST'); + expect(test.status).toBe(204); + + const onePage = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries?per_page=1`)); + expect(onePage.status).toBe(200); + expect(parse(onePage)).toHaveLength(1); + expect(onePage.headers.Link).toContain('cursor='); + + const list = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries`)); + expect(list.status).toBe(200); + const deliveries = parse(list); + expect(deliveries).toHaveLength(2); + expect(deliveries.map((delivery: any) => delivery.event).sort()).toEqual(['ping', 'push']); + expect(deliveries.every((delivery: any) => delivery.status_code === 200)).toBe(true); + + const success = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries?status=success`)); + expect(success.status).toBe(200); + expect(parse(success)).toHaveLength(2); + + const failure = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries?status=failure`)); + expect(failure.status).toBe(200); + expect(parse(failure)).toHaveLength(0); + + const invalidStatus = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries?status=unknown`)); + expect(invalidStatus.status).toBe(422); + + const pushDelivery = deliveries.find((delivery: any) => delivery.event === 'push'); + const detail = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries/${pushDelivery.id}`)); + expect(detail.status).toBe(200); + const detailed = parse(detail); + expect(detailed.url).toBe('https://example.com/deliveries'); + expect(detailed.request.headers['X-GitHub-Event']).toBe('push'); + expect(detailed.request.payload.repository.full_name).toBe(`${testDid}/test-repo`); + expect(detailed.response.payload).toBe('ok'); + + const redeliver = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries/${pushDelivery.id}/attempts`), 'POST'); + expect(redeliver.status).toBe(202); + + const afterRedelivery = parse(await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries`))); + const redelivered = afterRedelivery.find((delivery: any) => delivery.redelivery === true); + expect(afterRedelivery).toHaveLength(3); + expect(redelivered.guid).toBe(pushDelivery.guid); + + const missing = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/deliveries/999999`)); + expect(missing.status).toBe(404); + + const deleted = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + }); + + it('should update repository webhooks and webhook configuration', async () => { + const create = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + active : true, + events : ['push'], + config : { + url : 'https://example.com/original', + secret : 'alpha', + }, + }); + expect(create.status).toBe(201); + const hook = parse(create); + + const update = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`), 'PATCH', { + active : false, + add_events : ['issues'], + remove_events : ['push'], + config : { + url : 'https://example.com/updated', + content_type : 'json', + secret : 'beta', + }, + }); + expect(update.status).toBe(200); + const updated = parse(update); + expect(updated.active).toBe(false); + expect(updated.events).toEqual(['issues']); + expect(updated.config.url).toBe('https://example.com/updated'); + + const config = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}/config`), 'PATCH', { + url : 'https://example.com/config', + secret : 'gamma', + }); + expect(config.status).toBe(200); + expect(parse(config).url).toBe('https://example.com/config'); + expect(parse(config).secret).toBe('********'); + + const fetched = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`)); + expect(parse(fetched).config.url).toBe('https://example.com/config'); + + const deleted = await handleShimRequest(ctx, repoUrl(`/hooks/${hook.id}`), 'DELETE'); + expect(deleted.status).toBe(204); + }); + + it('should reject invalid repository webhook payloads', async () => { + const missingConfig = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + events: ['push'], + }); + expect(missingConfig.status).toBe(422); + + const invalidName = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + name : 'email', + config : { url: 'https://example.com/webhook' }, + }); + expect(invalidName.status).toBe(422); + + const invalidUrl = await handleShimRequest(ctx, repoUrl('/hooks'), 'POST', { + config: { url: 'not-a-url' }, + }); + expect(invalidUrl.status).toBe(422); + }); + + it('should return 404 for missing repository webhooks', async () => { + const get = await handleShimRequest(ctx, repoUrl('/hooks/999999')); + expect(get.status).toBe(404); + + const config = await handleShimRequest(ctx, repoUrl('/hooks/999999/config')); + expect(config.status).toBe(404); + + const ping = await handleShimRequest(ctx, repoUrl('/hooks/999999/pings'), 'POST'); + expect(ping.status).toBe(404); + }); + + it('should list, get, mark read, and delete notification threads', async () => { + const { record } = await ctx.notifications.records.create('notification', { + data: { + title : 'Fix the widget was mentioned', + body : 'You were mentioned on the widget issue.', + url : `${BASE}/repos/${testDid}/test-repo/issues/${issueRecId}`, + }, + tags: { + type : 'mention', + read : false, + repoDid : testDid, + repoRecordId : repoRecordId, + sourceRecordId : issueRecId, + subjectType : 'Issue', + }, + }); + const threadId = String(numericId(record!.id)); + + const list = await handleShimRequest(ctx, url('/notifications')); + expect(list.status).toBe(200); + const listData = parse(list); + const listed = listData.find((item: any) => item.id === threadId); + expect(listed.unread).toBe(true); + expect(listed.reason).toBe('mention'); + expect(listed.repository.name).toBe('test-repo'); + expect(listed.subject.type).toBe('Issue'); + + const repoList = await handleShimRequest(ctx, repoUrl('/notifications')); + expect(repoList.status).toBe(200); + expect(parse(repoList).map((item: any) => item.id)).toContain(threadId); + + const thread = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`)); + expect(thread.status).toBe(200); + expect(parse(thread).subject.title).toBe('Fix the widget was mentioned'); + + const markRead = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`), 'PATCH'); + expect(markRead.status).toBe(205); + + const unreadList = await handleShimRequest(ctx, url('/notifications')); + expect(parse(unreadList).map((item: any) => item.id)).not.toContain(threadId); + + const allList = await handleShimRequest(ctx, url('/notifications?all=true')); + expect(parse(allList).find((item: any) => item.id === threadId).unread).toBe(false); + + const deleted = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`), 'DELETE'); + expect(deleted.status).toBe(204); + + const missing = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`)); + expect(missing.status).toBe(404); + }); + + it('should mark repository notifications as read without touching unrelated threads', async () => { + const { record: repoNotification } = await ctx.notifications.records.create('notification', { + data: { + title : 'Repository issue comment', + body : 'A new issue comment arrived.', + url : `${BASE}/repos/${testDid}/test-repo/issues/${issueRecId}`, + }, + tags: { + type : 'issue_comment', + read : false, + repoDid : testDid, + repoRecordId : repoRecordId, + sourceRecordId : issueRecId, + subjectType : 'Issue', + }, + }); + const { record: unrelatedNotification } = await ctx.notifications.records.create('notification', { + data : { title: 'Standalone mention', body: 'No repository attached.' }, + tags : { type: 'mention', read: false }, + }); + const repoThreadId = String(numericId(repoNotification!.id)); + const unrelatedThreadId = String(numericId(unrelatedNotification!.id)); + + const markRepoRead = await handleShimRequest(ctx, repoUrl('/notifications'), 'PUT'); + expect(markRepoRead.status).toBe(205); + + const repoThread = parse(await handleShimRequest(ctx, url(`/notifications/threads/${repoThreadId}`))); + expect(repoThread.unread).toBe(false); + + const unrelatedThread = parse(await handleShimRequest(ctx, url(`/notifications/threads/${unrelatedThreadId}`))); + expect(unrelatedThread.unread).toBe(true); + + expect((await handleShimRequest(ctx, url(`/notifications/threads/${repoThreadId}`), 'DELETE')).status).toBe(204); + expect((await handleShimRequest(ctx, url(`/notifications/threads/${unrelatedThreadId}`), 'DELETE')).status).toBe(204); + }); + + it('should get, set, and delete notification thread subscriptions', async () => { + const { record } = await ctx.notifications.records.create('notification', { + data: { + title : 'Review requested', + body : 'Please review a pull request.', + url : `${BASE}/repos/${testDid}/test-repo/pulls/${patchRecId}`, + }, + tags: { + type : 'review_request', + read : false, + repoDid : testDid, + repoRecordId : repoRecordId, + sourceRecordId : patchRecId, + subjectType : 'PullRequest', + }, + }); + const threadId = String(numericId(record!.id)); + + const initial = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}/subscription`)); + expect(initial.status).toBe(200); + expect(parse(initial).subscribed).toBe(true); + expect(parse(initial).ignored).toBe(false); + + const ignored = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}/subscription`), 'PUT', { + ignored: true, + }); + expect(ignored.status).toBe(200); + expect(parse(ignored).subscribed).toBe(false); + expect(parse(ignored).ignored).toBe(true); + + const deleted = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}/subscription`), 'DELETE'); + expect(deleted.status).toBe(204); + + const afterDelete = await handleShimRequest(ctx, url(`/notifications/threads/${threadId}/subscription`)); + expect(parse(afterDelete).subscribed).toBe(false); + expect(parse(afterDelete).ignored).toBe(false); + + expect((await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`), 'DELETE')).status).toBe(204); + }); + + it('should validate notification filters and missing threads', async () => { + const invalidSince = await handleShimRequest(ctx, url('/notifications?since=not-a-date')); + expect(invalidSince.status).toBe(422); + + const missingThread = await handleShimRequest(ctx, url('/notifications/threads/999999')); + expect(missingThread.status).toBe(404); + + const missingSubscription = await handleShimRequest(ctx, url('/notifications/threads/999999/subscription')); + expect(missingSubscription.status).toBe(404); + + const { record } = await ctx.notifications.records.create('notification', { + data : { title: 'Invalid subscription payload' }, + tags : { type: 'mention', read: false }, + }); + const threadId = String(numericId(record!.id)); + + const invalidSubscription = await handleShimRequest( + ctx, + url(`/notifications/threads/${threadId}/subscription`), + 'PUT', + { ignored: 'yes' }, + ); + expect(invalidSubscription.status).toBe(422); + + expect((await handleShimRequest(ctx, url(`/notifications/threads/${threadId}`), 'DELETE')).status).toBe(204); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/readme, /license, /contents + // ========================================================================= + + describe('repository contents endpoints', () => { + it('should return README content as a GitHub content object', async () => { + const res = await handleShimRequest(ctx, repoUrl('/readme')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.name).toBe('README.md'); + expect(data.path).toBe('README.md'); + expect(data.type).toBe('file'); + expect(data.encoding).toBe('base64'); + + const decoded = Buffer.from(data.content, 'base64').toString('utf-8'); + expect(decoded).toContain('Readme from DWN metadata'); + + const raw = await handleShimRequest(ctx, repoUrl('/readme'), 'GET', {}, null, { + accept: 'application/vnd.github.raw+json', + }); + expect(raw.status).toBe(200); + expect(raw.headers['Content-Type']).toBe('text/markdown; charset=utf-8'); + expect(bodyBuffer(raw).toString('utf-8')).toContain('Readme from DWN metadata'); + + const html = await handleShimRequest(ctx, repoUrl('/readme'), 'GET', {}, null, { + accept: 'application/vnd.github.html', + }); + expect(html.status).toBe(200); + expect(html.headers['Content-Type']).toBe('text/html; charset=utf-8'); + expect(bodyBuffer(html).toString('utf-8')).toContain('

    Test Repo

    '); + }); + + it('should return license content with a license metadata field', async () => { + const res = await handleShimRequest(ctx, repoUrl('/license')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.name).toBe('LICENSE'); + expect(data.license).toBeNull(); + expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('Apache-2.0'); + + const raw = await handleShimRequest(ctx, repoUrl('/license'), 'GET', {}, null, { + accept: 'application/vnd.github.raw+json', + }); + expect(raw.status).toBe(200); + expect(raw.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(bodyBuffer(raw).toString('utf-8')).toContain('Apache-2.0'); + + const html = await handleShimRequest(ctx, repoUrl('/license'), 'GET', {}, null, { + accept: 'application/vnd.github.html+json', + }); + expect(html.status).toBe(200); + expect(html.headers['Content-Type']).toBe('text/html; charset=utf-8'); + expect(bodyBuffer(html).toString('utf-8')).toContain('
    Apache-2.0');
    +    });
    +
    +    it('should list DWN-backed metadata files at contents root', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/contents'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(Array.isArray(data)).toBe(true);
    +      expect(data.map((item: any) => item.name)).toContain('README.md');
    +      expect(data.map((item: any) => item.name)).toContain('LICENSE');
    +
    +      const objectRes = await handleShimRequest(ctx, repoUrl('/contents'), 'GET', {}, null, {
    +        accept: 'application/vnd.github.object+json',
    +      });
    +      expect(objectRes.status).toBe(200);
    +      const objectData = parse(objectRes);
    +      expect(Array.isArray(objectData.entries)).toBe(true);
    +      expect(objectData.entries.map((item: any) => item.name)).toContain('README.md');
    +    });
    +
    +    it('should return README through contents path lookup', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/contents/README.md'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.path).toBe('README.md');
    +      expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('Test Repo');
    +
    +      const raw = await handleShimRequest(ctx, new URL(data.download_url));
    +      expect(raw.status).toBe(200);
    +      expect(raw.headers['Content-Type']).toBe('text/markdown; charset=utf-8');
    +      expect(bodyBuffer(raw).toString('utf-8')).toContain('Test Repo');
    +    });
    +
    +    it('should return local git README when repo storage is provided', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/readme'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.path).toBe('README.md');
    +      expect(data.sha).toBe(gitReadmeSha);
    +      expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('Readme from local git objects');
    +
    +      const rawLicense = await handleShimRequest(ctx, repoUrl('/license?ref=main'), 'GET', {}, null, {
    +        ...shimOptions(),
    +        accept: 'application/vnd.github.v3.raw+json',
    +      });
    +      expect(rawLicense.status).toBe(200);
    +      expect(rawLicense.headers['Content-Type']).toBe('text/plain; charset=utf-8');
    +      expect(bodyBuffer(rawLicense).toString('utf-8')).toBe('MIT\n');
    +    });
    +
    +    it('should return local git README for a directory', async () => {
    +      const res = await handleShimRequest(ctx, contentWriteRepoUrl('/readme/docs?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.name).toBe('README.md');
    +      expect(data.path).toBe('docs/README.md');
    +      expect(data.type).toBe('file');
    +      expect(data.url).toContain('/contents/docs/README.md');
    +      expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('Directory-specific documentation');
    +
    +      const missing = await handleShimRequest(ctx, contentWriteRepoUrl('/readme/missing?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(missing.status).toBe(404);
    +
    +      const metadataOnly = await handleShimRequest(ctx, repoUrl('/readme/docs'));
    +      expect(metadataOnly.status).toBe(404);
    +    });
    +
    +    it('should list local git contents root when repo storage is provided', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/contents?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.map((item: any) => item.path)).toEqual(['LICENSE', 'README.md', 'src']);
    +      expect(data.find((item: any) => item.path === 'src').type).toBe('dir');
    +    });
    +
    +    it('should return local git file contents by path when repo storage is provided', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/contents/src/index.ts?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.path).toBe('src/index.ts');
    +      expect(data.type).toBe('file');
    +      expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('answer = 42');
    +
    +      const raw = await handleShimRequest(ctx, new URL(data.download_url), 'GET', {}, null, shimOptions());
    +      expect(raw.status).toBe(200);
    +      expect(raw.headers['Content-Type']).toBe('application/typescript; charset=utf-8');
    +      expect(bodyBuffer(raw).toString('utf-8')).toContain('answer = 42');
    +
    +      const acceptRaw = await handleShimRequest(
    +        ctx,
    +        repoUrl('/contents/src/index.ts?ref=main'),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.v3.raw' },
    +      );
    +      expect(acceptRaw.status).toBe(200);
    +      expect(acceptRaw.headers['Content-Type']).toBe('application/typescript; charset=utf-8');
    +      expect(bodyBuffer(acceptRaw).toString('utf-8')).toContain('answer = 42');
    +    });
    +
    +    it('should create and update local git file contents', async () => {
    +      const createRes = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/guide.md'), 'PUT', {
    +        branch  : 'main',
    +        message : 'Create guide',
    +        content : Buffer.from('hello from contents api\n', 'utf-8').toString('base64'),
    +      }, null, shimOptions());
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +      expect(created.content.path).toBe('docs/guide.md');
    +      expect(Buffer.from(created.content.content, 'base64').toString('utf-8')).toBe('hello from contents api\n');
    +
    +      const updateRes = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/guide.md'), 'PUT', {
    +        branch  : 'main',
    +        message : 'Update guide',
    +        sha     : created.content.sha,
    +        content : Buffer.from('updated through contents api\n', 'utf-8').toString('base64'),
    +      }, null, shimOptions());
    +      expect(updateRes.status).toBe(200);
    +      const updated = parse(updateRes);
    +      expect(updated.content.path).toBe('docs/guide.md');
    +      expect(updated.content.sha).not.toBe(created.content.sha);
    +
    +      const fetched = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/guide.md?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(fetched.status).toBe(200);
    +      expect(Buffer.from(parse(fetched).content, 'base64').toString('utf-8')).toBe('updated through contents api\n');
    +
    +      const branch = await handleShimRequest(ctx, contentWriteRepoUrl('/branches/main'));
    +      expect(branch.status).toBe(200);
    +      expect(parse(branch).commit.sha).toBe(updated.commit.sha);
    +    });
    +
    +    it('should delete local git file contents', async () => {
    +      const createRes = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/remove.md'), 'PUT', {
    +        branch  : 'main',
    +        message : 'Create removable file',
    +        content : Buffer.from('remove me\n', 'utf-8').toString('base64'),
    +      }, null, shimOptions());
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +
    +      const deleteRes = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/remove.md'), 'DELETE', {
    +        branch  : 'main',
    +        message : 'Delete removable file',
    +        sha     : created.content.sha,
    +      }, null, shimOptions());
    +      expect(deleteRes.status).toBe(200);
    +      const deleted = parse(deleteRes);
    +      expect(deleted.content).toBeNull();
    +      expect(deleted.commit.sha).not.toBe(created.commit.sha);
    +
    +      const fetched = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/docs/remove.md?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(fetched.status).toBe(404);
    +    });
    +
    +    it('should return 404 for unsupported contents paths', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/contents/src/index.ts'));
    +      expect(res.status).toBe(404);
    +      const data = parse(res);
    +      expect(data.message).toContain('not found');
    +
    +      const missingRaw = await handleShimRequest(ctx, repoUrl('/raw/main/missing.txt'), 'GET', {}, null, shimOptions());
    +      expect(missingRaw.status).toBe(404);
    +    });
    +
    +    it('should redirect archive requests to generated download URLs', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/tarball/main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(302);
    +      expect(res.headers.Location).toContain('/tarball/main');
    +      expect(res.headers.Location).toContain('download=1');
    +    });
    +
    +    it('should download tar archives from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/tarball?download=1'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      expect(res.headers['Content-Type']).toBe('application/gzip');
    +      expect(res.headers['Content-Disposition']).toContain('.tar.gz');
    +
    +      const bytes = bodyBuffer(res);
    +      expect(bytes[0]).toBe(0x1f);
    +      expect(bytes[1]).toBe(0x8b);
    +    });
    +
    +    it('should download zip archives from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/zipball/main?download=1'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      expect(res.headers['Content-Type']).toBe('application/zip');
    +      expect(res.headers['Content-Disposition']).toContain('.zip');
    +
    +      const bytes = bodyBuffer(res);
    +      expect(bytes.subarray(0, 4).toString('utf-8')).toBe('PK\u0003\u0004');
    +    });
    +  });
    +
    +  // =========================================================================
    +  // GET /repos/:did/:repo/branches, /tags, /git/ref
    +  // =========================================================================
    +
    +  describe('repository ref endpoints', () => {
    +    it('should list mirrored branches', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/branches'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.map((branch: any) => branch.name)).toContain('main');
    +      expect(data.map((branch: any) => branch.name)).toContain('feature/demo');
    +
    +      const main = data.find((branch: any) => branch.name === 'main');
    +      expect(main.commit.sha).toBe(MAIN_SHA);
    +      expect(main.protected).toBe(false);
    +    });
    +
    +    it('should filter mirrored branches by protected status', async () => {
    +      const initialProtected = await handleShimRequest(ctx, repoUrl('/branches?protected=true'));
    +      expect(initialProtected.status).toBe(200);
    +      expect(parse(initialProtected)).toEqual([]);
    +
    +      const protectMain = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +      });
    +      expect(protectMain.status).toBe(200);
    +
    +      const protectFeature = await handleShimRequest(ctx, repoUrl('/branches/feature/demo/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +      });
    +      expect(protectFeature.status).toBe(200);
    +
    +      const protectedBranches = await handleShimRequest(ctx, repoUrl('/branches?protected=true'));
    +      expect(protectedBranches.status).toBe(200);
    +      expect(parse(protectedBranches).map((branch: any) => branch.name)).toEqual(['feature/demo', 'main']);
    +
    +      const firstProtectedPage = await handleShimRequest(ctx, repoUrl('/branches?protected=true&per_page=1'));
    +      expect(firstProtectedPage.status).toBe(200);
    +      expect(parse(firstProtectedPage).map((branch: any) => branch.name)).toEqual(['feature/demo']);
    +      expect(firstProtectedPage.headers.Link).toContain('/branches?protected=true&page=2&per_page=1');
    +
    +      const unprotectedBranches = await handleShimRequest(ctx, repoUrl('/branches?protected=false'));
    +      expect(unprotectedBranches.status).toBe(200);
    +      expect(parse(unprotectedBranches)).toEqual([]);
    +
    +      const invalid = await handleShimRequest(ctx, repoUrl('/branches?protected=maybe'));
    +      expect(invalid.status).toBe(422);
    +
    +      const deleteMain = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(deleteMain.status).toBe(204);
    +      const deleteFeature = await handleShimRequest(ctx, repoUrl('/branches/feature/demo/protection'), 'DELETE');
    +      expect(deleteFeature.status).toBe(204);
    +    });
    +
    +    it('should treat active branch rulesets as protected branches', async () => {
    +      const createRuleset = await handleShimRequest(ctx, repoUrl('/rulesets'), 'POST', {
    +        name        : 'default branch required signatures',
    +        target      : 'branch',
    +        enforcement : 'active',
    +        conditions  : {
    +          ref_name: {
    +            include : ['~DEFAULT_BRANCH'],
    +            exclude : [],
    +          },
    +        },
    +        rules: [{ type: 'required_signatures' }],
    +      });
    +      expect(createRuleset.status).toBe(201);
    +      const ruleset = parse(createRuleset);
    +
    +      const mainBranch = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(mainBranch.status).toBe(200);
    +      const main = parse(mainBranch);
    +      expect(main.protected).toBe(true);
    +      expect(main.protection.enabled).toBe(true);
    +      expect(main.protection.required_status_checks.contexts).toEqual([]);
    +
    +      const protectedBranches = await handleShimRequest(ctx, repoUrl('/branches?protected=true'));
    +      expect(protectedBranches.status).toBe(200);
    +      expect(parse(protectedBranches).map((branch: any) => branch.name)).toEqual(['main']);
    +
    +      const unprotectedBranches = await handleShimRequest(ctx, repoUrl('/branches?protected=false'));
    +      expect(unprotectedBranches.status).toBe(200);
    +      expect(parse(unprotectedBranches).map((branch: any) => branch.name)).toEqual(['feature/demo']);
    +
    +      const evaluateRuleset = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`), 'PUT', {
    +        enforcement: 'evaluate',
    +      });
    +      expect(evaluateRuleset.status).toBe(200);
    +
    +      const evaluatedProtectedBranches = await handleShimRequest(ctx, repoUrl('/branches?protected=true'));
    +      expect(evaluatedProtectedBranches.status).toBe(200);
    +      expect(parse(evaluatedProtectedBranches)).toEqual([]);
    +
    +      const deleteRuleset = await handleShimRequest(ctx, repoUrl(`/rulesets/${ruleset.id}`), 'DELETE');
    +      expect(deleteRuleset.status).toBe(204);
    +    });
    +
    +    it('should return branch detail', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.name).toBe('main');
    +      expect(data.commit.sha).toBe(MAIN_SHA);
    +    });
    +
    +    it('should return branch detail for slash-containing branch names', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/branches/feature%2Fdemo'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.name).toBe('feature/demo');
    +      expect(data.commit.sha).toBe(FEATURE_SHA);
    +    });
    +
    +    it('should create, read, reflect, and delete branch protection', async () => {
    +      const initial = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(initial.status).toBe(404);
    +
    +      const update = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          strict   : true,
    +          contexts : ['lint'],
    +          checks   : [
    +            { context: 'test', app_id: 12345 },
    +            { context: 'deploy', app_id: -1 },
    +          ],
    +        },
    +        required_pull_request_reviews: {
    +          bypass_pull_request_allowances: {
    +            users : ['release-manager'],
    +            teams : ['ops'],
    +            apps  : ['merge-bot'],
    +          },
    +          dismissal_restrictions: {
    +            users : ['alice'],
    +            teams : ['core'],
    +            apps  : ['review-bot'],
    +          },
    +          dismiss_stale_reviews           : true,
    +          require_code_owner_reviews      : true,
    +          required_approving_review_count : 2,
    +          require_last_push_approval      : true,
    +        },
    +        enforce_admins : null,
    +        restrictions   : null,
    +      });
    +      expect(update.status).toBe(200);
    +      const updated = parse(update);
    +      expect(updated.required_status_checks.contexts).toEqual(['lint', 'test', 'deploy']);
    +      expect(updated.required_status_checks.checks).toEqual([
    +        { app_id: null, context: 'lint' },
    +        { app_id: 12345, context: 'test' },
    +        { app_id: -1, context: 'deploy' },
    +      ]);
    +      expect(updated.required_status_checks.strict).toBe(true);
    +      expect(updated.enforce_admins.enabled).toBe(false);
    +      expect(updated.required_pull_request_reviews.required_approving_review_count).toBe(2);
    +      expect(updated.required_pull_request_reviews.bypass_pull_request_allowances.users.map((user: any) => user.login)).toEqual(['release-manager']);
    +      expect(updated.required_pull_request_reviews.bypass_pull_request_allowances.teams.map((team: any) => team.slug)).toEqual(['ops']);
    +      expect(updated.required_pull_request_reviews.bypass_pull_request_allowances.apps.map((app: any) => app.slug)).toEqual(['merge-bot']);
    +      expect(updated.required_pull_request_reviews.dismissal_restrictions.users.map((user: any) => user.login)).toEqual(['alice']);
    +      expect(updated.required_pull_request_reviews.dismissal_restrictions.teams.map((team: any) => team.slug)).toEqual(['core']);
    +      expect(updated.required_pull_request_reviews.dismissal_restrictions.apps.map((app: any) => app.slug)).toEqual(['review-bot']);
    +      expect(updated.required_pull_request_reviews.dismiss_stale_reviews).toBe(true);
    +      expect(updated.required_pull_request_reviews.require_code_owner_reviews).toBe(true);
    +      expect(updated.required_pull_request_reviews.require_last_push_approval).toBe(true);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(fetched.status).toBe(200);
    +      const fetchedData = parse(fetched);
    +      expect(fetchedData.required_status_checks.contexts).toEqual(['lint', 'test', 'deploy']);
    +      expect(fetchedData.required_status_checks.checks).toEqual([
    +        { app_id: null, context: 'lint' },
    +        { app_id: 12345, context: 'test' },
    +        { app_id: -1, context: 'deploy' },
    +      ]);
    +      expect(fetchedData.required_status_checks.strict).toBe(true);
    +      expect(fetchedData.required_pull_request_reviews.bypass_pull_request_allowances.users.map((user: any) => user.login)).toEqual(['release-manager']);
    +      expect(fetchedData.required_pull_request_reviews.dismissal_restrictions.users.map((user: any) => user.login)).toEqual(['alice']);
    +      expect(fetchedData.required_pull_request_reviews.dismiss_stale_reviews).toBe(true);
    +      expect(fetchedData.required_pull_request_reviews.require_code_owner_reviews).toBe(true);
    +      expect(fetchedData.required_pull_request_reviews.require_last_push_approval).toBe(true);
    +
    +      const branch = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(branch.status).toBe(200);
    +      const branchData = parse(branch);
    +      expect(branchData.protected).toBe(true);
    +      expect(branchData.protection.enabled).toBe(true);
    +      expect(branchData.protection.required_status_checks.contexts).toEqual(['lint', 'test', 'deploy']);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(missing.status).toBe(404);
    +
    +      const unprotectedBranch = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(parse(unprotectedBranch).protected).toBe(false);
    +    });
    +
    +    it('should reflect branch protection boolean options', async () => {
    +      const update = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +        enforce_admins                   : true,
    +        required_linear_history          : true,
    +        allow_force_pushes               : true,
    +        allow_deletions                  : true,
    +        block_creations                  : true,
    +        required_conversation_resolution : true,
    +        lock_branch                      : true,
    +        allow_fork_syncing               : true,
    +      });
    +      expect(update.status).toBe(200);
    +
    +      const booleanFields = [
    +        'enforce_admins',
    +        'required_linear_history',
    +        'allow_force_pushes',
    +        'allow_deletions',
    +        'block_creations',
    +        'required_conversation_resolution',
    +        'lock_branch',
    +        'allow_fork_syncing',
    +      ];
    +      const updated = parse(update);
    +      for (const field of booleanFields) {
    +        expect(updated[field].enabled).toBe(true);
    +      }
    +      expect(updated.required_signatures.enabled).toBe(false);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(fetched.status).toBe(200);
    +      const fetchedData = parse(fetched);
    +      for (const field of booleanFields) {
    +        expect(fetchedData[field].enabled).toBe(true);
    +      }
    +
    +      const reset = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +        enforce_admins                   : false,
    +        required_linear_history          : null,
    +        allow_force_pushes               : false,
    +        allow_deletions                  : null,
    +        block_creations                  : false,
    +        required_conversation_resolution : null,
    +        lock_branch                      : false,
    +        allow_fork_syncing               : null,
    +      });
    +      expect(reset.status).toBe(200);
    +      const resetData = parse(reset);
    +      for (const field of booleanFields) {
    +        expect(resetData[field].enabled).toBe(false);
    +      }
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should manage branch protection access restrictions', async () => {
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions'));
    +      expect(missing.status).toBe(404);
    +
    +      const update = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +        restrictions: {
    +          users : ['alice', 'bob'],
    +          teams : ['core'],
    +          apps  : ['gitd-ci'],
    +        },
    +      });
    +      expect(update.status).toBe(200);
    +      const updated = parse(update);
    +      expect(updated.restrictions.users.map((user: any) => user.login)).toEqual(['alice', 'bob']);
    +      expect(updated.restrictions.teams.map((team: any) => team.slug)).toEqual(['core']);
    +      expect(updated.restrictions.apps.map((app: any) => app.slug)).toEqual(['gitd-ci']);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions'));
    +      expect(fetched.status).toBe(200);
    +      expect(parse(fetched).users.map((user: any) => user.login)).toEqual(['alice', 'bob']);
    +
    +      const users = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions/users'));
    +      expect(users.status).toBe(200);
    +      expect(parse(users).map((user: any) => user.login)).toEqual(['alice', 'bob']);
    +
    +      const addUsers = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions/users'), 'POST', {
    +        users: ['bob', 'carol'],
    +      });
    +      expect(addUsers.status).toBe(200);
    +      expect(parse(addUsers).map((user: any) => user.login)).toEqual(['alice', 'bob', 'carol']);
    +
    +      const setTeams = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions/teams'), 'PUT', {
    +        teams: ['release'],
    +      });
    +      expect(setTeams.status).toBe(200);
    +      expect(parse(setTeams).map((team: any) => team.slug)).toEqual(['release']);
    +
    +      const removeApps = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions/apps'), 'DELETE', {
    +        apps: ['gitd-ci'],
    +      });
    +      expect(removeApps.status).toBe(200);
    +      expect(parse(removeApps)).toEqual([]);
    +
    +      const afterActorUpdates = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(afterActorUpdates.status).toBe(200);
    +      const actorUpdatesData = parse(afterActorUpdates);
    +      expect(actorUpdatesData.restrictions.users.map((user: any) => user.login)).toEqual(['alice', 'bob', 'carol']);
    +      expect(actorUpdatesData.restrictions.teams.map((team: any) => team.slug)).toEqual(['release']);
    +      expect(actorUpdatesData.restrictions.apps).toEqual([]);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection/restrictions'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const afterDelete = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(afterDelete.status).toBe(200);
    +      expect(parse(afterDelete).restrictions).toBeNull();
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should reject invalid branch protection payloads', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: { contexts: ['lint', 42] },
    +      });
    +      expect(res.status).toBe(422);
    +
    +      const booleanRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_linear_history: 'yes',
    +      });
    +      expect(booleanRes.status).toBe(422);
    +
    +      const restrictionsRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        restrictions: {
    +          users : ['alice'],
    +          teams : 42,
    +          apps  : [],
    +        },
    +      });
    +      expect(restrictionsRes.status).toBe(422);
    +
    +      const strictRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: { contexts: ['lint'], strict: 'yes' },
    +      });
    +      expect(strictRes.status).toBe(422);
    +
    +      const statusCheckAppRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          checks: [{ context: 'lint', app_id: 'github-actions' }],
    +        },
    +      });
    +      expect(statusCheckAppRes.status).toBe(422);
    +
    +      const reviewsRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_pull_request_reviews: {
    +          dismiss_stale_reviews: 'yes',
    +        },
    +      });
    +      expect(reviewsRes.status).toBe(422);
    +
    +      const dismissalRestrictionsRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_pull_request_reviews: {
    +          dismissal_restrictions: {
    +            users: 'alice',
    +          },
    +        },
    +      });
    +      expect(dismissalRestrictionsRes.status).toBe(422);
    +
    +      const bypassAllowancesRes = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_pull_request_reviews: {
    +          bypass_pull_request_allowances: {
    +            users: 'release-manager',
    +          },
    +        },
    +      });
    +      expect(bypassAllowancesRes.status).toBe(422);
    +    });
    +
    +    it('should return 404 for branch protection on missing branches', async () => {
    +      const getRes = await handleShimRequest(ctx, repoUrl('/branches/missing/protection'));
    +      expect(getRes.status).toBe(404);
    +
    +      const putRes = await handleShimRequest(ctx, repoUrl('/branches/missing/protection'), 'PUT', {
    +        required_status_checks: { contexts: ['lint'] },
    +      });
    +      expect(putRes.status).toBe(404);
    +
    +      const deleteRes = await handleShimRequest(ctx, repoUrl('/branches/missing/protection'), 'DELETE');
    +      expect(deleteRes.status).toBe(404);
    +
    +      const restrictionsRes = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/restrictions/users'), 'POST', {
    +        users: ['alice'],
    +      });
    +      expect(restrictionsRes.status).toBe(404);
    +    });
    +
    +    it('should manage required status check protection subresource', async () => {
    +      const update = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'), 'PATCH', {
    +        strict   : true,
    +        contexts : ['build'],
    +        checks   : [{ context: 'lint', app_id: 42 }],
    +      });
    +      expect(update.status).toBe(200);
    +      expect(parse(update).contexts).toEqual(['build', 'lint']);
    +      expect(parse(update).checks).toEqual([
    +        { app_id: null, context: 'build' },
    +        { app_id: 42, context: 'lint' },
    +      ]);
    +      expect(parse(update).strict).toBe(true);
    +
    +      const strictOnly = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'), 'PATCH', {
    +        strict: false,
    +      });
    +      expect(strictOnly.status).toBe(200);
    +      expect(parse(strictOnly).contexts).toEqual(['build', 'lint']);
    +      expect(parse(strictOnly).checks).toEqual([
    +        { app_id: null, context: 'build' },
    +        { app_id: 42, context: 'lint' },
    +      ]);
    +      expect(parse(strictOnly).strict).toBe(false);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'));
    +      expect(fetched.status).toBe(200);
    +      expect(parse(fetched).contexts).toEqual(['build', 'lint']);
    +      expect(parse(fetched).checks).toEqual([
    +        { app_id: null, context: 'build' },
    +        { app_id: 42, context: 'lint' },
    +      ]);
    +      expect(parse(fetched).strict).toBe(false);
    +
    +      const branch = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(parse(branch).protection.required_status_checks.contexts).toEqual(['build', 'lint']);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'));
    +      expect(missing.status).toBe(404);
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should add, set, list, and remove required status check contexts', async () => {
    +      const created = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks'), 'PATCH', {
    +        contexts: ['lint'],
    +      });
    +      expect(created.status).toBe(200);
    +
    +      const added = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks/contexts'), 'POST', {
    +        contexts: ['test', 'lint'],
    +      });
    +      expect(added.status).toBe(200);
    +      expect(parse(added)).toEqual(['lint', 'test']);
    +
    +      const listed = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks/contexts'));
    +      expect(parse(listed)).toEqual(['lint', 'test']);
    +
    +      const set = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks/contexts'), 'PUT', {
    +        contexts: ['deploy'],
    +      });
    +      expect(set.status).toBe(200);
    +      expect(parse(set)).toEqual(['deploy']);
    +
    +      const removed = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks/contexts'), 'DELETE', {
    +        contexts: ['deploy'],
    +      });
    +      expect(removed.status).toBe(200);
    +      expect(parse(removed)).toEqual([]);
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should manage pull request review protection subresource', async () => {
    +      const update = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'), 'PATCH', {
    +        bypass_pull_request_allowances: {
    +          users : ['release-manager'],
    +          teams : ['ops'],
    +          apps  : ['merge-bot'],
    +        },
    +        dismissal_restrictions: {
    +          users : ['alice'],
    +          teams : ['core'],
    +          apps  : ['review-bot'],
    +        },
    +        dismiss_stale_reviews           : true,
    +        require_code_owner_reviews      : true,
    +        required_approving_review_count : 3,
    +        require_last_push_approval      : true,
    +      });
    +      expect(update.status).toBe(200);
    +      const updated = parse(update);
    +      expect(updated.required_approving_review_count).toBe(3);
    +      expect(updated.bypass_pull_request_allowances.users.map((user: any) => user.login)).toEqual(['release-manager']);
    +      expect(updated.bypass_pull_request_allowances.teams.map((team: any) => team.slug)).toEqual(['ops']);
    +      expect(updated.bypass_pull_request_allowances.apps.map((app: any) => app.slug)).toEqual(['merge-bot']);
    +      expect(updated.dismissal_restrictions.users.map((user: any) => user.login)).toEqual(['alice']);
    +      expect(updated.dismissal_restrictions.teams.map((team: any) => team.slug)).toEqual(['core']);
    +      expect(updated.dismissal_restrictions.apps.map((app: any) => app.slug)).toEqual(['review-bot']);
    +      expect(updated.dismiss_stale_reviews).toBe(true);
    +      expect(updated.require_code_owner_reviews).toBe(true);
    +      expect(updated.require_last_push_approval).toBe(true);
    +
    +      const clearBooleans = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'), 'PATCH', {
    +        bypass_pull_request_allowances : {},
    +        dismissal_restrictions         : {},
    +        dismiss_stale_reviews          : false,
    +        require_code_owner_reviews     : false,
    +        require_last_push_approval     : false,
    +      });
    +      expect(clearBooleans.status).toBe(200);
    +      const cleared = parse(clearBooleans);
    +      expect(cleared.required_approving_review_count).toBe(3);
    +      expect(cleared.dismissal_restrictions.users).toEqual([]);
    +      expect(cleared.dismissal_restrictions.teams).toEqual([]);
    +      expect(cleared.dismissal_restrictions.apps).toEqual([]);
    +      expect(cleared.bypass_pull_request_allowances.users).toEqual([]);
    +      expect(cleared.bypass_pull_request_allowances.teams).toEqual([]);
    +      expect(cleared.bypass_pull_request_allowances.apps).toEqual([]);
    +      expect(cleared.dismiss_stale_reviews).toBe(false);
    +      expect(cleared.require_code_owner_reviews).toBe(false);
    +      expect(cleared.require_last_push_approval).toBe(false);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'));
    +      expect(fetched.status).toBe(200);
    +      expect(parse(fetched).required_approving_review_count).toBe(3);
    +      expect(parse(fetched).dismiss_stale_reviews).toBe(false);
    +
    +      const branch = await handleShimRequest(ctx, repoUrl('/branches/main'));
    +      expect(parse(branch).protected).toBe(true);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'));
    +      expect(missing.status).toBe(404);
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should manage admin branch protection enforcement', async () => {
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection/enforce_admins'));
    +      expect(missing.status).toBe(404);
    +
    +      const createProtection = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +        enforce_admins: null,
    +      });
    +      expect(createProtection.status).toBe(200);
    +
    +      const initial = await handleShimRequest(ctx, repoUrl('/branches/main/protection/enforce_admins'));
    +      expect(initial.status).toBe(200);
    +      expect(parse(initial).enabled).toBe(false);
    +
    +      const set = await handleShimRequest(ctx, repoUrl('/branches/main/protection/enforce_admins'), 'POST');
    +      expect(set.status).toBe(200);
    +      expect(parse(set).enabled).toBe(true);
    +
    +      const protectedBranch = await handleShimRequest(ctx, repoUrl('/branches/main/protection'));
    +      expect(parse(protectedBranch).enforce_admins.enabled).toBe(true);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection/enforce_admins'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const afterDelete = await handleShimRequest(ctx, repoUrl('/branches/main/protection/enforce_admins'));
    +      expect(afterDelete.status).toBe(200);
    +      expect(parse(afterDelete).enabled).toBe(false);
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should manage commit signature protection', async () => {
    +      const missing = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'));
    +      expect(missing.status).toBe(404);
    +
    +      const createProtection = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'PUT', {
    +        required_status_checks: {
    +          contexts: ['lint'],
    +        },
    +      });
    +      expect(createProtection.status).toBe(200);
    +
    +      const initial = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'));
    +      expect(initial.status).toBe(200);
    +      expect(parse(initial).enabled).toBe(false);
    +
    +      const createSignatures = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'), 'POST');
    +      expect(createSignatures.status).toBe(200);
    +      expect(parse(createSignatures).enabled).toBe(true);
    +
    +      const fetched = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'));
    +      expect(fetched.status).toBe(200);
    +      expect(parse(fetched).enabled).toBe(true);
    +
    +      const deleted = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'), 'DELETE');
    +      expect(deleted.status).toBe(204);
    +
    +      const afterDelete = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_signatures'));
    +      expect(afterDelete.status).toBe(200);
    +      expect(parse(afterDelete).enabled).toBe(false);
    +
    +      const cleanup = await handleShimRequest(ctx, repoUrl('/branches/main/protection'), 'DELETE');
    +      expect(cleanup.status).toBe(204);
    +    });
    +
    +    it('should reject invalid branch protection subresource payloads', async () => {
    +      const contexts = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_status_checks/contexts'), 'POST', {
    +        contexts: ['lint', 42],
    +      });
    +      expect(contexts.status).toBe(422);
    +
    +      const reviews = await handleShimRequest(ctx, repoUrl('/branches/main/protection/required_pull_request_reviews'), 'PATCH', {
    +        required_approving_review_count: 9,
    +      });
    +      expect(reviews.status).toBe(422);
    +    });
    +
    +    it('should return 404 for branch protection subresources on missing branches', async () => {
    +      const statusChecks = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/required_status_checks'));
    +      expect(statusChecks.status).toBe(404);
    +
    +      const contexts = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/required_status_checks/contexts'), 'POST', {
    +        contexts: ['lint'],
    +      });
    +      expect(contexts.status).toBe(404);
    +
    +      const reviews = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/required_pull_request_reviews'), 'PATCH', {
    +        required_approving_review_count: 1,
    +      });
    +      expect(reviews.status).toBe(404);
    +
    +      const enforceAdmins = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/enforce_admins'), 'POST');
    +      expect(enforceAdmins.status).toBe(404);
    +
    +      const signatures = await handleShimRequest(ctx, repoUrl('/branches/missing/protection/required_signatures'), 'POST');
    +      expect(signatures.status).toBe(404);
    +    });
    +
    +    it('should list mirrored tags', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/tags'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.length).toBe(1);
    +      expect(data[0].name).toBe('v1.0.0');
    +      expect(data[0].commit.sha).toBe(TAG_SHA);
    +    });
    +
    +    it('should return a git ref object', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/git/ref/heads/main'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.ref).toBe('refs/heads/main');
    +      expect(data.object.type).toBe('commit');
    +      expect(data.object.sha).toBe(MAIN_SHA);
    +    });
    +
    +    it('should list matching git refs', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/git/matching-refs/heads'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.map((ref: any) => ref.ref)).toContain('refs/heads/main');
    +      expect(data.map((ref: any) => ref.ref)).toContain('refs/heads/feature/demo');
    +    });
    +
    +    it('should return 404 for missing branches', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/branches/missing'));
    +      expect(res.status).toBe(404);
    +    });
    +  });
    +
    +  // =========================================================================
    +  // GET /repos/:did/:repo/git/blobs, /git/trees, /git/commits, /commits
    +  // =========================================================================
    +
    +  describe('git object endpoints', () => {
    +    it('should return a git blob object from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/git/blobs/${gitReadmeSha}`), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.sha).toBe(gitReadmeSha);
    +      expect(data.encoding).toBe('base64');
    +      expect(Buffer.from(data.content, 'base64').toString('utf-8')).toContain('Local Git Repo');
    +
    +      const raw = await handleShimRequest(ctx, repoUrl(`/git/blobs/${gitReadmeSha}`), 'GET', {}, null, {
    +        ...shimOptions(),
    +        accept: 'application/vnd.github.raw+json',
    +      });
    +      expect(raw.status).toBe(200);
    +      expect(raw.headers['Content-Type']).toBe('application/octet-stream');
    +      expect(bodyBuffer(raw).toString('utf-8')).toContain('Local Git Repo');
    +    });
    +
    +    it('should return a git tree object from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/git/trees/${gitTreeSha}`), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.sha).toBe(gitTreeSha);
    +      expect(data.truncated).toBe(false);
    +      expect(data.tree.map((entry: any) => entry.path)).toEqual(['LICENSE', 'README.md', 'src']);
    +    });
    +
    +    it('should return a recursive git tree object from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/git/trees/${gitTreeSha}?recursive=1`), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.tree.map((entry: any) => entry.path)).toContain('src/index.ts');
    +    });
    +
    +    it('should return a low-level git commit object from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/git/commits/${gitMainSha}`), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.sha).toBe(gitMainSha);
    +      expect(data.message).toBe('Add feature file');
    +      expect(data.tree.sha).toBe(gitTreeSha);
    +    });
    +
    +    it('should return a repository commit object for a branch ref', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/commits/main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.sha).toBe(gitMainSha);
    +      expect(data.commit.message).toBe('Add feature file');
    +      expect(data.commit.tree.sha).toBe(gitTreeSha);
    +
    +      const diffRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/commits/main'),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.diff' },
    +      );
    +      expect(diffRes.status).toBe(200);
    +      expect(diffRes.headers['Content-Type']).toBe('application/vnd.github.diff; charset=utf-8');
    +      expect(bodyBuffer(diffRes).toString('utf-8')).toContain('diff --git a/src/feature.ts b/src/feature.ts');
    +
    +      const patchRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/commits/main'),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.v3.patch' },
    +      );
    +      expect(patchRes.status).toBe(200);
    +      expect(patchRes.headers['Content-Type']).toBe('application/vnd.github.patch; charset=utf-8');
    +      expect(bodyBuffer(patchRes).toString('utf-8')).toContain('Add feature file');
    +
    +      const shaRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/commits/main'),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.sha' },
    +      );
    +      expect(shaRes.status).toBe(200);
    +      expect(shaRes.headers['Content-Type']).toBe('application/vnd.github.sha; charset=utf-8');
    +      expect(bodyBuffer(shaRes).toString('utf-8').trim()).toBe(gitMainSha);
    +    });
    +
    +    it('should list repository commits from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/commits?sha=main'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data[0].sha).toBe(gitMainSha);
    +      expect(data[0].commit.message).toBe('Add feature file');
    +      expect(data.map((commit: any) => commit.sha)).toContain(gitBaseSha);
    +    });
    +
    +    it('should paginate repository commit lists', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/commits?sha=main&per_page=1'), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data).toHaveLength(1);
    +      expect(data[0].sha).toBe(gitMainSha);
    +    });
    +
    +    it('should create git blobs, trees, commits, and references', async () => {
    +      const headRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/heads/main'));
    +      expect(headRes.status).toBe(200);
    +      const headSha = parse(headRes).object.sha;
    +
    +      const headCommitRes = await handleShimRequest(ctx, contentWriteRepoUrl(`/git/commits/${headSha}`), 'GET', {}, null, shimOptions());
    +      expect(headCommitRes.status).toBe(200);
    +      const headCommit = parse(headCommitRes);
    +
    +      const blobRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/blobs'), 'POST', {
    +        content  : 'raw git database file\n',
    +        encoding : 'utf-8',
    +      }, null, shimOptions());
    +      expect(blobRes.status).toBe(201);
    +      const blob = parse(blobRes);
    +      expect(blob.sha).toMatch(/^[0-9a-f]{40}$/);
    +
    +      const treeRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/trees'), 'POST', {
    +        base_tree : headCommit.tree.sha,
    +        tree      : [{
    +          path : 'api/raw.txt',
    +          mode : '100644',
    +          type : 'blob',
    +          sha  : blob.sha,
    +        }],
    +      }, null, shimOptions());
    +      expect(treeRes.status).toBe(201);
    +      const tree = parse(treeRes);
    +      expect(tree.sha).toMatch(/^[0-9a-f]{40}$/);
    +
    +      const commitRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/commits'), 'POST', {
    +        message : 'Create raw git API commit',
    +        tree    : tree.sha,
    +        parents : [headSha],
    +        author  : { name: 'API Author', email: 'api@example.test' },
    +      }, null, shimOptions());
    +      expect(commitRes.status).toBe(201);
    +      const commit = parse(commitRes);
    +      expect(commit.message).toBe('Create raw git API commit');
    +      expect(commit.parents[0].sha).toBe(headSha);
    +
    +      const createRefRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs'), 'POST', {
    +        ref : 'refs/heads/api-branch',
    +        sha : commit.sha,
    +      }, null, shimOptions());
    +      expect(createRefRes.status).toBe(201);
    +      expect(parse(createRefRes).object.sha).toBe(commit.sha);
    +
    +      const updateMainRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs/heads/main'), 'PATCH', {
    +        sha: commit.sha,
    +      }, null, shimOptions());
    +      expect(updateMainRes.status).toBe(200);
    +      expect(parse(updateMainRes).object.sha).toBe(commit.sha);
    +
    +      const fetched = await handleShimRequest(ctx, contentWriteRepoUrl('/contents/api/raw.txt?ref=main'), 'GET', {}, null, shimOptions());
    +      expect(fetched.status).toBe(200);
    +      expect(Buffer.from(parse(fetched).content, 'base64').toString('utf-8')).toBe('raw git database file\n');
    +
    +      const branch = await handleShimRequest(ctx, contentWriteRepoUrl('/branches/main'));
    +      expect(branch.status).toBe(200);
    +      expect(parse(branch).commit.sha).toBe(commit.sha);
    +
    +      const deleteRefRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs/heads/api-branch'), 'DELETE', {}, null, shimOptions());
    +      expect(deleteRefRes.status).toBe(204);
    +
    +      const missingBranch = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/heads/api-branch'));
    +      expect(missingBranch.status).toBe(404);
    +    });
    +
    +    it('should create and read annotated git tag objects', async () => {
    +      const headRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/heads/main'));
    +      expect(headRes.status).toBe(200);
    +      const headSha = parse(headRes).object.sha;
    +
    +      const tagRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/tags'), 'POST', {
    +        tag     : 'v-api-tag',
    +        message : 'Annotated tag from API',
    +        object  : headSha,
    +        type    : 'commit',
    +        tagger  : {
    +          name  : 'Tag Author',
    +          email : 'tag@example.test',
    +          date  : '2026-06-22T00:00:00Z',
    +        },
    +      }, null, shimOptions());
    +      expect(tagRes.status).toBe(201);
    +      const tag = parse(tagRes);
    +      expect(tag.sha).toMatch(/^[0-9a-f]{40}$/);
    +      expect(tag.tag).toBe('v-api-tag');
    +      expect(tag.message).toBe('Annotated tag from API');
    +      expect(tag.object.type).toBe('commit');
    +      expect(tag.object.sha).toBe(headSha);
    +      expect(tag.tagger.name).toBe('Tag Author');
    +      expect(tag.tagger.email).toBe('tag@example.test');
    +      expect(tag.verification.reason).toBe('unsigned');
    +
    +      const fetchedTagRes = await handleShimRequest(ctx, contentWriteRepoUrl(`/git/tags/${tag.sha}`), 'GET', {}, null, shimOptions());
    +      expect(fetchedTagRes.status).toBe(200);
    +      const fetchedTag = parse(fetchedTagRes);
    +      expect(fetchedTag.sha).toBe(tag.sha);
    +      expect(fetchedTag.object.sha).toBe(headSha);
    +
    +      const createRefRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs'), 'POST', {
    +        ref : 'refs/tags/v-api-tag',
    +        sha : tag.sha,
    +      }, null, shimOptions());
    +      expect(createRefRes.status).toBe(201);
    +      expect(parse(createRefRes).object.type).toBe('tag');
    +
    +      const fetchedRefRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/tags/v-api-tag'));
    +      expect(fetchedRefRes.status).toBe(200);
    +      const fetchedRef = parse(fetchedRefRes);
    +      expect(fetchedRef.ref).toBe('refs/tags/v-api-tag');
    +      expect(fetchedRef.object.type).toBe('tag');
    +      expect(fetchedRef.object.sha).toBe(tag.sha);
    +
    +      const listTagsRes = await handleShimRequest(ctx, contentWriteRepoUrl('/tags'), 'GET', {}, null, shimOptions());
    +      expect(listTagsRes.status).toBe(200);
    +      const listedTag = parse(listTagsRes).find((item: any) => item.name === 'v-api-tag');
    +      expect(listedTag.commit.sha).toBe(headSha);
    +      expect(listedTag.commit.url).toContain(`/commits/${headSha}`);
    +    });
    +
    +    it('should reject unsupported git tag object target types', async () => {
    +      const headRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/heads/main'));
    +      expect(headRes.status).toBe(200);
    +      const headSha = parse(headRes).object.sha;
    +
    +      const tagRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/tags'), 'POST', {
    +        tag     : 'v-invalid-target',
    +        message : 'Invalid tag target',
    +        object  : headSha,
    +        type    : 'tag',
    +      }, null, shimOptions());
    +      expect(tagRes.status).toBe(422);
    +    });
    +
    +    it('should protect git reference updates from non-fast-forwards and default branch deletes', async () => {
    +      const headRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/ref/heads/main'));
    +      expect(headRes.status).toBe(200);
    +      const headSha = parse(headRes).object.sha;
    +
    +      const headCommitRes = await handleShimRequest(ctx, contentWriteRepoUrl(`/git/commits/${headSha}`), 'GET', {}, null, shimOptions());
    +      expect(headCommitRes.status).toBe(200);
    +      const parentSha = parse(headCommitRes).parents[0].sha;
    +
    +      const rewindRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs/heads/main'), 'PATCH', {
    +        sha: parentSha,
    +      }, null, shimOptions());
    +      expect(rewindRes.status).toBe(409);
    +
    +      const deleteDefaultRes = await handleShimRequest(ctx, contentWriteRepoUrl('/git/refs/heads/main'), 'DELETE', {}, null, shimOptions());
    +      expect(deleteDefaultRes.status).toBe(422);
    +    });
    +
    +    it('should list repository contributors from local git history', async () => {
    +      const res = await handleShimRequest(
    +        ctx,
    +        url(`/repos/${testDid}/${CONTRIBUTORS_REPO_NAME}/contributors?per_page=1`),
    +        'GET',
    +        {},
    +        null,
    +        shimOptions(),
    +      );
    +      expect(res.status).toBe(200);
    +      expect(res.headers.Link).toContain('rel="next"');
    +
    +      const pageOne = parse(res);
    +      expect(pageOne).toHaveLength(1);
    +      expect(pageOne[0].name).toBe('Alice Author');
    +      expect(pageOne[0].email).toBe('alice@example.test');
    +      expect(pageOne[0].contributions).toBe(2);
    +      expect(pageOne[0].login.startsWith('alice-author-')).toBe(true);
    +
    +      const pageTwoRes = await handleShimRequest(
    +        ctx,
    +        url(`/repos/${testDid}/${CONTRIBUTORS_REPO_NAME}/contributors?per_page=1&page=2`),
    +        'GET',
    +        {},
    +        null,
    +        shimOptions(),
    +      );
    +      expect(pageTwoRes.status).toBe(200);
    +      const pageTwo = parse(pageTwoRes);
    +      expect(pageTwo).toHaveLength(1);
    +      expect(pageTwo[0].name).toBe('Bob Builder');
    +      expect(pageTwo[0].contributions).toBe(1);
    +    });
    +
    +    it('should return 204 for empty repository contributors', async () => {
    +      const res = await handleShimRequest(
    +        ctx,
    +        url(`/repos/${testDid}/${EMPTY_REPO_NAME}/contributors`),
    +        'GET',
    +        {},
    +        null,
    +        shimOptions(),
    +      );
    +      expect(res.status).toBe(204);
    +      expect(res.body).toBe('');
    +    });
    +
    +    it('should return GitHub-compatible community, traffic, and statistics metrics', async () => {
    +      const communityRes = await handleShimRequest(ctx, repoUrl('/community/profile'), 'GET', {}, null, shimOptions());
    +      expect(communityRes.status).toBe(200);
    +      const community = parse(communityRes);
    +      expect(community.description).toBe('A test repository');
    +      expect(community.health_percentage).toBeGreaterThan(0);
    +      expect(community.files.readme.url).toContain('/contents/README.md');
    +      expect(community.files.license.html_url).toContain('/blob/HEAD/LICENSE');
    +
    +      const clonesRes = await handleShimRequest(ctx, repoUrl('/traffic/clones?per=week'));
    +      expect(clonesRes.status).toBe(200);
    +      expect(parse(clonesRes)).toEqual({ count: 0, uniques: 0, clones: [] });
    +
    +      const viewsRes = await handleShimRequest(ctx, repoUrl('/traffic/views'));
    +      expect(viewsRes.status).toBe(200);
    +      expect(parse(viewsRes)).toEqual({ count: 0, uniques: 0, views: [] });
    +
    +      const pathsRes = await handleShimRequest(ctx, repoUrl('/traffic/popular/paths'));
    +      expect(pathsRes.status).toBe(200);
    +      expect(parse(pathsRes)).toEqual([]);
    +
    +      const referrersRes = await handleShimRequest(ctx, repoUrl('/traffic/popular/referrers'));
    +      expect(referrersRes.status).toBe(200);
    +      expect(parse(referrersRes)).toEqual([]);
    +
    +      const metricsBase = `/repos/${testDid}/${CONTRIBUTORS_REPO_NAME}`;
    +      const codeFrequencyRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/code_frequency`), 'GET', {}, null, shimOptions());
    +      expect(codeFrequencyRes.status).toBe(200);
    +      const codeFrequency = parse(codeFrequencyRes);
    +      expect(codeFrequency.length).toBeGreaterThan(0);
    +      expect(codeFrequency[0][1]).toBeGreaterThan(0);
    +
    +      const activityRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/commit_activity`), 'GET', {}, null, shimOptions());
    +      expect(activityRes.status).toBe(200);
    +      const activity = parse(activityRes);
    +      expect(activity.reduce((sum: number, week: any) => sum + week.total, 0)).toBe(3);
    +      expect(activity[0].days).toHaveLength(7);
    +
    +      const statsContributorsRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/contributors`), 'GET', {}, null, shimOptions());
    +      expect(statsContributorsRes.status).toBe(200);
    +      const statsContributors = parse(statsContributorsRes);
    +      expect(statsContributors).toHaveLength(2);
    +      expect(statsContributors[0].author.login.startsWith('alice-author-')).toBe(true);
    +      expect(statsContributors[0].total).toBe(2);
    +      expect(statsContributors[0].weeks[0].c).toBe(2);
    +
    +      const participationRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/participation`), 'GET', {}, null, shimOptions());
    +      expect(participationRes.status).toBe(200);
    +      const participation = parse(participationRes);
    +      expect(participation.all).toHaveLength(52);
    +      expect(participation.owner).toHaveLength(52);
    +      expect(participation.all.reduce((sum: number, count: number) => sum + count, 0)).toBe(3);
    +
    +      const punchCardRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/punch_card`), 'GET', {}, null, shimOptions());
    +      expect(punchCardRes.status).toBe(200);
    +      const punchCard = parse(punchCardRes);
    +      expect(punchCard).toHaveLength(168);
    +      expect(punchCard.reduce((sum: number, entry: number[]) => sum + entry[2], 0)).toBe(3);
    +    });
    +
    +    it('should return empty metrics for empty repositories and validate traffic periods', async () => {
    +      const metricsBase = `/repos/${testDid}/${EMPTY_REPO_NAME}`;
    +      const codeFrequencyRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/code_frequency`), 'GET', {}, null, shimOptions());
    +      expect(codeFrequencyRes.status).toBe(200);
    +      expect(parse(codeFrequencyRes)).toEqual([]);
    +
    +      const participationRes = await handleShimRequest(ctx, url(`${metricsBase}/stats/participation`), 'GET', {}, null, shimOptions());
    +      expect(participationRes.status).toBe(200);
    +      const participation = parse(participationRes);
    +      expect(participation.all).toHaveLength(52);
    +      expect(participation.all.every((count: number) => count === 0)).toBe(true);
    +
    +      const invalidTrafficRes = await handleShimRequest(ctx, repoUrl('/traffic/clones?per=month'));
    +      expect(invalidTrafficRes.status).toBe(422);
    +
    +      const missingRepoRes = await handleShimRequest(ctx, url(`/repos/${testDid}/missing-repo/stats/punch_card`), 'GET', {}, null, shimOptions());
    +      expect(missingRepoRes.status).toBe(404);
    +    });
    +
    +    it('should compare commits from local repo storage', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/compare/${gitBaseSha}...main`), 'GET', {}, null, shimOptions());
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.status).toBe('ahead');
    +      expect(data.ahead_by).toBe(1);
    +      expect(data.behind_by).toBe(0);
    +      expect(data.total_commits).toBe(1);
    +      expect(data.base_commit.sha).toBe(gitBaseSha);
    +      expect(data.commits[0].sha).toBe(gitMainSha);
    +
    +      const feature = data.files.find((file: any) => file.filename === 'src/feature.ts');
    +      expect(feature.status).toBe('added');
    +      expect(feature.additions).toBe(1);
    +      expect(feature.deletions).toBe(0);
    +
    +      const diffRes = await handleShimRequest(ctx, new URL(data.diff_url), 'GET', {}, null, shimOptions());
    +      expect(diffRes.status).toBe(200);
    +      expect(diffRes.headers['Content-Type']).toBe('application/vnd.github.diff; charset=utf-8');
    +      expect(bodyBuffer(diffRes).toString('utf-8')).toContain('diff --git a/src/feature.ts b/src/feature.ts');
    +
    +      const patchRes = await handleShimRequest(ctx, new URL(data.patch_url), 'GET', {}, null, shimOptions());
    +      expect(patchRes.status).toBe(200);
    +      expect(patchRes.headers['Content-Type']).toBe('application/vnd.github.patch; charset=utf-8');
    +      expect(bodyBuffer(patchRes).toString('utf-8')).toContain('Add feature file');
    +
    +      const acceptDiffRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/compare/${gitBaseSha}...main`),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.diff' },
    +      );
    +      expect(acceptDiffRes.status).toBe(200);
    +      expect(acceptDiffRes.headers['Content-Type']).toBe('application/vnd.github.diff; charset=utf-8');
    +      expect(bodyBuffer(acceptDiffRes).toString('utf-8')).toContain('diff --git a/src/feature.ts b/src/feature.ts');
    +
    +      const acceptPatchRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/compare/${gitBaseSha}...main`),
    +        'GET',
    +        {},
    +        null,
    +        { ...shimOptions(), accept: 'application/vnd.github.v3.patch' },
    +      );
    +      expect(acceptPatchRes.status).toBe(200);
    +      expect(acceptPatchRes.headers['Content-Type']).toBe('application/vnd.github.patch; charset=utf-8');
    +      expect(bodyBuffer(acceptPatchRes).toString('utf-8')).toContain('Add feature file');
    +    });
    +  });
    +
    +  // =========================================================================
    +  // GET/POST /statuses and /check-runs
    +  // =========================================================================
    +
    +  describe('repository status and check endpoints', () => {
    +    it('should return a combined commit status from CI runs', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/commits/main/status'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.sha).toBe(MAIN_SHA);
    +      expect(data.state).toBe('success');
    +      expect(data.total_count).toBe(1);
    +      expect(data.statuses[0].context).toBe('lint');
    +      expect(data.statuses[0].state).toBe('success');
    +    });
    +
    +    it('should list commit statuses from CI runs', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/statuses`));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(Array.isArray(data)).toBe(true);
    +      expect(data.length).toBe(1);
    +      expect(data[0].context).toBe('lint');
    +      expect(data[0].description).toBe('All clean.');
    +    });
    +
    +    it('should list check suites for a commit ref', async () => {
    +      const res = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/commits/main/check-suites?app_id=${numericId('gitd-ci')}&check_name=lint&per_page=1`),
    +      );
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.total_count).toBe(1);
    +      expect(data.check_suites[0].id).toBe(numericId(checkSuiteRecId));
    +      expect(data.check_suites[0].head_sha).toBe(MAIN_SHA);
    +      expect(data.check_suites[0].status).toBe('completed');
    +      expect(data.check_suites[0].conclusion).toBe('success');
    +
    +      const invalid = await handleShimRequest(ctx, repoUrl('/commits/main/check-suites?app_id=not-a-number'));
    +      expect(invalid.status).toBe(422);
    +    });
    +
    +    it('should list check runs for a commit ref with GitHub filters', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/commits/main/check-runs?check_name=lint&status=completed&per_page=1'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.total_count).toBe(1);
    +      expect(data.check_runs).toHaveLength(1);
    +      expect(data.check_runs[0].id).toBe(numericId(checkRunRecId));
    +      expect(data.check_runs[0].name).toBe('lint');
    +      expect(data.check_runs[0].output.annotations_url).toContain(`/check-runs/${numericId(checkRunRecId)}/annotations`);
    +
    +      const filtered = await handleShimRequest(ctx, repoUrl('/commits/main/check-runs?check_name=build'));
    +      expect(parse(filtered).total_count).toBe(0);
    +
    +      const invalid = await handleShimRequest(ctx, repoUrl('/commits/main/check-runs?status=waiting'));
    +      expect(invalid.status).toBe(422);
    +    });
    +
    +    it('should return check suite detail', async () => {
    +      const suiteId = numericId(checkSuiteRecId);
    +      const res = await handleShimRequest(ctx, repoUrl(`/check-suites/${suiteId}`));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.id).toBe(suiteId);
    +      expect(data.latest_check_runs_count).toBe(1);
    +      expect(data.app.name).toBe('gitd-ci');
    +    });
    +
    +    it('should create check suites and return existing suites for the same app and SHA', async () => {
    +      const createRes = await handleShimRequest(ctx, repoUrl('/check-suites'), 'POST', {
    +        head_sha: MAIN_SHA,
    +      });
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +      expect(created.head_sha).toBe(MAIN_SHA);
    +      expect(created.head_branch).toBe('main');
    +      expect(created.status).toBe('queued');
    +      expect(created.conclusion).toBeNull();
    +      expect(created.app.name).toBe('github-checks');
    +
    +      const duplicateRes = await handleShimRequest(ctx, repoUrl('/check-suites'), 'POST', {
    +        head_sha: MAIN_SHA,
    +      });
    +      expect(duplicateRes.status).toBe(200);
    +      expect(parse(duplicateRes).id).toBe(created.id);
    +
    +      const missingHead = await handleShimRequest(ctx, repoUrl('/check-suites'), 'POST', {});
    +      expect(missingHead.status).toBe(422);
    +    });
    +
    +    it('should list check runs for a suite', async () => {
    +      const suiteId = numericId(checkSuiteRecId);
    +      const res = await handleShimRequest(ctx, repoUrl(`/check-suites/${suiteId}/check-runs`));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.total_count).toBe(1);
    +      expect(data.check_runs[0].id).toBe(numericId(checkRunRecId));
    +      expect(data.check_runs[0].name).toBe('lint');
    +      expect(data.check_runs[0].output.summary).toBe('All clean.');
    +    });
    +
    +    it('should return check run detail', async () => {
    +      const runId = numericId(checkRunRecId);
    +      const res = await handleShimRequest(ctx, repoUrl(`/check-runs/${runId}`));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.id).toBe(runId);
    +      expect(data.name).toBe('lint');
    +      expect(data.status).toBe('completed');
    +      expect(data.conclusion).toBe('success');
    +    });
    +
    +    it('should rerequest a check suite by resetting suite status', async () => {
    +      const suiteId = numericId(checkSuiteRecId);
    +      const res = await handleShimRequest(ctx, repoUrl(`/check-suites/${suiteId}/rerequest`), 'POST');
    +      expect(res.status).toBe(201);
    +      expect(res.body).toBe('');
    +
    +      const detail = await handleShimRequest(ctx, repoUrl(`/check-suites/${suiteId}`));
    +      expect(parse(detail).status).toBe('queued');
    +      expect(parse(detail).conclusion).toBeNull();
    +
    +      const missing = await handleShimRequest(ctx, repoUrl('/check-suites/999/rerequest'), 'POST');
    +      expect(missing.status).toBe(404);
    +    });
    +
    +    it('should append and list check run annotations', async () => {
    +      const createRes = await handleShimRequest(ctx, repoUrl('/check-runs'), 'POST', {
    +        name     : 'annotated-build',
    +        head_sha : FEATURE_SHA,
    +        status   : 'in_progress',
    +        output   : {
    +          title       : 'Annotated build',
    +          summary     : 'Started.',
    +          annotations : [{
    +            path             : 'README.md',
    +            start_line       : 1,
    +            end_line         : 1,
    +            annotation_level : 'warning',
    +            title            : 'Style',
    +            message          : 'Use a shorter heading.',
    +            raw_details      : 'Heading is long.',
    +          }],
    +        },
    +      });
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +      expect(created.output.annotations_count).toBe(1);
    +
    +      const updateRes = await handleShimRequest(ctx, repoUrl(`/check-runs/${created.id}`), 'PATCH', {
    +        status     : 'completed',
    +        conclusion : 'success',
    +        output     : {
    +          title       : 'Annotated build',
    +          summary     : 'Passed with notes.',
    +          annotations : [{
    +            path             : 'src/feature.ts',
    +            start_line       : 2,
    +            end_line         : 2,
    +            annotation_level : 'notice',
    +            message          : 'Generated file touched.',
    +          }],
    +        },
    +      });
    +      expect(updateRes.status).toBe(200);
    +      expect(parse(updateRes).output.annotations_count).toBe(2);
    +
    +      const annotationsRes = await handleShimRequest(ctx, repoUrl(`/check-runs/${created.id}/annotations?per_page=1`));
    +      expect(annotationsRes.status).toBe(200);
    +      expect(annotationsRes.headers.Link).toContain('rel="next"');
    +      const annotations = parse(annotationsRes);
    +      expect(annotations).toHaveLength(1);
    +      expect(annotations[0].path).toBe('README.md');
    +      expect(annotations[0].annotation_level).toBe('warning');
    +      expect(annotations[0].blob_href).toContain('/contents/README.md');
    +    });
    +
    +    it('should create a commit status backed by forge-ci', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl(`/statuses/${FEATURE_SHA}`), 'POST', {
    +        state       : 'failure',
    +        context     : 'deploy',
    +        description : 'Deployment failed.',
    +      });
    +      expect(res.status).toBe(201);
    +      const data = parse(res);
    +      expect(data.context).toBe('deploy');
    +      expect(data.state).toBe('failure');
    +
    +      const combined = await handleShimRequest(ctx, repoUrl(`/commits/${FEATURE_SHA}/status`));
    +      const combinedData = parse(combined);
    +      expect(combinedData.state).toBe('failure');
    +      expect(combinedData.statuses.find((s: any) => s.context === 'deploy')).toBeDefined();
    +    });
    +
    +    it('should create and update a check run', async () => {
    +      const createRes = await handleShimRequest(ctx, repoUrl('/check-runs'), 'POST', {
    +        name     : 'build',
    +        head_sha : FEATURE_SHA,
    +        status   : 'queued',
    +        output   : { title: 'Build', summary: 'Queued.' },
    +      });
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +      expect(created.name).toBe('build');
    +      expect(created.status).toBe('queued');
    +
    +      const updateRes = await handleShimRequest(ctx, repoUrl(`/check-runs/${created.id}`), 'PATCH', {
    +        status     : 'completed',
    +        conclusion : 'success',
    +        output     : { title: 'Build', summary: 'Passed.' },
    +      });
    +      expect(updateRes.status).toBe(200);
    +      const updated = parse(updateRes);
    +      expect(updated.status).toBe('completed');
    +      expect(updated.conclusion).toBe('success');
    +      expect(updated.output.summary).toBe('Passed.');
    +    });
    +
    +    it('should rerequest a check run without mutating the run result', async () => {
    +      const createRes = await handleShimRequest(ctx, repoUrl('/check-runs'), 'POST', {
    +        name       : 'rerun-target',
    +        head_sha   : FEATURE_SHA,
    +        status     : 'completed',
    +        conclusion : 'failure',
    +        output     : { title: 'Rerun target', summary: 'Failed.' },
    +      });
    +      expect(createRes.status).toBe(201);
    +      const created = parse(createRes);
    +
    +      const rerequestRes = await handleShimRequest(ctx, repoUrl(`/check-runs/${created.id}/rerequest`), 'POST');
    +      expect(rerequestRes.status).toBe(201);
    +      expect(rerequestRes.body).toBe('');
    +
    +      const runDetail = parse(await handleShimRequest(ctx, repoUrl(`/check-runs/${created.id}`)));
    +      expect(runDetail.status).toBe('completed');
    +      expect(runDetail.conclusion).toBe('failure');
    +
    +      const suiteDetail = parse(await handleShimRequest(ctx, repoUrl(`/check-suites/${created.check_suite.id}`)));
    +      expect(suiteDetail.status).toBe('queued');
    +      expect(suiteDetail.conclusion).toBeNull();
    +    });
    +
    +    it('should return 404 for missing check runs', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/check-runs/999'));
    +      expect(res.status).toBe(404);
    +
    +      const annotationsRes = await handleShimRequest(ctx, repoUrl('/check-runs/999/annotations'));
    +      expect(annotationsRes.status).toBe(404);
    +
    +      const rerequestRes = await handleShimRequest(ctx, repoUrl('/check-runs/999/rerequest'), 'POST');
    +      expect(rerequestRes.status).toBe(404);
    +    });
    +  });
    +
    +  // =========================================================================
    +  // GET/POST /repos/:did/:repo/actions/runs and jobs
    +  // =========================================================================
    +
    +  describe('GitHub Actions workflow endpoints', () => {
    +    it('should create, list, update, and delete repository Actions variables', async () => {
    +      const initialRes = await handleShimRequest(ctx, repoUrl('/actions/variables'));
    +      expect(initialRes.status).toBe(200);
    +      expect(parse(initialRes).variables).toEqual([]);
    +
    +      const createRes = await handleShimRequest(ctx, repoUrl('/actions/variables'), 'POST', {
    +        name  : 'ACTIONS_COLOR',
    +        value : 'blue',
    +      });
    +      expect(createRes.status).toBe(201);
    +      expect(createRes.body).toBe('');
    +
    +      const duplicateRes = await handleShimRequest(ctx, repoUrl('/actions/variables'), 'POST', {
    +        name  : 'actions_color',
    +        value : 'red',
    +      });
    +      expect(duplicateRes.status).toBe(409);
    +
    +      const emptyValueRes = await handleShimRequest(ctx, repoUrl('/actions/variables'), 'POST', {
    +        name  : 'ACTIONS_EMPTY',
    +        value : '',
    +      });
    +      expect(emptyValueRes.status).toBe(201);
    +
    +      const invalidRes = await handleShimRequest(ctx, repoUrl('/actions/variables'), 'POST', {
    +        name  : 'ACTIONS_INVALID',
    +        value : 42,
    +      });
    +      expect(invalidRes.status).toBe(422);
    +
    +      const listRes = await handleShimRequest(ctx, repoUrl('/actions/variables?per_page=1'));
    +      expect(listRes.status).toBe(200);
    +      expect(listRes.headers.Link).toContain('rel="next"');
    +      const list = parse(listRes);
    +      expect(list.total_count).toBe(2);
    +      expect(list.variables).toHaveLength(1);
    +
    +      const getRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_COLOR'));
    +      expect(getRes.status).toBe(200);
    +      expect(parse(getRes).value).toBe('blue');
    +
    +      const updateRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_COLOR'), 'PATCH', {
    +        name  : 'ACTIONS_SHADE',
    +        value : 'green with spaces ',
    +      });
    +      expect(updateRes.status).toBe(204);
    +
    +      const oldNameRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_COLOR'));
    +      expect(oldNameRes.status).toBe(404);
    +
    +      const updatedRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_SHADE'));
    +      expect(updatedRes.status).toBe(200);
    +      expect(parse(updatedRes).value).toBe('green with spaces ');
    +
    +      const emptyGetRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_EMPTY'));
    +      expect(emptyGetRes.status).toBe(200);
    +      expect(parse(emptyGetRes).value).toBe('');
    +
    +      const emptyPatchRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_SHADE'), 'PATCH', {});
    +      expect(emptyPatchRes.status).toBe(422);
    +
    +      const deleteRenamedRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_SHADE'), 'DELETE');
    +      expect(deleteRenamedRes.status).toBe(204);
    +
    +      const deleteEmptyRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_EMPTY'), 'DELETE');
    +      expect(deleteEmptyRes.status).toBe(204);
    +
    +      const missingRes = await handleShimRequest(ctx, repoUrl('/actions/variables/ACTIONS_SHADE'));
    +      expect(missingRes.status).toBe(404);
    +    });
    +
    +    it('should create, list, update, and delete repository Actions secrets', async () => {
    +      const publicKeyRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/public-key'));
    +      expect(publicKeyRes.status).toBe(200);
    +      const publicKey = parse(publicKeyRes);
    +      expect(typeof publicKey.key_id).toBe('string');
    +      expect(typeof publicKey.key).toBe('string');
    +
    +      const initialRes = await handleShimRequest(ctx, repoUrl('/actions/secrets'));
    +      expect(initialRes.status).toBe(200);
    +      expect(parse(initialRes)).toEqual({ total_count: 0, secrets: [] });
    +
    +      const createRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/DEPLOY_TOKEN'), 'PUT', {
    +        encrypted_value : 'c2VjcmV0',
    +        key_id          : publicKey.key_id,
    +      });
    +      expect(createRes.status).toBe(201);
    +      expect(createRes.body).toBe('');
    +
    +      const updateRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/DEPLOY_TOKEN'), 'PUT', {
    +        encrypted_value : 'bmV3LXNlY3JldA==',
    +        key_id          : publicKey.key_id,
    +      });
    +      expect(updateRes.status).toBe(204);
    +
    +      const secondRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/API_KEY'), 'PUT', {
    +        encrypted_value : 'YXBpLWtleQ==',
    +        key_id          : publicKey.key_id,
    +      });
    +      expect(secondRes.status).toBe(201);
    +
    +      const invalidRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/INVALID_SECRET'), 'PUT', {
    +        encrypted_value: 'bWlzc2luZy1rZXk=',
    +      });
    +      expect(invalidRes.status).toBe(422);
    +
    +      const listRes = await handleShimRequest(ctx, repoUrl('/actions/secrets?per_page=1'));
    +      expect(listRes.status).toBe(200);
    +      expect(listRes.headers.Link).toContain('rel="next"');
    +      const list = parse(listRes);
    +      expect(list.total_count).toBe(2);
    +      expect(list.secrets).toHaveLength(1);
    +      expect(list.secrets[0].encrypted_value).toBeUndefined();
    +
    +      const getRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/DEPLOY_TOKEN'));
    +      expect(getRes.status).toBe(200);
    +      const secret = parse(getRes);
    +      expect(secret.name).toBe('DEPLOY_TOKEN');
    +      expect(secret.encrypted_value).toBeUndefined();
    +      expect(secret.created_at).toBeDefined();
    +      expect(secret.updated_at).toBeDefined();
    +
    +      const deleteFirstRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/DEPLOY_TOKEN'), 'DELETE');
    +      expect(deleteFirstRes.status).toBe(204);
    +
    +      const deleteSecondRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/API_KEY'), 'DELETE');
    +      expect(deleteSecondRes.status).toBe(204);
    +
    +      const missingRes = await handleShimRequest(ctx, repoUrl('/actions/secrets/DEPLOY_TOKEN'));
    +      expect(missingRes.status).toBe(404);
    +    });
    +
    +    it('should get and update repository Actions permissions', async () => {
    +      const defaultPermissionsRes = await handleShimRequest(ctx, repoUrl('/actions/permissions'));
    +      expect(defaultPermissionsRes.status).toBe(200);
    +      const defaultPermissions = parse(defaultPermissionsRes);
    +      expect(defaultPermissions.enabled).toBe(true);
    +      expect(defaultPermissions.allowed_actions).toBe('all');
    +      expect(defaultPermissions.sha_pinning_required).toBe(false);
    +      expect(defaultPermissions.selected_actions_url).toContain('/actions/permissions/selected-actions');
    +
    +      const defaultSelectedRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/selected-actions'));
    +      expect(defaultSelectedRes.status).toBe(200);
    +      expect(parse(defaultSelectedRes)).toEqual({
    +        github_owned_allowed : true,
    +        verified_allowed     : true,
    +        patterns_allowed     : [],
    +      });
    +
    +      const defaultWorkflowRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/workflow'));
    +      expect(defaultWorkflowRes.status).toBe(200);
    +      expect(parse(defaultWorkflowRes)).toEqual({
    +        default_workflow_permissions     : 'read',
    +        can_approve_pull_request_reviews : false,
    +      });
    +
    +      const missingEnabledRes = await handleShimRequest(ctx, repoUrl('/actions/permissions'), 'PUT', {
    +        allowed_actions: 'selected',
    +      });
    +      expect(missingEnabledRes.status).toBe(422);
    +
    +      const updatePermissionsRes = await handleShimRequest(ctx, repoUrl('/actions/permissions'), 'PUT', {
    +        enabled              : false,
    +        allowed_actions      : 'selected',
    +        sha_pinning_required : true,
    +      });
    +      expect(updatePermissionsRes.status).toBe(204);
    +
    +      const updatedPermissionsRes = await handleShimRequest(ctx, repoUrl('/actions/permissions'));
    +      expect(updatedPermissionsRes.status).toBe(200);
    +      const updatedPermissions = parse(updatedPermissionsRes);
    +      expect(updatedPermissions.enabled).toBe(false);
    +      expect(updatedPermissions.allowed_actions).toBe('selected');
    +      expect(updatedPermissions.sha_pinning_required).toBe(true);
    +
    +      const invalidPermissionsRes = await handleShimRequest(ctx, repoUrl('/actions/permissions'), 'PUT', {
    +        enabled         : true,
    +        allowed_actions : 'external_only',
    +      });
    +      expect(invalidPermissionsRes.status).toBe(422);
    +
    +      const selectedActionsRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/selected-actions'), 'PUT', {
    +        github_owned_allowed : false,
    +        verified_allowed     : false,
    +        patterns_allowed     : ['actions/*', 'did:jwk:*/workflow@*', 'actions/*'],
    +      });
    +      expect(selectedActionsRes.status).toBe(204);
    +
    +      const selectedActionsGetRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/selected-actions'));
    +      expect(parse(selectedActionsGetRes)).toEqual({
    +        github_owned_allowed : false,
    +        verified_allowed     : false,
    +        patterns_allowed     : ['actions/*', 'did:jwk:*/workflow@*'],
    +      });
    +
    +      const invalidSelectedActionsRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/actions/permissions/selected-actions'),
    +        'PUT',
    +        { patterns_allowed: ['actions/*', 42] },
    +      );
    +      expect(invalidSelectedActionsRes.status).toBe(422);
    +
    +      const workflowRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/workflow'), 'PUT', {
    +        default_workflow_permissions     : 'write',
    +        can_approve_pull_request_reviews : true,
    +      });
    +      expect(workflowRes.status).toBe(204);
    +
    +      const workflowGetRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/workflow'));
    +      expect(parse(workflowGetRes)).toEqual({
    +        default_workflow_permissions     : 'write',
    +        can_approve_pull_request_reviews : true,
    +      });
    +
    +      const invalidWorkflowRes = await handleShimRequest(ctx, repoUrl('/actions/permissions/workflow'), 'PUT', {
    +        default_workflow_permissions: 'admin',
    +      });
    +      expect(invalidWorkflowRes.status).toBe(422);
    +    });
    +
    +    it('should list usage, limits, filters, and deletes for repository Actions caches', async () => {
    +      const defaultRetentionRes = await handleShimRequest(ctx, repoUrl('/actions/cache/retention-limit'));
    +      expect(defaultRetentionRes.status).toBe(200);
    +      expect(parse(defaultRetentionRes).max_cache_retention_days).toBe(7);
    +
    +      const setRetentionRes = await handleShimRequest(ctx, repoUrl('/actions/cache/retention-limit'), 'PUT', {
    +        max_cache_retention_days: 21,
    +      });
    +      expect(setRetentionRes.status).toBe(204);
    +
    +      const retentionRes = await handleShimRequest(ctx, repoUrl('/actions/cache/retention-limit'));
    +      expect(parse(retentionRes).max_cache_retention_days).toBe(21);
    +
    +      const invalidRetentionRes = await handleShimRequest(ctx, repoUrl('/actions/cache/retention-limit'), 'PUT', {
    +        max_cache_retention_days: 0,
    +      });
    +      expect(invalidRetentionRes.status).toBe(422);
    +
    +      const defaultStorageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/storage-limit'));
    +      expect(defaultStorageRes.status).toBe(200);
    +      expect(parse(defaultStorageRes).max_cache_size_gb).toBe(10);
    +
    +      const setStorageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/storage-limit'), 'PUT', {
    +        max_cache_size_gb: 150,
    +      });
    +      expect(setStorageRes.status).toBe(204);
    +
    +      const storageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/storage-limit'));
    +      expect(parse(storageRes).max_cache_size_gb).toBe(150);
    +
    +      const invalidStorageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/storage-limit'), 'PUT', {
    +        max_cache_size_gb: 'large',
    +      });
    +      expect(invalidStorageRes.status).toBe(422);
    +
    +      await mergeRepoSettings({
    +        actionsCaches: {
    +          '505': {
    +            id             : 505,
    +            ref            : 'refs/heads/main',
    +            key            : 'Linux-node-main',
    +            version        : 'cache-version-1',
    +            lastAccessedAt : '2024-01-04T00:00:00.000Z',
    +            createdAt      : '2024-01-01T00:00:00.000Z',
    +            sizeInBytes    : 1024,
    +          },
    +          '506': {
    +            id             : 506,
    +            ref            : 'refs/heads/main',
    +            key            : 'Linux-node-feature',
    +            version        : 'cache-version-2',
    +            lastAccessedAt : '2024-01-03T00:00:00.000Z',
    +            createdAt      : '2024-01-02T00:00:00.000Z',
    +            sizeInBytes    : 2048,
    +          },
    +          '507': {
    +            id             : 507,
    +            ref            : 'refs/heads/feature/cache',
    +            key            : 'macOS-node-feature',
    +            version        : 'cache-version-3',
    +            lastAccessedAt : '2024-01-05T00:00:00.000Z',
    +            createdAt      : '2024-01-03T00:00:00.000Z',
    +            sizeInBytes    : 512,
    +          },
    +        },
    +      });
    +
    +      const usageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/usage'));
    +      expect(usageRes.status).toBe(200);
    +      const usage = parse(usageRes);
    +      expect(usage.full_name).toBe(`${testDid}/test-repo`);
    +      expect(usage.active_caches_count).toBe(3);
    +      expect(usage.active_caches_size_in_bytes).toBe(3584);
    +
    +      const listRes = await handleShimRequest(ctx, repoUrl('/actions/caches?sort=size_in_bytes&direction=asc&per_page=2'));
    +      expect(listRes.status).toBe(200);
    +      expect(listRes.headers.Link).toContain('rel="next"');
    +      const list = parse(listRes);
    +      expect(list.total_count).toBe(3);
    +      expect(list.actions_caches.map((cache: any) => cache.id)).toEqual([507, 505]);
    +      expect(list.actions_caches[0].size_in_bytes).toBe(512);
    +
    +      const filteredRes = await handleShimRequest(ctx, repoUrl('/actions/caches?ref=refs/heads/main&key=Linux-node'));
    +      expect(filteredRes.status).toBe(200);
    +      const filtered = parse(filteredRes);
    +      expect(filtered.total_count).toBe(2);
    +      expect(filtered.actions_caches.map((cache: any) => cache.id)).toEqual([505, 506]);
    +
    +      const invalidSortRes = await handleShimRequest(ctx, repoUrl('/actions/caches?sort=name'));
    +      expect(invalidSortRes.status).toBe(422);
    +
    +      const invalidDirectionRes = await handleShimRequest(ctx, repoUrl('/actions/caches?direction=sideways'));
    +      expect(invalidDirectionRes.status).toBe(422);
    +
    +      const missingKeyRes = await handleShimRequest(ctx, repoUrl('/actions/caches'), 'DELETE');
    +      expect(missingKeyRes.status).toBe(422);
    +
    +      const deleteByKeyRes = await handleShimRequest(ctx, repoUrl('/actions/caches?key=Linux-node-main'), 'DELETE');
    +      expect(deleteByKeyRes.status).toBe(200);
    +      const deletedByKey = parse(deleteByKeyRes);
    +      expect(deletedByKey.total_count).toBe(1);
    +      expect(deletedByKey.actions_caches[0].id).toBe(505);
    +
    +      const deleteByIdRes = await handleShimRequest(ctx, repoUrl('/actions/caches/506'), 'DELETE');
    +      expect(deleteByIdRes.status).toBe(204);
    +
    +      const missingIdRes = await handleShimRequest(ctx, repoUrl('/actions/caches/506'), 'DELETE');
    +      expect(missingIdRes.status).toBe(404);
    +
    +      const wrongRefDeleteRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/actions/caches?key=macOS-node-feature&ref=refs/heads/main'),
    +        'DELETE',
    +      );
    +      expect(wrongRefDeleteRes.status).toBe(200);
    +      expect(parse(wrongRefDeleteRes).total_count).toBe(0);
    +
    +      const finalDeleteRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/actions/caches?key=macOS-node-feature&ref=refs/heads/feature/cache'),
    +        'DELETE',
    +      );
    +      expect(finalDeleteRes.status).toBe(200);
    +      expect(parse(finalDeleteRes).total_count).toBe(1);
    +
    +      const finalUsageRes = await handleShimRequest(ctx, repoUrl('/actions/cache/usage'));
    +      expect(parse(finalUsageRes).active_caches_count).toBe(0);
    +
    +      const finalListRes = await handleShimRequest(ctx, repoUrl('/actions/caches'));
    +      expect(parse(finalListRes)).toEqual({ total_count: 0, actions_caches: [] });
    +    });
    +
    +    it('should list, get, and download workflow artifacts from CI artifact records', async () => {
    +      const artifactBytes = Buffer.from('artifact archive bytes\n', 'utf-8');
    +      const digest = `sha256:${createHash('sha256').update(artifactBytes).digest('hex')}`;
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'artifact-ci', headBranch: 'artifact-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'success', branch: 'artifact-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const { record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data            : { output: { title: 'Package', summary: 'Created artifact.' } },
    +        tags            : { name: 'package', status: 'completed', conclusion: 'success' },
    +        parentContextId : suiteRec!.contextId ?? '',
    +      } as any);
    +      expect(runRec).toBeDefined();
    +
    +      const { record: artifactRec } = await ctx.ci.records.create('repo/checkSuite/checkRun/artifact' as any, {
    +        data            : new Uint8Array(artifactBytes),
    +        dataFormat      : 'application/zip',
    +        tags            : { name: 'build-output', size: artifactBytes.byteLength, contentType: 'application/zip', digest },
    +        parentContextId : runRec!.contextId ?? '',
    +      } as any);
    +      expect(artifactRec).toBeDefined();
    +
    +      const artifactId = numericId(artifactRec!.id);
    +      const suiteId = numericId(suiteRec!.id);
    +      const listRes = await handleShimRequest(ctx, repoUrl('/actions/artifacts?name=build-output'));
    +      expect(listRes.status).toBe(200);
    +      const list = parse(listRes);
    +      expect(list.total_count).toBe(1);
    +      expect(list.artifacts[0].id).toBe(artifactId);
    +      expect(list.artifacts[0].name).toBe('build-output');
    +      expect(list.artifacts[0].size_in_bytes).toBe(artifactBytes.byteLength);
    +      expect(list.artifacts[0].digest).toBe(digest);
    +      expect(list.artifacts[0].workflow_run.id).toBe(suiteId);
    +      expect(list.artifacts[0].archive_download_url).toContain(`/actions/artifacts/${artifactId}/zip`);
    +
    +      const runArtifactsRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/artifacts?direction=asc`));
    +      expect(runArtifactsRes.status).toBe(200);
    +      const runArtifacts = parse(runArtifactsRes);
    +      expect(runArtifacts.total_count).toBe(1);
    +      expect(runArtifacts.artifacts[0].id).toBe(artifactId);
    +
    +      const detailRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${artifactId}`));
    +      expect(detailRes.status).toBe(200);
    +      expect(parse(detailRes).url).toContain(`/actions/artifacts/${artifactId}`);
    +
    +      const redirectRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${artifactId}/zip`));
    +      expect(redirectRes.status).toBe(302);
    +      expect(redirectRes.headers.Location).toContain(`/actions/artifacts/${artifactId}/zip?download=1`);
    +
    +      const downloadRes = await handleShimRequest(ctx, new URL(redirectRes.headers.Location));
    +      expect(downloadRes.status).toBe(200);
    +      expect(downloadRes.headers['Content-Type']).toBe('application/zip');
    +      expect(Buffer.from(downloadRes.body as Uint8Array).toString('utf-8')).toBe(artifactBytes.toString('utf-8'));
    +    });
    +
    +    it('should delete artifacts and reject expired or invalid artifact downloads', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'artifact-delete-ci', headBranch: 'artifact-delete-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'failure', branch: 'artifact-delete-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const { record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data            : { output: { title: 'Package', summary: 'Failed artifact.' } },
    +        tags            : { name: 'package', status: 'completed', conclusion: 'failure' },
    +        parentContextId : suiteRec!.contextId ?? '',
    +      } as any);
    +      expect(runRec).toBeDefined();
    +
    +      const { record: expiredArtifactRec } = await ctx.ci.records.create('repo/checkSuite/checkRun/artifact' as any, {
    +        data            : new Uint8Array(Buffer.from('expired archive\n', 'utf-8')),
    +        dataFormat      : 'application/zip',
    +        tags            : { name: 'expired-output', size: 16, contentType: 'application/zip', expired: true },
    +        parentContextId : runRec!.contextId ?? '',
    +      } as any);
    +      expect(expiredArtifactRec).toBeDefined();
    +
    +      const { record: deleteArtifactRec } = await ctx.ci.records.create('repo/checkSuite/checkRun/artifact' as any, {
    +        data            : new Uint8Array(Buffer.from('delete archive\n', 'utf-8')),
    +        dataFormat      : 'application/zip',
    +        tags            : { name: 'delete-output', size: 15, contentType: 'application/zip' },
    +        parentContextId : runRec!.contextId ?? '',
    +      } as any);
    +      expect(deleteArtifactRec).toBeDefined();
    +
    +      const expiredId = numericId(expiredArtifactRec!.id);
    +      const deleteId = numericId(deleteArtifactRec!.id);
    +
    +      const expiredDownloadRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${expiredId}/zip`));
    +      expect(expiredDownloadRes.status).toBe(410);
    +
    +      const invalidFormatRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${deleteId}/tar`));
    +      expect(invalidFormatRes.status).toBe(422);
    +
    +      const deleteRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${deleteId}`), 'DELETE');
    +      expect(deleteRes.status).toBe(204);
    +      expect(deleteRes.body).toBe('');
    +
    +      const missingDetailRes = await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${deleteId}`));
    +      expect(missingDetailRes.status).toBe(404);
    +
    +      const missingRunArtifactsRes = await handleShimRequest(ctx, repoUrl('/actions/runs/999999/artifacts'));
    +      expect(missingRunArtifactsRes.status).toBe(404);
    +    });
    +
    +    it('should download workflow run and job logs from CI check run output', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'logs-ci', headBranch: 'logs-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'success', branch: 'logs-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const { record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data: {
    +          output: {
    +            title   : 'Build',
    +            summary : 'Build log summary.',
    +            text    : 'checkout\nbun install\nbun test',
    +            steps   : [
    +              { name: 'Install', status: 'completed', conclusion: 'success', logs: 'bun install --frozen-lockfile' },
    +              { name: 'Test', status: 'completed', conclusion: 'success', logs: 'bun test tests/github-shim.spec.ts' },
    +            ],
    +          },
    +        },
    +        tags            : { name: 'build', status: 'completed', conclusion: 'success' },
    +        parentContextId : suiteRec!.contextId ?? '',
    +      } as any);
    +      expect(runRec).toBeDefined();
    +
    +      const suiteId = numericId(suiteRec!.id);
    +      const jobId = numericId(runRec!.id);
    +
    +      const jobRes = await handleShimRequest(ctx, repoUrl(`/actions/jobs/${jobId}`));
    +      expect(jobRes.status).toBe(200);
    +      expect(parse(jobRes).logs_url).toContain(`/actions/jobs/${jobId}/logs`);
    +
    +      const runRedirectRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/logs`));
    +      expect(runRedirectRes.status).toBe(302);
    +      expect(runRedirectRes.headers.Location).toContain(`/actions/runs/${suiteId}/logs?download=1`);
    +
    +      const runDownloadRes = await handleShimRequest(ctx, new URL(runRedirectRes.headers.Location));
    +      expect(runDownloadRes.status).toBe(200);
    +      expect(runDownloadRes.headers['Content-Type']).toBe('application/zip');
    +      const runLogBytes = Buffer.from(runDownloadRes.body as Uint8Array);
    +      expect(runLogBytes.subarray(0, 2).toString('utf-8')).toBe('PK');
    +      expect(runLogBytes.toString('utf-8')).toContain('bun test tests/github-shim.spec.ts');
    +
    +      const jobRedirectRes = await handleShimRequest(ctx, repoUrl(`/actions/jobs/${jobId}/logs`));
    +      expect(jobRedirectRes.status).toBe(302);
    +      expect(jobRedirectRes.headers.Location).toContain(`/actions/jobs/${jobId}/logs?download=1`);
    +
    +      const jobDownloadRes = await handleShimRequest(ctx, new URL(jobRedirectRes.headers.Location));
    +      expect(jobDownloadRes.status).toBe(200);
    +      expect(jobDownloadRes.headers['Content-Type']).toBe('text/plain; charset=utf-8');
    +      expect(Buffer.from(jobDownloadRes.body as Uint8Array).toString('utf-8')).toContain('bun install');
    +    });
    +
    +    it('should delete workflow run logs and return 404 for missing log targets', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'delete-logs-ci', headBranch: 'delete-logs-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'failure', branch: 'delete-logs-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const { record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data            : { output: { title: 'Failing job', text: 'secret failure log line' } },
    +        tags            : { name: 'failure', status: 'completed', conclusion: 'failure' },
    +        parentContextId : suiteRec!.contextId ?? '',
    +      } as any);
    +      expect(runRec).toBeDefined();
    +
    +      const suiteId = numericId(suiteRec!.id);
    +      const jobId = numericId(runRec!.id);
    +
    +      const deleteRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/logs`), 'DELETE');
    +      expect(deleteRes.status).toBe(204);
    +      expect(deleteRes.body).toBe('');
    +
    +      const jobDownloadRes = await handleShimRequest(ctx, repoUrl(`/actions/jobs/${jobId}/logs?download=1`));
    +      expect(jobDownloadRes.status).toBe(200);
    +      expect(Buffer.from(jobDownloadRes.body as Uint8Array).toString('utf-8')).toBe('');
    +
    +      const missingRunRes = await handleShimRequest(ctx, repoUrl('/actions/runs/999999/logs'));
    +      expect(missingRunRes.status).toBe(404);
    +
    +      const missingJobRes = await handleShimRequest(ctx, repoUrl('/actions/jobs/999999/logs'));
    +      expect(missingJobRes.status).toBe(404);
    +    });
    +
    +    it('should return workflow run attempts, attempt logs, and run usage timing', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data: {
    +          app         : 'attempt-ci',
    +          headBranch  : 'attempt-branch',
    +          startedAt   : '2026-01-01T00:00:00.000Z',
    +          completedAt : '2026-01-01T00:05:00.000Z',
    +        },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'success', branch: 'attempt-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const { record: runRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data: {
    +          startedAt   : '2026-01-01T00:01:00.000Z',
    +          completedAt : '2026-01-01T00:04:00.000Z',
    +          output      : { title: 'Attempt job', summary: 'Attempt summary.', text: 'attempt log line' },
    +        },
    +        tags            : { name: 'attempt-job', status: 'completed', conclusion: 'success' },
    +        parentContextId : suiteRec!.contextId ?? '',
    +      } as any);
    +      expect(runRec).toBeDefined();
    +
    +      const suiteId = numericId(suiteRec!.id);
    +      const jobId = numericId(runRec!.id);
    +
    +      const attemptRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/attempts/1`));
    +      expect(attemptRes.status).toBe(200);
    +      expect(parse(attemptRes).run_attempt).toBe(1);
    +
    +      const missingAttemptRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/attempts/2`));
    +      expect(missingAttemptRes.status).toBe(404);
    +
    +      const attemptLogsRedirect = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/attempts/1/logs`));
    +      expect(attemptLogsRedirect.status).toBe(302);
    +      expect(attemptLogsRedirect.headers.Location).toContain(`/actions/runs/${suiteId}/attempts/1/logs?download=1`);
    +
    +      const attemptLogsDownload = await handleShimRequest(ctx, new URL(attemptLogsRedirect.headers.Location));
    +      expect(attemptLogsDownload.status).toBe(200);
    +      expect(attemptLogsDownload.headers['Content-Type']).toBe('application/zip');
    +      expect(Buffer.from(attemptLogsDownload.body as Uint8Array).toString('utf-8')).toContain('attempt log line');
    +
    +      const timingRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/timing`));
    +      expect(timingRes.status).toBe(200);
    +      const timing = parse(timingRes);
    +      expect(timing.run_duration_ms).toBe(300000);
    +      expect(timing.billable.UBUNTU.total_ms).toBe(180000);
    +      expect(timing.billable.UBUNTU.job_runs).toEqual([{ job_id: jobId, duration_ms: 180000 }]);
    +
    +      const missingTimingRes = await handleShimRequest(ctx, repoUrl('/actions/runs/999999/timing'));
    +      expect(missingTimingRes.status).toBe(404);
    +    });
    +
    +    it('should cancel, force-cancel, and delete workflow runs', async () => {
    +      const { record: cancelSuiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'cancel-ci', headBranch: 'cancel-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'in_progress', branch: 'cancel-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(cancelSuiteRec).toBeDefined();
    +
    +      const { record: cancelRunRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data            : { output: { title: 'Cancel job', summary: 'Running.' } },
    +        tags            : { name: 'cancel-job', status: 'in_progress' },
    +        parentContextId : cancelSuiteRec!.contextId ?? '',
    +      } as any);
    +      expect(cancelRunRec).toBeDefined();
    +
    +      const cancelSuiteId = numericId(cancelSuiteRec!.id);
    +      const cancelJobId = numericId(cancelRunRec!.id);
    +      const cancelRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${cancelSuiteId}/cancel`), 'POST');
    +      expect(cancelRes.status).toBe(202);
    +      expect(cancelRes.body).toBe('');
    +
    +      const cancelledRun = parse(await handleShimRequest(ctx, repoUrl(`/actions/runs/${cancelSuiteId}`)));
    +      expect(cancelledRun.status).toBe('completed');
    +      expect(cancelledRun.conclusion).toBe('cancelled');
    +      const cancelledJob = parse(await handleShimRequest(ctx, repoUrl(`/actions/jobs/${cancelJobId}`)));
    +      expect(cancelledJob.status).toBe('completed');
    +      expect(cancelledJob.conclusion).toBe('cancelled');
    +
    +      const repeatedCancelRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${cancelSuiteId}/cancel`), 'POST');
    +      expect(repeatedCancelRes.status).toBe(409);
    +
    +      const { record: forceSuiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'force-cancel-ci', headBranch: 'force-cancel-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'failure', branch: 'force-cancel-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(forceSuiteRec).toBeDefined();
    +
    +      const forceSuiteId = numericId(forceSuiteRec!.id);
    +      const forceCancelRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${forceSuiteId}/force-cancel`), 'POST');
    +      expect(forceCancelRes.status).toBe(202);
    +      expect(parse(await handleShimRequest(ctx, repoUrl(`/actions/runs/${forceSuiteId}`))).conclusion).toBe('cancelled');
    +
    +      const { record: deleteSuiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'delete-run-ci', headBranch: 'delete-run-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'completed', conclusion: 'success', branch: 'delete-run-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(deleteSuiteRec).toBeDefined();
    +
    +      const { record: deleteRunRec } = await ctx.ci.records.create('repo/checkSuite/checkRun' as any, {
    +        data            : { output: { title: 'Delete job', summary: 'Complete.' } },
    +        tags            : { name: 'delete-job', status: 'completed', conclusion: 'success' },
    +        parentContextId : deleteSuiteRec!.contextId ?? '',
    +      } as any);
    +      expect(deleteRunRec).toBeDefined();
    +
    +      const { record: deleteArtifactRec } = await ctx.ci.records.create('repo/checkSuite/checkRun/artifact' as any, {
    +        data            : new Uint8Array(Buffer.from('delete-run artifact\n', 'utf-8')),
    +        dataFormat      : 'application/zip',
    +        tags            : { name: 'delete-run-artifact', size: 20, contentType: 'application/zip' },
    +        parentContextId : deleteRunRec!.contextId ?? '',
    +      } as any);
    +      expect(deleteArtifactRec).toBeDefined();
    +
    +      const deleteSuiteId = numericId(deleteSuiteRec!.id);
    +      const deleteArtifactId = numericId(deleteArtifactRec!.id);
    +      const deleteRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${deleteSuiteId}`), 'DELETE');
    +      expect(deleteRes.status).toBe(204);
    +      expect(deleteRes.body).toBe('');
    +      expect((await handleShimRequest(ctx, repoUrl(`/actions/runs/${deleteSuiteId}`))).status).toBe(404);
    +      expect((await handleShimRequest(ctx, repoUrl(`/actions/artifacts/${deleteArtifactId}`))).status).toBe(404);
    +      expect((await handleShimRequest(ctx, repoUrl('/actions/runs/999999/cancel'), 'POST')).status).toBe(404);
    +      expect((await handleShimRequest(ctx, repoUrl('/actions/runs/999999'), 'DELETE')).status).toBe(404);
    +    });
    +
    +    it('should list, get, and list runs for workflow definitions from CI check suites', async () => {
    +      const workflowSha = '5555555555555555555555555555555555555555';
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'workflow-ci', headBranch: 'workflow-branch' },
    +        tags            : { commitSha: workflowSha, status: 'completed', conclusion: 'success', branch: 'workflow-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const workflowId = numericId('workflow:workflow-ci');
    +      const listRes = await handleShimRequest(ctx, repoUrl('/actions/workflows?per_page=100'));
    +      expect(listRes.status).toBe(200);
    +      const list = parse(listRes);
    +      const workflow = list.workflows.find((entry: any) => entry.id === workflowId);
    +      expect(workflow).toBeDefined();
    +      expect(workflow.name).toBe('workflow-ci');
    +      expect(workflow.path).toBe('.github/workflows/workflow-ci.yml');
    +      expect(workflow.state).toBe('active');
    +      expect(workflow.url).toContain(`/actions/workflows/${workflowId}`);
    +
    +      const detailRes = await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}`));
    +      expect(detailRes.status).toBe(200);
    +      expect(parse(detailRes).name).toBe('workflow-ci');
    +
    +      const fileDetailRes = await handleShimRequest(ctx, repoUrl('/actions/workflows/workflow-ci.yml'));
    +      expect(fileDetailRes.status).toBe(200);
    +      expect(parse(fileDetailRes).id).toBe(workflowId);
    +
    +      const runsRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/workflows/${workflowId}/runs?branch=workflow-branch&status=success&head_sha=${workflowSha}`),
    +      );
    +      expect(runsRes.status).toBe(200);
    +      const runs = parse(runsRes);
    +      expect(runs.total_count).toBe(1);
    +      expect(runs.workflow_runs[0].workflow_id).toBe(workflowId);
    +      expect(runs.workflow_runs[0].head_sha).toBe(workflowSha);
    +
    +      const timingRes = await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}/timing`));
    +      expect(timingRes.status).toBe(200);
    +      expect(parse(timingRes)).toEqual({ billable: {} });
    +
    +      const missingRes = await handleShimRequest(ctx, repoUrl('/actions/workflows/999999'));
    +      expect(missingRes.status).toBe(404);
    +    });
    +
    +    it('should enable and disable workflow definitions', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'toggle-ci', headBranch: 'toggle-branch' },
    +        tags            : { commitSha: FEATURE_SHA, status: 'queued', branch: 'toggle-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const workflowId = numericId('workflow:toggle-ci');
    +      const disableRes = await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}/disable`), 'PUT');
    +      expect(disableRes.status).toBe(204);
    +      expect(disableRes.body).toBe('');
    +
    +      const disabled = parse(await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}`)));
    +      expect(disabled.state).toBe('disabled_manually');
    +
    +      const disabledDispatch = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/workflows/${workflowId}/dispatches`),
    +        'POST',
    +        { ref: 'main' },
    +      );
    +      expect(disabledDispatch.status).toBe(422);
    +
    +      const enableRes = await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}/enable`), 'PUT');
    +      expect(enableRes.status).toBe(204);
    +      expect(enableRes.body).toBe('');
    +
    +      const enabled = parse(await handleShimRequest(ctx, repoUrl(`/actions/workflows/${workflowId}`)));
    +      expect(enabled.state).toBe('active');
    +    });
    +
    +    it('should dispatch a workflow by creating a queued CI check suite', async () => {
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'dispatch-ci', headBranch: 'main' },
    +        tags            : { commitSha: MAIN_SHA, status: 'completed', conclusion: 'success', branch: 'main' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +
    +      const workflowId = numericId('workflow:dispatch-ci');
    +      const dispatchRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/workflows/${workflowId}/dispatches`),
    +        'POST',
    +        { ref: 'feature/demo', inputs: { target: 'staging' } },
    +      );
    +      expect(dispatchRes.status).toBe(200);
    +      const dispatch = parse(dispatchRes);
    +      expect(dispatch.workflow_run_id).toBeGreaterThan(0);
    +      expect(dispatch.run_url).toContain(`/actions/runs/${dispatch.workflow_run_id}`);
    +
    +      const runRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${dispatch.workflow_run_id}`));
    +      expect(runRes.status).toBe(200);
    +      const run = parse(runRes);
    +      expect(run.workflow_id).toBe(workflowId);
    +      expect(run.event).toBe('workflow_dispatch');
    +      expect(run.status).toBe('queued');
    +      expect(run.conclusion).toBeNull();
    +      expect(run.head_branch).toBe('feature/demo');
    +      expect(run.head_sha).toBe(FEATURE_SHA);
    +
    +      const missingRefRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/workflows/${workflowId}/dispatches`),
    +        'POST',
    +        { inputs: { target: 'staging' } },
    +      );
    +      expect(missingRefRes.status).toBe(422);
    +
    +      const missingWorkflowRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/actions/workflows/999999/dispatches'),
    +        'POST',
    +        { ref: 'main' },
    +      );
    +      expect(missingWorkflowRes.status).toBe(404);
    +    });
    +
    +    it('should list, filter, and get workflow runs from CI check suites', async () => {
    +      const actionSha = '4444444444444444444444444444444444444444';
    +      const { record: suiteRec } = await ctx.ci.records.create('repo/checkSuite' as any, {
    +        data            : { app: 'actions-ci', headBranch: 'actions-branch' },
    +        tags            : { commitSha: actionSha, status: 'completed', conclusion: 'success', branch: 'actions-branch' },
    +        parentContextId : repoContextId,
    +      } as any);
    +      expect(suiteRec).toBeDefined();
    +      const suiteId = numericId(suiteRec!.id);
    +
    +      const res = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/runs?branch=actions-branch&status=success&head_sha=${actionSha}&per_page=1`),
    +      );
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.total_count).toBe(1);
    +      expect(data.workflow_runs[0].id).toBe(suiteId);
    +      expect(data.workflow_runs[0].check_suite_id).toBe(suiteId);
    +      expect(data.workflow_runs[0].head_sha).toBe(actionSha);
    +      expect(data.workflow_runs[0].status).toBe('completed');
    +      expect(data.workflow_runs[0].conclusion).toBe('success');
    +      expect(data.workflow_runs[0].jobs_url).toContain(`/actions/runs/${suiteId}/jobs`);
    +      expect(data.workflow_runs[0].repository.full_name).toBe(`${testDid}/test-repo`);
    +
    +      const detailRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}`));
    +      expect(detailRes.status).toBe(200);
    +      const detail = parse(detailRes);
    +      expect(detail.id).toBe(suiteId);
    +      expect(detail.workflow_id).toBe(numericId('workflow:actions-ci'));
    +      expect(detail.check_suite_url).toContain(`/check-suites/${suiteId}`);
    +
    +      const emptyRes = await handleShimRequest(
    +        ctx,
    +        repoUrl('/actions/runs?status=cancelled&head_sha=0000000000000000000000000000000000000000'),
    +      );
    +      expect(parse(emptyRes).total_count).toBe(0);
    +
    +      const invalidSuiteRes = await handleShimRequest(ctx, repoUrl('/actions/runs?check_suite_id=not-a-number'));
    +      expect(invalidSuiteRes.status).toBe(422);
    +
    +      const invalidStatusRes = await handleShimRequest(ctx, repoUrl('/actions/runs?status=banana'));
    +      expect(invalidStatusRes.status).toBe(422);
    +    });
    +
    +    it('should list and get workflow jobs from CI check runs', async () => {
    +      const suiteId = numericId(checkSuiteRecId);
    +      const runId = numericId(checkRunRecId);
    +
    +      const listRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${suiteId}/jobs?per_page=1`));
    +      expect(listRes.status).toBe(200);
    +      const data = parse(listRes);
    +      expect(data.total_count).toBe(1);
    +      expect(data.jobs[0].id).toBe(runId);
    +      expect(data.jobs[0].run_id).toBe(suiteId);
    +      expect(data.jobs[0].name).toBe('lint');
    +      expect(data.jobs[0].status).toBe('completed');
    +      expect(data.jobs[0].conclusion).toBe('success');
    +      expect(data.jobs[0].check_run_url).toContain(`/check-runs/${runId}`);
    +      expect(data.jobs[0].steps[0].name).toBe('Lint');
    +
    +      const detailRes = await handleShimRequest(ctx, repoUrl(`/actions/jobs/${runId}`));
    +      expect(detailRes.status).toBe(200);
    +      const detail = parse(detailRes);
    +      expect(detail.id).toBe(runId);
    +      expect(detail.workflow_name).toBe('gitd-ci');
    +      expect(detail.head_branch).toBe('main');
    +
    +      const missingRunRes = await handleShimRequest(ctx, repoUrl('/actions/runs/999/jobs'));
    +      expect(missingRunRes.status).toBe(404);
    +
    +      const missingJobRes = await handleShimRequest(ctx, repoUrl('/actions/jobs/999'));
    +      expect(missingJobRes.status).toBe(404);
    +    });
    +
    +    it('should rerun workflow runs, failed jobs, and individual workflow jobs', async () => {
    +      const failedRunRes = await handleShimRequest(ctx, repoUrl('/check-runs'), 'POST', {
    +        name       : 'actions-failed-jobs',
    +        head_sha   : FEATURE_SHA,
    +        status     : 'completed',
    +        conclusion : 'failure',
    +        output     : { title: 'Actions failed jobs', summary: 'Failed before rerun.' },
    +      });
    +      expect(failedRunRes.status).toBe(201);
    +      const failedRun = parse(failedRunRes);
    +
    +      const rerunFailedRes = await handleShimRequest(
    +        ctx,
    +        repoUrl(`/actions/runs/${failedRun.check_suite.id}/rerun-failed-jobs`),
    +        'POST',
    +      );
    +      expect(rerunFailedRes.status).toBe(201);
    +      expect(rerunFailedRes.body).toBe('');
    +
    +      const rerunFailedJob = parse(await handleShimRequest(ctx, repoUrl(`/actions/jobs/${failedRun.id}`)));
    +      expect(rerunFailedJob.status).toBe('queued');
    +      expect(rerunFailedJob.conclusion).toBeNull();
    +
    +      const failedJobRes = await handleShimRequest(ctx, repoUrl('/check-runs'), 'POST', {
    +        name       : 'actions-job-rerun',
    +        head_sha   : FEATURE_SHA,
    +        status     : 'completed',
    +        conclusion : 'failure',
    +        output     : { title: 'Actions job rerun', summary: 'Failed before job rerun.' },
    +      });
    +      expect(failedJobRes.status).toBe(201);
    +      const failedJob = parse(failedJobRes);
    +
    +      const jobRerunRes = await handleShimRequest(ctx, repoUrl(`/actions/jobs/${failedJob.id}/rerun`), 'POST');
    +      expect(jobRerunRes.status).toBe(201);
    +      expect(jobRerunRes.body).toBe('');
    +
    +      const jobAfterRerun = parse(await handleShimRequest(ctx, repoUrl(`/actions/jobs/${failedJob.id}`)));
    +      expect(jobAfterRerun.status).toBe('queued');
    +      expect(jobAfterRerun.conclusion).toBeNull();
    +
    +      const workflowRerunRes = await handleShimRequest(ctx, repoUrl(`/actions/runs/${failedJob.check_suite.id}/rerun`), 'POST');
    +      expect(workflowRerunRes.status).toBe(201);
    +      expect(workflowRerunRes.body).toBe('');
    +
    +      const workflowAfterRerun = parse(await handleShimRequest(ctx, repoUrl(`/actions/runs/${failedJob.check_suite.id}`)));
    +      expect(workflowAfterRerun.status).toBe('queued');
    +      expect(workflowAfterRerun.conclusion).toBeNull();
    +
    +      const missingRunRerunRes = await handleShimRequest(ctx, repoUrl('/actions/runs/999/rerun'), 'POST');
    +      expect(missingRunRerunRes.status).toBe(404);
    +
    +      const missingJobRerunRes = await handleShimRequest(ctx, repoUrl('/actions/jobs/999/rerun'), 'POST');
    +      expect(missingJobRerunRes.status).toBe(404);
    +    });
    +  });
    +
    +  // =========================================================================
    +  // GET /repos/:did/:repo/issues
    +  // =========================================================================
    +
    +  describe('GET /repos/:did/:repo/issues', () => {
    +    it('should return open issues by default', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/issues'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(Array.isArray(data)).toBe(true);
    +      expect(data.length).toBe(1);
    +      expect(data[0].title).toBe('Fix the widget');
    +      expect(data[0].state).toBe('open');
    +    });
    +
    +    it('should filter by state=closed', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/issues?state=closed'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.length).toBe(1);
    +      expect(data[0].title).toBe('Old bug');
    +      expect(data[0].state).toBe('closed');
    +    });
    +
    +    it('should return all issues with state=all', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/issues?state=all'));
    +      expect(res.status).toBe(200);
    +      const data = parse(res);
    +      expect(data.length).toBe(2);
    +    });
    +
    +    it('should include GitHub-style issue fields', async () => {
    +      const res = await handleShimRequest(ctx, repoUrl('/issues?state=all'));
    +      const data = parse(res);
    +      const issueNum = numericId(issueRecId);
    +      const issue = data.find((i: any) => i.number === issueNum);
    +      expect(issue).toBeDefined();
    +      expect(issue.title).toBe('Fix the widget');
    +      expect(issue.body).toBe('The widget is broken.');
    +      expect(issue.user.login).toBe(testDid);
    +      expect(issue.url).toContain(`/issues/${issueNum}`);
    +      expect(issue.comments_url).toContain(`/issues/${issueNum}/comments`);
    +      expect(issue.labels).toEqual([]);
    +      expect(issue.assignee).toBeNull();
    +      expect(issue.assignees).toEqual([]);
    +      expect(issue.milestone.title).toBe(MILESTONE_TITLE);
    +      expect(issue.milestone.number).toBe(githubMilestoneNumber(MILESTONE_TITLE));
    +      expect(issue.locked).toBe(false);
    +      expect(issue.active_lock_reason).toBeNull();
    +    });
    +
    +    it('should honor GitHub issue body media types', async () => {
    +      const issueNum = numericId(issueRecId);
    +      const htmlRes = await handleShimRequest(ctx, repoUrl('/issues?state=all'), 'GET', {}, null, {
    +        accept: 'application/vnd.github.html+json',
    +      });
    +      expect(htmlRes.status).toBe(200);
    +      const htmlIssue = parse(htmlRes).find((issue: any) => issue.number === issueNum);
    +      expect(htmlIssue.body).toBeUndefined();
    +      expect(htmlIssue.body_text).toBeUndefined();
    +      expect(htmlIssue.body_html).toBe('

    The widget is broken.

    '); + + const fullRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'GET', {}, null, { + accept: 'application/vnd.github.full+json', + }); + expect(fullRes.status).toBe(200); + const fullIssue = parse(fullRes); + expect(fullIssue.body).toBe('The widget is broken.'); + expect(fullIssue.body_text).toBe('The widget is broken.'); + expect(fullIssue.body_html).toBe('

    The widget is broken.

    '); + }); + + it('should set closed_at for closed issues', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues?state=closed')); + const data = parse(res); + expect(data[0].closed_at).toBeDefined(); + expect(data[0].closed_at).not.toBeNull(); + }); + + it('should set closed_at to null for open issues', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues')); + const data = parse(res); + expect(data[0].closed_at).toBeNull(); + }); + + it('should support pagination with per_page', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues?state=all&per_page=1')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.length).toBe(1); + }); + + it('should include Link header for paginated results', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues?state=all&per_page=1')); + expect(res.headers['Link']).toBeDefined(); + expect(res.headers['Link']).toContain('rel="next"'); + expect(res.headers['Link']).toContain('rel="last"'); + }); + }); + + // ========================================================================= + // GET /issues, /user/issues, and /orgs/:org/issues + // ========================================================================= + + describe('top-level issue inbox endpoints', () => { + it('should list assigned and user account issues across visible repositories', async () => { + const inboxRepoName = 'inbox-issues-repo'; + const createRepoRes = await handleShimRequest(ctx, url('/user/repos'), 'POST', { + name : inboxRepoName, + description : 'Issue inbox fixture repository', + visibility : 'public', + }, null, shimOptions()); + expect(createRepoRes.status).toBe(201); + + const inboxRepoUrl = (subPath: string): URL => url(`/repos/${testDid}/${inboxRepoName}${subPath}`); + const createRes = await handleShimRequest(ctx, inboxRepoUrl('/issues'), 'POST', { + title : 'Inbox assigned issue', + body : `Mentioning ${testDid} for inbox filters.`, + labels : [{ name: 'inbox-label', color: '5319e7' }], + assignees : [testDid], + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const assignedRes = await handleShimRequest(ctx, url('/issues?labels=inbox-label')); + expect(assignedRes.status).toBe(200); + const assigned = parse(assignedRes); + expect(assigned.map((issue: any) => issue.number)).toContain(created.number); + expect(assigned[0].repository.full_name).toBe(`${testDid}/${inboxRepoName}`); + expect(assigned[0].assignees.map((user: any) => user.login)).toContain(testDid); + + const userCreatedRes = await handleShimRequest( + ctx, + url('/user/issues?filter=created&state=all&labels=inbox-label'), + ); + expect(userCreatedRes.status).toBe(200); + expect(parse(userCreatedRes).map((issue: any) => issue.number)).toEqual([created.number]); + + const mentionedRes = await handleShimRequest( + ctx, + url('/user/issues?filter=mentioned&state=all&labels=inbox-label'), + ); + expect(mentionedRes.status).toBe(200); + expect(parse(mentionedRes).map((issue: any) => issue.number)).toEqual([created.number]); + + const pagedRes = await handleShimRequest(ctx, url('/user/issues?filter=all&state=all&per_page=1')); + expect(pagedRes.status).toBe(200); + expect(pagedRes.headers.Link).toContain('rel="next"'); + + const invalidFilterRes = await handleShimRequest(ctx, url('/issues?filter=banana')); + expect(invalidFilterRes.status).toBe(422); + }); + + it('should list organization issues for existing organization routes', async () => { + const orgRes = await handleShimRequest(ctx, orgUrl('/issues?filter=all&state=all&labels=inbox-label')); + expect(orgRes.status).toBe(200); + const data = parse(orgRes); + expect(data.length).toBe(1); + expect(data[0].title).toBe('Inbox assigned issue'); + expect(data[0].repository.full_name).toBe(`${testDid}/inbox-issues-repo`); + + const missingOrgRes = await handleShimRequest(ctx, url('/orgs/missing-org/issues')); + expect(missingOrgRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/issues/:number + // ========================================================================= + + describe('GET /repos/:did/:repo/issues/:number', () => { + it('should return issue detail', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.number).toBe(issueNum); + expect(data.title).toBe('Fix the widget'); + expect(data.body).toBe('The widget is broken.'); + expect(data.state).toBe('open'); + }); + + it('should include comment count', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); + const data = parse(res); + expect(data.comments).toBe(2); + }); + + it('should return 404 for non-existent issue', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999')); + expect(res.status).toBe(404); + const data = parse(res); + expect(data.message).toContain('not found'); + }); + + it('should include documentation_url in 404 response', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999')); + const data = parse(res); + expect(data.documentation_url).toBeDefined(); + }); + }); + + // ========================================================================= + // PUT/DELETE /repos/:did/:repo/issues/:number/lock + // ========================================================================= + + describe('issue lock endpoints', () => { + it('should lock and unlock issue conversations', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Lockable issue', + }); + expect(createRes.status).toBe(201); + const issue = parse(createRes); + + const lockRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/lock`), 'PUT', { + lock_reason: 'too heated', + }); + expect(lockRes.status).toBe(204); + + const lockedRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`)); + expect(lockedRes.status).toBe(200); + const locked = parse(lockedRes); + expect(locked.locked).toBe(true); + expect(locked.active_lock_reason).toBe('too heated'); + + const unlockRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/lock`), 'DELETE'); + expect(unlockRes.status).toBe(204); + + const unlockedRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`)); + expect(unlockedRes.status).toBe(200); + const unlocked = parse(unlockedRes); + expect(unlocked.locked).toBe(false); + expect(unlocked.active_lock_reason).toBeNull(); + }); + + it('should reject invalid issue lock reasons and missing issues', async () => { + const issueNum = numericId(issueRecId); + const invalidRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/lock`), 'PUT', { + lock_reason: 'duplicate', + }); + expect(invalidRes.status).toBe(422); + expect(parse(invalidRes).message).toContain('lock_reason'); + + const missingLockRes = await handleShimRequest(ctx, repoUrl('/issues/999/lock'), 'PUT', { + lock_reason: 'spam', + }); + expect(missingLockRes.status).toBe(404); + + const missingUnlockRes = await handleShimRequest(ctx, repoUrl('/issues/999/lock'), 'DELETE'); + expect(missingUnlockRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/milestones + // ========================================================================= + + describe('milestone endpoints', () => { + it('should list repository milestones with issue counts and pagination', async () => { + const res = await handleShimRequest(ctx, repoUrl('/milestones')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].title).toBe(MILESTONE_TITLE); + expect(data[0].number).toBe(githubMilestoneNumber(MILESTONE_TITLE)); + expect(data[0].state).toBe('open'); + expect(data[0].open_issues).toBe(1); + expect(data[0].closed_issues).toBe(1); + expect(data[0].labels_url).toContain(`/milestones/${githubMilestoneNumber(MILESTONE_TITLE)}/labels`); + + const pagedRes = await handleShimRequest(ctx, repoUrl('/milestones?state=all&per_page=1')); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes)).toHaveLength(1); + }); + + it('should return a single milestone by number', async () => { + const number = githubMilestoneNumber(MILESTONE_TITLE); + const res = await handleShimRequest(ctx, repoUrl(`/milestones/${number}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.title).toBe(MILESTONE_TITLE); + expect(data.url).toContain(`/milestones/${number}`); + expect(data.creator.login).toBe(testDid); + expect(data.due_on).toBeNull(); + }); + + it('should list labels for issues in a milestone', async () => { + const number = githubMilestoneNumber(MILESTONE_TITLE); + const res = await handleShimRequest(ctx, repoUrl(`/milestones/${number}/labels`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.map((label: any) => label.name)).toEqual(['bug', 'enhancement']); + }); + + it('should create, update, and delete repository milestones', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/milestones'), 'POST', { + title : 'v2.0', + description : 'Tracking milestone for version 2.0', + due_on : '2012-10-09T23:39:01Z', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.title).toBe('v2.0'); + expect(created.state).toBe('open'); + expect(created.description).toBe('Tracking milestone for version 2.0'); + expect(created.due_on).toBe('2012-10-09T23:39:01.000Z'); + expect(created.open_issues).toBe(0); + expect(created.closed_issues).toBe(0); + + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Issue with managed milestone', + milestone : created.number, + }); + expect(issueRes.status).toBe(201); + const issue = parse(issueRes); + expect(issue.milestone.title).toBe('v2.0'); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/milestones/${created.number}`), 'PATCH', { + title : 'v2.1', + state : 'closed', + description : null, + due_on : null, + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.number).toBe(created.number); + expect(updated.title).toBe('v2.1'); + expect(updated.state).toBe('closed'); + expect(updated.description).toBeNull(); + expect(updated.due_on).toBeNull(); + expect(updated.closed_at).not.toBeNull(); + expect(updated.open_issues).toBe(1); + + const issueAfterRenameRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`)); + expect(issueAfterRenameRes.status).toBe(200); + const issueAfterRename = parse(issueAfterRenameRes); + expect(issueAfterRename.milestone.number).toBe(created.number); + expect(issueAfterRename.milestone.title).toBe('v2.1'); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/milestones/${created.number}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/milestones/${created.number}`)); + expect(missingRes.status).toBe(404); + + const issueAfterDeleteRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`)); + expect(issueAfterDeleteRes.status).toBe(200); + expect(parse(issueAfterDeleteRes).milestone).toBeNull(); + }); + + it('should reject duplicate or invalid milestone payloads', async () => { + const duplicateRes = await handleShimRequest(ctx, repoUrl('/milestones'), 'POST', { + title: MILESTONE_TITLE, + }); + expect(duplicateRes.status).toBe(422); + + const invalidDueDateRes = await handleShimRequest(ctx, repoUrl('/milestones'), 'POST', { + title : 'Bad due date', + due_on : 'not a date', + }); + expect(invalidDueDateRes.status).toBe(422); + + const invalidStateRes = await handleShimRequest( + ctx, repoUrl(`/milestones/${githubMilestoneNumber(MILESTONE_TITLE)}`), 'PATCH', { state: 'done' }, + ); + expect(invalidStateRes.status).toBe(422); + + const missingUpdateRes = await handleShimRequest(ctx, repoUrl('/milestones/999'), 'PATCH', { + title: 'Still missing', + }); + expect(missingUpdateRes.status).toBe(404); + }); + + it('should return 404 for missing milestones', async () => { + const detailRes = await handleShimRequest(ctx, repoUrl('/milestones/999')); + expect(detailRes.status).toBe(404); + + const labelsRes = await handleShimRequest(ctx, repoUrl('/milestones/999/labels')); + expect(labelsRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET/POST/DELETE /repos/:did/:repo/issues/:number/reactions + // ========================================================================= + + describe('issue reaction endpoints', () => { + it('should create, list, filter, and delete issue reactions', async () => { + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Issue that will receive reactions', + }); + expect(issueRes.status).toBe(201); + const issue = parse(issueRes); + expect(issue.reactions.url).toContain(`/issues/${issue.number}/reactions`); + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const createHeartRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`), 'POST', { + content: 'heart', + }); + expect(createHeartRes.status).toBe(201); + const heart = parse(createHeartRes); + expect(heart.content).toBe('heart'); + expect(heart.user.login).toBe(testDid); + + const createRocketRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`), 'POST', { + content: 'rocket', + }); + expect(createRocketRes.status).toBe(201); + + const filteredRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions?content=heart`)); + expect(filteredRes.status).toBe(200); + expect(parse(filteredRes).map((reaction: any) => reaction.content)).toEqual(['heart']); + + const detailRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`)); + const detail = parse(detailRes); + expect(detail.reactions.total_count).toBe(2); + expect(detail.reactions.heart).toBe(1); + expect(detail.reactions.rocket).toBe(1); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${issue.number}/reactions/${heart.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(204); + + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions?content=heart`)); + expect(parse(afterDeleteRes)).toEqual([]); + }); + + it('should return existing issue reactions on duplicate creates', async () => { + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Duplicate issue reaction target', + }); + const issue = parse(issueRes); + + const firstRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`), 'POST', { + content: '+1', + }); + expect(firstRes.status).toBe(201); + const first = parse(firstRes); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`), 'POST', { + content: '+1', + }); + expect(duplicateRes.status).toBe(200); + const duplicate = parse(duplicateRes); + expect(duplicate.id).toBe(first.id); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`)); + expect(parse(listRes).length).toBe(1); + }); + + it('should reject invalid issue reaction content', async () => { + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Invalid issue reaction target', + }); + const issue = parse(issueRes); + + const createRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions`), 'POST', { + content: 'shipit', + }); + expect(createRes.status).toBe(422); + expect(parse(createRes).message).toContain('content'); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/reactions?content=shipit`)); + expect(listRes.status).toBe(422); + expect(parse(listRes).message).toContain('content'); + }); + + it('should return 404 for missing issue reactions', async () => { + const missingIssueRes = await handleShimRequest(ctx, repoUrl('/issues/999/reactions')); + expect(missingIssueRes.status).toBe(404); + + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Missing issue reaction target', + }); + const issue = parse(issueRes); + + const missingReactionRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${issue.number}/reactions/999`), + 'DELETE', + ); + expect(missingReactionRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/issues/:number/comments + // ========================================================================= + + describe('GET /repos/:did/:repo/issues/:number/comments', () => { + it('should return issue comments', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(2); + }); + + it('should include GitHub-style comment fields', async () => { + const issueNum = numericId(issueRecId); const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`)); + const data = parse(res); + expect(data[0].body).toBe('I can reproduce this.'); + expect(data[0].user.login).toBe(testDid); + expect(data[0].created_at).toBeDefined(); + expect(data[0].author_association).toBe('OWNER'); + expect(data[0].issue_url).toContain(`/issues/${issueNum}`); + }); + + it('should honor GitHub issue comment body media types', async () => { + const issueNum = numericId(issueRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: '**Bold** and `code`.', + }, null, { + accept: 'application/vnd.github.full+json', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.body).toBe('**Bold** and `code`.'); + expect(created.body_text).toBe('Bold and code.'); + expect(created.body_html).toBe('

    Bold and code.

    '); + + const listTextRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'GET', {}, null, { + accept: 'application/vnd.github.text+json', + }); + expect(listTextRes.status).toBe(200); + const listed = parse(listTextRes).find((comment: any) => comment.id === created.id); + expect(listed.body).toBeUndefined(); + expect(listed.body_text).toBe('Bold and code.'); + expect(listed.body_html).toBeUndefined(); + + const getHtmlRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'GET', {}, null, { + accept: 'application/vnd.github.v3.html+json', + }); + expect(getHtmlRes.status).toBe(200); + const html = parse(getHtmlRes); + expect(html.body).toBeUndefined(); + expect(html.body_html).toBe('

    Bold and code.

    '); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'PATCH', { + body: 'Updated **comment**.', + }, null, { + accept: 'application/vnd.github.full+json', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBe('Updated **comment**.'); + expect(updated.body_text).toBe('Updated comment.'); + expect(updated.body_html).toBe('

    Updated comment.

    '); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + }); + + it('should return 404 for non-existent issue comments', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999/comments')); + expect(res.status).toBe(404); + }); + + it('should support pagination', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments?per_page=1`)); + const data = parse(res); + expect(data.length).toBe(1); + expect(res.headers['Link']).toBeDefined(); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/issues/:number/timeline + // ========================================================================= + + describe('GET /repos/:did/:repo/issues/:number/timeline', () => { + it('should return commented timeline entries with pagination', async () => { + const issueNum = numericId(issueRecId); + const pagedRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/timeline?per_page=1`)); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes)).toHaveLength(1); + expect(pagedRes.headers['Link']).toContain('rel="next"'); + + const issueRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); + expect(parse(issueRes).timeline_url).toContain(`/issues/${issueNum}/timeline`); + + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/timeline`)); + expect(res.status).toBe(200); + const data = parse(res); + const comment = data.find((entry: any) => entry.body === 'I can reproduce this.'); + expect(comment).toBeDefined(); + expect(comment.event).toBe('commented'); + expect(comment.actor.login).toBe(testDid); + expect(comment.user.login).toBe(testDid); + expect(comment.issue_url).toContain(`/issues/${issueNum}`); + expect(comment.performed_via_github_app).toBeNull(); + expect(comment.reactions.total_count).toBe(0); + }); + + it('should include issue status events and return 404 for missing issues', async () => { + const issueNum = numericId(closedIssueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/timeline`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.map((entry: any) => entry.event)).toEqual(['closed', 'reopened', 'closed']); + + const seeded = data.find((entry: any) => entry.id === numericId(issueEventRecId)); + expect(seeded).toBeDefined(); + expect(seeded.reason).toBe('Fixed before import'); + expect(seeded.actor.login).toBe(testDid); + expect(seeded.issue).toBeUndefined(); + expect(seeded.performed_via_github_app).toBeNull(); + + const missingRes = await handleShimRequest(ctx, repoUrl('/issues/999/timeline')); + expect(missingRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET/PATCH/DELETE /repos/:did/:repo/issues/comments{/:id} + // ========================================================================= + + describe('repository issue comment endpoints', () => { + it('should list issue comments across the repository', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/comments')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.length).toBeGreaterThanOrEqual(2); + expect(data.map((comment: any) => comment.body)).toContain('I can reproduce this.'); + expect(data[0].url).toContain('/issues/comments/'); + }); + + it('should get, update, and delete an issue comment by ID', async () => { + const issueNum = numericId(issueRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Temporary comment for ID routes.', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const getRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).body).toBe('Temporary comment for ID routes.'); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'PATCH', { + body: 'Updated temporary comment.', + }); + expect(updateRes.status).toBe(200); + expect(parse(updateRes).body).toBe('Updated temporary comment.'); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`)); + expect(missingRes.status).toBe(404); + }); + + it('should pin and unpin an issue comment', async () => { + const issueNum = numericId(issueRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Pinned comment fixture.', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.pin).toBeNull(); + + const pinRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}/pin`), 'PUT'); + expect(pinRes.status).toBe(200); + const pinned = parse(pinRes); + expect(pinned.pin.pinned_at).toBeDefined(); + expect(pinned.pin.pinned_by.login).toBe(testDid); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`), 'PATCH', { + body: 'Pinned comment fixture, edited.', + }); + expect(updateRes.status).toBe(200); + expect(parse(updateRes).pin.pinned_by.login).toBe(testDid); + + const unpinRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}/pin`), 'DELETE'); + expect(unpinRes.status).toBe(204); + expect(unpinRes.body).toBe(''); + + const getRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${created.id}`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).pin).toBeNull(); + + const missingRes = await handleShimRequest(ctx, repoUrl('/issues/comments/999999/pin'), 'PUT'); + expect(missingRes.status).toBe(404); + }); + + it('should reject issue comment updates without a body', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/comments/123'), 'PATCH', {}); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('body'); + }); + + it('should return 404 for missing issue comments', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/comments/999')); + expect(res.status).toBe(404); + }); + + it('should create, list, filter, and delete issue comment reactions', async () => { + const issueNum = numericId(issueRecId); + const commentRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Comment that will receive reactions.', + }); + expect(commentRes.status).toBe(201); + const comment = parse(commentRes); + expect(comment.reactions.url).toContain(`/issues/comments/${comment.id}/reactions`); + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const createHeartRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(createHeartRes.status).toBe(201); + const heart = parse(createHeartRes); + expect(heart.content).toBe('heart'); + expect(heart.user.login).toBe(testDid); + + const createEyesRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`), 'POST', { + content: 'eyes', + }); + expect(createEyesRes.status).toBe(201); + + const filteredRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions?content=heart`)); + expect(filteredRes.status).toBe(200); + expect(parse(filteredRes).map((reaction: any) => reaction.content)).toEqual(['heart']); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`)); + expect(listRes.status).toBe(200); + expect(parse(listRes).map((reaction: any) => reaction.content).sort()).toEqual(['eyes', 'heart']); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/issues/comments/${comment.id}/reactions/${heart.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(204); + + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions?content=heart`)); + expect(parse(afterDeleteRes)).toEqual([]); + }); + + it('should return existing issue comment reactions on duplicate creates', async () => { + const issueNum = numericId(issueRecId); + const commentRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Duplicate reaction target.', + }); + const comment = parse(commentRes); + + const firstRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`), 'POST', { + content: '+1', + }); + expect(firstRes.status).toBe(201); + const first = parse(firstRes); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`), 'POST', { + content: '+1', + }); + expect(duplicateRes.status).toBe(200); + const duplicate = parse(duplicateRes); + expect(duplicate.id).toBe(first.id); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`)); + expect(parse(listRes).length).toBe(1); + }); + + it('should reject invalid issue comment reaction content', async () => { + const issueNum = numericId(issueRecId); + const commentRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Invalid reaction target.', + }); + const comment = parse(commentRes); + + const createRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions`), 'POST', { + content: 'shipit', + }); + expect(createRes.status).toBe(422); + expect(parse(createRes).message).toContain('content'); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/comments/${comment.id}/reactions?content=shipit`)); + expect(listRes.status).toBe(422); + expect(parse(listRes).message).toContain('content'); + }); + + it('should return 404 for missing issue comment reactions', async () => { + const issueNum = numericId(issueRecId); + const commentRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'Missing reaction target.', + }); + const comment = parse(commentRes); + + const missingCommentRes = await handleShimRequest(ctx, repoUrl('/issues/comments/999/reactions')); + expect(missingCommentRes.status).toBe(404); + + const missingReactionRes = await handleShimRequest( + ctx, + repoUrl(`/issues/comments/${comment.id}/reactions/999`), + 'DELETE', + ); + expect(missingReactionRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/pulls + // ========================================================================= + + describe('GET /repos/:did/:repo/pulls', () => { + it('should return open pulls by default', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls')); + expect(res.status).toBe(200); + const data = parse(res); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(data[0].title).toBe('Add feature X'); + expect(data[0].state).toBe('open'); + expect(data[0].merged).toBe(false); + }); + + it('should include merged pulls in state=closed filter', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls?state=closed')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.length).toBe(1); + expect(data[0].title).toBe('Fix typo'); + expect(data[0].state).toBe('closed'); + expect(data[0].merged).toBe(true); + }); + + it('should return all pulls with state=all', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls?state=all')); + const data = parse(res); + expect(data.length).toBe(2); + }); + + it('should include GitHub-style pull request fields', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl('/pulls')); + const data = parse(res); + const pr = data[0]; + expect(pr.number).toBe(patchNum); + expect(pr.head.ref).toBe('feat-x'); + expect(pr.base.ref).toBe('main'); + expect(pr.user.login).toBe(testDid); + expect(pr.draft).toBe(false); + expect(pr.diff_url).toContain(`/pulls/${patchNum}.diff`); + expect(pr.patch_url).toContain(`/pulls/${patchNum}.patch`); + }); + + it('should filter pull requests by head and base branches', async () => { + const { record } = await ctx.patches.records.create('repo/patch', { + data : { title: 'Filtered branch PR', body: 'Uses list filters.' }, + tags : { status: 'open', baseBranch: 'release/v1', headBranch: 'filter-head', sourceDid: MAINTAINER_DID }, + parentContextId : repoContextId, + }); + expect(record).toBeDefined(); + const pullNumber = numericId(record!.id); + + const encodedHead = encodeURIComponent(`${MAINTAINER_DID}:filter-head`); + const encodedBase = encodeURIComponent('release/v1'); + + const headRes = await handleShimRequest(ctx, repoUrl(`/pulls?head=${encodedHead}`)); + expect(headRes.status).toBe(200); + const headMatches = parse(headRes); + expect(headMatches.some((pull: any) => pull.number === pullNumber)).toBe(true); + expect(headMatches.every((pull: any) => pull.head.label === `${MAINTAINER_DID}:filter-head`)).toBe(true); + + const bareHeadRes = await handleShimRequest(ctx, repoUrl('/pulls?head=filter-head')); + expect(bareHeadRes.status).toBe(200); + expect(parse(bareHeadRes).some((pull: any) => pull.number === pullNumber)).toBe(true); + + const baseRes = await handleShimRequest(ctx, repoUrl(`/pulls?base=${encodedBase}`)); + expect(baseRes.status).toBe(200); + const baseMatches = parse(baseRes); + expect(baseMatches.some((pull: any) => pull.number === pullNumber)).toBe(true); + expect(baseMatches.every((pull: any) => pull.base.ref === 'release/v1')).toBe(true); + + const combinedRes = await handleShimRequest(ctx, repoUrl(`/pulls?head=${encodedHead}&base=${encodedBase}`)); + expect(combinedRes.status).toBe(200); + expect(parse(combinedRes).map((pull: any) => pull.number)).toEqual([pullNumber]); + + const missRes = await handleShimRequest(ctx, repoUrl(`/pulls?head=${encodeURIComponent(`${MAINTAINER_DID}:other`)}`)); + expect(missRes.status).toBe(200); + expect(parse(missRes)).toEqual([]); + }); + + it('should sort pull requests by created and updated time', async () => { + const firstRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'Sort PR one', + base : 'sort-target', + head : 'sort-one', + }); + expect(firstRes.status).toBe(201); + const first = parse(firstRes); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const secondRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'Sort PR two', + base : 'sort-target', + head : 'sort-two', + }); + expect(secondRes.status).toBe(201); + const second = parse(secondRes); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/pulls/${first.number}`), 'PATCH', { + title: 'Sort PR one updated', + }); + expect(updateRes.status).toBe(200); + + const createdAscRes = await handleShimRequest(ctx, repoUrl('/pulls?base=sort-target&sort=created&direction=asc')); + expect(createdAscRes.status).toBe(200); + expect(parse(createdAscRes).map((pull: any) => pull.number)).toEqual([first.number, second.number]); + + const createdDescRes = await handleShimRequest(ctx, repoUrl('/pulls?base=sort-target&sort=created&direction=desc')); + expect(createdDescRes.status).toBe(200); + expect(parse(createdDescRes).map((pull: any) => pull.number)).toEqual([second.number, first.number]); + + const updatedDescRes = await handleShimRequest(ctx, repoUrl('/pulls?base=sort-target&sort=updated&direction=desc')); + expect(updatedDescRes.status).toBe(200); + expect(parse(updatedDescRes).map((pull: any) => pull.number)).toEqual([first.number, second.number]); + }); + + it('should honor GitHub pull request body media types', async () => { + const patchNum = numericId(patchRecId); + const htmlRes = await handleShimRequest(ctx, repoUrl('/pulls?state=all'), 'GET', {}, null, { + accept: 'application/vnd.github.html+json', + }); + expect(htmlRes.status).toBe(200); + const htmlPull = parse(htmlRes).find((pull: any) => pull.number === patchNum); + expect(htmlPull.body).toBeUndefined(); + expect(htmlPull.body_text).toBeUndefined(); + expect(htmlPull.body_html).toBe('

    Implements feature X.

    '); + + const fullRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`), 'GET', {}, null, { + accept: 'application/vnd.github.full+json', + }); + expect(fullRes.status).toBe(200); + const fullPull = parse(fullRes); + expect(fullPull.body).toBe('Implements feature X.'); + expect(fullPull.body_text).toBe('Implements feature X.'); + expect(fullPull.body_html).toBe('

    Implements feature X.

    '); + }); + + it('should populate commit and diff stats from revision record', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`)); + const pr = parse(res); + expect(pr.head.sha).toBe('abc1234567890abcdef1234567890abcdef12345'); + expect(pr.base.sha).toBe('def0987654321fedcba0987654321fedcba09876'); + expect(pr.commits).toBe(3); + expect(pr.additions).toBe(42); + expect(pr.deletions).toBe(7); + expect(pr.changed_files).toBe(5); + }); + + it('should set merged_at and merge_commit_sha for merged pulls', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls?state=closed')); + const data = parse(res); + const merged = data.find((p: any) => p.merged === true); + expect(merged).toBeDefined(); + expect(merged.merged_at).toBeDefined(); + expect(merged.merged_at).not.toBeNull(); + expect(merged.merge_commit_sha).toBe('ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00'); + }); + + it('should set merged_at to null for open pulls', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls')); + const data = parse(res); + expect(data[0].merged_at).toBeNull(); + expect(data[0].merged).toBe(false); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/pulls/:number + // ========================================================================= + + describe('GET /repos/:did/:repo/pulls/:number', () => { + it('should return pull request detail', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.number).toBe(patchNum); + expect(data.title).toBe('Add feature X'); + expect(data.body).toBe('Implements feature X.'); + }); + + it('should return merged pull detail with correct flags', async () => { + const mergedNum = numericId(mergedPatchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${mergedNum}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.number).toBe(mergedNum); + expect(data.state).toBe('closed'); + expect(data.merged).toBe(true); + }); + + it('should return 404 for non-existent pull', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/999')); + expect(res.status).toBe(404); + const data = parse(res); + expect(data.message).toContain('not found'); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/pulls/:number/commits + // ========================================================================= + + describe('GET /repos/:did/:repo/pulls/:number/commits', () => { + it('should return revision-backed commit metadata for imported pulls', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/commits`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].sha).toBe('abc1234567890abcdef1234567890abcdef12345'); + expect(data[0].commit.message).toBe('v1: 3 commits'); + expect(data[0].commit.verification.reason).toBe('unsigned'); + expect(data[0].parents[0].sha).toBe('def0987654321fedcba0987654321fedcba09876'); + expect(data[0].url).toContain(`/commits/${data[0].sha}`); + }); + + it('should list real local git commits for pulls with stored commit ranges', async () => { + const { record: localPatch } = await ctx.patches.records.create('repo/patch', { + data : { title: 'Local git PR', body: 'Uses local git commit history.' }, + tags : { status: 'open', baseBranch: 'main', headBranch: 'local-git-pr', sourceDid: testDid }, + parentContextId : repoContextId, + }); + expect(localPatch).toBeDefined(); + + await ctx.patches.records.create('repo/patch/revision' as any, { + data: { + description : 'local git commit range', + diffStat : { additions: 1, deletions: 0, filesChanged: 1 }, + }, + tags: { + headCommit : gitMainSha, + baseCommit : gitBaseSha, + commitCount : 1, + }, + parentContextId: localPatch!.contextId ?? '', + } as any); + + const pullNum = numericId(localPatch!.id); + const res = await handleShimRequest( + ctx, repoUrl(`/pulls/${pullNum}/commits?per_page=1`), 'GET', {}, null, shimOptions(), + ); + expect(res.status).toBe(200); + const data = parse(res); + expect(data).toHaveLength(1); + expect(data[0].sha).toBe(gitMainSha); + expect(data[0].commit.message).toBe('Add feature file'); + expect(data[0].commit.tree.sha).toBe(gitTreeSha); + expect(data[0].parents.map((parent: any) => parent.sha)).toContain(gitBaseSha); + + const pullRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pullNum}`), 'GET', {}, null, shimOptions()); + const pull = parse(pullRes); + + const diffRes = await handleShimRequest(ctx, new URL(pull.diff_url), 'GET', {}, null, shimOptions()); + expect(diffRes.status).toBe(200); + expect(diffRes.headers['Content-Type']).toBe('application/vnd.github.diff; charset=utf-8'); + expect(bodyBuffer(diffRes).toString('utf-8')).toContain('diff --git a/src/feature.ts b/src/feature.ts'); + + const patchRes = await handleShimRequest(ctx, new URL(pull.patch_url), 'GET', {}, null, shimOptions()); + expect(patchRes.status).toBe(200); + expect(patchRes.headers['Content-Type']).toBe('application/vnd.github.patch; charset=utf-8'); + expect(bodyBuffer(patchRes).toString('utf-8')).toContain('Add feature file'); + + const acceptDiffRes = await handleShimRequest( + ctx, + repoUrl(`/pulls/${pullNum}`), + 'GET', + {}, + null, + { ...shimOptions(), accept: 'application/vnd.github.diff' }, + ); + expect(acceptDiffRes.status).toBe(200); + expect(acceptDiffRes.headers['Content-Type']).toBe('application/vnd.github.diff; charset=utf-8'); + expect(bodyBuffer(acceptDiffRes).toString('utf-8')).toContain('diff --git a/src/feature.ts b/src/feature.ts'); + + const acceptPatchRes = await handleShimRequest( + ctx, + repoUrl(`/pulls/${pullNum}`), + 'GET', + {}, + null, + { ...shimOptions(), accept: 'application/vnd.github.v3.patch' }, + ); + expect(acceptPatchRes.status).toBe(200); + expect(acceptPatchRes.headers['Content-Type']).toBe('application/vnd.github.patch; charset=utf-8'); + expect(bodyBuffer(acceptPatchRes).toString('utf-8')).toContain('Add feature file'); + }); + + it('should return 404 for missing pull request commits', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/999/commits')); + expect(res.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/pulls/:number/reviews + // ========================================================================= + + describe('GET /repos/:did/:repo/pulls/:number/reviews', () => { + it('should return pull request reviews', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(2); + }); + + it('should map verdict to GitHub review state', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + const data = parse(res); + const approved = data.find((r: any) => r.state === 'APPROVED'); + expect(approved).toBeDefined(); + expect(approved.body).toBe('Looks good to me.'); + + const commented = data.find((r: any) => r.state === 'COMMENTED'); + expect(commented).toBeDefined(); + expect(commented.body).toBe('One nit.'); + }); + + it('should include GitHub-style review fields', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + const data = parse(res); + expect(data[0].user.login).toBe(testDid); + expect(data[0].submitted_at).toBeDefined(); + expect(data[0].author_association).toBe('OWNER'); + expect(data[0].pull_request_url).toContain(`/pulls/${patchNum}`); + }); + + it('should honor GitHub pull request review body media types', async () => { + const patchNum = numericId(patchRecId); + const reviewId = numericId(reviewRecId); + const listHtmlRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.html+json', + }); + expect(listHtmlRes.status).toBe(200); + const approved = parse(listHtmlRes).find((review: any) => review.id === reviewId); + expect(approved.body).toBeUndefined(); + expect(approved.body_text).toBeUndefined(); + expect(approved.body_html).toBe('

    Looks good to me.

    '); + + const getFullRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(getFullRes.status).toBe(200); + const full = parse(getFullRes); + expect(full.body).toBe('Looks good to me.'); + expect(full.body_text).toBe('Looks good to me.'); + expect(full.body_html).toBe('

    Looks good to me.

    '); + }); + + it('should return a pull request review by ID', async () => { + const patchNum = numericId(patchRecId); + const reviewId = numericId(reviewRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.id).toBe(reviewId); + expect(data.body).toBe('Looks good to me.'); + expect(data.state).toBe('APPROVED'); + expect(data.commit_id).toBe('abc1234567890abcdef1234567890abcdef12345'); + expect(data._links.pull_request.href).toContain(`/pulls/${patchNum}`); + }); + + it('should update a pull request review body', async () => { + const patchNum = numericId(patchRecId); + const reviewId = numericId(reviewRecId); + const updateRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}`), 'PATCH', { + body: 'Updated review summary.', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.id).toBe(reviewId); + expect(updated.body).toBe('Updated review summary.'); + expect(updated.state).toBe('APPROVED'); + + const getRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}`)); + expect(parse(getRes).body).toBe('Updated review summary.'); + + const invalidRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}`), 'PATCH', {}); + expect(invalidRes.status).toBe(422); + }); + + it('should update a pull request review with the documented PUT method', async () => { + const createPullRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for PUT review update', + head : 'put-review-update', + }); + expect(createPullRes.status).toBe(201); + const pr = parse(createPullRes); + + const createReviewRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body : 'Initial review body.', + event : 'COMMENT', + }); + expect(createReviewRes.status).toBe(201); + const review = parse(createReviewRes); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${review.id}`), 'PUT', { + body: 'Updated **review**.', + }, null, { + accept: 'application/vnd.github-commitcomment.text+json', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBeUndefined(); + expect(updated.body_text).toBe('Updated review.'); + expect(updated.body_html).toBeUndefined(); + }); + + it('should list comments for a pull request review', async () => { + const patchNum = numericId(patchRecId); + const reviewId = numericId(reviewRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/${reviewId}/comments`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.length).toBe(1); + expect(data[0].id).toBe(numericId(reviewCommentRecId)); + expect(data[0].pull_request_review_id).toBe(reviewId); + expect(data[0].body).toBe('Please keep this helper small.'); + expect(data[0].path).toBe('src/widget.ts'); + expect(data[0].line).toBe(12); + }); + + it('should return 404 for non-existent pull review detail routes', async () => { + const patchNum = numericId(patchRecId); + const detailRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/999`)); + expect(detailRes.status).toBe(404); + + const commentsRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews/999/comments`)); + expect(commentsRes.status).toBe(404); + }); + + it('should return 404 for non-existent pull reviews', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/999/reviews')); + expect(res.status).toBe(404); + }); + + it('should support pagination', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews?per_page=1`)); + const data = parse(res); + expect(data.length).toBe(1); + expect(res.headers['Link']).toBeDefined(); + }); + }); + + // ========================================================================= + // GET/POST /repos/:did/:repo/pulls/:number/comments + // ========================================================================= + + describe('pull request review comment endpoints', () => { + it('should list pull request review comments across the repository', async () => { + const patchNum = numericId(patchRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Repository-level review comment.', + path : 'src/repo-list.ts', + line : 8, + side : 'RIGHT', + diff_hunk : '@@ -4 +8 @@', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const pagedRes = await handleShimRequest(ctx, repoUrl('/pulls/comments?per_page=1')); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes).length).toBe(1); + expect(pagedRes.headers['Link']).toBeDefined(); + + const res = await handleShimRequest(ctx, repoUrl('/pulls/comments')); + expect(res.status).toBe(200); + const data = parse(res); + const seeded = data.find((comment: any) => comment.id === numericId(reviewCommentRecId)); + const extra = data.find((comment: any) => comment.id === created.id); + expect(seeded).toBeDefined(); + expect(seeded.pull_request_url).toContain(`/pulls/${patchNum}`); + expect(extra).toBeDefined(); + expect(extra.path).toBe('src/repo-list.ts'); + expect(extra.pull_request_review_id).toBe(created.pull_request_review_id); + }); + + it('should sort and filter repository pull request review comments', async () => { + const sortedRes = await handleShimRequest(ctx, repoUrl('/pulls/comments?sort=created&direction=desc')); + expect(sortedRes.status).toBe(200); + const sorted = parse(sortedRes); + expect(sorted.length).toBeGreaterThanOrEqual(2); + for (let i = 1; i < sorted.length; i++) { + expect(Date.parse(sorted[i - 1].created_at)).toBeGreaterThanOrEqual(Date.parse(sorted[i].created_at)); + } + + const futureRes = await handleShimRequest(ctx, repoUrl('/pulls/comments?since=2999-01-01T00:00:00Z')); + expect(futureRes.status).toBe(200); + expect(parse(futureRes)).toEqual([]); + }); + + it('should list pull request review comments', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.length).toBeGreaterThanOrEqual(1); + const seeded = data.find((comment: any) => comment.id === numericId(reviewCommentRecId)); + expect(seeded.pull_request_review_id).toBe(numericId(reviewRecId)); + expect(seeded.path).toBe('src/widget.ts'); + expect(seeded.line).toBe(12); + expect(seeded.side).toBe('RIGHT'); + expect(seeded.body).toBe('Please keep this helper small.'); + }); + + it('should return a pull request review comment by ID', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${numericId(reviewCommentRecId)}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.id).toBe(numericId(reviewCommentRecId)); + expect(data.diff_hunk).toBe('@@ -1 +1 @@'); + expect(data.pull_request_url).toContain(`/pulls/${numericId(patchRecId)}`); + expect(data._links.pull_request.href).toContain(`/pulls/${numericId(patchRecId)}`); + }); + + it('should update and delete a pull request review comment by ID', async () => { + const patchNum = numericId(patchRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Temporary review comment.', + path : 'src/widget.ts', + line : 20, + side : 'RIGHT', + diff_hunk : '@@ -18 +20 @@', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`), 'PATCH', { + body: 'Updated temporary review comment.', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBe('Updated temporary review comment.'); + expect(updated.path).toBe('src/widget.ts'); + expect(updated.line).toBe(20); + expect(updated.diff_hunk).toBe('@@ -18 +20 @@'); + + const getRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`)); + expect(parse(getRes).body).toBe('Updated temporary review comment.'); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`)); + expect(missingRes.status).toBe(404); + }); + + it('should honor GitHub pull review comment body media types', async () => { + const patchNum = numericId(patchRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : '**Review** and `code`.', + path : 'src/widget.ts', + line : 22, + side : 'RIGHT', + diff_hunk : '@@ -20 +22 @@', + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.body).toBe('**Review** and `code`.'); + expect(created.body_text).toBe('Review and code.'); + expect(created.body_html).toBe('

    Review and code.

    '); + + const listTextRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.text+json', + }); + expect(listTextRes.status).toBe(200); + const listed = parse(listTextRes).find((comment: any) => comment.id === created.id); + expect(listed.body).toBeUndefined(); + expect(listed.body_text).toBe('Review and code.'); + + const getHtmlRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.v3.html+json', + }); + expect(getHtmlRes.status).toBe(200); + const html = parse(getHtmlRes); + expect(html.body).toBeUndefined(); + expect(html.body_html).toBe('

    Review and code.

    '); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`), 'PATCH', { + body: 'Updated **review**.', + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBe('Updated **review**.'); + expect(updated.body_text).toBe('Updated review.'); + expect(updated.body_html).toBe('

    Updated review.

    '); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + }); + + it('should create, list, filter, de-duplicate, and delete pull request review comment reactions', async () => { + const patchNum = numericId(patchRecId); + const createCommentRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Review comment that will receive reactions.', + path : 'src/widget.ts', + line : 24, + side : 'RIGHT', + diff_hunk : '@@ -22 +24 @@', + }); + expect(createCommentRes.status).toBe(201); + const comment = parse(createCommentRes); + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const createHeartRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(createHeartRes.status).toBe(201); + const heart = parse(createHeartRes); + expect(heart.content).toBe('heart'); + expect(heart.user.login).toBe(testDid); + expect(typeof heart.created_at).toBe('string'); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(duplicateRes.status).toBe(200); + expect(parse(duplicateRes).id).toBe(heart.id); + + const createRocketRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`), 'POST', { + content: 'rocket', + }); + expect(createRocketRes.status).toBe(201); + + const filteredRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions?content=heart`)); + expect(filteredRes.status).toBe(200); + expect(parse(filteredRes).map((reaction: any) => reaction.content)).toEqual(['heart']); + + const listRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`)); + expect(listRes.status).toBe(200); + expect(parse(listRes).map((reaction: any) => reaction.content).sort()).toEqual(['heart', 'rocket']); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/pulls/comments/${comment.id}/reactions/${heart.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions?content=heart`)); + expect(afterDeleteRes.status).toBe(200); + expect(parse(afterDeleteRes)).toEqual([]); + }); + + it('should create a pull request review comment', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Please add a regression test.', + path : 'src/widget.ts', + start_line : 16, + start_side : 'RIGHT', + line : 18, + side : 'RIGHT', + diff_hunk : '@@ -10 +18 @@', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.body).toBe('Please add a regression test.'); + expect(data.path).toBe('src/widget.ts'); + expect(data.start_line).toBe(16); + expect(data.original_start_line).toBe(16); + expect(data.start_side).toBe('RIGHT'); + expect(data.line).toBe(18); + expect(data.side).toBe('RIGHT'); + expect(data.subject_type).toBe('line'); + + const listRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`)); + const comments = parse(listRes); + expect(comments.find((comment: any) => comment.body === 'Please add a regression test.')).toBeDefined(); + }); + + it('should create file-level pull request review comments', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'This whole file should be simpler.', + path : 'src/widget.ts', + subject_type : 'file', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.body).toBe('This whole file should be simpler.'); + expect(data.path).toBe('src/widget.ts'); + expect(data.subject_type).toBe('file'); + expect(data.line).toBeNull(); + expect(data.original_line).toBeNull(); + expect(data.start_line).toBeNull(); + expect(data.start_side).toBeNull(); + + const getRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${data.id}`)); + expect(parse(getRes).subject_type).toBe('file'); + }); + + it('should create pull request review comment replies', async () => { + const patchNum = numericId(patchRecId); + const createCommentRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Top-level review thread.', + path : 'src/widget.ts', + line : 28, + side : 'RIGHT', + diff_hunk : '@@ -26 +28 @@', + }); + expect(createCommentRes.status).toBe(201); + const parent = parse(createCommentRes); + + const replyRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments/${parent.id}/replies`), 'POST', { + body: 'Reply through the replies endpoint.', + }); + expect(replyRes.status).toBe(201); + const reply = parse(replyRes); + expect(reply.body).toBe('Reply through the replies endpoint.'); + expect(reply.in_reply_to_id).toBe(parent.id); + expect(reply.path).toBe('src/widget.ts'); + expect(reply.line).toBe(28); + expect(reply.pull_request_review_id).toBe(parent.pull_request_review_id); + + const inlineReplyRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Reply through in_reply_to.', + in_reply_to : parent.id, + }, null, { + accept: 'application/vnd.github-commitcomment.text+json', + }); + expect(inlineReplyRes.status).toBe(201); + const inlineReply = parse(inlineReplyRes); + expect(inlineReply.body).toBeUndefined(); + expect(inlineReply.body_text).toBe('Reply through in_reply_to.'); + expect(inlineReply.in_reply_to_id).toBe(parent.id); + + const listRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`)); + const listedReply = parse(listRes).find((comment: any) => comment.id === reply.id); + expect(listedReply.in_reply_to_id).toBe(parent.id); + + const getReplyRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${reply.id}`)); + expect(parse(getReplyRes).in_reply_to_id).toBe(parent.id); + + const nestedReplyRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments/${reply.id}/replies`), 'POST', { + body: 'Nested reply.', + }); + expect(nestedReplyRes.status).toBe(422); + + const missingBodyRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments/${parent.id}/replies`), 'POST', {}); + expect(missingBodyRes.status).toBe(422); + + const wrongPullRes = await handleShimRequest(ctx, repoUrl(`/pulls/999/comments/${parent.id}/replies`), 'POST', { + body: 'Wrong pull.', + }); + expect(wrongPullRes.status).toBe(404); + + const missingParentRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments/999/replies`), 'POST', { + body: 'Missing parent.', + }); + expect(missingParentRes.status).toBe(404); + }); + + it('should reject review comments without required fields', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body: 'Missing location.', + }); + expect(res.status).toBe(422); + }); + + it('should reject review comment updates without a body', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${numericId(reviewCommentRecId)}`), 'PATCH', {}); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('body'); + }); + + it('should return 404 for non-existent pull request review comments', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/999/comments')); + expect(res.status).toBe(404); + }); + + it('should validate pull request review comment reactions and missing reaction targets', async () => { + const patchNum = numericId(patchRecId); + const createCommentRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/comments`), 'POST', { + body : 'Invalid review reaction target.', + path : 'src/widget.ts', + line : 26, + side : 'RIGHT', + diff_hunk : '@@ -24 +26 @@', + }); + expect(createCommentRes.status).toBe(201); + const comment = parse(createCommentRes); + + const invalidCreateRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions`), 'POST', { + content: 'shipit', + }); + expect(invalidCreateRes.status).toBe(422); + expect(parse(invalidCreateRes).message).toContain('content'); + + const invalidFilterRes = await handleShimRequest(ctx, repoUrl(`/pulls/comments/${comment.id}/reactions?content=shipit`)); + expect(invalidFilterRes.status).toBe(422); + expect(parse(invalidFilterRes).message).toContain('content'); + + const missingCommentRes = await handleShimRequest(ctx, repoUrl('/pulls/comments/999/reactions')); + expect(missingCommentRes.status).toBe(404); + + const missingReactionRes = await handleShimRequest( + ctx, + repoUrl(`/pulls/comments/${comment.id}/reactions/999`), + 'DELETE', + ); + expect(missingReactionRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/releases + // ========================================================================= + + describe('GET /repos/:did/:repo/releases', () => { + it('should return releases', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases')); + expect(res.status).toBe(200); + const data = parse(res); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(2); + }); + + it('should include GitHub-style release fields', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases')); + const data = parse(res); + const stable = data.find((r: any) => r.tag_name === 'v1.0.0'); + expect(stable).toBeDefined(); + expect(stable.name).toBe('v1.0.0'); + expect(stable.body).toBe('Initial release.'); + expect(stable.draft).toBe(false); + expect(stable.prerelease).toBe(false); + expect(stable.immutable).toBe(false); + expect(stable.author.login).toBe(testDid); + expect(stable.assets).toHaveLength(1); + expect(stable.assets[0].name).toBe(RELEASE_ASSET_NAME); + expect(stable.assets[0].digest).toBe(RELEASE_ASSET_DIGEST); + expect(stable.tarball_url).toContain('/tarball/v1.0.0'); + }); + + it('should mark pre-releases correctly', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases')); + const data = parse(res); + const beta = data.find((r: any) => r.tag_name === 'v2.0.0-beta'); + expect(beta).toBeDefined(); + expect(beta.prerelease).toBe(true); + expect(beta.name).toBe('v2.0.0-beta'); + }); + + it('should support pagination', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases?per_page=1')); + const data = parse(res); + expect(data.length).toBe(1); + expect(res.headers['Link']).toBeDefined(); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/releases/latest + // ========================================================================= + + describe('GET /repos/:did/:repo/releases/latest', () => { + it('should return the latest published full release', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases/latest')); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.tag_name).toBe('v1.0.0'); + expect(data.prerelease).toBe(false); + expect(data.draft).toBe(false); + expect(data.assets).toHaveLength(1); + expect(data.assets[0].name).toBe(RELEASE_ASSET_NAME); + }); + + it('should return 404 when no published full release exists', async () => { + const { record: repoRec } = await ctx.repo.records.create('repo', { + data : { name: 'preonly-repo', description: 'Only prereleases', defaultBranch: 'main', dwnEndpoints: [] }, + tags : { name: 'preonly-repo', visibility: 'public' }, + }); + + await ctx.releases.records.create('repo/release' as any, { + data : { name: 'v0.1.0-beta', body: 'Beta only.' }, + tags : { tagName: 'v0.1.0-beta', prerelease: true }, + parentContextId : repoRec!.contextId ?? '', + } as any); + + const res = await handleShimRequest(ctx, url(`/repos/${testDid}/preonly-repo/releases/latest`)); + expect(res.status).toBe(404); + expect(parse(res).message).toContain('Latest release not found'); + }); + + it('should honor explicit make_latest settings', async () => { + let releaseId: number | undefined; + + try { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-make-latest-false', + name : 'Make Latest False', + make_latest : 'false', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + releaseId = created.id; + + const initialLatest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(initialLatest.tag_name).toBe('v1.0.0'); + + const pinRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + make_latest: 'true', + }); + expect(pinRes.status).toBe(200); + + const pinnedLatest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(pinnedLatest.tag_name).toBe('v-make-latest-false'); + + const unpinRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + make_latest: 'false', + }); + expect(unpinRes.status).toBe(200); + + const restoredLatest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(restoredLatest.tag_name).toBe('v1.0.0'); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should use semantic version ordering for legacy latest releases', async () => { + const releaseIds: number[] = []; + + try { + const highRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v20.0.0', + name : 'Legacy High Semver', + make_latest : 'legacy', + }); + expect(highRes.status).toBe(201); + releaseIds.push(parse(highRes).id); + + const lowRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v19.0.0', + name : 'Legacy Low Semver', + make_latest : 'legacy', + }); + expect(lowRes.status).toBe(201); + releaseIds.push(parse(lowRes).id); + + const latest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(latest.tag_name).toBe('v20.0.0'); + } finally { + for (const releaseId of releaseIds) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should reject invalid make_latest values and draft or prerelease latest pins', async () => { + const invalidValue = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-make-latest-invalid', + make_latest : 'newest', + }); + expect(invalidValue.status).toBe(422); + expect(parse(invalidValue).message).toContain('make_latest'); + + const draftPin = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-draft-latest', + draft : true, + make_latest : 'true', + }); + expect(draftPin.status).toBe(422); + expect(parse(draftPin).message).toContain('latest'); + + const prereleasePin = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-prerelease-latest', + prerelease : true, + make_latest : 'true', + }); + expect(prereleasePin.status).toBe(422); + + const invalidPatch = await handleShimRequest(ctx, repoUrl(`/releases/${numericId(releaseRecId)}`), 'PATCH', { + make_latest: 'newest', + }); + expect(invalidPatch.status).toBe(422); + expect(parse(invalidPatch).message).toContain('make_latest'); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/releases/:id and release assets + // ========================================================================= + + describe('GET /repos/:did/:repo/releases/:id and assets', () => { + it('should return release detail by numeric ID', async () => { + const releaseId = numericId(releaseRecId); + const res = await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`)); expect(res.status).toBe(200); const data = parse(res); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(2); + expect(data.id).toBe(releaseId); + expect(data.tag_name).toBe('v1.0.0'); + expect(data.assets).toHaveLength(1); + expect(data.assets[0].id).toBe(numericId(releaseAssetRecId)); }); - it('should include GitHub-style comment fields', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`)); + it('should create, list, filter, de-duplicate, and delete release reactions', async () => { + let releaseId: number | undefined; + + try { + const createReleaseRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-release-reactions', + name : 'Release Reactions', + make_latest : 'false', + }); + expect(createReleaseRes.status).toBe(201); + const release = parse(createReleaseRes); + releaseId = release.id; + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const createHeartRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(createHeartRes.status).toBe(201); + const heart = parse(createHeartRes); + expect(heart.content).toBe('heart'); + expect(heart.user.login).toBe(testDid); + expect(typeof heart.created_at).toBe('string'); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(duplicateRes.status).toBe(200); + expect(parse(duplicateRes).id).toBe(heart.id); + + const createRocketRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`), 'POST', { + content: 'rocket', + }); + expect(createRocketRes.status).toBe(201); + + const filteredRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions?content=heart`)); + expect(filteredRes.status).toBe(200); + expect(parse(filteredRes).map((reaction: any) => reaction.content)).toEqual(['heart']); + + const listRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`)); + expect(listRes.status).toBe(200); + expect(parse(listRes).map((reaction: any) => reaction.content).sort()).toEqual(['heart', 'rocket']); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/reactions/${heart.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions?content=heart`)); + expect(afterDeleteRes.status).toBe(200); + expect(parse(afterDeleteRes)).toEqual([]); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should validate release reactions and missing reaction targets', async () => { + let releaseId: number | undefined; + + try { + const createReleaseRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-invalid-release-reactions', + name : 'Invalid Release Reactions', + make_latest : 'false', + }); + expect(createReleaseRes.status).toBe(201); + const release = parse(createReleaseRes); + releaseId = release.id; + + const invalidCreateRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions`), 'POST', { + content: '-1', + }); + expect(invalidCreateRes.status).toBe(422); + expect(parse(invalidCreateRes).message).toContain('content'); + + const invalidFilterRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/reactions?content=confused`)); + expect(invalidFilterRes.status).toBe(422); + expect(parse(invalidFilterRes).message).toContain('content'); + + const missingReleaseRes = await handleShimRequest(ctx, repoUrl('/releases/999/reactions')); + expect(missingReleaseRes.status).toBe(404); + + const missingReactionRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/reactions/999`), + 'DELETE', + ); + expect(missingReactionRes.status).toBe(404); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should reflect repository immutable release settings in release responses', async () => { + const releaseId = numericId(releaseRecId); + let createdReleaseId: number | undefined; + + try { + const enableRes = await handleShimRequest(ctx, repoUrl('/immutable-releases'), 'PUT'); + expect(enableRes.status).toBe(204); + + const releases = parse(await handleShimRequest(ctx, repoUrl('/releases'))); + const listed = releases.find((release: any) => release.tag_name === 'v1.0.0'); + expect(listed.immutable).toBe(true); + + const latest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(latest.tag_name).toBe('v1.0.0'); + expect(latest.immutable).toBe(true); + + const detail = parse(await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`))); + expect(detail.immutable).toBe(true); + + const byTag = parse(await handleShimRequest(ctx, repoUrl('/releases/tags/v1.0.0'))); + expect(byTag.immutable).toBe(true); + + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-immutable-setting-test', + name : 'Immutable Setting Test', + draft : true, + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + createdReleaseId = created.id; + expect(created.immutable).toBe(true); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + name: 'Immutable Setting Test Updated', + }); + expect(updateRes.status).toBe(200); + expect(parse(updateRes).immutable).toBe(true); + } finally { + await handleShimRequest(ctx, repoUrl('/immutable-releases'), 'DELETE'); + if (createdReleaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${createdReleaseId}`), 'DELETE'); + } + } + + const afterDisable = parse(await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`))); + expect(afterDisable.immutable).toBe(false); + }); + + it('should list release assets', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/releases/${numericId(releaseRecId)}/assets`)); + expect(res.status).toBe(200); const data = parse(res); - expect(data[0].body).toBe('I can reproduce this.'); - expect(data[0].user.login).toBe(testDid); - expect(data[0].created_at).toBeDefined(); - expect(data[0].author_association).toBe('OWNER'); - expect(data[0].issue_url).toContain(`/issues/${issueNum}`); + expect(data).toHaveLength(1); + expect(data[0].id).toBe(numericId(releaseAssetRecId)); + expect(data[0].name).toBe(RELEASE_ASSET_NAME); + expect(data[0].content_type).toBe('application/gzip'); + expect(data[0].size).toBe(RELEASE_ASSET_BYTES.byteLength); + expect(data[0].digest).toBe(RELEASE_ASSET_DIGEST); + expect(data[0].browser_download_url).toContain(`/releases/download/v1.0.0/${RELEASE_ASSET_NAME}`); }); - it('should return 404 for non-existent issue comments', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/999/comments')); + it('should paginate release assets', async () => { + const releaseId = numericId(releaseRecId); + const createdAssetIds: number[] = []; + const assetBytes = Buffer.from('pagination asset bytes\n', 'utf-8'); + const firstName = `paged-${releaseId}-1.zip`; + const secondName = `paged-${releaseId}-2.zip`; + + try { + for (const name of [firstName, secondName]) { + const uploadRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${releaseId}/assets?name=${name}`), + 'POST', + {}, + null, + { rawBody: assetBytes, contentType: 'application/zip' }, + ); + expect(uploadRes.status).toBe(201); + createdAssetIds.push(parse(uploadRes).id); + } + + const firstPageRes = await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}/assets?per_page=1`)); + expect(firstPageRes.status).toBe(200); + expect(parse(firstPageRes)).toHaveLength(1); + expect(firstPageRes.headers.Link).toContain('rel="next"'); + expect(firstPageRes.headers.Link).toContain('page=3&per_page=1'); + + const secondPageRes = await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}/assets?page=2&per_page=1`)); + expect(secondPageRes.status).toBe(200); + const secondPage = parse(secondPageRes); + expect(secondPage).toHaveLength(1); + expect(secondPage[0].name).toBe(firstName); + expect(secondPageRes.headers.Link).toContain('rel="prev"'); + expect(secondPageRes.headers.Link).toContain('rel="next"'); + + const thirdPageRes = await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}/assets?page=3&per_page=1`)); + expect(thirdPageRes.status).toBe(200); + const thirdPage = parse(thirdPageRes); + expect(thirdPage).toHaveLength(1); + expect(thirdPage[0].name).toBe(secondName); + expect(thirdPageRes.headers.Link).toContain('rel="first"'); + expect(thirdPageRes.headers.Link).toContain('rel="prev"'); + } finally { + for (const assetId of createdAssetIds) { + await handleShimRequest(ctx, repoUrl(`/releases/assets/${assetId}`), 'DELETE'); + } + } + }); + + it('should return release asset metadata by asset ID', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/releases/assets/${numericId(releaseAssetRecId)}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.name).toBe(RELEASE_ASSET_NAME); + expect(data.state).toBe('uploaded'); + expect(data.digest).toBe(RELEASE_ASSET_DIGEST); + expect(data.uploader.login).toBe(testDid); + }); + + it('should download release asset bytes from the asset endpoint with octet-stream accept', async () => { + const res = await handleShimRequest( + ctx, + repoUrl(`/releases/assets/${numericId(releaseAssetRecId)}`), + 'GET', + {}, + null, + { accept: 'application/octet-stream' }, + ); + expect(res.status).toBe(200); + expect(res.headers['Content-Type']).toBe('application/gzip'); + expect(res.headers['Content-Length']).toBe(String(RELEASE_ASSET_BYTES.byteLength)); + expect(res.headers['Content-Disposition']).toBe(`attachment; filename="${RELEASE_ASSET_NAME}"`); + expect(res.headers['X-GitHub-Asset-Url']).toContain(`/releases/assets/${numericId(releaseAssetRecId)}`); + expect(bodyBuffer(res).toString('utf-8')).toBe(RELEASE_ASSET_BYTES.toString('utf-8')); + }); + + it('should download release asset bytes by asset ID', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/releases/assets/${numericId(releaseAssetRecId)}/download`)); + expect(res.status).toBe(200); + expect(res.headers['Content-Type']).toBe('application/gzip'); + expect(Buffer.from(res.body as Uint8Array).toString('utf-8')).toBe(RELEASE_ASSET_BYTES.toString('utf-8')); + }); + + it('should download release asset bytes by tag and filename', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/releases/download/v1.0.0/${RELEASE_ASSET_NAME}`)); + expect(res.status).toBe(200); + expect(res.headers['Content-Type']).toBe('application/gzip'); + expect(Buffer.from(res.body as Uint8Array).toString('utf-8')).toBe(RELEASE_ASSET_BYTES.toString('utf-8')); + }); + + it('should return 404 for missing release assets', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases/assets/999/download')); expect(res.status).toBe(404); }); + }); - it('should support pagination', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments?per_page=1`)); + // ========================================================================= + // GET /repos/:did/:repo/releases/tags/:tag + // ========================================================================= + + describe('GET /repos/:did/:repo/releases/tags/:tag', () => { + it('should return release by tag name', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases/tags/v1.0.0')); + expect(res.status).toBe(200); const data = parse(res); - expect(data.length).toBe(1); - expect(res.headers['Link']).toBeDefined(); + expect(data.tag_name).toBe('v1.0.0'); + expect(data.name).toBe('v1.0.0'); + expect(data.body).toBe('Initial release.'); + }); + + it('should return release by URL-encoded tag name', async () => { + const tagName = 'release/channel/v1'; + let releaseId: number | undefined; + + try { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : tagName, + name : 'Encoded Tag Release', + make_latest : 'false', + }); + expect(createRes.status).toBe(201); + releaseId = parse(createRes).id; + + const res = await handleShimRequest(ctx, repoUrl(`/releases/tags/${encodeURIComponent(tagName)}`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.tag_name).toBe(tagName); + expect(data.name).toBe('Encoded Tag Release'); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should return 404 for non-existent tag', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases/tags/v99.0.0')); + expect(res.status).toBe(404); + const data = parse(res); + expect(data.message).toContain('not found'); }); }); // ========================================================================= - // GET /repos/:did/:repo/pulls + // GET /users/:did // ========================================================================= - describe('GET /repos/:did/:repo/pulls', () => { - it('should return open pulls by default', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls')); + describe('GET /users/:did', () => { + it('should return user profile', async () => { + const res = await handleShimRequest(ctx, url(`/users/${testDid}`)); expect(res.status).toBe(200); const data = parse(res); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(1); - expect(data[0].title).toBe('Add feature X'); - expect(data[0].state).toBe('open'); - expect(data[0].merged).toBe(false); + expect(data.login).toBe(testDid); + expect(data.id).toBe(numericId(testDid)); + expect(data.type).toBe('User'); }); - it('should include merged pulls in state=closed filter', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls?state=closed')); + it('should include GitHub-style user fields', async () => { + const res = await handleShimRequest(ctx, url(`/users/${testDid}`)); + const data = parse(res); + expect(data.repos_url).toContain(`/users/${testDid}/repos`); + expect(data.site_admin).toBe(false); + expect(data.public_repos).toBe(0); + expect(data.followers).toBe(0); + expect(data.following).toBe(0); + }); + }); + + // ========================================================================= + // 404 handling + // ========================================================================= + + describe('unknown routes', () => { + it('should return 404 for unknown paths', async () => { + const res = await handleShimRequest(ctx, url('/nonexistent')); + expect(res.status).toBe(404); + const data = parse(res); + expect(data.message).toBeDefined(); + }); + + it('should return 404 for unknown sub-paths under a repo', async () => { + const res = await handleShimRequest(ctx, repoUrl('/nonexistent')); + expect(res.status).toBe(404); + }); + + it('should return 404 for non-numeric issue IDs', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/abc')); + expect(res.status).toBe(404); + }); + + it('should return 404 for non-numeric pull IDs', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/abc')); + expect(res.status).toBe(404); + }); + }); + + // ========================================================================= + // URL building + // ========================================================================= + + describe('URL building', () => { + it('should include base URL in all response URLs', async () => { + const res = await handleShimRequest(ctx, repoUrl('')); + const data = parse(res); + expect(data.url).toContain(BASE); + expect(data.owner.url).toContain(BASE); + }); + + it('should build correct issues_url template', async () => { + const res = await handleShimRequest(ctx, repoUrl('')); + const data = parse(res); + expect(data.issues_url).toContain('{/number}'); + }); + + it('should build correct pulls_url template', async () => { + const res = await handleShimRequest(ctx, repoUrl('')); + const data = parse(res); + expect(data.pulls_url).toContain('{/number}'); + }); + }); + + // ========================================================================= + // POST /repos/:did/:repo/issues — create issue + // ========================================================================= + + describe('POST /repos/:did/:repo/issues', () => { + it('should create an issue and return 201', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'New shim issue', + body : 'Created via API shim.', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.title).toBe('New shim issue'); + expect(data.body).toBe('Created via API shim.'); + expect(data.state).toBe('open'); + expect(typeof data.number).toBe('number'); + expect(data.number).toBeGreaterThan(0); + expect(data.user.login).toBe(testDid); + }); + + it('should assign a numericId derived from record ID', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Another shim issue', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(typeof data.number).toBe('number'); + expect(data.number).toBeGreaterThan(0); + }); + + it('should create an issue with assignees', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Assigned shim issue', + assignees : [MAINTAINER_DID], + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.assignee.login).toBe(MAINTAINER_DID); + expect(data.assignees.map((user: any) => user.login)).toEqual([MAINTAINER_DID]); + }); + + it('should reject issue creation with more than ten assignees', async () => { + const assignees = Array.from({ length: 11 }, (_, index) => `did:jwk:create-assignee-${index}`); + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Too many assignees', + assignees, + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('10 assignees'); + }); + + it('should create an issue with labels', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Labeled shim issue', + labels : [{ name: 'bug', color: 'd73a4a', description: 'Bug reports' }], + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.labels.map((label: any) => label.name)).toEqual(['bug']); + }); + + it('should create an issue with a milestone number', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Milestoned shim issue', + milestone : githubMilestoneNumber(MILESTONE_TITLE), + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.milestone.title).toBe(MILESTONE_TITLE); + expect(data.milestone.number).toBe(githubMilestoneNumber(MILESTONE_TITLE)); + }); + + it('should return 422 when title is missing', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + body: 'Missing title.', + }); + expect(res.status).toBe(422); + const data = parse(res); + expect(data.message).toContain('title'); + }); + + it('should return 404 for non-existent repo DID', async () => { + const res = await handleShimRequest(ctx, url('/repos/did:jwk:nonexistent/missing-repo/issues'), 'POST', { + title: 'Should fail', + }); + expect([404, 502]).toContain(res.status); + }); + }); + + // ========================================================================= + // PATCH /repos/:did/:repo/issues/:number — update issue + // ========================================================================= + + describe('PATCH /repos/:did/:repo/issues/:number', () => { + it('should update the title of an issue', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { + title: 'Fix the widget (updated)', + }); expect(res.status).toBe(200); const data = parse(res); - expect(data.length).toBe(1); - expect(data[0].title).toBe('Fix typo'); - expect(data[0].state).toBe('closed'); - expect(data[0].merged).toBe(true); + expect(data.title).toBe('Fix the widget (updated)'); + expect(data.number).toBe(issueNum); }); - it('should return all pulls with state=all', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls?state=all')); + it('should close an issue by setting state=closed', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { + state: 'closed', + }); + expect(res.status).toBe(200); const data = parse(res); - expect(data.length).toBe(2); + expect(data.state).toBe('closed'); + }); + + it('should reopen an issue by setting state=open', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { + state: 'open', + }); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.state).toBe('open'); }); - it('should include GitHub-style pull request fields', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl('/pulls')); + it('should replace issue assignees', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Assignment replacement target', + assignees : [MAINTAINER_DID], + }); + const created = parse(createRes); + + const res = await handleShimRequest(ctx, repoUrl(`/issues/${created.number}`), 'PATCH', { + assignees: [TRIAGER_DID], + }); + expect(res.status).toBe(200); const data = parse(res); - const pr = data[0]; - expect(pr.number).toBe(patchNum); - expect(pr.head.ref).toBe('feat-x'); - expect(pr.base.ref).toBe('main'); - expect(pr.user.login).toBe(testDid); - expect(pr.draft).toBe(false); - expect(pr.diff_url).toContain(`/pulls/${patchNum}.diff`); - expect(pr.patch_url).toContain(`/pulls/${patchNum}.patch`); + expect(data.assignee.login).toBe(TRIAGER_DID); + expect(data.assignees.map((user: any) => user.login)).toEqual([TRIAGER_DID]); }); - it('should populate commit and diff stats from revision record', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`)); - const pr = parse(res); - expect(pr.head.sha).toBe('abc1234567890abcdef1234567890abcdef123456'); - expect(pr.base.sha).toBe('def0987654321fedcba0987654321fedcba098765'); - expect(pr.commits).toBe(3); - expect(pr.additions).toBe(42); - expect(pr.deletions).toBe(7); - expect(pr.changed_files).toBe(5); + it('should reject issue updates with more than ten assignees', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Assignee overflow update target', + }); + const created = parse(createRes); + const assignees = Array.from({ length: 11 }, (_, index) => `did:jwk:update-assignee-${index}`); + + const res = await handleShimRequest(ctx, repoUrl(`/issues/${created.number}`), 'PATCH', { + title: 'Should not update over limit', + assignees, + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('10 assignees'); + + const afterRes = await handleShimRequest(ctx, repoUrl(`/issues/${created.number}`)); + const after = parse(afterRes); + expect(after.title).toBe('Assignee overflow update target'); + expect(after.assignees).toEqual([]); }); - it('should set merged_at and merge_commit_sha for merged pulls', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls?state=closed')); - const data = parse(res); - const merged = data.find((p: any) => p.merged === true); - expect(merged).toBeDefined(); - expect(merged.merged_at).toBeDefined(); - expect(merged.merged_at).not.toBeNull(); - expect(merged.merge_commit_sha).toBe('ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00'); + it('should set and clear issue milestones', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Milestone update target', + }); + const created = parse(createRes); + expect(created.milestone).toBeNull(); + + const setRes = await handleShimRequest(ctx, repoUrl(`/issues/${created.number}`), 'PATCH', { + milestone: githubMilestoneNumber(MILESTONE_TITLE), + }); + expect(setRes.status).toBe(200); + const setData = parse(setRes); + expect(setData.milestone.title).toBe(MILESTONE_TITLE); + + const clearRes = await handleShimRequest(ctx, repoUrl(`/issues/${created.number}`), 'PATCH', { + milestone: null, + }); + expect(clearRes.status).toBe(200); + expect(parse(clearRes).milestone).toBeNull(); }); - it('should set merged_at to null for open pulls', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls')); - const data = parse(res); - expect(data[0].merged_at).toBeNull(); - expect(data[0].merged).toBe(false); + it('should return 404 for non-existent issue number', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999'), 'PATCH', { + title: 'Nope', + }); + expect(res.status).toBe(404); }); }); // ========================================================================= - // GET /repos/:did/:repo/pulls/:number + // POST /repos/:did/:repo/issues/:number/comments — create comment // ========================================================================= - describe('GET /repos/:did/:repo/pulls/:number', () => { - it('should return pull request detail', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`)); - expect(res.status).toBe(200); + describe('POST /repos/:did/:repo/issues/:number/comments', () => { + it('should create a comment and return 201', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { + body: 'New comment via shim.', + }); + expect(res.status).toBe(201); const data = parse(res); - expect(data.number).toBe(patchNum); - expect(data.title).toBe('Add feature X'); - expect(data.body).toBe('Implements feature X.'); + expect(data.body).toBe('New comment via shim.'); + expect(data.user.login).toBe(testDid); + expect(data.issue_url).toContain(`/issues/${issueNum}`); + expect(data.author_association).toBe('OWNER'); }); - it('should return merged pull detail with correct flags', async () => { - const mergedNum = numericId(mergedPatchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${mergedNum}`)); - expect(res.status).toBe(200); + it('should return 422 when body is missing', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', {}); + expect(res.status).toBe(422); const data = parse(res); - expect(data.number).toBe(mergedNum); - expect(data.state).toBe('closed'); - expect(data.merged).toBe(true); + expect(data.message).toContain('body'); }); - it('should return 404 for non-existent pull', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls/999')); + it('should return 404 for non-existent issue', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999/comments'), 'POST', { + body: 'Should fail.', + }); expect(res.status).toBe(404); - const data = parse(res); - expect(data.message).toContain('not found'); }); }); // ========================================================================= - // GET /repos/:did/:repo/pulls/:number/reviews + // GET /repos/:did/:repo/issues/events // ========================================================================= - describe('GET /repos/:did/:repo/pulls/:number/reviews', () => { - it('should return pull request reviews', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + describe('issue event endpoints', () => { + it('should list issue events across the repository with pagination', async () => { + const pagedRes = await handleShimRequest(ctx, repoUrl('/issues/events?per_page=1')); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes)).toHaveLength(1); + expect(pagedRes.headers['Link']).toContain('rel="next"'); + + const res = await handleShimRequest(ctx, repoUrl('/issues/events')); expect(res.status).toBe(200); const data = parse(res); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(2); + const seeded = data.find((event: any) => event.id === numericId(issueEventRecId)); + expect(seeded).toBeDefined(); + expect(seeded.event).toBe('closed'); + expect(seeded.reason).toBe('Fixed before import'); + expect(seeded.actor.login).toBe(testDid); + expect(seeded.issue.number).toBe(numericId(closedIssueRecId)); + expect(seeded.issue.events_url).toContain(`/issues/${numericId(closedIssueRecId)}/events`); }); - it('should map verdict to GitHub review state', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + it('should return a single issue event by ID', async () => { + const res = await handleShimRequest(ctx, repoUrl(`/issues/events/${numericId(issueEventRecId)}`)); + expect(res.status).toBe(200); const data = parse(res); - const approved = data.find((r: any) => r.state === 'APPROVED'); - expect(approved).toBeDefined(); - expect(approved.body).toBe('Looks good to me.'); - - const commented = data.find((r: any) => r.state === 'COMMENTED'); - expect(commented).toBeDefined(); - expect(commented.body).toBe('One nit.'); + expect(data.id).toBe(numericId(issueEventRecId)); + expect(data.url).toContain(`/issues/events/${numericId(issueEventRecId)}`); + expect(data.event).toBe('closed'); + expect(data.commit_id).toBeNull(); + expect(data.commit_url).toBeNull(); }); - it('should include GitHub-style review fields', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews`)); + it('should list events for a single issue', async () => { + const issueNum = numericId(closedIssueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/events`)); + expect(res.status).toBe(200); const data = parse(res); - expect(data[0].user.login).toBe(testDid); - expect(data[0].submitted_at).toBeDefined(); - expect(data[0].author_association).toBe('OWNER'); - expect(data[0].pull_request_url).toContain(`/pulls/${patchNum}`); + expect(data.map((event: any) => event.event)).toEqual(['closed', 'reopened', 'closed']); + expect(data.every((event: any) => event.issue.number === issueNum)).toBe(true); }); - it('should return 404 for non-existent pull reviews', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls/999/reviews')); - expect(res.status).toBe(404); + it('should record issue state changes made through the shim', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Eventful issue', + }); + expect(createRes.status).toBe(201); + const issue = parse(createRes); + + const closeRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`), 'PATCH', { + state : 'closed', + state_reason : 'completed', + }); + expect(closeRes.status).toBe(200); + + const reopenRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}`), 'PATCH', { + state: 'open', + }); + expect(reopenRes.status).toBe(200); + + const eventsRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/events`)); + expect(eventsRes.status).toBe(200); + const events = parse(eventsRes); + expect(events.map((event: any) => event.event)).toEqual(['closed', 'reopened']); + expect(events[0].reason).toBe('completed'); }); - it('should support pagination', async () => { - const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/reviews?per_page=1`)); - const data = parse(res); - expect(data.length).toBe(1); - expect(res.headers['Link']).toBeDefined(); + it('should return 404 for missing issue events and missing issue routes', async () => { + const missingEventRes = await handleShimRequest(ctx, repoUrl('/issues/events/999')); + expect(missingEventRes.status).toBe(404); + + const missingIssueRes = await handleShimRequest(ctx, repoUrl('/issues/999/events')); + expect(missingIssueRes.status).toBe(404); }); }); // ========================================================================= - // GET /repos/:did/:repo/releases + // GET /repos/:did/:repo/labels // ========================================================================= - describe('GET /repos/:did/:repo/releases', () => { - it('should return releases', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases')); + describe('repository label endpoints', () => { + it('should list repository labels with pagination', async () => { + const res = await handleShimRequest(ctx, repoUrl('/labels')); expect(res.status).toBe(200); const data = parse(res); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(2); + expect(data.map((label: any) => label.name)).toEqual(['bug', 'enhancement']); + expect(data[0].color).toBe('d73a4a'); + expect(data[0].description).toBe('Bug reports'); + expect(data[0].url).toContain('/labels/bug'); + + const pagedRes = await handleShimRequest(ctx, repoUrl('/labels?per_page=1')); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes)).toHaveLength(1); + expect(pagedRes.headers['Link']).toContain('rel="next"'); }); - it('should include GitHub-style release fields', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases')); + it('should return a repository label by name', async () => { + const res = await handleShimRequest(ctx, repoUrl('/labels/enhancement')); + expect(res.status).toBe(200); const data = parse(res); - const stable = data.find((r: any) => r.tag_name === 'v1.0.0'); - expect(stable).toBeDefined(); - expect(stable.name).toBe('v1.0.0'); - expect(stable.body).toBe('Initial release.'); - expect(stable.draft).toBe(false); - expect(stable.prerelease).toBe(false); - expect(stable.author.login).toBe(testDid); - expect(stable.assets).toEqual([]); - expect(stable.tarball_url).toContain('/tarball/v1.0.0'); + expect(data.name).toBe('enhancement'); + expect(data.color).toBe('a2eeef'); + expect(data.description).toBe('New feature or request'); }); - it('should mark pre-releases correctly', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases')); - const data = parse(res); - const beta = data.find((r: any) => r.tag_name === 'v2.0.0-beta'); - expect(beta).toBeDefined(); - expect(beta.prerelease).toBe(true); - expect(beta.name).toBe('v2.0.0-beta'); + it('should create, update, and delete repository labels', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/labels'), 'POST', { + name : 'ops', + color : '#5319e7', + description : 'Operations work', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.name).toBe('ops'); + expect(created.color).toBe('5319e7'); + expect(created.description).toBe('Operations work'); + + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Issue with managed repo label', + }); + expect(issueRes.status).toBe(201); + const issue = parse(issueRes); + + const addLabelRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/labels`), 'POST', { + labels: [{ name: 'ops', color: '5319e7', description: 'Operations work' }], + }); + expect(addLabelRes.status).toBe(200); + expect(parse(addLabelRes).map((label: any) => label.name)).toEqual(['ops']); + + const updateRes = await handleShimRequest(ctx, repoUrl('/labels/ops'), 'PATCH', { + new_name : 'operations', + color : '0052cc', + description : null, + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.name).toBe('operations'); + expect(updated.color).toBe('0052cc'); + expect(updated.description).toBeNull(); + + const issueLabelsRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/labels`)); + expect(issueLabelsRes.status).toBe(200); + const issueLabels = parse(issueLabelsRes); + expect(issueLabels.map((label: any) => label.name)).toEqual(['operations']); + expect(issueLabels[0].color).toBe('0052cc'); + + const oldLabelRes = await handleShimRequest(ctx, repoUrl('/labels/ops')); + expect(oldLabelRes.status).toBe(404); + + const deleteRes = await handleShimRequest(ctx, repoUrl('/labels/operations'), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const deletedLabelRes = await handleShimRequest(ctx, repoUrl('/labels/operations')); + expect(deletedLabelRes.status).toBe(404); + + const finalIssueLabelsRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/labels`)); + expect(finalIssueLabelsRes.status).toBe(200); + expect(parse(finalIssueLabelsRes)).toEqual([]); }); - it('should support pagination', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases?per_page=1')); - const data = parse(res); - expect(data.length).toBe(1); - expect(res.headers['Link']).toBeDefined(); + it('should reject duplicate or invalid repository label payloads', async () => { + const duplicateRes = await handleShimRequest(ctx, repoUrl('/labels'), 'POST', { + name : 'bug', + color : 'd73a4a', + }); + expect(duplicateRes.status).toBe(422); + + const invalidColorRes = await handleShimRequest(ctx, repoUrl('/labels'), 'POST', { + name : 'bad-color', + color : 'nope', + }); + expect(invalidColorRes.status).toBe(422); + + const missingUpdateRes = await handleShimRequest(ctx, repoUrl('/labels/missing'), 'PATCH', { + new_name: 'still-missing', + }); + expect(missingUpdateRes.status).toBe(404); + }); + + it('should return 404 for missing repository labels', async () => { + const res = await handleShimRequest(ctx, repoUrl('/labels/missing')); + expect(res.status).toBe(404); + expect(parse(res).message).toContain('Label \'missing\' not found'); }); }); // ========================================================================= - // GET /repos/:did/:repo/releases/tags/:tag + // GET/POST/PUT/DELETE /repos/:did/:repo/issues/:number/labels // ========================================================================= - describe('GET /repos/:did/:repo/releases/tags/:tag', () => { - it('should return release by tag name', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases/tags/v1.0.0')); + describe('issue label endpoints', () => { + it('should list labels for an issue', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`)); expect(res.status).toBe(200); const data = parse(res); - expect(data.tag_name).toBe('v1.0.0'); - expect(data.name).toBe('v1.0.0'); - expect(data.body).toBe('Initial release.'); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); }); - it('should return 404 for non-existent tag', async () => { - const res = await handleShimRequest(ctx, repoUrl('/releases/tags/v99.0.0')); + it('should add, replace, and remove issue labels', async () => { + const issueNum = numericId(issueRecId); + + const addRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`), 'POST', { + labels: [ + { name: 'bug', color: 'd73a4a' }, + 'help wanted', + ], + }); + expect(addRes.status).toBe(200); + const added = parse(addRes); + expect(added.map((label: any) => label.name).sort()).toEqual(['bug', 'help wanted']); + expect(added.find((label: any) => label.name === 'bug').color).toBe('d73a4a'); + + const issueRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); + const issue = parse(issueRes); + expect(issue.labels.map((label: any) => label.name).sort()).toEqual(['bug', 'help wanted']); + + const replaceRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`), 'PUT', { + labels: [{ name: 'triaged', color: '#0e8a16' }], + }); + expect(replaceRes.status).toBe(200); + const replaced = parse(replaceRes); + expect(replaced).toHaveLength(1); + expect(replaced[0].name).toBe('triaged'); + expect(replaced[0].color).toBe('0e8a16'); + + const removeOneRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels/triaged`), 'DELETE'); + expect(removeOneRes.status).toBe(200); + expect(parse(removeOneRes)).toHaveLength(0); + + await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`), 'POST', { + labels: ['cleanup', 'docs'], + }); + const removeAllRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`), 'DELETE'); + expect(removeAllRes.status).toBe(204); + + const finalRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`)); + expect(parse(finalRes)).toHaveLength(0); + }); + + it('should reject invalid labels payloads', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/labels`), 'POST', { + labels: 'bug', + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('labels'); + }); + + it('should return 404 for label routes on missing issues', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999/labels'), 'POST', { + labels: ['bug'], + }); expect(res.status).toBe(404); - const data = parse(res); - expect(data.message).toContain('not found'); }); }); // ========================================================================= - // GET /users/:did + // GET/POST/DELETE /repos/:did/:repo/issues/:number/assignees // ========================================================================= - describe('GET /users/:did', () => { - it('should return user profile', async () => { - const res = await handleShimRequest(ctx, url(`/users/${testDid}`)); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.login).toBe(testDid); - expect(data.id).toBe(numericId(testDid)); - expect(data.type).toBe('User'); + describe('issue assignee endpoints', () => { + it('should identify actors allowed to mutate issue metadata', async () => { + const repo = { contextId: 'assignee-permission-repo' } as any; + const makeCtx = ( + did: string, + grants: Array<{ role: string; did: string }> = [], + ): AgentContext => ({ + did, + repo: { + records: { + query: async (role: string, options: any) => { + const hasGrant = grants.some((grant) => ( + grant.role === role + && grant.did === options.filter.tags.did + && options.filter.contextId === repo.contextId + )); + return { records: hasGrant ? [{ id: `${role}:${did}` }] : [] }; + }, + }, + }, + } as unknown as AgentContext); + + const contributorDid = 'did:jwk:assignee-contributor'; + const viewerDid = 'did:jwk:assignee-viewer'; + expect(await canMutateIssueMetadata(makeCtx(testDid), testDid, repo)).toBe(true); + expect(await canMutateIssueMetadata( + makeCtx(MAINTAINER_DID, [{ role: 'repo/maintainer', did: MAINTAINER_DID }]), + testDid, + repo, + )).toBe(true); + expect(await canMutateIssueMetadata( + makeCtx(contributorDid, [{ role: 'repo/contributor', did: contributorDid }]), + testDid, + repo, + )).toBe(true); + expect(await canMutateIssueMetadata( + makeCtx(TRIAGER_DID, [{ role: 'repo/triager', did: TRIAGER_DID }]), + testDid, + repo, + )).toBe(false); + expect(await canMutateIssueMetadata( + makeCtx(viewerDid, [{ role: 'repo/viewer', did: viewerDid }]), + testDid, + repo, + )).toBe(false); }); - it('should include GitHub-style user fields', async () => { - const res = await handleShimRequest(ctx, url(`/users/${testDid}`)); - const data = parse(res); - expect(data.repos_url).toContain(`/users/${testDid}/repos`); - expect(data.site_admin).toBe(false); - expect(data.public_repos).toBe(0); - expect(data.followers).toBe(0); - expect(data.following).toBe(0); + it('should check whether a user can be assigned to a specific issue', async () => { + const issueNum = numericId(issueRecId); + + const assignableRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${issueNum}/assignees/${encodeURIComponent(MAINTAINER_DID)}`), + ); + expect(assignableRes.status).toBe(204); + expect(assignableRes.body).toBe(''); + + const missingAssigneeRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${issueNum}/assignees/${encodeURIComponent(NEW_COLLABORATOR_DID)}`), + ); + expect(missingAssigneeRes.status).toBe(404); + + const missingIssueRes = await handleShimRequest( + ctx, + repoUrl(`/issues/999/assignees/${encodeURIComponent(MAINTAINER_DID)}`), + ); + expect(missingIssueRes.status).toBe(404); }); - }); - // ========================================================================= - // 404 handling - // ========================================================================= + it('should add and remove issue assignees', async () => { + const issueNum = numericId(issueRecId); - describe('unknown routes', () => { - it('should return 404 for unknown paths', async () => { - const res = await handleShimRequest(ctx, url('/nonexistent')); - expect(res.status).toBe(404); - const data = parse(res); - expect(data.message).toBeDefined(); + const addRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/assignees`), 'POST', { + assignees: [MAINTAINER_DID, TRIAGER_DID], + }); + expect(addRes.status).toBe(201); + const added = parse(addRes); + expect(added.assignees.map((user: any) => user.login).sort()).toEqual([MAINTAINER_DID, TRIAGER_DID].sort()); + expect(added.assignee.login).toBe(MAINTAINER_DID); + + const issueRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`)); + const issue = parse(issueRes); + expect(issue.assignees.map((user: any) => user.login).sort()).toEqual([MAINTAINER_DID, TRIAGER_DID].sort()); + + const removeRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/assignees`), 'DELETE', { + assignees: [MAINTAINER_DID], + }); + expect(removeRes.status).toBe(200); + const remaining = parse(removeRes); + expect(remaining.assignees.map((user: any) => user.login)).toEqual([TRIAGER_DID]); + expect(remaining.assignee.login).toBe(TRIAGER_DID); + + const cleanupRes = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/assignees`), 'DELETE', { + assignees: [TRIAGER_DID], + }); + expect(cleanupRes.status).toBe(200); + expect(parse(cleanupRes).assignees).toEqual([]); }); - it('should return 404 for unknown sub-paths under a repo', async () => { - const res = await handleShimRequest(ctx, repoUrl('/nonexistent')); - expect(res.status).toBe(404); + it('should reject adding assignees when the issue would exceed ten assignees', async () => { + const existingAssignees = Array.from({ length: 10 }, (_, index) => `did:jwk:existing-assignee-${index}`); + const createRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title : 'Assignee overflow add target', + assignees : existingAssignees, + }); + expect(createRes.status).toBe(201); + const issue = parse(createRes); + + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/assignees`), 'POST', { + assignees: ['did:jwk:overflow-assignee'], + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('10 assignees'); }); - it('should return 404 for non-numeric issue IDs', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/abc')); - expect(res.status).toBe(404); + it('should reject invalid assignees payloads', async () => { + const issueNum = numericId(issueRecId); + const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/assignees`), 'POST', { + assignees: 'not-an-array', + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('assignees'); }); - it('should return 404 for non-numeric pull IDs', async () => { - const res = await handleShimRequest(ctx, repoUrl('/pulls/abc')); + it('should return 404 for assignee routes on missing issues', async () => { + const res = await handleShimRequest(ctx, repoUrl('/issues/999/assignees'), 'POST', { + assignees: [MAINTAINER_DID], + }); expect(res.status).toBe(404); }); }); // ========================================================================= - // URL building + // GET/POST/DELETE /repos/:did/:repo/issues/:number/dependencies // ========================================================================= - describe('URL building', () => { - it('should include base URL in all response URLs', async () => { - const res = await handleShimRequest(ctx, repoUrl('')); - const data = parse(res); - expect(data.url).toContain(BASE); - expect(data.owner.url).toContain(BASE); - }); - - it('should build correct issues_url template', async () => { - const res = await handleShimRequest(ctx, repoUrl('')); - const data = parse(res); - expect(data.issues_url).toContain('{/number}'); - }); + describe('issue dependency endpoints', () => { + it('should add, list, and remove blocked-by issue dependencies', async () => { + const blockerRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Blocking issue', + }); + expect(blockerRes.status).toBe(201); + const blocker = parse(blockerRes); - it('should build correct pulls_url template', async () => { - const res = await handleShimRequest(ctx, repoUrl('')); - const data = parse(res); - expect(data.pulls_url).toContain('{/number}'); + const blockedRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Blocked issue', + }); + expect(blockedRes.status).toBe(201); + const blocked = parse(blockedRes); + + const addRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`), + 'POST', + { issue_id: blocker.id }, + ); + expect(addRes.status).toBe(201); + expect(parse(addRes).number).toBe(blocked.number); + + const blockedByRes = await handleShimRequest(ctx, repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`)); + expect(blockedByRes.status).toBe(200); + expect(parse(blockedByRes).map((issue: any) => issue.id)).toEqual([blocker.id]); + + const blockingRes = await handleShimRequest(ctx, repoUrl(`/issues/${blocker.number}/dependencies/blocking`)); + expect(blockingRes.status).toBe(200); + expect(parse(blockingRes).map((issue: any) => issue.number)).toEqual([blocked.number]); + + const duplicateRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`), + 'POST', + { issue_id: blocker.id }, + ); + expect(duplicateRes.status).toBe(422); + + const selfRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`), + 'POST', + { issue_id: blocked.id }, + ); + expect(selfRes.status).toBe(422); + + const invalidRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`), + 'POST', + { issue_id: 'not-an-id' }, + ); + expect(invalidRes.status).toBe(422); + + const missingBlockerRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`), + 'POST', + { issue_id: 999999 }, + ); + expect(missingBlockerRes.status).toBe(404); + + const missingIssueRes = await handleShimRequest(ctx, repoUrl('/issues/999/dependencies/blocked_by')); + expect(missingIssueRes.status).toBe(404); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by/${blocker.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(200); + expect(parse(deleteRes).number).toBe(blocked.number); + + const afterDeleteBlockedByRes = await handleShimRequest(ctx, repoUrl(`/issues/${blocked.number}/dependencies/blocked_by`)); + expect(parse(afterDeleteBlockedByRes)).toEqual([]); + + const afterDeleteBlockingRes = await handleShimRequest(ctx, repoUrl(`/issues/${blocker.number}/dependencies/blocking`)); + expect(parse(afterDeleteBlockingRes)).toEqual([]); + + const missingDeleteRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${blocked.number}/dependencies/blocked_by/${blocker.id}`), + 'DELETE', + ); + expect(missingDeleteRes.status).toBe(404); }); }); // ========================================================================= - // POST /repos/:did/:repo/issues — create issue + // GET/POST/PATCH/DELETE /repos/:did/:repo/issues/:number/sub-issues // ========================================================================= - describe('POST /repos/:did/:repo/issues', () => { - it('should create an issue and return 201', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { - title : 'New shim issue', - body : 'Created via API shim.', + describe('issue sub-issue endpoints', () => { + it('should add, list, reprioritize, reparent, and remove sub-issues', async () => { + const parentRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Parent issue', }); - expect(res.status).toBe(201); - const data = parse(res); - expect(data.title).toBe('New shim issue'); - expect(data.body).toBe('Created via API shim.'); - expect(data.state).toBe('open'); - expect(typeof data.number).toBe('number'); - expect(data.number).toBeGreaterThan(0); - expect(data.user.login).toBe(testDid); - }); + expect(parentRes.status).toBe(201); + const parent = parse(parentRes); - it('should assign a numericId derived from record ID', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { - title: 'Another shim issue', + const newParentRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Replacement parent issue', }); - expect(res.status).toBe(201); - const data = parse(res); - expect(typeof data.number).toBe('number'); - expect(data.number).toBeGreaterThan(0); - }); + expect(newParentRes.status).toBe(201); + const newParent = parse(newParentRes); - it('should return 422 when title is missing', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { - body: 'Missing title.', + const firstChildRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'First child issue', }); - expect(res.status).toBe(422); - const data = parse(res); - expect(data.message).toContain('title'); - }); + expect(firstChildRes.status).toBe(201); + const firstChild = parse(firstChildRes); - it('should return 404 for non-existent repo DID', async () => { - const res = await handleShimRequest(ctx, url('/repos/did:jwk:nonexistent/missing-repo/issues'), 'POST', { - title: 'Should fail', + const secondChildRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Second child issue', }); - expect([404, 502]).toContain(res.status); - }); - }); + expect(secondChildRes.status).toBe(201); + const secondChild = parse(secondChildRes); - // ========================================================================= - // PATCH /repos/:did/:repo/issues/:number — update issue - // ========================================================================= + const addFirstRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`), 'POST', { + sub_issue_id: firstChild.id, + }); + expect(addFirstRes.status).toBe(201); + expect(parse(addFirstRes).number).toBe(parent.number); - describe('PATCH /repos/:did/:repo/issues/:number', () => { - it('should update the title of an issue', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { - title: 'Fix the widget (updated)', + const addSecondRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`), 'POST', { + sub_issue_id: secondChild.id, }); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.title).toBe('Fix the widget (updated)'); - expect(data.number).toBe(issueNum); - }); + expect(addSecondRes.status).toBe(201); + + const listRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`)); + expect(listRes.status).toBe(200); + expect(parse(listRes).map((issue: any) => issue.id)).toEqual([firstChild.id, secondChild.id]); + + const parentLookupRes = await handleShimRequest(ctx, repoUrl(`/issues/${firstChild.number}/parent`)); + expect(parentLookupRes.status).toBe(200); + expect(parse(parentLookupRes).id).toBe(parent.id); + + const reprioritizeRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${parent.number}/sub_issues/priority`), + 'PATCH', + { sub_issue_id: secondChild.id, before_id: firstChild.id }, + ); + expect(reprioritizeRes.status).toBe(200); + + const reorderedRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`)); + expect(parse(reorderedRes).map((issue: any) => issue.id)).toEqual([secondChild.id, firstChild.id]); + + const invalidPriorityRes = await handleShimRequest( + ctx, + repoUrl(`/issues/${parent.number}/sub_issues/priority`), + 'PATCH', + { sub_issue_id: firstChild.id, before_id: secondChild.id, after_id: secondChild.id }, + ); + expect(invalidPriorityRes.status).toBe(422); + + const reparentBlockedRes = await handleShimRequest(ctx, repoUrl(`/issues/${newParent.number}/sub_issues`), 'POST', { + sub_issue_id: firstChild.id, + }); + expect(reparentBlockedRes.status).toBe(422); - it('should close an issue by setting state=closed', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { - state: 'closed', + const reparentRes = await handleShimRequest(ctx, repoUrl(`/issues/${newParent.number}/sub_issues`), 'POST', { + replace_parent : true, + sub_issue_id : firstChild.id, }); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.state).toBe('closed'); - }); + expect(reparentRes.status).toBe(201); - it('should reopen an issue by setting state=open', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}`), 'PATCH', { - state: 'open', + const oldParentListRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`)); + expect(parse(oldParentListRes).map((issue: any) => issue.id)).toEqual([secondChild.id]); + + const newParentListRes = await handleShimRequest(ctx, repoUrl(`/issues/${newParent.number}/sub_issues`)); + expect(parse(newParentListRes).map((issue: any) => issue.id)).toEqual([firstChild.id]); + + const parentAfterReplaceRes = await handleShimRequest(ctx, repoUrl(`/issues/${firstChild.number}/parent`)); + expect(parse(parentAfterReplaceRes).id).toBe(newParent.id); + + const removeRes = await handleShimRequest(ctx, repoUrl(`/issues/${newParent.number}/sub_issue`), 'DELETE', { + sub_issue_id: firstChild.id, }); - expect(res.status).toBe(200); - const data = parse(res); - expect(data.state).toBe('open'); - }); + expect(removeRes.status).toBe(200); + expect(parse(removeRes).number).toBe(newParent.number); - it('should return 404 for non-existent issue number', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/999'), 'PATCH', { - title: 'Nope', + const afterRemoveListRes = await handleShimRequest(ctx, repoUrl(`/issues/${newParent.number}/sub_issues`)); + expect(parse(afterRemoveListRes)).toEqual([]); + + const parentAfterRemoveRes = await handleShimRequest(ctx, repoUrl(`/issues/${firstChild.number}/parent`)); + expect(parentAfterRemoveRes.status).toBe(404); + + const invalidAddRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`), 'POST', { + sub_issue_id: 'not-an-id', }); - expect(res.status).toBe(404); + expect(invalidAddRes.status).toBe(422); + + const selfRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`), 'POST', { + sub_issue_id: parent.id, + }); + expect(selfRes.status).toBe(422); + + const missingChildRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issues`), 'POST', { + sub_issue_id: 999999, + }); + expect(missingChildRes.status).toBe(404); + + const missingRemoveRes = await handleShimRequest(ctx, repoUrl(`/issues/${parent.number}/sub_issue`), 'DELETE', { + sub_issue_id: firstChild.id, + }); + expect(missingRemoveRes.status).toBe(404); }); }); // ========================================================================= - // POST /repos/:did/:repo/issues/:number/comments — create comment + // GET/POST/PUT/DELETE /repos/:did/:repo/issues/:number/issue-field-values // ========================================================================= - describe('POST /repos/:did/:repo/issues/:number/comments', () => { - it('should create a comment and return 201', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', { - body: 'New comment via shim.', + describe('issue field value endpoints', () => { + it('should list, add, replace, delete, and clear issue field values', async () => { + const issueRes = await handleShimRequest(ctx, repoUrl('/issues'), 'POST', { + title: 'Issue with field values', }); - expect(res.status).toBe(201); - const data = parse(res); - expect(data.body).toBe('New comment via shim.'); - expect(data.user.login).toBe(testDid); - expect(data.issue_url).toContain(`/issues/${issueNum}`); - expect(data.author_association).toBe('OWNER'); - }); + expect(issueRes.status).toBe(201); + const issue = parse(issueRes); + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const addRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: [ + { field_id: 101, value: 'DRI' }, + { field_id: 102, value: 8 }, + { field_id: 103, value: '2026-07-01' }, + { field_id: 104, value: ['Frontend', 'Backend'] }, + ], + }); + expect(addRes.status).toBe(200); + const added = parse(addRes); + expect(added.map((field: any) => field.issue_field_id)).toEqual([101, 102, 103, 104]); + expect(added.map((field: any) => field.data_type)).toEqual(['text', 'number', 'date', 'multi_select']); + expect(added[3].value).toBe('Frontend,Backend'); + expect(added[3].multi_select_options.map((option: any) => option.name)).toEqual(['Frontend', 'Backend']); + + const pagedRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values?per_page=1`)); + expect(pagedRes.status).toBe(200); + expect(parse(pagedRes)).toHaveLength(1); + expect(pagedRes.headers.Link).toContain('rel="next"'); + + const upsertRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: [ + { field_id: 101, value: 'New DRI' }, + { field_id: 105, value: 'Added later' }, + ], + }); + expect(upsertRes.status).toBe(200); + const upserted = parse(upsertRes); + expect(upserted.find((field: any) => field.issue_field_id === 101).value).toBe('New DRI'); + expect(upserted.find((field: any) => field.issue_field_id === 105).value).toBe('Added later'); + + const replaceRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'PUT', { + issue_field_values: [ + { field_id: 201, value: 'Only field' }, + ], + }); + expect(replaceRes.status).toBe(200); + expect(parse(replaceRes).map((field: any) => field.issue_field_id)).toEqual([201]); - it('should return 422 when body is missing', async () => { - const issueNum = numericId(issueRecId); - const res = await handleShimRequest(ctx, repoUrl(`/issues/${issueNum}/comments`), 'POST', {}); - expect(res.status).toBe(422); - const data = parse(res); - expect(data.message).toContain('body'); - }); + const deleteRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values/201`), 'DELETE'); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); - it('should return 404 for non-existent issue', async () => { - const res = await handleShimRequest(ctx, repoUrl('/issues/999/comments'), 'POST', { - body: 'Should fail.', + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`)); + expect(parse(afterDeleteRes)).toEqual([]); + + await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: [{ field_id: 301, value: 'Temporary' }], }); - expect(res.status).toBe(404); + const clearRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: [], + }); + expect(clearRes.status).toBe(200); + expect(parse(clearRes)).toEqual([]); + + const invalidValuesRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: 'not-an-array', + }); + expect(invalidValuesRes.status).toBe(422); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values`), 'POST', { + issue_field_values: [ + { field_id: 401, value: 'a' }, + { field_id: 401, value: 'b' }, + ], + }); + expect(duplicateRes.status).toBe(422); + + const missingIssueRes = await handleShimRequest(ctx, repoUrl('/issues/999/issue-field-values')); + expect(missingIssueRes.status).toBe(404); + + const missingDeleteRes = await handleShimRequest(ctx, repoUrl(`/issues/${issue.number}/issue-field-values/999`), 'DELETE'); + expect(missingDeleteRes.status).toBe(404); }); }); @@ -916,6 +11603,7 @@ describe('GitHub API compatibility shim', () => { expect(data.body).toBe('Adds a new feature.'); expect(data.state).toBe('open'); expect(data.merged).toBe(false); + expect(data.draft).toBe(false); expect(data.base.ref).toBe('main'); expect(data.head.ref).toBe('feat-new'); expect(typeof data.number).toBe('number'); @@ -931,6 +11619,36 @@ describe('GitHub API compatibility shim', () => { expect(data.base.ref).toBe('main'); }); + it('should create draft pull requests and list them as open', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'Draft pull request', + body : 'Still being prepared.', + base : 'main', + head : 'draft-pr', + draft : true, + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.title).toBe('Draft pull request'); + expect(data.state).toBe('open'); + expect(data.draft).toBe(true); + expect(data.merged).toBe(false); + + const detailRes = await handleShimRequest(ctx, repoUrl(`/pulls/${data.number}`)); + expect(detailRes.status).toBe(200); + const detail = parse(detailRes); + expect(detail.draft).toBe(true); + expect(detail.state).toBe('open'); + + const openRes = await handleShimRequest(ctx, repoUrl('/pulls')); + expect(openRes.status).toBe(200); + expect(parse(openRes).some((pull: any) => pull.number === data.number && pull.draft === true)).toBe(true); + + const closedRes = await handleShimRequest(ctx, repoUrl('/pulls?state=closed')); + expect(closedRes.status).toBe(200); + expect(parse(closedRes).some((pull: any) => pull.number === data.number)).toBe(false); + }); + it('should return 422 when title is missing', async () => { const res = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { body: 'Missing title.', @@ -939,6 +11657,161 @@ describe('GitHub API compatibility shim', () => { const data = parse(res); expect(data.message).toContain('title'); }); + + it('should return 422 when draft is not a boolean', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'Invalid draft PR', + draft : 'yes', + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('draft'); + }); + + it('should honor GitHub pull request body media types on create', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'Create media PR', + body : '**Pull** and `code`.', + base : 'main', + head : 'media-create', + }, null, { + accept: 'application/vnd.github.full+json', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.body).toBe('**Pull** and `code`.'); + expect(data.body_text).toBe('Pull and code.'); + expect(data.body_html).toBe('

    Pull and code.

    '); + }); + }); + + // ========================================================================= + // GET/POST/DELETE /repos/:did/:repo/pulls/:number/requested_reviewers + // ========================================================================= + + describe('pull request review request endpoints', () => { + it('should list empty requested reviewers by default', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`)); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.users).toEqual([]); + expect(data.teams).toEqual([]); + }); + + it('should request, list, and remove pull request reviewers', async () => { + const patchNum = numericId(patchRecId); + const createRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`), 'POST', { + reviewers : [MAINTAINER_DID, TRIAGER_DID], + team_reviewers : ['core-reviewers'], + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.requested_reviewers.map((user: any) => user.login).sort()).toEqual([MAINTAINER_DID, TRIAGER_DID].sort()); + expect(created.requested_teams.map((team: any) => team.slug)).toEqual(['core-reviewers']); + + const listRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`)); + expect(listRes.status).toBe(200); + const listed = parse(listRes); + expect(listed.users.map((user: any) => user.login).sort()).toEqual([MAINTAINER_DID, TRIAGER_DID].sort()); + expect(listed.teams.map((team: any) => team.slug)).toEqual(['core-reviewers']); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`), 'DELETE', { + reviewers : [MAINTAINER_DID], + team_reviewers : ['core-reviewers'], + }); + expect(deleteRes.status).toBe(200); + const updated = parse(deleteRes); + expect(updated.requested_reviewers.map((user: any) => user.login)).toEqual([TRIAGER_DID]); + expect(updated.requested_teams).toEqual([]); + + const teamOnlyCreateRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`), 'POST', { + reviewers : [], + team_reviewers : ['ops-reviewers'], + }); + expect(teamOnlyCreateRes.status).toBe(201); + + const teamOnlyDeleteRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`), 'DELETE', { + reviewers : [], + team_reviewers : ['ops-reviewers'], + }); + expect(teamOnlyDeleteRes.status).toBe(200); + const teamOnlyUpdated = parse(teamOnlyDeleteRes); + expect(teamOnlyUpdated.requested_reviewers.map((user: any) => user.login)).toEqual([TRIAGER_DID]); + expect(teamOnlyUpdated.requested_teams).toEqual([]); + }); + + it('should reject invalid review request payloads and missing pulls', async () => { + const patchNum = numericId(patchRecId); + const invalidRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/requested_reviewers`), 'POST', { + reviewers: 'not-an-array', + }); + expect(invalidRes.status).toBe(422); + + const missingReviewersDeleteRes = await handleShimRequest( + ctx, + repoUrl(`/pulls/${patchNum}/requested_reviewers`), + 'DELETE', + { team_reviewers: ['core-reviewers'] }, + ); + expect(missingReviewersDeleteRes.status).toBe(422); + expect(parse(missingReviewersDeleteRes).message).toContain('reviewers'); + + const missingRes = await handleShimRequest(ctx, repoUrl('/pulls/999/requested_reviewers')); + expect(missingRes.status).toBe(404); + }); + }); + + // ========================================================================= + // PUT /repos/:did/:repo/pulls/:number/update-branch + // ========================================================================= + + describe('PUT /repos/:did/:repo/pulls/:number/update-branch', () => { + it('should accept branch update requests with a matching expected head SHA', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/update-branch`), 'PUT', { + expected_head_sha: 'abc1234567890abcdef1234567890abcdef12345', + }); + expect(res.status).toBe(202); + const data = parse(res); + expect(data.message).toBe('Updating pull request branch.'); + expect(data.url).toContain(`/pulls/${patchNum}`); + }); + + it('should reject branch update requests when expected_head_sha does not match', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/update-branch`), 'PUT', { + expected_head_sha: '0000000000000000000000000000000000000000', + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('expected_head_sha'); + }); + + it('should return 404 for missing pull request branch updates', async () => { + const res = await handleShimRequest(ctx, repoUrl('/pulls/999/update-branch'), 'PUT', {}); + expect(res.status).toBe(404); + }); + }); + + // ========================================================================= + // GET /repos/:did/:repo/pulls/:number/merge — check if merged + // ========================================================================= + + describe('GET /repos/:did/:repo/pulls/:number/merge', () => { + it('should return 204 with an empty body for merged pull requests', async () => { + const mergedNum = numericId(mergedPatchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${mergedNum}/merge`)); + expect(res.status).toBe(204); + expect(res.body).toBe(''); + }); + + it('should return 404 for unmerged or missing pull requests', async () => { + const patchNum = numericId(patchRecId); + const unmergedRes = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/merge`)); + expect(unmergedRes.status).toBe(404); + + const missingRes = await handleShimRequest(ctx, repoUrl('/pulls/999/merge')); + expect(missingRes.status).toBe(404); + }); }); // ========================================================================= @@ -984,6 +11857,20 @@ describe('GitHub API compatibility shim', () => { }); expect(res.status).toBe(404); }); + + it('should honor GitHub pull request body media types on update', async () => { + const patchNum = numericId(patchRecId); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}`), 'PATCH', { + body: 'Updated **pull**.', + }, null, { + accept: 'application/vnd.github.text+json', + }); + expect(res.status).toBe(200); + const data = parse(res); + expect(data.body).toBeUndefined(); + expect(data.body_text).toBe('Updated pull.'); + expect(data.body_html).toBeUndefined(); + }); }); // ========================================================================= @@ -1028,26 +11915,94 @@ describe('GitHub API compatibility shim', () => { // POST /repos/:did/:repo/pulls/:number/reviews — create review // ========================================================================= - describe('POST /repos/:did/:repo/pulls/:number/reviews', () => { - // Create a fresh PR per test — the numericId is derived from the record ID. - it('should create a review with APPROVE event', async () => { - // Create a fresh PR to review. + describe('POST /repos/:did/:repo/pulls/:number/reviews', () => { + // Create a fresh PR per test — the numericId is derived from the record ID. + it('should create a review with APPROVE event', async () => { + // Create a fresh PR to review. + const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for review test', + head : 'review-branch', + }); + expect(createRes.status).toBe(201); + const pr = parse(createRes); + + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body : 'Ship it!', + event : 'APPROVE', + }); + expect(res.status).toBe(201); + const data = parse(res); + expect(data.body).toBe('Ship it!'); + expect(data.state).toBe('APPROVED'); + expect(data.user.login).toBe(testDid); + }); + + it('should create inline comments from the review comments array', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for review comments array', + head : 'review-comments-array', + }); + expect(createRes.status).toBe(201); + const pr = parse(createRes); + + const reviewRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body : 'Summary comment.', + event : 'COMMENT', + comments : [ + { + body : 'Inline **comment**.', + path : 'src/widget.ts', + start_line : 29, + start_side : 'RIGHT', + line : 31, + side : 'RIGHT', + diff_hunk : '@@ -30 +31 @@', + }, + ], + }); + expect(reviewRes.status).toBe(201); + const review = parse(reviewRes); + expect(review.body).toBe('Summary comment.'); + + const commentsRes = await handleShimRequest( + ctx, repoUrl(`/pulls/${pr.number}/reviews/${review.id}/comments`), 'GET', {}, null, + { accept: 'application/vnd.github-commitcomment.full+json' }, + ); + expect(commentsRes.status).toBe(200); + const comments = parse(commentsRes); + expect(comments).toHaveLength(1); + expect(comments[0].pull_request_review_id).toBe(review.id); + expect(comments[0].body).toBe('Inline **comment**.'); + expect(comments[0].body_text).toBe('Inline comment.'); + expect(comments[0].body_html).toBe('

    Inline comment.

    '); + expect(comments[0].path).toBe('src/widget.ts'); + expect(comments[0].start_line).toBe(29); + expect(comments[0].original_start_line).toBe(29); + expect(comments[0].start_side).toBe('RIGHT'); + expect(comments[0].line).toBe(31); + expect(comments[0].side).toBe('RIGHT'); + expect(comments[0].subject_type).toBe('line'); + }); + + it('should honor GitHub pull request review body media types on create', async () => { const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { - title : 'PR for review test', - head : 'review-branch', + title : 'PR for review media', + head : 'review-media', }); expect(createRes.status).toBe(201); const pr = parse(createRes); const res = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { - body : 'Ship it!', - event : 'APPROVE', + body : '**Review** and `code`.', + event : 'COMMENT', + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', }); expect(res.status).toBe(201); const data = parse(res); - expect(data.body).toBe('Ship it!'); - expect(data.state).toBe('APPROVED'); - expect(data.user.login).toBe(testDid); + expect(data.body).toBe('**Review** and `code`.'); + expect(data.body_text).toBe('Review and code.'); + expect(data.body_html).toBe('

    Review and code.

    '); }); it('should create a review with REQUEST_CHANGES event', async () => { @@ -1066,7 +12021,7 @@ describe('GitHub API compatibility shim', () => { expect(data.state).toBe('CHANGES_REQUESTED'); }); - it('should default to COMMENTED when no event is specified', async () => { + it('should create a pending review when no event is specified', async () => { const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { title : 'PR for comment review', head : 'comment-branch', @@ -1078,7 +12033,111 @@ describe('GitHub API compatibility shim', () => { }); expect(res.status).toBe(201); const data = parse(res); - expect(data.state).toBe('COMMENTED'); + expect(data.state).toBe('PENDING'); + expect(data.submitted_at).toBeUndefined(); + }); + + it('should submit a pending review through the events endpoint', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for pending review submission', + head : 'pending-submit-branch', + }); + const pr = parse(createRes); + + const pendingRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body: 'Draft review body.', + }); + expect(pendingRes.status).toBe(201); + const pending = parse(pendingRes); + expect(pending.state).toBe('PENDING'); + + const invalidRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${pending.id}/events`), 'POST', { + event: 'NOPE', + }); + expect(invalidRes.status).toBe(422); + + const submitRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${pending.id}/events`), 'POST', { + body : 'Final review body.', + event : 'REQUEST_CHANGES', + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(submitRes.status).toBe(200); + const submitted = parse(submitRes); + expect(submitted.id).toBe(pending.id); + expect(submitted.body).toBe('Final review body.'); + expect(submitted.body_text).toBe('Final review body.'); + expect(submitted.body_html).toBe('

    Final review body.

    '); + expect(submitted.state).toBe('CHANGES_REQUESTED'); + expect(submitted.submitted_at).toBeDefined(); + + const getRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${pending.id}`)); + expect(parse(getRes).state).toBe('CHANGES_REQUESTED'); + }); + + it('should delete pending reviews and reject deleting submitted reviews', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for pending review deletion', + head : 'pending-delete-branch', + }); + const pr = parse(createRes); + + const pendingRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body: 'Delete this draft.', + }); + const pending = parse(pendingRes); + + const deleteRes = await handleShimRequest( + ctx, repoUrl(`/pulls/${pr.number}/reviews/${pending.id}`), 'DELETE', {}, null, + { accept: 'application/vnd.github-commitcomment.html+json' }, + ); + expect(deleteRes.status).toBe(200); + const deleted = parse(deleteRes); + expect(deleted.state).toBe('PENDING'); + expect(deleted.body).toBeUndefined(); + expect(deleted.body_html).toBe('

    Delete this draft.

    '); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${pending.id}`)); + expect(missingRes.status).toBe(404); + + const submittedDeleteRes = await handleShimRequest( + ctx, repoUrl(`/pulls/${numericId(patchRecId)}/reviews/${numericId(reviewRecId)}`), 'DELETE', + ); + expect(submittedDeleteRes.status).toBe(422); + }); + + it('should dismiss submitted pull request reviews', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/pulls'), 'POST', { + title : 'PR for review dismissal', + head : 'dismiss-review-branch', + }); + const pr = parse(createRes); + + const reviewRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews`), 'POST', { + body : 'Approved before new context.', + event : 'APPROVE', + }); + const review = parse(reviewRes); + + const invalidRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${review.id}/dismissals`), 'PUT', {}); + expect(invalidRes.status).toBe(422); + + const dismissRes = await handleShimRequest( + ctx, repoUrl(`/pulls/${pr.number}/reviews/${review.id}/dismissals`), 'PUT', { + message: 'No longer applies after force-push.', + }, null, { + accept: 'application/vnd.github-commitcomment.text+json', + }, + ); + expect(dismissRes.status).toBe(200); + const dismissed = parse(dismissRes); + expect(dismissed.id).toBe(review.id); + expect(dismissed.body).toBeUndefined(); + expect(dismissed.body_text).toBe('Approved before new context.'); + expect(dismissed.state).toBe('DISMISSED'); + + const getRes = await handleShimRequest(ctx, repoUrl(`/pulls/${pr.number}/reviews/${review.id}`)); + expect(parse(getRes).state).toBe('DISMISSED'); }); it('should return 404 for non-existent pull', async () => { @@ -1109,6 +12168,9 @@ describe('GitHub API compatibility shim', () => { expect(data.draft).toBe(false); expect(data.prerelease).toBe(false); expect(data.author.login).toBe(testDid); + + const latest = parse(await handleShimRequest(ctx, repoUrl('/releases/latest'))); + expect(latest.tag_name).toBe('v3.0.0'); }); it('should create a prerelease', async () => { @@ -1133,6 +12195,7 @@ describe('GitHub API compatibility shim', () => { expect(res.status).toBe(201); const data = parse(res); expect(data.draft).toBe(true); + expect(data.published_at).toBeNull(); }); it('should use tag_name as name when name is omitted', async () => { @@ -1144,6 +12207,92 @@ describe('GitHub API compatibility shim', () => { expect(data.name).toBe('v6.0.0'); }); + it('should default target_commitish to the repository default branch', async () => { + const repoName = 'release-default-target-repo'; + const { record: repoRec } = await ctx.repo.records.create('repo', { + data : { name: repoName, description: 'Release default target fixture', defaultBranch: 'trunk', dwnEndpoints: [] }, + tags : { name: repoName, visibility: 'public' }, + }); + let releaseId: number | undefined; + + try { + const createRes = await handleShimRequest(ctx, url(`/repos/${testDid}/${repoName}/releases`), 'POST', { + tag_name : 'v-default-target', + name : 'Default Target Release', + make_latest : 'false', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + releaseId = created.id; + expect(created.target_commitish).toBe('trunk'); + + const detailRes = await handleShimRequest(ctx, url(`/repos/${testDid}/${repoName}/releases/${created.id}`)); + expect(parse(detailRes).target_commitish).toBe('trunk'); + + const byTagRes = await handleShimRequest(ctx, url(`/repos/${testDid}/${repoName}/releases/tags/v-default-target`)); + expect(parse(byTagRes).target_commitish).toBe('trunk'); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, url(`/repos/${testDid}/${repoName}/releases/${releaseId}`), 'DELETE'); + } + if (repoRec) { + await repoRec.delete(); + } + } + }); + + it('should generate release notes during release creation', async () => { + let releaseId: number | undefined; + + try { + const res = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-create-generated-notes', + target_commitish : 'release-notes-branch', + body : 'Manual intro.', + generate_release_notes : true, + }); + expect(res.status).toBe(201); + const data = parse(res); + releaseId = data.id; + expect(data.name).toBe('Release v-create-generated-notes'); + expect(data.body).toContain('Manual intro.\n\n## Changes in v-create-generated-notes'); + expect(data.body).toContain('Target: release-notes-branch'); + + const byTagRes = await handleShimRequest(ctx, repoUrl('/releases/tags/v-create-generated-notes')); + expect(byTagRes.status).toBe(200); + expect(parse(byTagRes).body).toBe(data.body); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + + it('should create release discussion links when a discussion category is provided', async () => { + let releaseId: number | undefined; + + try { + const res = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-discussion-release', + name : 'Discussion Release', + discussion_category_name : 'Announcements', + make_latest : 'false', + }); + expect(res.status).toBe(201); + const data = parse(res); + releaseId = data.id; + expect(data.discussion_url).toContain(`/repos/${testDid}/test-repo/discussions/`); + + const byTagRes = await handleShimRequest(ctx, repoUrl('/releases/tags/v-discussion-release')); + expect(byTagRes.status).toBe(200); + expect(parse(byTagRes).discussion_url).toBe(data.discussion_url); + } finally { + if (releaseId !== undefined) { + await handleShimRequest(ctx, repoUrl(`/releases/${releaseId}`), 'DELETE'); + } + } + }); + it('should return 422 when tag_name is missing', async () => { const res = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { name: 'No tag', @@ -1153,6 +12302,15 @@ describe('GitHub API compatibility shim', () => { expect(data.message).toContain('tag_name'); }); + it('should reject duplicate release tag names', async () => { + const res = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v1.0.0', + name : 'Duplicate v1', + }); + expect(res.status).toBe(422); + expect(parse(res).message).toContain('tag_name'); + }); + it('should be visible in the releases list after creation', async () => { const res = await handleShimRequest(ctx, repoUrl('/releases')); expect(res.status).toBe(200); @@ -1163,6 +12321,1008 @@ describe('GitHub API compatibility shim', () => { }); }); + // ========================================================================= + // PATCH/DELETE /repos/:did/:repo/releases/:id and assets + // ========================================================================= + + describe('release update and delete endpoints', () => { + it('should update release metadata', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v7.0.0', + name : 'Version 7.0.0', + body : 'Initial v7 notes.', + draft : true, + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + tag_name : 'v7.1.0', + target_commitish : 'release-branch', + name : 'Version 7.1.0', + body : 'Updated v7 notes.', + draft : false, + prerelease : true, + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.id).toBe(created.id); + expect(updated.tag_name).toBe('v7.1.0'); + expect(updated.target_commitish).toBe('release-branch'); + expect(updated.name).toBe('Version 7.1.0'); + expect(updated.body).toBe('Updated v7 notes.'); + expect(updated.draft).toBe(false); + expect(updated.prerelease).toBe(true); + expect(typeof updated.published_at).toBe('string'); + + const byTagRes = await handleShimRequest(ctx, repoUrl('/releases/tags/v7.1.0')); + expect(byTagRes.status).toBe(200); + const byTag = parse(byTagRes); + expect(byTag.name).toBe('Version 7.1.0'); + expect(byTag.published_at).toBe(updated.published_at); + }); + + it('should reject release updates that reuse another release tag', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-update-duplicate-tag', + name : 'Update Duplicate Tag', + make_latest : 'false', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + try { + const updateRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + tag_name: 'v1.0.0', + }); + expect(updateRes.status).toBe(422); + expect(parse(updateRes).message).toContain('tag_name'); + } finally { + await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'DELETE'); + } + }); + + it('should add release discussion links on update', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-update-discussion', + name : 'Update Discussion Release', + make_latest : 'false', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + try { + const updateRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + discussion_category_name: 'Announcements', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.discussion_url).toContain(`/repos/${testDid}/test-repo/discussions/`); + + const ignoredRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'PATCH', { + discussion_category_name: '', + }); + expect(ignoredRes.status).toBe(200); + expect(parse(ignoredRes).discussion_url).toBe(updated.discussion_url); + } finally { + await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'DELETE'); + } + }); + + it('should delete releases', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v8.0.0', + name : 'Delete Me', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/releases/${created.id}`)); + expect(missingRes.status).toBe(404); + }); + + it('should update and delete release asset metadata without rewriting bytes', async () => { + const { record: releaseRec } = await ctx.releases.records.create('repo/release' as any, { + data : { name: 'Asset Metadata Release', body: 'Asset write fixture.' }, + tags : { tagName: 'v-assets-write' }, + parentContextId : repoContextId, + } as any); + expect(releaseRec).toBeDefined(); + + const assetBytes = Buffer.from('asset metadata bytes\n', 'utf-8'); + const { record: assetRec } = await ctx.releases.records.create('repo/release/asset' as any, { + data : assetBytes, + dataFormat : 'application/zip', + tags : { filename: 'old-name.zip', contentType: 'application/zip', size: assetBytes.byteLength }, + parentContextId : releaseRec!.contextId ?? '', + } as any); + expect(assetRec).toBeDefined(); + + const assetId = numericId(assetRec!.id); + const updateRes = await handleShimRequest(ctx, repoUrl(`/releases/assets/${assetId}`), 'PATCH', { + name : 'new-name.zip', + label : 'Mac binary', + state : 'uploaded', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.id).toBe(assetId); + expect(updated.name).toBe('new-name.zip'); + expect(updated.label).toBe('Mac binary'); + expect(updated.content_type).toBe('application/zip'); + expect(updated.size).toBe(assetBytes.byteLength); + expect(updated.browser_download_url).toContain('/releases/download/v-assets-write/new-name.zip'); + + const getRes = await handleShimRequest(ctx, repoUrl(`/releases/assets/${assetId}`)); + expect(parse(getRes).name).toBe('new-name.zip'); + + const downloadRes = await handleShimRequest(ctx, repoUrl('/releases/download/v-assets-write/new-name.zip')); + expect(downloadRes.status).toBe(200); + expect(Buffer.from(downloadRes.body as Uint8Array).toString('utf-8')).toBe(assetBytes.toString('utf-8')); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/releases/assets/${assetId}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/releases/assets/${assetId}`)); + expect(missingRes.status).toBe(404); + }); + + it('should upload release assets from raw bytes', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-upload-asset', + name : 'Upload Asset Release', + }); + expect(createRes.status).toBe(201); + const release = parse(createRes); + + const assetBytes = Buffer.from('raw upload asset bytes\n', 'utf-8'); + const assetDigest = `sha256:${createHash('sha256').update(assetBytes).digest('hex')}`; + const assetName = `uploaded-${release.id}.zip`; + const uploadRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/assets?name=${assetName}&label=Linux%20binary`), + 'POST', + {}, + null, + { rawBody: assetBytes, contentType: 'application/zip' }, + ); + expect(uploadRes.status).toBe(201); + const uploaded = parse(uploadRes); + expect(uploaded.name).toBe(assetName); + expect(uploaded.label).toBe('Linux binary'); + expect(uploaded.content_type).toBe('application/zip'); + expect(uploaded.size).toBe(assetBytes.byteLength); + expect(uploaded.digest).toBe(assetDigest); + expect(uploaded.browser_download_url).toContain(`/releases/download/v-upload-asset/${assetName}`); + + const listRes = await handleShimRequest(ctx, repoUrl(`/releases/${release.id}/assets`)); + expect(listRes.status).toBe(200); + const listed = parse(listRes); + expect(listed.some((asset: any) => + asset.name === assetName && asset.label === 'Linux binary' && asset.digest === assetDigest)).toBe(true); + + const downloadRes = await handleShimRequest(ctx, repoUrl(`/releases/download/v-upload-asset/${assetName}`)); + expect(downloadRes.status).toBe(200); + expect(bodyBuffer(downloadRes).equals(assetBytes)).toBe(true); + }); + + it('should reject duplicate release asset uploads and missing upload names', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/releases'), 'POST', { + tag_name : 'v-upload-dupe', + name : 'Upload Duplicate Release', + }); + expect(createRes.status).toBe(201); + const release = parse(createRes); + const assetBytes = Buffer.from('duplicate asset bytes\n', 'utf-8'); + const assetName = `duplicate-${release.id}.zip`; + + const firstUploadRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/assets?name=${assetName}`), + 'POST', + {}, + null, + { rawBody: assetBytes, contentType: 'application/zip' }, + ); + expect(firstUploadRes.status).toBe(201); + + const duplicateRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/assets?name=${assetName}`), + 'POST', + {}, + null, + { rawBody: assetBytes, contentType: 'application/zip' }, + ); + expect(duplicateRes.status).toBe(422); + expect(parse(duplicateRes).message).toContain('same filename'); + + const missingNameRes = await handleShimRequest( + ctx, + repoUrl(`/releases/${release.id}/assets`), + 'POST', + {}, + null, + { rawBody: assetBytes, contentType: 'application/zip' }, + ); + expect(missingNameRes.status).toBe(422); + expect(parse(missingNameRes).message).toContain('name'); + }); + + it('should generate release notes without creating a release', async () => { + const notesRes = await handleShimRequest(ctx, repoUrl('/releases/generate-notes'), 'POST', { + tag_name : 'v-generated-notes', + target_commitish : 'main', + previous_tag_name : 'v1.0.0', + }); + expect(notesRes.status).toBe(200); + const notes = parse(notesRes); + expect(notes.name).toBe('Release v-generated-notes'); + expect(notes.body).toContain('## Changes in v-generated-notes'); + expect(notes.body).toContain('Target: main'); + expect(notes.body).toContain('Previous tag: v1.0.0'); + + const byTagRes = await handleShimRequest(ctx, repoUrl('/releases/tags/v-generated-notes')); + expect(byTagRes.status).toBe(404); + }); + + it('should reject invalid generated release notes requests', async () => { + const missingTagRes = await handleShimRequest(ctx, repoUrl('/releases/generate-notes'), 'POST', { + target_commitish: 'main', + }); + expect(missingTagRes.status).toBe(422); + expect(parse(missingTagRes).message).toContain('tag_name'); + + const missingRepoRes = await handleShimRequest( + ctx, + url(`/repos/${testDid}/missing-repo/releases/generate-notes`), + 'POST', + { tag_name: 'v-missing' }, + ); + expect(missingRepoRes.status).toBe(404); + }); + + it('should return 404 for missing release write targets', async () => { + const updateReleaseRes = await handleShimRequest(ctx, repoUrl('/releases/999'), 'PATCH', { + name: 'Missing', + }); + expect(updateReleaseRes.status).toBe(404); + + const deleteReleaseRes = await handleShimRequest(ctx, repoUrl('/releases/999'), 'DELETE'); + expect(deleteReleaseRes.status).toBe(404); + + const updateAssetRes = await handleShimRequest(ctx, repoUrl('/releases/assets/999'), 'PATCH', { + name: 'missing.zip', + }); + expect(updateAssetRes.status).toBe(404); + + const deleteAssetRes = await handleShimRequest(ctx, repoUrl('/releases/assets/999'), 'DELETE'); + expect(deleteAssetRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GitHub Pages endpoints + // ========================================================================= + + describe('GitHub Pages endpoints', () => { + it('should create, update, build, deploy, health-check, cancel, and delete a Pages site', async () => { + const missingRes = await handleShimRequest(ctx, repoUrl('/pages')); + expect(missingRes.status).toBe(404); + + const invalidCreateRes = await handleShimRequest(ctx, repoUrl('/pages'), 'POST', { + build_type: 'legacy', + }); + expect(invalidCreateRes.status).toBe(422); + + const createRes = await handleShimRequest(ctx, repoUrl('/pages'), 'POST', { + source: { branch: 'main', path: '/docs' }, + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.status).toBe('built'); + expect(created.source).toEqual({ branch: 'main', path: '/docs' }); + expect(created.https_enforced).toBe(true); + expect(created.url).toContain('/pages'); + + const duplicateRes = await handleShimRequest(ctx, repoUrl('/pages'), 'POST', { + source: { branch: 'main', path: '/' }, + }); + expect(duplicateRes.status).toBe(409); + + const invalidUpdateRes = await handleShimRequest(ctx, repoUrl('/pages'), 'PUT', { + source: { branch: 'main', path: '/site' }, + }); + expect(invalidUpdateRes.status).toBe(422); + + const updateRes = await handleShimRequest(ctx, repoUrl('/pages'), 'PUT', { + cname : 'docs.example.com', + https_enforced : false, + source : { branch: 'gh-pages', path: '/' }, + }); + expect(updateRes.status).toBe(204); + + const updatedRes = await handleShimRequest(ctx, repoUrl('/pages')); + expect(updatedRes.status).toBe(200); + const updated = parse(updatedRes); + expect(updated.cname).toBe('docs.example.com'); + expect(updated.source).toEqual({ branch: 'gh-pages', path: '/' }); + expect(updated.https_enforced).toBe(false); + + const healthRes = await handleShimRequest(ctx, repoUrl('/pages/health')); + expect(healthRes.status).toBe(200); + const health = parse(healthRes); + expect(health.domain.host).toBe('docs.example.com'); + expect(health.alt_domain.host).toBe('www.docs.example.com'); + expect(health.domain.enforces_https).toBe(false); + + const requestBuildRes = await handleShimRequest(ctx, repoUrl('/pages/builds'), 'POST'); + expect(requestBuildRes.status).toBe(201); + expect(parse(requestBuildRes).status).toBe('queued'); + + const latestBuildRes = await handleShimRequest(ctx, repoUrl('/pages/builds/latest')); + expect(latestBuildRes.status).toBe(200); + const latestBuild = parse(latestBuildRes); + expect(latestBuild.status).toBe('queued'); + expect(latestBuild.commit).toMatch(/^[0-9a-f]{40}$/); + const buildId = latestBuild.url.split('/').pop(); + + const listBuildsRes = await handleShimRequest(ctx, repoUrl('/pages/builds?per_page=1')); + expect(listBuildsRes.status).toBe(200); + expect(parse(listBuildsRes)).toHaveLength(1); + + const buildRes = await handleShimRequest(ctx, repoUrl(`/pages/builds/${buildId}`)); + expect(buildRes.status).toBe(200); + expect(parse(buildRes).url).toBe(latestBuild.url); + + const invalidDeploymentRes = await handleShimRequest(ctx, repoUrl('/pages/deployments'), 'POST', { + pages_build_version : 'pages-sha-1', + oidc_token : 'token', + }); + expect(invalidDeploymentRes.status).toBe(422); + + const deploymentRes = await handleShimRequest(ctx, repoUrl('/pages/deployments'), 'POST', { + artifact_url : 'https://example.com/pages.zip', + environment : 'github-pages', + pages_build_version : 'pages-sha-1', + oidc_token : 'token', + }); + expect(deploymentRes.status).toBe(200); + const deployment = parse(deploymentRes); + expect(deployment.id).toBe('pages-sha-1'); + expect(deployment.status_url).toContain('/pages/deployments/pages-sha-1/status'); + expect(deployment.page_url).toBe('https://docs.example.com'); + + const deploymentStatusRes = await handleShimRequest(ctx, repoUrl('/pages/deployments/pages-sha-1')); + expect(deploymentStatusRes.status).toBe(200); + expect(parse(deploymentStatusRes).status).toBe('succeed'); + + const deploymentStatusUrlRes = await handleShimRequest(ctx, repoUrl('/pages/deployments/pages-sha-1/status')); + expect(deploymentStatusUrlRes.status).toBe(200); + expect(parse(deploymentStatusUrlRes).status).toBe('succeed'); + + const cancelRes = await handleShimRequest(ctx, repoUrl('/pages/deployments/pages-sha-1/cancel'), 'POST'); + expect(cancelRes.status).toBe(204); + + const cancelledStatusRes = await handleShimRequest(ctx, repoUrl('/pages/deployments/pages-sha-1')); + expect(parse(cancelledStatusRes).status).toBe('cancelled'); + + const deleteRes = await handleShimRequest(ctx, repoUrl('/pages'), 'DELETE'); + expect(deleteRes.status).toBe(204); + + const deletedRes = await handleShimRequest(ctx, repoUrl('/pages')); + expect(deletedRes.status).toBe(404); + }); + + it('should support workflow Pages sites without a legacy source branch', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/pages'), 'POST', { + build_type: 'workflow', + }); + expect(createRes.status).toBe(201); + expect(parse(createRes).source).toBeNull(); + + const healthRes = await handleShimRequest(ctx, repoUrl('/pages/health')); + expect(healthRes.status).toBe(422); + + const updateRes = await handleShimRequest(ctx, repoUrl('/pages'), 'PUT', { + source: { branch: 'site', path: '/docs' }, + }); + expect(updateRes.status).toBe(204); + + const updated = parse(await handleShimRequest(ctx, repoUrl('/pages'))); + expect(updated.source).toEqual({ branch: 'site', path: '/docs' }); + + const deleteRes = await handleShimRequest(ctx, repoUrl('/pages'), 'DELETE'); + expect(deleteRes.status).toBe(204); + }); + }); + + // ========================================================================= + // GET/POST/DELETE /repos/:did/:repo/deployments and statuses + // ========================================================================= + + describe('deployment endpoints', () => { + it('should create, list, update, and delete deployment environments', async () => { + const environmentName = 'review/apps'; + const encodedName = encodeURIComponent(environmentName); + const createRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + wait_timer : 30, + prevent_self_review : true, + reviewers : [{ type: 'User', id: 1 }, { type: 'Team', id: 2 }], + deployment_branch_policy : { protected_branches: false, custom_branch_policies: true }, + }); + expect(createRes.status).toBe(200); + const created = parse(createRes); + expect(created.name).toBe(environmentName); + expect(created.url).toContain(`/environments/${encodedName}`); + expect(created.deployment_branch_policy).toEqual({ protected_branches: false, custom_branch_policies: true }); + expect(created.protection_rules.map((rule: any) => rule.type).sort()).toEqual(['branch_policy', 'required_reviewers', 'wait_timer']); + const requiredReviewers = created.protection_rules.find((rule: any) => rule.type === 'required_reviewers'); + expect(requiredReviewers.prevent_self_review).toBe(true); + expect(requiredReviewers.reviewers.map((reviewer: any) => reviewer.type)).toEqual(['User', 'Team']); + + const listRes = await handleShimRequest(ctx, repoUrl('/environments?per_page=1')); + expect(listRes.status).toBe(200); + const list = parse(listRes); + expect(list.total_count).toBeGreaterThanOrEqual(1); + expect(list.environments).toHaveLength(1); + expect(list.environments[0].name).toBe(environmentName); + + const getRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).id).toBe(created.id); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + wait_timer : 5, + prevent_self_review : false, + reviewers : [{ type: 'Team', id: 3 }], + deployment_branch_policy : { protected_branches: true, custom_branch_policies: false }, + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.id).toBe(created.id); + expect(updated.deployment_branch_policy).toEqual({ protected_branches: true, custom_branch_policies: false }); + const waitRule = updated.protection_rules.find((rule: any) => rule.type === 'wait_timer'); + expect(waitRule.wait_timer).toBe(5); + + const invalidPolicyRes = await handleShimRequest(ctx, repoUrl('/environments/bad-policy'), 'PUT', { + deployment_branch_policy: { protected_branches: true, custom_branch_policies: true }, + }); + expect(invalidPolicyRes.status).toBe(422); + expect(parse(invalidPolicyRes).message).toContain('protected_branches'); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`)); + expect(missingRes.status).toBe(404); + }); + + it('should create, list, update, and delete deployment environment Actions variables', async () => { + const environmentName = 'actions/staging'; + const encodedName = encodeURIComponent(environmentName); + const createEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + deployment_branch_policy: null, + }); + expect(createEnvironmentRes.status).toBe(200); + + const missingEnvironmentRes = await handleShimRequest(ctx, repoUrl('/environments/missing-env/variables')); + expect(missingEnvironmentRes.status).toBe(404); + + const initialRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables`)); + expect(initialRes.status).toBe(200); + expect(parse(initialRes)).toEqual({ total_count: 0, variables: [] }); + + const createRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables`), 'POST', { + name : 'TARGET_URL', + value : 'https://staging.example.test', + }); + expect(createRes.status).toBe(201); + expect(createRes.body).toBe(''); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables`), 'POST', { + name : 'target_url', + value : 'https://duplicate.example.test', + }); + expect(duplicateRes.status).toBe(409); + + const emptyValueRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables`), 'POST', { + name : 'TARGET_EMPTY', + value : '', + }); + expect(emptyValueRes.status).toBe(201); + + const listRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables?per_page=1`)); + expect(listRes.status).toBe(200); + expect(listRes.headers.Link).toContain('rel="next"'); + const list = parse(listRes); + expect(list.total_count).toBe(2); + expect(list.variables).toHaveLength(1); + + const getRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_URL`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).value).toBe('https://staging.example.test'); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_URL`), 'PATCH', { + name : 'TARGET_HOST', + value : 'staging.example.test', + }); + expect(updateRes.status).toBe(204); + + const oldNameRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_URL`)); + expect(oldNameRes.status).toBe(404); + + const updateEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + wait_timer: 1, + }); + expect(updateEnvironmentRes.status).toBe(200); + + const updatedRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_HOST`)); + expect(updatedRes.status).toBe(200); + expect(parse(updatedRes).value).toBe('staging.example.test'); + + const emptyPatchRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_HOST`), 'PATCH', {}); + expect(emptyPatchRes.status).toBe(422); + + const deleteRenamedRes = await handleShimRequest( + ctx, + repoUrl(`/environments/${encodedName}/variables/TARGET_HOST`), + 'DELETE', + ); + expect(deleteRenamedRes.status).toBe(204); + + const deleteEmptyRes = await handleShimRequest( + ctx, + repoUrl(`/environments/${encodedName}/variables/TARGET_EMPTY`), + 'DELETE', + ); + expect(deleteEmptyRes.status).toBe(204); + + const missingVariableRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/variables/TARGET_HOST`)); + expect(missingVariableRes.status).toBe(404); + + const deleteEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'DELETE'); + expect(deleteEnvironmentRes.status).toBe(204); + }); + + it('should create, list, update, and delete deployment environment Actions secrets', async () => { + const environmentName = 'actions/secrets'; + const encodedName = encodeURIComponent(environmentName); + const createEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + deployment_branch_policy: null, + }); + expect(createEnvironmentRes.status).toBe(200); + + const missingPublicKeyRes = await handleShimRequest(ctx, repoUrl('/environments/missing-env/secrets/public-key')); + expect(missingPublicKeyRes.status).toBe(404); + + const publicKeyRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/public-key`)); + expect(publicKeyRes.status).toBe(200); + const publicKey = parse(publicKeyRes); + expect(typeof publicKey.key_id).toBe('string'); + expect(typeof publicKey.key).toBe('string'); + + const initialRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets`)); + expect(initialRes.status).toBe(200); + expect(parse(initialRes)).toEqual({ total_count: 0, secrets: [] }); + + const createRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/DEPLOY_TOKEN`), 'PUT', { + encrypted_value : 'c2VjcmV0', + key_id : publicKey.key_id, + }); + expect(createRes.status).toBe(201); + expect(createRes.body).toBe(''); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/DEPLOY_TOKEN`), 'PUT', { + encrypted_value : 'bmV3LXNlY3JldA==', + key_id : publicKey.key_id, + }); + expect(updateRes.status).toBe(204); + + const secondRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/API_KEY`), 'PUT', { + encrypted_value : 'YXBpLWtleQ==', + key_id : publicKey.key_id, + }); + expect(secondRes.status).toBe(201); + + const listRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets?per_page=1`)); + expect(listRes.status).toBe(200); + expect(listRes.headers.Link).toContain('rel="next"'); + const list = parse(listRes); + expect(list.total_count).toBe(2); + expect(list.secrets).toHaveLength(1); + expect(list.secrets[0].encrypted_value).toBeUndefined(); + + const updateEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'PUT', { + wait_timer: 2, + }); + expect(updateEnvironmentRes.status).toBe(200); + + const getRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/DEPLOY_TOKEN`)); + expect(getRes.status).toBe(200); + const secret = parse(getRes); + expect(secret.name).toBe('DEPLOY_TOKEN'); + expect(secret.encrypted_value).toBeUndefined(); + + const invalidRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/INVALID_SECRET`), 'PUT', { + key_id: publicKey.key_id, + }); + expect(invalidRes.status).toBe(422); + + const deleteFirstRes = await handleShimRequest( + ctx, + repoUrl(`/environments/${encodedName}/secrets/DEPLOY_TOKEN`), + 'DELETE', + ); + expect(deleteFirstRes.status).toBe(204); + + const deleteSecondRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/API_KEY`), 'DELETE'); + expect(deleteSecondRes.status).toBe(204); + + const missingSecretRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}/secrets/DEPLOY_TOKEN`)); + expect(missingSecretRes.status).toBe(404); + + const deleteEnvironmentRes = await handleShimRequest(ctx, repoUrl(`/environments/${encodedName}`), 'DELETE'); + expect(deleteEnvironmentRes.status).toBe(204); + }); + + it('should create, list, filter, and get deployments', async () => { + const firstRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + ref : 'main', + sha : MAIN_SHA, + task : 'deploy:migrations', + payload : { deploy: 'migrate' }, + environment : 'staging', + description : 'Deploy request from gitd tests.', + transient_environment : true, + production_environment : false, + }); + expect(firstRes.status).toBe(201); + const first = parse(firstRes); + expect(first.ref).toBe('main'); + expect(first.sha).toBe(MAIN_SHA); + expect(first.task).toBe('deploy:migrations'); + expect(first.payload.deploy).toBe('migrate'); + expect(first.environment).toBe('staging'); + expect(first.original_environment).toBe('staging'); + expect(first.creator.login).toBe(testDid); + expect(first.transient_environment).toBe(true); + expect(first.production_environment).toBe(false); + expect(first.statuses_url).toContain(`/deployments/${first.id}/statuses`); + + const secondRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + ref : FEATURE_SHA, + environment : 'staging', + task : 'deploy:migrations', + }); + expect(secondRes.status).toBe(201); + + const listRes = await handleShimRequest(ctx, repoUrl('/deployments?environment=staging&task=deploy:migrations&per_page=1')); + expect(listRes.status).toBe(200); + expect(listRes.headers.Link).toContain('rel="next"'); + const listed = parse(listRes); + expect(listed).toHaveLength(1); + expect(listed[0].environment).toBe('staging'); + + const getRes = await handleShimRequest(ctx, repoUrl(`/deployments/${first.id}`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).id).toBe(first.id); + }); + + it('should create, list, and get deployment statuses', async () => { + const createRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + ref : 'release-branch', + environment : 'qa', + description : 'Deploy QA build.', + }); + expect(createRes.status).toBe(201); + const deployment = parse(createRes); + + const statusRes = await handleShimRequest(ctx, repoUrl(`/deployments/${deployment.id}/statuses`), 'POST', { + state : 'success', + log_url : 'https://example.com/deploy/42/output', + description : 'Deployment finished successfully.', + environment : 'production', + environment_url : 'https://app.example.com', + }); + expect(statusRes.status).toBe(201); + const status = parse(statusRes); + expect(status.state).toBe('success'); + expect(status.target_url).toBe('https://example.com/deploy/42/output'); + expect(status.log_url).toBe('https://example.com/deploy/42/output'); + expect(status.environment).toBe('production'); + expect(status.deployment_url).toContain(`/deployments/${deployment.id}`); + + const listRes = await handleShimRequest(ctx, repoUrl(`/deployments/${deployment.id}/statuses`)); + expect(listRes.status).toBe(200); + const statuses = parse(listRes); + expect(statuses.map((entry: any) => entry.id)).toContain(status.id); + + const getStatusRes = await handleShimRequest(ctx, repoUrl(`/deployments/${deployment.id}/statuses/${status.id}`)); + expect(getStatusRes.status).toBe(200); + expect(parse(getStatusRes).environment_url).toBe('https://app.example.com'); + + const getDeploymentRes = await handleShimRequest(ctx, repoUrl(`/deployments/${deployment.id}`)); + expect(parse(getDeploymentRes).environment).toBe('production'); + }); + + it('should validate deployment inputs and deletion rules', async () => { + const invalidCreateRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + environment: 'production', + }); + expect(invalidCreateRes.status).toBe(422); + expect(parse(invalidCreateRes).message).toContain('ref'); + + const activeRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + ref : 'active-delete-test', + environment : 'review', + }); + expect(activeRes.status).toBe(201); + const active = parse(activeRes); + + const otherRes = await handleShimRequest(ctx, repoUrl('/deployments'), 'POST', { + ref : 'current-delete-test', + environment : 'review', + }); + expect(otherRes.status).toBe(201); + + const invalidStatusRes = await handleShimRequest(ctx, repoUrl(`/deployments/${active.id}/statuses`), 'POST', { + state: 'finished', + }); + expect(invalidStatusRes.status).toBe(422); + expect(parse(invalidStatusRes).message).toContain('state'); + + const activeDeleteRes = await handleShimRequest(ctx, repoUrl(`/deployments/${active.id}`), 'DELETE'); + expect(activeDeleteRes.status).toBe(422); + + const inactiveRes = await handleShimRequest(ctx, repoUrl(`/deployments/${active.id}/statuses`), 'POST', { + state : 'inactive', + description : 'Review app destroyed.', + }); + expect(inactiveRes.status).toBe(201); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/deployments/${active.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const missingDeploymentRes = await handleShimRequest(ctx, repoUrl(`/deployments/${active.id}`)); + expect(missingDeploymentRes.status).toBe(404); + + const missingStatusRes = await handleShimRequest(ctx, repoUrl(`/deployments/${parse(otherRes).id}/statuses/999`)); + expect(missingStatusRes.status).toBe(404); + }); + }); + + // ========================================================================= + // GET/POST/PATCH/DELETE /repos/:did/:repo/commit comments + // ========================================================================= + + describe('commit comment endpoints', () => { + it('should create, list, filter, and get commit comments', async () => { + const firstRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', { + body : 'Great stuff', + path : 'src/index.ts', + position : 4, + line : 14, + }); + expect(firstRes.status).toBe(201); + const first = parse(firstRes); + expect(first.body).toBe('Great stuff'); + expect(first.commit_id).toBe(MAIN_SHA); + expect(first.path).toBe('src/index.ts'); + expect(first.position).toBe(4); + expect(first.line).toBe(14); + expect(first.user.login).toBe(testDid); + expect(first.author_association).toBe('OWNER'); + expect(first.html_url).toContain(`#commitcomment-${first.id}`); + + const secondRes = await handleShimRequest(ctx, repoUrl(`/commits/${FEATURE_SHA}/comments`), 'POST', { + body: 'Feature branch note', + }); + expect(secondRes.status).toBe(201); + const second = parse(secondRes); + + const listRes = await handleShimRequest(ctx, repoUrl('/comments?per_page=1')); + expect(listRes.status).toBe(200); + expect(listRes.headers.Link).toContain('rel="next"'); + const listed = parse(listRes); + expect(listed).toHaveLength(1); + expect(listed[0].id).toBe(first.id); + + const commitListRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`)); + expect(commitListRes.status).toBe(200); + const commitComments = parse(commitListRes); + expect(commitComments.map((comment: any) => comment.id)).toContain(first.id); + expect(commitComments.map((comment: any) => comment.id)).not.toContain(second.id); + + const getRes = await handleShimRequest(ctx, repoUrl(`/comments/${first.id}`)); + expect(getRes.status).toBe(200); + expect(parse(getRes).commit_id).toBe(MAIN_SHA); + }); + + it('should update and delete commit comments', async () => { + const createRes = await handleShimRequest(ctx, repoUrl(`/commits/${FEATURE_SHA}/comments`), 'POST', { + body: 'Needs another look.', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'PATCH', { + body: 'Looks good after follow-up.', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBe('Looks good after follow-up.'); + expect(updated.commit_id).toBe(FEATURE_SHA); + expect(updated.updated_at).toBeDefined(); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const missingRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`)); + expect(missingRes.status).toBe(404); + }); + + it('should honor GitHub commit comment body media types', async () => { + const createRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', { + body : '**Commit** and `code`.', + path : 'src/index.ts', + position : 6, + line : 16, + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + expect(created.body).toBe('**Commit** and `code`.'); + expect(created.body_text).toBe('Commit and code.'); + expect(created.body_html).toBe('

    Commit and code.

    '); + + const listTextRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.text+json', + }); + expect(listTextRes.status).toBe(200); + const listed = parse(listTextRes).find((comment: any) => comment.id === created.id); + expect(listed.body).toBeUndefined(); + expect(listed.body_text).toBe('Commit and code.'); + + const getHtmlRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'GET', {}, null, { + accept: 'application/vnd.github-commitcomment.html+json', + }); + expect(getHtmlRes.status).toBe(200); + const html = parse(getHtmlRes); + expect(html.body).toBeUndefined(); + expect(html.body_html).toBe('

    Commit and code.

    '); + + const updateRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'PATCH', { + body: 'Updated **commit**.', + }, null, { + accept: 'application/vnd.github-commitcomment.full+json', + }); + expect(updateRes.status).toBe(200); + const updated = parse(updateRes); + expect(updated.body).toBe('Updated **commit**.'); + expect(updated.body_text).toBe('Updated commit.'); + expect(updated.body_html).toBe('

    Updated commit.

    '); + + const deleteRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'DELETE'); + expect(deleteRes.status).toBe(204); + }); + + it('should create, list, filter, de-duplicate, and delete commit comment reactions', async () => { + const createCommentRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', { + body: 'Comment that will receive commit reactions.', + }); + expect(createCommentRes.status).toBe(201); + const comment = parse(createCommentRes); + + const emptyRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`)); + expect(emptyRes.status).toBe(200); + expect(parse(emptyRes)).toEqual([]); + + const createHeartRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(createHeartRes.status).toBe(201); + const heart = parse(createHeartRes); + expect(heart.content).toBe('heart'); + expect(heart.user.login).toBe(testDid); + expect(typeof heart.created_at).toBe('string'); + + const duplicateRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`), 'POST', { + content: 'heart', + }); + expect(duplicateRes.status).toBe(200); + expect(parse(duplicateRes).id).toBe(heart.id); + + const createRocketRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`), 'POST', { + content: 'rocket', + }); + expect(createRocketRes.status).toBe(201); + + const filteredRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions?content=heart`)); + expect(filteredRes.status).toBe(200); + expect(parse(filteredRes).map((reaction: any) => reaction.content)).toEqual(['heart']); + + const listRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`)); + expect(listRes.status).toBe(200); + expect(parse(listRes).map((reaction: any) => reaction.content).sort()).toEqual(['heart', 'rocket']); + + const deleteRes = await handleShimRequest( + ctx, + repoUrl(`/comments/${comment.id}/reactions/${heart.id}`), + 'DELETE', + ); + expect(deleteRes.status).toBe(204); + expect(deleteRes.body).toBe(''); + + const afterDeleteRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions?content=heart`)); + expect(afterDeleteRes.status).toBe(200); + expect(parse(afterDeleteRes)).toEqual([]); + }); + + it('should validate commit comment requests and missing comments', async () => { + const invalidCreateRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', {}); + expect(invalidCreateRes.status).toBe(422); + expect(parse(invalidCreateRes).message).toContain('body'); + + const createRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', { + body: 'Validation target', + }); + expect(createRes.status).toBe(201); + const created = parse(createRes); + + const invalidUpdateRes = await handleShimRequest(ctx, repoUrl(`/comments/${created.id}`), 'PATCH', {}); + expect(invalidUpdateRes.status).toBe(422); + expect(parse(invalidUpdateRes).message).toContain('body'); + + const missingGetRes = await handleShimRequest(ctx, repoUrl('/comments/999')); + expect(missingGetRes.status).toBe(404); + + const missingPatchRes = await handleShimRequest(ctx, repoUrl('/comments/999'), 'PATCH', { + body: 'No target.', + }); + expect(missingPatchRes.status).toBe(404); + + const missingDeleteRes = await handleShimRequest(ctx, repoUrl('/comments/999'), 'DELETE'); + expect(missingDeleteRes.status).toBe(404); + }); + + it('should validate commit comment reactions and missing reaction targets', async () => { + const createCommentRes = await handleShimRequest(ctx, repoUrl(`/commits/${MAIN_SHA}/comments`), 'POST', { + body: 'Invalid commit reaction target.', + }); + expect(createCommentRes.status).toBe(201); + const comment = parse(createCommentRes); + + const invalidCreateRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions`), 'POST', { + content: 'shipit', + }); + expect(invalidCreateRes.status).toBe(422); + expect(parse(invalidCreateRes).message).toContain('content'); + + const invalidFilterRes = await handleShimRequest(ctx, repoUrl(`/comments/${comment.id}/reactions?content=shipit`)); + expect(invalidFilterRes.status).toBe(422); + expect(parse(invalidFilterRes).message).toContain('content'); + + const missingCommentRes = await handleShimRequest(ctx, repoUrl('/comments/999/reactions')); + expect(missingCommentRes.status).toBe(404); + + const missingReactionRes = await handleShimRequest( + ctx, + repoUrl(`/comments/${comment.id}/reactions/999`), + 'DELETE', + ); + expect(missingReactionRes.status).toBe(404); + }); + }); + // ========================================================================= // Method not allowed // ========================================================================= @@ -1183,9 +13343,9 @@ describe('GitHub API compatibility shim', () => { expect(res.status).toBe(405); }); - it('should return 405 for GET on /pulls/:number/merge', async () => { + it('should return 405 for POST on /pulls/:number/merge', async () => { const patchNum = numericId(patchRecId); - const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/merge`), 'GET', {}); + const res = await handleShimRequest(ctx, repoUrl(`/pulls/${patchNum}/merge`), 'POST', {}); expect(res.status).toBe(405); }); }); diff --git a/tests/helpers/identity.ts b/tests/helpers/identity.ts new file mode 100644 index 0000000..bb66018 --- /dev/null +++ b/tests/helpers/identity.ts @@ -0,0 +1,54 @@ +import type { EnboxUserAgent } from '@enbox/agent'; + +type PortableDidPublic = { + uri: string; + document: any; + metadata: any; +}; + +/** + * Cache a public DID document in an agent's DID API before local DWN requests + * need to verify messages signed by that DID. + */ +export async function cachePortableDid( + agent: EnboxUserAgent, + portableDid: PortableDidPublic, + tenant: string = agent.agentDid.uri, +): Promise { + void tenant; + await (agent.did as any).cache.set(portableDid.uri, { + didDocument : portableDid.document, + didDocumentMetadata : portableDid.metadata, + didResolutionMetadata : {}, + }); +} + +/** + * Cache the agent's own DID document in its DID API before local DWN requests + * need to verify messages signed by the agent DID. + */ +export async function cacheAgentDid(agent: EnboxUserAgent): Promise { + await cachePortableDid(agent, await agent.agentDid.export(), agent.agentDid.uri); +} + +/** + * Create a test identity that can author encrypted DWN protocol records on + * current Enbox SDKs. DID:JWK identities sign push tokens fine, but they do + * not provide the keyAgreement material needed by encrypted protocol setup. + */ +export function createTestIdentity( + agent: EnboxUserAgent, + name: string, +): ReturnType { + return agent.identity.create({ + didMethod : 'dht', + metadata : { name }, + didOptions : { + services : [], + verificationMethods : [ + { algorithm: 'Ed25519', id: 'sig', purposes: ['assertionMethod', 'authentication'] }, + { algorithm: 'X25519', id: 'enc', purposes: ['keyAgreement'] }, + ], + }, + }); +} diff --git a/tests/helpers/test-daemon.ts b/tests/helpers/test-daemon.ts index bfe245b..b582147 100644 --- a/tests/helpers/test-daemon.ts +++ b/tests/helpers/test-daemon.ts @@ -16,6 +16,8 @@ import { join } from 'node:path'; import { mkdirSync, writeFileSync } from 'node:fs'; +import { cacheAgentDid } from './identity.js'; +import { createTestIdentity } from './identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -64,18 +66,15 @@ async function main(): Promise { await agent.initialize({ password }); await agent.start({ password }); } + await cacheAgentDid(agent); const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Test Daemon' }, - didOptions : { algorithm: 'Ed25519' }, - }); + identity = await createTestIdentity(agent, 'Test Daemon'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const repoHandle = enbox.using(ForgeRepoProtocol); const refsHandle = enbox.using(ForgeRefsProtocol); await repoHandle.configure(); @@ -101,7 +100,9 @@ async function main(): Promise { // Push authentication — inline authenticator WITHOUT nonce replay protection. // Git reuses the same credentials for both ref discovery GET and receive-pack // POST within a single push, so nonce-tracking would reject the second call. - const verifySignature = createDidSignatureVerifier(); + const verifySignature = createDidSignatureVerifier({ + didDocuments: [identity.did.document], + }); const authorizePush = createDwnPushAuthorizer({ repo : repoHandle, ownerDid : identity.did.uri, diff --git a/tests/indexer.spec.ts b/tests/indexer.spec.ts index e8c723e..bc5c9a9 100644 --- a/tests/indexer.spec.ts +++ b/tests/indexer.spec.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -25,9 +26,10 @@ import { ForgeReleasesProtocol } from '../src/releases.js'; import { ForgeRepoProtocol } from '../src/repo.js'; import { ForgeSocialProtocol } from '../src/social.js'; import { ForgeWikiProtocol } from '../src/wiki.js'; -import { handleApiRequest } from '../src/indexer/api.js'; +import { handleExploreRequest } from '../src/indexer/explore.js'; import { IndexerCrawler } from '../src/indexer/crawler.js'; import { IndexerStore } from '../src/indexer/store.js'; +import { handleApiRequest, startApiServer } from '../src/indexer/api.js'; // --------------------------------------------------------------------------- // Constants @@ -47,6 +49,162 @@ function parseJson(body: string): any { return JSON.parse(body); } +function fakeRecord( + id: string, + contextId: string, + data: Record, + tags: Record, +): any { + return { + id, + contextId, + dateCreated : '2026-06-22T00:00:00.000Z', + tags, + data : { + json: async () => data, + }, + }; +} + +function createExternalSubmissionContext( + ownerDid: string, + submitterDid: string, + repoRecordId: string, + repoName: string, +): AgentContext { + const issue = fakeRecord( + 'external-issue-1', + 'external-issue-context', + { title: 'External bug report', body: 'Reported from another DID.' }, + { + status : 'open', + repoDid : ownerDid, + repoRecordId, + repoName, + }, + ); + const patch = fakeRecord( + 'external-patch-1', + 'external-patch-context', + { title: 'External patch', body: 'Patch submitted from another DID.' }, + { + status : 'open', + baseBranch : 'main', + headBranch : 'external/fix', + sourceDid : submitterDid, + repoDid : ownerDid, + repoRecordId, + repoName, + }, + ); + + const emptyQuery = async (): Promise<{ records: any[] }> => ({ records: [] }); + + return { + did : ownerDid, + repo : { records: { query: emptyQuery } }, + refs : { records: { query: emptyQuery } }, + issues : { + records: { + query: async (_path: string, options?: { from?: string }) => ({ + records: options?.from === submitterDid ? [issue] : [], + }), + }, + }, + patches: { + records: { + query: async (_path: string, options?: { from?: string }) => ({ + records: options?.from === submitterDid ? [patch] : [], + }), + }, + }, + ci : { records: { query: emptyQuery } }, + releases : { records: { query: emptyQuery } }, + registry : { records: { query: emptyQuery } }, + social : { records: { query: emptyQuery } }, + notifications : { records: { query: emptyQuery } }, + wiki : { records: { query: emptyQuery } }, + org : { records: { query: emptyQuery } }, + enbox : {}, + } as unknown as AgentContext; +} + +function createSubmissionDecisionContext( + ownerDid: string, + repoRecordId: string, + repoName: string, +): AgentContext { + const repo = fakeRecord( + repoRecordId, + 'owner-repo-context', + { name: repoName, description: '', defaultBranch: 'main' }, + { name: repoName, visibility: 'public' }, + ); + const issueDecision = fakeRecord( + 'issue-decision-1', + 'issue-decision-context', + { + kind : 'issue', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'external-issue-1', + reason : 'not actionable', + decidedBy : ownerDid, + decidedAt : '2026-06-22T00:00:00.000Z', + }, + { + kind : 'issue', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'external-issue-1', + }, + ); + const patchDecision = fakeRecord( + 'patch-decision-1', + 'patch-decision-context', + { + kind : 'patch', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'external-patch-1', + reason : 'out of scope', + decidedBy : ownerDid, + decidedAt : '2026-06-22T00:00:00.000Z', + }, + { + kind : 'patch', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'external-patch-1', + }, + ); + const emptyQuery = async (): Promise<{ records: any[] }> => ({ records: [] }); + + return { + did : ownerDid, + repo : { + records: { + query: async (path: string) => { + if (path === 'repo') { return { records: [repo] }; } + if (path === 'repo/submissionDecision') { return { records: [issueDecision, patchDecision] }; } + return { records: [] }; + }, + }, + }, + refs : { records: { query: emptyQuery } }, + issues : { records: { query: emptyQuery } }, + patches : { records: { query: emptyQuery } }, + ci : { records: { query: emptyQuery } }, + releases : { records: { query: emptyQuery } }, + registry : { records: { query: emptyQuery } }, + social : { records: { query: emptyQuery } }, + notifications : { records: { query: emptyQuery } }, + wiki : { records: { query: emptyQuery } }, + org : { records: { query: emptyQuery } }, + enbox : {}, + } as unknown as AgentContext; +} + // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- @@ -55,6 +213,7 @@ describe('gitd indexer', () => { let ctx: AgentContext; let store: IndexerStore; let crawler: IndexerCrawler; + let externalDid: string; beforeAll(async () => { rmSync(DATA_PATH, { recursive: true, force: true }); @@ -66,14 +225,13 @@ describe('gitd indexer', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Indexer Test' }, - }); + identity = await createTestIdentity(agent, 'Indexer Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; + const externalIdentity = await createTestIdentity(agent, 'External Contributor'); + externalDid = externalIdentity.did.uri; const repo = enbox.using(ForgeRepoProtocol); const refs = enbox.using(ForgeRefsProtocol); @@ -115,6 +273,13 @@ describe('gitd indexer', () => { }); const repoContextId = repoRec!.contextId ?? ''; + // 1b. Create a second repo under the same DID to verify native + // DID/repo addressing in the indexer. + await ctx.repo.records.create('repo', { + data : { name: 'cli-tool', description: 'A native CLI utility', defaultBranch: 'main', dwnEndpoints: [] }, + tags : { name: 'cli-tool', visibility: 'public', language: 'TypeScript' }, + }); + // 2. Create open issues. await ctx.issues.records.create('repo/issue', { data : { title: 'Bug report', body: 'Something broke.' }, @@ -161,7 +326,7 @@ describe('gitd indexer', () => { store = new IndexerStore(); crawler = new IndexerCrawler(ctx, store); - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); @@ -186,6 +351,30 @@ describe('gitd indexer', () => { expect(unitStore.getDids()).toHaveLength(1); }); + it('should collect known DIDs from repos, stars, and follows', () => { + unitStore.putRepo({ + did : 'did:jwk:repo-owner', recordId : 'r1', contextId : 'c1', name : 'repo', + description : '', defaultBranch : 'main', visibility : 'public', + language : '', topics : [], openIssues : 0, + openPatches : 0, releaseCount : 0, lastUpdated : '', indexedAt : '', + }); + unitStore.putStar({ + starrerDid : 'did:jwk:starrer', repoDid : 'did:jwk:starred-owner', + repoRecordId : 'r2', dateCreated : '', + }); + unitStore.putFollow({ + followerDid: 'did:jwk:follower', targetDid: 'did:jwk:target', dateCreated: '', + }); + + expect(unitStore.getKnownDids()).toEqual(expect.arrayContaining([ + 'did:jwk:repo-owner', + 'did:jwk:starrer', + 'did:jwk:starred-owner', + 'did:jwk:follower', + 'did:jwk:target', + ])); + }); + it('should store and retrieve repos', () => { unitStore.putRepo({ did : 'did:jwk:a', recordId : 'r1', contextId : 'c1', name : 'test', @@ -198,6 +387,178 @@ describe('gitd indexer', () => { expect(unitStore.getAllRepos()).toHaveLength(1); }); + it('should retrieve a specific repo by DID and name', () => { + unitStore.putRepo({ + did : 'did:jwk:a', recordId : 'r1', contextId : 'c1', name : 'alpha', + description : '', defaultBranch : 'main', visibility : 'public', + language : 'TypeScript', topics : [], openIssues : 0, + openPatches : 0, releaseCount : 0, lastUpdated : '', indexedAt : '', + }); + unitStore.putRepo({ + did : 'did:jwk:a', recordId : 'r2', contextId : 'c2', name : 'beta', + description : '', defaultBranch : 'main', visibility : 'public', + language : 'Rust', topics : [], openIssues : 0, + openPatches : 0, releaseCount : 0, lastUpdated : '', indexedAt : '', + }); + + expect(unitStore.getRepoByName('did:jwk:a', 'beta')!.recordId).toBe('r2'); + expect(unitStore.getRepoByName('did:jwk:a', 'missing')).toBeUndefined(); + }); + + it('should store work item summaries by repo', () => { + unitStore.putIssue({ + did : 'did:jwk:a', + repoRecordId : 'r1', + repoName : 'repo', + recordId : 'i1', + contextId : 'ci1', + title : 'Bug report', + body : 'Broken.', + status : 'open', + dateCreated : '2026-01-01T00:00:00Z', + indexedAt : '', + }); + unitStore.putPatch({ + did : 'did:jwk:a', + repoRecordId : 'r1', + repoName : 'repo', + recordId : 'p1', + contextId : 'cp1', + title : 'Fix bug', + body : 'Fix.', + status : 'open', + baseBranch : 'main', + headBranch : 'fix', + dateCreated : '2026-01-02T00:00:00Z', + indexedAt : '', + }); + unitStore.putRelease({ + did : 'did:jwk:a', + repoRecordId : 'r1', + repoName : 'repo', + recordId : 'rel1', + contextId : 'cr1', + tagName : 'v1.0.0', + name : 'v1.0.0', + body : '', + draft : false, + prerelease : false, + dateCreated : '2026-01-03T00:00:00Z', + indexedAt : '', + }); + + expect(unitStore.getIssuesForRepo('did:jwk:a', 'r1')[0].title).toBe('Bug report'); + expect(unitStore.getPatchesForRepo('did:jwk:a', 'r1')[0].title).toBe('Fix bug'); + expect(unitStore.getReleasesForRepo('did:jwk:a', 'r1')[0].tagName).toBe('v1.0.0'); + expect(unitStore.getIssueByRecord('did:jwk:a', 'r1', 'i1')!.body).toBe('Broken.'); + expect(unitStore.getPatchByRecord('did:jwk:a', 'r1', 'p1')!.headBranch).toBe('fix'); + expect(unitStore.getReleaseByRecord('did:jwk:a', 'r1', 'rel1')!.name).toBe('v1.0.0'); + }); + + it('should store external issue and patch submissions by target repo', () => { + unitStore.putIssueSubmission({ + submitterDid : 'did:jwk:submitter', + targetDid : 'did:jwk:owner', + targetRepoRecordId : 'r1', + targetRepoName : 'repo', + recordId : 'ext-i1', + contextId : 'ci1', + title : 'External bug', + body : 'From another DID.', + status : 'open', + dateCreated : '2026-01-04T00:00:00Z', + indexedAt : '', + }); + unitStore.putPatchSubmission({ + submitterDid : 'did:jwk:submitter', + targetDid : 'did:jwk:owner', + targetRepoRecordId : 'r1', + targetRepoName : 'repo', + sourceDid : 'did:jwk:submitter', + recordId : 'ext-p1', + contextId : 'cp1', + title : 'External patch', + body : 'From another DID.', + status : 'open', + baseBranch : 'main', + headBranch : 'fix', + dateCreated : '2026-01-05T00:00:00Z', + indexedAt : '', + }); + + const issues = unitStore.getIssueSubmissionsForRepo('did:jwk:owner', 'r1'); + const patches = unitStore.getPatchSubmissionsForRepo('did:jwk:owner', 'r1'); + expect(issues[0].submitterDid).toBe('did:jwk:submitter'); + expect(issues[0].title).toBe('External bug'); + expect(patches[0].sourceDid).toBe('did:jwk:submitter'); + expect(patches[0].headBranch).toBe('fix'); + expect(unitStore.getIssueSubmissionByRecord('did:jwk:owner', 'r1', 'ext-i1')!.body).toBe('From another DID.'); + expect(unitStore.getPatchSubmissionByRecord('did:jwk:owner', 'r1', 'ext-p1')!.headBranch).toBe('fix'); + }); + + it('should hide external submissions with owner-side ignore decisions', () => { + unitStore.putIssueSubmission({ + submitterDid : 'did:jwk:submitter', + targetDid : 'did:jwk:owner', + targetRepoRecordId : 'r1', + targetRepoName : 'repo', + recordId : 'ext-i1', + contextId : 'ci1', + title : 'External bug', + body : 'From another DID.', + status : 'open', + dateCreated : '2026-01-04T00:00:00Z', + indexedAt : '', + }); + unitStore.putPatchSubmission({ + submitterDid : 'did:jwk:submitter', + targetDid : 'did:jwk:owner', + targetRepoRecordId : 'r1', + targetRepoName : 'repo', + sourceDid : 'did:jwk:submitter', + recordId : 'ext-p1', + contextId : 'cp1', + title : 'External patch', + body : 'From another DID.', + status : 'open', + baseBranch : 'main', + headBranch : 'fix', + dateCreated : '2026-01-05T00:00:00Z', + indexedAt : '', + }); + + unitStore.putSubmissionDecision({ + ownerDid : 'did:jwk:owner', + repoRecordId : 'r1', + repoName : 'repo', + kind : 'issue', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'ext-i1', + submissionContextId : 'ci1', + reason : 'not actionable', + dateCreated : '2026-01-06T00:00:00Z', + indexedAt : '', + }); + unitStore.putSubmissionDecision({ + ownerDid : 'did:jwk:owner', + repoRecordId : 'r1', + repoName : 'repo', + kind : 'patch', + decision : 'ignored', + submitterDid : 'did:jwk:submitter', + submissionRecordId : 'ext-p1', + submissionContextId : 'cp1', + reason : 'out of scope', + dateCreated : '2026-01-06T00:00:00Z', + indexedAt : '', + }); + + expect(unitStore.getIssueSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(0); + expect(unitStore.getPatchSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(0); + expect(unitStore.getStats().submissionDecisions).toBe(2); + }); + it('should compute star counts', () => { unitStore.putStar({ starrerDid : 'did:jwk:b', repoDid : 'did:jwk:a', @@ -351,6 +712,29 @@ describe('gitd indexer', () => { expect(profile.repoCount).toBe(1); expect(profile.starCount).toBe(1); expect(profile.followerCount).toBe(1); + + expect(unitStore.getReposForDid('did:jwk:a')).toHaveLength(1); + expect(unitStore.getStarredReposByUser('did:jwk:b')[0].repo?.name).toBe('repo1'); + expect(unitStore.getFollowersForUser('did:jwk:a')[0].followerDid).toBe('did:jwk:c'); + expect(unitStore.getFollowingForUser('did:jwk:c')[0].targetDid).toBe('did:jwk:a'); + }); + + it('should search known users by DID', () => { + unitStore.putRepo({ + did : 'did:jwk:alice', recordId : 'r1', contextId : 'c1', name : 'alice-repo', + description : '', defaultBranch : 'main', visibility : 'public', + language : '', topics : [], openIssues : 0, + openPatches : 0, releaseCount : 0, lastUpdated : '', indexedAt : '', + }); + unitStore.putFollow({ + followerDid: 'did:jwk:bob', targetDid: 'did:jwk:alice', dateCreated: '', + }); + + const results = unitStore.searchUsers('alice'); + expect(results).toHaveLength(1); + expect(results[0].did).toBe('did:jwk:alice'); + expect(results[0].repoCount).toBe(1); + expect(results[0].followerCount).toBe(1); }); it('should return stats', () => { @@ -403,7 +787,7 @@ describe('gitd indexer', () => { }); it('should index repo metadata correctly', async () => { - const repo = store.getRepo(ctx.did); + const repo = store.getRepoByName(ctx.did, 'awesome-lib'); expect(repo).toBeDefined(); expect(repo!.name).toBe('awesome-lib'); expect(repo!.description).toBe('A great library'); @@ -412,20 +796,71 @@ describe('gitd indexer', () => { }); it('should count open issues and patches', async () => { - const repo = store.getRepo(ctx.did); + const repo = store.getRepoByName(ctx.did, 'awesome-lib'); expect(repo).toBeDefined(); expect(repo!.openIssues).toBe(2); expect(repo!.openPatches).toBe(1); }); it('should count releases', async () => { - const repo = store.getRepo(ctx.did); + const repo = store.getRepoByName(ctx.did, 'awesome-lib'); expect(repo).toBeDefined(); expect(repo!.releaseCount).toBe(1); }); it('should index stars', async () => { - expect(store.getStarCount(ctx.did, store.getRepo(ctx.did)!.recordId)).toBeGreaterThanOrEqual(1); + expect(store.getStarCount(ctx.did, store.getRepoByName(ctx.did, 'awesome-lib')!.recordId)).toBeGreaterThanOrEqual(1); + }); + + it('should index issue, patch, and release summaries', async () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib'); + expect(repo).toBeDefined(); + expect(store.getIssuesForRepo(ctx.did, repo!.recordId).map(issue => issue.title)).toContain('Bug report'); + expect(store.getIssuesForRepo(ctx.did, repo!.recordId).map(issue => issue.status)).toContain('closed'); + expect(store.getPatchesForRepo(ctx.did, repo!.recordId).map(patch => patch.title)).toContain('Fix bug'); + expect(store.getReleasesForRepo(ctx.did, repo!.recordId).map(release => release.tagName)).toContain('v1.0.0'); + }); + + it('should index external issue and patch submissions by target repo', async () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib'); + expect(repo).toBeDefined(); + + const submissionCrawler = new IndexerCrawler( + createExternalSubmissionContext(ctx.did, externalDid, repo!.recordId, repo!.name), + store, + ); + const result = await submissionCrawler.crawl({ dids: [externalDid] }); + expect(result.crawledDids).toBe(1); + expect(result.newIssueSubmissions).toBe(1); + expect(result.newPatchSubmissions).toBe(1); + expect(result.errors).toHaveLength(0); + + const issues = store.getIssueSubmissionsForRepo(ctx.did, repo!.recordId); + const patches = store.getPatchSubmissionsForRepo(ctx.did, repo!.recordId); + expect(issues.map(issue => issue.title)).toContain('External bug report'); + expect(issues[0].submitterDid).toBe(externalDid); + expect(patches.map(patch => patch.title)).toContain('External patch'); + expect(patches[0].sourceDid).toBe(externalDid); + }); + + it('should index owner-side ignore decisions and filter external submissions', async () => { + const isolatedStore = new IndexerStore(); + const submissionCrawler = new IndexerCrawler( + createExternalSubmissionContext('did:jwk:owner', 'did:jwk:submitter', 'r1', 'repo'), + isolatedStore, + ); + await submissionCrawler.crawl({ dids: ['did:jwk:submitter'] }); + expect(isolatedStore.getIssueSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(1); + expect(isolatedStore.getPatchSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(1); + + const decisionCrawler = new IndexerCrawler( + createSubmissionDecisionContext('did:jwk:owner', 'r1', 'repo'), + isolatedStore, + ); + const result = await decisionCrawler.crawl({ dids: ['did:jwk:owner'] }); + expect(result.newSubmissionDecisions).toBe(2); + expect(isolatedStore.getIssueSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(0); + expect(isolatedStore.getPatchSubmissionsForRepo('did:jwk:owner', 'r1')).toHaveLength(0); }); it('should index follows', async () => { @@ -512,6 +947,20 @@ describe('gitd indexer', () => { expect(data.openIssues).toBe(2); }); + it('GET /api/repos/:did/:repo should return the named repo detail', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/cli-tool`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.name).toBe('cli-tool'); + expect(data.description).toBe('A native CLI utility'); + expect(data.openIssues).toBe(0); + }); + + it('GET /api/repos/:did/:repo should return 404 for unknown repo name', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/missing-repo`)); + expect(res.status).toBe(404); + }); + it('GET /api/repos/:did should return 404 for unknown DID', () => { const res = handleApiRequest(store, apiUrl('/api/repos/did:jwk:nonexistent')); expect(res.status).toBe(404); @@ -525,6 +974,120 @@ describe('gitd indexer', () => { expect(data.stars.length).toBeGreaterThanOrEqual(1); }); + it('GET /api/repos/:did/:repo/stars should return stars for the named repo', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/stars`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.repo.name).toBe('awesome-lib'); + expect(data.starCount).toBeGreaterThanOrEqual(1); + expect(data.stars.length).toBeGreaterThanOrEqual(1); + }); + + it('GET /api/repos/:did/:repo/issues should return issue summaries', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/issues`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((issue: any) => issue.title)).toContain('Bug report'); + expect(data.map((issue: any) => issue.status)).toContain('closed'); + }); + + it('GET /api/repos/:did/:repo/issues/:recordId should return issue detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const issue = store.getIssuesForRepo(ctx.did, repo.recordId).find(item => item.title === 'Bug report')!; + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/issues/${encodeURIComponent(issue.recordId)}`)); + + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.title).toBe('Bug report'); + expect(data.body).toBe('Something broke.'); + expect(data.status).toBe('open'); + }); + + it('GET /api/repos/:did/:repo/patches should return patch summaries', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/patches`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((patch: any) => patch.title)).toContain('Fix bug'); + expect(data[0].baseBranch).toBe('main'); + }); + + it('GET /api/repos/:did/:repo/patches/:recordId should return patch detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const patch = store.getPatchesForRepo(ctx.did, repo.recordId).find(item => item.title === 'Fix bug')!; + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/patches/${encodeURIComponent(patch.recordId)}`)); + + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.title).toBe('Fix bug'); + expect(data.body).toBe('Fixes #1.'); + expect(data.baseBranch).toBe('main'); + }); + + it('GET /api/repos/:did/:repo/submissions/issues should return external issue submissions', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/submissions/issues`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((issue: any) => issue.title)).toContain('External bug report'); + expect(data[0].submitterDid).toBe(externalDid); + }); + + it('GET /api/repos/:did/:repo/submissions/issues/:recordId should return external issue submission detail', () => { + const res = handleApiRequest( + store, + apiUrl(`/api/repos/${ctx.did}/awesome-lib/submissions/issues/${encodeURIComponent('external-issue-1')}`), + ); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.title).toBe('External bug report'); + expect(data.body).toBe('Reported from another DID.'); + expect(data.recordId).toBe('external-issue-1'); + expect(data.submitterDid).toBe(externalDid); + }); + + it('GET /api/repos/:did/:repo/submissions/patches should return external patch submissions', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/submissions/patches`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((patch: any) => patch.title)).toContain('External patch'); + expect(data[0].sourceDid).toBe(externalDid); + }); + + it('GET /api/repos/:did/:repo/submissions/patches/:recordId should return external patch submission detail', () => { + const res = handleApiRequest( + store, + apiUrl(`/api/repos/${ctx.did}/awesome-lib/submissions/patches/${encodeURIComponent('external-patch-1')}`), + ); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.title).toBe('External patch'); + expect(data.body).toBe('Patch submitted from another DID.'); + expect(data.headBranch).toBe('external/fix'); + expect(data.recordId).toBe('external-patch-1'); + }); + + it('GET /api/repos/:did/:repo/releases should return release summaries', () => { + const res = handleApiRequest(store, apiUrl(`/api/repos/${ctx.did}/awesome-lib/releases`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((release: any) => release.tagName)).toContain('v1.0.0'); + expect(data[0].draft).toBe(false); + }); + + it('GET /api/repos/:did/:repo/releases/:recordId should return release detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const release = store.getReleasesForRepo(ctx.did, repo.recordId).find(item => item.tagName === 'v1.0.0')!; + const res = handleApiRequest( + store, + apiUrl(`/api/repos/${ctx.did}/awesome-lib/releases/${encodeURIComponent(release.recordId)}`), + ); + + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.tagName).toBe('v1.0.0'); + expect(data.body).toBe('First release.'); + expect(data.draft).toBe(false); + }); + it('GET /api/users/:did should return user profile', () => { const res = handleApiRequest(store, apiUrl(`/api/users/${ctx.did}`)); expect(res.status).toBe(200); @@ -534,9 +1097,246 @@ describe('gitd indexer', () => { expect(data.followerCount).toBeGreaterThanOrEqual(1); }); + it('GET /api/users/:did/repos should return repos owned by the user', () => { + const res = handleApiRequest(store, apiUrl(`/api/users/${ctx.did}/repos`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((repo: any) => repo.name)).toContain('awesome-lib'); + expect(data.map((repo: any) => repo.name)).toContain('cli-tool'); + }); + + it('GET /api/users/:did/starred should return repos starred by the user', () => { + const res = handleApiRequest(store, apiUrl(`/api/users/${ctx.did}/starred`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0].starrerDid).toBe(ctx.did); + expect(data[0].repo.name).toBe('awesome-lib'); + }); + + it('GET /api/users/:did/followers should return user followers', () => { + const res = handleApiRequest(store, apiUrl(`/api/users/${ctx.did}/followers`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0].targetDid).toBe(ctx.did); + }); + + it('GET /api/users/:did/following should return users followed by the user', () => { + const res = handleApiRequest(store, apiUrl(`/api/users/${ctx.did}/following`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0].followerDid).toBe(ctx.did); + }); + + it('GET /api/users/search?q= should search indexed DIDs', () => { + const suffix = ctx.did.split(':').at(-1)!.slice(0, 8); + const res = handleApiRequest(store, apiUrl(`/api/users/search?q=${suffix}`)); + expect(res.status).toBe(200); + const data = parseJson(res.body); + expect(data.map((user: any) => user.did)).toContain(ctx.did); + expect(data[0].score).toBeGreaterThan(0); + }); + + it('GET /api/users/search without q should return 400', () => { + const res = handleApiRequest(store, apiUrl('/api/users/search')); + expect(res.status).toBe(400); + }); + it('GET /api/unknown should return 404', () => { const res = handleApiRequest(store, apiUrl('/api/unknown')); expect(res.status).toBe(404); }); }); + + // ========================================================================= + // Indexer Explore UI + // ========================================================================= + + describe('Explore UI', () => { + it('GET / should render the native explore page', () => { + const res = handleExploreRequest(store, apiUrl('/')); + expect(res.status).toBe(200); + expect(res.body).toContain('gitd indexer'); + expect(res.body).toContain('Trending'); + expect(res.body).toContain('awesome-lib'); + }); + + it('GET /repos?q=awesome should render repository search results', () => { + const res = handleExploreRequest(store, apiUrl('/repos?q=awesome')); + expect(res.status).toBe(200); + expect(res.body).toContain('Repositories'); + expect(res.body).toContain('awesome-lib'); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib`); + }); + + it('GET /repos/:did/:repo should render repository detail', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib`)); + expect(res.status).toBe(200); + expect(res.body).toContain('gitd clone'); + expect(res.body).toContain(`${ctx.did}/awesome-lib`); + expect(res.body).toContain('Issues'); + expect(res.body).toContain('Patches'); + expect(res.body).toContain('Bug report'); + expect(res.body).toContain('Fix bug'); + expect(res.body).toContain('v1.0.0'); + expect(res.body).toContain('External Issues'); + expect(res.body).toContain('External Patches'); + expect(res.body).toContain('External bug report'); + expect(res.body).toContain('External patch'); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/issues/`); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/patches/`); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/releases/`); + }); + + it('GET /repos/:did/:repo/issues should render issue summaries', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/issues`)); + expect(res.status).toBe(200); + expect(res.body).toContain('Issues'); + expect(res.body).toContain('Bug report'); + expect(res.body).toContain('closed'); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/issues/`); + }); + + it('GET /repos/:did/:repo/issues/:recordId should render issue detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const issue = store.getIssuesForRepo(ctx.did, repo.recordId).find(item => item.title === 'Bug report')!; + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/issues/${encodeURIComponent(issue.recordId)}`)); + + expect(res.status).toBe(200); + expect(res.body).toContain('Bug report'); + expect(res.body).toContain('Something broke.'); + expect(res.body).toContain('open'); + }); + + it('GET /repos/:did/:repo/patches should render patch summaries', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/patches`)); + expect(res.status).toBe(200); + expect(res.body).toContain('Patches'); + expect(res.body).toContain('Fix bug'); + expect(res.body).toContain('main'); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/patches/`); + }); + + it('GET /repos/:did/:repo/patches/:recordId should render patch detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const patch = store.getPatchesForRepo(ctx.did, repo.recordId).find(item => item.title === 'Fix bug')!; + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/patches/${encodeURIComponent(patch.recordId)}`)); + + expect(res.status).toBe(200); + expect(res.body).toContain('Fix bug'); + expect(res.body).toContain('Fixes #1.'); + expect(res.body).toContain('main'); + }); + + it('GET /repos/:did/:repo/releases should render release summaries', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/releases`)); + expect(res.status).toBe(200); + expect(res.body).toContain('Releases'); + expect(res.body).toContain('v1.0.0'); + expect(res.body).toContain(`/repos/${ctx.did}/awesome-lib/releases/`); + }); + + it('GET /repos/:did/:repo/releases/:recordId should render release detail', () => { + const repo = store.getRepoByName(ctx.did, 'awesome-lib')!; + const release = store.getReleasesForRepo(ctx.did, repo.recordId).find(item => item.tagName === 'v1.0.0')!; + const res = handleExploreRequest( + store, + apiUrl(`/repos/${ctx.did}/awesome-lib/releases/${encodeURIComponent(release.recordId)}`), + ); + + expect(res.status).toBe(200); + expect(res.body).toContain('v1.0.0'); + expect(res.body).toContain('First release.'); + expect(res.body).toContain('published'); + }); + + it('GET /repos/:did/:repo/submissions/issues should render external issue submissions', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/submissions/issues`)); + expect(res.status).toBe(200); + expect(res.body).toContain('External Issues'); + expect(res.body).toContain('External bug report'); + expect(res.body).toContain('/submissions/issues/external-issue-1'); + expect(res.body).toContain(externalDid.slice(0, 18)); + }); + + it('GET /repos/:did/:repo/submissions/issues/:recordId should render external issue submission detail', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/submissions/issues/external-issue-1`)); + expect(res.status).toBe(200); + expect(res.body).toContain('External bug report'); + expect(res.body).toContain('Reported from another DID.'); + expect(res.body).toContain(`gitd issue accept ${externalDid} external-issue-1`); + expect(res.body).toContain(`gitd issue ignore ${externalDid} external-issue-1`); + }); + + it('GET /repos/:did/:repo/submissions/patches should render external patch submissions', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/submissions/patches`)); + expect(res.status).toBe(200); + expect(res.body).toContain('External Patches'); + expect(res.body).toContain('External patch'); + expect(res.body).toContain('/submissions/patches/external-patch-1'); + expect(res.body).toContain('external/fix'); + }); + + it('GET /repos/:did/:repo/submissions/patches/:recordId should render external patch submission detail', () => { + const res = handleExploreRequest(store, apiUrl(`/repos/${ctx.did}/awesome-lib/submissions/patches/external-patch-1`)); + expect(res.status).toBe(200); + expect(res.body).toContain('External patch'); + expect(res.body).toContain('Patch submitted from another DID.'); + expect(res.body).toContain('external/fix'); + expect(res.body).toContain(`gitd pr accept ${externalDid} external-patch-1`); + expect(res.body).toContain(`gitd pr ignore ${externalDid} external-patch-1`); + }); + + it('GET /users?q= should render user search results', () => { + const suffix = ctx.did.split(':').at(-1)!.slice(0, 8); + const res = handleExploreRequest(store, apiUrl(`/users?q=${suffix}`)); + expect(res.status).toBe(200); + expect(res.body).toContain('Users'); + expect(res.body).toContain(ctx.did); + }); + + it('GET /users/:did should render user profile sections', () => { + const res = handleExploreRequest(store, apiUrl(`/users/${ctx.did}`)); + expect(res.status).toBe(200); + expect(res.body).toContain('Repositories'); + expect(res.body).toContain('Starred'); + expect(res.body).toContain('Followers'); + expect(res.body).toContain('Following'); + }); + + it('GET /missing should return an HTML 404', () => { + const res = handleExploreRequest(store, apiUrl('/missing')); + expect(res.status).toBe(404); + expect(res.body).toContain('Page not found'); + }); + + it('startApiServer should serve Explore HTML outside /api', async () => { + const server = startApiServer({ store, port: 0 }); + await new Promise((resolve) => { + if (server.listening) { + resolve(); + return; + } + server.once('listening', () => resolve()); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected TCP server address'); + } + + const res = await fetch(`http://127.0.0.1:${address.port}/repos?q=awesome`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(await res.text()).toContain('awesome-lib'); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => err ? reject(err) : resolve()); + }); + } + }); + }); }); diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 0eb76b6..78d6d85 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -6,6 +6,9 @@ * role composition functions as designed. */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; + +import { rmSync } from 'node:fs'; + import { DidKey, UniversalResolver } from '@enbox/dids'; import type { Persona } from '@enbox/dwn-sdk-js'; @@ -13,8 +16,9 @@ import type { Persona } from '@enbox/dwn-sdk-js'; import { DataStoreLevel, DataStream, + DurableEventLog, Dwn, - EventEmitterEventLog, + EventEmitterWakePublisher, Jws, MessageStoreLevel, ProtocolsConfigure, @@ -23,7 +27,6 @@ import { RecordsRead, RecordsWrite, ResumableTaskStoreLevel, - StateIndexLevel, TestDataGenerator, } from '@enbox/dwn-sdk-js'; @@ -39,6 +42,9 @@ import { // --------------------------------------------------------------------------- const encoder = new TextEncoder(); +const MESSAGE_STORE_PATH = '__TESTDATA__/int-msg'; +const DATA_STORE_PATH = '__TESTDATA__/int-data'; +const TASK_STORE_PATH = '__TESTDATA__/int-tasks'; /** Creates a ProtocolsConfigure, processes it, and asserts success. */ async function installProtocol( @@ -115,7 +121,6 @@ describe('gitd integration', () => { let dwn: Dwn; let messageStore: MessageStoreLevel; let dataStore: DataStoreLevel; - let stateIndex: StateIndexLevel; let resumableTaskStore: ResumableTaskStoreLevel; // Personas @@ -125,20 +130,23 @@ describe('gitd integration', () => { let stranger: Persona; // No role beforeAll(async () => { + rmSync(MESSAGE_STORE_PATH, { recursive: true, force: true }); + rmSync(DATA_STORE_PATH, { recursive: true, force: true }); + rmSync(TASK_STORE_PATH, { recursive: true, force: true }); + const didResolver = new UniversalResolver({ didResolvers: [DidKey] }); - messageStore = new MessageStoreLevel({ blockstoreLocation: '__TESTDATA__/int-msg', indexLocation: '__TESTDATA__/int-idx' }); - dataStore = new DataStoreLevel({ blockstoreLocation: '__TESTDATA__/int-data' }); - stateIndex = new StateIndexLevel({ location: '__TESTDATA__/int-state' }); - resumableTaskStore = new ResumableTaskStoreLevel({ location: '__TESTDATA__/int-tasks' }); - const eventLog = new EventEmitterEventLog(); + const wakePublisher = new EventEmitterWakePublisher(); + messageStore = new MessageStoreLevel({ location: MESSAGE_STORE_PATH, wakePublisher }); + dataStore = new DataStoreLevel({ blockstoreLocation: DATA_STORE_PATH }); + resumableTaskStore = new ResumableTaskStoreLevel({ location: TASK_STORE_PATH }); + const eventLog = new DurableEventLog(messageStore, wakePublisher); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, stateIndex, resumableTaskStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, resumableTaskStore, eventLog }); }); beforeEach(async () => { await messageStore.clear(); await dataStore.clear(); - await stateIndex.clear(); await resumableTaskStore.clear(); // Create fresh personas for each test @@ -149,7 +157,10 @@ describe('gitd integration', () => { }); afterAll(async () => { - await dwn.close(); + if (dwn) { await dwn.close(); } + rmSync(MESSAGE_STORE_PATH, { recursive: true, force: true }); + rmSync(DATA_STORE_PATH, { recursive: true, force: true }); + rmSync(TASK_STORE_PATH, { recursive: true, force: true }); }); // ========================================================================= @@ -247,6 +258,7 @@ describe('gitd integration', () => { const reply = await queryRecords(dwn, owner.did, owner, { protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/readme', + contextId : repo.message.contextId, }); expect(reply.entries?.length).toBe(1); }); @@ -300,6 +312,7 @@ describe('gitd integration', () => { const reply = await queryRecords(dwn, owner.did, owner, { protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/contributor', + contextId : repo.message.contextId, }); expect(reply.entries?.length).toBe(1); expect(reply.entries![0].recordId).toBe(roleWrite.message.recordId); @@ -373,6 +386,7 @@ describe('gitd integration', () => { const reply = await queryRecords(dwn, owner.did, owner, { protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', + contextId : repoContextId, }); expect(reply.entries?.length).toBe(1); expect(reply.entries![0].recordId).toBe(issue.message.recordId); @@ -395,11 +409,12 @@ describe('gitd integration', () => { const reply = await queryRecords(dwn, owner.did, owner, { protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', + contextId : repoContextId, }); expect(reply.entries?.length).toBe(1); }); - it('should allow issue creation from anyone (open model)', async () => { + it('should reject issue creation from non-collaborators', async () => { const { repoContextId } = await setupRepoWithRoles(); const data = encoder.encode(JSON.stringify({ title: 'External report', body: 'Found a bug' })); @@ -414,7 +429,7 @@ describe('gitd integration', () => { signer : Jws.createSigner(stranger), }); const reply = await dwn.processMessage(owner.did, write.message, { dataStream: DataStream.fromBytes(data) }); - expect(reply.status.code).toBe(202); + expect(reply.status.code).toBeGreaterThanOrEqual(400); }); it('should create nested comments on issues', async () => { @@ -590,6 +605,7 @@ describe('gitd integration', () => { const reply = await queryRecords(dwn, owner.did, owner, { protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch', + contextId : repoContextId, }); expect(reply.entries?.length).toBe(1); expect(reply.entries![0].recordId).toBe(patch.message.recordId); @@ -691,8 +707,8 @@ describe('gitd integration', () => { protocolRole : 'repo:repo/contributor', }); - // First merge result succeeds - await writeRecord(dwn, owner.did, { + // First merge result succeeds. + const firstMerge = await writeRecord(dwn, owner.did, { author : maintainer, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/mergeResult', @@ -703,7 +719,7 @@ describe('gitd integration', () => { protocolRole : 'repo:repo/maintainer', }); - // Second merge result should be rejected + // `$recordLimit` candidates are accepted and projected at read time. const data = encoder.encode(JSON.stringify({ mergedBy: maintainer.did })); const write = await RecordsWrite.create({ protocol : ForgePatchesDefinition.protocol, @@ -717,8 +733,15 @@ describe('gitd integration', () => { signer : Jws.createSigner(maintainer), }); const reply = await dwn.processMessage(owner.did, write.message, { dataStream: DataStream.fromBytes(data) }); - // $recordLimit exceeded — DWN rejects with non-202 status - expect(reply.status.code).not.toBe(202); + expect(reply.status.code).toBe(202); + + const projected = await queryRecords(dwn, owner.did, owner, { + protocol : ForgePatchesDefinition.protocol, + protocolPath : 'repo/patch/mergeResult', + contextId : patch.message.contextId, + }); + expect(projected.entries?.length).toBe(1); + expect(projected.entries![0].recordId).toBe(firstMerge.message.recordId); }); }); diff --git a/tests/profiles.spec.ts b/tests/profiles.spec.ts index 2c54436..9ef0f9a 100644 --- a/tests/profiles.spec.ts +++ b/tests/profiles.spec.ts @@ -211,11 +211,15 @@ describe('connectAgent with profile dataPath', () => { afterAll(() => { rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync('DATA', { recursive: true, force: true }); + rmSync('RESOLVERCACHE', { recursive: true, force: true }); }); - // SQLite migration + Dwn.create() takes longer than the default 5 s timeout. + // SQLite migration + Dwn.create() can take longer with newer Enbox SDKs. it('should create agent at specified dataPath', async () => { rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync('DATA', { recursive: true, force: true }); + rmSync('RESOLVERCACHE', { recursive: true, force: true }); const { connectAgent } = await import('../src/cli/agent.js'); const result = await connectAgent({ @@ -230,7 +234,7 @@ describe('connectAgent with profile dataPath', () => { expect(existsSync(TEST_DATA)).toBe(true); // Verify the DWN uses SQLite instead of LevelDB. expect(existsSync(`${TEST_DATA}/dwn.sqlite`)).toBe(true); - }, 15_000); + }, 30_000); it('should not create RESOLVERCACHE in CWD', () => { // The DWN resolver cache should live inside the profile data path, diff --git a/tests/protocols.spec.ts b/tests/protocols.spec.ts index 5935868..1b07ea4 100644 --- a/tests/protocols.spec.ts +++ b/tests/protocols.spec.ts @@ -49,14 +49,17 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.types.maintainer).toBeDefined(); expect(ForgeRepoDefinition.types.triager).toBeDefined(); expect(ForgeRepoDefinition.types.contributor).toBeDefined(); + expect(ForgeRepoDefinition.types.viewer).toBeDefined(); expect(ForgeRepoDefinition.types.topic).toBeDefined(); + expect(ForgeRepoDefinition.types.submissionDecision).toBeDefined(); expect(ForgeRepoDefinition.types.webhook).toBeDefined(); }); - it('should mark maintainer, triager, and contributor as roles', () => { + it('should mark maintainer, triager, contributor, and viewer as roles', () => { expect(ForgeRepoDefinition.structure.repo.maintainer.$role).toBe(true); expect(ForgeRepoDefinition.structure.repo.triager.$role).toBe(true); expect(ForgeRepoDefinition.structure.repo.contributor.$role).toBe(true); + expect(ForgeRepoDefinition.structure.repo.viewer.$role).toBe(true); }); it('should NOT have $recordLimit on repo (multi-repo)', () => { @@ -72,6 +75,9 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.structure.repo.$tags?.$requiredTags).toContain('name'); expect(ForgeRepoDefinition.structure.repo.$tags?.$requiredTags).toContain('visibility'); expect(ForgeRepoDefinition.structure.repo.$tags?.$allowUndefinedTags).toBe(false); + expect(ForgeRepoDefinition.structure.repo.$tags?.forkedFromDid).toEqual({ type: 'string' }); + expect(ForgeRepoDefinition.structure.repo.$tags?.forkedFromRepoName).toEqual({ type: 'string' }); + expect(ForgeRepoDefinition.structure.repo.$tags?.forkedFromRecordId).toEqual({ type: 'string' }); }); it('should restrict visibility tag to public and private', () => { @@ -83,6 +89,7 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.structure.repo.maintainer.$tags?.$requiredTags).toContain('did'); expect(ForgeRepoDefinition.structure.repo.triager.$tags?.$requiredTags).toContain('did'); expect(ForgeRepoDefinition.structure.repo.contributor.$tags?.$requiredTags).toContain('did'); + expect(ForgeRepoDefinition.structure.repo.viewer.$tags?.$requiredTags).toContain('did'); }); it('should require encryption on webhook type', () => { @@ -131,6 +138,19 @@ describe('@enbox/gitd', () => { expect(maintainerAction!.can).toContain('squash'); }); + it('should support maintainer decisions for external submissions', () => { + const decision = ForgeRepoDefinition.structure.repo.submissionDecision; + expect(decision.$immutable).toBe(true); + expect(decision.$tags.$requiredTags).toEqual(['kind', 'decision', 'submitterDid', 'submissionRecordId']); + expect(decision.$tags.kind.enum).toEqual(['issue', 'patch']); + expect(decision.$tags.decision.enum).toEqual(['ignored']); + + const anyoneAction = decision.$actions.find((a) => 'who' in a && a.who === 'anyone'); + const maintainerAction = decision.$actions.find((a) => 'role' in a && a.role === 'repo/maintainer'); + expect(anyoneAction?.can).toContain('read'); + expect(maintainerAction?.can).toContain('create'); + }); + it('should wrap definition via defineProtocol()', () => { expect(ForgeRepoProtocol.definition).toBe(ForgeRepoDefinition); }); @@ -255,14 +275,39 @@ describe('@enbox/gitd', () => { expect(status.enum).toEqual(['open', 'closed']); }); - it('should nest comment, label, statusChange, and assignment under issue', () => { + it('should define GitHub-compatible issue lock tags', () => { + const tags = ForgeIssuesDefinition.structure.repo.issue.$tags; + const locked = tags?.locked as { enum: string[] }; + const lockReason = tags?.lockReason as { enum: string[] }; + expect(locked.enum).toEqual(['true', 'false']); + expect(lockReason.enum).toEqual(['off-topic', 'too heated', 'resolved', 'spam']); + }); + + it('should allow target repo tags for external issue submissions', () => { + const tags = ForgeIssuesDefinition.structure.repo.issue.$tags!; + expect(tags.repoDid).toEqual({ type: 'string' }); + expect(tags.repoRecordId).toEqual({ type: 'string' }); + expect(tags.repoName).toEqual({ type: 'string' }); + expect(tags.$requiredTags).not.toContain('repoDid'); + }); + + it('should allow provenance tags for accepted external issues', () => { + const tags = ForgeIssuesDefinition.structure.repo.issue.$tags!; + expect(tags.submitterDid).toEqual({ type: 'string' }); + expect(tags.submissionRecordId).toEqual({ type: 'string' }); + expect(tags.submissionContextId).toEqual({ type: 'string' }); + expect(tags.$requiredTags).not.toContain('submissionRecordId'); + }); + + it('should nest reaction, comment, label, statusChange, and assignment under issue', () => { + expect(ForgeIssuesDefinition.structure.repo.issue.reaction).toBeDefined(); expect(ForgeIssuesDefinition.structure.repo.issue.comment).toBeDefined(); expect(ForgeIssuesDefinition.structure.repo.issue.label).toBeDefined(); expect(ForgeIssuesDefinition.structure.repo.issue.statusChange).toBeDefined(); expect(ForgeIssuesDefinition.structure.repo.issue.assignment).toBeDefined(); }); - it('should nest reaction under comment (3-level nesting)', () => { + it('should also nest reaction under comment (3-level nesting)', () => { expect(ForgeIssuesDefinition.structure.repo.issue.comment.reaction).toBeDefined(); }); @@ -279,6 +324,14 @@ describe('@enbox/gitd', () => { expect(maintainerAction).toBeDefined(); }); + it('should keep direct issue writes role-gated', () => { + const actions = ForgeIssuesDefinition.structure.repo.issue.$actions!; + const anyoneAction = actions.find((a) => a.who === 'anyone'); + expect(anyoneAction).toBeDefined(); + expect(anyoneAction!.can).toContain('read'); + expect(anyoneAction!.can).not.toContain('create'); + }); + it('should allow issue author to update their own issue', () => { const actions = ForgeIssuesDefinition.structure.repo.issue.$actions!; const authorAction = actions.find((a) => a.who === 'author' && a.of === 'repo/issue'); @@ -333,6 +386,22 @@ describe('@enbox/gitd', () => { expect(status.enum).toEqual(['draft', 'open', 'closed', 'merged']); }); + it('should allow target repo tags for external patch submissions', () => { + const tags = ForgePatchesDefinition.structure.repo.patch.$tags!; + expect(tags.repoDid).toEqual({ type: 'string' }); + expect(tags.repoRecordId).toEqual({ type: 'string' }); + expect(tags.repoName).toEqual({ type: 'string' }); + expect(tags.$requiredTags).not.toContain('repoDid'); + }); + + it('should allow provenance tags for accepted external patches', () => { + const tags = ForgePatchesDefinition.structure.repo.patch.$tags!; + expect(tags.submitterDid).toEqual({ type: 'string' }); + expect(tags.submissionRecordId).toEqual({ type: 'string' }); + expect(tags.submissionContextId).toEqual({ type: 'string' }); + expect(tags.$requiredTags).not.toContain('submissionRecordId'); + }); + it('should nest revision, review, statusChange, and mergeResult under patch', () => { expect(ForgePatchesDefinition.structure.repo.patch.revision).toBeDefined(); expect(ForgePatchesDefinition.structure.repo.patch.review).toBeDefined(); @@ -403,12 +472,15 @@ describe('@enbox/gitd', () => { expect(strategy.enum).toEqual(['merge', 'squash', 'rebase']); }); - it('should allow anyone to create and read patches (open submissions)', () => { + it('should keep direct patch writes role-gated', () => { const actions = ForgePatchesDefinition.structure.repo.patch.$actions!; const anyoneAction = actions.find((a) => a.who === 'anyone'); + const contributorAction = actions.find((a) => a.role === 'repo:repo/contributor'); expect(anyoneAction).toBeDefined(); - expect(anyoneAction!.can).toContain('create'); expect(anyoneAction!.can).toContain('read'); + expect(anyoneAction!.can).not.toContain('create'); + expect(contributorAction).toBeDefined(); + expect(contributorAction!.can).toContain('create'); }); it('should allow anyone to read revisions, reviews, statusChanges, and mergeResults', () => { @@ -431,12 +503,24 @@ describe('@enbox/gitd', () => { expect(mergeActions.find((a) => a.who === 'anyone')!.can).toContain('read'); }); - it('should allow anyone to create reviews and review comments', () => { + it('should keep direct review writes role-gated', () => { const reviewActions = ForgePatchesDefinition.structure.repo.patch.review.$actions!; - expect(reviewActions.find((a) => a.who === 'anyone')!.can).toContain('create'); + expect(reviewActions.find((a) => a.who === 'anyone')!.can).not.toContain('create'); + expect(reviewActions.find((a) => a.role === 'repo:repo/contributor')!.can).toContain('create'); const commentActions = ForgePatchesDefinition.structure.repo.patch.review.reviewComment.$actions!; - expect(commentActions.find((a) => a.who === 'anyone')!.can).toContain('create'); + expect(commentActions.find((a) => a.who === 'anyone')!.can).not.toContain('create'); + expect(commentActions.find((a) => a.role === 'repo:repo/contributor')!.can).toContain('create'); + }); + + it('should allow review comment authors and maintainers to update and delete review comments', () => { + const commentActions = ForgePatchesDefinition.structure.repo.patch.review.reviewComment.$actions!; + const authorAction = commentActions.find((a) => a.who === 'author' && a.of === 'repo/patch/review/reviewComment'); + const maintainerAction = commentActions.find((a) => a.role === 'repo:repo/maintainer'); + expect(authorAction?.can).toContain('update'); + expect(authorAction?.can).toContain('delete'); + expect(maintainerAction?.can).toContain('update'); + expect(maintainerAction?.can).toContain('delete'); }); it('should allow patch author to create revisions and status changes', () => { @@ -510,10 +594,16 @@ describe('@enbox/gitd', () => { }); it('should allow checkSuite author to create checkRuns and artifacts', () => { + const suiteActions = ForgeCiDefinition.structure.repo.checkSuite.$actions!; + const maintainerSuite = suiteActions.find((a) => a.role === 'repo:repo/maintainer'); + expect(maintainerSuite).toBeDefined(); + expect(maintainerSuite!.can).toContain('delete'); + const runActions = ForgeCiDefinition.structure.repo.checkSuite.checkRun.$actions!; const authorRun = runActions.find((a) => a.who === 'author' && a.of === 'repo/checkSuite'); expect(authorRun).toBeDefined(); expect(authorRun!.can).toContain('create'); + expect(authorRun!.can).toContain('delete'); const artifactActions = ForgeCiDefinition.structure.repo.checkSuite.checkRun.artifact.$actions!; const authorArtifact = artifactActions.find((a) => a.who === 'author' && a.of === 'repo/checkSuite'); @@ -698,15 +788,21 @@ describe('@enbox/gitd', () => { expect(ForgeSocialDefinition.uses).toBeUndefined(); }); - it('should define star, follow, and activity types', () => { + it('should define star, follow, gist, gistComment, gistStar, and activity types', () => { expect(ForgeSocialDefinition.types.star).toBeDefined(); expect(ForgeSocialDefinition.types.follow).toBeDefined(); + expect(ForgeSocialDefinition.types.gist).toBeDefined(); + expect(ForgeSocialDefinition.types.gistComment).toBeDefined(); + expect(ForgeSocialDefinition.types.gistStar).toBeDefined(); expect(ForgeSocialDefinition.types.activity).toBeDefined(); }); it('should have all types at the top level (flat structure)', () => { expect(ForgeSocialDefinition.structure.star).toBeDefined(); expect(ForgeSocialDefinition.structure.follow).toBeDefined(); + expect(ForgeSocialDefinition.structure.gist).toBeDefined(); + expect(ForgeSocialDefinition.structure.gistComment).toBeDefined(); + expect(ForgeSocialDefinition.structure.gistStar).toBeDefined(); expect(ForgeSocialDefinition.structure.activity).toBeDefined(); }); @@ -721,6 +817,26 @@ describe('@enbox/gitd', () => { expect(tags?.$requiredTags).toContain('targetDid'); }); + it('should require visibility tag on gist', () => { + const tags = ForgeSocialDefinition.structure.gist.$tags; + expect(tags?.$requiredTags).toContain('visibility'); + const visibility = tags?.visibility as { enum: string[] }; + expect(visibility.enum).toEqual(['public', 'secret']); + expect(tags?.forkOfOwnerDid).toEqual({ type: 'string' }); + expect(tags?.forkOfGistId).toEqual({ type: 'string' }); + }); + + it('should require gistId tag on gist comments', () => { + const tags = ForgeSocialDefinition.structure.gistComment.$tags; + expect(tags?.$requiredTags).toContain('gistId'); + }); + + it('should require ownerDid and gistId tags on gist stars', () => { + const tags = ForgeSocialDefinition.structure.gistStar.$tags; + expect(tags?.$requiredTags).toContain('ownerDid'); + expect(tags?.$requiredTags).toContain('gistId'); + }); + it('should restrict activity type to expected events', () => { const activityType = ForgeSocialDefinition.structure.activity.$tags?.type as { enum: string[] }; expect(activityType.enum).toContain('push'); @@ -734,13 +850,22 @@ describe('@enbox/gitd', () => { expect(ForgeSocialDefinition.structure.activity.$tags?.$allowUndefinedTags).toBe(true); }); - it('should allow anyone to read star, follow, and activity', () => { + it('should allow anyone to read star, follow, gist, gistComment, gistStar, and activity', () => { const starActions = ForgeSocialDefinition.structure.star.$actions!; expect(starActions.find((a) => a.who === 'anyone')!.can).toContain('read'); const followActions = ForgeSocialDefinition.structure.follow.$actions!; expect(followActions.find((a) => a.who === 'anyone')!.can).toContain('read'); + const gistActions = ForgeSocialDefinition.structure.gist.$actions!; + expect(gistActions.find((a) => a.who === 'anyone')!.can).toContain('read'); + + const gistCommentActions = ForgeSocialDefinition.structure.gistComment.$actions!; + expect(gistCommentActions.find((a) => a.who === 'anyone')!.can).toContain('read'); + + const gistStarActions = ForgeSocialDefinition.structure.gistStar.$actions!; + expect(gistStarActions.find((a) => a.who === 'anyone')!.can).toContain('read'); + const activityActions = ForgeSocialDefinition.structure.activity.$actions!; expect(activityActions.find((a) => a.who === 'anyone')!.can).toContain('read'); }); @@ -883,12 +1008,17 @@ describe('@enbox/gitd', () => { expect(ForgeOrgDefinition.uses).toBeUndefined(); }); - it('should define org, owner, member, team, and teamMember types', () => { + it('should define org, owner, member, team, teamMember, and webhook types', () => { expect(ForgeOrgDefinition.types.org).toBeDefined(); expect(ForgeOrgDefinition.types.owner).toBeDefined(); expect(ForgeOrgDefinition.types.member).toBeDefined(); expect(ForgeOrgDefinition.types.team).toBeDefined(); expect(ForgeOrgDefinition.types.teamMember).toBeDefined(); + expect(ForgeOrgDefinition.types.webhook).toBeDefined(); + }); + + it('should require encryption on organization webhook type', () => { + expect(ForgeOrgDefinition.types.webhook.encryptionRequired).toBe(true); }); it('should enforce $recordLimit on org singleton', () => { @@ -907,10 +1037,11 @@ describe('@enbox/gitd', () => { expect(ForgeOrgDefinition.structure.org.team.teamMember.$tags?.$requiredTags).toContain('did'); }); - it('should nest owner, member, and team under org', () => { + it('should nest owner, member, team, and webhook under org', () => { expect(ForgeOrgDefinition.structure.org.owner).toBeDefined(); expect(ForgeOrgDefinition.structure.org.member).toBeDefined(); expect(ForgeOrgDefinition.structure.org.team).toBeDefined(); + expect(ForgeOrgDefinition.structure.org.webhook).toBeDefined(); }); it('should nest teamMember under team (3-level nesting)', () => { diff --git a/tests/push-authorizer.spec.ts b/tests/push-authorizer.spec.ts index d3d1184..de0f83b 100644 --- a/tests/push-authorizer.spec.ts +++ b/tests/push-authorizer.spec.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -36,13 +37,10 @@ describe('createDwnPushAuthorizer', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Push Auth Test' }, - }); + identity = await createTestIdentity(agent, 'Push Auth Test'); } - enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + enbox = new Enbox({ agent, connectedDid: identity.did.uri }); did = identity.did.uri; repo = enbox.using(ForgeRepoProtocol); @@ -54,7 +52,7 @@ describe('createDwnPushAuthorizer', () => { tags : { name: 'test-repo', visibility: 'public' }, }); repoContextId = record.contextId!; - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); @@ -111,6 +109,21 @@ describe('createDwnPushAuthorizer', () => { expect(result).toBe(true); }); + it('should reject a DID with only a viewer role', async () => { + const viewerDid = 'did:jwk:viewer1'; + + await repo.records.create('repo/viewer' as any, { + data : { did: viewerDid, alias: 'Viewer' }, + tags : { did: viewerDid }, + parentContextId : repoContextId, + recipient : viewerDid, + }); + + const authorizer = createDwnPushAuthorizer({ repo: repo as any, ownerDid: did }); + const result = await authorizer(viewerDid, did, 'test-repo'); + expect(result).toBe(false); + }); + it('should reject a DID after its role is revoked', async () => { const tempDid = 'did:jwk:temporary1'; diff --git a/tests/resolver.spec.ts b/tests/resolver.spec.ts index 9cb134e..657c35a 100644 --- a/tests/resolver.spec.ts +++ b/tests/resolver.spec.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -61,13 +62,10 @@ describe('Package resolver, attestation, and trust chain', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Resolver Test' }, - }); + identity = await createTestIdentity(agent, 'Resolver Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; testDid = did; @@ -164,7 +162,7 @@ describe('Package resolver, attestation, and trust chain', () => { data : { name: 'empty-pkg' }, tags : { name: 'empty-pkg', ecosystem: 'npm' }, }); - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); diff --git a/tests/schemas.spec.ts b/tests/schemas.spec.ts index aa60e0f..f7d6e4e 100644 --- a/tests/schemas.spec.ts +++ b/tests/schemas.spec.ts @@ -117,6 +117,9 @@ describe('JSON Schemas', () => { expect(schema.required).toContain('secret'); expect(schema.required).toContain('events'); expect(schema.required).toContain('active'); + expect(schema.properties.deliveries).toBeDefined(); + expect(schema.properties.deliveries.additionalProperties.required).toContain('guid'); + expect(schema.properties.deliveries.additionalProperties.required).toContain('statusCode'); }); it('settings.json should support branchProtection as flexible object', () => { @@ -125,6 +128,217 @@ describe('JSON Schemas', () => { expect(schema.properties.branchProtection.type).toBe('object'); }); + it('settings.json should support repository label catalog entries', () => { + const schema = readSchema('repo', 'settings.json'); + const labels = schema.properties.labels; + expect(labels).toBeDefined(); + expect(labels.type).toBe('object'); + expect(labels.additionalProperties.required).toContain('name'); + expect(labels.additionalProperties.required).toContain('color'); + }); + + it('settings.json should support repository milestone catalog entries', () => { + const schema = readSchema('repo', 'settings.json'); + const milestones = schema.properties.milestones; + expect(milestones).toBeDefined(); + expect(milestones.type).toBe('object'); + expect(milestones.additionalProperties.required).toContain('title'); + expect(milestones.additionalProperties.required).toContain('number'); + expect(milestones.additionalProperties.properties.state.enum).toEqual(['open', 'closed']); + }); + + it('settings.json should support GitHub comment reaction overlays', () => { + const schema = readSchema('repo', 'settings.json'); + const reaction = schema.definitions.githubReaction; + expect(reaction.required).toEqual(['id', 'userDid', 'content', 'createdAt']); + expect(reaction.properties.content.enum).toEqual(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']); + + const commitComments = schema.properties.commitComments; + expect(commitComments.additionalProperties.properties.reactions.additionalProperties.$ref).toBe('#/definitions/githubReaction'); + expect(schema.properties.pullReviewCommentReactions.additionalProperties.additionalProperties.$ref) + .toBe('#/definitions/githubReaction'); + }); + + it('settings.json should support repository deploy keys', () => { + const schema = readSchema('repo', 'settings.json'); + const deployKeys = schema.properties.deployKeys; + expect(deployKeys).toBeDefined(); + expect(deployKeys.type).toBe('object'); + expect(deployKeys.additionalProperties.required).toContain('key'); + expect(deployKeys.additionalProperties.required).toContain('readOnly'); + expect(deployKeys.additionalProperties.properties.lastUsed.type).toEqual(['string', 'null']); + }); + + it('settings.json should support repository autolinks, interaction limits, and rulesets', () => { + const schema = readSchema('repo', 'settings.json'); + const autolinks = schema.properties.autolinks; + expect(autolinks).toBeDefined(); + expect(autolinks.type).toBe('object'); + expect(autolinks.additionalProperties.required).toEqual(['id', 'keyPrefix', 'urlTemplate', 'isAlphanumeric']); + + const interactionLimit = schema.properties.interactionLimit; + expect(interactionLimit).toBeDefined(); + expect(interactionLimit.required).toEqual(['limit', 'expiresAt']); + expect(interactionLimit.properties.limit.enum).toEqual(['existing_users', 'contributors_only', 'collaborators_only']); + expect(schema.properties.vulnerabilityAlertsEnabled.type).toBe('boolean'); + expect(schema.properties.automatedSecurityFixesEnabled.type).toBe('boolean'); + expect(schema.properties.automatedSecurityFixesPaused.type).toBe('boolean'); + expect(schema.properties.privateVulnerabilityReportingEnabled.type).toBe('boolean'); + expect(schema.properties.immutableReleasesEnabled.type).toBe('boolean'); + expect(schema.properties.customProperties.type).toBe('object'); + expect(schema.properties.customProperties.additionalProperties.oneOf[0].type).toBe('string'); + expect(schema.properties.customProperties.additionalProperties.oneOf[1].items.type).toBe('string'); + expect(schema.properties.attestations.type).toBe('object'); + expect(schema.properties.attestations.additionalProperties.required).toContain('subjectDigest'); + expect(schema.properties.attestations.additionalProperties.properties.bundle.type).toBe('object'); + expect(schema.properties.issueTypes.type).toBe('object'); + expect(schema.properties.issueTypes.additionalProperties.required).toEqual(['id', 'name', 'createdAt', 'updatedAt']); + expect(schema.properties.transferRequest.required).toEqual(['id', 'newOwner', 'requestedBy', 'createdAt']); + expect(schema.properties.transferRequest.properties.teamIds.items.minimum).toBe(1); + + const rulesets = schema.properties.rulesets; + expect(rulesets).toBeDefined(); + expect(rulesets.type).toBe('object'); + expect(rulesets.additionalProperties.required).toContain('name'); + expect(rulesets.additionalProperties.required).toContain('rules'); + expect(rulesets.additionalProperties.properties.target.enum).toEqual(['branch', 'tag', 'push']); + expect(rulesets.additionalProperties.properties.enforcement.enum).toEqual(['disabled', 'active', 'evaluate']); + + const ruleSuites = schema.properties.ruleSuites; + expect(ruleSuites).toBeDefined(); + expect(ruleSuites.type).toBe('object'); + expect(ruleSuites.additionalProperties.required).toEqual([ + 'id', + 'actorName', + 'beforeSha', + 'afterSha', + 'ref', + 'pushedAt', + 'result', + ]); + expect(ruleSuites.additionalProperties.properties.result.enum).toEqual(['pass', 'fail', 'bypass']); + expect(ruleSuites.additionalProperties.properties.ruleEvaluations.items.required).toEqual([ + 'ruleSource', + 'enforcement', + 'result', + 'ruleType', + ]); + }); + + it('settings.json should support repository deployment environments', () => { + const schema = readSchema('repo', 'settings.json'); + const environments = schema.properties.environments; + expect(environments).toBeDefined(); + expect(environments.type).toBe('object'); + expect(environments.additionalProperties.required).toContain('id'); + expect(environments.additionalProperties.required).toContain('name'); + expect(environments.additionalProperties.properties.reviewers.items.properties.type.enum).toEqual(['User', 'Team']); + expect(environments.additionalProperties.properties.deploymentBranchPolicy.type).toEqual(['object', 'null']); + const variables = environments.additionalProperties.properties.variables; + expect(variables).toBeDefined(); + expect(variables.type).toBe('object'); + expect(variables.additionalProperties.required).toEqual(['name', 'value', 'createdAt', 'updatedAt']); + const secrets = environments.additionalProperties.properties.secrets; + expect(secrets).toBeDefined(); + expect(secrets.type).toBe('object'); + expect(secrets.additionalProperties.required).toEqual(['name', 'encryptedValue', 'keyId', 'createdAt', 'updatedAt']); + }); + + it('settings.json should support repository Actions variables', () => { + const schema = readSchema('repo', 'settings.json'); + const variables = schema.properties.actionsVariables; + expect(variables).toBeDefined(); + expect(variables.type).toBe('object'); + expect(variables.additionalProperties.required).toEqual(['name', 'value', 'createdAt', 'updatedAt']); + }); + + it('settings.json should support repository Actions secrets', () => { + const schema = readSchema('repo', 'settings.json'); + const secrets = schema.properties.actionsSecrets; + expect(secrets).toBeDefined(); + expect(secrets.type).toBe('object'); + expect(secrets.additionalProperties.required).toEqual(['name', 'encryptedValue', 'keyId', 'createdAt', 'updatedAt']); + }); + + it('settings.json should support repository Actions caches and limits', () => { + const schema = readSchema('repo', 'settings.json'); + const caches = schema.properties.actionsCaches; + expect(caches).toBeDefined(); + expect(caches.type).toBe('object'); + expect(caches.additionalProperties.required).toEqual([ + 'id', + 'ref', + 'key', + 'version', + 'lastAccessedAt', + 'createdAt', + 'sizeInBytes', + ]); + expect(caches.additionalProperties.properties.sizeInBytes.minimum).toBe(0); + expect(schema.properties.actionsCacheRetentionLimitDays.minimum).toBe(1); + expect(schema.properties.actionsCacheStorageLimitGb.minimum).toBe(1); + }); + + it('settings.json should support repository Actions permissions', () => { + const schema = readSchema('repo', 'settings.json'); + const permissions = schema.properties.actionsPermissions; + expect(permissions).toBeDefined(); + expect(permissions.required).toEqual([ + 'enabled', + 'allowedActions', + 'shaPinningRequired', + 'selectedActions', + 'defaultWorkflowPermissions', + 'canApprovePullRequestReviews', + ]); + expect(permissions.properties.allowedActions.enum).toEqual(['all', 'local_only', 'selected']); + expect(permissions.properties.defaultWorkflowPermissions.enum).toEqual(['read', 'write']); + expect(permissions.properties.selectedActions.required).toEqual([ + 'githubOwnedAllowed', + 'verifiedAllowed', + 'patternsAllowed', + ]); + }); + + it('settings.json should support repository GitHub Pages state', () => { + const schema = readSchema('repo', 'settings.json'); + const pages = schema.properties.pages; + expect(pages).toBeDefined(); + expect(pages.required).toEqual([ + 'status', + 'cname', + 'custom404', + 'source', + 'buildType', + 'public', + 'httpsEnforced', + 'createdAt', + 'updatedAt', + ]); + expect(pages.properties.status.enum).toEqual(['queued', 'building', 'built', 'errored']); + expect(pages.properties.buildType.enum).toEqual(['legacy', 'workflow']); + expect(pages.properties.source.properties.path.enum).toEqual(['/', '/docs']); + expect(pages.properties.builds.additionalProperties.required).toEqual([ + 'id', + 'status', + 'errorMessage', + 'pusherDid', + 'commit', + 'duration', + 'createdAt', + 'updatedAt', + ]); + expect(pages.properties.deployments.additionalProperties.required).toEqual([ + 'id', + 'environment', + 'pagesBuildVersion', + 'oidcTokenHash', + 'status', + 'createdAt', + 'updatedAt', + ]); + }); + it('settings.json should restrict mergeStrategies items to merge, squash, rebase', () => { const schema = readSchema('repo', 'settings.json'); const items = schema.properties.mergeStrategies.items; @@ -154,6 +368,24 @@ describe('JSON Schemas', () => { const schema = readSchema('issues', 'assignment.json'); expect(schema.required).toContain('assigneeDid'); }); + + it('issue-dependency.json should require issueId', () => { + const schema = readSchema('issues', 'issue-dependency.json'); + expect(schema.required).toContain('issueId'); + }); + + it('issue-sub-issue.json should require issueId and priority', () => { + const schema = readSchema('issues', 'issue-sub-issue.json'); + expect(schema.required).toContain('issueId'); + expect(schema.required).toContain('priority'); + }); + + it('issue-field-value.json should require fieldId, dataType, and value', () => { + const schema = readSchema('issues', 'issue-field-value.json'); + expect(schema.required).toContain('fieldId'); + expect(schema.required).toContain('dataType'); + expect(schema.required).toContain('value'); + }); }); describe('patches schemas', () => { @@ -202,6 +434,14 @@ describe('JSON Schemas', () => { const schema = readSchema('releases', 'release.json'); expect(schema.required).toContain('name'); }); + + it('release.json should support GitHub release reactions', () => { + const schema = readSchema('releases', 'release.json'); + const reaction = schema.definitions.releaseReaction; + expect(reaction.required).toEqual(['id', 'userDid', 'content', 'createdAt']); + expect(reaction.properties.content.enum).toEqual(['+1', 'laugh', 'heart', 'hooray', 'rocket', 'eyes']); + expect(schema.properties.reactions.additionalProperties.$ref).toBe('#/definitions/releaseReaction'); + }); }); describe('registry schemas', () => { @@ -234,6 +474,80 @@ describe('JSON Schemas', () => { expect(schema.required).toContain('targetDid'); }); + it('block.json should require targetDid', () => { + const schema = readSchema('social', 'block.json'); + expect(schema.required).toContain('targetDid'); + expect(schema.properties.blockedAt.type).toBe('string'); + }); + + it('email.json should require email', () => { + const schema = readSchema('social', 'email.json'); + expect(schema.required).toContain('email'); + expect(schema.properties.primary.type).toBe('boolean'); + expect(schema.properties.verified.type).toBe('boolean'); + expect(schema.properties.visibility.enum).toEqual(['public', 'private', null]); + }); + + it('profile.json should require did and support public profile fields', () => { + const schema = readSchema('social', 'profile.json'); + expect(schema.required).toContain('did'); + expect(schema.properties.name.type).toEqual(['string', 'null']); + expect(schema.properties.twitterUsername.type).toEqual(['string', 'null']); + expect(schema.properties.hireable.type).toEqual(['boolean', 'null']); + }); + + it('social-account.json should require provider and url', () => { + const schema = readSchema('social', 'social-account.json'); + expect(schema.required).toContain('provider'); + expect(schema.required).toContain('url'); + expect(schema.properties.createdAt.type).toBe('string'); + }); + + it('gist.json should require files and support gist file content', () => { + const schema = readSchema('social', 'gist.json'); + expect(schema.required).toContain('files'); + expect(schema.properties.files.minProperties).toBe(1); + expect(schema.properties.files.additionalProperties.required).toContain('filename'); + expect(schema.properties.files.additionalProperties.required).toContain('content'); + expect(schema.properties.forkOfOwnerDid.type).toBe('string'); + expect(schema.properties.forkOfGistId.type).toBe('string'); + }); + + it('gist-comment.json should require gistId and body', () => { + const schema = readSchema('social', 'gist-comment.json'); + expect(schema.required).toContain('gistId'); + expect(schema.required).toContain('body'); + }); + + it('gist-star.json should require ownerDid and gistId', () => { + const schema = readSchema('social', 'gist-star.json'); + expect(schema.required).toContain('ownerDid'); + expect(schema.required).toContain('gistId'); + }); + + it('gpg-key.json should require armoredPublicKey', () => { + const schema = readSchema('social', 'gpg-key.json'); + expect(schema.required).toContain('armoredPublicKey'); + expect(schema.properties.keyId.type).toBe('string'); + expect(schema.properties.emails.items.required).toContain('email'); + expect(schema.properties.subkeys.items.required).toContain('publicKey'); + }); + + it('ssh-key.json should require key', () => { + const schema = readSchema('social', 'ssh-key.json'); + expect(schema.required).toContain('key'); + expect(schema.properties.title.type).toBe('string'); + expect(schema.properties.verified.type).toBe('boolean'); + expect(schema.properties.readOnly.type).toBe('boolean'); + }); + + it('ssh-signing-key.json should require key', () => { + const schema = readSchema('social', 'ssh-signing-key.json'); + expect(schema.required).toContain('key'); + expect(schema.properties.title.type).toBe('string'); + expect(schema.properties.createdAt.type).toBe('string'); + }); + it('activity.json should require type and summary', () => { const schema = readSchema('social', 'activity.json'); expect(schema.required).toContain('type'); @@ -284,16 +598,60 @@ describe('JSON Schemas', () => { it('org-member.json should require did', () => { const schema = readSchema('org', 'org-member.json'); expect(schema.required).toContain('did'); + expect(schema.properties.public.type).toBe('boolean'); + }); + + it('org-blocked-user.json should require did', () => { + const schema = readSchema('org', 'org-blocked-user.json'); + expect(schema.required).toContain('did'); + expect(schema.properties.blockedAt.type).toBe('string'); + expect(schema.properties.blockedBy.type).toBe('string'); + }); + + it('org-webhook.json should require url, secret, events, and active', () => { + const schema = readSchema('org', 'org-webhook.json'); + expect(schema.required).toContain('url'); + expect(schema.required).toContain('secret'); + expect(schema.required).toContain('events'); + expect(schema.required).toContain('active'); + expect(schema.properties.deliveries).toBeDefined(); + expect(schema.properties.deliveries.additionalProperties.required).toContain('guid'); + expect(schema.properties.deliveries.additionalProperties.required).toContain('statusCode'); + }); + + it('org-issue-field.json should require name and dataType', () => { + const schema = readSchema('org', 'org-issue-field.json'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('dataType'); + expect(schema.properties.dataType.enum).toEqual(['text', 'single_select', 'number', 'date', 'multi_select']); + }); + + it('org-issue-type.json should require name and isEnabled', () => { + const schema = readSchema('org', 'org-issue-type.json'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('isEnabled'); + expect(schema.properties.color.enum).toEqual(['gray', 'blue', 'green', 'yellow', 'orange', 'red', 'pink', 'purple', null]); + }); + + it('org-custom-property.json should require propertyName and valueType', () => { + const schema = readSchema('org', 'org-custom-property.json'); + expect(schema.required).toContain('propertyName'); + expect(schema.required).toContain('valueType'); + expect(schema.properties.valueType.enum).toEqual(['string', 'single_select', 'multi_select', 'true_false', 'url']); }); it('team.json should require name', () => { const schema = readSchema('org', 'team.json'); expect(schema.required).toContain('name'); + expect(schema.properties.privacy.enum).toEqual(['visible', 'secret']); + expect(schema.properties.repositories.additionalProperties.required).toEqual(['owner', 'repo', 'permission']); }); it('team-member.json should require did', () => { const schema = readSchema('org', 'team-member.json'); expect(schema.required).toContain('did'); + expect(schema.properties.role.enum).toEqual(['member', 'maintainer']); + expect(schema.properties.state.enum).toEqual(['active', 'pending']); }); }); }); diff --git a/tests/shims.spec.ts b/tests/shims.spec.ts index d2c728d..3cf12e0 100644 --- a/tests/shims.spec.ts +++ b/tests/shims.spec.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -103,13 +104,10 @@ describe('Package manager shims', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Shim Test' }, - }); + identity = await createTestIdentity(agent, 'Shim Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; testDid = did; @@ -241,7 +239,7 @@ describe('Package manager shims', () => { tags : { filename: 'manifest.json', contentType: 'application/octet-stream', size: OCI_MANIFEST_BYTES.byteLength }, parentContextId : ociV1Ctx, } as any); - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); diff --git a/tests/verify.spec.ts b/tests/verify.spec.ts index 3d78fc4..cec4ba9 100644 --- a/tests/verify.spec.ts +++ b/tests/verify.spec.ts @@ -71,6 +71,28 @@ describe('createDidSignatureVerifier', () => { expect(result).toBe(false); }); + it('should verify against a trusted local DID document before resolver lookup', async () => { + const bearerDid = await DidJwk.create({ options: { algorithm: 'Ed25519' } }); + const localDid = 'did:dht:local-verifier'; + const localDocument = structuredClone(bearerDid.document) as any; + localDocument.id = localDid; + localDocument.verificationMethod = localDocument.verificationMethod.map((method: any) => ({ + ...method, + id : `${localDid}#sig`, + controller : localDid, + })); + localDocument.authentication = [`${localDid}#sig`]; + + const verifier = createDidSignatureVerifier({ didDocuments: [localDocument] }); + const data = new TextEncoder().encode('local document data'); + const portableDid = await bearerDid.export(); + const privateKey = portableDid.privateKeys?.[0]; + const signature = await sign(privateKey as Record, data); + + const result = await verifier(localDid, data, signature); + expect(result).toBe(true); + }); + it('should reject when DID cannot be resolved', async () => { const verifier = createDidSignatureVerifier(); const data = new TextEncoder().encode('test'); diff --git a/tests/web.spec.ts b/tests/web.spec.ts index 3823f59..3e3a254 100644 --- a/tests/web.spec.ts +++ b/tests/web.spec.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { rmSync } from 'node:fs'; +import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; @@ -78,13 +79,10 @@ describe('gitd web UI', () => { const identities = await agent.identity.list(); let identity = identities[0]; if (!identity) { - identity = await agent.identity.create({ - didMethod : 'jwk', - metadata : { name: 'Web UI Test' }, - }); + identity = await createTestIdentity(agent, 'Web UI Test'); } - const enbox = Enbox.connect({ agent, connectedDid: identity.did.uri }); + const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); const did = identity.did.uri; testDid = did; @@ -178,7 +176,7 @@ describe('gitd web UI', () => { tags : { slug: 'getting-started', title: 'Getting Started' }, parentContextId : repoContextId, } as any); - }); + }, 30_000); afterAll(() => { rmSync(DATA_PATH, { recursive: true, force: true }); From 031466fd9750f23d6ad0753685d2cb73d786b35c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 23 Jun 2026 12:57:03 +0000 Subject: [PATCH 2/5] Implement repo-level public DWN MVP --- PLAN.md | 6 + README.md | 5 +- REPO_LEVEL_MVP_PLAN.md | 694 +++++++++++++++++++++++++ schemas/refs/branch-state.json | 60 +++ schemas/refs/branch.json | 28 + schemas/repo/moderation-event.json | 57 +++ src/branch-state.ts | 249 +++++++++ src/cli/agent.ts | 142 +++++- src/cli/commands/clone.ts | 4 + src/cli/commands/init.ts | 2 +- src/cli/commands/issue.ts | 206 +++++++- src/cli/commands/mod.ts | 289 +++++++++++ src/cli/commands/pr.ts | 505 +++++++++++++++--- src/cli/commands/repo.ts | 117 ++++- src/cli/commands/serve-lifecycle.ts | 30 +- src/cli/commands/serve.ts | 423 ++++++++++++++- src/cli/dispatch.ts | 117 +++++ src/cli/flags.ts | 31 ++ src/cli/local-rpc.ts | 89 ++++ src/cli/main.ts | 169 ++---- src/cli/moderation-state.ts | 133 +++++ src/cli/record-send.ts | 189 +++++++ src/cli/repo-context.ts | 175 ++++++- src/daemon/lifecycle.ts | 58 ++- src/daemon/lockfile.ts | 47 +- src/git-remote/credential-main.ts | 2 +- src/git-remote/index.ts | 3 +- src/git-remote/main.ts | 60 ++- src/git-remote/resolve.ts | 116 ++++- src/git-server/auth.ts | 25 +- src/git-server/bundle-restore.ts | 80 ++- src/git-server/bundle-sync.ts | 189 ++++++- src/git-server/did-service.ts | 5 + src/git-server/http-handler.ts | 97 +++- src/git-server/index.ts | 2 + src/git-server/push-authorizer.ts | 74 ++- src/git-server/push-updates.ts | 84 +++ src/git-server/ref-sync.ts | 147 +++++- src/git-server/remote-branch-sync.ts | 323 ++++++++++++ src/git-server/server.ts | 88 +++- src/git-server/verify.ts | 11 +- src/index.ts | 1 + src/issues.ts | 14 +- src/patches.ts | 2 + src/profiles/config.ts | 7 +- src/refs.ts | 131 ++++- src/repo.ts | 91 +++- tests/branch-state.spec.ts | 196 +++++++ tests/bundle-restore.spec.ts | 100 +++- tests/bundle-sync.spec.ts | 36 ++ tests/cli.spec.ts | 538 +++++++++++++++++++ tests/daemon-lifecycle.spec.ts | 32 +- tests/e2e-collaboration.spec.ts | 726 ++++++++++++++++++++++++-- tests/e2e-passive-dwn-sync.spec.ts | 738 +++++++++++++++++++++++++++ tests/e2e-profile-helpers.spec.ts | 207 ++++++++ tests/e2e.spec.ts | 83 ++- tests/exports.spec.ts | 16 +- tests/git-auth.spec.ts | 6 +- tests/git-remote.spec.ts | 89 +++- tests/git-server.spec.ts | 42 ++ tests/helpers/cache-did-docs.ts | 43 ++ tests/helpers/passive-dwn-server.ts | 350 +++++++++++++ tests/helpers/profile-bootstrap.ts | 42 ++ tests/helpers/test-daemon.ts | 10 +- tests/passive-dwn-server.spec.ts | 159 ++++++ tests/profiles.spec.ts | 43 ++ tests/protocols.spec.ts | 86 +++- tests/push-policy.spec.ts | 219 ++++++++ tests/ref-sync.spec.ts | 110 +++- tests/schemas.spec.ts | 28 + 70 files changed, 8832 insertions(+), 444 deletions(-) create mode 100644 REPO_LEVEL_MVP_PLAN.md create mode 100644 schemas/refs/branch-state.json create mode 100644 schemas/refs/branch.json create mode 100644 schemas/repo/moderation-event.json create mode 100644 src/branch-state.ts create mode 100644 src/cli/commands/mod.ts create mode 100644 src/cli/dispatch.ts create mode 100644 src/cli/local-rpc.ts create mode 100644 src/cli/moderation-state.ts create mode 100644 src/cli/record-send.ts create mode 100644 src/git-server/push-updates.ts create mode 100644 src/git-server/remote-branch-sync.ts create mode 100644 tests/branch-state.spec.ts create mode 100644 tests/e2e-passive-dwn-sync.spec.ts create mode 100644 tests/e2e-profile-helpers.spec.ts create mode 100644 tests/helpers/cache-did-docs.ts create mode 100644 tests/helpers/passive-dwn-server.ts create mode 100644 tests/helpers/profile-bootstrap.ts create mode 100644 tests/passive-dwn-server.spec.ts create mode 100644 tests/push-policy.spec.ts diff --git a/PLAN.md b/PLAN.md index 2a38260..c33750b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,11 @@ # gitd: Architecture & Implementation Plan +For the narrowed public repo-level GitHub replacement scope, see +[REPO_LEVEL_MVP_PLAN.md](./REPO_LEVEL_MVP_PLAN.md). That plan is the guardrail +and E2E MVP contract for repo, branch, issue, pull request, and moderation work +before CI, private repos, organization/team permissions, or broad GitHub API +compatibility. + ## Table of Contents 1. [Problem Statement](#1-problem-statement) diff --git a/README.md b/README.md index 9b3acae..a79daf5 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,10 @@ gitd web --port 3000 ## Architecture -See [ARCHITECTURE.md](./ARCHITECTURE.md) for protocol and system design, or [PLAN.md](./PLAN.md) for the full roadmap. +See [ARCHITECTURE.md](./ARCHITECTURE.md) for protocol and system design, +[PLAN.md](./PLAN.md) for the full roadmap, or +[REPO_LEVEL_MVP_PLAN.md](./REPO_LEVEL_MVP_PLAN.md) for the focused public +repo-level GitHub replacement plan and E2E MVP contract. ## Development diff --git a/REPO_LEVEL_MVP_PLAN.md b/REPO_LEVEL_MVP_PLAN.md new file mode 100644 index 0000000..3db4cb9 --- /dev/null +++ b/REPO_LEVEL_MVP_PLAN.md @@ -0,0 +1,694 @@ +# gitd Repo-Level MVP Plan + +This plan narrows `gitd` to the repo-level forge features needed for a project +to start using it in place of GitHub. It intentionally excludes CI, package +registry work, broad GitHub API compatibility, private repositories, and +organization/team permissions. Those can build on this once the repo-level +model is stable. + +## Goals + +- Let a public project host source, branches, tags, issues, and pull requests + through Enbox/DWN protocols. +- Keep project authority at the edges. Maintainers and contributors act through + their own Enbox agent or wallet. No remote live service should need to hold + project delegate keys. +- Support a canonical repo workflow. Contributors can push to the canonical + repository, while maintainers control protected/default branch changes and + merge decisions. +- Add first-class repo-level moderation with explicit moderator assignment and + revocation. +- Keep public indexers optional and non-authoritative. They may help discovery, + but repo correctness and permissions must not depend on one. + +## Non-Goals + +- CI execution, Actions compatibility, check suites beyond future merge-status + placeholders. +- GitHub REST API breadth. The shim should only expose the endpoints needed by + the repo-level flows until the core model is stable. +- Private repositories and encrypted read-access management. +- Organization and team permission inheritance. +- Detailed indexer design. This plan only records the contract the core should + leave available for later discovery work. + +## Product Decisions + +- Add `moderator` as a new repo role instead of overloading `triager`. +- Contributors may push to the canonical repo. +- Outside parties cannot write issues or pull requests directly to the repo + owner's DWN in v1. They create records on their own fork/DWN and share them + out of band. +- Optional third-party public indexers are acceptable, but they must not carry + repository authority or delegate keys. +- Clone and fetch should work through a standard DWN server. The local app, + CLI, or browser delegate can materialize git data from DWN records. +- Browser dapps must sign through the standard Enbox agent or wallet. +- The E2E MVP is CLI/local-helper first. Web and browser dapps are deferred, + except that the protocol should not block a future helper-backed web surface. +- `gitd serve` is the local helper service. It owns local git caches, talks to + the local Enbox agent, and syncs/sends records through local and remote DWNs + using the same Enbox pattern as the demo dapp. +- One-shot CLI commands such as `gitd repo`, `gitd issue`, `gitd pr`, and + `gitd mod` reuse a running same-profile local helper through a local-only + helper RPC. This keeps the helper as the single owner of the profile's agent + stores during normal CLI use. `GITD_CLI_RPC=off` disables this forwarding for + debugging. +- For the CLI MVP, clone/fetch for a non-local owner DID can route through the + local helper and read that owner's standard DWN records. Contributor push + policy is enforced from Git receive-pack commands: contributors may update + only their own `refs/heads/users//...` branches, while protected + refs and global tags remain maintainer/owner authority. Remote-owner + contributor pushes through the local helper write signed `repo/branch`, + `repo/branch/state`, and `repo/branch/bundle` records directly to the owner + DWN. +- DWN `$squash` is used for authoritative checkpoints over deterministic state + streams. When old deltas should be purged, the deltas and checkpoint must be + sibling records on the same `$squash` protocol path and parent context. + `$squash` is not the conflict-resolution algorithm; helpers must still + validate roles, branch rules, ancestry, and moderation state before accepting + an operation into the canonical view. +- In this plan, "squash" means DWN `$squash` checkpoint compaction unless + explicitly called "Git squash merge." +- Branch checkpoint authority follows branch ownership. Contributors can + `$squash` checkpoints and bundle history for branches they created in their + contributor namespace; only maintainers and owners can `$squash` protected or + default branch checkpoints. +- Public repos only for this phase. +- Moderation v1 includes block DID, lock issue/PR, hide comment, delete comment, + report queue, and interaction limits. +- Record IDs are acceptable for issue and PR identifiers for now. +- Repo-level permissions ship first; org/team permissions follow later. + +## E2E MVP Acceptance Path + +The MVP is end-to-end only when this full public repo flow works without a +hosted authority service holding project keys: + +1. Alice creates a public repo on her DID and pushes an initial default branch. +2. Alice adds Bob as a contributor and Casey as a moderator. +3. Alice, Bob, and Casey run local `gitd serve` helpers backed by their Enbox + agents. The helpers keep local DWNs in sync with configured remote DWN + endpoints. +4. Bob clones/fetches the repo through a standard DWN server using + `git clone did::/`. +5. Bob pushes a non-protected canonical feature branch in his contributor + namespace, such as `refs/heads/users//feature`. +6. Bob opens a canonical PR from that branch. +7. Casey moderates the PR or issue discussion without being able to push or + merge code. +8. Alice reviews, merges locally at the edge, and pushes the protected/default + branch update. +9. Bob fetches again through DWN and sees the merged result. + +Outside submissions, public indexers, private repos, org/team permissions, and +CI are not required for this acceptance path. + +## E2E MVP Contract + +The MVP is a single public repo collaboration loop. It is complete only when a +fresh test can create three identities, run the command flow below, and prove +the canonical repo state is reconstructed from DWN records without a hosted +authority service. + +### Required Actors + +| Actor | Role | Runs | +|---|---|---| +| Alice | Owner/maintainer | local Enbox agent, local DWN, `gitd serve` helper | +| Bob | Contributor | local Enbox agent, local DWN, `gitd serve` helper | +| Casey | Moderator | local Enbox agent, local DWN, `gitd serve` helper | +| Remote DWN endpoint | Passive sync/storage | no git server, no maintainer keys, no project delegate keys | + +### Target Command Flow + +These commands define the E2E UX target. Exact flags can move during +implementation, but the observable flow should stay this small. + +```bash +# Alice +GITD_PROFILE=alice gitd init demo --public +GITD_PROFILE=alice gitd serve +GITD_PROFILE=alice gitd repo add-contributor --repo demo +GITD_PROFILE=alice gitd repo add-moderator --repo demo + +# Bob +GITD_PROFILE=bob gitd serve +GITD_PROFILE=bob git clone did::/demo +cd demo +git checkout -b users//feature +git commit --allow-empty -m "feature" +git push origin HEAD:refs/heads/users//feature +GITD_PROFILE=bob gitd pr create "Feature" \ + --repo demo \ + --base main \ + --head refs/heads/users//feature + +# Casey +GITD_PROFILE=casey gitd serve +GITD_PROFILE=casey gitd pr comment "needs cleanup" --repo demo +GITD_PROFILE=casey gitd mod hide-comment --reason spam --repo demo +GITD_PROFILE=casey gitd mod lock pr --reason heated --repo demo +GITD_PROFILE=casey gitd mod unlock pr --repo demo + +# Alice +GITD_PROFILE=alice gitd pr checkout --repo demo +GITD_PROFILE=alice gitd pr merge --repo demo +GITD_PROFILE=alice git push origin main + +# Bob verifies convergence through DWN-backed fetch +GITD_PROFILE=bob git fetch origin main +git merge-base --is-ancestor origin/main +``` + +### Minimum Feature Set + +| Area | MVP Requirement | +|---|---| +| Identity/session | Named local profiles can create distinct DIDs and run helpers with sync enabled. | +| Repo creation | Alice creates a public repo record, default branch metadata, and initial branch state. | +| Roles | Owner can add/remove contributor and moderator role records. `triager` is not user-facing. | +| Clone/fetch | Bob clones and fetches through `did::` using a local helper that reads DWN records. | +| Push | Bob pushes only to `refs/heads/users//*`; protected/default branch pushes are rejected. | +| Branch state | Contributor branch state uses branch-scoped `$squash`; protected/default branch state uses maintainer-owned `$squash`. | +| PRs | Bob opens a canonical PR from his canonical contributor branch; Alice can checkout and merge it. | +| Issues/comments | Contributors, moderators, maintainers, and owners can create canonical issue/PR comments. | +| Moderation | Casey can hide/tombstone comments, lock/unlock PRs/issues, block/unblock DIDs, and review reports. | +| Authorization | Casey cannot push/merge; Bob cannot update or squash protected/default branch state; blocked users cannot continue canonical writes. | +| Convergence | A helper with an empty git cache can reconstruct current branch heads and objects from DWN records. | +| No hosted authority | The remote endpoint never signs as Alice, Bob, Casey, or a project delegate. | + +### Minimum Record Shape + +The final protocol names can follow the existing `repo`, `refs`, `issues`, and +`patches` modules, but the E2E slice needs these logical records: + +| Logical Record | Purpose | Write Authority | +|---|---|---| +| `repo` | public repo metadata, default branch, visibility | owner | +| `repo/contributor` | contributor role grant | owner | +| `repo/moderator` | moderator role grant | owner | +| `branch` | branch context with `refName`, `ownerDid`, `kind` | contributor for own namespace, maintainer for protected/default | +| `branch/state` | `$squash` stream containing `refUpdate` deltas and `checkpoint` records | branch owner for contributor branches, maintainer/owner for protected/default | +| `branch/bundle` | `$squash` stream containing incremental/full git bundles for that branch | branch owner for contributor branches, maintainer/owner for protected/default | +| `repo/bundle` | optional full repo clone acceleration checkpoint | maintainer/owner | +| `patch` | canonical PR metadata | contributor, maintainer, owner | +| `patch/revision` | PR revision metadata pointing at branch state/bundle records | PR author, maintainer, owner | +| `issue` | canonical issue metadata | contributor, moderator, maintainer, owner | +| `comment` | issue/PR comments | contributor, moderator, maintainer, owner unless locked/blocked | +| `moderationEvent` | lock, hide, tombstone, block, report resolution | moderator, maintainer, owner | +| `viewSnapshot` | `$squash` snapshot for issue/PR/mod queues | moderator, maintainer, owner | + +### Verification Gates + +The E2E MVP should not be called done until these checks pass: + +1. `bun run build` +2. Unit tests for role authorization, branch namespace validation, branch-state + reducer convergence, `$squash` authority, blocked DID enforcement, and + moderation tombstones. +3. Integration tests for local helper clone/fetch/push using DWN records as the + source of truth. +4. A new E2E test, for example `tests/e2e-public-repo-mvp.spec.ts`, that runs + the Alice/Bob/Casey flow with isolated profiles and empty-cache restore. +5. A negative E2E path proving Bob cannot push or `$squash` `main`, and Casey + cannot push or merge. + +### Current Tree Status + +These are the concrete implementation facts and remaining gaps visible in the +current tree: + +- `triager` remains as compatibility surface in some code paths; MVP user-facing + paths should keep moving to `moderator`. +- `src/refs.ts` now exposes branch-scoped `repo/branch/state` and + `repo/branch/bundle` records with `$squash`, and `src/branch-state.ts` + contains the pure reducer/validation helpers. +- `src/git-server/ref-sync.ts` still maintains legacy `repo/ref` mirror records + for compatibility, and now also writes squashed `repo/branch/state` + checkpoints after push. It still does not emit fine-grained CRDT deltas from + individual push packet commands. +- `src/git-server/bundle-sync.ts` still writes restore-compatible repo bundles, + and now also writes squashed branch checkpoint bundles to + `repo/branch/bundle` when the refs protocol handle is available. It still + does not emit per-branch incremental bundles, but + `src/git-server/bundle-restore.ts` now replays the newest branch-scoped + checkpoint bundle for each branch when a refs protocol handle is available. +- `src/git-remote/resolve.ts`, `src/cli/commands/serve.ts`, and + `src/git-server/bundle-restore.ts` now support DWN-only clone/fetch through a + local `LocalDwnHelper` that reads a remote owner's standard DWN repo and + bundle records. `gitd serve` advertises that DWN-helper capability in the + daemon lockfile, and `git-remote-did` can fall back to the advertised local + helper when DID resolution does not produce a public `GitTransport` endpoint. +- Daemon discovery is now profile-scoped when `GITD_PROFILE` or an explicit + lifecycle profile is active. Alice, Bob, and Casey helpers can run as + separate foreground processes in one `ENBOX_HOME` without overwriting a + single global lockfile. +- Normal one-shot agent commands now forward to an already-running same-profile + helper over a local-only `/cli` endpoint before opening their own agent + connection. `tests/e2e-profile-helpers.spec.ts` covers `gitd repo info` while + Alice's helper is running, preventing the previous `Database is not open` + failure mode. +- `src/profiles/config.ts` now accepts `GITD_PROFILE` as the documented profile + selector, with `ENBOX_PROFILE` retained as a compatibility alias. +- `connectAgent` still uses AuthManager for vault/session/protocol bootstrap, + and now supports `GITD_DWN_ENDPOINT`, comma-separated + `GITD_DWN_ENDPOINTS`, and `GITD_DWN_REGISTRATION=off` for deterministic + local-only tests or edge setups that do not want to contact the hosted DWN + registration service. When sync is enabled, gitd registers its forge + protocols with the Enbox sync engine and triggers an initial best-effort + push after broadening the scope so existing repo records are not missed by + AuthManager's earlier identity-recovery sync pass. `GITD_DID_REPUBLISH=off` + disables background DID DHT republishing for the same deterministic test + path. +- `gitd init` now publishes public repo records (`published: true`) while + keeping private repo records unpublished, so public repos are visible to + unsigned standard-DWN reads from passive endpoints. +- `src/git-server/push-updates.ts` parses Git receive-pack ref commands, and + `src/git-server/push-authorizer.ts` now enforces contributor branch namespace + rules and latest `block` moderation events for POST pushes while allowing + role-based receive-pack ref discovery. +- `src/git-server/remote-branch-sync.ts` now writes Bob-signed contributor + branch, branch ref-update state, squashed branch checkpoint state, and + branch bundle records to the owner DWN after a remote-owner push accepted by + Bob's local helper. +- `src/repo.ts` now includes immutable `repo/moderationEvent` records, and + `src/cli/commands/mod.ts` writes block/unblock, lock/unlock, + hide/unhide/delete comment, report resolution, and interaction-limit events. + Block events are enforced by push authorization and by CLI issue/PR write + paths. CLI issue/PR reads consume lock and comment visibility events when + rendering discussion views, and CLI issue/PR comment writes reject locked + discussions. +- `src/cli/commands/issue.ts` and `src/cli/commands/pr.ts` now target the + canonical owner repo context for remote repos, create contributor/moderator/ + maintainer records with `store: false`, and send those signed records to the + owner's DID. Reads query the canonical owner DWN with `from`. +- Existing E2E tests cover transport/bundle pieces and the Alice/Bob/Casey role + path, including Bob's contributor-namespace push and Bob/Casey negative push + cases, plus Bob's local-helper push writing signed contributor branch records + and a squashed checkpoint to Alice's DWN. The collaboration E2E now also + starts a fresh Bob helper cache after Alice's merge and proves it can restore + `main` plus Bob's contributor branch from Alice's DWN records, then runs a + real `git clone did::...` child process through `git-remote-did` into that + empty-cache local DWN helper. +- `tests/e2e-collaboration.spec.ts` now extends the Alice/Bob/Casey path + through canonical CLI issue and PR records sent from Bob to Alice's DWN, + including PR revision and bundle records, and Casey's CLI moderation events + locking the PR and hiding a review note. `tests/cli.spec.ts` covers the + remote-owner issue/PR command behavior and CLI moderation lock/hide/block + reducers. +- `tests/e2e-profile-helpers.spec.ts` now bootstraps Alice, Bob, and Casey + profiles through the real `connectAgent`/AuthManager path in child processes, + then starts three real `gitd serve --foreground --no-sync` helper processes + concurrently and verifies profile-scoped lockfiles plus `/health` for each. +- `tests/helpers/passive-dwn-server.ts` provides a test-only passive standard + DWN HTTP endpoint backed by SQLite stores. It implements `/info`, + `dwn.processMessage`, streamed record reads via the `dwn-response` header, + and `dwn.applyReplicatedMessage`, with a seedable DID resolver for + unpublished local profile DIDs. +- `tests/passive-dwn-server.spec.ts` proves that passive endpoint contract with + direct `HttpDwnRpcClient` writes, streamed reads, and replicated apply. + `tests/e2e-passive-dwn-sync.spec.ts` starts a real Alice profile, runs + `gitd init`, starts a spawned `gitd serve --foreground --sync 1s`, and proves + Alice's public repo metadata is replicated into the passive DWN with no + hosted authority or project delegate key. +- `tests/e2e-passive-dwn-sync.spec.ts` now runs the combined spawned + Alice/Bob/Casey profile slice against a passive standard DWN. Alice creates + the public repo and role grants, Bob clones through his local helper, Bob + pushes a contributor branch to Alice's canonical repo through passive-DWN + records, Bob opens a canonical PR, Casey writes moderation events, Alice + checks out and merges the PR, and a fresh Bob helper with an empty cache + clones the merged repo through `git-remote-did` and DWN bundle restore. This + closes the previous passive-DWN E2E hardening gap for the public repo loop. +- The remaining repo-level gaps are outside the CLI/public-repo MVP slice: + optional public indexer discovery, private repos and encrypted read access, + organization/team inheritance, detailed CI/check-suite behavior, and + browser/dapp helper integration. +- The combined E2E exposed and fixed three CLI-helper requirements: `/auth/token` + resolves the connected session DID directly instead of depending on + `identity.list()`, owner-side post-push sync explicitly flushes the local DWN + to registered remotes, and public repo bundle/ref/branch checkpoint records + are marked `published` so passive endpoints and optional indexers can read + them without a hosted git server. + +## E2E MVP Decisions + +These decisions should be treated as settled for the CLI E2E MVP: + +1. **DWN-native git remote shape.** `git-remote-did` should resolve DID remotes + to the local helper. The helper may expose local smart HTTP to Git, but its + source of truth is DWN repo, ref state, event, checkpoint, and bundle records. + A remote `GitTransport` service is optional compatibility, not required. + +2. **Contributor branch namespace.** Contributor pushes go under + `refs/heads/users//`. Top-level branches are shared or + protected space and require maintainer authority unless the repo settings + explicitly allow otherwise later. + +3. **Tags.** Global tags are maintainer-only in the E2E MVP. Contributor tags + can be added later as a namespaced feature if needed. + +4. **Protected ref enforcement.** Contributors never write the authoritative + protected/default ref checkpoint path. They can write contributor branch + state records; maintainers write protected ref state records and checkpoints. + Reducers must ignore any state record whose path, actor role, or ref + namespace does not match the branch rules. + +5. **Bundle and checkpoint authority.** Contributor pushes write branch-scoped + bundle records for the branch they are allowed to update. Contributors can + compact their own branch history with `$squash`. Maintainers and owners can + write protected/default branch checkpoints, full repo bundle checkpoints, and + repo-wide bundle compaction. + +6. **Push conflict semantics.** Every ref operation includes `oldTarget` and + `newTarget`. A helper rejects locally when `oldTarget` does not match the + reduced branch head. If racing edge writes arrive through DWN, the reducer + accepts the valid fast-forward chain and marks conflicting state records as + stale, requiring a rebase or force-push permission. + +7. **Moderator powers.** Moderators can lock/unlock discussions, hide/unhide or + tombstone comments, resolve/dismiss reports, block/unblock DIDs, and apply + interaction limits. They cannot push, merge, update protected refs, assign + roles, edit repo settings, edit labels, assign users, or close/reopen PRs for + project-management reasons in the MVP. + +8. **Delete vs. tombstone.** Normal comment deletion is a moderation tombstone + in canonical views. Hard deletion remains owner-only emergency behavior and + should still leave a moderation audit record where possible. + +9. **Block enforcement.** Blocking a DID also revokes that DID's contributor or + moderator role records in the repo. Unblocking does not restore roles + automatically. + +10. **Report records.** Canonical report records are limited to contributors, + moderators, maintainers, and owners. Outside reports stay actor-owned and + out-of-band until the indexer/external-submission effort. + +11. **Role assignment authority.** Owner-only role assignment for the E2E MVP. + Maintainer-managed collaborators can be a later repo setting. + +12. **User surface.** CLI and local helper only. Web/dapp is a later effort and + may need a helper bridge, but it should not shape the E2E MVP. + +## Permission Model + +The repo protocol should expose these repo-scoped roles: + +| Role | Scope | +|---|---| +| Owner | Implicit DID tenant authority. Can change repo settings, assign roles, and delete the repo. | +| Maintainer | Can push, merge, manage protected/default branches, manage releases, manage repo settings that are not owner-only, and assign non-owner roles if allowed. | +| Moderator | Can moderate issues, pull requests, comments, reports, locks, blocks, and interaction limits. Cannot push code or merge. | +| Contributor | Can push branches to the canonical repo, create canonical issues/PRs, comment, review, and update their own records. Cannot merge or moderate others. | +| Viewer | Read-only role, mostly future-facing until private repos are added. Public repos are readable without a role. | + +`triager` should either be replaced by `moderator` or treated as a deprecated +compatibility alias during migration. The user-facing CLI should use +`moderator`. + +Push policy needs one additional split: + +- Contributors can push non-protected branches in their contributor namespace, + subject to repo rules. +- Global tags are maintainer-only in the MVP. +- Maintainers can update protected/default branches and perform merges. +- The owner can always recover or override. + +## Core Protocol Work + +### Repo + +- Add `repo/moderator` as a `$role: true` path. +- Keep `repo/maintainer`, `repo/contributor`, and `repo/viewer`. +- Replace user-facing `triager` language with `moderator`. +- Add owner-controlled moderation settings: + - interaction limit mode + - maximum open external submissions to surface later + - lock defaults +- Keep `submissionDecision`, but keep it minimal. Decisions can record ignored + outside submissions that were discovered out of band or by an optional + indexer. + +### Refs and Branches + +- Represent branch movement as immutable state records plus checkpoint records, + not only mutable ref records. +- Add a branch ownership context: + - contributor branch records are authored by the contributor and must map to + `refs/heads/users//` + - protected/default branch records are maintainer/owner authority + - reducers ignore branch records whose author, role, or ref namespace does not + match repo rules +- Make ref state writes reflect the push policy: + - contributors write ref-update deltas and `$squash` checkpoints for branches + they own in their contributor namespace + - maintainers write ref-update deltas and `$squash` checkpoints for + protected/default branches + - maintainers and owners can write repo-wide ref snapshots +- Add minimal branch protection configuration for v1: + - protected ref patterns + - maintainers-only direct update + - allow/disallow force push + - allow/disallow delete + +### Issues + +- Canonical issue records are created by contributors, moderators, maintainers, + and owners. +- Public outside reports are not written to the repo owner's DWN in v1. +- Issue moderation records should support: + - lock and unlock + - hide and unhide comment + - delete comment + - report content + - resolve or dismiss report + - block DID from repo interaction + - interaction-limit changes +- Preserve immutable audit records for moderation actions. + +### Pull Requests + +- Canonical PR records are created by contributors, maintainers, and owners. +- PR revisions continue to use immutable records and git bundle attachments. +- Moderators can moderate PR discussion and reports, but cannot merge. +- Maintainers can merge and close/reopen PRs. +- Contributors can update their own PRs and push canonical feature branches. + +### Git Object Transport + +The desired shape is DWN-native clone/fetch with local materialization: + +1. A standard DWN server stores repo metadata, refs, and git bundles. +2. `git-remote-did` resolves the target DID and repo. +3. A local delegate reads the repo's DWN records, restores or updates a local + bare cache from bundles, and serves git's expected remote-helper behavior. +4. Pushes are signed by the contributor's Enbox identity and authorized by repo + roles/rules. +5. Post-push ref and bundle writes are authored by the pushing edge delegate, + not by a remote service holding maintainer keys. + +The existing smart HTTP sidecar can remain as an optimization or compatibility +adapter, but it should not be the only path for clone/fetch and it should not +require a hosted service with project authority. + +## Local Helper, Operation Logs, and `$squash` + +For CLI/local use, `gitd serve` should be the edge helper that bridges Git, +Enbox, and DWN: + +- It runs against the user's local Enbox agent and local DWN store. +- It syncs to configured remote DWN endpoints, similar to the demo dapp's + local-vault plus remote-DWN sync pattern. +- It maintains local bare git caches materialized from DWN bundle/checkpoint + records. +- It exposes local interfaces for `git-remote-did`, CLI issue/PR commands, and + future helper-backed web surfaces. +- It never needs a remote hosted process with maintainer or project keys. + +The canonical repo state should be reducible from records: + +- Immutable state/event records describe actions: branch updates, issue edits, + PR revisions, comments, moderation actions, reports, and role changes. +- Checkpoint records store a reduced view after a known state/event frontier. +- `$squash` purges older sibling records at the same protocol path and parent + context. If a checkpoint must compact old deltas, the deltas and checkpoint + must share that path and be distinguished by a `kind` field. +- Every helper should be able to query the latest checkpoint, replay newer + state/event records, validate actor authority, and arrive at the same + canonical view. + +Ref movement should be modeled as branch-scoped state streams: + +- `branch` establishes a branch context with `refName`, `ownerDid`, and branch + kind such as `contributor` or `protected`. +- `branch/state` is `$squash`-enabled. It contains immutable records with + `kind: "refUpdate"` or `kind: "checkpoint"`. +- `refUpdate` records include `refName`, `oldTarget`, `newTarget`, `actorDid`, + `bundleRecordId`, `createdAt`, and a push nonce. +- `checkpoint` records store the current reduced branch head plus the accepted + state frontier. Because checkpoints and ref updates are siblings, a + checkpoint can compact old branch deltas. +- Contributor branch state is authorized to the branch author/owner. +- Protected/default branch state is maintainer/owner authority. + +Git object storage follows the same branch ownership rule: + +- Contributor branch bundles are scoped under that contributor's branch context + and may be compacted by that contributor with `$squash`. +- Protected/default branch bundles and full repo bundles are maintainer/owner + checkpoints. +- A repo-wide full bundle checkpoint is an optimization for faster clone/fetch, + not the only source of truth. Helpers can still reconstruct from branch + checkpoints and accepted state records if needed. + +Conflict handling is reducer logic, not DWN storage magic: + +- A local helper rejects a push if `oldTarget` is not the current reduced branch + head. +- If two validly authored operations race through sync, only the operation that + extends the accepted head is canonical. The other becomes stale and requires + rebase, unless the branch rules allow force push. +- Record timestamps are not enough to decide Git history. Git ancestry and + branch protection rules decide whether a ref operation is valid. + +Issues, PRs, and moderation should use the same event/checkpoint shape: + +- Comments and revisions are append-only records. +- Edits are explicit operations, so concurrent edits can be reduced + deterministically by operation type, actor authority, timestamp, and record ID. +- Deletion in the canonical public view is a tombstone/moderation operation. +- Issue, PR, and moderation checkpoints can use `$squash` on snapshot paths to + keep reads cheap, but audit/event records should not be purged in the MVP. + The reducer must still validate post-checkpoint operations. + +Protocol design must avoid accidental checkpoint authority. In the current DWN +implementation, a squash write can authorize through `squash` or `create`. +Therefore contributor-created operations and maintainer-only checkpoints should +not share a `$squash` path unless the path/context rules safely scope create and +squash authority. The safer MVP shape is separate child paths or branch-owned +contexts where a contributor can only checkpoint their own branch and maintainers +own protected/default branch checkpoints. + +## CLI Work + +Repo commands: + +- `gitd repo add-moderator [--alias ]` +- `gitd repo remove-moderator ` +- `gitd repo add-contributor [--alias ]` +- `gitd repo remove-contributor ` +- Keep generic `add-collaborator` only as an advanced or compatibility command. + +Moderation commands: + +- `gitd mod block [--reason ]` +- `gitd mod unblock ` +- `gitd mod lock issue [--reason ]` +- `gitd mod unlock issue ` +- `gitd mod lock pr [--reason ]` +- `gitd mod unlock pr ` +- `gitd mod hide-comment [--reason ]` +- `gitd mod unhide-comment ` +- `gitd mod delete-comment [--reason ]` +- `gitd mod reports` +- `gitd mod report [--reason ]` +- `gitd mod resolve-report ` +- `gitd mod interaction-limit [--duration ]` + +Issue and PR commands should stay focused on canonical records. External +records can still be accepted or ignored when a maintainer already has the +submitter DID and record ID, but discovery is explicitly out of scope for core +v1. + +## Indexer Contract + +The core should leave enough metadata for an optional public indexer to: + +- discover public repos +- list public refs and basic repo activity +- surface public outside issue/PR records that target a repo +- observe owner-side ignore decisions +- avoid needing write access, keys, or delegated authority + +No indexer-specific ranking, registration protocol, hosting model, moderation +queue policy, or reputation system belongs in this repo-level MVP plan. That +needs a separate discovery pass. + +## Implementation Sequence + +1. Trim PR scope. + - Keep core protocol and CLI improvements. + - Defer broad GitHub shim additions, Actions, pages, webhooks, security + scanning, custom fields, and org/team breadth. + +2. Add first-class moderator role. + - Protocol role. + - CLI add/remove/list. + - Tests for role assignment and revocation. + +3. Build the DWN-native helper path. + - `git-remote-did` resolves DWN-only remotes to local `gitd serve` for + clone/fetch. + - The helper reads remote repo and bundle records via DWN `from` queries and + maintains local bare git caches. + - Contributor push policy is enforced for smart HTTP receive-pack commands. + - Contributor push writes signed branch state and bundle records directly to + the owner DWN from the contributor's helper. + - Smart HTTP remains a local adapter, not the authoritative remote service. + +4. Add branch operation/checkpoint records. + - Contributor branch contexts under `refs/heads/users//`. + - Contributor-owned `$squash` checkpoints for those branches. + - Maintainer-owned `$squash` checkpoints for protected/default branches. + - Reducer tests for stale races, protected branch rejection, and maintainer + protected branch updates. + +5. Correct push authorization. + - Contributors can push canonical non-protected branches in their namespace. + - Maintainers can update protected/default branches. + - Tests cover contributor branch push and protected branch rejection at the + receive-pack command level. Pure authority tests cover contributor-owned + branch squash, moderator rejection, and maintainer/owner protected branch + squash. Direct DWN-write tests for maintainer protected branch squash are + still a useful hardening follow-up. + +6. Add moderation records and commands. + - Immutable `repo/moderationEvent` records exist for blocks, locks, + hidden/deleted comments, reports, report resolution, and interaction + limits. + - `gitd mod` writes these events and tests cover the command surface. + - Push authorization rejects DIDs whose latest moderation event is `block`. + - CLI issue/PR render paths hide tombstoned comments, and CLI comment writes + reject locked discussions. GitHub shim/web moderation reducers are outside + the CLI/local-helper MVP unless they become required by a repo-level flow. + +7. Refine issue and PR permissions. + - No `anyone.create` on canonical owner DWN records. + - Contributors, moderators, and maintainers get only their intended actions. + - Outside submissions remain actor-owned records. + +8. Make DWN-native clone/fetch explicit. + - Define the local delegate flow for reading refs/bundles from a standard + DWN server. + - Keep smart HTTP as an adapter, not the core authority model. + +9. Update docs and tests. + - README quick start should show repo-level roles and public repo workflow. + - Architecture should explain edge authority and standard DWN clone/fetch. + - Tests should exercise the full public repo contributor and moderator path. + +## Drift Guardrails + +Do not add new GitHub API endpoints unless they are needed by one of the v1 +repo-level workflows above. + +Do not add CI implementation in this phase. Merge-status records may be shaped +for future compatibility, but no runner, Actions, or check execution work should +land here. + +Do not make indexer behavior authoritative. Indexers can discover and present +records; maintainers and moderators decide through signed repo records. + +Do not introduce a hosted delegate service that must hold maintainer or project +keys. Delegation happens through local apps, CLI processes, or browser dapps +using the user's Enbox agent or wallet. diff --git a/schemas/refs/branch-state.json b/schemas/refs/branch-state.json new file mode 100644 index 0000000..78639d5 --- /dev/null +++ b/schemas/refs/branch-state.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/branch-state", + "title": "BranchStateData", + "description": "A branch ref update or authoritative checkpoint for a branch-owned git state log.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["refUpdate", "checkpoint"], + "description": "State record discriminator" + }, + "refName": { + "type": "string", + "description": "Full branch ref name this state applies to" + }, + "oldTarget": { + "type": ["string", "null"], + "description": "Expected previous commit target for ref updates" + }, + "newTarget": { + "type": ["string", "null"], + "description": "New commit target for ref updates; null represents deletion" + }, + "target": { + "type": ["string", "null"], + "description": "Authoritative target for checkpoints; null represents deletion" + }, + "actorDid": { + "type": "string", + "description": "DID that produced this state record" + }, + "bundleRecordId": { + "type": "string", + "description": "Optional associated branch bundle record ID" + }, + "force": { + "type": "boolean", + "description": "Whether this update represents a non-fast-forward git push" + }, + "nonce": { + "type": "string", + "description": "Optional helper-generated tie-breaker" + }, + "acceptedStateRecordId": { + "type": "string", + "description": "Latest accepted delta included in a checkpoint" + }, + "acceptedAt": { + "type": "string", + "description": "Timestamp when a checkpoint was accepted" + }, + "createdAt": { + "type": "string", + "description": "Timestamp when this state record was created" + } + }, + "required": ["kind", "refName", "actorDid", "createdAt"], + "additionalProperties": false +} diff --git a/schemas/refs/branch.json b/schemas/refs/branch.json new file mode 100644 index 0000000..aa15628 --- /dev/null +++ b/schemas/refs/branch.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/branch", + "title": "BranchData", + "description": "Stable metadata for a branch-owned git state log.", + "type": "object", + "properties": { + "refName": { + "type": "string", + "description": "Full branch ref name, e.g. refs/heads/main" + }, + "ownerDid": { + "type": "string", + "description": "DID that owns this branch log" + }, + "kind": { + "type": "string", + "enum": ["contributor", "protected", "shared"], + "description": "Branch ownership class" + }, + "createdAt": { + "type": "string", + "description": "Optional ISO timestamp for helper reconciliation" + } + }, + "required": ["refName", "ownerDid", "kind"], + "additionalProperties": false +} diff --git a/schemas/repo/moderation-event.json b/schemas/repo/moderation-event.json new file mode 100644 index 0000000..7844551 --- /dev/null +++ b/schemas/repo/moderation-event.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.org/schemas/forge/moderation-event", + "title": "ModerationEventData", + "description": "Immutable repo-level moderation audit event.", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "block", + "unblock", + "lock", + "unlock", + "hideComment", + "unhideComment", + "deleteComment", + "report", + "resolveReport", + "dismissReport", + "interactionLimit" + ] + }, + "actorDid": { + "type": "string" + }, + "targetDid": { + "type": "string" + }, + "targetKind": { + "type": "string", + "enum": ["repo", "issue", "pr", "issueComment", "prComment", "report"] + }, + "targetId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "reportStatus": { + "type": "string", + "enum": ["open", "resolved", "dismissed"] + }, + "interactionLimit": { + "type": "string", + "enum": ["off", "contributors", "collaborators"] + }, + "duration": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["action", "actorDid", "createdAt"], + "additionalProperties": false +} diff --git a/src/branch-state.ts b/src/branch-state.ts new file mode 100644 index 0000000..1c8d113 --- /dev/null +++ b/src/branch-state.ts @@ -0,0 +1,249 @@ +/** + * Branch state helpers for DWN-backed git refs. + * + * These helpers are intentionally pure. The local gitd helper can use them to + * validate branch ownership before writing DWN records and to reduce sibling + * `repo/branch/state` records into the current branch target. + * + * @module + */ + +import { createHash } from 'node:crypto'; + +import type { BranchData, BranchStateData } from './refs.js'; + +/** Default refs that only maintainers should control. */ +export const DEFAULT_PROTECTED_BRANCH_REFS = ['refs/heads/main', 'refs/heads/master'] as const; + +/** Hash a DID into the short branch namespace used for contributor refs. */ +export function branchOwnerHash(did: string): string { + return createHash('sha256').update(did).digest('hex').slice(0, 16); +} + +/** Return the required ref prefix for a contributor-owned branch. */ +export function contributorBranchPrefix(did: string): string { + return `refs/heads/users/${branchOwnerHash(did)}/`; +} + +/** Return true when `refName` is inside the DID-owned contributor namespace. */ +export function isContributorBranchRef(refName: string, ownerDid: string): boolean { + const suffix = refName.slice(contributorBranchPrefix(ownerDid).length); + return refName.startsWith(contributorBranchPrefix(ownerDid)) && suffix.length > 0 && !suffix.includes('..'); +} + +/** Return true when a ref is protected by exact name or a trailing `*` prefix pattern. */ +export function isProtectedBranchRef( + refName: string, + protectedRefs: readonly string[] = DEFAULT_PROTECTED_BRANCH_REFS, +): boolean { + return protectedRefs.some((pattern) => { + if (pattern.endsWith('*')) { + return refName.startsWith(pattern.slice(0, -1)); + } + return refName === pattern; + }); +} + +export type BranchRecordValidation = + | { ok: true } + | { ok: false; reason: string }; + +export type BranchRecordValidationOptions = { + /** Repo owner DID, used to ensure protected branches belong to the canonical repo owner. */ + repoOwnerDid?: string; + /** Exact refs or trailing-* prefix patterns that only maintainers can control. */ + protectedRefs?: readonly string[]; +}; + +/** Repo-level role relevant to branch checkpoint compaction. */ +export type BranchSquashRole = 'owner' | 'maintainer' | 'contributor' | 'moderator' | 'none'; + +/** Validate branch metadata against repo-level ownership rules. */ +export function validateBranchRecord( + branch: BranchData, + options: BranchRecordValidationOptions = {}, +): BranchRecordValidation { + if (!branch.refName.startsWith('refs/heads/')) { + return { ok: false, reason: 'branch refName must start with refs/heads/' }; + } + if (!branch.ownerDid) { + return { ok: false, reason: 'branch ownerDid is required' }; + } + + const protectedRefs = options.protectedRefs ?? DEFAULT_PROTECTED_BRANCH_REFS; + const protectedBranch = isProtectedBranchRef(branch.refName, protectedRefs); + + if (branch.kind === 'protected') { + if (!protectedBranch) { + return { ok: false, reason: 'protected branch kind requires a protected refName' }; + } + if (options.repoOwnerDid && branch.ownerDid !== options.repoOwnerDid) { + return { ok: false, reason: 'protected branch ownerDid must match the repo owner DID' }; + } + return { ok: true }; + } + + if (protectedBranch) { + return { ok: false, reason: 'only protected branch records may use protected refNames' }; + } + + if (branch.kind === 'contributor') { + if (!isContributorBranchRef(branch.refName, branch.ownerDid)) { + return { ok: false, reason: 'contributor branch refName must be under the owner DID namespace' }; + } + return { ok: true }; + } + + if (branch.kind === 'shared') { + if (branch.refName.startsWith('refs/heads/users/')) { + return { ok: false, reason: 'shared branch refName must not be under a contributor namespace' }; + } + return { ok: true }; + } + + return { ok: false, reason: `unknown branch kind: ${(branch as { kind?: string }).kind ?? ''}` }; +} + +/** Build branch metadata for a ref using the repo owner as protected/shared owner. */ +export function branchDataForRef(refName: string, ownerDid: string, createdAt = new Date().toISOString()): BranchData { + let kind: BranchData['kind'] = 'shared'; + if (isProtectedBranchRef(refName)) { + kind = 'protected'; + } else if (isContributorBranchRef(refName, ownerDid)) { + kind = 'contributor'; + } + + return { + refName, + ownerDid, + kind, + createdAt, + }; +} + +/** Return true when an actor may compact branch state/bundles with DWN `$squash`. */ +export function canSquashBranch( + actorDid: string, + role: BranchSquashRole, + branch: BranchData, +): boolean { + if (role === 'owner' || role === 'maintainer') { + return branch.kind === 'protected' || branch.kind === 'shared' || branch.ownerDid === actorDid; + } + + if (role !== 'contributor') { + return false; + } + + return branch.kind === 'contributor' + && branch.ownerDid === actorDid + && isContributorBranchRef(branch.refName, actorDid); +} + +/** A DWN branch state record reduced by the local helper. */ +export type BranchStateRecord = { + recordId: string; + data: BranchStateData; + authorDid?: string; + dateCreated?: string; +}; + +export type AcceptedBranchStateRecord = { + record: BranchStateRecord; + target: string | null; +}; + +export type RejectedBranchStateRecord = { + record: BranchStateRecord; + reason: string; +}; + +export type BranchStateReduction = { + refName: string; + target: string | null; + accepted: AcceptedBranchStateRecord[]; + rejected: RejectedBranchStateRecord[]; + compactedRecordIds: string[]; +}; + +export type ReduceBranchStateOptions = { + /** Initial target when there is no checkpoint. Defaults to null. */ + initialTarget?: string | null; +}; + +/** Reduce branch state records into the current target, honoring the latest checkpoint. */ +export function reduceBranchState( + refName: string, + records: readonly BranchStateRecord[], + options: ReduceBranchStateOptions = {}, +): BranchStateReduction { + const ordered = [...records] + .filter((record) => record.data.refName === refName) + .sort(compareBranchStateRecords); + + let checkpointIndex = -1; + for (let i = ordered.length - 1; i >= 0; i--) { + if (ordered[i].data.kind === 'checkpoint') { + checkpointIndex = i; + break; + } + } + + const compactedRecordIds = checkpointIndex > 0 + ? ordered.slice(0, checkpointIndex).map((record) => record.recordId) + : []; + + const accepted: AcceptedBranchStateRecord[] = []; + const rejected: RejectedBranchStateRecord[] = []; + + let target = options.initialTarget ?? null; + let startIndex = 0; + + if (checkpointIndex >= 0) { + const checkpoint = ordered[checkpointIndex]; + if (checkpoint.data.kind === 'checkpoint') { + target = checkpoint.data.target; + accepted.push({ record: checkpoint, target }); + } + startIndex = checkpointIndex + 1; + } + + for (const record of ordered.slice(startIndex)) { + const data = record.data; + if (data.kind === 'checkpoint') { + target = data.target; + accepted.push({ record, target }); + continue; + } + + const expectedOldTarget = data.oldTarget ?? null; + if (expectedOldTarget !== target) { + rejected.push({ + record, + reason: `stale ref update: expected oldTarget ${formatTarget(target)}, got ${formatTarget(expectedOldTarget)}`, + }); + continue; + } + + target = data.newTarget; + accepted.push({ record, target }); + } + + return { refName, target, accepted, rejected, compactedRecordIds }; +} + +function compareBranchStateRecords(a: BranchStateRecord, b: BranchStateRecord): number { + const byTime = branchStateTimestamp(a).localeCompare(branchStateTimestamp(b)); + if (byTime !== 0) { + return byTime; + } + return a.recordId.localeCompare(b.recordId); +} + +function branchStateTimestamp(record: BranchStateRecord): string { + return record.data.createdAt || record.dateCreated || ''; +} + +function formatTarget(target: string | null): string { + return target ?? ''; +} diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 29e4690..ba7ba55 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -73,11 +73,16 @@ export type AgentContext = { wiki : TypedEnbox; org : TypedEnbox; enbox : Enbox; + /** Test/adapter hook for sending store:false records to another DID. */ + sendRecord? : (record: any, targetDid: string) => Promise; }; /** Valid sync interval values. */ export type SyncInterval = 'off' | '1s' | '5s' | '15s' | '30s' | '1m' | '5m'; +/** Protocol scope shape accepted by Enbox sync registration. */ +type IdentitySyncProtocols = 'all' | [string, ...string[]]; + /** Options for connecting to the agent. */ export type ConnectOptions = { /** Vault password. */ @@ -107,6 +112,20 @@ export type ConnectOptions = { * @default 'off' */ sync? : SyncInterval; + /** + * Whether to run remote DWN tenant registration during AuthManager connect. + * Defaults to enabled unless `GITD_DWN_REGISTRATION=off` or `0` is set. + */ + registration? : boolean; + /** + * DWN endpoints to place in new DID documents and use for AuthManager sync. + * Defaults to `GITD_DWN_ENDPOINTS`, `GITD_DWN_ENDPOINT`, then Enbox hosted DWN. + */ + dwnEndpoints? : string[]; + /** + * Sync protocol scope for gitd records. Defaults to all forge protocols. + */ + identitySyncProtocols? : IdentitySyncProtocols; }; // Re-export types for external consumers. @@ -118,6 +137,21 @@ export type { ProviderAuthParams, RegistrationTokenData }; /** File name for cached DWN registration tokens. */ const TOKENS_FILE = 'registration-tokens.json'; +const DEFAULT_DWN_ENDPOINTS = ['https://enbox-dwn.fly.dev']; + +const GITD_SYNC_PROTOCOLS: IdentitySyncProtocols = [ + ForgeRepoProtocol.definition.protocol, + ForgeRefsProtocol.definition.protocol, + ForgeIssuesProtocol.definition.protocol, + ForgePatchesProtocol.definition.protocol, + ForgeCiProtocol.definition.protocol, + ForgeReleasesProtocol.definition.protocol, + ForgeRegistryProtocol.definition.protocol, + ForgeSocialProtocol.definition.protocol, + ForgeNotificationsProtocol.definition.protocol, + ForgeWikiProtocol.definition.protocol, + ForgeOrgProtocol.definition.protocol, +]; /** * Resolve the path to the registration-tokens file for a profile. @@ -205,6 +239,8 @@ export async function connectAgent(options: ConnectOptions): Promise { /* silent */ }, - onFailure : (err) => { console.error(`[dwn-registration] ${(err as Error).message}`); }, - onProviderAuthRequired : handleProviderAuth, - registrationTokens : loadRegistrationTokens(dataPath), - onRegistrationTokens : (tokens) => { saveRegistrationTokens(dataPath, tokens); }, - }, + dwnEndpoints, + identitySyncProtocols, + registration : registrationEnabled + ? { + onSuccess : () => { /* silent */ }, + onFailure : (err) => { console.error(`[dwn-registration] ${(err as Error).message}`); }, + onProviderAuthRequired : handleProviderAuth, + registrationTokens : loadRegistrationTokens(dataPath), + onRegistrationTokens : (tokens) => { saveRegistrationTokens(dataPath, tokens); }, + } + : undefined, }); // Connect: first launch initializes vault + creates identity; @@ -237,6 +278,11 @@ export async function connectAgent(options: ConnectOptions): Promise endpoint.trim()) + .filter((endpoint) => endpoint.length > 0); + + return endpoints.length > 0 ? endpoints : [...DEFAULT_DWN_ENDPOINTS]; +} + +function isDwnRegistrationEnabled(): boolean { + const value = process.env.GITD_DWN_REGISTRATION?.toLowerCase(); + return value !== 'off' && value !== '0' && value !== 'false'; +} + async function cacheLocalDid(agent: EnboxUserAgent, did: string): Promise { const agentDid = agent.agentDid; const localDid = did === agentDid?.uri @@ -263,6 +326,69 @@ async function cacheLocalDid(agent: EnboxUserAgent, did: string): Promise }); } +async function ensureGitdSyncScope( + agent: EnboxUserAgent, + did: string, + gitdProtocols: IdentitySyncProtocols, +): Promise { + const sync = agent.sync as unknown as { + getIdentityOptions: (did: string) => Promise<{ protocols: IdentitySyncProtocols; delegateDid?: string } | undefined>; + registerIdentity: (params: { did: string; options: { protocols: IdentitySyncProtocols; delegateDid?: string } }) => Promise; + updateIdentityOptions: (params: { did: string; options: { protocols: IdentitySyncProtocols; delegateDid?: string } }) => Promise; + }; + + const existing = await sync.getIdentityOptions(did); + const mergedProtocols = mergeSyncProtocols(existing?.protocols, gitdProtocols); + if (existing?.protocols === 'all' || sameStringSet(existing?.protocols, mergedProtocols)) { + return; + } + + const options = { + ...(existing?.delegateDid ? { delegateDid: existing.delegateDid } : {}), + protocols: mergedProtocols, + }; + + if (existing) { + await sync.updateIdentityOptions({ did, options }); + return; + } + + await sync.registerIdentity({ did, options }); +} + +async function pushGitdSyncScope(agent: EnboxUserAgent): Promise { + try { + await agent.sync.sync('push'); + } catch (err) { + console.error(`[dwn-sync] initial gitd push failed: ${(err as Error).message}`); + } +} + +function mergeSyncProtocols( + existing: IdentitySyncProtocols | undefined, + next: IdentitySyncProtocols, +): IdentitySyncProtocols { + if (existing === 'all' || next === 'all') { return 'all'; } + + const merged = new Set(existing ?? []); + for (const protocol of next) { + merged.add(protocol); + } + + return [...merged] as [string, ...string[]]; +} + +function sameStringSet( + left: IdentitySyncProtocols | undefined, + right: IdentitySyncProtocols, +): boolean { + if (left === undefined) { return false; } + if (left === 'all' || right === 'all') { return left === right; } + if (left.length !== right.length) { return false; } + const rightSet = new Set(right); + return left.every((value) => rightSet.has(value)); +} + /** Bind typed protocol handles and configure all protocols. */ async function bindProtocols( enbox: Enbox, diff --git a/src/cli/commands/clone.ts b/src/cli/commands/clone.ts index 0e2cc0f..3424b95 100644 --- a/src/cli/commands/clone.ts +++ b/src/cli/commands/clone.ts @@ -161,4 +161,8 @@ export async function cloneCommand(args: string[]): Promise { cwd : cloneDir, stdio : 'pipe', }); + spawnSync('git', ['config', 'enbox.owner', didPart], { + cwd : cloneDir, + stdio : 'pipe', + }); } diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 3d360a4..f10031a 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -76,6 +76,7 @@ export async function initCommand(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + debugIssue('create: resolving target'); + const target = await resolveIssueTarget(ctx, args); + debugIssue(`create: target resolved ${target.ownerDid}/${target.repo.name}`); + debugIssue('create: checking write access'); + await ensureRepoWriteAllowed(ctx, target); + debugIssue('create: resolving role'); + const protocolRole = await issueWriteRole(ctx, target); + debugIssue(`create: role ${protocolRole ?? ''}`); + if (target.remote) { + debugIssue('create: creating remote issue'); + const recordId = await createRemoteIssue(ctx, target, title, body, protocolRole); + console.log(`Created issue ${shortId(recordId)}: "${title}"`); + console.log(` Record ID: ${recordId}`); + return; + } const { status, record } = await ctx.issues.records.create('repo/issue', { data : { title, body }, tags : { status: 'open' }, - parentContextId : repoContextId, - }); + parentContextId : target.repo.contextId, + } as any); if (status.code >= 300) { console.error(`Failed to create issue: ${status.code} ${status.detail}`); @@ -72,7 +92,6 @@ async function issueCreate(ctx: AgentContext, args: string[]): Promise { } if (!record) {throw new Error('Failed to create issue record');} - const id = shortId(record.id); console.log(`Created issue ${id}: "${title}"`); console.log(` Record ID: ${record.id}`); @@ -89,8 +108,8 @@ async function issueShow(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const record = await findById(ctx, repoContextId, idStr); + const target = await resolveIssueTarget(ctx, args); + const record = await findById(ctx, target, idStr); if (!record) { console.error(`Issue ${idStr} not found.`); process.exit(1); @@ -113,14 +132,16 @@ async function issueShow(ctx: AgentContext, args: string[]): Promise { // Fetch comments. const { records: comments } = await ctx.issues.records.query('repo/issue/comment' as any, { + ...(target.from ? { from: target.from } : {}), filter: { contextId: record.contextId }, }); - if (comments.length > 0) { + const visibleComments = await visibleCommentRecords(ctx, target, 'issueComment', comments); + if (visibleComments.length > 0) { console.log(''); - console.log(` Comments (${comments.length}):`); + console.log(` Comments (${visibleComments.length}):`); console.log(' ---'); - for (const comment of comments) { + for (const comment of visibleComments) { const commentData = await comment.data.json(); const commentDate = comment.dateCreated?.slice(0, 19)?.replace('T', ' ') ?? ''; console.log(` ${commentDate}`); @@ -146,16 +167,24 @@ async function issueComment(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const issue = await findById(ctx, repoContextId, idStr); + const target = await resolveIssueTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const issue = await findById(ctx, target, idStr); if (!issue) { console.error(`Issue ${idStr} not found.`); process.exit(1); } - const { status } = await ctx.issues.records.create('repo/issue/comment' as any, { + if (await discussionIsLocked(ctx, target, 'issue', issue)) { + console.error(`Issue ${idStr} is locked.`); + process.exit(1); + } + + const protocolRole = await issueWriteRole(ctx, target); + const { status, record: commentRecord } = await ctx.issues.records.create('repo/issue/comment' as any, { data : { body }, parentContextId : issue.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), } as any); if (status.code >= 300) { @@ -163,6 +192,11 @@ async function issueComment(ctx: AgentContext, args: string[]): Promise { process.exit(1); } + if (target.remote) { + if (!commentRecord) { throw new Error('Failed to create issue comment record'); } + await sendRecordToTarget(ctx, commentRecord, target.ownerDid, 'issue comment'); + } + console.log(`Added comment to issue ${idStr}.`); } @@ -178,8 +212,9 @@ async function issueClose(ctx: AgentContext, args: string[]): Promise { } const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const issue = await findById(ctx, repoContextId, idStr); + const target = await resolveIssueTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const issue = await findById(ctx, target, idStr); if (!issue) { console.error(`Issue ${idStr} not found.`); process.exit(1); @@ -193,25 +228,34 @@ async function issueClose(ctx: AgentContext, args: string[]): Promise { return; } - const { status } = await issue.update({ + const { status, record: updatedIssue } = await issue.update({ data : data, tags : { ...tags, status: 'closed' }, - }); + ...(target.remote ? { store: false } : {}), + } as any); if (status.code >= 300) { console.error(`Failed to close issue: ${status.code} ${status.detail}`); process.exit(1); } + if (target.remote) { + await sendRecordToTarget(ctx, updatedIssue ?? issue, target.ownerDid, 'issue update'); + } + const event = await ctx.issues.records.create('repo/issue/statusChange' as any, { data : reason ? { reason } : {}, tags : { from: tags?.status ?? 'open', to: 'closed' }, parentContextId : issue.contextId, + ...(target.remote ? { store: false } : {}), } as any); if (event.status.code >= 300) { console.error(`Failed to record issue status change: ${event.status.code} ${event.status.detail}`); process.exit(1); } + if (target.remote && event.record) { + await sendRecordToTarget(ctx, event.record, target.ownerDid, 'issue status change'); + } console.log(`Closed issue ${idStr}: "${data.title}"`); } @@ -227,8 +271,9 @@ async function issueReopen(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const issue = await findById(ctx, repoContextId, idStr); + const target = await resolveIssueTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const issue = await findById(ctx, target, idStr); if (!issue) { console.error(`Issue ${idStr} not found.`); process.exit(1); @@ -242,25 +287,33 @@ async function issueReopen(ctx: AgentContext, args: string[]): Promise { return; } - const { status } = await issue.update({ + const { status, record: updatedIssue } = await issue.update({ data : data, tags : { ...tags, status: 'open' }, - }); + ...(target.remote ? { store: false } : {}), + } as any); if (status.code >= 300) { console.error(`Failed to reopen issue: ${status.code} ${status.detail}`); process.exit(1); } + if (target.remote) { + await sendRecordToTarget(ctx, updatedIssue ?? issue, target.ownerDid, 'issue update'); + } const event = await ctx.issues.records.create('repo/issue/statusChange' as any, { data : {}, tags : { from: tags?.status ?? 'closed', to: 'open' }, parentContextId : issue.contextId, + ...(target.remote ? { store: false } : {}), } as any); if (event.status.code >= 300) { console.error(`Failed to record issue status change: ${event.status.code} ${event.status.detail}`); process.exit(1); } + if (target.remote && event.record) { + await sendRecordToTarget(ctx, event.record, target.ownerDid, 'issue status change'); + } console.log(`Reopened issue ${idStr}: "${data.title}"`); } @@ -388,11 +441,11 @@ async function issueIgnore(ctx: AgentContext, args: string[]): Promise { async function issueList(ctx: AgentContext, args: string[]): Promise { const statusFilter = flagValue(args, '--status') ?? flagValue(args, '-s'); - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const target = await resolveIssueTarget(ctx, args); const filter: Record = {}; - if (repoContextId) { - filter.contextId = repoContextId; + if (target.repo.contextId) { + filter.contextId = target.repo.contextId; } const tags: Record = {}; @@ -404,6 +457,7 @@ async function issueList(ctx: AgentContext, args: string[]): Promise { } const { records } = await ctx.issues.records.query('repo/issue', { + ...(target.from ? { from: target.from } : {}), filter, }); @@ -433,16 +487,108 @@ async function issueList(ctx: AgentContext, args: string[]): Promise { */ async function findById( ctx: AgentContext, - repoContextId: string, + target: IssueTarget, idStr: string, ): Promise { const { records } = await ctx.issues.records.query('repo/issue', { - filter: { contextId: repoContextId }, + ...(target.from ? { from: target.from } : {}), + filter: { contextId: target.repo.contextId }, }); return findByShortId(records, idStr); } +type IssueTarget = { + ownerDid : string; + repo : RepoContext; + from? : string; + remote : boolean; +}; + +async function createRemoteIssue( + ctx: AgentContext, + target: IssueTarget, + title: string, + body: string, + protocolRole?: string, +): Promise { + if (configuredDwnEndpoints(ctx).length === 0) { + const { status, record } = await ctx.issues.records.create('repo/issue', { + data : { title, body }, + tags : { status: 'open' }, + parentContextId : target.repo.contextId, + protocolRole, + store : false, + } as any); + + if (status.code >= 300) { + console.error(`Failed to create issue: ${status.code} ${status.detail}`); + process.exit(1); + } + + if (!record) { throw new Error('Failed to create issue record'); } + await sendRecordToTarget(ctx, record, target.ownerDid, 'issue'); + return record.id; + } + + const data = jsonBody({ title, body }); + debugIssue('remote: loading signer'); + const signer = await messageSignerForContext(ctx); + debugIssue('remote: composing write'); + const write = await RecordsWrite.create({ + protocol : ForgeIssuesDefinition.protocol, + protocolPath : 'repo/issue', + schema : ForgeIssuesDefinition.types.issue.schema, + dataFormat : 'application/json', + data, + tags : { status: 'open' }, + parentContextId : target.repo.contextId, + protocolRole, + published : true, + recipient : target.ownerDid, + signer, + }); + + debugIssue('remote: processing write'); + await processMessageOnTargetEndpoints(ctx, target.ownerDid, write.message, 'issue', bodyInit(data)); + debugIssue('remote: write processed'); + return write.message.recordId; +} + +async function resolveIssueTarget(ctx: AgentContext, args: string[]): Promise { + const ownerDid = resolveRepoOwner(args) ?? ctx.did; + const repo = await getRepoContextForDid(ctx, ownerDid, resolveRepoName(args)); + const from = fromOpt(ctx, ownerDid); + return { ownerDid, repo, from, remote: ownerDid !== ctx.did }; +} + +async function issueWriteRole(ctx: AgentContext, target: IssueTarget): Promise { + return resolveTargetRole(ctx, target, ['contributor', 'moderator', 'maintainer', 'triager'], 'contributor'); +} + +async function ensureRepoWriteAllowed(ctx: AgentContext, target: IssueTarget): Promise { + const block = await latestActiveBlock(ctx, target); + if (!block) { + return; + } + + const reason = block.data.reason ? ` Reason: ${block.data.reason}` : ''; + console.error(`You are blocked from writing to ${target.ownerDid}/${target.repo.name}.${reason}`); + process.exit(1); +} + +async function resolveTargetRole( + ctx: AgentContext, + target: IssueTarget, + candidates: readonly RepoRoleName[], + fallback: RepoRoleName, +): Promise { + if (target.remote) { + return `repo:repo/${fallback}`; + } + return resolveRepoProtocolRole(ctx, target.ownerDid, target.repo.contextId, candidates, fallback); +} + function findExternalRecord(records: any[], idStr: string): any | undefined { return records.find(record => record.id === idStr || shortId(record.id).startsWith(idStr.toLowerCase())); } @@ -495,3 +641,9 @@ async function copyExternalIssueThread( return { comments: copiedComments, statusChanges: copiedStatusChanges }; } + +function debugIssue(message: string): void { + if (process.env.GITD_DEBUG === '1') { + console.error(`[issue] ${message}`); + } +} diff --git a/src/cli/commands/mod.ts b/src/cli/commands/mod.ts new file mode 100644 index 0000000..0856cb4 --- /dev/null +++ b/src/cli/commands/mod.ts @@ -0,0 +1,289 @@ +/** + * `gitd mod` — focused repository moderator management. + * + * Usage: + * gitd mod add [--alias ] Grant moderator role + * gitd mod remove Revoke moderator role + * gitd mod list List repository moderators + * + * @module + */ + +import type { AgentContext } from '../agent.js'; +import type { ModerationEventData } from '../../repo.js'; + +import { getRepoContextForDid, getRepoContextId } from '../repo-context.js'; +import { repoCommand } from './repo.js'; +import { flagValue, resolveRepoName, resolveRepoOwner } from '../flags.js'; +import { sendRecordToTarget } from '../record-send.js'; + +// --------------------------------------------------------------------------- +// Sub-command dispatch +// --------------------------------------------------------------------------- + +export async function modCommand(ctx: AgentContext, args: string[]): Promise { + const sub = args[0]; + const rest = args.slice(1); + + switch (sub) { + case 'add': return repoCommand(ctx, ['add-moderator', ...rest]); + case 'remove': + case 'rm': return repoCommand(ctx, ['remove-moderator', ...rest]); + case 'list': + case 'ls': return modList(ctx, rest); + case 'block': return moderationTargetDid(ctx, rest, 'block'); + case 'unblock': return moderationTargetDid(ctx, rest, 'unblock'); + case 'lock': return moderationLock(ctx, rest, 'lock'); + case 'unlock': return moderationLock(ctx, rest, 'unlock'); + case 'hide-comment': return moderationComment(ctx, rest, 'hideComment'); + case 'unhide-comment': return moderationComment(ctx, rest, 'unhideComment'); + case 'delete-comment': return moderationComment(ctx, rest, 'deleteComment'); + case 'report': return moderationReport(ctx, rest); + case 'resolve-report': return moderationReportDecision(ctx, rest, 'resolveReport'); + case 'dismiss-report': return moderationReportDecision(ctx, rest, 'dismissReport'); + case 'interaction-limit': return moderationInteractionLimit(ctx, rest); + default: + console.error('Usage: gitd mod '); + process.exit(1); + } +} + +async function modList(ctx: AgentContext, args: string[]): Promise { + const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const { records } = await ctx.repo.records.query('repo/moderator' as any, { + filter: { contextId: repoContextId }, + }); + + if (records.length === 0) { + console.log('No moderators found.'); + return; + } + + console.log(`Moderators (${records.length}):`); + for (const record of records) { + const data = await record.data.json(); + const alias = data.alias ? ` (${data.alias})` : ''; + console.log(` - ${data.did}${alias}`); + } +} + +async function moderationTargetDid( + ctx: AgentContext, + args: string[], + action: 'block' | 'unblock', +): Promise { + const did = args[0]; + if (!did) { + console.error(`Usage: gitd mod ${action} [--reason ] [--repo ] [--owner ]`); + process.exit(1); + } + + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + await createModerationEvent(ctx, args, { + action, + targetDid : did, + targetKind: 'repo', + reason, + }); + + if (action === 'block') { + await revokeBlockedRoles(ctx, args, did); + } + + console.log(`${action === 'block' ? 'Blocked' : 'Unblocked'} ${did}.`); +} + +async function moderationLock( + ctx: AgentContext, + args: string[], + action: 'lock' | 'unlock', +): Promise { + const targetKind = parseDiscussionKind(args[0]); + const targetId = args[1]; + if (!targetKind || !targetId) { + console.error(`Usage: gitd mod ${action} [--reason ] [--repo ] [--owner ]`); + process.exit(1); + } + + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + await createModerationEvent(ctx, args, { + action, + targetKind, + targetId, + reason, + }); + + console.log(`${action === 'lock' ? 'Locked' : 'Unlocked'} ${targetKind} ${targetId}.`); +} + +async function moderationComment( + ctx: AgentContext, + args: string[], + action: 'hideComment' | 'unhideComment' | 'deleteComment', +): Promise { + const targetId = args[0]; + if (!targetId) { + const command = action === 'hideComment' + ? 'hide-comment' + : action === 'unhideComment' + ? 'unhide-comment' + : 'delete-comment'; + console.error(`Usage: gitd mod ${command} [--kind ] [--reason ] [--repo ] [--owner ]`); + process.exit(1); + } + + const kind = flagValue(args, '--kind') === 'pr' ? 'prComment' : 'issueComment'; + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + await createModerationEvent(ctx, args, { + action, + targetKind: kind, + targetId, + reason, + }); + + const label = action === 'hideComment' + ? 'Hid' + : action === 'unhideComment' + ? 'Unhid' + : 'Deleted'; + console.log(`${label} comment ${targetId}.`); +} + +async function moderationReport(ctx: AgentContext, args: string[]): Promise { + const targetId = args[0]; + if (!targetId) { + console.error('Usage: gitd mod report [--kind ] [--reason ] [--repo ] [--owner ]'); + process.exit(1); + } + + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + await createModerationEvent(ctx, args, { + action : 'report', + targetKind : parseReportKind(flagValue(args, '--kind')), + targetId, + reason, + reportStatus : 'open', + }); + console.log(`Reported ${targetId}.`); +} + +async function moderationReportDecision( + ctx: AgentContext, + args: string[], + action: 'resolveReport' | 'dismissReport', +): Promise { + const targetId = args[0]; + if (!targetId) { + const command = action === 'resolveReport' ? 'resolve-report' : 'dismiss-report'; + console.error(`Usage: gitd mod ${command} [--reason ] [--repo ] [--owner ]`); + process.exit(1); + } + + const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); + await createModerationEvent(ctx, args, { + action, + targetKind : 'report', + targetId, + reason, + reportStatus : action === 'resolveReport' ? 'resolved' : 'dismissed', + }); + console.log(`${action === 'resolveReport' ? 'Resolved' : 'Dismissed'} report ${targetId}.`); +} + +async function moderationInteractionLimit(ctx: AgentContext, args: string[]): Promise { + const limit = args[0] as ModerationEventData['interactionLimit'] | undefined; + if (!limit || !['off', 'contributors', 'collaborators'].includes(limit)) { + console.error('Usage: gitd mod interaction-limit [--duration ] [--repo ] [--owner ]'); + process.exit(1); + } + + await createModerationEvent(ctx, args, { + action: 'interactionLimit', + targetKind: 'repo', + interactionLimit: limit, + duration: flagValue(args, '--duration'), + }); + console.log(`Set interaction limit: ${limit}.`); +} + +async function createModerationEvent( + ctx: AgentContext, + args: string[], + partial: Omit, +): Promise { + const ownerDid = resolveRepoOwner(args) ?? ctx.did; + const repoName = resolveRepoName(args); + const repo = await getRepoContextForDid(ctx, ownerDid, repoName); + const remote = ownerDid !== ctx.did; + const roleName = remote ? 'moderator' : undefined; + const protocolRole = roleName ? `repo/${roleName}` : undefined; + const data: ModerationEventData = { + ...partial, + actorDid : ctx.did, + createdAt : new Date().toISOString(), + }; + const tags = moderationTags(data); + + const { status, record } = await ctx.repo.records.create('repo/moderationEvent' as any, { + data, + tags, + parentContextId : repo.contextId, + ...(remote ? { protocolRole, store: false } : {}), + } as any); + + if (status.code >= 300) { + console.error(`Failed to create moderation event: ${status.code} ${status.detail}`); + process.exit(1); + } + if (!record) { throw new Error('Failed to create moderation event record'); } + + if (remote) { + await sendRecordToTarget(ctx, record, ownerDid, 'moderation event'); + } + + return record; +} + +function moderationTags(data: ModerationEventData): Record { + const tags: Record = { + action: data.action, + actorDid: data.actorDid, + }; + if (data.targetDid) { tags.targetDid = data.targetDid; } + if (data.targetKind) { tags.targetKind = data.targetKind; } + if (data.targetId) { tags.targetId = data.targetId; } + if (data.reportStatus) { tags.reportStatus = data.reportStatus; } + if (data.interactionLimit) { tags.interactionLimit = data.interactionLimit; } + return tags; +} + +async function revokeBlockedRoles(ctx: AgentContext, args: string[], did: string): Promise { + const ownerDid = resolveRepoOwner(args) ?? ctx.did; + if (ownerDid !== ctx.did) { + return; + } + + const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + for (const role of ['contributor', 'moderator'] as const) { + const { records } = await ctx.repo.records.query(`repo/${role}` as any, { + filter: { contextId: repoContextId, tags: { did } }, + }); + for (const record of records) { + await record.delete(); + } + } +} + +function parseDiscussionKind(value: string | undefined): 'issue' | 'pr' | undefined { + if (value === 'issue' || value === 'pr') { + return value; + } + return undefined; +} + +function parseReportKind(value: string | undefined): ModerationEventData['targetKind'] { + if (value === 'pr') { return 'pr'; } + if (value === 'issue-comment') { return 'issueComment'; } + if (value === 'pr-comment') { return 'prComment'; } + return 'issue'; +} diff --git a/src/cli/commands/pr.ts b/src/cli/commands/pr.ts index 1303967..3251a41 100644 --- a/src/cli/commands/pr.ts +++ b/src/cli/commands/pr.ts @@ -19,16 +19,36 @@ */ import type { AgentContext } from '../agent.js'; +import type { RepoContext, RepoRoleName } from '../repo-context.js'; +import { Buffer } from 'node:buffer'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { HttpDwnRpcClient } from '@enbox/dwn-clients'; +import { + DataStream, + RecordsQuery, + RecordsRead, + RecordsWrite, +} from '@enbox/dwn-sdk-js'; + import { recordIgnoredSubmission } from '../submission-decisions.js'; -import { findByShortId, shortId } from '../../github-shim/helpers.js'; -import { flagValue, hasFlag, resolveRepoName } from '../flags.js'; -import { getRepoContext, getRepoContextId } from '../repo-context.js'; +import { shortId } from '../../github-shim/helpers.js'; +import { flagValue, hasFlag, resolveRepoName, resolveRepoOwner } from '../flags.js'; +import { fromOpt, getRepoContext, getRepoContextForDid, getRepoContextId, resolveRepoProtocolRole } from '../repo-context.js'; +import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; +import { ForgePatchesDefinition } from '../../patches.js'; +import { + bodyInit, + configuredDwnEndpoints, + jsonBody, + messageSignerForContext, + processMessageOnTargetEndpoints, + sendRecordToTarget, +} from '../record-send.js'; // --------------------------------------------------------------------------- // Sub-command dispatch @@ -73,7 +93,12 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const target = await resolvePatchTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const protocolRole = await patchCreateRole(ctx, target); + if (target.remote) { + await (ctx.patches as any).configure?.({ encryption: true }); + } // Detect git context for revision + bundle creation. const gitInfo = noBundle ? null : detectGitContext(base); @@ -90,8 +115,9 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise { const { status, record } = await ctx.patches.records.create('repo/patch', { data : { title, body }, tags, - parentContextId : repoContextId, - }); + parentContextId : target.repo.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); if (status.code >= 300) { console.error(`Failed to create PR: ${status.code} ${status.detail}`); @@ -99,6 +125,9 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise { } if (!record) {throw new Error('Failed to create PR record');} + if (target.remote) { + await sendRecordToTarget(ctx, record, target.ownerDid, 'PR'); + } const id = shortId(record.id); @@ -107,7 +136,7 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise { // Create revision + bundle if we have git context. if (gitInfo) { - await createRevisionAndBundle(ctx, record, gitInfo); + await createRevisionAndBundle(ctx, record, gitInfo, target); } } @@ -132,8 +161,8 @@ async function prCheckout(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const patch = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + const patch = await findById(ctx, target, idStr); if (!patch) { console.error(`PR ${idStr} not found.`); process.exit(1); @@ -142,8 +171,8 @@ async function prCheckout(ctx: AgentContext, args: string[]): Promise { const patchTags = patch.tags as Record | undefined; // Fetch the latest revision under this patch. - const { records: revisions } = await ctx.patches.records.query('repo/patch/revision' as any, { - filter: { contextId: patch.contextId }, + const revisions = await queryPatchRecords(ctx, target, 'repo/patch/revision', { + contextId: patch.contextId, }); if (revisions.length === 0) { @@ -156,8 +185,8 @@ async function prCheckout(ctx: AgentContext, args: string[]): Promise { const revisionTags = revision.tags as Record | undefined; // Fetch the bundle from the revision. - const { records: bundles } = await ctx.patches.records.query('repo/patch/revision/revisionBundle' as any, { - filter: { contextId: revision.contextId }, + const bundles = await queryPatchRecords(ctx, target, 'repo/patch/revision/revisionBundle', { + contextId: revision.contextId, }); if (bundles.length === 0) { @@ -246,8 +275,8 @@ async function prShow(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const record = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + const record = await findById(ctx, target, idStr); if (!record) { console.error(`PR ${idStr} not found.`); process.exit(1); @@ -274,14 +303,16 @@ async function prShow(ctx: AgentContext, args: string[]): Promise { // Fetch reviews. const { records: reviews } = await ctx.patches.records.query('repo/patch/review' as any, { + ...(target.from ? { from: target.from } : {}), filter: { contextId: record.contextId }, }); - if (reviews.length > 0) { + const visibleReviews = await visibleCommentRecords(ctx, target, 'prComment', reviews); + if (visibleReviews.length > 0) { console.log(''); - console.log(` Reviews (${reviews.length}):`); + console.log(` Reviews (${visibleReviews.length}):`); console.log(' ---'); - for (const review of reviews) { + for (const review of visibleReviews) { const reviewData = await review.data.json(); const reviewTags = review.tags as Record | undefined; const verdict = reviewTags?.verdict ?? 'comment'; @@ -312,24 +343,36 @@ async function prComment(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const patch = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const patch = await findById(ctx, target, idStr); if (!patch) { console.error(`PR ${idStr} not found.`); process.exit(1); } + if (await discussionIsLocked(ctx, target, 'pr', patch)) { + console.error(`PR ${idStr} is locked.`); + process.exit(1); + } + + const protocolRole = await patchDiscussionRole(ctx, target); // Create a review with verdict: 'comment' (general comment, not approve/reject). - const { status } = await ctx.patches.records.create('repo/patch/review' as any, { + const { status, record: reviewRecord } = await ctx.patches.records.create('repo/patch/review' as any, { data : { body }, tags : { verdict: 'comment' }, parentContextId : patch.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), } as any); if (status.code >= 300) { console.error(`Failed to add comment: ${status.code} ${status.detail}`); process.exit(1); } + if (target.remote) { + if (!reviewRecord) { throw new Error('Failed to create PR comment record'); } + await sendRecordToTarget(ctx, reviewRecord, target.ownerDid, 'PR comment'); + } console.log(`Added comment to PR ${idStr}.`); } @@ -359,8 +402,9 @@ async function prMerge(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const patch = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const patch = await findById(ctx, target, idStr); if (!patch) { console.error(`PR ${idStr} not found.`); process.exit(1); @@ -489,29 +533,70 @@ async function prMerge(ctx: AgentContext, args: string[]): Promise { const mergeCommit = git(['rev-parse', 'HEAD']) ?? 'unknown'; // Update the patch status to merged. - const { status } = await patch.update({ - data : data, - tags : { ...tags, status: 'merged' }, - }); + const protocolRole = await patchMaintainerRole(ctx, target, 'maintainer'); + if (isEndpointBackedRecord(patch)) { + await updateEndpointBackedPatch(ctx, target, patch, data, { ...tags, status: 'merged' }, protocolRole); + } else { + const { status, record: updatedPatch } = await patch.update({ + data : data, + tags : { ...tags, status: 'merged' }, + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); - if (status.code >= 300) { - console.error(`Failed to update PR status: ${status.code} ${status.detail}`); - process.exit(1); + if (status.code >= 300) { + console.error(`Failed to update PR status: ${status.code} ${status.detail}`); + process.exit(1); + } + if (target.remote) { + await sendRecordToTarget(ctx, updatedPatch ?? patch, target.ownerDid, 'PR update'); + } } // Create a merge result record with the actual commit SHA. - await ctx.patches.records.create('repo/patch/mergeResult' as any, { - data : { mergedBy: ctx.did }, - tags : { mergeCommit, strategy }, - parentContextId : patch.contextId, - } as any); + if (isEndpointBackedRecord(patch)) { + await createEndpointBackedPatchChild(ctx, target, { + protocolPath : 'repo/patch/mergeResult', + schema : ForgePatchesDefinition.types.mergeResult.schema, + data : { mergedBy: ctx.did }, + tags : { mergeCommit, strategy }, + parentContextId : patch.contextId, + protocolRole, + label : 'PR merge result', + }); + } else { + const mergeResult = await ctx.patches.records.create('repo/patch/mergeResult' as any, { + data : { mergedBy: ctx.did }, + tags : { mergeCommit, strategy }, + parentContextId : patch.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); + if (target.remote && mergeResult.record) { + await sendRecordToTarget(ctx, mergeResult.record, target.ownerDid, 'PR merge result'); + } + } // Create a status change record (audit trail). - await ctx.patches.records.create('repo/patch/statusChange' as any, { - data : { reason: `Merged via ${strategy} strategy` }, - tags : { from: tags?.status ?? 'open', to: 'merged' }, - parentContextId : patch.contextId, - } as any); + if (isEndpointBackedRecord(patch)) { + await createEndpointBackedPatchChild(ctx, target, { + protocolPath : 'repo/patch/statusChange', + schema : ForgePatchesDefinition.types.statusChange.schema, + data : { reason: `Merged via ${strategy} strategy` }, + tags : { from: tags?.status ?? 'open', to: 'merged' }, + parentContextId : patch.contextId, + protocolRole, + label : 'PR status change', + }); + } else { + const statusChange = await ctx.patches.records.create('repo/patch/statusChange' as any, { + data : { reason: `Merged via ${strategy} strategy` }, + tags : { from: tags?.status ?? 'open', to: 'merged' }, + parentContextId : patch.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); + if (target.remote && statusChange.record) { + await sendRecordToTarget(ctx, statusChange.record, target.ownerDid, 'PR status change'); + } + } const commitLabel = commitCount > 0 ? ` (${commitCount} commit${commitCount !== 1 ? 's' : ''})` @@ -542,8 +627,9 @@ async function prClose(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const patch = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const patch = await findById(ctx, target, idStr); if (!patch) { console.error(`PR ${idStr} not found.`); process.exit(1); @@ -562,22 +648,31 @@ async function prClose(ctx: AgentContext, args: string[]): Promise { return; } - const { status } = await patch.update({ + const protocolRole = await patchMaintainerRole(ctx, target); + const { status, record: updatedPatch } = await patch.update({ data : data, tags : { ...tags, status: 'closed' }, - }); + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); if (status.code >= 300) { console.error(`Failed to close PR: ${status.code} ${status.detail}`); process.exit(1); } + if (target.remote) { + await sendRecordToTarget(ctx, updatedPatch ?? patch, target.ownerDid, 'PR update'); + } // Audit trail. - await ctx.patches.records.create('repo/patch/statusChange' as any, { + const statusChange = await ctx.patches.records.create('repo/patch/statusChange' as any, { data : { reason: 'Closed by maintainer' }, tags : { from: tags?.status ?? 'open', to: 'closed' }, parentContextId : patch.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), } as any); + if (target.remote && statusChange.record) { + await sendRecordToTarget(ctx, statusChange.record, target.ownerDid, 'PR status change'); + } console.log(`Closed PR ${idStr}: "${data.title}"`); } @@ -593,8 +688,9 @@ async function prReopen(ctx: AgentContext, args: string[]): Promise { process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); - const patch = await findById(ctx, repoContextId, idStr); + const target = await resolvePatchTarget(ctx, args); + await ensureRepoWriteAllowed(ctx, target); + const patch = await findById(ctx, target, idStr); if (!patch) { console.error(`PR ${idStr} not found.`); process.exit(1); @@ -613,22 +709,31 @@ async function prReopen(ctx: AgentContext, args: string[]): Promise { return; } - const { status } = await patch.update({ + const protocolRole = await patchMaintainerRole(ctx, target); + const { status, record: updatedPatch } = await patch.update({ data : data, tags : { ...tags, status: 'open' }, - }); + ...(target.remote ? { protocolRole, store: false } : {}), + } as any); if (status.code >= 300) { console.error(`Failed to reopen PR: ${status.code} ${status.detail}`); process.exit(1); } + if (target.remote) { + await sendRecordToTarget(ctx, updatedPatch ?? patch, target.ownerDid, 'PR update'); + } // Audit trail. - await ctx.patches.records.create('repo/patch/statusChange' as any, { + const statusChange = await ctx.patches.records.create('repo/patch/statusChange' as any, { data : { reason: 'Reopened by maintainer' }, tags : { from: tags?.status ?? 'closed', to: 'open' }, parentContextId : patch.contextId, + ...(target.remote ? { protocolRole, store: false } : {}), } as any); + if (target.remote && statusChange.record) { + await sendRecordToTarget(ctx, statusChange.record, target.ownerDid, 'PR status change'); + } console.log(`Reopened PR ${idStr}: "${data.title}"`); } @@ -772,11 +877,11 @@ async function prIgnore(ctx: AgentContext, args: string[]): Promise { async function prList(ctx: AgentContext, args: string[]): Promise { const statusFilter = flagValue(args, '--status') ?? flagValue(args, '-s'); - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const target = await resolvePatchTarget(ctx, args); const filter: Record = {}; - if (repoContextId) { - filter.contextId = repoContextId; + if (target.repo.contextId) { + filter.contextId = target.repo.contextId; } const filterTags: Record = {}; @@ -788,6 +893,7 @@ async function prList(ctx: AgentContext, args: string[]): Promise { } const { records } = await ctx.patches.records.query('repo/patch', { + ...(target.from ? { from: target.from } : {}), filter, }); @@ -895,6 +1001,7 @@ async function createRevisionAndBundle( ctx: AgentContext, patchRecord: any, gitCtx: GitContext, + target?: PatchTarget, ): Promise { // Create the revision record. const { status: revStatus, record: revisionRecord } = await ctx.patches.records.create( @@ -910,6 +1017,7 @@ async function createRevisionAndBundle( commitCount : gitCtx.commitCount, }, parentContextId: patchRecord.contextId, + ...(target?.remote ? { store: false } : {}), } as any, ); @@ -918,6 +1026,9 @@ async function createRevisionAndBundle( return; } if (!revisionRecord) {throw new Error('Failed to create revision record');} + if (target?.remote) { + await sendRecordToTarget(ctx, revisionRecord, target.ownerDid, 'PR revision'); + } console.log(` Revision: ${gitCtx.commitCount} commit${gitCtx.commitCount !== 1 ? 's' : ''} (${gitCtx.baseCommit.slice(0, 7)}..${gitCtx.headCommit.slice(0, 7)})`); console.log(` DiffStat: +${gitCtx.diffStat.additions} -${gitCtx.diffStat.deletions} (${gitCtx.diffStat.filesChanged} file${gitCtx.diffStat.filesChanged !== 1 ? 's' : ''})`); @@ -938,7 +1049,7 @@ async function createRevisionAndBundle( const refListOutput = git(['bundle', 'list-heads', bundlePath]) ?? ''; const refCount = refListOutput.split('\n').filter((l) => l.trim().length > 0).length; - const { status: bundleStatus } = await ctx.patches.records.create( + const { status: bundleStatus, record: bundleRecord } = await ctx.patches.records.create( 'repo/patch/revision/revisionBundle' as any, { data : bundleBytes, @@ -950,6 +1061,7 @@ async function createRevisionAndBundle( size : bundleSize, }, parentContextId: revisionRecord.contextId, + ...(target?.remote ? { store: false } : {}), } as any, ); @@ -957,6 +1069,10 @@ async function createRevisionAndBundle( console.error(` Warning: failed to attach bundle: ${bundleStatus.code} ${bundleStatus.detail}`); return; } + if (target?.remote) { + if (!bundleRecord) { throw new Error('Failed to create PR bundle record'); } + await sendRecordToTarget(ctx, bundleRecord, target.ownerDid, 'PR bundle'); + } console.log(` Bundle: ${bundleSize} bytes, ${refCount} ref${refCount !== 1 ? 's' : ''}`); } finally { @@ -1116,14 +1232,287 @@ async function copyExternalPatchDiscussion( */ async function findById( ctx: AgentContext, - repoContextId: string, + target: PatchTarget, idStr: string, ): Promise { - const { records } = await ctx.patches.records.query('repo/patch', { - filter: { contextId: repoContextId }, + const records = await queryPatchRecords(ctx, target, 'repo/patch', { + contextId: target.repo.contextId, + }); + + const record = findExternalRecord(records, idStr); + if (record) { + return record; + } + + if (target.remote && idStr.startsWith('bafy')) { + return { + id : idStr, + contextId : `${target.repo.contextId}/${idStr}`, + tags : {}, + data : { + json: async () => ({ title: idStr, body: '' }), + }, + }; + } +} + +async function queryPatchRecords( + ctx: AgentContext, + target: PatchTarget, + protocolPath: string, + filter: Record, +): Promise { + const { records } = await ctx.patches.records.query(protocolPath as any, { + ...(target.from ? { from: target.from } : {}), + filter, + }); + if (records.length > 0) { + return records; + } + + return queryEndpointPatchRecords(ctx, target, protocolPath, filter); +} + +async function queryEndpointPatchRecords( + ctx: AgentContext, + target: PatchTarget, + protocolPath: string, + filter: Record, +): Promise { + const endpoints = configuredDwnEndpoints(ctx); + if (endpoints.length === 0) { + return []; + } + + const query = await RecordsQuery.create({ + signer : await messageSignerForContext(ctx), + filter : { + protocol: ForgePatchesDefinition.protocol, + protocolPath, + ...filter, + }, + }); + const client = new HttpDwnRpcClient(); + + for (const endpoint of endpoints) { + let errorMessage: string | undefined; + const reply = await client.sendDwnRequest({ + dwnUrl : endpoint, + targetDid : target.ownerDid, + message : query.message, + signal : AbortSignal.timeout(15_000), + timeoutMs : 15_000, + }).catch((err) => { + errorMessage = (err as Error).message; + return undefined; + }); + + if (process.env.GITD_DEBUG === '1') { + console.error(`[pr] endpoint query ${protocolPath} ${endpoint} status=${reply?.status.code ?? ''} entries=${reply?.entries?.length ?? 0}${errorMessage ? ` error=${errorMessage}` : ''}`); + } + + if (reply?.status.code === 200 && (reply.entries?.length ?? 0) > 0) { + return (reply.entries ?? []).map((entry) => endpointBackedRecord(ctx, target, entry)); + } + } + + return []; +} + +function endpointBackedRecord(ctx: AgentContext, target: PatchTarget, entry: any): any { + const descriptor = entry.descriptor ?? entry.initialWrite?.descriptor ?? {}; + const recordId = entry.recordId ?? entry.initialWrite?.recordId; + const contextId = entry.contextId ?? entry.initialWrite?.contextId; + + return { + __gitdEndpointBackedRecord : true, + id : recordId, + contextId, + tags : descriptor.tags ?? {}, + rawMessage : entry, + dataSize : descriptor.dataSize ?? 0, + data: { + json: async () => JSON.parse(new TextDecoder().decode(await endpointRecordBytes(ctx, target, entry))), + blob: async () => new Blob( + [await endpointRecordBytes(ctx, target, entry)], + { type: descriptor.dataFormat ?? 'application/octet-stream' }, + ), + }, + }; +} + +function isEndpointBackedRecord(record: any): boolean { + return record?.__gitdEndpointBackedRecord === true; +} + +async function endpointRecordBytes( + ctx: AgentContext, + target: PatchTarget, + entry: any, +): Promise { + if (typeof entry.encodedData === 'string') { + return decodeBase64Url(entry.encodedData); + } + + const recordId = entry.recordId ?? entry.initialWrite?.recordId; + if (!recordId) { + throw new Error('Endpoint record is missing recordId'); + } + + const endpoints = configuredDwnEndpoints(ctx); + const read = await RecordsRead.create({ + signer : await messageSignerForContext(ctx), + filter : { recordId }, + }); + const client = new HttpDwnRpcClient(); + + for (const endpoint of endpoints) { + let errorMessage: string | undefined; + const reply = await client.sendDwnRequest({ + dwnUrl : endpoint, + targetDid : target.ownerDid, + message : read.message, + signal : AbortSignal.timeout(15_000), + timeoutMs : 15_000, + }).catch((err) => { + errorMessage = (err as Error).message; + return undefined; + }); + + if (process.env.GITD_DEBUG === '1') { + console.error(`[pr] endpoint read ${recordId} ${endpoint} status=${reply?.status.code ?? ''}${errorMessage ? ` error=${errorMessage}` : ''}`); + } + + if (reply?.status.code === 200 && reply.entry?.data) { + return DataStream.toBytes(reply.entry.data); + } + + const encodedData = (reply?.entry as any)?.encodedData + ?? (reply?.entry?.recordsWrite as any)?.encodedData; + if (reply?.status.code === 200 && typeof encodedData === 'string') { + return decodeBase64Url(encodedData); + } + } + + throw new Error(`Endpoint record ${recordId} has no readable data`); +} + +async function updateEndpointBackedPatch( + ctx: AgentContext, + target: PatchTarget, + patch: any, + data: unknown, + tags: Record, + protocolRole: string | undefined, +): Promise { + const dataBytes = jsonBody(data); + const write = await RecordsWrite.create({ + recordId : patch.id, + dateCreated : patch.rawMessage.descriptor.dateCreated, + protocol : ForgePatchesDefinition.protocol, + protocolPath : 'repo/patch', + schema : ForgePatchesDefinition.types.patch.schema, + parentContextId : target.repo.contextId, + data : dataBytes, + dataFormat : 'application/json', + tags, + published : true, + ...(patch.rawMessage.descriptor.recipient ? { recipient: patch.rawMessage.descriptor.recipient } : {}), + ...(protocolRole ? { protocolRole } : {}), + signer : await messageSignerForContext(ctx), + }); + + await processMessageOnTargetEndpoints(ctx, target.ownerDid, write.message, 'PR update', bodyInit(dataBytes)); +} + +async function createEndpointBackedPatchChild( + ctx: AgentContext, + target: PatchTarget, + options: { + protocolPath: string; + schema: string; + data: unknown; + tags: Record; + parentContextId: string; + protocolRole?: string; + label: string; + }, +): Promise { + const dataBytes = jsonBody(options.data); + const write = await RecordsWrite.create({ + protocol : ForgePatchesDefinition.protocol, + protocolPath : options.protocolPath, + schema : options.schema, + parentContextId : options.parentContextId, + data : dataBytes, + dataFormat : 'application/json', + tags : options.tags, + published : true, + recipient : target.ownerDid, + ...(options.protocolRole ? { protocolRole: options.protocolRole } : {}), + signer : await messageSignerForContext(ctx), }); - return findByShortId(records, idStr); + await processMessageOnTargetEndpoints(ctx, target.ownerDid, write.message, options.label, bodyInit(dataBytes)); +} + +function decodeBase64Url(value: string): Uint8Array { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return new Uint8Array(Buffer.from(padded, 'base64')); +} + +type PatchTarget = { + ownerDid : string; + repo : RepoContext; + from? : string; + remote : boolean; +}; + +async function resolvePatchTarget(ctx: AgentContext, args: string[]): Promise { + const ownerDid = resolveRepoOwner(args) ?? ctx.did; + const repo = await getRepoContextForDid(ctx, ownerDid, resolveRepoName(args)); + const from = fromOpt(ctx, ownerDid); + return { ownerDid, repo, from, remote: ownerDid !== ctx.did }; +} + +async function patchCreateRole(ctx: AgentContext, target: PatchTarget): Promise { + return resolvePatchRole(ctx, target, ['contributor', 'maintainer'], 'contributor'); +} + +async function ensureRepoWriteAllowed(ctx: AgentContext, target: PatchTarget): Promise { + const block = await latestActiveBlock(ctx, target); + if (!block) { + return; + } + + const reason = block.data.reason ? ` Reason: ${block.data.reason}` : ''; + console.error(`You are blocked from writing to ${target.ownerDid}/${target.repo.name}.${reason}`); + process.exit(1); +} + +async function patchDiscussionRole(ctx: AgentContext, target: PatchTarget): Promise { + return resolvePatchRole(ctx, target, ['contributor', 'maintainer', 'moderator'], 'contributor'); +} + +async function patchMaintainerRole( + ctx: AgentContext, + target: PatchTarget, + fallback?: RepoRoleName, +): Promise { + return resolvePatchRole(ctx, target, ['maintainer'], fallback); +} + +async function resolvePatchRole( + ctx: AgentContext, + target: PatchTarget, + candidates: readonly RepoRoleName[], + fallback?: RepoRoleName, +): Promise { + if (target.remote && fallback) { + return `repo:repo/${fallback}`; + } + return resolveRepoProtocolRole(ctx, target.ownerDid, target.repo.contextId, candidates, fallback); } function findExternalRecord(records: any[], idStr: string): any | undefined { diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index e118761..da3fa28 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -3,24 +3,34 @@ * * Usage: * gitd repo info Show repository metadata + * gitd repo add-moderator Grant moderator role + * gitd repo remove-moderator Revoke moderator role + * gitd repo add-contributor Grant contributor role + * gitd repo remove-contributor Revoke contributor role * gitd repo add-collaborator Grant a role * gitd repo remove-collaborator Revoke a collaborator role * - * Roles: maintainer, triager, contributor, viewer + * Roles: maintainer, moderator, contributor, viewer. Legacy triager is still + * accepted by add-collaborator for compatibility. * * @module */ import type { AgentContext } from '../agent.js'; +import type { RepoContext } from '../repo-context.js'; -import { getRepoContextId } from '../repo-context.js'; +import { getRepoContext, getRepoContextId } from '../repo-context.js'; import { flagValue, resolveRepoName } from '../flags.js'; +import { getDwnEndpoints } from '../../git-server/did-service.js'; +import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; // --------------------------------------------------------------------------- // Valid roles // --------------------------------------------------------------------------- -const VALID_ROLES = ['maintainer', 'triager', 'contributor', 'viewer'] as const; +const PRIMARY_ROLES = ['maintainer', 'moderator', 'contributor', 'viewer'] as const; +const COMPATIBILITY_ROLES = ['triager'] as const; +const VALID_ROLES = [...PRIMARY_ROLES, ...COMPATIBILITY_ROLES] as const; type Role = typeof VALID_ROLES[number]; // --------------------------------------------------------------------------- @@ -34,10 +44,14 @@ export async function repoCommand(ctx: AgentContext, args: string[]): Promise'); + console.error('Usage: gitd repo '); process.exit(1); } } @@ -131,11 +145,10 @@ async function repoList(ctx: AgentContext): Promise { async function addCollaborator(ctx: AgentContext, args: string[]): Promise { const did = args[0]; const role = args[1] as Role | undefined; - const alias = flagValue(args, '--alias') ?? flagValue(args, '-a'); if (!did || !role) { console.error('Usage: gitd repo add-collaborator [--alias ]'); - console.error(` Roles: ${VALID_ROLES.join(', ')}`); + console.error(` Roles: ${PRIMARY_ROLES.join(', ')} (legacy: ${COMPATIBILITY_ROLES.join(', ')})`); process.exit(1); } @@ -144,42 +157,126 @@ async function addCollaborator(ctx: AgentContext, args: string[]): Promise process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + await addRole(ctx, args, role); +} + +async function addRole(ctx: AgentContext, args: string[], role: Role): Promise { + const did = args[0]; + const alias = flagValue(args, '--alias') ?? flagValue(args, '-a'); + + if (!did) { + console.error(`Usage: gitd repo add-${role} [--alias ]`); + process.exit(1); + } + + const repo = await getRepoContext(ctx, resolveRepoName(args)); const { status, record } = await ctx.repo.records.create(`repo/${role}` as any, { data : { did, alias: alias ?? '' }, tags : { did }, - parentContextId : repoContextId, + parentContextId : repo.contextId, recipient : did, + ...(repo.visibility === 'public' ? { published: true } : {}), }); if (status.code >= 300) { + if (status.detail?.includes('ProtocolAuthorizationDuplicateRoleRecipient')) { + const existing = await findRoleGrant(ctx, repo, role, did); + if (existing) { + await publishRoleGrant(ctx, repo, existing, role); + console.log(`Added ${role}: ${did}`); + console.log(` Record ID: ${existing.id}`); + return; + } + } console.error(`Failed to add collaborator: ${status.code} ${status.detail}`); process.exit(1); } if (!record) {throw new Error('Failed to create collaborator record');} + await publishRoleGrant(ctx, repo, record, role); + console.log(`Added ${role}: ${did}`); console.log(` Record ID: ${record.id}`); } +async function findRoleGrant( + ctx: AgentContext, + repo: RepoContext, + role: Role, + did: string, +): Promise { + const { records } = await ctx.repo.records.query(`repo/${role}` as any, { + filter: { + contextId : repo.contextId, + tags : { did }, + }, + }); + return records[0]; +} + +async function publishRoleGrant( + ctx: AgentContext, + repo: RepoContext, + roleRecord: any, + role: Role, +): Promise { + if (repo.visibility !== 'public') { + return; + } + + const dwnEndpoints = getDwnEndpoints(ctx.enbox); + if (dwnEndpoints.length === 0) { + return; + } + + const { records: repoRecords } = await ctx.repo.records.query('repo', { + filter: { tags: { name: repo.name } }, + }); + const repoRecord = repoRecords.find((record: any) => record.id === repo.recordId) ?? repoRecords[0]; + if (!repoRecord) { + console.warn(`Warning: could not publish ${role} grant: repo record not found locally.`); + return; + } + + const protocolResult = await ctx.repo.configure({ encryption: true }); + for (const endpoint of dwnEndpoints) { + try { + if (protocolResult.protocol) { + await applyMessageToDwnEndpoint(endpoint, ctx.did, protocolResult.protocol.toJSON(), 'repo protocol'); + } + await applyRecordToDwnEndpoint(endpoint, ctx.did, repoRecord, 'repo record'); + await applyRecordToDwnEndpoint(endpoint, ctx.did, roleRecord, `${role} grant`); + } catch (err) { + console.warn(`Warning: could not publish ${role} grant to ${endpoint}: ${(err as Error).message}`); + } + } +} + // --------------------------------------------------------------------------- // repo remove-collaborator // --------------------------------------------------------------------------- async function removeCollaborator(ctx: AgentContext, args: string[]): Promise { + return removeRole(ctx, args); +} + +async function removeRole(ctx: AgentContext, args: string[], onlyRole?: Role): Promise { const did = args[0]; if (!did) { - console.error('Usage: gitd repo remove-collaborator '); + const usage = onlyRole + ? `Usage: gitd repo remove-${onlyRole} ` + : 'Usage: gitd repo remove-collaborator '; + console.error(usage); process.exit(1); } const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); let found = false; - for (const role of VALID_ROLES) { + for (const role of onlyRole ? [onlyRole] : VALID_ROLES) { const { records: collabs } = await ctx.repo.records.query(`repo/${role}` as any, { filter: { contextId: repoContextId, tags: { did } }, }); diff --git a/src/cli/commands/serve-lifecycle.ts b/src/cli/commands/serve-lifecycle.ts index bf96666..fbb1ed8 100644 --- a/src/cli/commands/serve-lifecycle.ts +++ b/src/cli/commands/serve-lifecycle.ts @@ -17,6 +17,7 @@ import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../daemon/lifecycle.js'; +import { flagValue } from '../flags.js'; // --------------------------------------------------------------------------- // Command @@ -24,19 +25,20 @@ import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../dae export async function serveDaemonCommand(args: string[]): Promise { const sub = args[0]; + const profileName = flagValue(args, '--profile') ?? undefined; switch (sub) { case 'status': - return statusCmd(); + return statusCmd(profileName); case 'stop': - return stopCmd(); + return stopCmd(profileName); case 'restart': - return restartCmd(); + return restartCmd(profileName); case 'logs': - return logsCmd(); + return logsCmd(profileName); default: console.error(`Unknown serve subcommand: ${sub}`); @@ -49,8 +51,8 @@ export async function serveDaemonCommand(args: string[]): Promise { // Subcommands // --------------------------------------------------------------------------- -function statusCmd(): void { - const status = daemonStatus(); +function statusCmd(profileName?: string): void { + const status = daemonStatus({ profileName }); if (!status.running) { console.log('Daemon is not running.'); @@ -65,11 +67,11 @@ function statusCmd(): void { console.log(` Version: ${status.version}`); } console.log(` Started: ${status.startedAt}`); - console.log(` Log: ${daemonLogPath()}`); + console.log(` Log: ${daemonLogPath(profileName)}`); } -function stopCmd(): void { - const stopped = stopDaemon(); +function stopCmd(profileName?: string): void { + const stopped = stopDaemon({ profileName }); if (stopped) { console.log('Daemon stopped.'); } else { @@ -77,11 +79,11 @@ function stopCmd(): void { } } -async function restartCmd(): Promise { - stopDaemon(); +async function restartCmd(profileName?: string): Promise { + stopDaemon({ profileName }); console.log('Starting daemon...'); try { - const result = await ensureDaemon(); + const result = await ensureDaemon(undefined, { profileName }); console.log(`Daemon started on port ${result.port}.`); } catch (err) { console.error(`Failed to start daemon: ${(err as Error).message}`); @@ -89,8 +91,8 @@ async function restartCmd(): Promise { } } -function logsCmd(): void { - const logPath = daemonLogPath(); +function logsCmd(profileName?: string): void { + const logPath = daemonLogPath(profileName); if (!existsSync(logPath)) { console.log(`No log file found at ${logPath}`); diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 46da3ad..7072ad1 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -23,18 +23,26 @@ import type { DidDocument } from '@enbox/dids'; import type { EnboxPlatformAgent } from '@enbox/agent'; +import type { CliRpcRequest, CliRpcResponse } from '../local-rpc.js'; +import type { PushRefUpdate } from '../../git-server/push-updates.js'; import type { AgentContext } from '../agent.js'; +import { rmSync } from 'node:fs'; + import { createBundleSyncer } from '../../git-server/bundle-sync.js'; +import { dispatchAgentCommand } from '../dispatch.js'; +import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; import { createDidSignatureVerifier } from '../../git-server/verify.js'; import { createDwnPushAuthorizer } from '../../git-server/push-authorizer.js'; import { createGitServer } from '../../git-server/server.js'; import { createRefSyncer } from '../../git-server/ref-sync.js'; -import { getRepoContext } from '../repo-context.js'; +import { fromOpt, getRepoContext, getRepoContextForDid } from '../repo-context.js'; import { getVersion } from '../../version.js'; import { restoreFromBundles } from '../../git-server/bundle-restore.js'; +import { syncRemoteBranchPush } from '../../git-server/remote-branch-sync.js'; import { withRepoLock } from '../../git-server/repo-mutex.js'; +import { isContributorBranchRef } from '../../branch-state.js'; import { createPushAuthenticator, createPushTokenPayload, @@ -98,8 +106,21 @@ export async function checkPublicUrl(publicUrl: string): Promise { async function getLocalDidDocuments(ctx: AgentContext): Promise> { const documents = new Map(); + const agent = ctx.enbox.agent as EnboxPlatformAgent; + try { + const connectedDid = await resolveConnectedBearerDid(agent, ctx.did); + if (connectedDid) { + const portableDid = await connectedDid.export?.(); + const document = portableDid?.document ?? connectedDid.document; + if (document) { + documents.set(connectedDid.uri, document); + } + } + } catch { + // identity.list below and resolver lookup can still handle it. + } + try { - const agent = ctx.enbox.agent as EnboxPlatformAgent; const identities = await agent.identity.list(); for (const identity of identities) { documents.set(identity.did.uri, identity.did.document); @@ -110,6 +131,221 @@ async function getLocalDidDocuments(ctx: AgentContext): Promise { + if (agent.agentDid?.uri === did) { + return agent.agentDid; + } + + const storedDid = await agent.did.get({ didUri: did, tenant: agent.agentDid?.uri }); + if (storedDid) { + return storedDid; + } + + const identities = await agent.identity.list(); + return identities.find((identity) => identity.did.uri === did)?.did; +} + +async function syncLocalDwn(ctx: AgentContext, direction: 'push' | 'pull', label: string): Promise { + const agent = ctx.enbox.agent as unknown as { + sync?: { sync?: (direction: 'push' | 'pull') => Promise }; + }; + + try { + await agent.sync?.sync?.(direction); + } catch (err) { + console.error(`[dwn-sync] ${label} failed: ${(err as Error).message}`); + } +} + +function createEndpointRecordSender( + ctx: AgentContext, + label: string, + protocolMessage?: any, +): (record: any, targetDid: string) => Promise { + const dwnEndpoints = getDwnEndpoints(ctx.enbox); + const configuredTargets = new Set(); + + return async (record: any, targetDid: string): Promise => { + if (dwnEndpoints.length === 0) { + debugLog(`[dwn-send] ${label}: no endpoint configured, using record.send for ${record.id ?? ''}`); + const status = await record.send(targetDid); + if (status.code >= 300) { + throw new Error(`${label} failed: ${status.code} ${status.detail ?? ''}`.trim()); + } + return; + } + + for (const endpoint of dwnEndpoints) { + const configureKey = `${endpoint} ${targetDid}`; + if (protocolMessage && !configuredTargets.has(configureKey)) { + debugLog(`[dwn-send] ${label}: applying protocol to ${targetDid} via ${endpoint}`); + await applyMessageToDwnEndpoint(endpoint, targetDid, protocolMessage, `${label} protocol`); + configuredTargets.add(configureKey); + } + debugLog(`[dwn-send] ${label}: applying ${record.id ?? ''} to ${targetDid} via ${endpoint} descriptor=${JSON.stringify(record.rawMessage?.descriptor ?? {})}`); + await applyRecordToDwnEndpoint(endpoint, targetDid, record, label); + } + }; +} + +async function publishProtocolToLocalDwnEndpoints( + ctx: AgentContext, + protocolMessage: any, + label: string, +): Promise { + const dwnEndpoints = getDwnEndpoints(ctx.enbox); + for (const endpoint of dwnEndpoints) { + debugLog(`[dwn-send] ${label}: applying protocol to ${ctx.did} via ${endpoint}`); + await applyMessageToDwnEndpoint(endpoint, ctx.did, protocolMessage, `${label} protocol`); + } +} + +function debugLog(message: string): void { + if (process.env.GITD_DEBUG === '1') { + console.error(message); + } +} + +const FORWARDED_LONG_RUNNING_COMMANDS = new Set(['serve', 'web', 'daemon', 'indexer', 'github-api', 'shim']); + +class CliRpcExit extends Error { + public constructor(public readonly code: number) { + super(`CLI exited with status ${code}`); + } +} + +function createCliRpcHandler(ctx: AgentContext): (request: CliRpcRequest) => Promise { + let queue = Promise.resolve(); + + return async (request: CliRpcRequest): Promise => { + const previous = queue; + let release = (): void => {}; + queue = new Promise((resolveQueue) => { release = resolveQueue; }); + await previous; + try { + return await executeCliRpc(ctx, request); + } finally { + release(); + } + }; +} + +async function executeCliRpc(ctx: AgentContext, request: CliRpcRequest): Promise { + if (!request.command || FORWARDED_LONG_RUNNING_COMMANDS.has(request.command)) { + return { + status : 1, + stdout : '', + stderr : `Command cannot be forwarded to the local helper: ${request.command || ''}\n`, + }; + } + + const originalCwd = process.cwd(); + const originalExit = process.exit; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + const originalEnv = new Map(); + + let stdout = ''; + let stderr = ''; + let status = 0; + + const writeStdout = (chunk: unknown): void => { + stdout += Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk); + }; + const writeStderr = (chunk: unknown): void => { + stderr += Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk); + }; + + try { + for (const [key, value] of Object.entries(request.env ?? {})) { + originalEnv.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + if (request.cwd) { + process.chdir(request.cwd); + } + + (process.stdout.write as any) = (chunk: unknown, ..._args: unknown[]) => { + writeStdout(chunk); + return true; + }; + (process.stderr.write as any) = (chunk: unknown, ..._args: unknown[]) => { + writeStderr(chunk); + return true; + }; + console.log = (...values: unknown[]) => { + stdout += `${values.map(formatConsoleValue).join(' ')}\n`; + }; + console.error = (...values: unknown[]) => { + stderr += `${values.map(formatConsoleValue).join(' ')}\n`; + }; + console.warn = (...values: unknown[]) => { + stderr += `${values.map(formatConsoleValue).join(' ')}\n`; + }; + (process as any).exit = (code?: number): never => { + throw new CliRpcExit(typeof code === 'number' ? code : 0); + }; + + try { + if (request.command === 'whoami') { + console.log(ctx.did); + } else { + await dispatchAgentCommand(ctx, request.command, request.args ?? []); + } + } catch (err) { + if (err instanceof CliRpcExit) { + status = err.code; + } else { + status = 1; + stderr += `Fatal: ${(err as Error).message}\n`; + } + } + + if (status === 0) { + await syncLocalDwn(ctx, 'push', `cli ${request.command}`); + } + } finally { + (process as any).exit = originalExit; + (process.stdout.write as any) = originalStdoutWrite; + (process.stderr.write as any) = originalStderrWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + for (const [key, value] of originalEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + process.chdir(originalCwd); + } catch { + // Keep the helper alive even if the caller's cwd disappeared. + } + } + + return { status, stdout, stderr }; +} + +function formatConsoleValue(value: unknown): string { + if (typeof value === 'string') { return value; } + if (value instanceof Error) { return value.stack ?? value.message; } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -131,50 +367,146 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise'}`); const verifySignature = createDidSignatureVerifier({ - didDocuments: await getLocalDidDocuments(ctx), + didDocuments: localDidDocuments, }); - // DWN-based push authorization — checks role records. - const authorizePush = createDwnPushAuthorizer({ + // DWN-based push authorization — checks role records and branch rules. + const authorizeLocalPush = createDwnPushAuthorizer({ repo : ctx.repo, ownerDid : ctx.did, }); + const remoteAuthorizers = new Map>(); + const remoteRepoContexts = new Map>>(); + const authorizePush = async ( + actorDid: string, + ownerDid: string, + repoName: string, + updates?: readonly PushRefUpdate[], + ): Promise => { + debugLog(`[authz] checking ${actorDid} -> ${ownerDid}/${repoName}`); + if (ownerDid === ctx.did) { + const allowed = await authorizeLocalPush(actorDid, ownerDid, repoName, updates); + debugLog(`[authz] local result ${allowed}`); + return allowed; + } - const authenticatePush = createPushAuthenticator({ + if (actorDid === ctx.did && updates) { + const allowed = updates.every((update) => + update.refName.startsWith('refs/heads/') + && isContributorBranchRef(update.refName, actorDid), + ); + debugLog(`[authz] local helper contributor branch result ${allowed}`); + return allowed; + } + + let remoteAuthorizer = remoteAuthorizers.get(ownerDid); + if (!remoteAuthorizer) { + remoteAuthorizer = createDwnPushAuthorizer({ + repo : ctx.repo, + ownerDid, + from : fromOpt(ctx, ownerDid), + }); + remoteAuthorizers.set(ownerDid, remoteAuthorizer); + } + + const allowed = await remoteAuthorizer(actorDid, ownerDid, repoName, updates); + debugLog(`[authz] remote result ${allowed}`); + return allowed; + }; + + const authenticateLocalPush = createPushAuthenticator({ verifySignature, authorizePush, }); + const authenticatePush = async ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise => { + try { + debugLog(`[auth] authenticating push for ${did}/${repo}; auth=${request.headers.has('Authorization') ? 'present' : 'missing'}`); + const allowed = await authenticateLocalPush(request, did, repo, updates); + debugLog(`[auth] authenticated push for ${did}/${repo}: ${allowed}`); + return allowed; + } catch (err) { + console.error(`[auth] Push authentication failed for ${did}/${repo}: ${(err as Error).message}`); + return false; + } + }; + // Post-push callback — resolves repo context dynamically per-push, // then runs ref sync and bundle sync. Serialized per-repo via mutex // to prevent concurrent pushes from racing on DWN record updates. - const onPushComplete = async (_did: string, repoName: string, repoPath: string): Promise => { + const onPushComplete = async ( + _did: string, + repoName: string, + repoPath: string, + pushContext?: { updates?: readonly PushRefUpdate[] }, + ): Promise => { const lockKey = `${_did}/${repoName}`; await withRepoLock(lockKey, async () => { + debugLog(`[push-sync] start ${_did}/${repoName}`); let repoCtx; try { - repoCtx = await getRepoContext(ctx, repoName); + repoCtx = _did === ctx.did + ? await getRepoContext(ctx, repoName) + : remoteRepoContexts.get(lockKey) ?? await getRepoContextForDid(ctx, _did, repoName); + if (_did !== ctx.did) { + remoteRepoContexts.set(lockKey, repoCtx); + } } catch { console.error(`push-sync: repo "${repoName}" not found in DWN — skipping ref/bundle sync.`); return; } + debugLog(`[push-sync] context resolved ${_did}/${repoName}: ${repoCtx.contextId}`); + + if (_did !== ctx.did) { + try { + debugLog(`[push-sync] remote branch writeback start ${_did}/${repoName}`); + await syncRemoteBranchPush({ + refs : ctx.refs, + repoContextId : repoCtx.contextId, + targetDid : _did, + actorDid : ctx.did, + repoPath, + updates : pushContext?.updates ?? [], + sendRecord : createEndpointRecordSender(ctx, `remote branch writeback for ${_did}/${repoName}`), + }); + debugLog(`[push-sync] remote branch writeback complete ${_did}/${repoName}`); + } catch (err) { + console.error(`push-sync: failed to write contributor branch records for ${_did}/${repoName}: ${(err as Error).message}`); + } + return; + } const syncRefs = createRefSyncer({ refs : ctx.refs, repoContextId : repoCtx.contextId, + visibility : repoCtx.visibility, }); const syncBundle = createBundleSyncer({ repo : ctx.repo, + refs : ctx.refs, repoContextId : repoCtx.contextId, visibility : repoCtx.visibility, }); - await Promise.all([ - syncRefs(_did, repoName, repoPath), - syncBundle(_did, repoName, repoPath), - ]); + const refsProtocolResult = await ctx.refs.configure({ encryption: true }); + if (refsProtocolResult.protocol) { + await publishProtocolToLocalDwnEndpoints(ctx, refsProtocolResult.protocol.toJSON(), `refs protocol for ${_did}/${repoName}`); + } + await syncRefs(_did, repoName, repoPath); + debugLog(`[push-sync] refs synced ${_did}/${repoName}`); + await syncBundle(_did, repoName, repoPath); + debugLog(`[push-sync] bundle synced ${_did}/${repoName}`); + await syncLocalDwn(ctx, 'push', `post-push sync for ${_did}/${repoName}`); + debugLog(`[push-sync] dwn pushed ${_did}/${repoName}`); }); }; @@ -186,15 +518,18 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise { let repoCtx; try { - repoCtx = await getRepoContext(ctx, repoName); + repoCtx = await getRepoContextForDid(ctx, _did, repoName); + remoteRepoContexts.set(lockKey, repoCtx); } catch { - console.error(`restore: repo "${repoName}" not found in DWN — cannot restore.`); + console.error(`restore: repo "${repoName}" not found in DWN for ${_did} — cannot restore.`); return false; } - console.log(`Restoring repo "${repoName}" from DWN bundles → ${repoPath}`); + console.log(`Restoring repo "${repoName}" from DWN bundles for ${_did} → ${repoPath}`); const result = await restoreFromBundles({ repo : ctx.repo, + refs : ctx.refs, + from : fromOpt(ctx, _did), repoPath, repoContextId : repoCtx.contextId, }); @@ -207,6 +542,41 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise => { + if (_did === ctx.did) { + return true; + } + + const lockKey = `${_did}/${repoName}`; + return withRepoLock(lockKey, async () => { + let repoCtx = remoteRepoContexts.get(lockKey); + try { + await syncLocalDwn(ctx, 'pull', `pre-fetch sync for ${_did}/${repoName}`); + repoCtx = repoCtx ?? await getRepoContextForDid(ctx, _did, repoName); + remoteRepoContexts.set(lockKey, repoCtx); + } catch (err) { + console.error(`refresh: repo "${repoName}" not found in DWN for ${_did}: ${(err as Error).message}`); + return false; + } + + rmSync(repoPath, { recursive: true, force: true }); + const result = await restoreFromBundles({ + repo : ctx.repo, + refs : ctx.refs, + from : fromOpt(ctx, _did), + repoPath, + repoContextId : repoCtx.contextId, + }); + + if (result.success) { + debugLog(`[fetch-refresh] restored ${_did}/${repoName} bundles=${result.bundlesApplied} tip=${result.tipCommit}`); + } else { + console.error(`Bundle refresh failed: ${result.error}`); + } + return result.success; + }); + }; + // Idle auto-shutdown — when running as a background daemon, shut down // after 1 hour of no incoming HTTP requests to prevent orphaned processes. const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour @@ -229,18 +599,17 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise => { try { const agent = ctx.enbox.agent as EnboxPlatformAgent; - const identities = await agent.identity.list(); - const identity = identities[0]; - if (!identity) { return null; } + const bearerDid = await resolveConnectedBearerDid(agent, ctx.did); + if (!bearerDid) { return null; } - const payload = createPushTokenPayload(identity.did.uri, owner, repo); + const payload = createPushTokenPayload(bearerDid.uri, owner, repo); const token = encodePushToken(payload); - const signer = await identity.did.getSigner(); + const signer = await bearerDid.getSigner(); const tokenBytes = new TextEncoder().encode(token); const signature = await signer.sign({ data: tokenBytes }); const signatureBase64url = Buffer.from(signature).toString('base64url'); @@ -260,10 +629,13 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise => { - removeLockfile(); + removeLockfile(ctx.profileName); stopRepublisher(); await server.stop(); process.exit(0); @@ -346,7 +721,7 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise(() => { process.on('SIGINT', async () => { console.log('\nShutting down...'); - removeLockfile(); + removeLockfile(ctx.profileName); stopRepublisher(); await server.stop(); process.exit(0); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 0000000..3246527 --- /dev/null +++ b/src/cli/dispatch.ts @@ -0,0 +1,117 @@ +import type { AgentContext } from './agent.js'; + +import { ciCommand } from './commands/ci.js'; +import { daemonCommand } from './commands/daemon.js'; +import { githubApiCommand } from './commands/github-api.js'; +import { indexerCommand } from '../indexer/main.js'; +import { initCommand } from './commands/init.js'; +import { issueCommand } from './commands/issue.js'; +import { logCommand } from './commands/log.js'; +import { migrateCommand } from './commands/migrate.js'; +import { modCommand } from './commands/mod.js'; +import { notificationCommand } from './commands/notification.js'; +import { orgCommand } from './commands/org.js'; +import { prCommand } from './commands/pr.js'; +import { registryCommand } from './commands/registry.js'; +import { releaseCommand } from './commands/release.js'; +import { repoCommand } from './commands/repo.js'; +import { serveCommand } from './commands/serve.js'; +import { shimCommand } from './commands/shim.js'; +import { socialCommand } from './commands/social.js'; +import { webCommand } from './commands/web.js'; +import { wikiCommand } from './commands/wiki.js'; + +export async function dispatchAgentCommand( + ctx: AgentContext, + command: string, + rest: string[], +): Promise { + switch (command) { + case 'init': + await initCommand(ctx, rest); + break; + + case 'issue': + await issueCommand(ctx, rest); + break; + + case 'pr': + case 'patch': + await prCommand(ctx, rest); + break; + + case 'repo': + await repoCommand(ctx, rest); + break; + + case 'mod': + await modCommand(ctx, rest); + break; + + case 'serve': + await serveCommand(ctx, rest); + break; + + case 'release': + await releaseCommand(ctx, rest); + break; + + case 'registry': + await registryCommand(ctx, rest); + break; + + case 'ci': + await ciCommand(ctx, rest); + break; + + case 'wiki': + await wikiCommand(ctx, rest); + break; + + case 'org': + await orgCommand(ctx, rest); + break; + + case 'social': + await socialCommand(ctx, rest); + break; + + case 'notification': + case 'notifications': + await notificationCommand(ctx, rest); + break; + + case 'migrate': + await migrateCommand(ctx, rest); + break; + + case 'web': + await webCommand(ctx, rest); + break; + + case 'indexer': + await indexerCommand(ctx, rest); + break; + + case 'daemon': + await daemonCommand(ctx, rest); + break; + + case 'github-api': + await githubApiCommand(ctx, rest); + break; + + case 'shim': + await shimCommand(ctx, rest); + break; + + case 'log': + await logCommand(ctx, rest); + break; + + default: + console.error(`Unknown command: ${command}`); + console.error('Run `gitd help` for usage.'); + process.exit(1); + } +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 64dd034..21bb369 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -59,6 +59,37 @@ export function resolveRepoName(args: string[]): string | undefined { return undefined; } +/** + * Resolve the canonical repo owner DID from CLI flags, env, or git config. + * + * Priority: + * 1. `--owner ` flag + * 2. `GITD_REPO_OWNER` env var + * 3. `git config enbox.owner` in the current working directory + * 4. `undefined` (caller uses the local DID) + */ +export function resolveRepoOwner(args: string[]): string | undefined { + const flag = flagValue(args, '--owner'); + if (flag) { return flag; } + + const env = process.env.GITD_REPO_OWNER; + if (env) { return env; } + + try { + const result = spawnSync('git', ['config', 'enbox.owner'], { + encoding : 'utf-8', + timeout : 2000, + stdio : ['pipe', 'pipe', 'pipe'], + }); + const value = result.stdout?.trim(); + if (value && result.status === 0) { return value; } + } catch { + // Not in a git repo or git not available — fall through. + } + + return undefined; +} + /** * Parse a port number string, validating that it's a valid TCP port. * Exits the process with an error if the value is not a valid port. diff --git a/src/cli/local-rpc.ts b/src/cli/local-rpc.ts new file mode 100644 index 0000000..58d56ab --- /dev/null +++ b/src/cli/local-rpc.ts @@ -0,0 +1,89 @@ +import { readLockfile } from '../daemon/lockfile.js'; + +export type CliRpcRequest = { + command: string; + args: string[]; + cwd: string; + env: Record; +}; + +export type CliRpcResponse = { + status: number; + stdout: string; + stderr: string; +}; + +const AGENT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; + +export const CLI_RPC_PATH = '/cli'; + +export function cliRpcDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GITD_CLI_RPC?.toLowerCase(); + return value === 'off' || value === '0' || value === 'false'; +} + +export function forwardedEnv(env: NodeJS.ProcessEnv = process.env): Record { + return { + GITD_REPO : env.GITD_REPO, + GITD_REPO_OWNER : env.GITD_REPO_OWNER, + }; +} + +export async function forwardCliCommandIfAvailable( + profileName: string | undefined, + command: string, + args: string[], +): Promise { + if (cliRpcDisabled()) { return undefined; } + + const lock = readLockfile(profileName); + if (!lock) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[cli-rpc] no helper lockfile for profile=${profileName ?? process.env.GITD_PROFILE ?? process.env.ENBOX_PROFILE ?? ''}`); + } + return undefined; + } + + try { + if (process.env.GITD_DEBUG === '1') { + console.error(`[cli-rpc] forwarding ${command} to 127.0.0.1:${lock.port}`); + } + const response = await fetch(`http://127.0.0.1:${lock.port}${CLI_RPC_PATH}`, { + method : 'POST', + headers : { 'Content-Type': 'application/json' }, + body : JSON.stringify({ + command, + args, + cwd : process.cwd(), + env : forwardedEnv(), + } satisfies CliRpcRequest), + signal: AbortSignal.timeout(AGENT_COMMAND_TIMEOUT_MS), + }); + + if (!response.ok) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[cli-rpc] helper returned HTTP ${response.status}`); + } + return undefined; + } + + const body = await response.json() as Partial; + if (typeof body.status !== 'number') { + if (process.env.GITD_DEBUG === '1') { + console.error('[cli-rpc] helper response missing status'); + } + return undefined; + } + + return { + status : body.status, + stdout : typeof body.stdout === 'string' ? body.stdout : '', + stderr : typeof body.stderr === 'string' ? body.stderr : '', + }; + } catch (err) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[cli-rpc] forwarding failed: ${(err as Error).message}`); + } + return undefined; + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 77958c4..79038c1 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,16 @@ * gitd clone / Clone a repo via DID * gitd init [--description ] Create a repo record + bare git repo * gitd repo info Show repo metadata + * gitd repo add-moderator Grant moderator role + * gitd repo remove-moderator Revoke moderator role + * gitd mod add Grant moderator role + * gitd mod remove Revoke moderator role + * gitd mod list List moderators + * gitd mod block Block a DID from repo interaction + * gitd mod lock pr Lock a PR discussion + * gitd mod hide-comment Hide a comment in canonical views + * gitd repo add-contributor Grant contributor role + * gitd repo remove-contributor Revoke contributor role * gitd repo add-collaborator Grant a role * gitd repo remove-collaborator Revoke a collaborator role * gitd issue create File an issue @@ -72,30 +82,13 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { authCommand } from './commands/auth.js'; -import { ciCommand } from './commands/ci.js'; import { cloneCommand } from './commands/clone.js'; import { connectAgent } from './agent.js'; -import { daemonCommand } from './commands/daemon.js'; +import { dispatchAgentCommand } from './dispatch.js'; import { ensureDaemon } from '../daemon/lifecycle.js'; -import { githubApiCommand } from './commands/github-api.js'; -import { indexerCommand } from '../indexer/main.js'; -import { initCommand } from './commands/init.js'; -import { issueCommand } from './commands/issue.js'; -import { logCommand } from './commands/log.js'; -import { migrateCommand } from './commands/migrate.js'; -import { notificationCommand } from './commands/notification.js'; -import { orgCommand } from './commands/org.js'; -import { prCommand } from './commands/pr.js'; -import { registryCommand } from './commands/registry.js'; -import { releaseCommand } from './commands/release.js'; -import { repoCommand } from './commands/repo.js'; -import { serveCommand } from './commands/serve.js'; import { serveDaemonCommand } from './commands/serve-lifecycle.js'; import { setupCommand } from './commands/setup.js'; -import { shimCommand } from './commands/shim.js'; -import { socialCommand } from './commands/social.js'; -import { webCommand } from './commands/web.js'; -import { wikiCommand } from './commands/wiki.js'; +import { forwardCliCommandIfAvailable } from './local-rpc.js'; import { checkGit, requireGit, warnGit } from './preflight.js'; import { flagValue, hasFlag } from './flags.js'; import { profileDataPath, resolveProfile } from '../profiles/config.js'; @@ -130,8 +123,24 @@ function printUsage(): void { console.log(' serve logs Tail daemon log file'); console.log(''); console.log(' repo info Show repo metadata'); - console.log(' repo add-collaborator <did> <role> Grant a role (maintainer|triager|contributor|viewer)'); + console.log(' repo add-moderator <did> Grant moderator role'); + console.log(' repo remove-moderator <did> Revoke moderator role'); + console.log(' repo add-contributor <did> Grant contributor role'); + console.log(' repo remove-contributor <did> Revoke contributor role'); + console.log(' repo add-collaborator <did> <role> Grant a role (maintainer|moderator|contributor|viewer)'); console.log(' repo remove-collaborator <did> Revoke a collaborator role'); + console.log(' mod add <did> [--alias <name>] Grant moderator role'); + console.log(' mod remove <did> Revoke moderator role'); + console.log(' mod list List moderators'); + console.log(' mod block <did> [--reason <text>] Block repo interaction'); + console.log(' mod unblock <did> Remove a repo block'); + console.log(' mod lock <issue|pr> <id> Lock a discussion'); + console.log(' mod unlock <issue|pr> <id> Unlock a discussion'); + console.log(' mod hide-comment <id> [--kind issue|pr] Hide a comment'); + console.log(' mod delete-comment <id> [--kind issue|pr] Tombstone a comment'); + console.log(' mod report <record-id> Report repo content'); + console.log(' mod resolve-report <id> Resolve a report'); + console.log(' mod interaction-limit <mode> Set interaction limit'); console.log(''); console.log(' issue create <title> [--body <text>] File an issue'); console.log(' issue show <number> Show issue details and comments'); @@ -219,12 +228,18 @@ function printUsage(): void { console.log(' help Show this message\n'); console.log('Environment:'); console.log(' GITD_PASSWORD vault password (prompted if not set)'); + console.log(' GITD_PROFILE active identity profile (alias: ENBOX_PROFILE)'); console.log(' GITD_PORT server port for `serve` (default: 9418)'); console.log(' GITD_WEB_PORT web UI port for `web` (default: 8080)'); console.log(' GITD_REPOS base path for bare repos (default: ~/.enbox/profiles/<name>/repos/)'); console.log(' GITD_PUBLIC_URL public URL for `serve` (enables DID service registration)'); console.log(' GITD_SYNC DWN sync interval: off|5s|30s|1m (default: 30s for serve, off otherwise)'); - console.log(' GITD_DWN_ENDPOINT DWN endpoint URL for repo records'); + console.log(' GITD_DWN_REGISTRATION=off skip remote DWN registration'); + console.log(' GITD_DID_REPUBLISH=off skip DID DHT republishing in serve'); + console.log(' GITD_DWN_ENDPOINT DWN endpoint URL for repo records and agent sync'); + console.log(' GITD_DWN_ENDPOINTS comma-separated DWN endpoint URLs for agent sync'); + console.log(' GITD_DID_RESOLUTION_TIMEOUT_MS DID lookup timeout before local-helper fallback'); + console.log(' GITD_CLI_RPC=off disable one-shot command forwarding to a running local helper'); console.log(' GITD_INDEXER_PORT indexer API port (default: 8090)'); console.log(' GITD_INDEXER_INTERVAL crawl interval in seconds (default: 60)'); console.log(' GITD_GITHUB_API_PORT GitHub API shim port (default: 8181)'); @@ -364,8 +379,9 @@ async function main(): Promise<void> { // blocks the terminal. if (!hasFlag(rest, '--foreground') && process.env.GITD_DAEMON_BACKGROUND !== '1') { const pw = await getPassword(); + const profileName = resolveProfile(flagValue(rest, '--profile')) ?? undefined; try { - const result = await ensureDaemon(pw); + const result = await ensureDaemon(pw, { profileName }); if (result.spawned) { console.log(`gitd server started in the background on port ${result.port}.`); } else { @@ -381,11 +397,8 @@ async function main(): Promise<void> { break; // Fall through to agent-requiring path for foreground serve. } - // Commands that require the Enbox agent. - const password = await getPassword(); const profileFlag = flagValue(rest, '--profile'); const profileName = resolveProfile(profileFlag); - const dataPath = profileName ? profileDataPath(profileName) : undefined; // Resolve DWN sync interval. // Long-running commands default to '30s'; one-shot commands default to 'off'. @@ -396,6 +409,19 @@ async function main(): Promise<void> { const syncFlag = flagValue(rest, '--sync'); const sync = noSync ? 'off' : (syncFlag ?? syncEnv ?? syncDefault); + if (!longRunning) { + const forwarded = await forwardCliCommandIfAvailable(profileName ?? undefined, command, rest); + if (forwarded) { + if (forwarded.stdout) { process.stdout.write(forwarded.stdout); } + if (forwarded.stderr) { process.stderr.write(forwarded.stderr); } + process.exit(forwarded.status); + } + } + + // Commands that require the Enbox agent. + const password = await getPassword(); + const dataPath = profileName ? profileDataPath(profileName) : undefined; + const ctx = await connectAgent({ password, dataPath, sync: sync as any }); ctx.profileName = profileName ?? undefined; @@ -404,100 +430,17 @@ async function main(): Promise<void> { // commands either *are* the server or manage their own lifecycle. if (!longRunning) { try { - await ensureDaemon(password); + await ensureDaemon(password, { profileName: profileName ?? undefined }); } catch { // Non-fatal — warn but don't block the command. console.error('[daemon] Could not start background server. Run `gitd serve` manually for push/clone.'); } } - switch (command) { - case 'init': - await initCommand(ctx, rest); - break; - - case 'issue': - await issueCommand(ctx, rest); - break; - - case 'pr': - case 'patch': - await prCommand(ctx, rest); - break; - - case 'repo': - await repoCommand(ctx, rest); - break; - - case 'serve': - await serveCommand(ctx, rest); - break; - - case 'release': - await releaseCommand(ctx, rest); - break; - - case 'registry': - await registryCommand(ctx, rest); - break; - - case 'ci': - await ciCommand(ctx, rest); - break; - - case 'wiki': - await wikiCommand(ctx, rest); - break; - - case 'org': - await orgCommand(ctx, rest); - break; - - case 'social': - await socialCommand(ctx, rest); - break; - - case 'notification': - case 'notifications': - await notificationCommand(ctx, rest); - break; - - case 'migrate': - await migrateCommand(ctx, rest); - break; - - case 'web': - await webCommand(ctx, rest); - break; - - case 'indexer': - await indexerCommand(ctx, rest); - break; - - case 'daemon': - await daemonCommand(ctx, rest); - break; - - case 'github-api': - await githubApiCommand(ctx, rest); - break; - - case 'shim': - await shimCommand(ctx, rest); - break; - - case 'log': - await logCommand(ctx, rest); - break; - - case 'whoami': - console.log(ctx.did); - break; - - default: - console.error(`Unknown command: ${command}`); - printUsage(); - process.exit(1); + if (command === 'whoami') { + console.log(ctx.did); + } else { + await dispatchAgentCommand(ctx, command, rest); } // One-shot commands reach here after completing. The Enbox agent keeps diff --git a/src/cli/moderation-state.ts b/src/cli/moderation-state.ts new file mode 100644 index 0000000..38e3ffd --- /dev/null +++ b/src/cli/moderation-state.ts @@ -0,0 +1,133 @@ +import type { AgentContext } from './agent.js'; +import type { RepoContext } from './repo-context.js'; +import type { ModerationEventData } from '../repo.js'; + +import { shortId } from '../github-shim/helpers.js'; + +export type ModerationTarget = { + ownerDid : string; + repo : RepoContext; + from? : string; +}; + +type ModerationEventEntry = { + record : any; + data : Partial<ModerationEventData>; + tags : Record<string, string>; +}; + +/** Return the latest block event for `actorDid`, if it has not been unblocked. */ +export async function latestActiveBlock( + ctx: AgentContext, + target: ModerationTarget, + actorDid = ctx.did, +): Promise<ModerationEventEntry | undefined> { + if (actorDid === target.ownerDid) { + return undefined; + } + + const events = await listModerationEvents(ctx, target); + const latest = events + .filter((entry) => + eventTargetDid(entry) === actorDid + && (eventAction(entry) === 'block' || eventAction(entry) === 'unblock')) + .sort(compareModerationEventsDesc)[0]; + + return eventAction(latest) === 'block' ? latest : undefined; +} + +/** Return true when the latest lock/unlock event locks a discussion record. */ +export async function discussionIsLocked( + ctx: AgentContext, + target: ModerationTarget, + kind: 'issue' | 'pr', + record: any, +): Promise<boolean> { + const events = await listModerationEvents(ctx, target); + const latest = events + .filter((entry) => + eventTargetKind(entry) === kind + && (eventAction(entry) === 'lock' || eventAction(entry) === 'unlock') + && matchesTargetRecord(eventTargetId(entry), record)) + .sort(compareModerationEventsDesc)[0]; + + return eventAction(latest) === 'lock'; +} + +/** Remove comments whose latest moderation event hides or deletes them. */ +export async function visibleCommentRecords( + ctx: AgentContext, + target: ModerationTarget, + kind: 'issueComment' | 'prComment', + comments: readonly any[], +): Promise<any[]> { + const events = await listModerationEvents(ctx, target); + return comments.filter((comment) => { + const latest = events + .filter((entry) => + eventTargetKind(entry) === kind + && (eventAction(entry) === 'hideComment' + || eventAction(entry) === 'unhideComment' + || eventAction(entry) === 'deleteComment') + && matchesTargetRecord(eventTargetId(entry), comment)) + .sort(compareModerationEventsDesc)[0]; + + return eventAction(latest) !== 'hideComment' && eventAction(latest) !== 'deleteComment'; + }); +} + +async function listModerationEvents( + ctx: AgentContext, + target: ModerationTarget, +): Promise<ModerationEventEntry[]> { + const { records } = await ctx.repo.records.query('repo/moderationEvent' as any, { + ...(target.from ? { from: target.from } : {}), + filter: { contextId: target.repo.contextId }, + }); + + const entries: ModerationEventEntry[] = []; + for (const record of records) { + entries.push({ + record, + data: {}, + tags: (record.tags ?? {}) as Record<string, string>, + }); + } + return entries; +} + +function compareModerationEventsDesc(a: ModerationEventEntry, b: ModerationEventEntry): number { + const byTime = moderationTimestamp(b).localeCompare(moderationTimestamp(a)); + if (byTime !== 0) { + return byTime; + } + return String(b.record.id ?? '').localeCompare(String(a.record.id ?? '')); +} + +function moderationTimestamp(entry: ModerationEventEntry): string { + return entry.data.createdAt || entry.record.dateCreated || ''; +} + +function eventAction(entry: ModerationEventEntry | undefined): ModerationEventData['action'] | undefined { + return (entry?.tags.action ?? entry?.data.action) as ModerationEventData['action'] | undefined; +} + +function eventTargetDid(entry: ModerationEventEntry): string | undefined { + return entry.tags.targetDid ?? entry.data.targetDid; +} + +function eventTargetKind(entry: ModerationEventEntry): ModerationEventData['targetKind'] | undefined { + return (entry.tags.targetKind ?? entry.data.targetKind) as ModerationEventData['targetKind'] | undefined; +} + +function eventTargetId(entry: ModerationEventEntry): string | undefined { + return entry.tags.targetId ?? entry.data.targetId; +} + +function matchesTargetRecord(targetId: string | undefined, record: any): boolean { + if (!targetId) { + return false; + } + const recordId = String(record.id ?? ''); + return targetId === recordId || shortId(recordId).startsWith(targetId.toLowerCase()); +} diff --git a/src/cli/record-send.ts b/src/cli/record-send.ts new file mode 100644 index 0000000..7996ab4 --- /dev/null +++ b/src/cli/record-send.ts @@ -0,0 +1,189 @@ +import type { AgentContext } from './agent.js'; + +import { HttpDwnRpcClient } from '@enbox/dwn-clients'; +import { DataStream, type MessageSigner } from '@enbox/dwn-sdk-js'; +import { getDwnEndpoints } from '../git-server/did-service.js'; + +/** Send a locally composed `store:false` record to another DID. */ +export async function sendRecordToTarget( + ctx: AgentContext, + record: any, + targetDid: string, + label = 'record', +): Promise<void> { + if (ctx.sendRecord) { + await ctx.sendRecord(record, targetDid); + return; + } + + const dwnEndpoints = configuredDwnEndpoints(ctx); + if (dwnEndpoints.length > 0) { + for (const endpoint of dwnEndpoints) { + await applyRecordToDwnEndpoint(endpoint, targetDid, record, label); + } + return; + } + + const status = await record.send(targetDid); + if (status.code >= 300) { + throw new Error(`Failed to send ${label}: ${status.code} ${status.detail ?? ''}`.trim()); + } +} + +/** Apply a raw DWN message to every configured local/passive endpoint. */ +export async function applyMessageToTargetEndpoints( + ctx: AgentContext, + targetDid: string, + message: any, + label = 'message', + data?: BodyInit, +): Promise<void> { + const dwnEndpoints = configuredDwnEndpoints(ctx); + if (dwnEndpoints.length === 0) { + throw new Error(`No DWN endpoint configured for ${label}`); + } + + for (const endpoint of dwnEndpoints) { + await applyMessageToDwnEndpoint(endpoint, targetDid, message, label, data); + } +} + +/** Process an incoming DWN message on every configured local/passive endpoint. */ +export async function processMessageOnTargetEndpoints( + ctx: AgentContext, + targetDid: string, + message: any, + label = 'message', + data?: BodyInit, +): Promise<void> { + const dwnEndpoints = configuredDwnEndpoints(ctx); + if (dwnEndpoints.length === 0) { + throw new Error(`No DWN endpoint configured for ${label}`); + } + + if (process.env.GITD_DEBUG === '1') { + console.error(`[dwn-process] ${label}: endpoints=${dwnEndpoints.join(',')}`); + } + + for (const endpoint of dwnEndpoints) { + await processMessageOnDwnEndpoint(endpoint, targetDid, message, label, data); + } +} + +/** Apply a raw DWN message directly to a known DWN endpoint. */ +export async function applyMessageToDwnEndpoint( + dwnUrl: string, + targetDid: string, + message: any, + label = 'message', + data?: BodyInit, +): Promise<void> { + const result = await new HttpDwnRpcClient().applyReplicatedMessage({ + dwnUrl, + targetDid, + message, + ...(data ? { data } : {}), + signal : AbortSignal.timeout(15_000), + timeoutMs : 15_000, + }); + + if (process.env.GITD_DEBUG === '1') { + console.error(`[dwn-apply] ${label}: ${result.kind}`); + } + + if (result.kind === 'Applied' || result.kind === 'Duplicate' || result.kind === 'Superseded') { + return; + } + + if (result.kind === 'Incomplete') { + const missing = result.missing.map((entry) => entry.type).join(', '); + throw new Error(`Failed to apply ${label}: missing dependencies (${missing})`); + } + + throw new Error(`Failed to apply ${label}: ${result.kind}${'reason' in result ? ` ${result.reason}` : ''}`); +} + +/** Send a raw DWN message through the normal processMessage entry point. */ +export async function processMessageOnDwnEndpoint( + dwnUrl: string, + targetDid: string, + message: any, + label = 'message', + data?: BodyInit, +): Promise<void> { + const reply = await new HttpDwnRpcClient().sendDwnRequest({ + dwnUrl, + targetDid, + message, + ...(data ? { data } : {}), + signal : AbortSignal.timeout(15_000), + timeoutMs : 15_000, + }); + + if (process.env.GITD_DEBUG === '1') { + console.error(`[dwn-process] ${label}: ${reply.status.code} ${reply.status.detail ?? ''}`.trim()); + } + + if (reply.status.code >= 300) { + throw new Error(`Failed to process ${label}: ${reply.status.code} ${reply.status.detail ?? ''}`.trim()); + } +} + +/** Apply a record directly to a known DWN endpoint without DID endpoint resolution. */ +export async function applyRecordToDwnEndpoint( + dwnUrl: string, + targetDid: string, + record: any, + label = 'record', +): Promise<void> { + const rawMessage = record.rawMessage; + const isWrite = rawMessage?.descriptor?.interface === 'Records' + && rawMessage?.descriptor?.method === 'Write'; + const data = isWrite && (record.dataSize ?? 0) > 0 + ? await record.data.blob() + : undefined; + + await applyMessageToDwnEndpoint(dwnUrl, targetDid, rawMessage, label, data); +} + +/** Build a DWN SDK signer backed by the active Enbox wallet identity. */ +export async function messageSignerForContext(ctx: AgentContext): Promise<MessageSigner> { + const agent = (ctx.enbox as any).agent; + const agentDid = agent?.agentDid; + const bearerDid = ctx.did === agentDid?.uri + ? agentDid + : await agent?.did?.get?.({ didUri: ctx.did, tenant: agentDid?.uri }); + + if (!bearerDid) { + throw new Error(`Unable to load signer for ${ctx.did}`); + } + + const signer = await bearerDid.getSigner(); + + return { + keyId : signer.keyId, + algorithm : signer.algorithm, + sign: (content: Uint8Array) => signer.sign({ data: content }), + }; +} + +export function jsonBody(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +export function bodyInit(bytes: Uint8Array): BodyInit { + return DataStream.fromBytes(bytes) as BodyInit; +} + +export function configuredDwnEndpoints(ctx: AgentContext): string[] { + const fromDid = ctx.enbox ? getDwnEndpoints(ctx.enbox) : []; + const fromEnv = [ + ...(process.env.GITD_DWN_ENDPOINTS ?? '') + .split(',') + .map((endpoint) => endpoint.trim()) + .filter(Boolean), + ...(process.env.GITD_DWN_ENDPOINT ? [process.env.GITD_DWN_ENDPOINT] : []), + ]; + + return Array.from(new Set([...fromDid, ...fromEnv])); +} diff --git a/src/cli/repo-context.ts b/src/cli/repo-context.ts index bf2f1d0..1c077fa 100644 --- a/src/cli/repo-context.ts +++ b/src/cli/repo-context.ts @@ -15,6 +15,12 @@ import type { AgentContext } from './agent.js'; +import { HttpDwnRpcClient } from '@enbox/dwn-clients'; +import { RecordsQuery } from '@enbox/dwn-sdk-js'; + +import { ForgeRepoDefinition } from '../repo.js'; +import { getDwnEndpoints } from '../git-server/did-service.js'; + /** Repo context returned by {@link getRepoContext}. */ export type RepoContext = { /** The repo record ID (stable identifier used by indexers and cross-DWN submissions). */ @@ -27,6 +33,20 @@ export type RepoContext = { name: string; }; +/** Repo role names that can be used as cross-protocol role grants. */ +export type RepoRoleName = 'maintainer' | 'moderator' | 'triager' | 'contributor' | 'viewer'; + +const repoContextCache = new Map<string, RepoContext>(); + +export function rememberRepoContext(targetDid: string, repo: RepoContext): void { + repoContextCache.set(repoContextCacheKey(targetDid, repo.name), repo); +} + +/** Build a DWN `from` option for local-vs-remote repo queries. */ +export function fromOpt(ctx: AgentContext, targetDid: string): string | undefined { + return targetDid === ctx.did ? undefined : targetDid; +} + /** * Query the local DWN for a repo record and return its context. * @@ -38,24 +58,51 @@ export async function getRepoContext( ctx: AgentContext, repoName?: string, ): Promise<RepoContext> { + return getRepoContextForDid(ctx, ctx.did, repoName); +} + +/** + * Query a local or remote DWN for a repo record and return its context. + * + * @param ctx - Agent context with typed protocol handles. + * @param targetDid - DID whose DWN should be queried. + * @param repoName - Repo name to look up. When `undefined`, falls back + * to the only repo if exactly one exists, or exits with an error. + */ +export async function getRepoContextForDid( + ctx: AgentContext, + targetDid: string, + repoName?: string, +): Promise<RepoContext> { + const from = fromOpt(ctx, targetDid); + if (repoName) { + const cached = repoContextCache.get(repoContextCacheKey(targetDid, repoName)); + if (cached) { + return cached; + } + // Look up by name tag. - const { records } = await ctx.repo.records.query('repo', { - filter: { tags: { name: repoName } }, - }); + const records = await queryRepoRecordsByName(ctx, targetDid, repoName, from); if (records.length === 0) { - throw new Error(`Repository "${repoName}" not found. Run \`gitd init ${repoName}\` first.`); + const owner = targetDid === ctx.did ? 'local DWN' : targetDid; + throw new Error(`Repository "${repoName}" not found in ${owner}.`); } - return extractContext(records[0], repoName); + const repo = extractContext(records[0], repoName); + rememberRepoContext(targetDid, repo); + return repo; } // No name provided — fall back to single-repo or error. - const { records } = await ctx.repo.records.query('repo'); + const { records } = await ctx.repo.records.query('repo', { + ...(from ? { from } : {}), + }); if (records.length === 0) { - throw new Error('No repository found. Run `gitd init <name>` first.'); + const owner = targetDid === ctx.did ? 'local DWN' : targetDid; + throw new Error(`No repository found in ${owner}.`); } if (records.length > 1) { @@ -66,6 +113,77 @@ export async function getRepoContext( return extractContext(records[0], data.name ?? 'unnamed'); } +async function queryRepoRecordsByName( + ctx: AgentContext, + targetDid: string, + repoName: string, + from?: string, +): Promise<any[]> { + try { + const { records } = await ctx.repo.records.query('repo', { + ...(from ? { from } : {}), + filter: { tags: { name: repoName } }, + }); + if (records.length > 0 || !from) { + return records; + } + } catch { + if (!from) { + throw new Error(`Repository "${repoName}" not found in local DWN.`); + } + } + + return queryRepoRecordsByNameViaEndpoints(ctx, targetDid, repoName); +} + +async function queryRepoRecordsByNameViaEndpoints( + ctx: AgentContext, + targetDid: string, + repoName: string, +): Promise<any[]> { + const endpoints = [...new Set([...getDwnEndpoints(ctx.enbox), ...envDwnEndpoints()])]; + if (process.env.GITD_DEBUG === '1') { + console.error(`[repo-context] direct lookup ${targetDid}/${repoName} endpoints=${endpoints.join(',') || '<none>'}`); + } + if (endpoints.length === 0) { + return []; + } + + const query = await RecordsQuery.create({ + filter: { + protocol : ForgeRepoDefinition.protocol, + protocolPath : 'repo', + tags : { name: repoName }, + }, + }); + const client = new HttpDwnRpcClient(); + for (const endpoint of endpoints) { + let errorMessage: string | undefined; + const reply = await client.sendDwnRequest({ + dwnUrl : endpoint, + targetDid, + message : query.message, + }).catch((err) => { + errorMessage = (err as Error).message; + return undefined; + }); + if (process.env.GITD_DEBUG === '1') { + console.error(`[repo-context] ${endpoint} status=${reply?.status.code ?? '<error>'} entries=${reply?.entries?.length ?? 0}${errorMessage ? ` error=${errorMessage}` : ''}`); + } + if (reply?.status.code === 200 && (reply.entries?.length ?? 0) > 0) { + return reply.entries as any[]; + } + } + + return []; +} + +function envDwnEndpoints(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.GITD_DWN_ENDPOINTS ?? env.GITD_DWN_ENDPOINT; + if (!raw) { return []; } + return raw.split(',').map((value) => value.trim()).filter(Boolean); +} + /** * Convenience wrapper — returns only the contextId. */ @@ -77,6 +195,43 @@ export async function getRepoContextId( return contextId; } +/** Resolve the caller's repo role on a remote repo. */ +export async function resolveRepoRoleName( + ctx: AgentContext, + targetDid: string, + repoContextId: string, + candidates: readonly RepoRoleName[], + fallback?: RepoRoleName, +): Promise<RepoRoleName | undefined> { + if (targetDid === ctx.did) { + return undefined; + } + + for (const role of candidates) { + const { records } = await ctx.repo.records.query(`repo/${role}` as any, { + from : targetDid, + filter : { contextId: repoContextId, tags: { did: ctx.did } }, + }); + if (records.length > 0) { + return role; + } + } + + return fallback; +} + +/** Resolve the cross-protocol protocolRole string for a caller's role on a remote repo. */ +export async function resolveRepoProtocolRole( + ctx: AgentContext, + targetDid: string, + repoContextId: string, + candidates: readonly RepoRoleName[], + fallback?: RepoRoleName, +): Promise<string | undefined> { + const role = await resolveRepoRoleName(ctx, targetDid, repoContextId, candidates, fallback); + return role ? `repo:repo/${role}` : undefined; +} + // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- @@ -88,5 +243,9 @@ function extractContext(record: any, name: string): RepoContext { } const visibility = (record.tags?.visibility as 'public' | 'private') ?? 'public'; - return { recordId: record.id, contextId, visibility, name }; + return { recordId: record.id ?? record.recordId, contextId, visibility, name }; +} + +function repoContextCacheKey(targetDid: string, repoName: string): string { + return `${targetDid}\0${repoName}`; } diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index fc5d5a8..1682011 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -16,7 +16,7 @@ import { spawn } from 'node:child_process'; import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { enboxHome } from '../profiles/config.js'; +import { enboxHome, profilesDir } from '../profiles/config.js'; import { getVersion } from '../version.js'; import { readLockfile, removeLockfile } from './lockfile.js'; @@ -43,7 +43,11 @@ const HEALTH_PROBE_TIMEOUT_MS = 2_000; // --------------------------------------------------------------------------- /** Path to the daemon log file. */ -export function daemonLogPath(): string { +export function daemonLogPath(profileName?: string): string { + const profile = profileName || process.env.GITD_PROFILE || process.env.ENBOX_PROFILE; + if (profile) { + return join(profilesDir(), profile, 'gitd', 'daemon.log'); + } return join(enboxHome(), 'gitd', 'daemon.log'); } @@ -83,6 +87,11 @@ export type EnsureDaemonResult = { spawned: boolean; }; +export type DaemonLifecycleOptions = { + /** Named profile whose helper should be discovered or spawned. */ + profileName?: string; +}; + /** * Ensure a local gitd daemon is running and healthy. * @@ -97,8 +106,11 @@ export type EnsureDaemonResult = { * @returns The port of the running daemon. * @throws If the daemon cannot be started within the timeout. */ -export async function ensureDaemon(password?: string): Promise<EnsureDaemonResult> { - const lock = readLockfile(); +export async function ensureDaemon( + password?: string, + options: DaemonLifecycleOptions = {}, +): Promise<EnsureDaemonResult> { + const lock = readLockfile(options.profileName); if (lock) { // Check for version mismatch (user upgraded gitd). @@ -107,7 +119,7 @@ export async function ensureDaemon(password?: string): Promise<EnsureDaemonResul console.error( `[daemon] Version mismatch: running ${lock.version}, current ${currentVersion}. Restarting...`, ); - stopDaemonByLock(lock); + stopDaemonByLock(lock, options.profileName); } else { // Version matches (or unknown) — check health. const healthy = await probeDaemonHealth(lock.port); @@ -116,12 +128,12 @@ export async function ensureDaemon(password?: string): Promise<EnsureDaemonResul } // PID is alive (readLockfile validated it) but not responding — stale. console.error('[daemon] Daemon is not responding. Restarting...'); - stopDaemonByLock(lock); + stopDaemonByLock(lock, options.profileName); } } // Spawn a new daemon in the background. - return spawnDaemon(password); + return spawnDaemon(password, options); } // --------------------------------------------------------------------------- @@ -136,8 +148,11 @@ export async function ensureDaemon(password?: string): Promise<EnsureDaemonResul * Polls the health endpoint with exponential backoff until the daemon * is ready or the timeout is exceeded. */ -async function spawnDaemon(password?: string): Promise<EnsureDaemonResult> { - const logPath = daemonLogPath(); +async function spawnDaemon( + password?: string, + options: DaemonLifecycleOptions = {}, +): Promise<EnsureDaemonResult> { + const logPath = daemonLogPath(options.profileName); mkdirSync(dirname(logPath), { recursive: true }); // Open a raw file descriptor for the log file. Bun's `spawn()` does not @@ -160,6 +175,9 @@ async function spawnDaemon(password?: string): Promise<EnsureDaemonResult> { if (!env.GITD_PASSWORD && password) { env.GITD_PASSWORD = password; } + if (options.profileName) { + env.GITD_PROFILE = options.profileName; + } const child = spawn(gitdBin.command, [...gitdBin.prefix, 'serve'], { detached : true, @@ -186,7 +204,7 @@ async function spawnDaemon(password?: string): Promise<EnsureDaemonResult> { // Poll the health endpoint until the daemon is ready, but fail fast // if the spawn itself errored (e.g. binary not found). - const port = await Promise.race([waitForDaemon(), spawnError]); + const port = await Promise.race([waitForDaemon(options), spawnError]); return { port, spawned: true }; } @@ -196,7 +214,7 @@ async function spawnDaemon(password?: string): Promise<EnsureDaemonResult> { * @returns The port the daemon is listening on. * @throws If the daemon does not become healthy within the timeout. */ -async function waitForDaemon(): Promise<number> { +async function waitForDaemon(options: DaemonLifecycleOptions = {}): Promise<number> { const deadline = Date.now() + SPAWN_TIMEOUT_MS; let delay = INITIAL_BACKOFF_MS; @@ -204,7 +222,7 @@ async function waitForDaemon(): Promise<number> { await sleep(delay); delay = Math.min(delay * 2, MAX_BACKOFF_MS); - const lock = readLockfile(); + const lock = readLockfile(options.profileName); if (!lock) { continue; } const healthy = await probeDaemonHealth(lock.port); @@ -213,7 +231,7 @@ async function waitForDaemon(): Promise<number> { throw new Error( 'Timed out waiting for the gitd daemon to start. ' - + `Check the log at ${daemonLogPath()} for details, or run \`gitd serve\` manually to debug.`, + + `Check the log at ${daemonLogPath(options.profileName)} for details, or run \`gitd serve\` manually to debug.`, ); } @@ -224,13 +242,13 @@ async function waitForDaemon(): Promise<number> { /** * Stop a running daemon by PID from the lockfile. */ -function stopDaemonByLock(lock: DaemonLock): void { +function stopDaemonByLock(lock: DaemonLock, profileName?: string): void { try { process.kill(lock.pid, 'SIGTERM'); } catch { // Process already dead — fine. } - removeLockfile(); + removeLockfile(profileName); } /** @@ -238,10 +256,10 @@ function stopDaemonByLock(lock: DaemonLock): void { * * @returns `true` if a daemon was stopped, `false` if none was running. */ -export function stopDaemon(): boolean { - const lock = readLockfile(); +export function stopDaemon(options: DaemonLifecycleOptions = {}): boolean { + const lock = readLockfile(options.profileName); if (!lock) { return false; } - stopDaemonByLock(lock); + stopDaemonByLock(lock, options.profileName); return true; } @@ -262,8 +280,8 @@ export type DaemonStatus = { /** * Get the status of the running daemon. */ -export function daemonStatus(): DaemonStatus { - const lock = readLockfile(); +export function daemonStatus(options: DaemonLifecycleOptions = {}): DaemonStatus { + const lock = readLockfile(options.profileName); if (!lock) { return { running: false }; } diff --git a/src/daemon/lockfile.ts b/src/daemon/lockfile.ts index f8a1cbe..dc9ba9f 100644 --- a/src/daemon/lockfile.ts +++ b/src/daemon/lockfile.ts @@ -4,7 +4,7 @@ * When `gitd serve` starts, it writes a JSON lockfile to * `~/.enbox/daemon.lock` containing `{ pid, port, startedAt, ownerDid }`. * `git-remote-did` reads this file to discover a running local daemon - * and resolve `did::` remotes to `http://localhost:<port>/...` instead + * and resolve `did::` remotes to `http://127.0.0.1:<port>/...` instead * of performing DID document resolution. * * The lockfile is removed on graceful shutdown and validated (PID check) @@ -16,7 +16,7 @@ import { dirname, join } from 'node:path'; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { enboxHome } from '../profiles/config.js'; +import { enboxHome, profilesDir } from '../profiles/config.js'; // --------------------------------------------------------------------------- // Types @@ -38,14 +38,29 @@ export type DaemonLock = { /** The DID of the identity that owns this daemon. */ ownerDid?: string; + + /** + * True when this daemon can serve as a local DWN-backed helper for repos + * owned by DIDs other than `ownerDid`. + */ + dwnHelper?: boolean; }; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- +/** Resolve the profile whose daemon lockfile should be used, if any. */ +function lockfileProfile(profileName?: string): string | undefined { + return profileName || process.env.GITD_PROFILE || process.env.ENBOX_PROFILE || undefined; +} + /** Path to the daemon lockfile. */ -export function lockfilePath(): string { +export function lockfilePath(profileName?: string): string { + const profile = lockfileProfile(profileName); + if (profile) { + return join(profilesDir(), profile, 'daemon.lock'); + } return join(enboxHome(), 'daemon.lock'); } @@ -53,23 +68,37 @@ export function lockfilePath(): string { // Write / remove // --------------------------------------------------------------------------- +/** Additional advertised daemon capabilities. */ +export type WriteLockfileOptions = { + /** Advertise that this daemon can restore/fetch remote-owner repos from DWN records. */ + dwnHelper?: boolean; + /** Named profile this daemon serves. Defaults to the active GITD/ENBOX profile env. */ + profileName?: string; +}; + /** Write the daemon lockfile. Overwrites any existing file. */ -export function writeLockfile(port: number, version?: string, ownerDid?: string): void { +export function writeLockfile( + port: number, + version?: string, + ownerDid?: string, + options: WriteLockfileOptions = {}, +): void { const lock: DaemonLock = { pid : process.pid, port, startedAt : new Date().toISOString(), ...(version ? { version } : {}), ...(ownerDid ? { ownerDid } : {}), + ...(options.dwnHelper ? { dwnHelper: true } : {}), }; - const path = lockfilePath(); + const path = lockfilePath(options.profileName); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(lock, null, 2) + '\n', { mode: 0o644 }); } /** Remove the daemon lockfile if it exists and belongs to this process. */ -export function removeLockfile(): void { - const path = lockfilePath(); +export function removeLockfile(profileName?: string): void { + const path = lockfilePath(profileName); if (!existsSync(path)) { return; } try { @@ -96,8 +125,8 @@ export function removeLockfile(): void { * Returns `null` if the lockfile doesn't exist, is corrupt, or the * recorded PID is no longer running (stale lockfile). */ -export function readLockfile(): DaemonLock | null { - const path = lockfilePath(); +export function readLockfile(profileName?: string): DaemonLock | null { + const path = lockfilePath(profileName); if (!existsSync(path)) { return null; } try { diff --git a/src/git-remote/credential-main.ts b/src/git-remote/credential-main.ts index b2888cf..bd23050 100644 --- a/src/git-remote/credential-main.ts +++ b/src/git-remote/credential-main.ts @@ -207,7 +207,7 @@ async function requestTokenFromDaemon( owner: string, repo: string, ): Promise<{ username: string; password: string } | null> { - const lock = readLockfile(); + const lock = readLockfile(resolveProfile() ?? undefined); if (!lock) { return null; } try { diff --git a/src/git-remote/index.ts b/src/git-remote/index.ts index a899914..83b1529 100644 --- a/src/git-remote/index.ts +++ b/src/git-remote/index.ts @@ -7,5 +7,6 @@ export * from './credential-cache.js'; export * from './credential-helper.js'; export * from './parse-url.js'; -export * from './resolve.js'; +export { assertNotPrivateUrl, resolveGitEndpoint } from './resolve.js'; +export type { GitEndpoint } from './resolve.js'; export * from './service.js'; diff --git a/src/git-remote/main.ts b/src/git-remote/main.ts index 46d62cc..0484322 100644 --- a/src/git-remote/main.ts +++ b/src/git-remote/main.ts @@ -21,16 +21,58 @@ */ import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { parseDidUrl } from './parse-url.js'; import { resolveGitEndpoint } from './resolve.js'; +import { decodePushToken, parseAuthPassword } from '../git-server/auth.js'; /** Resolve the absolute path to the credential helper binary (sibling file). */ function resolveCredentialHelper(): string { const thisFile = fileURLToPath(import.meta.url); - return resolve(thisFile, '..', 'credential-main.js'); + const dir = dirname(thisFile); + const jsPath = resolve(dir, 'credential-main.js'); + if (existsSync(jsPath)) { + return jsPath; + } + return resolve(dir, 'credential-main.ts'); +} + +async function localEndpointAuthHeader( + endpointUrl: string, + owner: string, + repo: string | undefined, +): Promise<string | undefined> { + if (!repo) { return undefined; } + + try { + const url = new URL(endpointUrl); + const response = await fetch(`${url.origin}/auth/token`, { + method : 'POST', + headers : { 'Content-Type': 'application/json' }, + body : JSON.stringify({ owner, repo }), + signal : AbortSignal.timeout(5_000), + }); + if (!response.ok) { return undefined; } + + const creds = await response.json() as { username?: string; password?: string }; + if (!creds.username || !creds.password) { return undefined; } + + if (process.env.GITD_DEBUG === '1') { + try { + const payload = decodePushToken(parseAuthPassword(creds.password).token); + console.error(`[git-remote-did] local auth token did=${payload.did} owner=${payload.owner} repo=${payload.repo}`); + } catch { + console.error('[git-remote-did] local auth token could not be decoded'); + } + } + + return `Authorization: Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString('base64')}`; + } catch { + return undefined; + } } // --------------------------------------------------------------------------- @@ -79,11 +121,19 @@ async function main(): Promise<void> { // Use git's `!<command>` syntax to invoke it via bun, which avoids // needing the file to be +x or on PATH. const credHelper = resolveCredentialHelper(); - const child = spawn('git', [ + const gitArgs = [ '-c', `credential.helper=!bun '${credHelper}'`, '-c', 'credential.useHttpPath=true', - helper, remoteName, endpoint.url, - ], { + ]; + const authHeader = endpoint.source === 'LocalDaemon' || endpoint.source === 'LocalDwnHelper' + ? await localEndpointAuthHeader(endpoint.url, parsed.did, parsed.repo) + : undefined; + if (authHeader) { + gitArgs.push('-c', `http.extraHeader=${authHeader}`); + } + gitArgs.push(helper, remoteName, endpoint.url); + + const child = spawn('git', gitArgs, { stdio: 'inherit', }); diff --git a/src/git-remote/resolve.ts b/src/git-remote/resolve.ts index 6d97e60..075bc61 100644 --- a/src/git-remote/resolve.ts +++ b/src/git-remote/resolve.ts @@ -3,9 +3,10 @@ * * Resolves a DID document and extracts the git transport endpoint URL. * The resolution order is: - * 0. Local daemon (auto-started if needed) + * 0. Local daemon for the daemon owner's own DID (auto-started if needed) * 1. Service of type `GitTransport` in the DID document - * 2. Failure — no git endpoint found + * 2. Local DWN helper when the DID has a `DecentralizedWebNode` service + * 3. Failure — no git endpoint found * * @module */ @@ -33,18 +34,24 @@ export type GitEndpoint = { did: string; /** How the endpoint was discovered. */ - source: 'LocalDaemon' | 'GitTransport'; + source: 'LocalDaemon' | 'GitTransport' | 'LocalDwnHelper'; }; // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- +type ResolverResult = Awaited<ReturnType<UniversalResolver['resolve']>>; + +type DidResolver = { + resolve(did: string): Promise<ResolverResult>; +}; + /** Shared resolver instance (lazy-initialized). */ -let resolver: UniversalResolver | undefined; +let resolver: DidResolver | undefined; /** Get or create the DID resolver. */ -function getResolver(): UniversalResolver { +function getResolver(): DidResolver { if (!resolver) { resolver = new UniversalResolver({ didResolvers: [DidDht, DidJwk, DidWeb, DidKey], @@ -53,8 +60,13 @@ function getResolver(): UniversalResolver { return resolver; } -/** DID resolution timeout in milliseconds. */ -const DID_RESOLUTION_TIMEOUT_MS = 30_000; +/** Test hook for deterministic DID resolution without network access. */ +export function __setResolverForTests(testResolver?: DidResolver): void { + resolver = testResolver; +} + +/** Default DID resolution timeout in milliseconds. */ +const DEFAULT_DID_RESOLUTION_TIMEOUT_MS = 30_000; /** * Resolve a DID to a git transport HTTPS endpoint. @@ -65,22 +77,36 @@ const DID_RESOLUTION_TIMEOUT_MS = 30_000; * @throws If resolution fails, times out, or no git-compatible service is found */ export async function resolveGitEndpoint(did: string, repo?: string): Promise<GitEndpoint> { - // Priority 0: Check for a running local daemon. - const local = await resolveLocalDaemon(did, repo); + // Priority 0: Check for a local daemon serving its own owner DID. + const local = await resolveLocalDaemon(did, repo, 'owner-only'); if (local) { return local; } - const { didDocument, didResolutionMetadata } = await Promise.race([ - getResolver().resolve(did), - new Promise<never>((_, reject) => - setTimeout(() => reject(new Error(`DID resolution timed out after ${DID_RESOLUTION_TIMEOUT_MS}ms for ${did}`)), DID_RESOLUTION_TIMEOUT_MS), - ), - ]); + let resolved: ResolverResult; + try { + const timeoutMs = didResolutionTimeoutMs(); + resolved = await Promise.race([ + getResolver().resolve(did), + new Promise<never>((_, reject) => + setTimeout(() => reject(new Error(`DID resolution timed out after ${timeoutMs}ms for ${did}`)), timeoutMs), + ), + ]); + } catch (err) { + const localDwnHelper = await resolveLocalDaemon(did, repo, 'dwn-helper'); + if (localDwnHelper) { return localDwnHelper; } + throw err; + } + + const { didDocument, didResolutionMetadata } = resolved; if (didResolutionMetadata.error) { + const localDwnHelper = await resolveLocalDaemon(did, repo, 'dwn-helper'); + if (localDwnHelper) { return localDwnHelper; } throw new Error(`DID resolution failed for ${did}: ${didResolutionMetadata.error}`); } if (!didDocument) { + const localDwnHelper = await resolveLocalDaemon(did, repo, 'dwn-helper'); + if (localDwnHelper) { return localDwnHelper; } throw new Error(`DID resolution returned no document for ${did}`); } @@ -100,11 +126,16 @@ export async function resolveGitEndpoint(did: string, repo?: string): Promise<Gi // No git-capable endpoint found. Build a helpful error message. const dwnService = services.find((s) => s.type === 'DecentralizedWebNode'); if (dwnService) { + const localDwnHelper = await resolveLocalDaemon(did, repo, 'dwn-helper'); + if (localDwnHelper) { return localDwnHelper; } + throw new Error( `No GitTransport service found for ${did}. ` - + 'The DID has a DecentralizedWebNode service but no git server is registered.\n' - + 'Hint: start a local server with `gitd serve`, or register a public ' - + 'GitTransport endpoint with `gitd serve --public-url <url>`.', + + 'The DID has a DecentralizedWebNode service, but no local gitd daemon is available ' + + 'to restore the repo from DWN records.\n' + + 'Hint: run `gitd serve` in another terminal, set GITD_PASSWORD so gitd can ' + + 'auto-start the helper, or register a public GitTransport endpoint with ' + + '`gitd serve --public-url <url>`.', ); } @@ -114,12 +145,25 @@ export async function resolveGitEndpoint(did: string, repo?: string): Promise<Gi ); } +function didResolutionTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.GITD_DID_RESOLUTION_TIMEOUT_MS; + if (!raw) { return DEFAULT_DID_RESOLUTION_TIMEOUT_MS; } + + const value = Number.parseInt(raw, 10); + return Number.isFinite(value) && value > 0 + ? value + : DEFAULT_DID_RESOLUTION_TIMEOUT_MS; +} + // --------------------------------------------------------------------------- // Local daemon discovery // --------------------------------------------------------------------------- /** Timeout for the local daemon health probe (ms). */ const LOCAL_PROBE_TIMEOUT_MS = 2_000; +const LOCAL_LOOPBACK_ORIGIN = 'http://127.0.0.1'; + +type LocalDaemonMode = 'owner-only' | 'dwn-helper'; /** * Check whether a local gitd daemon is running and reachable. @@ -128,9 +172,13 @@ const LOCAL_PROBE_TIMEOUT_MS = 2_000; * the health endpoint to confirm the server is responsive. If no daemon * is running, attempts to auto-start one via `ensureDaemon()`. * - * @returns A `GitEndpoint` pointing to `http://localhost:<port>/...`, or `null`. + * @returns A `GitEndpoint` pointing to `http://127.0.0.1:<port>/...`, or `null`. */ -async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoint | null> { +async function resolveLocalDaemon( + did: string, + repo?: string, + mode: LocalDaemonMode = 'owner-only', +): Promise<GitEndpoint | null> { // Fast path: check for an already-running daemon. const lock = readLockfile(); if (lock) { @@ -139,11 +187,14 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi // to DID document resolution so the request reaches the correct // remote server. Lockfiles without `ownerDid` (written by older // versions) are treated as matching for backwards compatibility. - if (lock.ownerDid && lock.ownerDid !== did) { + if (mode === 'owner-only' && lock.ownerDid && lock.ownerDid !== did) { + return null; + } + if (mode === 'dwn-helper' && !lock.dwnHelper) { return null; } - const healthUrl = `http://localhost:${lock.port}/health`; + const healthUrl = `${LOCAL_LOOPBACK_ORIGIN}:${lock.port}/health`; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS); @@ -151,9 +202,9 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi clearTimeout(timer); if (res.ok) { return { - url : buildUrl(`http://localhost:${lock.port}`, did, repo), + url : buildUrl(`${LOCAL_LOOPBACK_ORIGIN}:${lock.port}`, did, repo), did, - source : 'LocalDaemon', + source : mode === 'dwn-helper' ? 'LocalDwnHelper' : 'LocalDaemon', }; } } catch { @@ -171,10 +222,18 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi try { const result = await ensureDaemon(password); + const spawnedLock = readLockfile(); + if (mode === 'owner-only' && spawnedLock?.ownerDid && spawnedLock.ownerDid !== did) { + return null; + } + if (mode === 'dwn-helper' && !spawnedLock?.dwnHelper) { + return null; + } + return { - url : buildUrl(`http://localhost:${result.port}`, did, repo), + url : buildUrl(`${LOCAL_LOOPBACK_ORIGIN}:${result.port}`, did, repo), did, - source : 'LocalDaemon', + source : mode === 'dwn-helper' ? 'LocalDwnHelper' : 'LocalDaemon', }; } catch (err) { // Could not start daemon — warn clearly so the user knows why @@ -342,6 +401,7 @@ function assertNotPrivateIp(ip: string, urlString: string): void { */ function buildUrl(base: string, did: string, repo?: string): string { const normalized = base.replace(/\/$/, ''); - if (!repo) { return `${normalized}/${did}`; } - return `${normalized}/${did}/${repo}`; + const encodedDid = encodeURIComponent(did); + if (!repo) { return `${normalized}/${encodedDid}`; } + return `${normalized}/${encodedDid}/${encodeURIComponent(repo)}`; } diff --git a/src/git-server/auth.ts b/src/git-server/auth.ts index b3d3f20..e2345eb 100644 --- a/src/git-server/auth.ts +++ b/src/git-server/auth.ts @@ -20,6 +20,8 @@ import { randomBytes } from 'node:crypto'; +import type { PushRefUpdate } from './push-updates.js'; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -90,6 +92,7 @@ export type PushAuthorizer = ( did: string, owner: string, repo: string, + updates?: readonly PushRefUpdate[], ) => Promise<boolean>; /** Options for creating a push authenticator. */ @@ -212,10 +215,20 @@ export function parseAuthPassword(password: string): SignedPushToken { */ export function createPushAuthenticator( options: PushAuthenticatorOptions, -): (request: Request, did: string, repo: string) => Promise<boolean> { +): ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], +) => Promise<boolean> { const { verifySignature, authorizePush, maxTokenAge = 300 } = options; - return async (request: Request, ownerDid: string, repo: string): Promise<boolean> => { + return async ( + request: Request, + ownerDid: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { // Extract HTTP Basic auth credentials. // Username is fixed to "did-auth" (DIDs contain colons, which conflict // with HTTP Basic auth's colon separator). The DID is inside the token. @@ -253,6 +266,9 @@ export function createPushAuthenticator( } catch { return false; } + if (process.env.GITD_DEBUG === '1') { + console.error(`[auth] token DID ${payload.did} for ${ownerDid}/${repo}`); + } // Verify the token targets the correct owner and repo. if (payload.owner !== ownerDid || payload.repo !== repo) { @@ -276,6 +292,9 @@ export function createPushAuthenticator( const signatureValid = await verifySignature(payload.did, tokenBytes, signatureBytes); if (!signatureValid) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[auth] signature verification failed for ${payload.did}`); + } return false; } @@ -287,7 +306,7 @@ export function createPushAuthenticator( // Optional: Check role-based push authorization. if (authorizePush) { - return authorizePush(payload.did, ownerDid, repo); + return authorizePush(payload.did, ownerDid, repo, updates); } return true; diff --git a/src/git-server/bundle-restore.ts b/src/git-server/bundle-restore.ts index f773405..a8bc9b8 100644 --- a/src/git-server/bundle-restore.ts +++ b/src/git-server/bundle-restore.ts @@ -17,6 +17,8 @@ import type { ForgeRepoProtocol } from '../repo.js'; import type { ForgeRepoSchemaMap } from '../repo.js'; +import type { ForgeRefsProtocol } from '../refs.js'; +import type { ForgeRefsSchemaMap } from '../refs.js'; import type { TypedEnbox } from '@enbox/api'; import { DateSort } from '@enbox/dwn-sdk-js'; @@ -35,6 +37,12 @@ export type BundleRestoreOptions = { /** The typed ForgeRepoProtocol handle for the owner's DWN. */ repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>; + /** Optional ForgeRefsProtocol handle used to restore branch-scoped bundles. */ + refs?: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + + /** Remote DID to query. Omit when restoring from the local DWN. */ + from?: string; + /** Path where the bare repository should be created. */ repoPath: string; @@ -73,13 +81,14 @@ export type BundleRestoreResult = { export async function restoreFromBundles( options: BundleRestoreOptions, ): Promise<BundleRestoreResult> { - const { repo, repoPath, repoContextId } = options; + const { repo, refs, from, repoPath, repoContextId } = options; // 1. Query for the most recent full bundle. const fullFilter: Record<string, unknown> = { tags: { isFull: true } }; if (repoContextId) { fullFilter.contextId = repoContextId; } const { records: fullBundles } = await repo.records.query('repo/bundle', { + ...(from ? { from } : {}), filter : fullFilter, dateSort : DateSort.CreatedDescending, }); @@ -109,6 +118,7 @@ export async function restoreFromBundles( if (repoContextId) { incFilter.contextId = repoContextId; } const { records: incrementals } = await repo.records.query('repo/bundle', { + ...(from ? { from } : {}), filter : incFilter, dateSort : DateSort.CreatedAscending, }); @@ -134,6 +144,15 @@ export async function restoreFromBundles( } } + if (refs && repoContextId) { + bundlesApplied += await restoreBranchBundles({ + refs, + from, + repoPath, + repoContextId, + }); + } + // 5. Get the tip commit. const tipCommit = await getTipCommit(repoPath); @@ -149,10 +168,69 @@ export async function restoreFromBundles( } } +// --------------------------------------------------------------------------- +// Branch-scoped bundle restore +// --------------------------------------------------------------------------- + +type BranchBundleRestoreOptions = { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + from?: string; + repoPath: string; + repoContextId: string; +}; + +async function restoreBranchBundles(options: BranchBundleRestoreOptions): Promise<number> { + const { refs, from, repoPath, repoContextId } = options; + const { records: branches } = await refs.records.query('repo/branch' as any, { + ...(from ? { from } : {}), + filter: { contextId: repoContextId }, + }); + + let applied = 0; + for (const branch of branches) { + const data = await branch.data.json(); + if (typeof data.refName !== 'string' || !branch.contextId) { + continue; + } + + const { records: bundles } = await refs.records.query('repo/branch/bundle' as any, { + ...(from ? { from } : {}), + filter : { contextId: branch.contextId, tags: { refName: data.refName } }, + dateSort : DateSort.CreatedDescending, + }); + if (bundles.length === 0) { + continue; + } + + const tipCommit = bundles[0].tags?.tipCommit as string | undefined; + if (tipCommit && await refAlreadyAt(repoPath, data.refName, tipCommit)) { + continue; + } + + const branchPath = tempBundlePath(); + try { + const blob = await bundles[0].data.blob(); + const bytes = Buffer.from(await blob.arrayBuffer()); + await writeFile(branchPath, bytes); + await spawnChecked('git', ['fetch', '--update-head-ok', branchPath, `${data.refName}:${data.refName}`], repoPath); + applied++; + } finally { + await unlink(branchPath).catch(() => {}); + } + } + + return applied; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +async function refAlreadyAt(repoPath: string, refName: string, target: string): Promise<boolean> { + const current = await spawnCollectOptional('git', ['rev-parse', '--verify', refName], repoPath); + return current === target; +} + /** Generate a unique temp file path for a bundle. */ function tempBundlePath(): string { return join(tmpdir(), `gitd-restore-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`); diff --git a/src/git-server/bundle-sync.ts b/src/git-server/bundle-sync.ts index 75aac6b..17ecb40 100644 --- a/src/git-server/bundle-sync.ts +++ b/src/git-server/bundle-sync.ts @@ -21,7 +21,9 @@ import type { ForgeRepoProtocol } from '../repo.js'; import type { ForgeRepoSchemaMap } from '../repo.js'; -import type { OnPushComplete } from './ref-sync.js'; +import type { ForgeRefsProtocol } from '../refs.js'; +import type { ForgeRefsSchemaMap } from '../refs.js'; +import type { GitRef, OnPushComplete } from './ref-sync.js'; import type { TypedEnbox } from '@enbox/api'; import { DateSort } from '@enbox/dwn-sdk-js'; @@ -29,6 +31,8 @@ import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { tmpdir } from 'node:os'; import { readFile, stat, unlink } from 'node:fs/promises'; +import { branchDataForRef } from '../branch-state.js'; +import { readGitRefs } from './ref-sync.js'; // --------------------------------------------------------------------------- // Types @@ -42,6 +46,9 @@ export type BundleSyncOptions = { /** The repo's contextId (from the ForgeRepoProtocol repo record). */ repoContextId: string; + /** Optional ForgeRefsProtocol handle used to write branch-scoped bundles. */ + refs?: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + /** * Repo visibility — controls whether bundle records are encrypted. * @@ -79,6 +86,18 @@ export type BundleInfo = { size: number; }; +/** Metadata about a generated branch-scoped bundle. */ +export type BranchBundleInfo = { + /** Path to the bundle file on disk. */ + path: string; + /** Full branch ref name included in the bundle. */ + refName: string; + /** Commit SHA at the branch tip. */ + tipCommit: string; + /** File size in bytes. */ + size: number; +}; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -90,10 +109,10 @@ export type BundleInfo = { * @returns An async callback to invoke after a successful push */ export function createBundleSyncer(options: BundleSyncOptions): OnPushComplete { - const { repo, repoContextId, visibility = 'public', squashThreshold = 5 } = options; + const { repo, repoContextId, refs, visibility = 'public', squashThreshold = 5 } = options; const encrypt = visibility === 'private'; - return async (_did: string, _repoName: string, repoPath: string): Promise<void> => { + return async (did: string, _repoName: string, repoPath: string): Promise<void> => { // Query existing bundle records scoped to this repo, newest first. const { records: existingBundles } = await repo.records.query('repo/bundle', { filter : { contextId: repoContextId, tags: { isFull: true } }, @@ -151,6 +170,7 @@ export function createBundleSyncer(options: BundleSyncOptions): OnPushComplete { tags, parentContextId : repoContextId, encryption : encrypt, + published : !encrypt, }; if (shouldSquash) { @@ -159,6 +179,16 @@ export function createBundleSyncer(options: BundleSyncOptions): OnPushComplete { } await repo.records.create('repo/bundle', createOptions); + + if (refs) { + await syncBranchBundles({ + refs, + repoContextId, + ownerDid: did, + repoPath, + encrypt, + }); + } } finally { // Clean up the temp bundle file. await unlink(bundleInfo.path).catch(() => {}); @@ -166,6 +196,136 @@ export function createBundleSyncer(options: BundleSyncOptions): OnPushComplete { }; } +// --------------------------------------------------------------------------- +// Branch bundle sync +// --------------------------------------------------------------------------- + +type BranchBundleSyncOptions = { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + repoContextId: string; + ownerDid: string; + repoPath: string; + encrypt: boolean; +}; + +async function syncBranchBundles(options: BranchBundleSyncOptions): Promise<void> { + const { refs, repoContextId, ownerDid, repoPath, encrypt } = options; + let gitRefs: GitRef[]; + try { + gitRefs = await readGitRefs(repoPath); + } catch (err) { + console.error(`bundle-sync: failed to read refs from ${repoPath}: ${(err as Error).message}`); + return; + } + + const branchRefs = gitRefs.filter((ref) => ref.type === 'branch'); + if (branchRefs.length === 0) { + return; + } + + const { records: existingBranches } = await refs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId }, + }); + const branchRecords = new Map<string, any>(); + for (const record of existingBranches) { + const data = await record.data.json(); + if (typeof data.refName === 'string') { + branchRecords.set(data.refName, record); + } + } + + for (const ref of branchRefs) { + const branchRecord = await ensureBranchRecord(refs, repoContextId, ownerDid, ref.name, branchRecords, !encrypt); + await writeBranchBundleIfChanged(refs, branchRecord, repoPath, ref, encrypt); + } +} + +async function ensureBranchRecord( + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>, + repoContextId: string, + ownerDid: string, + refName: string, + branchRecords: Map<string, any>, + publish: boolean, +): Promise<any> { + const existing = branchRecords.get(refName); + if (existing) { + return existing; + } + + const { records: matching } = await refs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId, tags: { refName } }, + }); + if (matching.length > 0) { + branchRecords.set(refName, matching[0]); + return matching[0]; + } + + const data = branchDataForRef(refName, ownerDid); + const { record } = await refs.records.create('repo/branch' as any, { + data, + tags: { + refName : data.refName, + ownerDid : data.ownerDid, + kind : data.kind, + }, + parentContextId: repoContextId, + published : publish, + }); + branchRecords.set(refName, record); + return record; +} + +async function writeBranchBundleIfChanged( + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>, + branchRecord: any, + repoPath: string, + ref: GitRef, + encrypt: boolean, +): Promise<void> { + const branchContextId = branchRecord.contextId; + if (!branchContextId) { + console.error(`bundle-sync: branch record for ${ref.name} has no contextId; skipping branch bundle`); + return; + } + + const { records } = await refs.records.query('repo/branch/bundle' as any, { + filter : { contextId: branchContextId }, + dateSort : DateSort.CreatedDescending, + }); + if (records[0]?.tags?.tipCommit === ref.target) { + return; + } + + let bundleInfo: BranchBundleInfo; + try { + bundleInfo = await createBranchBundle(repoPath, ref.name); + } catch (err) { + console.error(`bundle-sync: failed to create branch bundle for ${ref.name}: ${(err as Error).message}`); + return; + } + + try { + const bundleData = new Uint8Array(await readFile(bundleInfo.path)); + await refs.records.create('repo/branch/bundle' as any, { + data : bundleData, + dataFormat : 'application/x-git-bundle', + tags : { + kind : 'checkpoint', + refName : ref.name, + tipCommit : bundleInfo.tipCommit, + size : bundleInfo.size, + }, + parentContextId : branchContextId, + encryption : encrypt, + published : !encrypt, + squash : true, + }); + } finally { + await unlink(bundleInfo.path).catch(() => {}); + } +} + // --------------------------------------------------------------------------- // Bundle creation // --------------------------------------------------------------------------- @@ -226,6 +386,29 @@ export async function createIncrementalBundle( }; } +/** + * Create a branch-scoped checkpoint bundle containing one branch ref. + * + * @param repoPath - Path to the bare git repository + * @param refName - Full branch ref name to include + * @returns Branch bundle metadata and file path + */ +export async function createBranchBundle(repoPath: string, refName: string): Promise<BranchBundleInfo> { + const bundlePath = join(tmpdir(), `gitd-branch-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`); + + await spawnChecked('git', ['bundle', 'create', bundlePath, refName], repoPath); + + const tipCommit = (await spawnCollectStdout('git', ['rev-parse', refName], repoPath)).trim(); + const fileInfo = await stat(bundlePath); + + return { + path: bundlePath, + refName, + tipCommit, + size: fileInfo.size, + }; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/git-server/did-service.ts b/src/git-server/did-service.ts index b054b1d..9d14997 100644 --- a/src/git-server/did-service.ts +++ b/src/git-server/did-service.ts @@ -137,6 +137,11 @@ export function startDidRepublisher( web5: Enbox, intervalMs: number = DEFAULT_REPUBLISH_INTERVAL_MS, ): () => void { + const republishSetting = process.env.GITD_DID_REPUBLISH?.toLowerCase(); + if (republishSetting === 'off' || republishSetting === '0' || republishSetting === 'false') { + return (): void => {}; + } + const agent = web5.agent as any; const bearerDid = agent?.agentDid; diff --git a/src/git-server/http-handler.ts b/src/git-server/http-handler.ts index 1d824a5..f1e7764 100644 --- a/src/git-server/http-handler.ts +++ b/src/git-server/http-handler.ts @@ -25,6 +25,9 @@ import { spawn } from 'node:child_process'; import type { GitBackend } from './git-backend.js'; +import type { PushRefUpdate } from './push-updates.js'; + +import { parseReceivePackUpdatesFromRequest } from './push-updates.js'; // --------------------------------------------------------------------------- // Types @@ -44,7 +47,21 @@ export type GitHttpHandlerOptions = { * @param did - The repository owner's DID * @param repo - The repository name */ - authenticatePush?: (request: Request, did: string, repo: string) => Promise<boolean>; + authenticatePush?: ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ) => Promise<boolean>; + + /** + * Whether `GET /info/refs?service=git-receive-pack` requires push auth. + * The POST receive-pack request is always authenticated when + * `authenticatePush` is configured. + * + * @default true + */ + authenticateReceivePackDiscovery?: boolean; /** * Optional callback invoked after a successful `git receive-pack` (push). @@ -54,7 +71,12 @@ export type GitHttpHandlerOptions = { * @param repo - The repository name * @param repoPath - Filesystem path to the bare repository */ - onPushComplete?: (did: string, repo: string, repoPath: string) => Promise<void>; + onPushComplete?: ( + did: string, + repo: string, + repoPath: string, + context?: { updates?: readonly PushRefUpdate[] }, + ) => Promise<void>; /** * Optional callback invoked when a clone/fetch request arrives for a @@ -68,6 +90,13 @@ export type GitHttpHandlerOptions = { */ onRepoNotFound?: (did: string, repo: string, repoPath: string) => Promise<boolean>; + /** + * Optional callback invoked before clone/fetch services are served for a + * repository that exists on disk. Implementations can refresh a stale mirror + * from DWN bundle records. + */ + onRepoAccess?: (did: string, repo: string, repoPath: string) => Promise<boolean>; + /** * Optional path prefix to strip from incoming URLs. * For example, if the sidecar is mounted at `/git`, set this to `/git`. @@ -97,7 +126,15 @@ type GitRoute = { export function createGitHttpHandler( options: GitHttpHandlerOptions, ): (request: Request) => Response | Promise<Response> { - const { backend, authenticatePush, onPushComplete, onRepoNotFound, pathPrefix = '' } = options; + const { + backend, + authenticatePush, + authenticateReceivePackDiscovery = true, + onPushComplete, + onRepoNotFound, + onRepoAccess, + pathPrefix = '', + } = options; /** * Ensure a repo exists on disk, attempting DWN bundle restore if absent. @@ -119,6 +156,18 @@ export function createGitHttpHandler( return false; } + async function refreshRepo(did: string, repo: string): Promise<boolean> { + if (!onRepoAccess) { return true; } + + const repoPath = backend.repoPath(did, repo); + try { + return await onRepoAccess(did, repo, repoPath); + } catch (err) { + console.error(`onRepoAccess error for ${did}/${repo}: ${(err as Error).message}`); + return false; + } + } + return async (request: Request): Promise<Response> => { const url = new URL(request.url); let pathname = url.pathname; @@ -146,20 +195,30 @@ export function createGitHttpHandler( } // Auth check for push ref discovery. - if (service === 'git-receive-pack' && authenticatePush) { + if (service === 'git-receive-pack' && authenticatePush && authenticateReceivePackDiscovery) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[git-http] authenticating receive-pack discovery for ${did}/${repo}; auth=${request.headers.has('Authorization') ? 'present' : 'missing'}`); + } const authorized = await authenticatePush(request, did, repo); if (!authorized) { return new Response('Unauthorized', { status : 401, - headers : { 'WWW-Authenticate': 'Basic realm="gitd"' }, + headers : { + 'WWW-Authenticate' : 'Basic realm="gitd"', + Connection : 'close', + }, }); } } // Ensure repo exists (auto-restore from DWN if possible). + const existedBeforeRequest = backend.exists(did, repo); if (!(await ensureRepo(did, repo))) { return new Response('Repository not found', { status: 404 }); } + if (service === 'git-upload-pack' && existedBeforeRequest && !(await refreshRepo(did, repo))) { + return new Response('Repository refresh failed', { status: 500 }); + } return handleInfoRefs(backend, did, repo, service); } @@ -179,13 +238,18 @@ export function createGitHttpHandler( // POST /<did>/<repo>/git-receive-pack // ----------------------------------------------------------------------- if (request.method === 'POST' && action === 'git-receive-pack') { + const updates = await parseReceivePackUpdatesFromRequest(request.clone()); + // Auth check for push. if (authenticatePush) { - const authorized = await authenticatePush(request, did, repo); + const authorized = await authenticatePush(request, did, repo, updates); if (!authorized) { return new Response('Unauthorized', { status : 401, - headers : { 'WWW-Authenticate': 'Basic realm="gitd"' }, + headers : { + 'WWW-Authenticate' : 'Basic realm="gitd"', + Connection : 'close', + }, }); } } @@ -204,7 +268,7 @@ export function createGitHttpHandler( exitCode.then((code) => { if (code === 0) { const repoPath = backend.repoPath(did, repo); - return onPushComplete(did, repo, repoPath); + return onPushComplete(did, repo, repoPath, { updates }); } }).catch((err) => { console.error(`onPushComplete error for ${did}/${repo}: ${(err as Error).message}`); @@ -293,7 +357,6 @@ async function handleInfoRefs( const gitService = service === 'git-upload-pack' ? 'upload-pack' : 'receive-pack'; const repoPath = backend.repoPath(did, repo); - // Run git with --advertise-refs to get the ref listing. const refData = await spawnAndCollect(gitService, repoPath); if (refData === null) { @@ -314,6 +377,8 @@ async function handleInfoRefs( headers : { 'Content-Type' : `application/x-${service}-advertisement`, 'Cache-Control' : 'no-cache', + 'Content-Length': String(body.byteLength), + Connection : 'close', }, }); } @@ -364,6 +429,7 @@ async function handleServiceRpc( headers : { 'Content-Type' : `application/x-git-${service}-result`, 'Cache-Control' : 'no-cache', + Connection : 'close', }, }); @@ -378,16 +444,21 @@ async function handleServiceRpc( * Spawn `git <service> --stateless-rpc --advertise-refs` and collect stdout. * Returns `null` if the process exits with a non-zero code. */ -function spawnAndCollect(service: string, repoPath: string): Promise<Uint8Array | null> { +function spawnAndCollect( + service: string, + repoPath: string, +): Promise<Uint8Array | null> { return new Promise((resolve, reject) => { const child = spawn('git', [service, '--stateless-rpc', '--advertise-refs', repoPath], { + env: process.env, stdio: ['pipe', 'pipe', 'pipe'], }); + child.stdin?.end(); const chunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; - // Drain stderr to prevent pipe buffer deadlocks. - child.stderr!.resume(); + child.stderr!.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); child.stdout!.on('data', (chunk: Buffer) => chunks.push(chunk)); child.stdout!.on('error', reject); @@ -395,6 +466,8 @@ function spawnAndCollect(service: string, repoPath: string): Promise<Uint8Array child.on('error', reject); child.on('exit', (code) => { if (code !== 0) { + const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim(); + console.error(`git ${service} --advertise-refs failed for ${repoPath} with code ${code}${stderr ? `: ${stderr}` : ''}`); resolve(null); } else { resolve(new Uint8Array(Buffer.concat(chunks))); diff --git a/src/git-server/index.ts b/src/git-server/index.ts index 8cf3873..12b20de 100644 --- a/src/git-server/index.ts +++ b/src/git-server/index.ts @@ -11,7 +11,9 @@ export * from './did-service.js'; export * from './git-backend.js'; export * from './http-handler.js'; export * from './push-authorizer.js'; +export * from './push-updates.js'; export * from './ref-sync.js'; +export * from './remote-branch-sync.js'; export * from './repo-mutex.js'; export * from './server.js'; export * from './verify.js'; diff --git a/src/git-server/push-authorizer.ts b/src/git-server/push-authorizer.ts index ab612d7..63030fb 100644 --- a/src/git-server/push-authorizer.ts +++ b/src/git-server/push-authorizer.ts @@ -8,7 +8,8 @@ * Authorization rules: * - The repo owner (DID that owns the DWN) can always push * - DIDs with a `maintainer` role record can push - * - DIDs with a `contributor` role record can push + * - DIDs with a `contributor` role record can push only their own + * contributor branch namespace * - All other DIDs are rejected * * @module @@ -18,7 +19,9 @@ import type { TypedEnbox } from '@enbox/api'; import type { ForgeRepoProtocol } from '../repo.js'; import type { ForgeRepoSchemaMap } from '../repo.js'; +import type { PushRefUpdate } from './push-updates.js'; +import { isContributorBranchRef } from '../branch-state.js'; import type { PushAuthorizer } from './auth.js'; // --------------------------------------------------------------------------- @@ -31,6 +34,8 @@ export type DwnPushAuthorizerOptions = { repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>; /** The DID of the DWN owner (server operator). */ ownerDid: string; + /** Optional remote DWN DID to query for repo and role records. */ + from?: string; /** Optional known repo context. If omitted, resolved from the pushed repo name. */ repoContextId?: string; }; @@ -50,21 +55,31 @@ export type DwnPushAuthorizerOptions = { * @returns A PushAuthorizer callback */ export function createDwnPushAuthorizer(options: DwnPushAuthorizerOptions): PushAuthorizer { - const { repo, ownerDid, repoContextId } = options; + const { repo, ownerDid, from, repoContextId } = options; - return async (did: string, owner: string, repoName: string): Promise<boolean> => { + return async ( + did: string, + owner: string, + repoName: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { // The owner can always push to their own repos. if (did === owner || did === ownerDid) { return true; } - const contextId = repoContextId ?? await findRepoContextId(repo, repoName); + const contextId = repoContextId ?? await findRepoContextId(repo, repoName, from); if (!contextId) { return false; } + if (await isBlocked(repo, contextId, did, from)) { + return false; + } + // Query for maintainer role records for this DID. const { records: maintainers } = await repo.records.query('repo/maintainer' as any, { + ...(from ? { from } : {}), filter: { contextId, tags: { did } }, }); if (maintainers.length > 0) { @@ -73,22 +88,71 @@ export function createDwnPushAuthorizer(options: DwnPushAuthorizerOptions): Push // Query for contributor role records for this DID. const { records: contributors } = await repo.records.query('repo/contributor' as any, { + ...(from ? { from } : {}), filter: { contextId, tags: { did } }, }); if (contributors.length > 0) { - return true; + return contributorCanPushUpdates(did, updates); } return false; }; } +function contributorCanPushUpdates( + did: string, + updates?: readonly PushRefUpdate[], +): boolean { + // GET /info/refs does not include update commands. Allow advertisement for + // contributors; the POST receive-pack command list is checked below. + if (!updates) { return true; } + + return updates.every((update) => + update.refName.startsWith('refs/heads/') + && isContributorBranchRef(update.refName, did), + ); +} + async function findRepoContextId( repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>, repoName: string, + from?: string, ): Promise<string | undefined> { const { records } = await repo.records.query('repo', { + ...(from ? { from } : {}), filter: { tags: { name: repoName } }, }); return records[0]?.contextId; } + +async function isBlocked( + repo: TypedEnbox<typeof ForgeRepoProtocol.definition, ForgeRepoSchemaMap>, + contextId: string, + did: string, + from?: string, +): Promise<boolean> { + const { records } = await repo.records.query('repo/moderationEvent' as any, { + ...(from ? { from } : {}), + filter: { contextId, tags: { targetDid: did } }, + }); + if (records.length === 0) { + return false; + } + + const events = await Promise.all(records.map(async (record: any) => { + const data = await record.data.json().catch(() => ({})); + return { + action : (record.tags?.action ?? data.action) as string | undefined, + createdAt : data.createdAt ?? record.dateCreated ?? '', + id : record.id ?? '', + }; + })); + + events.sort((a, b) => { + const byTime = b.createdAt.localeCompare(a.createdAt); + if (byTime !== 0) { return byTime; } + return b.id.localeCompare(a.id); + }); + + return events[0]?.action === 'block'; +} diff --git a/src/git-server/push-updates.ts b/src/git-server/push-updates.ts new file mode 100644 index 0000000..5451924 --- /dev/null +++ b/src/git-server/push-updates.ts @@ -0,0 +1,84 @@ +/** + * Git receive-pack update parsing. + * + * A receive-pack request starts with pkt-line commands of the form: + * `<old-oid> <new-oid> <ref-name>\0<capabilities>`. + * The packfile follows after the flush packet. Authorization only needs + * the command header, not the pack payload. + * + * @module + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** One ref update requested by a git push. */ +export type PushRefUpdate = { + /** Previous object ID, or null when creating a ref. */ + oldTarget: string | null; + + /** New object ID, or null when deleting a ref. */ + newTarget: string | null; + + /** Full ref name, e.g. `refs/heads/main`. */ + refName: string; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Parse receive-pack update commands from a request body clone. */ +export async function parseReceivePackUpdatesFromRequest(request: Request): Promise<PushRefUpdate[]> { + if (!request.body) { return []; } + const body = new Uint8Array(await request.arrayBuffer()); + return parseReceivePackUpdates(body); +} + +/** Parse receive-pack update commands from raw pkt-line bytes. */ +export function parseReceivePackUpdates(input: Uint8Array | string): PushRefUpdate[] { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input; + const decoder = new TextDecoder(); + const updates: PushRefUpdate[] = []; + + let offset = 0; + while (offset + 4 <= bytes.length) { + const lenText = decoder.decode(bytes.slice(offset, offset + 4)); + if (lenText === '0000') { break; } + + const len = Number.parseInt(lenText, 16); + if (!Number.isFinite(len) || len < 4 || offset + len > bytes.length) { + break; + } + + const payload = decoder.decode(bytes.slice(offset + 4, offset + len)); + const update = parseUpdateLine(payload); + if (!update) { break; } + updates.push(update); + + offset += len; + } + + return updates; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseUpdateLine(line: string): PushRefUpdate | undefined { + const command = line.split('\0', 1)[0].trimEnd(); + const match = /^([0-9a-f]{40,64}) ([0-9a-f]{40,64}) ([^\s\0]+)$/i.exec(command); + if (!match) { return undefined; } + + return { + oldTarget : oidToTarget(match[1]), + newTarget : oidToTarget(match[2]), + refName : match[3], + }; +} + +function oidToTarget(oid: string): string | null { + return /^0+$/.test(oid) ? null : oid.toLowerCase(); +} diff --git a/src/git-server/ref-sync.ts b/src/git-server/ref-sync.ts index 70060ab..e13dd50 100644 --- a/src/git-server/ref-sync.ts +++ b/src/git-server/ref-sync.ts @@ -1,9 +1,9 @@ /** - * Git ref → DWN sync — mirrors git branch/tag refs as ForgeRefs records. + * Git ref → DWN sync — mirrors git refs and checkpoints branch state. * * After a successful `git push`, this module reads the current refs from - * the bare repository and creates or updates corresponding DWN records - * using the ForgeRefsProtocol. + * the bare repository and creates or updates corresponding DWN records using + * the ForgeRefsProtocol. * * Ref sync flow: * 1. Run `git for-each-ref` on the bare repo to enumerate current refs @@ -11,16 +11,20 @@ * 3. Create new records for refs that don't exist in DWN * 4. Update existing records whose target (SHA) has changed * 5. Delete DWN records for refs that no longer exist in git + * 6. Ensure branch records exist and write `$squash` checkpoints for branch + * targets under `repo/branch/state` * * @module */ import type { TypedEnbox } from '@enbox/api'; +import type { PushRefUpdate } from './push-updates.js'; import { spawn } from 'node:child_process'; +import { branchDataForRef, reduceBranchState } from '../branch-state.js'; import type { ForgeRefsProtocol } from '../refs.js'; -import type { ForgeRefsSchemaMap } from '../refs.js'; +import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; // --------------------------------------------------------------------------- // Types @@ -42,6 +46,8 @@ export type RefSyncOptions = { refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; /** The repo's contextId (from the ForgeRepoProtocol repo record). */ repoContextId: string; + /** Repo visibility controls whether ref and branch checkpoint records are published. */ + visibility?: 'public' | 'private'; }; /** Callback for post-push ref synchronization. */ @@ -49,6 +55,9 @@ export type OnPushComplete = ( did: string, repo: string, repoPath: string, + context?: { + updates?: readonly PushRefUpdate[]; + }, ) => Promise<void>; // --------------------------------------------------------------------------- @@ -62,9 +71,10 @@ export type OnPushComplete = ( * @returns An async callback to invoke after a successful push */ export function createRefSyncer(options: RefSyncOptions): OnPushComplete { - const { refs, repoContextId } = options; + const { refs, repoContextId, visibility = 'public' } = options; + const publish = visibility === 'public'; - return async (_did: string, _repo: string, repoPath: string): Promise<void> => { + return async (did: string, _repo: string, repoPath: string): Promise<void> => { // Read current git refs from the bare repository. // If git fails (corrupt repo, permission denied, etc.), abort the sync // to avoid deleting all DWN ref records due to an empty ref list. @@ -101,6 +111,7 @@ export function createRefSyncer(options: RefSyncOptions): OnPushComplete { data : { name: ref.name, target: ref.target, type: ref.type }, tags : { name: ref.name, type: ref.type, target: ref.target }, parentContextId : repoContextId, + published : publish, }); } else if (existing.target !== ref.target) { // Update existing record with new target. @@ -118,9 +129,133 @@ export function createRefSyncer(options: RefSyncOptions): OnPushComplete { await record.delete(); } } + + await syncBranchCheckpoints(refs, repoContextId, did, gitRefs, publish); }; } +// --------------------------------------------------------------------------- +// Branch state sync +// --------------------------------------------------------------------------- + +/** Ensure branch records and checkpoint state exist for current git branches. */ +async function syncBranchCheckpoints( + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>, + repoContextId: string, + ownerDid: string, + gitRefs: GitRef[], + publish: boolean, +): Promise<void> { + const branchRefs = gitRefs.filter((ref) => ref.type === 'branch'); + const branchRefNames = new Set(branchRefs.map((ref) => ref.name)); + const { records: existingBranchRecords } = await refs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId }, + }); + + const branchRecords = new Map<string, any>(); + for (const record of existingBranchRecords) { + const data = await record.data.json(); + if (typeof data.refName === 'string') { + branchRecords.set(data.refName, record); + } + } + + for (const ref of branchRefs) { + const branchRecord = await ensureBranchRecord(refs, repoContextId, ownerDid, ref.name, branchRecords, publish); + await writeCheckpointIfChanged(refs, branchRecord, ownerDid, ref.name, ref.target, publish); + } + + for (const [refName, branchRecord] of branchRecords) { + if (!branchRefNames.has(refName)) { + await writeCheckpointIfChanged(refs, branchRecord, ownerDid, refName, null, publish); + } + } +} + +async function ensureBranchRecord( + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>, + repoContextId: string, + ownerDid: string, + refName: string, + branchRecords: Map<string, any>, + publish: boolean, +): Promise<any> { + const existing = branchRecords.get(refName); + if (existing) { + return existing; + } + + const data = branchDataForRef(refName, ownerDid); + const { record } = await refs.records.create('repo/branch' as any, { + data, + tags: { + refName : data.refName, + ownerDid : data.ownerDid, + kind : data.kind, + }, + parentContextId: repoContextId, + published : publish, + }); + + branchRecords.set(refName, record); + return record; +} + +async function writeCheckpointIfChanged( + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>, + branchRecord: any, + actorDid: string, + refName: string, + target: string | null, + publish: boolean, +): Promise<void> { + const branchContextId = branchRecord.contextId; + if (!branchContextId) { + console.error(`ref-sync: branch record for ${refName} has no contextId; skipping branch state checkpoint`); + return; + } + + const { records } = await refs.records.query('repo/branch/state' as any, { + filter: { contextId: branchContextId }, + }); + const stateRecords = await Promise.all(records.map(async (record: any) => ({ + recordId : record.id ?? '', + authorDid : record.author ?? record.authorDid, + dateCreated : record.dateCreated, + data : await record.data.json() as BranchStateData, + }))); + const reduction = reduceBranchState(refName, stateRecords); + if (reduction.target === target && reduction.accepted.length > 0) { + return; + } + + const now = new Date().toISOString(); + const data: BranchStateData = { + kind : 'checkpoint', + refName, + target, + actorDid, + acceptedAt : now, + createdAt : now, + }; + const tags: Record<string, string> = { + kind: 'checkpoint', + refName, + actorDid, + }; + if (target) { + tags.target = target; + } + + await refs.records.create('repo/branch/state' as any, { + data, + tags, + parentContextId : branchContextId, + published : publish, + squash : true, + }); +} + // --------------------------------------------------------------------------- // Git ref reader // --------------------------------------------------------------------------- diff --git a/src/git-server/remote-branch-sync.ts b/src/git-server/remote-branch-sync.ts new file mode 100644 index 0000000..3e137a3 --- /dev/null +++ b/src/git-server/remote-branch-sync.ts @@ -0,0 +1,323 @@ +/** + * Remote-owner branch writeback for local helper pushes. + * + * When a contributor pushes to a DID remote that resolves to their local + * helper, the helper updates its local bare cache via git receive-pack, then + * writes contributor-authored branch state and bundle records to the repo + * owner's DWN. This keeps the remote DWN as the source of truth without a + * hosted git server holding project keys. + * + * @module + */ + +import type { TypedEnbox } from '@enbox/api'; + +import { randomUUID } from 'node:crypto'; +import { readFile, unlink } from 'node:fs/promises'; + +import type { ForgeRefsProtocol } from '../refs.js'; +import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; +import type { PushRefUpdate } from './push-updates.js'; + +import { branchDataForRef, isContributorBranchRef } from '../branch-state.js'; +import { createBranchBundle } from './bundle-sync.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Function used to deliver a locally-created record to the repo owner's DWN. */ +export type RemoteRecordSender = (record: any, targetDid: string) => Promise<void>; + +/** Function used to look up existing branch records in the repo owner's DWN. */ +export type RemoteBranchLookup = (refName: string) => Promise<any[]>; + +export type RemoteBranchPushSyncOptions = { + /** The contributor's typed refs handle. */ + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + + /** Alice's repo context ID, read from Alice's DWN. */ + repoContextId: string; + + /** Repo owner DID whose DWN receives the branch records. */ + targetDid: string; + + /** DID authoring the contributor branch records. */ + actorDid: string; + + /** Local bare repo cache updated by receive-pack. */ + repoPath: string; + + /** Git receive-pack ref updates from the accepted push. */ + updates: readonly PushRefUpdate[]; + + /** Optional test hook. Defaults to `record.send(targetDid)`. */ + sendRecord?: RemoteRecordSender; + + /** Optional test hook. Defaults to querying `refs` with `from: targetDid`. */ + lookupBranches?: RemoteBranchLookup; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Write contributor branch state and bundle records to the repo owner's DWN. */ +export async function syncRemoteBranchPush(options: RemoteBranchPushSyncOptions): Promise<void> { + const { + refs, + repoContextId, + targetDid, + actorDid, + repoPath, + updates, + sendRecord = sendRecordToDwn, + lookupBranches, + } = options; + + for (const update of updates) { + if (!isContributorBranchRef(update.refName, actorDid)) { + throw new Error(`Ref ${update.refName} is not in contributor namespace for ${actorDid}`); + } + + const branchRecord = await ensureRemoteBranchRecord({ + refs, + repoContextId, + targetDid, + actorDid, + refName: update.refName, + sendRecord, + lookupBranches, + }); + + const bundleRecordId = update.newTarget + ? await writeRemoteBranchBundle({ + refs, + branchRecord, + targetDid, + repoPath, + update, + sendRecord, + }) + : undefined; + + const stateRecordId = await writeRemoteBranchRefUpdate({ + refs, + branchRecord, + targetDid, + actorDid, + update, + bundleRecordId, + sendRecord, + }); + + await writeRemoteBranchCheckpoint({ + refs, + branchRecord, + targetDid, + actorDid, + update, + acceptedStateRecordId: stateRecordId, + sendRecord, + }); + } +} + +// --------------------------------------------------------------------------- +// Branch records +// --------------------------------------------------------------------------- + +async function ensureRemoteBranchRecord(options: { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + repoContextId: string; + targetDid: string; + actorDid: string; + refName: string; + sendRecord: RemoteRecordSender; + lookupBranches?: RemoteBranchLookup; +}): Promise<any> { + const { refs, repoContextId, targetDid, actorDid, refName, sendRecord, lookupBranches } = options; + + const records = lookupBranches + ? await lookupBranches(refName) + : (await refs.records.query('repo/branch' as any, { + from : targetDid, + filter : { contextId: repoContextId, tags: { refName } }, + })).records; + + if (records.length > 0) { + const data = await records[0].data.json(); + if (data.ownerDid !== actorDid || data.kind !== 'contributor') { + throw new Error(`Remote branch ${refName} is not owned by ${actorDid}`); + } + return records[0]; + } + + const data = branchDataForRef(refName, actorDid); + if (data.kind !== 'contributor') { + throw new Error(`Ref ${refName} does not map to a contributor branch for ${actorDid}`); + } + + const { record } = await refs.records.create('repo/branch' as any, { + data, + tags: { + refName : data.refName, + ownerDid : data.ownerDid, + kind : data.kind, + }, + parentContextId : repoContextId, + protocolRole : 'repo:repo/contributor', + recipient : targetDid, + published : true, + store : false, + }); + if (!record) { + throw new Error(`Failed to create contributor branch record for ${refName}`); + } + + await sendRecord(record, targetDid); + return record; +} + +// --------------------------------------------------------------------------- +// State and bundle records +// --------------------------------------------------------------------------- + +async function writeRemoteBranchBundle(options: { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + branchRecord: any; + targetDid: string; + repoPath: string; + update: PushRefUpdate; + sendRecord: RemoteRecordSender; +}): Promise<string> { + const { refs, branchRecord, targetDid, repoPath, update, sendRecord } = options; + const bundleInfo = await createBranchBundle(repoPath, update.refName); + + try { + const bundleData = new Uint8Array(await readFile(bundleInfo.path)); + const { record } = await refs.records.create('repo/branch/bundle' as any, { + data : bundleData, + dataFormat : 'application/x-git-bundle', + tags : { + kind : 'checkpoint', + refName : update.refName, + tipCommit : bundleInfo.tipCommit, + size : bundleInfo.size, + }, + parentContextId : branchRecord.contextId, + recipient : targetDid, + published : true, + squash : true, + store : false, + }); + if (!record) { + throw new Error(`Failed to create branch bundle record for ${update.refName}`); + } + + await sendRecord(record, targetDid); + return record.id; + } finally { + await unlink(bundleInfo.path).catch(() => {}); + } +} + +async function writeRemoteBranchRefUpdate(options: { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + branchRecord: any; + targetDid: string; + actorDid: string; + update: PushRefUpdate; + bundleRecordId?: string; + sendRecord: RemoteRecordSender; +}): Promise<string> { + const { refs, branchRecord, targetDid, actorDid, update, bundleRecordId, sendRecord } = options; + const now = new Date().toISOString(); + const data: BranchStateData = { + kind : 'refUpdate', + refName : update.refName, + oldTarget : update.oldTarget, + newTarget : update.newTarget, + actorDid, + createdAt : now, + nonce : randomUUID(), + ...(bundleRecordId ? { bundleRecordId } : {}), + }; + + const tags: Record<string, string> = { + kind : 'refUpdate', + refName : update.refName, + actorDid, + }; + if (update.oldTarget) { tags.oldTarget = update.oldTarget; } + if (update.newTarget) { tags.newTarget = update.newTarget; } + if (bundleRecordId) { tags.bundleRecordId = bundleRecordId; } + + const { record } = await refs.records.create('repo/branch/state' as any, { + data, + tags, + parentContextId : branchRecord.contextId, + recipient : targetDid, + published : true, + store : false, + }); + if (!record) { + throw new Error(`Failed to create branch state record for ${update.refName}`); + } + + await sendRecord(record, targetDid); + return record.id; +} + +async function writeRemoteBranchCheckpoint(options: { + refs: TypedEnbox<typeof ForgeRefsProtocol.definition, ForgeRefsSchemaMap>; + branchRecord: any; + targetDid: string; + actorDid: string; + update: PushRefUpdate; + acceptedStateRecordId: string; + sendRecord: RemoteRecordSender; +}): Promise<void> { + const { refs, branchRecord, targetDid, actorDid, update, acceptedStateRecordId, sendRecord } = options; + const now = new Date().toISOString(); + const data: BranchStateData = { + kind : 'checkpoint', + refName : update.refName, + target : update.newTarget, + actorDid, + acceptedStateRecordId, + acceptedAt : now, + createdAt : now, + }; + const tags: Record<string, string> = { + kind: 'checkpoint', + refName: update.refName, + actorDid, + acceptedStateRecordId, + }; + if (update.newTarget) { + tags.target = update.newTarget; + } + + const { record } = await refs.records.create('repo/branch/state' as any, { + data, + tags, + parentContextId : branchRecord.contextId, + recipient : targetDid, + published : true, + squash : true, + store : false, + }); + if (!record) { + throw new Error(`Failed to create branch checkpoint record for ${update.refName}`); + } + + await sendRecord(record, targetDid); +} + +async function sendRecordToDwn(record: any, targetDid: string): Promise<void> { + const status = await record.send(targetDid); + if (status.code >= 300) { + throw new Error(`remote branch write failed: ${status.code} ${status.detail ?? ''}`.trim()); + } +} diff --git a/src/git-server/server.ts b/src/git-server/server.ts index ead1acb..3bbf825 100644 --- a/src/git-server/server.ts +++ b/src/git-server/server.ts @@ -20,10 +20,13 @@ */ import type { IncomingMessage } from 'node:http'; +import type { PushRefUpdate } from './push-updates.js'; +import type { CliRpcRequest, CliRpcResponse } from '../cli/local-rpc.js'; import { createServer } from 'node:http'; import { createGitHttpHandler } from './http-handler.js'; +import { CLI_RPC_PATH } from '../cli/local-rpc.js'; import { GitBackend } from './git-backend.js'; // --------------------------------------------------------------------------- @@ -54,13 +57,30 @@ export type GitServerOptions = { * Optional authentication callback for push operations. * @see GitHttpHandlerOptions.authenticatePush */ - authenticatePush?: (request: Request, did: string, repo: string) => Promise<boolean>; + authenticatePush?: ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ) => Promise<boolean>; + + /** + * Whether receive-pack ref discovery requires push auth. Actual + * receive-pack POST requests remain authenticated when `authenticatePush` + * is configured. + */ + authenticateReceivePackDiscovery?: boolean; /** * Optional callback invoked after a successful push. * @see GitHttpHandlerOptions.onPushComplete */ - onPushComplete?: (did: string, repo: string, repoPath: string) => Promise<void>; + onPushComplete?: ( + did: string, + repo: string, + repoPath: string, + context?: { updates?: readonly PushRefUpdate[] }, + ) => Promise<void>; /** * Optional callback invoked when a repo is not found on disk. @@ -69,6 +89,12 @@ export type GitServerOptions = { */ onRepoNotFound?: (did: string, repo: string, repoPath: string) => Promise<boolean>; + /** + * Optional callback invoked before serving clone/fetch for an existing repo. + * Implementations can refresh stale remote mirrors from DWN bundle records. + */ + onRepoAccess?: (did: string, repo: string, repoPath: string) => Promise<boolean>; + /** * Maximum request body size in bytes for POST requests (git pack data). * @default 50 * 1024 * 1024 (50 MB) @@ -93,6 +119,12 @@ export type GitServerOptions = { * @returns Push credentials (username + password) or null if generation fails */ generateToken?: (owner: string, repo: string) => Promise<{ username: string; password: string } | null>; + + /** + * Optional local-only command executor used by one-shot CLI invocations + * when the profile helper already owns the agent stores. + */ + handleCliCommand?: (request: CliRpcRequest) => Promise<CliRpcResponse>; }; /** A running git server instance. */ @@ -124,11 +156,14 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer hostname = '0.0.0.0', pathPrefix, authenticatePush, + authenticateReceivePackDiscovery, onPushComplete, onRepoNotFound, + onRepoAccess, maxBodySize = DEFAULT_MAX_BODY_GIT, onRequest, generateToken, + handleCliCommand, } = options; const backend = new GitBackend({ basePath }); @@ -137,8 +172,10 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer backend, pathPrefix, authenticatePush, + authenticateReceivePackDiscovery, onPushComplete, onRepoNotFound, + onRepoAccess, }); const server = createServer(async (req, res) => { @@ -151,6 +188,37 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer return; } + if (req.url === CLI_RPC_PATH && req.method === 'POST') { + if (!handleCliCommand) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'cli rpc not configured' })); + return; + } + if (!isLocalRequest(req)) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'cli rpc is local-only' })); + return; + } + + try { + const bodyBuf = await collectRequestBody(req, 1_000_000); + if (!bodyBuf) { + res.writeHead(413, { 'Content-Type': 'text/plain' }); + res.end('Payload Too Large'); + return; + } + + const commandRequest = JSON.parse(new TextDecoder().decode(bodyBuf)) as CliRpcRequest; + const commandResponse = await handleCliCommand(commandRequest); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(commandResponse)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return; + } + // Token generation endpoint — the credential helper calls this instead // of opening the agent's LevelDB (which would deadlock while the // daemon holds the lock). @@ -195,6 +263,9 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer try { // Build a Request object from the Node.js IncomingMessage. const url = `http://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`; + if (process.env.GITD_DEBUG === '1') { + console.error(`[http] ${req.method ?? 'GET'} ${req.url ?? '/'} auth=${req.headers.authorization ? 'present' : 'missing'}`); + } const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value) { @@ -223,10 +294,16 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer const response = await fetchHandler(request); // Write response back to Node.js ServerResponse. + res.shouldKeepAlive = false; const responseHeaders: Record<string, string> = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); res.writeHead(response.status, responseHeaders); if (response.body) { + if (response.headers.has('content-length')) { + res.end(Buffer.from(await response.arrayBuffer())); + return; + } + const reader = response.body.getReader(); try { while (true) { @@ -284,6 +361,13 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer // Helpers // --------------------------------------------------------------------------- +function isLocalRequest(req: IncomingMessage): boolean { + const address = req.socket.remoteAddress; + return address === '127.0.0.1' + || address === '::1' + || address === '::ffff:127.0.0.1'; +} + /** * Collect the full request body from a Node.js IncomingMessage. * Returns `null` if the body exceeds `maxBytes`. diff --git a/src/git-server/verify.ts b/src/git-server/verify.ts index 73c6bff..a2baacb 100644 --- a/src/git-server/verify.ts +++ b/src/git-server/verify.ts @@ -65,9 +65,15 @@ export function createDidSignatureVerifier(options: DidSignatureVerifierOptions try { const localDocument = localDocuments.get(did); if (localDocument) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[auth] verifying ${did} with local DID document`); + } return verifyWithDocument(localDocument, payload, signature); } + if (process.env.GITD_DEBUG === '1') { + console.error(`[auth] resolving ${did} for signature verification`); + } const { didDocument, didResolutionMetadata } = await Promise.race([ getResolver().resolve(did), new Promise<never>((_, reject) => @@ -80,7 +86,10 @@ export function createDidSignatureVerifier(options: DidSignatureVerifierOptions } return verifyWithDocument(didDocument, payload, signature); - } catch { + } catch (err) { + if (process.env.GITD_DEBUG === '1') { + console.error(`[auth] signature verification error for ${did}: ${(err as Error).message}`); + } return false; } }; diff --git a/src/index.ts b/src/index.ts index bad7b2b..2c86180 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from './repo.js'; export * from './refs.js'; +export * from './branch-state.js'; export * from './issues.js'; export * from './patches.js'; export * from './ci.js'; diff --git a/src/issues.ts b/src/issues.ts index 0622e72..a9d8a34 100644 --- a/src/issues.ts +++ b/src/issues.ts @@ -2,9 +2,9 @@ * Forge Issues Protocol — issue tracking with comments, labels, and status changes. * * Composes with the Forge Repo protocol via `uses` for role-based authorization. - * Only users with a contributor, triager, or maintainer role can create issues - * directly on the repo owner's DWN. External issue reports live on the reporter's - * own DWN and are surfaced via indexers. + * Only users with a contributor, moderator, legacy triager, or maintainer role + * can create issues directly on the repo owner's DWN. External issue reports + * live on the reporter's own DWN and are surfaced via indexers. * * @module */ @@ -148,6 +148,7 @@ export const ForgeIssuesDefinition = { { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'read', 'co-update'] }, { role: 'repo:repo/triager', can: ['create', 'read', 'co-update'] }, { who: 'author', of: 'repo/issue', can: ['create', 'update'] }, ], @@ -184,6 +185,7 @@ export const ForgeIssuesDefinition = { { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'read'] }, { role: 'repo:repo/triager', can: ['create', 'read'] }, { who: 'author', of: 'repo/issue/comment', can: ['create', 'update', 'delete'] }, ], @@ -206,6 +208,7 @@ export const ForgeIssuesDefinition = { $actions : [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'delete'] }, ], $tags: { @@ -221,6 +224,7 @@ export const ForgeIssuesDefinition = { $actions : [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create'] }, + { role: 'repo:repo/moderator', can: ['create'] }, { role: 'repo:repo/triager', can: ['create'] }, { who: 'author', of: 'repo/issue', can: ['create'] }, ], @@ -236,6 +240,7 @@ export const ForgeIssuesDefinition = { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'delete'] }, ], $tags: { @@ -250,6 +255,7 @@ export const ForgeIssuesDefinition = { $actions : [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'delete'] }, ], $tags: { @@ -263,6 +269,7 @@ export const ForgeIssuesDefinition = { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'update', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'update', 'delete'] }, ], $tags: { @@ -276,6 +283,7 @@ export const ForgeIssuesDefinition = { $actions: [ { role: 'repo:repo/contributor', can: ['read'] }, { role: 'repo:repo/maintainer', can: ['create', 'update', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'update', 'delete'] }, { role: 'repo:repo/triager', can: ['create', 'update', 'delete'] }, ], $tags: { diff --git a/src/patches.ts b/src/patches.ts index 3e4c982..4098548 100644 --- a/src/patches.ts +++ b/src/patches.ts @@ -179,6 +179,7 @@ export const ForgePatchesDefinition = { { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read'] }, + { role: 'repo:repo/moderator', can: ['create', 'read'] }, ], $tags: { $requiredTags : ['verdict'], @@ -192,6 +193,7 @@ export const ForgePatchesDefinition = { { who: 'anyone', can: ['read'] }, { role: 'repo:repo/contributor', can: ['create', 'read'] }, { role: 'repo:repo/maintainer', can: ['create', 'read', 'update', 'delete'] }, + { role: 'repo:repo/moderator', can: ['create', 'read'] }, { who: 'author', of: 'repo/patch/review/reviewComment', can: ['create', 'update', 'delete'] }, ], $tags: { diff --git a/src/profiles/config.ts b/src/profiles/config.ts index 3f63cbf..8ac49ac 100644 --- a/src/profiles/config.ts +++ b/src/profiles/config.ts @@ -135,7 +135,7 @@ export function listProfiles(): string[] { * * Precedence (highest to lowest): * 1. `--profile <name>` flag (passed as `flagProfile`) - * 2. `ENBOX_PROFILE` environment variable + * 2. `GITD_PROFILE` or `ENBOX_PROFILE` environment variable * 3. `.git/config` → `[enbox] profile = <name>` * 4. `~/.enbox/config.json` → `defaultProfile` * 5. First (and only) profile, if exactly one exists @@ -146,8 +146,9 @@ export function resolveProfile(flagProfile?: string): string | null { // 1. Explicit flag. if (flagProfile) { return flagProfile; } - // 2. Environment variable. - const envProfile = process.env.ENBOX_PROFILE; + // 2. Environment variable. GITD_PROFILE matches gitd docs; ENBOX_PROFILE + // remains accepted for compatibility with existing Enbox tooling. + const envProfile = process.env.GITD_PROFILE ?? process.env.ENBOX_PROFILE; if (envProfile) { return envProfile; } // 3. Per-repo git config. diff --git a/src/refs.ts b/src/refs.ts index d351bdd..4c35698 100644 --- a/src/refs.ts +++ b/src/refs.ts @@ -1,13 +1,13 @@ /** - * Forge Git Refs Protocol — mirrors git branch/tag refs as DWN records. + * Forge Git Refs Protocol — branch-owned git state as DWN records. * - * This protocol provides a DWN-native view of git ref pointers (branches, tags) - * so that other DWN participants can subscribe to ref changes via - * `RecordsSubscribe`. The actual git object data stays in git — these records - * only track the ref name → commit SHA mapping. + * This protocol provides a DWN-native branch log for repo-level git state. + * Contributors own and compact their own branch state, while maintainers own + * protected/canonical branches such as `refs/heads/main`. * - * Uses `$ref` to compose with the Repo protocol for role-based authorization: - * only maintainers can update refs in the repo owner's DWN. + * The legacy `repo/ref` record remains as a mutable mirror for existing shim + * and migration code. New helper code should write branch records plus + * `$squash`-enabled `repo/branch/state` checkpoints. * * @module */ @@ -38,13 +38,64 @@ export type GitRefData = { message?: string; }; +/** Branch ownership class. */ +export type BranchKind = 'contributor' | 'protected' | 'shared'; + +/** Stable metadata for a branch log. The target lives in branch state records. */ +export type BranchData = { + /** Full branch ref name, e.g. `refs/heads/main`. */ + refName : string; + + /** DID that owns this branch log. */ + ownerDid : string; + + /** Whether this branch is contributor-owned, protected, or shared. */ + kind : BranchKind; + + /** Optional ISO timestamp for local helper reconciliation. */ + createdAt? : string; +}; + +/** Append-only branch ref update. */ +export type BranchRefUpdateData = { + kind : 'refUpdate'; + refName : string; + oldTarget? : string | null; + newTarget : string | null; + actorDid : string; + bundleRecordId? : string; + force? : boolean; + nonce? : string; + createdAt : string; +}; + +/** Authoritative branch checkpoint, usually replacing prior sibling state via `$squash`. */ +export type BranchCheckpointData = { + kind : 'checkpoint'; + refName : string; + target : string | null; + actorDid : string; + acceptedStateRecordId? : string; + acceptedAt : string; + createdAt : string; +}; + +/** Branch state records are either deltas or compacting checkpoints. */ +export type BranchStateData = BranchRefUpdateData | BranchCheckpointData; + +/** Raw git bundle bytes associated with a branch update/checkpoint. */ +export type BranchBundleData = Uint8Array; + // --------------------------------------------------------------------------- // Schema map // --------------------------------------------------------------------------- /** Maps protocol type names to their TypeScript data shapes. */ export type ForgeRefsSchemaMap = { - ref: GitRefData; + ref : GitRefData; + branch : BranchData; + state : BranchStateData; + bundle : BranchBundleData; }; // --------------------------------------------------------------------------- @@ -62,6 +113,17 @@ export const ForgeRefsDefinition = { schema : 'https://enbox.org/schemas/forge/git-ref', dataFormats : ['application/json'], }, + branch: { + schema : 'https://enbox.org/schemas/forge/branch', + dataFormats : ['application/json'], + }, + state: { + schema : 'https://enbox.org/schemas/forge/branch-state', + dataFormats : ['application/json'], + }, + bundle: { + dataFormats: ['application/x-git-bundle'], + }, }, structure: { repo: { @@ -79,6 +141,59 @@ export const ForgeRefsDefinition = { target : { type: 'string' }, }, }, + branch: { + $actions: [ + { who: 'anyone', can: ['read'] }, + { role: 'repo:repo/contributor', can: ['create', 'read'] }, + { role: 'repo:repo/maintainer', can: ['create', 'read', 'delete'] }, + ], + $tags: { + $requiredTags : ['refName', 'ownerDid', 'kind'], + $allowUndefinedTags : false, + refName : { type: 'string' }, + ownerDid : { type: 'string' }, + kind : { type: 'string', enum: ['contributor', 'protected', 'shared'] }, + }, + + state: { + $squash : true, + $actions : [ + { who: 'anyone', can: ['read'] }, + { who: 'author', of: 'repo/branch', can: ['create', 'squash'] }, + { role: 'repo:repo/maintainer', can: ['create', 'squash'] }, + ], + $tags: { + $requiredTags : ['kind', 'refName'], + $allowUndefinedTags : false, + kind : { type: 'string', enum: ['refUpdate', 'checkpoint'] }, + refName : { type: 'string' }, + oldTarget : { type: 'string' }, + newTarget : { type: 'string' }, + target : { type: 'string' }, + actorDid : { type: 'string' }, + bundleRecordId : { type: 'string' }, + acceptedStateRecordId : { type: 'string' }, + }, + }, + + bundle: { + $squash : true, + $actions : [ + { who: 'anyone', can: ['read'] }, + { who: 'author', of: 'repo/branch', can: ['create', 'squash'] }, + { role: 'repo:repo/maintainer', can: ['create', 'squash'] }, + ], + $tags: { + $requiredTags : ['kind', 'refName', 'tipCommit'], + $allowUndefinedTags : false, + kind : { type: 'string', enum: ['incremental', 'checkpoint'] }, + refName : { type: 'string' }, + tipCommit : { type: 'string' }, + baseCommit : { type: 'string' }, + size : { type: 'integer' }, + }, + }, + }, }, }, } as const satisfies ProtocolDefinition; diff --git a/src/repo.ts b/src/repo.ts index c28ef7e..80dcd42 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -1,10 +1,10 @@ /** * Forge Repository Protocol — foundational protocol for repository management. * - * Defines repository metadata, collaborator roles (maintainer, triager, - * contributor, viewer), and repo-level resources (readme, license, topics, settings, - * webhooks). Other forge protocols compose with this via `uses` to leverage - * role-based authorization. + * Defines repository metadata, collaborator roles (maintainer, moderator, + * contributor, viewer, and legacy triager), and repo-level resources (readme, + * license, topics, settings, webhooks). Other forge protocols compose with + * this via `uses` to leverage role-based authorization. * * @module */ @@ -44,7 +44,7 @@ export type RepoData = { forkedFromRecordId? : string; }; -/** Data shape for a collaborator role record (maintainer, triager, contributor, viewer). */ +/** Data shape for a collaborator role record. */ export type CollaboratorData = { did : string; alias? : string; @@ -67,6 +67,31 @@ export type SubmissionDecisionData = { decidedAt : string; }; +/** Immutable repo-level moderation event. */ +export type ModerationEventData = { + action : + | 'block' + | 'unblock' + | 'lock' + | 'unlock' + | 'hideComment' + | 'unhideComment' + | 'deleteComment' + | 'report' + | 'resolveReport' + | 'dismissReport' + | 'interactionLimit'; + actorDid : string; + targetDid? : string; + targetKind? : 'repo' | 'issue' | 'pr' | 'issueComment' | 'prComment' | 'report'; + targetId? : string; + reason? : string; + reportStatus? : 'open' | 'resolved' | 'dismissed'; + interactionLimit? : 'off' | 'contributors' | 'collaborators'; + duration? : string; + createdAt : string; +}; + export type RepositoryRulesetStateData = { id: number; name: string; @@ -551,11 +576,14 @@ export type ForgeRepoSchemaMap = { readme : string; license : string; maintainer : CollaboratorData; + moderator : CollaboratorData; + /** Compatibility role retained for existing records and GitHub-shim behavior. */ triager : CollaboratorData; contributor : CollaboratorData; viewer : CollaboratorData; topic : TopicData; submissionDecision : SubmissionDecisionData; + moderationEvent : ModerationEventData; webhook : WebhookData; }; @@ -585,6 +613,10 @@ export const ForgeRepoDefinition = { schema : 'https://enbox.org/schemas/forge/collaborator', dataFormats : ['application/json'], }, + moderator: { + schema : 'https://enbox.org/schemas/forge/collaborator', + dataFormats : ['application/json'], + }, triager: { schema : 'https://enbox.org/schemas/forge/collaborator', dataFormats : ['application/json'], @@ -605,6 +637,10 @@ export const ForgeRepoDefinition = { schema : 'https://enbox.org/schemas/forge/submission-decision', dataFormats : ['application/json'], }, + moderationEvent: { + schema : 'https://enbox.org/schemas/forge/moderation-event', + dataFormats : ['application/json'], + }, bundle: { dataFormats: ['application/x-git-bundle'], }, @@ -640,6 +676,16 @@ export const ForgeRepoDefinition = { }, }, + moderator: { + $role : true, + $actions : [{ who: 'anyone', can: ['read'] }], + $tags : { + $requiredTags : ['did'], + $allowUndefinedTags : false, + did : { type: 'string' }, + }, + }, + triager: { $role : true, $actions : [{ who: 'anyone', can: ['read'] }], @@ -728,6 +774,41 @@ export const ForgeRepoDefinition = { }, }, + moderationEvent: { + $immutable : true, + $actions : [ + { who: 'anyone', can: ['read'] }, + { role: 'repo/maintainer', can: ['create'] }, + { role: 'repo/moderator', can: ['create'] }, + ], + $tags: { + $requiredTags : ['action', 'actorDid'], + $allowUndefinedTags : false, + action : { + type : 'string', + enum : [ + 'block', + 'unblock', + 'lock', + 'unlock', + 'hideComment', + 'unhideComment', + 'deleteComment', + 'report', + 'resolveReport', + 'dismissReport', + 'interactionLimit', + ], + }, + actorDid : { type: 'string' }, + targetDid : { type: 'string' }, + targetKind : { type: 'string', enum: ['repo', 'issue', 'pr', 'issueComment', 'prComment', 'report'] }, + targetId : { type: 'string' }, + reportStatus : { type: 'string', enum: ['open', 'resolved', 'dismissed'] }, + interactionLimit : { type: 'string', enum: ['off', 'contributors', 'collaborators'] }, + }, + }, + settings: { $recordLimit: { max: 1, strategy: 'reject' }, // Owner-only: no $actions = only the DWN tenant can read/write diff --git a/tests/branch-state.spec.ts b/tests/branch-state.spec.ts new file mode 100644 index 0000000..398d040 --- /dev/null +++ b/tests/branch-state.spec.ts @@ -0,0 +1,196 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'bun:test'; + +import { + branchDataForRef, + branchOwnerHash, + canSquashBranch, + contributorBranchPrefix, + isContributorBranchRef, + reduceBranchState, + validateBranchRecord, +} from '../src/branch-state.js'; +import type { BranchData } from '../src/refs.js'; +import type { BranchStateRecord } from '../src/branch-state.js'; + +describe('branch state helpers', () => { + const aliceDid = 'did:dht:alice'; + const bobDid = 'did:dht:bob'; + + it('derives contributor branch namespaces from the owner DID', () => { + const expectedHash = createHash('sha256').update(aliceDid).digest('hex').slice(0, 16); + expect(branchOwnerHash(aliceDid)).toBe(expectedHash); + expect(contributorBranchPrefix(aliceDid)).toBe(`refs/heads/users/${expectedHash}/`); + expect(isContributorBranchRef(`${contributorBranchPrefix(aliceDid)}feature`, aliceDid)).toBe(true); + expect(isContributorBranchRef('refs/heads/main', aliceDid)).toBe(false); + expect(isContributorBranchRef(`${contributorBranchPrefix(aliceDid)}feature`, bobDid)).toBe(false); + }); + + it('validates contributor branches against the owner namespace', () => { + const valid: BranchData = { + refName : `${contributorBranchPrefix(aliceDid)}feature`, + ownerDid : aliceDid, + kind : 'contributor', + }; + const invalid: BranchData = { + refName : 'refs/heads/feature', + ownerDid : aliceDid, + kind : 'contributor', + }; + + expect(validateBranchRecord(valid).ok).toBe(true); + expect(validateBranchRecord(invalid)).toEqual({ + ok : false, + reason : 'contributor branch refName must be under the owner DID namespace', + }); + }); + + it('classifies branch records from refs', () => { + expect(branchDataForRef('refs/heads/main', aliceDid, '2026-01-01T00:00:00.000Z')).toEqual({ + refName : 'refs/heads/main', + ownerDid : aliceDid, + kind : 'protected', + createdAt : '2026-01-01T00:00:00.000Z', + }); + expect(branchDataForRef(`${contributorBranchPrefix(aliceDid)}topic`, aliceDid).kind).toBe('contributor'); + expect(branchDataForRef('refs/heads/feature', aliceDid).kind).toBe('shared'); + }); + + it('keeps protected branches under repo-owner control', () => { + expect(validateBranchRecord({ + refName : 'refs/heads/main', + ownerDid : aliceDid, + kind : 'protected', + }, { repoOwnerDid: aliceDid }).ok).toBe(true); + + expect(validateBranchRecord({ + refName : 'refs/heads/main', + ownerDid : bobDid, + kind : 'protected', + }, { repoOwnerDid: aliceDid })).toEqual({ + ok : false, + reason : 'protected branch ownerDid must match the repo owner DID', + }); + + expect(validateBranchRecord({ + refName : 'refs/heads/main', + ownerDid : aliceDid, + kind : 'contributor', + }).ok).toBe(false); + }); + + it('allows contributors to squash only their own branch namespace', () => { + const bobBranch: BranchData = { + refName : `${contributorBranchPrefix(bobDid)}feature`, + ownerDid : bobDid, + kind : 'contributor', + }; + const aliceBranch: BranchData = { + refName : `${contributorBranchPrefix(aliceDid)}feature`, + ownerDid : aliceDid, + kind : 'contributor', + }; + + expect(canSquashBranch(bobDid, 'contributor', bobBranch)).toBe(true); + expect(canSquashBranch(aliceDid, 'contributor', bobBranch)).toBe(false); + expect(canSquashBranch(bobDid, 'contributor', aliceBranch)).toBe(false); + expect(canSquashBranch(bobDid, 'moderator', bobBranch)).toBe(false); + }); + + it('reserves protected/shared branch squash for maintainers and owners', () => { + const protectedBranch: BranchData = { + refName : 'refs/heads/main', + ownerDid : aliceDid, + kind : 'protected', + }; + const sharedBranch: BranchData = { + refName : 'refs/heads/release', + ownerDid : aliceDid, + kind : 'shared', + }; + + expect(canSquashBranch(aliceDid, 'owner', protectedBranch)).toBe(true); + expect(canSquashBranch(bobDid, 'maintainer', protectedBranch)).toBe(true); + expect(canSquashBranch(bobDid, 'contributor', protectedBranch)).toBe(false); + expect(canSquashBranch(bobDid, 'moderator', protectedBranch)).toBe(false); + expect(canSquashBranch(bobDid, 'maintainer', sharedBranch)).toBe(true); + expect(canSquashBranch(bobDid, 'contributor', sharedBranch)).toBe(false); + }); + + it('reduces sequential branch updates into the final target', () => { + const refName = `${contributorBranchPrefix(aliceDid)}feature`; + const records: BranchStateRecord[] = [ + update('r1', refName, null, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '2026-01-01T00:00:00.000Z'), + update('r2', refName, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '2026-01-01T00:01:00.000Z'), + ]; + + const result = reduceBranchState(refName, records); + expect(result.target).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect(result.accepted.map((entry) => entry.record.recordId)).toEqual(['r1', 'r2']); + expect(result.rejected).toEqual([]); + }); + + it('rejects stale racing updates with the same old target', () => { + const refName = `${contributorBranchPrefix(aliceDid)}feature`; + const records: BranchStateRecord[] = [ + update('r1', refName, null, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '2026-01-01T00:00:00.000Z'), + update('r2', refName, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '2026-01-01T00:01:00.000Z'), + update('r3', refName, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'cccccccccccccccccccccccccccccccccccccccc', '2026-01-01T00:02:00.000Z'), + ]; + + const result = reduceBranchState(refName, records); + expect(result.target).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect(result.accepted.map((entry) => entry.record.recordId)).toEqual(['r1', 'r2']); + expect(result.rejected.map((entry) => entry.record.recordId)).toEqual(['r3']); + expect(result.rejected[0].reason).toContain('stale ref update'); + }); + + it('uses the latest checkpoint as the authoritative base', () => { + const refName = 'refs/heads/main'; + const records: BranchStateRecord[] = [ + update('old', refName, null, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '2026-01-01T00:00:00.000Z'), + checkpoint('cp', refName, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '2026-01-01T00:01:00.000Z'), + update('next', refName, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccccccccccccccccc', '2026-01-01T00:02:00.000Z'), + ]; + + const result = reduceBranchState(refName, records); + expect(result.target).toBe('cccccccccccccccccccccccccccccccccccccccc'); + expect(result.accepted.map((entry) => entry.record.recordId)).toEqual(['cp', 'next']); + expect(result.compactedRecordIds).toEqual(['old']); + }); +}); + +function update( + recordId: string, + refName: string, + oldTarget: string | null, + newTarget: string | null, + createdAt: string, +): BranchStateRecord { + return { + recordId, + data: { + kind: 'refUpdate', + refName, + oldTarget, + newTarget, + actorDid: 'did:dht:alice', + createdAt, + }, + }; +} + +function checkpoint(recordId: string, refName: string, target: string | null, createdAt: string): BranchStateRecord { + return { + recordId, + data: { + kind: 'checkpoint', + refName, + target, + actorDid: 'did:dht:alice', + acceptedAt: createdAt, + createdAt, + }, + }; +} diff --git a/tests/bundle-restore.spec.ts b/tests/bundle-restore.spec.ts index a4b114f..b1fa6e8 100644 --- a/tests/bundle-restore.spec.ts +++ b/tests/bundle-restore.spec.ts @@ -9,16 +9,18 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; -import { createBundleSyncer } from '../src/git-server/bundle-sync.js'; +import { branchDataForRef, branchOwnerHash } from '../src/branch-state.js'; +import { createBranchBundle, createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { GitBackend } from '../src/git-server/git-backend.js'; import { restoreFromBundles } from '../src/git-server/bundle-restore.js'; +import { ForgeRefsProtocol } from '../src/refs.js'; import { ForgeRepoProtocol } from '../src/repo.js'; const exec = promisify(execCb); @@ -31,6 +33,8 @@ const DATA_PATH = '__TESTDATA__/bundle-restore-agent'; const REPOS_PATH = '__TESTDATA__/bundle-restore-repos'; const WORK_PATH = '__TESTDATA__/bundle-restore-work'; const RESTORE_PATH = '__TESTDATA__/bundle-restore-output'; +const CONTRIBUTOR_DID = 'did:dht:restore-contributor'; +const CONTRIBUTOR_REF = `refs/heads/users/${branchOwnerHash(CONTRIBUTOR_DID)}/feature`; // --------------------------------------------------------------------------- // Test suite @@ -40,6 +44,7 @@ describe('restoreFromBundles', () => { let repoPath: string; let repoContextId: string; let repoHandle: ReturnType<InstanceType<typeof Enbox>['using']>; + let refsHandle: ReturnType<InstanceType<typeof Enbox>['using']>; beforeAll(async () => { rmSync(DATA_PATH, { recursive: true, force: true }); @@ -61,8 +66,10 @@ describe('restoreFromBundles', () => { const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); repoHandle = enbox.using(ForgeRepoProtocol); + refsHandle = enbox.using(ForgeRefsProtocol); // Skip encryption: true — the test DID (did:jwk Ed25519) lacks X25519. await repoHandle.configure(); + await refsHandle.configure(); // Create a repo record. const { record } = await repoHandle.records.create('repo', { @@ -99,6 +106,45 @@ describe('restoreFromBundles', () => { await exec('git push origin main', { cwd: WORK_PATH }); await syncer('did:dht:restoretest', 'restore-test', repoPath); + + // Create a contributor branch after repo-wide bundles have been synced. + // Only a branch-scoped refs bundle is written for this branch, so restore + // must replay repo/branch/bundle records to recover it from an empty cache. + await exec('git checkout -b restore-contributor-feature main', { cwd: WORK_PATH }); + await exec('echo "contributor branch" > contributor.txt', { cwd: WORK_PATH }); + await exec('git add contributor.txt', { cwd: WORK_PATH }); + await exec('git commit -m "contributor branch commit"', { cwd: WORK_PATH }); + await exec(`git push origin HEAD:"${CONTRIBUTOR_REF}"`, { cwd: WORK_PATH }); + + const branchData = branchDataForRef(CONTRIBUTOR_REF, CONTRIBUTOR_DID); + const { record: branchRecord } = await (refsHandle as any).records.create('repo/branch', { + data : branchData, + tags : { + refName : branchData.refName, + ownerDid : branchData.ownerDid, + kind : branchData.kind, + }, + parentContextId: repoContextId, + }); + + const bundleInfo = await createBranchBundle(repoPath, CONTRIBUTOR_REF); + try { + const bundleBytes = new Uint8Array(readFileSync(bundleInfo.path)); + await (refsHandle as any).records.create('repo/branch/bundle', { + data : bundleBytes, + dataFormat : 'application/x-git-bundle', + tags : { + kind : 'checkpoint', + refName : CONTRIBUTOR_REF, + tipCommit : bundleInfo.tipCommit, + size : bundleInfo.size, + }, + parentContextId : branchRecord.contextId, + squash : true, + }); + } finally { + rmSync(bundleInfo.path, { force: true }); + } }, 30000); afterAll(() => { @@ -164,6 +210,30 @@ describe('restoreFromBundles', () => { rmSync(clonePath, { recursive: true, force: true }); }); + it('should restore contributor branches from branch-scoped bundles', async () => { + const restoredRepoPath = `${RESTORE_PATH}/restored-branches.git`; + + const result = await restoreFromBundles({ + repo : repoHandle as any, + refs : refsHandle as any, + repoPath : restoredRepoPath, + repoContextId : repoContextId, + }); + + expect(result.success).toBe(true); + expect(result.bundlesApplied).toBeGreaterThanOrEqual(3); + + const { stdout: showRef } = await exec(`git show-ref --verify "${CONTRIBUTOR_REF}"`, { + cwd: restoredRepoPath, + }); + expect(showRef).toContain(CONTRIBUTOR_REF); + + const { stdout: log } = await exec(`git log --oneline "${CONTRIBUTOR_REF}"`, { + cwd: restoredRepoPath, + }); + expect(log).toContain('contributor branch commit'); + }); + it('should return failure when no bundles exist', async () => { // Create a fresh Enbox agent with no bundle records. const freshDataPath = `${DATA_PATH}-fresh`; @@ -202,6 +272,32 @@ describe('restoreFromBundles', () => { rmSync(freshDataPath, { recursive: true, force: true }); }, 30_000); + it('should query a remote DWN when a from DID is provided', async () => { + const remoteDid = 'did:dht:remoteowner'; + const queries: Array<{ path: string; options: any }> = []; + + const result = await restoreFromBundles({ + repo: { + records: { + query: async (path: string, options: any) => { + queries.push({ path, options }); + return { records: [] }; + }, + }, + } as any, + from : remoteDid, + repoPath : `${RESTORE_PATH}/remote-should-not-exist.git`, + repoContextId : 'remote-context', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No full bundle found'); + expect(queries).toHaveLength(1); + expect(queries[0].path).toBe('repo/bundle'); + expect(queries[0].options.from).toBe(remoteDid); + expect(queries[0].options.filter.contextId).toBe('remote-context'); + }); + it('should restore the tip commit matching the original', async () => { const restoredRepoPath = `${RESTORE_PATH}/restored-tip.git`; diff --git a/tests/bundle-sync.spec.ts b/tests/bundle-sync.spec.ts index 683cc4b..9be85fd 100644 --- a/tests/bundle-sync.spec.ts +++ b/tests/bundle-sync.spec.ts @@ -17,6 +17,7 @@ import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; import { ForgeRepoProtocol } from '../src/repo.js'; +import { ForgeRefsProtocol } from '../src/refs.js'; import { GitBackend } from '../src/git-server/git-backend.js'; import { createBundleSyncer, createFullBundle, createIncrementalBundle } from '../src/git-server/bundle-sync.js'; @@ -173,6 +174,7 @@ describe('createBundleSyncer (DWN integration)', () => { let repoPath: string; let repoContextId: string; let repoHandle: ReturnType<InstanceType<typeof Enbox>['using']>; + let refsHandle: ReturnType<InstanceType<typeof Enbox>['using']>; beforeAll(async () => { rmSync(DATA_PATH, { recursive: true, force: true }); @@ -193,10 +195,12 @@ describe('createBundleSyncer (DWN integration)', () => { const enbox = new Enbox({ agent, connectedDid: identity.did.uri }); repoHandle = enbox.using(ForgeRepoProtocol); + refsHandle = enbox.using(ForgeRefsProtocol); // Skip encryption: true — the test DID (did:jwk Ed25519) lacks X25519. // Production uses encryption: true for webhook support; bundle encryption // is tested separately with an appropriate DID. await repoHandle.configure(); + await refsHandle.configure(); // Create a repo record. const { record } = await repoHandle.records.create('repo', { @@ -228,6 +232,7 @@ describe('createBundleSyncer (DWN integration)', () => { it('should create a full bundle record on first sync', async () => { const syncer = createBundleSyncer({ repo : repoHandle as any, + refs : refsHandle as any, repoContextId : repoContextId, visibility : 'public', }); @@ -250,6 +255,35 @@ describe('createBundleSyncer (DWN integration)', () => { expect(record.dataFormat).toBe('application/x-git-bundle'); }); + it('should create a branch-scoped checkpoint bundle on sync', async () => { + const { records: branchRecords } = await (refsHandle as any).records.query('repo/branch', { + filter: { contextId: repoContextId }, + }); + expect(branchRecords.length).toBeGreaterThanOrEqual(1); + + const branches = await Promise.all(branchRecords.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const mainBranch = branches.find((entry) => entry.data.refName === 'refs/heads/main'); + expect(mainBranch).toBeDefined(); + expect(mainBranch!.data.kind).toBe('protected'); + + const { records: bundleRecords } = await (refsHandle as any).records.query('repo/branch/bundle', { + filter: { contextId: mainBranch!.record.contextId }, + }); + expect(bundleRecords).toHaveLength(1); + expect(bundleRecords[0].tags.kind).toBe('checkpoint'); + expect(bundleRecords[0].tags.refName).toBe('refs/heads/main'); + expect(bundleRecords[0].tags.tipCommit).toMatch(/^[0-9a-f]{40}$/); + expect(bundleRecords[0].dataFormat).toBe('application/x-git-bundle'); + + const blob = await bundleRecords[0].data.blob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const header = new TextDecoder().decode(bytes.slice(0, 20)); + expect(header).toMatch(/^# v[23] git bundle/); + }); + it('should create incremental bundles on subsequent syncs', async () => { // Push another commit. await exec('echo "update" >> README.md', { cwd: WORK_PATH }); @@ -259,6 +293,7 @@ describe('createBundleSyncer (DWN integration)', () => { const syncer = createBundleSyncer({ repo : repoHandle as any, + refs : refsHandle as any, repoContextId : repoContextId, visibility : 'public', }); @@ -284,6 +319,7 @@ describe('createBundleSyncer (DWN integration)', () => { // Use a low threshold so we trigger squash quickly. const syncer = createBundleSyncer({ repo : repoHandle as any, + refs : refsHandle as any, repoContextId : repoContextId, visibility : 'public', squashThreshold : 2, diff --git a/tests/cli.spec.ts b/tests/cli.spec.ts index a4114c3..e84c6c2 100644 --- a/tests/cli.spec.ts +++ b/tests/cli.spec.ts @@ -31,6 +31,7 @@ import { ForgeReleasesProtocol } from '../src/releases.js'; import { ForgeRepoProtocol } from '../src/repo.js'; import { ForgeSocialProtocol } from '../src/social.js'; import { ForgeWikiProtocol } from '../src/wiki.js'; +import { shortId } from '../src/github-shim/helpers.js'; // --------------------------------------------------------------------------- // Constants @@ -181,6 +182,123 @@ function withExternalPatchRecords( } as unknown as AgentContext; } +type SentRecord = { record: any; targetDid: string }; +type CapturedCreate = { path: string; options: any }; + +function withRemoteOwnerRecords( + ctx: AgentContext, + ownerDid: string, + repoRecord: any, + role: 'contributor' | 'maintainer' | 'moderator' = 'contributor', + sentRecords: SentRecord[] = [], + capturedCreates: CapturedCreate[] = [], +): AgentContext { + const remoteRecordsForPath = (path: string): any[] => + sentRecords + .map(entry => entry.record) + .filter(record => recordProtocolPath(record) === path); + + const remoteQuery = async (protocol: 'issues' | 'patches', path: string, options?: any): Promise<{ records: any[] }> => { + if (options?.from === ownerDid) { + if (protocol === 'issues' && path === 'repo/issue') { + return { records: remoteRecordsForPath(path) }; + } + if (protocol === 'issues' && path === 'repo/issue/comment') { + return { records: matchingContext(remoteRecordsForPath(path), options) }; + } + if (protocol === 'patches' && path === 'repo/patch') { + return { records: remoteRecordsForPath(path) }; + } + if (protocol === 'patches' && path === 'repo/patch/review') { + return { records: matchingContext(remoteRecordsForPath(path), options) }; + } + if (protocol === 'patches' && path === 'repo/patch/revision') { + return { records: matchingContext(remoteRecordsForPath(path), options) }; + } + if (protocol === 'patches' && path === 'repo/patch/revision/revisionBundle') { + return { records: matchingContext(remoteRecordsForPath(path), options) }; + } + } + + const api = protocol === 'issues' ? ctx.issues : ctx.patches; + return (api.records.query as any)(path, options); + }; + + return { + ...ctx, + sendRecord: async (record: any, targetDid: string): Promise<void> => { + sentRecords.push({ record, targetDid }); + }, + repo: { + ...ctx.repo, + records: { + ...ctx.repo.records, + query: async (path: string, options?: any) => { + if (options?.from === ownerDid) { + if (path === 'repo') { + return { records: [repoRecord] }; + } + if (path === 'repo/moderationEvent') { + return { records: remoteRecordsForPath(path) }; + } + if (path === `repo/${role}` && options?.filter?.tags?.did === ctx.did) { + return { records: [fakeJsonRecord(`remote-${role}-role`, repoRecord.contextId, { did: ctx.did }, { did: ctx.did })] }; + } + if (path.startsWith('repo/')) { + return { records: [] }; + } + } + return (ctx.repo.records.query as any)(path, options); + }, + }, + }, + issues: { + ...ctx.issues, + records: { + ...ctx.issues.records, + create: async (path: string, options?: any) => { + capturedCreates.push({ path, options }); + return (ctx.issues.records.create as any)(path, options); + }, + query: async (path: string, options?: any) => remoteQuery('issues', path, options), + }, + }, + patches: { + ...ctx.patches, + records: { + ...ctx.patches.records, + create: async (path: string, options?: any) => { + capturedCreates.push({ path, options }); + return (ctx.patches.records.create as any)(path, options); + }, + query: async (path: string, options?: any) => remoteQuery('patches', path, options), + }, + }, + } as unknown as AgentContext; +} + +function recordProtocolPath(record: any): string | undefined { + return record.protocolPath ?? record.toJSON?.().protocolPath; +} + +function moderationRecord( + id: string, + contextId: string, + tags: Record<string, string>, + dateCreated: string, +): any { + return { + ...fakeJsonRecord( + id, + contextId, + { actorDid: 'did:jwk:moderator', createdAt: dateCreated, ...tags }, + { actorDid: 'did:jwk:moderator', ...tags }, + ), + dateCreated, + protocolPath: 'repo/moderationEvent', + }; +} + // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- @@ -359,6 +477,7 @@ describe('gitd CLI commands', () => { expect(records.length).toBe(1); const tags = records[0].tags as Record<string, string>; expect(tags.visibility).toBe('public'); + expect(records[0].published).toBe(true); }); it('should create a private repo with --private flag', async () => { @@ -374,6 +493,7 @@ describe('gitd CLI commands', () => { expect(records.length).toBe(1); const tags = records[0].tags as Record<string, string>; expect(tags.visibility).toBe('private'); + expect(records[0].published).toBe(false); }); }); @@ -645,6 +765,138 @@ describe('gitd CLI commands', () => { expect(logs.some((l) => l.includes('Added contributor'))).toBe(true); }); + it('should add a contributor with the dedicated command', async () => { + const { repoCommand } = await import('../src/cli/commands/repo.js'); + const logs = await captureLog(() => + repoCommand(ctx, ['add-contributor', 'did:jwk:contrib789', '--alias', 'Bob Two']), + ); + expect(logs.some((l) => l.includes('Added contributor'))).toBe(true); + expect(logs.some((l) => l.includes('did:jwk:contrib789'))).toBe(true); + }); + + it('should add a moderator with the dedicated command', async () => { + const { repoCommand } = await import('../src/cli/commands/repo.js'); + const logs = await captureLog(() => + repoCommand(ctx, ['add-moderator', 'did:jwk:mod123', '--alias', 'Casey']), + ); + expect(logs.some((l) => l.includes('Added moderator'))).toBe(true); + expect(logs.some((l) => l.includes('did:jwk:mod123'))).toBe(true); + }); + + it('should add, list, and remove moderators with the mod command', async () => { + const { modCommand } = await import('../src/cli/commands/mod.js'); + const addLogs = await captureLog(() => + modCommand(ctx, ['add', 'did:jwk:mod456', '--alias', 'Dana']), + ); + expect(addLogs.some((l) => l.includes('Added moderator'))).toBe(true); + + const listLogs = await captureLog(() => modCommand(ctx, ['list'])); + expect(listLogs.some((l) => l.includes('Moderators'))).toBe(true); + expect(listLogs.some((l) => l.includes('did:jwk:mod456 (Dana)'))).toBe(true); + + const removeLogs = await captureLog(() => + modCommand(ctx, ['remove', 'did:jwk:mod456']), + ); + expect(removeLogs.some((l) => l.includes('Removed moderator'))).toBe(true); + }); + + it('should write moderation events for block, lock, and comment moderation', async () => { + const { modCommand } = await import('../src/cli/commands/mod.js'); + const { repoCommand } = await import('../src/cli/commands/repo.js'); + await captureLog(() => repoCommand(ctx, ['add-contributor', 'did:jwk:block-me'])); + + const blockLogs = await captureLog(() => + modCommand(ctx, ['block', 'did:jwk:block-me', '--reason', 'spam']), + ); + expect(blockLogs.some((l) => l.includes('Blocked did:jwk:block-me'))).toBe(true); + + const lockLogs = await captureLog(() => + modCommand(ctx, ['lock', 'pr', 'abc1234', '--reason', 'heated']), + ); + expect(lockLogs.some((l) => l.includes('Locked pr abc1234'))).toBe(true); + + const hideLogs = await captureLog(() => + modCommand(ctx, ['hide-comment', 'comment123', '--kind', 'pr', '--reason', 'off-topic']), + ); + expect(hideLogs.some((l) => l.includes('Hid comment comment123'))).toBe(true); + + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const { records: events } = await ctx.repo.records.query('repo/moderationEvent' as any, { + filter: { contextId: repos[0].contextId }, + }); + const eventData = await Promise.all(events.map(async (record: any) => ({ + tags: record.tags as Record<string, string>, + data: await record.data.json(), + }))); + + expect(eventData.some((entry) => + entry.tags.action === 'block' + && entry.tags.targetDid === 'did:jwk:block-me' + && entry.data.reason === 'spam', + )).toBe(true); + expect(eventData.some((entry) => + entry.tags.action === 'lock' + && entry.tags.targetKind === 'pr' + && entry.tags.targetId === 'abc1234', + )).toBe(true); + expect(eventData.some((entry) => + entry.tags.action === 'hideComment' + && entry.tags.targetKind === 'prComment' + && entry.tags.targetId === 'comment123', + )).toBe(true); + + const { records: remainingContributorRoles } = await ctx.repo.records.query('repo/contributor' as any, { + filter: { contextId: repos[0].contextId, tags: { did: 'did:jwk:block-me' } }, + }); + expect(remainingContributorRoles).toHaveLength(0); + }); + + it('should write report and interaction-limit moderation events', async () => { + const { modCommand } = await import('../src/cli/commands/mod.js'); + const reportLogs = await captureLog(() => + modCommand(ctx, ['report', 'record123', '--kind', 'issue-comment', '--reason', 'abuse']), + ); + expect(reportLogs.some((l) => l.includes('Reported record123'))).toBe(true); + + const resolveLogs = await captureLog(() => + modCommand(ctx, ['resolve-report', 'report123', '--reason', 'handled']), + ); + expect(resolveLogs.some((l) => l.includes('Resolved report report123'))).toBe(true); + + const limitLogs = await captureLog(() => + modCommand(ctx, ['interaction-limit', 'contributors', '--duration', '24h']), + ); + expect(limitLogs.some((l) => l.includes('Set interaction limit: contributors'))).toBe(true); + + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const { records: events } = await ctx.repo.records.query('repo/moderationEvent' as any, { + filter: { contextId: repos[0].contextId }, + }); + const eventData = await Promise.all(events.map(async (record: any) => ({ + tags: record.tags as Record<string, string>, + data: await record.data.json(), + }))); + + expect(eventData.some((entry) => + entry.tags.action === 'report' + && entry.tags.targetKind === 'issueComment' + && entry.tags.reportStatus === 'open', + )).toBe(true); + expect(eventData.some((entry) => + entry.tags.action === 'resolveReport' + && entry.tags.reportStatus === 'resolved', + )).toBe(true); + expect(eventData.some((entry) => + entry.tags.action === 'interactionLimit' + && entry.tags.interactionLimit === 'contributors' + && entry.data.duration === '24h', + )).toBe(true); + }); + it('should add a viewer collaborator', async () => { const { repoCommand } = await import('../src/cli/commands/repo.js'); const logs = await captureLog(() => @@ -660,6 +912,24 @@ describe('gitd CLI commands', () => { expect(logs.some((l) => l.includes('did:jwk:collab123'))).toBe(true); expect(logs.some((l) => l.includes('contributors:'))).toBe(true); expect(logs.some((l) => l.includes('did:jwk:contrib456'))).toBe(true); + expect(logs.some((l) => l.includes('moderators:'))).toBe(true); + expect(logs.some((l) => l.includes('did:jwk:mod123'))).toBe(true); + }); + + it('should remove a moderator with the dedicated command', async () => { + const { repoCommand } = await import('../src/cli/commands/repo.js'); + const logs = await captureLog(() => + repoCommand(ctx, ['remove-moderator', 'did:jwk:mod123']), + ); + expect(logs.some((l) => l.includes('Removed moderator'))).toBe(true); + }); + + it('should remove a contributor with the dedicated command', async () => { + const { repoCommand } = await import('../src/cli/commands/repo.js'); + const logs = await captureLog(() => + repoCommand(ctx, ['remove-contributor', 'did:jwk:contrib789']), + ); + expect(logs.some((l) => l.includes('Removed contributor'))).toBe(true); }); it('should remove a collaborator by DID', async () => { @@ -771,6 +1041,152 @@ describe('gitd CLI commands', () => { expect(logs.some((l) => l.includes('No issues found'))).toBe(true); }); + it('should create, list, and comment on canonical issues for a remote owner', async () => { + const { issueCommand } = await import('../src/cli/commands/issue.js'); + const remoteOwnerDid = 'did:jwk:remote-cli-issue-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const sent: SentRecord[] = []; + const captured: CapturedCreate[] = []; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'contributor', sent, captured); + + const createLogs = await captureLog(() => + issueCommand(remoteCtx, ['create', 'Remote canonical issue', '--body', 'Written to owner DWN', '--owner', remoteOwnerDid]), + ); + expect(createLogs.some((l) => l.includes('Created issue'))).toBe(true); + expect(sent).toHaveLength(1); + expect(sent[0].targetDid).toBe(remoteOwnerDid); + expect(captured[0]).toMatchObject({ + path : 'repo/issue', + options : { + parentContextId : repos[0].contextId, + protocolRole : 'repo:repo/contributor', + store : false, + }, + }); + + const issueId = shortId(sent[0].record.id); + const listLogs = await captureLog(() => + issueCommand(remoteCtx, ['list', '--owner', remoteOwnerDid]), + ); + expect(listLogs.some((l) => l.includes('Remote canonical issue'))).toBe(true); + + const commentLogs = await captureLog(() => + issueCommand(remoteCtx, ['comment', issueId, 'Remote owner comment', '--owner', remoteOwnerDid]), + ); + expect(commentLogs.some((l) => l.includes(`Added comment to issue ${issueId}`))).toBe(true); + expect(sent).toHaveLength(2); + expect(recordProtocolPath(sent[1].record)).toBe('repo/issue/comment'); + expect(captured.some((entry) => + entry.path === 'repo/issue/comment' + && entry.options.protocolRole === 'repo:repo/contributor' + && entry.options.store === false, + )).toBe(true); + }); + + it('should reject remote canonical issue writes when the actor is blocked', async () => { + const { issueCommand } = await import('../src/cli/commands/issue.js'); + const remoteOwnerDid = 'did:jwk:remote-blocked-issue-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const sent: SentRecord[] = [{ + targetDid : remoteOwnerDid, + record : { + ...fakeJsonRecord( + 'remote-block-event', + repos[0].contextId, + { + action : 'block', + actorDid : remoteOwnerDid, + targetDid : ctx.did, + targetKind : 'repo', + reason : 'spam', + createdAt : '2026-06-23T00:00:00.000Z', + }, + { action: 'block', actorDid: remoteOwnerDid, targetDid: ctx.did, targetKind: 'repo' }, + ), + protocolPath: 'repo/moderationEvent', + }, + }]; + const captured: CapturedCreate[] = []; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'contributor', sent, captured); + + const { errors, exitCode } = await captureError(() => + issueCommand(remoteCtx, ['create', 'Blocked issue', '--owner', remoteOwnerDid]), + ); + expect(exitCode).toBe(1); + expect(errors[0]).toContain('blocked'); + expect(captured).toHaveLength(0); + }); + + it('should enforce issue locks and hide moderated comments', async () => { + const { issueCommand } = await import('../src/cli/commands/issue.js'); + const remoteOwnerDid = 'did:jwk:remote-moderated-issue-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const issue = { + ...fakeJsonRecord( + 'remote-moderated-issue-record', + repos[0].contextId, + { title: 'Moderated issue', body: '' }, + { status: 'open' }, + ), + protocolPath: 'repo/issue', + }; + const issueId = shortId(issue.id); + const comment = { + ...fakeJsonRecord( + 'remote-moderated-issue-comment', + issue.contextId, + { body: 'Visible before moderation' }, + {}, + ), + protocolPath: 'repo/issue/comment', + }; + const lockEvent = moderationRecord('remote-issue-lock', repos[0].contextId, { + action : 'lock', + targetKind : 'issue', + targetId : issueId, + }, '2026-06-23T00:00:00.000Z'); + const hideEvent = moderationRecord('remote-issue-hide', repos[0].contextId, { + action : 'hideComment', + targetKind : 'issueComment', + targetId : shortId(comment.id), + }, '2026-06-23T00:01:00.000Z'); + const sent: SentRecord[] = [ + { record: issue, targetDid: remoteOwnerDid }, + { record: comment, targetDid: remoteOwnerDid }, + { record: lockEvent, targetDid: remoteOwnerDid }, + { record: hideEvent, targetDid: remoteOwnerDid }, + ]; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'contributor', sent); + + const locked = await captureError(() => + issueCommand(remoteCtx, ['comment', issueId, 'Rejected while locked', '--owner', remoteOwnerDid]), + ); + expect(locked.exitCode).toBe(1); + expect(locked.errors[0]).toContain('locked'); + + const showLogs = await captureLog(() => issueCommand(remoteCtx, ['show', issueId, '--owner', remoteOwnerDid])); + expect(showLogs.some((line) => line.includes('Visible before moderation'))).toBe(false); + + sent.push({ + targetDid : remoteOwnerDid, + record : moderationRecord('remote-issue-unlock', repos[0].contextId, { + action : 'unlock', + targetKind : 'issue', + targetId : issueId, + }, '2026-06-23T00:02:00.000Z'), + }); + const unlockedLogs = await captureLog(() => + issueCommand(remoteCtx, ['comment', issueId, 'Accepted after unlock', '--owner', remoteOwnerDid]), + ); + expect(unlockedLogs.some((line) => line.includes(`Added comment to issue ${issueId}`))).toBe(true); + }); + it('should fail show for non-existent issue', async () => { const { issueCommand } = await import('../src/cli/commands/issue.js'); const { errors, exitCode } = await captureError(() => issueCommand(ctx, ['show', 'fffffff'])); @@ -1090,6 +1506,128 @@ describe('gitd CLI commands', () => { expect(logs.some((l) => l.includes('Merge test PR'))).toBe(true); }); + it('should create, list, and comment on canonical PRs for a remote owner', async () => { + const { prCommand } = await import('../src/cli/commands/pr.js'); + const remoteOwnerDid = 'did:jwk:remote-cli-pr-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const sent: SentRecord[] = []; + const captured: CapturedCreate[] = []; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'contributor', sent, captured); + + const createLogs = await captureLog(() => + prCommand(remoteCtx, [ + 'create', + 'Remote canonical PR', + '--body', + 'Written to owner DWN', + '--base', + 'main', + '--head', + 'users/bob/topic', + '--no-bundle', + '--owner', + remoteOwnerDid, + ]), + ); + expect(createLogs.some((l) => l.includes('Created PR'))).toBe(true); + expect(sent).toHaveLength(1); + expect(sent[0].targetDid).toBe(remoteOwnerDid); + expect(captured[0]).toMatchObject({ + path : 'repo/patch', + options : { + parentContextId : repos[0].contextId, + protocolRole : 'repo:repo/contributor', + store : false, + }, + }); + + const prId = shortId(sent[0].record.id); + const listLogs = await captureLog(() => + prCommand(remoteCtx, ['list', '--owner', remoteOwnerDid]), + ); + expect(listLogs.some((l) => l.includes('Remote canonical PR'))).toBe(true); + + const commentLogs = await captureLog(() => + prCommand(remoteCtx, ['comment', prId, 'Remote owner review note', '--owner', remoteOwnerDid]), + ); + expect(commentLogs.some((l) => l.includes(`Added comment to PR ${prId}`))).toBe(true); + expect(sent).toHaveLength(2); + expect(recordProtocolPath(sent[1].record)).toBe('repo/patch/review'); + expect(captured.some((entry) => + entry.path === 'repo/patch/review' + && entry.options.protocolRole === 'repo:repo/contributor' + && entry.options.store === false, + )).toBe(true); + }); + + it('should enforce PR locks and hide moderated PR comments', async () => { + const { prCommand } = await import('../src/cli/commands/pr.js'); + const remoteOwnerDid = 'did:jwk:remote-moderated-pr-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const patch = { + ...fakeJsonRecord( + 'remote-moderated-pr-record', + repos[0].contextId, + { title: 'Moderated PR', body: '' }, + { status: 'open', baseBranch: 'main', headBranch: 'moderated-pr' }, + ), + protocolPath: 'repo/patch', + }; + const prId = shortId(patch.id); + const review = { + ...fakeJsonRecord( + 'remote-moderated-pr-review', + patch.contextId, + { body: 'Visible PR comment before moderation' }, + { verdict: 'comment' }, + ), + protocolPath: 'repo/patch/review', + }; + const lockEvent = moderationRecord('remote-pr-lock', repos[0].contextId, { + action : 'lock', + targetKind : 'pr', + targetId : prId, + }, '2026-06-23T00:00:00.000Z'); + const hideEvent = moderationRecord('remote-pr-hide', repos[0].contextId, { + action : 'hideComment', + targetKind : 'prComment', + targetId : shortId(review.id), + }, '2026-06-23T00:01:00.000Z'); + const sent: SentRecord[] = [ + { record: patch, targetDid: remoteOwnerDid }, + { record: review, targetDid: remoteOwnerDid }, + { record: lockEvent, targetDid: remoteOwnerDid }, + { record: hideEvent, targetDid: remoteOwnerDid }, + ]; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'contributor', sent); + + const locked = await captureError(() => + prCommand(remoteCtx, ['comment', prId, 'Rejected while locked', '--owner', remoteOwnerDid]), + ); + expect(locked.exitCode).toBe(1); + expect(locked.errors[0]).toContain('locked'); + + const showLogs = await captureLog(() => prCommand(remoteCtx, ['show', prId, '--owner', remoteOwnerDid])); + expect(showLogs.some((line) => line.includes('Visible PR comment before moderation'))).toBe(false); + + sent.push({ + targetDid : remoteOwnerDid, + record : moderationRecord('remote-pr-unlock', repos[0].contextId, { + action : 'unlock', + targetKind : 'pr', + targetId : prId, + }, '2026-06-23T00:02:00.000Z'), + }); + const unlockedLogs = await captureLog(() => + prCommand(remoteCtx, ['comment', prId, 'Accepted after PR unlock', '--owner', remoteOwnerDid]), + ); + expect(unlockedLogs.some((line) => line.includes(`Added comment to PR ${prId}`))).toBe(true); + }); + it('should fail show for non-existent PR', async () => { const { prCommand } = await import('../src/cli/commands/pr.js'); const { errors, exitCode } = await captureError(() => prCommand(ctx, ['show', 'fffffff'])); diff --git a/tests/daemon-lifecycle.spec.ts b/tests/daemon-lifecycle.spec.ts index b7d014f..60b8b16 100644 --- a/tests/daemon-lifecycle.spec.ts +++ b/tests/daemon-lifecycle.spec.ts @@ -5,11 +5,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { createGitServer } from '../src/git-server/server.js'; import { createServer } from 'node:http'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { getVersion } from '../src/version.js'; import { daemonLogPath, daemonStatus, findGitdBin, stopDaemon } from '../src/daemon/lifecycle.js'; import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { lockfilePath, readLockfile, removeLockfile, writeLockfile } from '../src/daemon/lockfile.js'; +import { profilesDir } from '../src/profiles/config.js'; // --------------------------------------------------------------------------- // Lockfile version field @@ -50,6 +51,31 @@ describe('lockfile version field', () => { removeLockfile(); }); + it('should write DWN helper capability when advertised', () => { + writeLockfile(9418, '1.0.0', 'did:dht:owner123', { dwnHelper: true }); + const lock = readLockfile(); + expect(lock).not.toBeNull(); + expect(lock!.dwnHelper).toBe(true); + removeLockfile(); + }); + + it('should isolate lockfiles by profile name', () => { + const alicePath = lockfilePath('alice'); + const bobPath = lockfilePath('bob'); + writeLockfile(9418, '1.0.0', 'did:dht:alice', { profileName: 'alice' }); + writeLockfile(9419, '1.0.0', 'did:dht:bob', { profileName: 'bob' }); + + expect(alicePath).toBe(join(profilesDir(), 'alice', 'daemon.lock')); + expect(bobPath).toBe(join(profilesDir(), 'bob', 'daemon.lock')); + expect(readLockfile('alice')!.ownerDid).toBe('did:dht:alice'); + expect(readLockfile('alice')!.port).toBe(9418); + expect(readLockfile('bob')!.ownerDid).toBe('did:dht:bob'); + expect(readLockfile('bob')!.port).toBe(9419); + + removeLockfile('alice'); + removeLockfile('bob'); + }); + it('should omit ownerDid when not provided', () => { writeLockfile(9418, '1.0.0'); const lock = readLockfile(); @@ -129,6 +155,10 @@ describe('daemonLogPath', () => { expect(logPath).toContain('gitd'); expect(logPath).toContain('daemon.log'); }); + + it('should return a profile-scoped path when profile is provided', () => { + expect(daemonLogPath('alice')).toBe(join(profilesDir(), 'alice', 'gitd', 'daemon.log')); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/e2e-collaboration.spec.ts b/tests/e2e-collaboration.spec.ts index 7943206..1405738 100644 --- a/tests/e2e-collaboration.spec.ts +++ b/tests/e2e-collaboration.spec.ts @@ -1,5 +1,5 @@ /** - * End-to-end collaboration test: two-actor workflow. + * End-to-end collaboration test: maintainer, contributor, and moderator workflow. * * Models the Linux kernel / b4 contribution flow mapped to DWN: * @@ -7,7 +7,8 @@ * 2. Bob (contributor) clones, makes changes on a branch * 3. Bob submits a PR (patch bundle) to Alice's DWN * 4. Alice checks out Bob's PR, reviews it, merges it - * 5. Bob pulls from Alice's repo and sees the merged changes + * 5. Casey (moderator) can review but cannot push + * 6. Bob pulls from Alice's repo and sees the merged changes * * Both agents share a single DWN instance (multi-tenant) so that * cross-DWN writes (`store: false` → `processRequest({ target })`) @@ -23,10 +24,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { exec as execCb } from 'node:child_process'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { tmpdir } from 'node:os'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { cachePortableDid } from './helpers/identity.js'; import { createTestIdentity } from './helpers/identity.js'; @@ -38,16 +39,25 @@ import { DidDht, DidJwk } from '@enbox/dids'; import type { AgentContext } from '../src/cli/agent.js'; import type { GitServer } from '../src/git-server/server.js'; +import { writeLockfile } from '../src/daemon/lockfile.js'; import { createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { createDidSignatureVerifier } from '../src/git-server/verify.js'; import { createDwnPushAuthorizer } from '../src/git-server/push-authorizer.js'; import { createGitServer } from '../src/git-server/server.js'; import { createRefSyncer } from '../src/git-server/ref-sync.js'; +import { branchOwnerHash } from '../src/branch-state.js'; +import { issueCommand } from '../src/cli/commands/issue.js'; +import { modCommand } from '../src/cli/commands/mod.js'; +import { prCommand } from '../src/cli/commands/pr.js'; +import { restoreFromBundles } from '../src/git-server/bundle-restore.js'; +import { syncRemoteBranchPush } from '../src/git-server/remote-branch-sync.js'; +import { ForgeIssuesProtocol } from '../src/issues.js'; import { ForgePatchesProtocol } from '../src/patches.js'; import { ForgeRefsProtocol } from '../src/refs.js'; import { ForgeRepoProtocol } from '../src/repo.js'; import { generatePushCredentials } from '../src/git-remote/credential-helper.js'; import { GitBackend } from '../src/git-server/git-backend.js'; +import type { PushRefUpdate } from '../src/git-server/push-updates.js'; import { shortId } from '../src/github-shim/helpers.js'; import { decodePushToken, @@ -64,7 +74,15 @@ const exec = promisify(execCb); const BASE = '__TESTDATA__/collab-e2e'; const ALICE_DATA = `${BASE}/alice-agent`; const BOB_DATA = `${BASE}/bob-agent`; +const CASEY_DATA = `${BASE}/casey-agent`; const REPOS_PATH = `${BASE}/repos`; +const BOB_HELPER_REPOS_PATH = `${BASE}/bob-helper-repos`; +const BOB_RESTORE_REPOS_PATH = `${BASE}/bob-restore-repos`; +const BOB_RESTORE_CLONE_PATH = `${BASE}/bob-restore-clone`; +const BOB_DID_REMOTE_HOME = `${BASE}/bob-did-remote-home`; +const BOB_DID_REMOTE_BIN = `${BASE}/bob-did-remote-bin`; +const BOB_DID_REMOTE_REPOS_PATH = `${BASE}/bob-did-remote-repos`; +const BOB_DID_REMOTE_CLONE_PATH = `${BASE}/bob-did-remote-clone`; const ALICE_CLONE_PATH = `${BASE}/alice-clone`; const BOB_CLONE_PATH = `${BASE}/bob-clone`; @@ -138,7 +156,7 @@ async function createOfflineAgent(dataPath: string): Promise<{ // Test suite // --------------------------------------------------------------------------- -describe('E2E: two-actor collaboration (maintainer + contributor)', () => { +describe('E2E: repo collaboration (maintainer + contributor + moderator)', () => { // Alice's state let aliceDid: string; let aliceDidDocument: any; @@ -146,6 +164,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { let aliceAgent: EnboxUserAgent; let aliceRepo: AgentContext['repo']; let aliceRefs: AgentContext['refs']; + let aliceIssues: AgentContext['issues']; let alicePatches: AgentContext['patches']; let repoContextId: string; @@ -153,11 +172,24 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { let bobDid: string; let bobDidDocument: any; let bobPrivateKey: Record<string, unknown>; + let bobRepo: AgentContext['repo']; + let bobRefs: AgentContext['refs']; + let bobIssues: AgentContext['issues']; let bobPatches: AgentContext['patches']; + let bobContributorBranchRef: string; + + // Casey's state + let caseyDid: string; + let caseyDidDocument: any; + let caseyPrivateKey: Record<string, unknown>; + let caseyRepo: AgentContext['repo']; + let caseyPatches: AgentContext['patches']; // Shared infrastructure let server: GitServer; + let bobHelperServer: GitServer | undefined; let cloneUrl: string; + let helperBranchRef: string; // ========================================================================= // Setup — create two independent Enbox agents (no DHT required) @@ -175,9 +207,11 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { aliceRepo = alice.enbox.using(ForgeRepoProtocol); aliceRefs = alice.enbox.using(ForgeRefsProtocol); + aliceIssues = alice.enbox.using(ForgeIssuesProtocol); alicePatches = alice.enbox.using(ForgePatchesProtocol); await aliceRepo.configure(); await aliceRefs.configure(); + await aliceIssues.configure(); await alicePatches.configure(); // ----- Bob (contributor) ----- @@ -185,20 +219,39 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { bobDid = bob.did; bobDidDocument = bob.didDocument; bobPrivateKey = bob.privateKey; + bobContributorBranchRef = `refs/heads/users/${branchOwnerHash(bobDid)}/feat/add-multiply`; await cachePortableDid(alice.agent, bob.portableDid); await cachePortableDid(bob.agent, alice.portableDid); // Bob must install ForgeRepoProtocol before ForgePatchesProtocol // because the patches definition `uses` the repo protocol ($ref). - const bobRepo = bob.enbox.using(ForgeRepoProtocol); + bobRepo = bob.enbox.using(ForgeRepoProtocol); await bobRepo.configure(); + bobRefs = bob.enbox.using(ForgeRefsProtocol); + await bobRefs.configure(); + bobIssues = bob.enbox.using(ForgeIssuesProtocol); + await bobIssues.configure(); // Now Bob can install the patches protocol on his own DWN so he // can create properly signed records with `store: false`. bobPatches = bob.enbox.using(ForgePatchesProtocol); await bobPatches.configure(); + // ----- Casey (moderator) ----- + const casey = await createOfflineAgent(CASEY_DATA); + caseyDid = casey.did; + caseyDidDocument = casey.didDocument; + caseyPrivateKey = casey.privateKey; + + await cachePortableDid(alice.agent, casey.portableDid); + await cachePortableDid(casey.agent, alice.portableDid); + + caseyRepo = casey.enbox.using(ForgeRepoProtocol); + await caseyRepo.configure(); + caseyPatches = casey.enbox.using(ForgePatchesProtocol); + await caseyPatches.configure(); + // ----- Create Alice's repo in DWN ----- const { record } = await aliceRepo.records.create('repo', { data: { @@ -225,12 +278,26 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { throw new Error(`Failed to grant contributor role: ${roleStatus.code} ${roleStatus.detail}`); } + // ----- Grant Casey the moderator role ----- + const { status: moderatorStatus } = await aliceRepo.records.create( + 'repo/moderator' as any, + { + data : { did: caseyDid, alias: 'Casey' }, + tags : { did: caseyDid }, + parentContextId : repoContextId, + recipient : caseyDid, + } as any, + ); + if (moderatorStatus.code >= 300) { + throw new Error(`Failed to grant moderator role: ${moderatorStatus.code} ${moderatorStatus.detail}`); + } + // ----- Init bare git repo + start server ----- const backend = new GitBackend({ basePath: REPOS_PATH }); await backend.initRepo(aliceDid, 'collab-repo'); const verifySignature = createDidSignatureVerifier({ - didDocuments: [aliceDidDocument, bobDidDocument], + didDocuments: [aliceDidDocument, bobDidDocument, caseyDidDocument], }); const authorizePush = createDwnPushAuthorizer({ repo : aliceRepo, @@ -239,7 +306,10 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Custom authenticatePush — no nonce replay (see e2e.spec.ts for rationale). const authenticatePush = async ( - request: Request, did: string, repo: string, + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], ): Promise<boolean> => { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Basic ')) { return false; } @@ -265,7 +335,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const signatureBytes = new Uint8Array(Buffer.from(signed.signature, 'base64url')); if (!(await verifySignature(payload.did, tokenBytes, signatureBytes))) { return false; } - return authorizePush(payload.did, did, repo); + return authorizePush(payload.did, did, repo, updates); }; const refSyncer = createRefSyncer({ @@ -275,6 +345,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const bundleSyncer = createBundleSyncer({ repo : aliceRepo, + refs : aliceRefs, repoContextId, visibility : 'public', }); @@ -282,10 +353,8 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const onPushComplete = async ( pushDid: string, repoName: string, repoPath: string, ): Promise<void> => { - await Promise.all([ - refSyncer(pushDid, repoName, repoPath), - bundleSyncer(pushDid, repoName, repoPath), - ]); + await refSyncer(pushDid, repoName, repoPath); + await bundleSyncer(pushDid, repoName, repoPath); }; server = await createGitServer({ @@ -299,6 +368,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { }, 60_000); afterAll(async () => { + try { if (bobHelperServer) { await bobHelperServer.stop(); } } catch { /* ok */ } try { if (server) { await server.stop(); } } catch { /* ok */ } rmSync(BASE, { recursive: true, force: true }); }); @@ -322,6 +392,92 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { return `!f() { test "$1" = get && echo "username=${user}" && echo "password=${pass}"; }; f`; } + async function sendToAlice(record: any): Promise<void> { + const blob = await record.data.blob(); + const reply = await aliceAgent.dwn.node.processMessage( + aliceDid, + record.rawMessage, + { dataStream: DataStream.fromBytes(new Uint8Array(await blob.arrayBuffer())) }, + ); + if (reply.status.code >= 300) { + throw new Error(`sendToAlice failed: ${reply.status.code} ${reply.status.detail}`); + } + } + + async function captureLog(fn: () => Promise<void>): Promise<string[]> { + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]): void => { logs.push(args.map(String).join(' ')); }; + try { + await fn(); + } finally { + console.log = orig; + } + return logs; + } + + async function captureError(fn: () => Promise<void>): Promise<{ errors: string[]; exitCode?: number }> { + const errors: string[] = []; + const origError = console.error; + const origExit = process.exit; + let exitCode: number | undefined; + console.error = (...args: unknown[]): void => { errors.push(args.map(String).join(' ')); }; + process.exit = ((code?: number) => { exitCode = code ?? 1; throw new Error(`process.exit(${code})`); }) as never; + try { + await fn(); + } catch (err: unknown) { + if (!(err instanceof Error && err.message.startsWith('process.exit'))) { + throw err; + } + } finally { + console.error = origError; + process.exit = origExit; + } + return { errors, exitCode }; + } + + function bobCliContext(): AgentContext { + return { + did : bobDid, + repo : withAliceReads(bobRepo, aliceRepo), + refs : bobRefs, + issues : withAliceReads(bobIssues, aliceIssues), + patches : withAliceReads(bobPatches, alicePatches), + sendRecord : async (record: any, targetDid: string): Promise<void> => { + expect(targetDid).toBe(aliceDid); + await sendToAlice(record); + }, + } as unknown as AgentContext; + } + + function caseyCliContext(): AgentContext { + return { + did : caseyDid, + repo : withAliceReads(caseyRepo, aliceRepo), + patches : withAliceReads(caseyPatches, alicePatches), + sendRecord : async (record: any, targetDid: string): Promise<void> => { + expect(targetDid).toBe(aliceDid); + await sendToAlice(record); + }, + } as unknown as AgentContext; + } + + function withAliceReads<T extends { records: any }>(localProtocol: T, aliceProtocol: T): T { + return { + ...localProtocol, + records: { + ...localProtocol.records, + query: async (path: string, options?: any) => { + if (options?.from === aliceDid) { + const { from: _from, ...rest } = options; + return aliceProtocol.records.query(path, rest); + } + return localProtocol.records.query(path, options); + }, + }, + }; + } + // ========================================================================= // Phase 1: Alice (maintainer) sets up the repo with initial content // @@ -503,21 +659,6 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { const { bundlePath, baseCommit, headCommit } = (globalThis as any).__collab_bundle; - // Helper: send a record created with store:false to Alice's DWN. - // In production this would be `record.send(aliceDid)` over HTTP. - // Here we feed the signed message directly into Alice's DWN node. - async function sendToAlice(record: any): Promise<void> { - const blob = await record.data.blob(); - const reply = await aliceAgent.dwn.node.processMessage( - aliceDid, - record.rawMessage, - { dataStream: DataStream.fromBytes(new Uint8Array(await blob.arrayBuffer())) }, - ); - if (reply.status.code >= 300) { - throw new Error(`sendToAlice failed: ${reply.status.code} ${reply.status.detail}`); - } - } - // Create the patch record (PR) — signed by Bob, not stored locally const { record: patchRecord } = await bobPatches.records.create( 'repo/patch', @@ -529,7 +670,7 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { tags: { status : 'open', baseBranch : 'main', - headBranch : 'feat/add-multiply', + headBranch : bobContributorBranchRef, sourceDid : bobDid, }, parentContextId : repoContextId, @@ -622,6 +763,36 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { expect(data.title).toBe('feat: add multiply function'); }); + it('Phase 4a2: Casey can moderate Bob\'s PR with a review comment', async () => { + const patchResults = await alicePatches.records.query('repo/patch', { + filter: { contextId: repoContextId }, + }); + const patch = patchResults.records.find((r: any) => r.id === patchRecordId)!; + + const { record: reviewRecord } = await caseyPatches.records.create( + 'repo/patch/review' as any, + { + data: { + body: 'Moderator note: keep discussion focused on the implementation.', + }, + tags : { verdict: 'comment' }, + parentContextId : patch.contextId, + protocolRole : 'repo:repo/moderator', + store : false, + } as any, + ); + await sendToAlice(reviewRecord); + + const { records: reviews } = await alicePatches.records.query('repo/patch/review' as any, { + filter: { contextId: patch.contextId, tags: { verdict: 'comment' } }, + }); + const caseyReview = reviews.find((record: any) => record.id === reviewRecord.id); + expect(caseyReview).toBeDefined(); + + const data = await caseyReview!.data.json(); + expect(data.body).toContain('Moderator note'); + }); + it('Phase 4b: Alice checks out Bob\'s PR (fetches bundle into local tree)', async () => { // Fetch the revision and bundle from Alice's DWN const patchResults = await alicePatches.records.query('repo/patch', { @@ -846,24 +1017,50 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { // Phase 6: Verify push authorization (contributor can push, stranger can't) // ========================================================================= - it('Phase 6a: Bob (contributor) can push to Alice\'s repo', async () => { + it('Phase 6a: Bob (contributor) can push to his canonical contributor branch', async () => { // Bob pushes his feature branch to the server await exec('git checkout feat/add-multiply', { cwd: BOB_CLONE_PATH }); const helper = await credentialHelper(bobDid, bobPrivateKey); await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: BOB_CLONE_PATH }); await exec( - 'GIT_TERMINAL_PROMPT=0 git push origin feat/add-multiply', + `GIT_TERMINAL_PROMPT=0 git push origin HEAD:${bobContributorBranchRef}`, { cwd: BOB_CLONE_PATH }, ); // Verify the branch exists in the bare repo const repoPath = server.backend.repoPath(aliceDid, 'collab-repo'); - const { stdout } = await exec('git branch -a', { cwd: repoPath }); - expect(stdout).toContain('feat/add-multiply'); + const { stdout } = await exec(`git show-ref --verify ${bobContributorBranchRef}`, { cwd: repoPath }); + expect(stdout).toContain(bobContributorBranchRef); }, 15_000); - it('Phase 6b: Unauthorized DID cannot push', async () => { + it('Phase 6b: Bob (contributor) cannot push main', async () => { + await exec('git checkout feat/add-multiply', { cwd: BOB_CLONE_PATH }); + + const helper = await credentialHelper(bobDid, bobPrivateKey); + await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: BOB_CLONE_PATH }); + + await expect( + exec('GIT_TERMINAL_PROMPT=0 git push origin HEAD:refs/heads/main', { cwd: BOB_CLONE_PATH }), + ).rejects.toThrow(); + }, 15_000); + + it('Phase 6c: Casey (moderator) cannot push', async () => { + const creds = await generatePushCredentials( + { path: `/${aliceDid}/collab-repo` }, + caseyDid, + caseyPrivateKey, + ); + expect(creds).toBeDefined(); + + const authHeader = `Basic ${Buffer.from(`${creds!.username}:${creds!.password}`).toString('base64')}`; + const res = await fetch(`${cloneUrl}/info/refs?service=git-receive-pack`, { + headers: { Authorization: authHeader }, + }); + expect(res.status).toBe(401); + }); + + it('Phase 6d: Unauthorized DID cannot push', async () => { // Create a stranger DID (no contributor role) const stranger = await DidJwk.create({ options: { algorithm: 'Ed25519' } }); const strangerPortable = await stranger.export(); @@ -883,4 +1080,463 @@ describe('E2E: two-actor collaboration (maintainer + contributor)', () => { expect(res.status).toBe(401); }); -}); + it('Phase 7: Bob local helper writes contributor branch records to Alice DWN', async () => { + helperBranchRef = `refs/heads/users/${branchOwnerHash(bobDid)}/local-helper-writeback`; + const bobHelperBackend = new GitBackend({ basePath: BOB_HELPER_REPOS_PATH }); + const bobHelperRepoPath = bobHelperBackend.repoPath(aliceDid, 'collab-repo'); + rmSync(BOB_HELPER_REPOS_PATH, { recursive: true, force: true }); + mkdirSync(dirname(bobHelperRepoPath), { recursive: true }); + await exec(`git clone --bare "${cloneUrl}" "${bobHelperRepoPath}"`); + + const verifySignature = createDidSignatureVerifier({ + didDocuments: [bobDidDocument], + }); + const authorizePush = createDwnPushAuthorizer({ + repo : aliceRepo, + ownerDid : aliceDid, + }); + + const authenticatePush = async ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { + const authHeader = request.headers.get('Authorization'); + if (!authHeader?.startsWith('Basic ')) { return false; } + + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8'); + const colonIdx = decoded.indexOf(':'); + if (colonIdx === -1) { return false; } + + const username = decoded.slice(0, colonIdx); + const password = decoded.slice(colonIdx + 1); + if (username !== DID_AUTH_USERNAME) { return false; } + + let signed; + try { signed = parseAuthPassword(password); } catch { return false; } + + let payload; + try { payload = decodePushToken(signed.token); } catch { return false; } + + if (payload.owner !== did || payload.repo !== repo) { return false; } + if (payload.exp < Math.floor(Date.now() / 1000)) { return false; } + + const tokenBytes = new TextEncoder().encode(signed.token); + const signatureBytes = new Uint8Array(Buffer.from(signed.signature, 'base64url')); + if (!(await verifySignature(payload.did, tokenBytes, signatureBytes))) { return false; } + + return authorizePush(payload.did, did, repo, updates); + }; + + bobHelperServer = await createGitServer({ + basePath : BOB_HELPER_REPOS_PATH, + port : 0, + authenticatePush, + onPushComplete: async (did, _repo, repoPath, pushContext) => { + await syncRemoteBranchPush({ + refs : bobRefs, + repoContextId, + targetDid : did, + actorDid : bobDid, + repoPath, + updates : pushContext?.updates ?? [], + sendRecord : sendToAlice, + lookupBranches : async (refName) => { + const { records } = await aliceRefs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId, tags: { refName } }, + }); + return records; + }, + }); + }, + }); + + await exec('git checkout -B local-helper-writeback main', { cwd: BOB_CLONE_PATH }); + writeFileSync( + join(BOB_CLONE_PATH, 'local-helper.ts'), + 'export const localHelperWriteback = true;\n', + ); + await exec('git add local-helper.ts', { cwd: BOB_CLONE_PATH }); + await exec('git commit -m "feat: local helper branch writeback"', { cwd: BOB_CLONE_PATH }); + + const helper = await credentialHelper(bobDid, bobPrivateKey); + await exec(`git config --replace-all credential.helper '${helper}'`, { cwd: BOB_CLONE_PATH }); + const bobHelperUrl = `http://localhost:${bobHelperServer.port}/${aliceDid}/collab-repo`; + await exec(`GIT_TERMINAL_PROMPT=0 git push "${bobHelperUrl}" HEAD:${helperBranchRef}`, { cwd: BOB_CLONE_PATH }); + + let branches: any[] = []; + for (let attempt = 0; attempt < 20; attempt++) { + ({ records: branches } = await aliceRefs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId, tags: { refName: helperBranchRef } }, + })); + if (branches.length > 0) { break; } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(branches).toHaveLength(1); + const branchData = await branches[0].data.json(); + expect(branchData.ownerDid).toBe(bobDid); + expect(branchData.kind).toBe('contributor'); + + const { records: states } = await aliceRefs.records.query('repo/branch/state' as any, { + filter: { contextId: branches[0].contextId }, + }); + expect(states.length).toBeGreaterThanOrEqual(1); + const stateEntries = await Promise.all(states.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const checkpoint = stateEntries.find((entry) => entry.data.kind === 'checkpoint'); + expect(checkpoint).toBeDefined(); + expect(checkpoint!.data.actorDid).toBe(bobDid); + expect(checkpoint!.data.refName).toBe(helperBranchRef); + expect(checkpoint!.data.target).toMatch(/^[0-9a-f]{40}$/); + + const { records: bundles } = await aliceRefs.records.query('repo/branch/bundle' as any, { + filter: { contextId: branches[0].contextId, tags: { refName: helperBranchRef } }, + }); + expect(bundles.length).toBeGreaterThanOrEqual(1); + expect(bundles[0].tags.tipCommit).toBe(checkpoint!.data.target); + }, 30_000); + + it('Phase 8: Bob CLI creates canonical issue and PR records in Alice DWN', async () => { + const ctx = bobCliContext(); + + const issueLogs = await captureLog(() => + issueCommand(ctx, [ + 'create', + 'CLI canonical issue', + '--body', + 'Created by Bob through the local helper path.', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(issueLogs.some((line) => line.includes('Created issue'))).toBe(true); + + const { records: issues } = await aliceIssues.records.query('repo/issue', { + filter: { contextId: repoContextId }, + }); + const issueEntries = await Promise.all(issues.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const cliIssue = issueEntries.find((entry) => entry.data.title === 'CLI canonical issue')?.record; + expect(cliIssue).toBeDefined(); + + const issueId = shortId(cliIssue!.id); + const commentLogs = await captureLog(() => + issueCommand(ctx, [ + 'comment', + issueId, + 'Commented by Bob through the local helper path.', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(commentLogs.some((line) => line.includes(`Added comment to issue ${issueId}`))).toBe(true); + + const { records: issueComments } = await aliceIssues.records.query('repo/issue/comment' as any, { + filter: { contextId: cliIssue!.contextId }, + }); + expect(issueComments.length).toBeGreaterThanOrEqual(1); + const issueCommentData = await issueComments[issueComments.length - 1].data.json(); + expect(issueCommentData.body).toContain('Commented by Bob'); + + const origCwd = process.cwd(); + try { + process.chdir(BOB_CLONE_PATH); + const prLogs = await captureLog(() => + prCommand(ctx, [ + 'create', + 'CLI canonical PR', + '--body', + 'Created by Bob through gitd pr create.', + '--base', + 'main', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(prLogs.some((line) => line.includes('Created PR'))).toBe(true); + expect(prLogs.some((line) => line.includes('Bundle:'))).toBe(true); + } finally { + process.chdir(origCwd); + } + + const { records: patches } = await alicePatches.records.query('repo/patch', { + filter: { contextId: repoContextId }, + }); + const patchEntries = await Promise.all(patches.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const cliPatch = patchEntries.find((entry) => entry.data.title === 'CLI canonical PR')?.record; + expect(cliPatch).toBeDefined(); + + const { records: revisions } = await alicePatches.records.query('repo/patch/revision' as any, { + filter: { contextId: cliPatch!.contextId }, + }); + expect(revisions.length).toBeGreaterThanOrEqual(1); + const { records: revisionBundles } = await alicePatches.records.query('repo/patch/revision/revisionBundle' as any, { + filter: { contextId: revisions[0].contextId }, + }); + expect(revisionBundles.length).toBe(1); + + const patchId = shortId(cliPatch!.id); + const prCommentLogs = await captureLog(() => + prCommand(ctx, [ + 'comment', + patchId, + 'Review note from Bob through the local helper path.', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(prCommentLogs.some((line) => line.includes(`Added comment to PR ${patchId}`))).toBe(true); + + const { records: reviews } = await alicePatches.records.query('repo/patch/review' as any, { + filter: { contextId: cliPatch!.contextId, tags: { verdict: 'comment' } }, + }); + const reviewEntries = await Promise.all(reviews.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + expect(reviewEntries.some((entry) => entry.data.body.includes('Review note from Bob'))).toBe(true); + }, 30_000); + + it('Phase 9: Casey CLI moderation locks PR discussion and hides Bob review note', async () => { + const bobCtx = bobCliContext(); + const caseyCtx = caseyCliContext(); + + const { records: patches } = await alicePatches.records.query('repo/patch', { + filter: { contextId: repoContextId }, + }); + const patchEntries = await Promise.all(patches.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const cliPatch = patchEntries.find((entry) => entry.data.title === 'CLI canonical PR')?.record; + expect(cliPatch).toBeDefined(); + const patchId = shortId(cliPatch!.id); + + const { records: reviews } = await alicePatches.records.query('repo/patch/review' as any, { + filter: { contextId: cliPatch!.contextId, tags: { verdict: 'comment' } }, + }); + const reviewEntries = await Promise.all(reviews.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const bobReview = reviewEntries.find((entry) => entry.data.body.includes('Review note from Bob'))?.record; + expect(bobReview).toBeDefined(); + const reviewId = shortId(bobReview!.id); + + const lockLogs = await captureLog(() => + modCommand(caseyCtx, [ + 'lock', + 'pr', + patchId, + '--reason', + 'heated', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(lockLogs.some((line) => line.includes(`Locked pr ${patchId}`))).toBe(true); + + const hideLogs = await captureLog(() => + modCommand(caseyCtx, [ + 'hide-comment', + reviewId, + '--kind', + 'pr', + '--reason', + 'off topic', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(hideLogs.some((line) => line.includes(`Hid comment ${reviewId}`))).toBe(true); + + const { records: moderationEvents } = await aliceRepo.records.query('repo/moderationEvent' as any, { + filter: { contextId: repoContextId }, + }); + expect(moderationEvents.some((record: any) => + record.tags?.action === 'lock' + && record.tags?.targetKind === 'pr' + && record.tags?.targetId === patchId, + )).toBe(true); + expect(moderationEvents.some((record: any) => + record.tags?.action === 'hideComment' + && record.tags?.targetKind === 'prComment' + && record.tags?.targetId === reviewId, + )).toBe(true); + + const showLogs = await captureLog(() => + prCommand(bobCtx, ['show', patchId, '--repo', 'collab-repo', '--owner', aliceDid]), + ); + expect(showLogs.some((line) => line.includes('Review note from Bob'))).toBe(false); + + const { errors, exitCode } = await captureError(() => + prCommand(bobCtx, [ + 'comment', + patchId, + 'This should be blocked by Casey lock.', + '--repo', + 'collab-repo', + '--owner', + aliceDid, + ]), + ); + expect(exitCode).toBe(1); + expect(errors.some((line) => line.includes(`PR ${patchId} is locked.`))).toBe(true); + }, 30_000); + + it('Phase 10: Bob empty helper cache restores Alice repo from DWN records', async () => { + rmSync(BOB_RESTORE_REPOS_PATH, { recursive: true, force: true }); + rmSync(BOB_RESTORE_CLONE_PATH, { recursive: true, force: true }); + + const { stdout: mergedTipOutput } = await exec('git rev-parse main', { cwd: ALICE_CLONE_PATH }); + const mergedTip = mergedTipOutput.trim(); + let mergeBundleSynced = false; + for (let attempt = 0; attempt < 20; attempt++) { + const { records } = await aliceRepo.records.query('repo/bundle', { + filter: { contextId: repoContextId }, + }); + mergeBundleSynced = records.some((record: any) => record.tags?.tipCommit === mergedTip); + if (mergeBundleSynced) { break; } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(mergeBundleSynced).toBe(true); + + const restoreRepo = withAliceReads(bobRepo, aliceRepo); + const restoreRefs = withAliceReads(bobRefs, aliceRefs); + const restoreServer = await createGitServer({ + basePath : BOB_RESTORE_REPOS_PATH, + port : 0, + onRepoNotFound : async (did, repoName, repoPath): Promise<boolean> => { + expect(did).toBe(aliceDid); + expect(repoName).toBe('collab-repo'); + const result = await restoreFromBundles({ + repo : restoreRepo, + refs : restoreRefs, + from : aliceDid, + repoPath, + repoContextId, + }); + return result.success; + }, + }); + + try { + const restoreUrl = `http://localhost:${restoreServer.port}/${aliceDid}/collab-repo`; + await exec(`git clone --branch main "${restoreUrl}" "${BOB_RESTORE_CLONE_PATH}"`); + + const { stdout: log } = await exec('git log --oneline -5', { cwd: BOB_RESTORE_CLONE_PATH }); + expect(log).toContain('Merge PR'); + expect(log).toContain('add multiply function'); + + const utils = readFileSync(join(BOB_RESTORE_CLONE_PATH, 'utils.ts'), 'utf-8'); + expect(utils).toContain('multiply'); + + const restoredRepoPath = restoreServer.backend.repoPath(aliceDid, 'collab-repo'); + const { stdout: branchRef } = await exec(`git show-ref --verify ${helperBranchRef}`, { cwd: restoredRepoPath }); + expect(branchRef).toContain(helperBranchRef); + } finally { + await restoreServer.stop(); + rmSync(BOB_RESTORE_REPOS_PATH, { recursive: true, force: true }); + rmSync(BOB_RESTORE_CLONE_PATH, { recursive: true, force: true }); + } + }, 30_000); + + it('Phase 11: git-remote-did routes did:: clone to Bob local DWN helper', async () => { + rmSync(BOB_DID_REMOTE_HOME, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_BIN, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_REPOS_PATH, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_CLONE_PATH, { recursive: true, force: true }); + + const fakeAliceDid = 'did:gitd-test:alice'; + const helperBinPath = join(BOB_DID_REMOTE_BIN, 'git-remote-did'); + mkdirSync(BOB_DID_REMOTE_BIN, { recursive: true }); + writeFileSync( + helperBinPath, + `#!/usr/bin/env bash\nexec bun ${JSON.stringify(resolve('src/git-remote/main.ts'))} "$@"\n`, + 'utf-8', + ); + chmodSync(helperBinPath, 0o755); + + const restoreRepo = withAliceReads(bobRepo, aliceRepo); + const restoreRefs = withAliceReads(bobRefs, aliceRefs); + const restoreServer = await createGitServer({ + basePath : BOB_DID_REMOTE_REPOS_PATH, + port : 0, + onRepoNotFound : async (did, repoName, repoPath): Promise<boolean> => { + expect(did).toBe(fakeAliceDid); + expect(repoName).toBe('collab-repo'); + const result = await restoreFromBundles({ + repo : restoreRepo, + refs : restoreRefs, + from : aliceDid, + repoPath, + repoContextId, + }); + return result.success; + }, + }); + + const originalHome = process.env.ENBOX_HOME; + process.env.ENBOX_HOME = BOB_DID_REMOTE_HOME; + try { + writeLockfile(restoreServer.port, 'test', bobDid, { dwnHelper: true }); + } finally { + if (originalHome === undefined) { + delete process.env.ENBOX_HOME; + } else { + process.env.ENBOX_HOME = originalHome; + } + } + + try { + const env = { + ...process.env, + ENBOX_HOME : BOB_DID_REMOTE_HOME, + PATH : `${resolve(BOB_DID_REMOTE_BIN)}:${process.env.PATH ?? ''}`, + }; + const { stderr } = await exec( + `git clone --branch main "did::gitd-test:alice/collab-repo" "${BOB_DID_REMOTE_CLONE_PATH}"`, + { env }, + ); + expect(stderr).toContain('(via LocalDwnHelper)'); + + const { stdout: log } = await exec('git log --oneline -5', { cwd: BOB_DID_REMOTE_CLONE_PATH }); + expect(log).toContain('Merge PR'); + expect(log).toContain('add multiply function'); + + const utils = readFileSync(join(BOB_DID_REMOTE_CLONE_PATH, 'utils.ts'), 'utf-8'); + expect(utils).toContain('multiply'); + + const restoredRepoPath = restoreServer.backend.repoPath(fakeAliceDid, 'collab-repo'); + const { stdout: branchRef } = await exec(`git show-ref --verify ${helperBranchRef}`, { cwd: restoredRepoPath }); + expect(branchRef).toContain(helperBranchRef); + } finally { + await restoreServer.stop(); + rmSync(BOB_DID_REMOTE_HOME, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_BIN, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_REPOS_PATH, { recursive: true, force: true }); + rmSync(BOB_DID_REMOTE_CLONE_PATH, { recursive: true, force: true }); + } + }, 30_000); + + }); diff --git a/tests/e2e-passive-dwn-sync.spec.ts b/tests/e2e-passive-dwn-sync.spec.ts new file mode 100644 index 0000000..1eca1c2 --- /dev/null +++ b/tests/e2e-passive-dwn-sync.spec.ts @@ -0,0 +1,738 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { DidDocument } from '@enbox/dids'; + +import { createServer } from 'node:http'; +import { resolve } from 'node:path'; +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import { HttpDwnRpcClient } from '@enbox/dwn-clients'; +import { RecordsQuery } from '@enbox/dwn-sdk-js'; + +import { ForgeRepoDefinition } from '../src/repo.js'; +import { ForgeRefsDefinition } from '../src/refs.js'; +import { branchOwnerHash } from '../src/branch-state.js'; +import { startPassiveDwnServer } from './helpers/passive-dwn-server.js'; + +const BASE = resolve('__TESTDATA__/passive-dwn-sync-e2e'); +const ENBOX_HOME = resolve(BASE, 'home'); +const PASSWORD = 'passive-dwn-sync-e2e-password'; +const BOOTSTRAP = resolve('tests/helpers/profile-bootstrap.ts'); +const CACHE_DID_DOCS = resolve('tests/helpers/cache-did-docs.ts'); +const GITD_MAIN = resolve('src/cli/main.ts'); + +type BootstrapResult = { + profile: string; + did: string; + didDocument: DidDocument; + didDocumentMetadata?: Record<string, unknown>; +}; + +const processes: ChildProcessWithoutNullStreams[] = []; + +describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { + afterEach(async () => { + await Promise.all(processes.splice(0).map((proc) => stopProcess(proc))); + rmSync(BASE, { recursive: true, force: true }); + }); + + it('pushes Alice public repo metadata from gitd serve into the passive DWN', async () => { + rmSync(BASE, { recursive: true, force: true }); + + const passiveDwn = await startPassiveDwnServer({ dataPath: resolve(BASE, 'passive-dwn') }); + try { + const alice = bootstrapProfile('alice', passiveDwn.url); + expect(alice.didDocument.id).toBe(alice.did); + passiveDwn.addDidDocument({ + didDocument : alice.didDocument, + didDocumentMetadata : alice.didDocumentMetadata, + }); + + const init = spawnSync('bun', [ + GITD_MAIN, + 'init', + 'passive-demo', + '--no-local', + ], { + cwd : resolve('.'), + encoding : 'utf-8', + env : testEnv('alice', passiveDwn.url), + }); + if (init.status !== 0) { + throw new Error(`gitd init failed:\nstdout:\n${init.stdout}\nstderr:\n${init.stderr}`); + } + + const port = await freePort(); + const serve = await startServe('alice', passiveDwn.url, port); + processes.push(serve.proc); + + await waitFor(async () => { + const entries = await queryRemoteRepos(passiveDwn.url, alice.did, 'passive-demo'); + return entries.length === 1; + }, async () => { + const entries = await queryRemoteRepos(passiveDwn.url, alice.did, 'passive-demo'); + return `passive DWN never received Alice repo record; entries=${entries.length}\nstdout:\n${serve.stdout()}\nstderr:\n${serve.stderr()}`; + }, 20_000); + } finally { + await passiveDwn.stop(); + } + }, 90_000); + + it('runs contributor push, canonical work items, moderation, maintainer merge, and clone through passive DWN', async () => { + rmSync(BASE, { recursive: true, force: true }); + + const passiveDwn = await startPassiveDwnServer({ dataPath: resolve(BASE, 'passive-dwn') }); + try { + const alice = bootstrapProfile('alice', passiveDwn.url); + const bob = bootstrapProfile('bob', passiveDwn.url); + const casey = bootstrapProfile('casey', passiveDwn.url); + const actors = [alice, bob, casey]; + + for (const actor of actors) { + expect(actor.didDocument.id).toBe(actor.did); + passiveDwn.addDidDocument({ + didDocument : actor.didDocument, + didDocumentMetadata : actor.didDocumentMetadata, + }); + } + cacheDidDocuments(actors, passiveDwn.url); + + run('bun', [ + GITD_MAIN, + 'init', + 'passive-demo', + '--no-local', + ], { env: testEnv('alice', passiveDwn.url) }); + + const addContributor = await runEventually('bun', [ + GITD_MAIN, + 'repo', + 'add-contributor', + bob.did, + '--repo', + 'passive-demo', + ], { env: testEnv('alice', passiveDwn.url) }); + expect(addContributor.stdout).toContain('Added contributor'); + expect(addContributor.stderr).not.toContain('Database is not open'); + const addModerator = await runEventually('bun', [ + GITD_MAIN, + 'repo', + 'add-moderator', + casey.did, + '--repo', + 'passive-demo', + ], { env: testEnv('alice', passiveDwn.url) }); + expect(addModerator.stdout).toContain('Added moderator'); + expect(addModerator.stderr).not.toContain('Database is not open'); + + const alicePort = await freePort(); + const aliceServe = await startServe('alice', passiveDwn.url, alicePort); + processes.push(aliceServe.proc); + + const remoteUrl = await authenticatedRemoteUrl( + alicePort, + alice.did, + 'passive-demo', + () => `stdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`, + ); + const aliceWork = resolve(BASE, 'alice-work'); + createAndPushMain(aliceWork, remoteUrl); + + await waitFor(async () => { + return aliceServe.stderr().includes('[push-sync] dwn pushed'); + }, async () => { + return `Alice helper did not finish post-push DWN sync\nstdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`; + }, 40_000); + + await stopProcess(aliceServe.proc); + + const bobPort = await freePort(); + const bobServe = await startServe('bob', passiveDwn.url, bobPort); + processes.push(bobServe.proc); + + const binDir = resolve(BASE, 'bin'); + writeRemoteHelperWrapper(binDir); + + const bobClone = resolve(BASE, 'bob-clone'); + let clone = { stdout: '', stderr: '' }; + await waitFor(async () => { + rmSync(bobClone, { recursive: true, force: true }); + const result = await runAsyncRaw('git', [ + '-c', + 'protocol.version=0', + 'clone', + '--branch', + 'main', + `did::${alice.did}/passive-demo`, + bobClone, + ], { + cwd : resolve('.'), + timeoutMs: 60_000, + env : { + ...testEnv('bob', passiveDwn.url), + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }, + }); + clone = { + stdout : result.stdout, + stderr : result.stderr, + }; + return result.status === 0; + }, async () => { + return `Bob could not clone Alice repo through passive DWN\nstdout:\n${clone.stdout}\nstderr:\n${clone.stderr}\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`; + }, 60_000); + + expect(clone.stderr).toContain('(via LocalDwnHelper)'); + expect(readFileSync(resolve(bobClone, 'README.md'), 'utf-8')).toContain('Passive DWN demo'); + const tip = run('git', ['rev-parse', 'HEAD'], { cwd: bobClone }); + expect(tip.stdout.trim()).toMatch(/^[0-9a-f]{40}$/); + + const contributorRef = `refs/heads/users/${branchOwnerHash(bob.did)}/passive-feature`; + run('git', ['config', 'user.email', 'bob@example.com'], { cwd: bobClone }); + run('git', ['config', 'user.name', 'Bob'], { cwd: bobClone }); + run('git', ['checkout', '-b', 'passive-feature'], { cwd: bobClone }); + writeFileSync(resolve(bobClone, 'feature.txt'), 'Bob feature via passive DWN.\n', 'utf-8'); + run('git', ['add', 'feature.txt'], { cwd: bobClone }); + run('git', ['commit', '-m', 'feat: passive contributor branch'], { cwd: bobClone }); + + const pushResult = await runAsyncRaw('git', [ + 'push', + 'origin', + `HEAD:${contributorRef}`, + ], { + cwd : bobClone, + timeoutMs: 120_000, + env : { + ...testEnv('bob', passiveDwn.url), + PATH: `${binDir}:${process.env.PATH ?? ''}`, + GIT_TERMINAL_PROMPT: '0', + }, + }); + if (pushResult.status !== 0) { + throw new Error(`Bob contributor push failed with status ${pushResult.status}\nstdout:\n${pushResult.stdout}\nstderr:\n${pushResult.stderr}\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`); + } + expect(pushResult.stderr).toContain('(via LocalDwnHelper)'); + await waitFor(async () => { + return bobServe.stderr().includes('[push-sync] remote branch writeback complete') + && bobServe.stderr().includes('[dwn-apply] remote branch writeback') + && bobServe.stderr().includes(': Applied'); + }, async () => { + return `Bob contributor branch writeback did not apply to Alice passive DWN\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`; + }, 60_000); + + const issue = await runAsync('bun', [ + GITD_MAIN, + 'issue', + 'create', + 'Passive canonical issue', + '--body', + 'Created by Bob through his local helper and Alice passive DWN.', + '--repo', + 'passive-demo', + '--owner', + alice.did, + ], { + cwd : bobClone, + env : testEnv('bob', passiveDwn.url), + }); + expect(issue.stdout).toContain('Created issue'); + expect(issue.stderr).toContain('[dwn-process] issue: 202'); + expect(issue.stderr).not.toContain('Database is not open'); + + const pr = await runAsync('bun', [ + GITD_MAIN, + 'pr', + 'create', + 'Passive canonical PR', + '--body', + 'Created by Bob through his local helper and Alice passive DWN.', + '--base', + 'main', + '--repo', + 'passive-demo', + '--owner', + alice.did, + ], { + cwd : bobClone, + env : testEnv('bob', passiveDwn.url), + }); + expect(pr.stdout).toContain('Created PR'); + expect(pr.stdout).toContain('Bundle:'); + expect(pr.stderr).toContain('[dwn-apply] PR: Applied'); + const prRecordId = recordIdFromOutput(pr.stdout, 'PR'); + + const prComment = await runAsync('bun', [ + GITD_MAIN, + 'pr', + 'comment', + prRecordId, + 'Bob review note before moderator lock.', + '--repo', + 'passive-demo', + '--owner', + alice.did, + ], { + cwd : bobClone, + env : testEnv('bob', passiveDwn.url), + }); + expect(prComment.stdout).toContain('Added comment to PR'); + + const caseyPort = await freePort(); + const caseyServe = await startServe('casey', passiveDwn.url, caseyPort); + processes.push(caseyServe.proc); + + const lock = await runAsync('bun', [ + GITD_MAIN, + 'mod', + 'lock', + 'pr', + prRecordId, + '--reason', + 'heated', + '--repo', + 'passive-demo', + '--owner', + alice.did, + ], { + env: testEnv('casey', passiveDwn.url), + }); + expect(lock.stdout).toContain('Locked pr'); + expect(lock.stderr).toContain('[dwn-apply] moderation event: Applied'); + expect(lock.stderr).not.toContain('Database is not open'); + + const aliceMergePort = await freePort(); + const aliceMergeServe = await startServe('alice', passiveDwn.url, aliceMergePort); + processes.push(aliceMergeServe.proc); + + let checkout = { stdout: '', stderr: '' }; + await waitFor(async () => { + const result = await runAsyncRaw('bun', [ + GITD_MAIN, + 'pr', + 'checkout', + prRecordId, + '--repo', + 'passive-demo', + ], { + cwd : aliceWork, + timeoutMs : 30_000, + env : testEnv('alice', passiveDwn.url), + }); + checkout = { + stdout : result.stdout, + stderr : result.stderr, + }; + return result.status === 0; + }, async () => { + return `Alice could not checkout Bob PR after sync from passive DWN\nstdout:\n${checkout.stdout}\nstderr:\n${checkout.stderr}\nhelper stdout:\n${aliceMergeServe.stdout()}\nhelper stderr:\n${aliceMergeServe.stderr()}`; + }, 90_000); + + const merge = await runAsync('bun', [ + GITD_MAIN, + 'pr', + 'merge', + prRecordId, + '--no-delete-branch', + '--repo', + 'passive-demo', + ], { + cwd : aliceWork, + env : testEnv('alice', passiveDwn.url), + }); + expect(merge.stdout).toContain('Merged PR'); + + const freshRemoteUrl = await authenticatedRemoteUrl( + aliceMergePort, + alice.did, + 'passive-demo', + () => `stdout:\n${aliceMergeServe.stdout()}\nstderr:\n${aliceMergeServe.stderr()}`, + ); + run('git', ['remote', 'set-url', 'origin', freshRemoteUrl], { cwd: aliceWork }); + run('git', ['push', 'origin', 'main'], { + cwd : aliceWork, + env : { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }); + + await waitFor(async () => { + return aliceMergeServe.stderr().includes('[push-sync] dwn pushed'); + }, async () => { + return `Alice merge push did not sync to passive DWN\nstdout:\n${aliceMergeServe.stdout()}\nstderr:\n${aliceMergeServe.stderr()}`; + }, 60_000); + + await stopProcess(bobServe.proc); + rmSync(bobClone, { recursive: true, force: true }); + + const bobFinalPort = await freePort(); + const bobFinalServe = await startServe('bob', passiveDwn.url, bobFinalPort); + processes.push(bobFinalServe.proc); + + const finalClone = resolve(BASE, 'bob-final-clone'); + let finalResult = { stdout: '', stderr: '' }; + await waitFor(async () => { + rmSync(finalClone, { recursive: true, force: true }); + const result = await runAsyncRaw('git', [ + '-c', + 'protocol.version=0', + 'clone', + '--branch', + 'main', + `did::${alice.did}/passive-demo`, + finalClone, + ], { + cwd : resolve('.'), + timeoutMs: 60_000, + env : { + ...testEnv('bob', passiveDwn.url), + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }, + }); + finalResult = { + stdout : result.stdout, + stderr : result.stderr, + }; + return result.status === 0; + }, async () => { + return `Bob could not clone Alice merged repo through passive DWN\nstdout:\n${finalResult.stdout}\nstderr:\n${finalResult.stderr}\nhelper stdout:\n${bobFinalServe.stdout()}\nhelper stderr:\n${bobFinalServe.stderr()}`; + }, 90_000); + + expect(finalResult.stderr).toContain('(via LocalDwnHelper)'); + expect(readFileSync(resolve(finalClone, 'feature.txt'), 'utf-8')).toContain('Bob feature via passive DWN.'); + } finally { + await passiveDwn.stop(); + } + }, 360_000); +}); + +function bootstrapProfile(profile: string, dwnEndpoint: string): BootstrapResult { + const result = spawnSync('bun', [BOOTSTRAP, profile, PASSWORD], { + cwd : resolve('.'), + encoding : 'utf-8', + env : testEnv(profile, dwnEndpoint), + }); + + if (result.status !== 0) { + throw new Error(`profile bootstrap failed for ${profile}:\n${result.stderr}\n${result.stdout}`); + } + + return JSON.parse(result.stdout.trim()) as BootstrapResult; +} + +function cacheDidDocuments(actors: BootstrapResult[], dwnEndpoint: string): void { + const docsPath = resolve(BASE, 'did-docs.json'); + writeFileSync(docsPath, JSON.stringify(actors.map((actor) => ({ + didDocument : actor.didDocument, + didDocumentMetadata : actor.didDocumentMetadata, + }))), 'utf-8'); + + for (const actor of actors) { + run('bun', [ + CACHE_DID_DOCS, + actor.profile, + PASSWORD, + docsPath, + ], { env: testEnv(actor.profile, dwnEndpoint) }); + } +} + +async function startServe( + profile: string, + dwnEndpoint: string, + port: number, +): Promise<{ + proc: ChildProcessWithoutNullStreams; + stdout: () => string; + stderr: () => string; +}> { + const proc = spawn('bun', [ + GITD_MAIN, + 'serve', + '--foreground', + '--sync', + '1s', + '--port', + String(port), + ], { + cwd : resolve('.'), + env : testEnv(profile, dwnEndpoint), + stdio : ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + try { + await waitFor( + async () => stdout.includes('gitd server listening on port'), + async () => `gitd serve did not start\nstdout:\n${stdout}\nstderr:\n${stderr}`, + 30_000, + ); + } catch (err) { + await stopProcess(proc); + throw err; + } + + return { + proc, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +async function authenticatedRemoteUrl( + port: number, + ownerDid: string, + repoName: string, + diagnostics: () => string = () => '', +): Promise<string> { + const response = await fetch(`http://127.0.0.1:${port}/auth/token`, { + method : 'POST', + headers : { 'Content-Type': 'application/json' }, + body : JSON.stringify({ owner: ownerDid, repo: repoName }), + }); + + if (!response.ok) { + throw new Error(`auth token request failed: ${response.status} ${await response.text()}\n${diagnostics()}`); + } + + const creds = await response.json() as { username?: string; password?: string }; + if (!creds.username || !creds.password) { + throw new Error('auth token response was missing username or password'); + } + + return `http://${encodeURIComponent(creds.username)}:${encodeURIComponent(creds.password)}@127.0.0.1:${port}/${encodeURIComponent(ownerDid)}/${repoName}`; +} + +function createAndPushMain(workdir: string, remoteUrl: string): void { + mkdirSync(workdir, { recursive: true }); + run('git', ['init'], { cwd: workdir }); + run('git', ['config', 'user.email', 'alice@example.com'], { cwd: workdir }); + run('git', ['config', 'user.name', 'Alice'], { cwd: workdir }); + run('git', ['checkout', '-b', 'main'], { cwd: workdir }); + writeFileSync(resolve(workdir, 'README.md'), '# Passive DWN demo\n\nPublished from Alice edge helper.\n', 'utf-8'); + run('git', ['add', 'README.md'], { cwd: workdir }); + run('git', ['commit', '-m', 'Initial commit'], { cwd: workdir }); + run('git', ['remote', 'add', 'origin', remoteUrl], { cwd: workdir }); + run('git', ['push', '-u', 'origin', 'main'], { + cwd : workdir, + env : { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }); +} + +function writeRemoteHelperWrapper(binDir: string): void { + mkdirSync(binDir, { recursive: true }); + const helperBinPath = resolve(binDir, 'git-remote-did'); + writeFileSync( + helperBinPath, + `#!/usr/bin/env bash\nexec bun ${JSON.stringify(resolve('src/git-remote/main.ts'))} "$@"\n`, + 'utf-8', + ); + chmodSync(helperBinPath, 0o755); +} + +async function queryRemoteRepos( + dwnUrl: string, + ownerDid: string, + repoName: string, +): Promise<NonNullable<Awaited<ReturnType<HttpDwnRpcClient['sendDwnRequest']>>['entries']>> { + return queryRemoteRecords(dwnUrl, ownerDid, ForgeRepoDefinition.protocol, 'repo', { name: repoName }); +} + +async function queryRemoteRecords( + dwnUrl: string, + ownerDid: string, + protocol: string, + protocolPath: string, + tags?: Record<string, unknown>, + filter?: Record<string, unknown>, +): Promise<NonNullable<Awaited<ReturnType<HttpDwnRpcClient['sendDwnRequest']>>['entries']>> { + const query = await RecordsQuery.create({ + filter: { + protocol, + protocolPath, + ...(filter ?? {}), + ...(tags ? { tags } : {}), + }, + }); + + const reply = await new HttpDwnRpcClient().sendDwnRequest({ + dwnUrl, + targetDid : ownerDid, + message : query.message, + }); + + if (reply.status.code !== 200) { + return []; + } + + return reply.entries ?? []; +} + +function run( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): { stdout: string; stderr: string } { + const result = spawnSync(command, args, { + cwd : options.cwd ?? resolve('.'), + env : options.env ?? process.env, + encoding : 'utf-8', + }); + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + + return { + stdout : result.stdout, + stderr : result.stderr, + }; +} + +async function runAsync( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise<{ stdout: string; stderr: string }> { + const result = await runAsyncRaw(command, args, options); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + + return { + stdout : result.stdout, + stderr : result.stderr, + }; +} + +async function runEventually( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, + timeoutMs = 30_000, +): Promise<{ stdout: string; stderr: string }> { + let last = { status: 1 as number | null, stdout: '', stderr: '' }; + await waitFor(async () => { + last = await runAsyncRaw(command, args, options); + return last.status === 0; + }, async () => { + return `${command} ${args.join(' ')} did not succeed\nstdout:\n${last.stdout}\nstderr:\n${last.stderr}`; + }, timeoutMs); + + return { + stdout : last.stdout, + stderr : last.stderr, + }; +} + +async function runAsyncRaw( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + const child = spawn(command, args, { + cwd : options.cwd ?? resolve('.'), + env : options.env ?? process.env, + stdio : ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + let timedOut = false; + const timer = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, options.timeoutMs) + : undefined; + + const status = await new Promise<number | null>((resolveExit) => { + child.once('exit', (code) => resolveExit(code)); + child.once('error', (err) => { + stderr += `\nspawn error: ${err.message}`; + resolveExit(1); + }); + }); + + if (timer) { clearTimeout(timer); } + if (timedOut) { + stderr += `\ntimeout after ${options.timeoutMs}ms`; + } + + return { status, stdout, stderr }; +} + +function recordIdFromOutput(stdout: string, label: string): string { + const match = stdout.match(/Record ID:\s+(\S+)/); + if (!match) { + throw new Error(`Could not parse ${label} record ID from output:\n${stdout}`); + } + return match[1]; +} + +function testEnv(profile: string, dwnEndpoint: string): NodeJS.ProcessEnv { + return { + ...process.env, + ENBOX_HOME, + GITD_PROFILE : profile, + GITD_PASSWORD : PASSWORD, + GITD_DWN_ENDPOINT : dwnEndpoint, + GITD_DWN_REGISTRATION : 'off', + GITD_DID_REPUBLISH : 'off', + GITD_DEBUG : '1', + GITD_DID_RESOLUTION_TIMEOUT_MS: '1000', + }; +} + +async function freePort(): Promise<number> { + const server = createServer(); + await new Promise<void>((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise<void>((resolveClose) => server.close(() => resolveClose())); + return port; +} + +async function waitFor( + predicate: () => boolean | Promise<boolean>, + errorMessage: () => string | Promise<string>, + timeoutMs: number, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { return; } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(await errorMessage()); +} + +async function stopProcess(proc: ChildProcessWithoutNullStreams): Promise<void> { + if (proc.killed || proc.exitCode !== null) { return; } + proc.kill('SIGINT'); + await new Promise<void>((resolveStop) => { + const timer = setTimeout(() => { + if (!proc.killed && proc.exitCode === null) { + proc.kill('SIGKILL'); + } + resolveStop(); + }, 2_000); + proc.once('exit', () => { + clearTimeout(timer); + resolveStop(); + }); + }); +} diff --git a/tests/e2e-profile-helpers.spec.ts b/tests/e2e-profile-helpers.spec.ts new file mode 100644 index 0000000..3e23f14 --- /dev/null +++ b/tests/e2e-profile-helpers.spec.ts @@ -0,0 +1,207 @@ +import { afterAll, describe, expect, it } from 'bun:test'; + +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { createServer } from 'node:http'; +import { resolve } from 'node:path'; +import { rmSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import { readLockfile } from '../src/daemon/lockfile.js'; + +const BASE = resolve('__TESTDATA__/profile-helper-e2e'); +const ENBOX_HOME = resolve(BASE, 'home'); +const PASSWORD = 'profile-helper-e2e-password'; +const BOOTSTRAP = resolve('tests/helpers/profile-bootstrap.ts'); +const GITD_MAIN = resolve('src/cli/main.ts'); + +type Actor = { + profile: 'alice' | 'bob' | 'casey'; + did: string; + port: number; + proc: ChildProcessWithoutNullStreams; +}; + +const actors: Actor[] = []; + +describe('E2E: profile-backed spawned gitd helpers', () => { + afterAll(async () => { + await Promise.all(actors.map((actor) => stopProcess(actor.proc))); + rmSync(BASE, { recursive: true, force: true }); + }); + + it('starts Alice, Bob, and Casey helpers as separate profile-scoped processes', async () => { + rmSync(BASE, { recursive: true, force: true }); + + const alice = bootstrapProfile('alice'); + const bob = bootstrapProfile('bob'); + const casey = bootstrapProfile('casey'); + + expect(new Set([alice.did, bob.did, casey.did]).size).toBe(3); + + runGitd('alice', ['init', 'helper-demo', '--no-local']); + + for (const profile of ['alice', 'bob', 'casey'] as const) { + const actor = await startServeWithRetry(profile); + actors.push(actor); + } + + const originalHome = process.env.ENBOX_HOME; + process.env.ENBOX_HOME = ENBOX_HOME; + try { + for (const actor of actors) { + const lock = readLockfile(actor.profile); + expect(lock).not.toBeNull(); + expect(lock!.ownerDid).toBe(actor.did); + expect(lock!.port).toBe(actor.port); + expect(lock!.dwnHelper).toBe(true); + + const health = await fetch(`http://127.0.0.1:${actor.port}/health`); + expect(health.status).toBe(200); + } + + const repoInfo = runGitd('alice', ['repo', 'info', '--repo', 'helper-demo']); + expect(repoInfo.stdout).toContain('Repository: helper-demo'); + expect(repoInfo.stdout).toContain(`DID: ${alice.did}`); + expect(repoInfo.stderr).not.toContain('Database is not open'); + } finally { + if (originalHome === undefined) { + delete process.env.ENBOX_HOME; + } else { + process.env.ENBOX_HOME = originalHome; + } + } + }, 90_000); +}); + +function bootstrapProfile(profile: Actor['profile']): { did: string } { + const result = spawnSync('bun', [BOOTSTRAP, profile, PASSWORD], { + cwd : resolve('.'), + encoding : 'utf-8', + env : testEnv(profile), + }); + + if (result.status !== 0) { + throw new Error(`profile bootstrap failed for ${profile}:\n${result.stderr}\n${result.stdout}`); + } + + return JSON.parse(result.stdout.trim()) as { did: string }; +} + +function runGitd(profile: Actor['profile'], args: string[]): { stdout: string; stderr: string } { + const result = spawnSync('bun', [GITD_MAIN, ...args], { + cwd : resolve('.'), + encoding : 'utf-8', + timeout : 30_000, + env : testEnv(profile), + }); + + if (result.status !== 0) { + throw new Error(`gitd ${args.join(' ')} failed for ${profile}:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + + return { + stdout : result.stdout, + stderr : result.stderr, + }; +} + +async function startServe(profile: Actor['profile'], port: number): Promise<Actor> { + const proc = spawn('bun', [ + GITD_MAIN, + 'serve', + '--foreground', + '--no-sync', + '--port', + String(port), + ], { + cwd : resolve('.'), + env : testEnv(profile), + stdio : ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + try { + await waitFor(() => stdout.includes('gitd server listening on port'), () => + `gitd serve did not start for ${profile}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } catch (err) { + await stopProcess(proc); + throw err; + } + + const did = stdout.match(/DID:\s+(did:[^\s]+)/)?.[1]; + if (!did) { + throw new Error(`Could not parse DID from ${profile} serve output:\n${stdout}`); + } + + return { profile, did, port, proc }; +} + +async function startServeWithRetry(profile: Actor['profile']): Promise<Actor> { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + const port = await freePort(); + try { + return await startServe(profile, port); + } catch (err) { + lastError = err; + if (!String((err as Error).message).includes('is already in use')) { + break; + } + } + } + throw lastError; +} + +function testEnv(profile: string): NodeJS.ProcessEnv { + return { + ...process.env, + ENBOX_HOME, + GITD_PROFILE : profile, + GITD_PASSWORD : PASSWORD, + GITD_DWN_REGISTRATION : 'off', + GITD_DID_REPUBLISH : 'off', + GITD_SYNC : 'off', + }; +} + +async function freePort(): Promise<number> { + const server = createServer(); + await new Promise<void>((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise<void>((resolveClose) => server.close(() => resolveClose())); + return port; +} + +async function waitFor(predicate: () => boolean, errorMessage: () => string): Promise<void> { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (predicate()) { return; } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(errorMessage()); +} + +async function stopProcess(proc: ChildProcessWithoutNullStreams): Promise<void> { + if (proc.killed || proc.exitCode !== null) { return; } + proc.kill('SIGINT'); + await new Promise<void>((resolveStop) => { + const timer = setTimeout(() => { + if (!proc.killed && proc.exitCode === null) { + proc.kill('SIGKILL'); + } + resolveStop(); + }, 2_000); + proc.once('exit', () => { + clearTimeout(timer); + resolveStop(); + }); + }); +} diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index 6a98fbf..b3dcb78 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -26,6 +26,7 @@ import { EnboxUserAgent } from '@enbox/agent'; import type { AgentContext } from '../src/cli/agent.js'; import type { GitServer } from '../src/git-server/server.js'; import type { RepoContext } from '../src/cli/repo-context.js'; +import type { PushRefUpdate } from '../src/git-server/push-updates.js'; import { createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { createDidSignatureVerifier } from '../src/git-server/verify.js'; @@ -194,6 +195,37 @@ describe('E2E: init → serve → clone → push → verify', () => { expect(refData.target).toMatch(/^[0-9a-f]{40}$/); }); + it('should sync branch state checkpoints to DWN after push', async () => { + const repoPath = server.backend.repoPath(did, 'e2e-test-repo'); + const syncer = createRefSyncer({ refs, repoContextId }); + await syncer(did, 'e2e-test-repo', repoPath); + + const { records: branchRecords } = await refs.records.query('repo/branch' as any, { + filter: { contextId: repoContextId }, + }); + expect(branchRecords.length).toBeGreaterThanOrEqual(1); + + const branches = await Promise.all(branchRecords.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const mainBranch = branches.find((entry) => entry.data.refName === 'refs/heads/main'); + expect(mainBranch).toBeDefined(); + expect(mainBranch!.data.kind).toBe('protected'); + expect(mainBranch!.data.ownerDid).toBe(did); + + const { records: stateRecords } = await refs.records.query('repo/branch/state' as any, { + filter: { contextId: mainBranch!.record.contextId }, + }); + expect(stateRecords).toHaveLength(1); + + const checkpoint = await stateRecords[0].data.json(); + expect(checkpoint.kind).toBe('checkpoint'); + expect(checkpoint.refName).toBe('refs/heads/main'); + expect(checkpoint.target).toMatch(/^[0-9a-f]{40}$/); + expect(checkpoint.actorDid).toBe(did); + }); + it('should handle a second push updating the ref', async () => { await exec('echo "update" >> README.md', { cwd: CLONE_PATH }); await exec('git add README.md', { cwd: CLONE_PATH }); @@ -283,6 +315,7 @@ describe('E2E: push → bundle sync → cold start → clone via restore', () => const bundleSyncer = createBundleSyncer({ repo : repoHandle, + refs : refsHandle, repoContextId : repoContextId, visibility : 'public', }); @@ -340,6 +373,26 @@ describe('E2E: push → bundle sync → cold start → clone via restore', () => expect(tags.tipCommit).toMatch(/^[0-9a-f]{40}$/); }); + it('should have a branch-scoped bundle record in the DWN', async () => { + const { records: branchRecords } = await refsHandle.records.query('repo/branch' as any, { + filter: { contextId: repoContextId }, + }); + const branches = await Promise.all(branchRecords.map(async (record: any) => ({ + record, + data: await record.data.json(), + }))); + const mainBranch = branches.find((entry) => entry.data.refName === 'refs/heads/main'); + expect(mainBranch).toBeDefined(); + + const { records: bundleRecords } = await refsHandle.records.query('repo/branch/bundle' as any, { + filter: { contextId: mainBranch!.record.contextId }, + }); + expect(bundleRecords).toHaveLength(1); + expect(bundleRecords[0].tags.kind).toBe('checkpoint'); + expect(bundleRecords[0].tags.refName).toBe('refs/heads/main'); + expect(bundleRecords[0].tags.tipCommit).toMatch(/^[0-9a-f]{40}$/); + }); + it('should restore the repo from bundles on cold start and serve a clone', async () => { // Stop the original server. await server.stop(); @@ -448,7 +501,12 @@ describe('E2E: authenticated push with DID-signed tokens', () => { ownerDid : ownerDid, }); - const authenticatePush = async (request: Request, did: string, repo: string): Promise<boolean> => { + const authenticatePush = async ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Basic ')) { return false; } @@ -473,7 +531,7 @@ describe('E2E: authenticated push with DID-signed tokens', () => { const signatureBytes = new Uint8Array(Buffer.from(signed.signature, 'base64url')); if (!(await verifySignature(payload.did, tokenBytes, signatureBytes))) { return false; } - return authorizePush(payload.did, did, repo); + return authorizePush(payload.did, did, repo, updates); }; const refSyncer = createRefSyncer({ @@ -742,7 +800,12 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', ownerDid : profileDid, }); - const authenticatePush = async (request: Request, did: string, repo: string): Promise<boolean> => { + const authenticatePush = async ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Basic ')) { return false; } @@ -767,7 +830,7 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', const signatureBytes = new Uint8Array(Buffer.from(signed.signature, 'base64url')); if (!(await verifySignature(payload.did, tokenBytes, signatureBytes))) { return false; } - return authorizePush(payload.did, did, repo); + return authorizePush(payload.did, did, repo, updates); }; const refSyncer = createRefSyncer({ @@ -777,6 +840,7 @@ describe('E2E: profile-based agent → repo → serve → clone → auth push', const bundleSyncer = createBundleSyncer({ repo : repoHandle, + refs : refsHandle, repoContextId : repoContextId, visibility : 'public', }); @@ -1020,14 +1084,13 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' const syncBundle = createBundleSyncer({ repo : repoHandle, + refs : refsHandle, repoContextId : repoCtx.contextId, visibility : repoCtx.visibility, }); - await Promise.all([ - syncRefs(_did, repoName, repoPath), - syncBundle(_did, repoName, repoPath), - ]); + await syncRefs(_did, repoName, repoPath); + await syncBundle(_did, repoName, repoPath); }; server = await createGitServer({ @@ -1142,8 +1205,8 @@ describe('E2E: multi-repo — two repos, dynamic context, scoped sync + restore' if (existingAlphaBundles.length === 0 || existingBetaBundles.length === 0) { const alphaPath = server.backend.repoPath(did, 'repo-alpha'); const betaPath = server.backend.repoPath(did, 'repo-beta'); - const alphaSync = createBundleSyncer({ repo: repoHandle, repoContextId: alphaCtx.contextId, visibility: 'public' }); - const betaSync = createBundleSyncer({ repo: repoHandle, repoContextId: betaCtx.contextId, visibility: 'public' }); + const alphaSync = createBundleSyncer({ repo: repoHandle, refs: refsHandle, repoContextId: alphaCtx.contextId, visibility: 'public' }); + const betaSync = createBundleSyncer({ repo: repoHandle, refs: refsHandle, repoContextId: betaCtx.contextId, visibility: 'public' }); await alphaSync(did, 'repo-alpha', alphaPath); await betaSync(did, 'repo-beta', betaPath); } diff --git a/tests/exports.spec.ts b/tests/exports.spec.ts index c2916cb..c0e4033 100644 --- a/tests/exports.spec.ts +++ b/tests/exports.spec.ts @@ -45,6 +45,7 @@ describe('@enbox/gitd/git-server exports', () => { expect(typeof mod.createBundleSyncer).toBe('function'); expect(typeof mod.createFullBundle).toBe('function'); expect(typeof mod.createIncrementalBundle).toBe('function'); + expect(typeof mod.createBranchBundle).toBe('function'); expect(typeof mod.restoreFromBundles).toBe('function'); }); @@ -61,12 +62,23 @@ describe('@enbox/gitd/git-server exports', () => { expect(typeof mod.createDidSignatureVerifier).toBe('function'); }); + it('should export push update parsing utilities', async () => { + const mod = await import('../src/git-server/index.js'); + expect(typeof mod.parseReceivePackUpdates).toBe('function'); + expect(typeof mod.parseReceivePackUpdatesFromRequest).toBe('function'); + }); + + it('should export remote branch sync utilities', async () => { + const mod = await import('../src/git-server/index.js'); + expect(typeof mod.syncRemoteBranchPush).toBe('function'); + }); + it('should export exactly the expected number of symbols', async () => { const mod = await import('../src/git-server/index.js'); const exported = Object.keys(mod); - // 20 functions + 1 constant + 1 class = 22 runtime exports + // 24 functions + 1 constant + 1 class = 26 runtime exports // (types are erased at runtime) - expect(exported.length).toBe(22); + expect(exported.length).toBe(26); }); }); diff --git a/tests/git-auth.spec.ts b/tests/git-auth.spec.ts index b65b2d1..4b5346f 100644 --- a/tests/git-auth.spec.ts +++ b/tests/git-auth.spec.ts @@ -242,13 +242,15 @@ describe('createPushAuthenticator', () => { it('should call authorizePush when provided and signature is valid', async () => { let authorizeCalled = false; + const updates = [{ oldTarget: null, newTarget: 'a'.repeat(40), refName: 'refs/heads/main' }]; const authenticator = createPushAuthenticator({ verifySignature : alwaysValid, - authorizePush : async (did, owner, repo) => { + authorizePush : async (did, owner, repo, receivedUpdates) => { authorizeCalled = true; expect(did).toBe(TEST_DID); expect(owner).toBe(OWNER_DID); expect(repo).toBe(REPO); + expect(receivedUpdates).toEqual(updates); return true; }, }); @@ -258,7 +260,7 @@ describe('createPushAuthenticator', () => { const signed = { signature: 'fake-sig', token }; const request = makeAuthRequest(signed); - const result = await authenticator(request, OWNER_DID, REPO); + const result = await authenticator(request, OWNER_DID, REPO, updates); expect(result).toBe(true); expect(authorizeCalled).toBe(true); }); diff --git a/tests/git-remote.spec.ts b/tests/git-remote.spec.ts index 0b1c84c..02e0c0f 100644 --- a/tests/git-remote.spec.ts +++ b/tests/git-remote.spec.ts @@ -7,9 +7,9 @@ */ import { DidDht } from '@enbox/dids'; import { parseDidUrl } from '../src/git-remote/parse-url.js'; -import { resolveGitEndpoint } from '../src/git-remote/resolve.js'; +import { __setResolverForTests, resolveGitEndpoint } from '../src/git-remote/resolve.js'; -import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import { createGitTransportService, @@ -465,13 +465,13 @@ describe('resolveGitEndpoint with local daemon', () => { it('should resolve via local daemon when lockfile has no ownerDid (backwards compat)', async () => { const result = await resolveGitEndpoint('did:dht:abc123', 'my-repo'); expect(result.source).toBe('LocalDaemon'); - expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123/my-repo`); + expect(result.url).toBe(`http://127.0.0.1:${port}/did%3Adht%3Aabc123/my-repo`); }); it('should resolve without repo name via local daemon', async () => { const result = await resolveGitEndpoint('did:dht:abc123'); expect(result.source).toBe('LocalDaemon'); - expect(result.url).toBe(`http://localhost:${port}/did:dht:abc123`); + expect(result.url).toBe(`http://127.0.0.1:${port}/did%3Adht%3Aabc123`); }); }); @@ -497,7 +497,11 @@ describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { resolve(); }); }); - // Write a lockfile with ownerDid set. + }); + + beforeEach(() => { + // Default to a daemon that serves only its owner DID. Individual tests + // opt into DWN-helper capability when they need remote-owner routing. writeLockfile(port, '1.0.0', ownerDid); }); @@ -506,10 +510,14 @@ describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { server.close(); }); + afterEach(() => { + __setResolverForTests(); + }); + it('should use local daemon when requested DID matches ownerDid', async () => { const result = await resolveGitEndpoint(ownerDid, 'my-repo'); expect(result.source).toBe('LocalDaemon'); - expect(result.url).toBe(`http://localhost:${port}/${ownerDid}/my-repo`); + expect(result.url).toBe(`http://127.0.0.1:${port}/${encodeURIComponent(ownerDid)}/my-repo`); }); it('should skip local daemon when requested DID differs from ownerDid', async () => { @@ -518,4 +526,73 @@ describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { await expect(resolveGitEndpoint(remoteDid, 'their-repo')) .rejects.toThrow(); }); + + it('should use local DWN helper for a remote DID with a DWN service', async () => { + writeLockfile(port, '1.0.0', ownerDid, { dwnHelper: true }); + + __setResolverForTests({ + resolve: async (did: string) => ({ + didDocument: { + id : did, + service : [ + { id: '#dwn', type: 'DecentralizedWebNode', serviceEndpoint: 'https://dwn.example.com' }, + ], + }, + didDocumentMetadata : {}, + didResolutionMetadata : {}, + } as any), + }); + + const result = await resolveGitEndpoint(remoteDid, 'their-repo'); + expect(result.source).toBe('LocalDwnHelper'); + expect(result.did).toBe(remoteDid); + expect(result.url).toBe(`http://127.0.0.1:${port}/${encodeURIComponent(remoteDid)}/their-repo`); + }); + + it('should prefer GitTransport over the local DWN helper', async () => { + writeLockfile(port, '1.0.0', ownerDid, { dwnHelper: true }); + + __setResolverForTests({ + resolve: async (did: string) => ({ + didDocument: { + id : did, + service : [ + { id: '#dwn', type: 'DecentralizedWebNode', serviceEndpoint: 'https://dwn.example.com' }, + { id: '#git', type: 'GitTransport', serviceEndpoint: 'https://git.example.com/repos' }, + ], + }, + didDocumentMetadata : {}, + didResolutionMetadata : {}, + } as any), + }); + + const result = await resolveGitEndpoint(remoteDid, 'their-repo'); + expect(result.source).toBe('GitTransport'); + expect(result.url).toBe(`https://git.example.com/repos/${encodeURIComponent(remoteDid)}/their-repo`); + }); + + it('should not use local DWN helper when the remote DID has no DWN service', async () => { + writeLockfile(port, '1.0.0', ownerDid, { dwnHelper: true }); + + __setResolverForTests({ + resolve: async (did: string) => ({ + didDocument : { id: did, service: [] }, + didDocumentMetadata : {}, + didResolutionMetadata : {}, + } as any), + }); + + await expect(resolveGitEndpoint(remoteDid, 'their-repo')) + .rejects.toThrow('No GitTransport service found'); + }); + + it('should fall back to an advertised local DWN helper when DID resolution fails', async () => { + writeLockfile(port, '1.0.0', ownerDid, { dwnHelper: true }); + + const unresolvedDid = 'did:gitd-test:alice'; + const result = await resolveGitEndpoint(unresolvedDid, 'their-repo'); + expect(result.source).toBe('LocalDwnHelper'); + expect(result.did).toBe(unresolvedDid); + expect(result.url).toBe(`http://127.0.0.1:${port}/${encodeURIComponent(unresolvedDid)}/their-repo`); + }); }); diff --git a/tests/git-server.spec.ts b/tests/git-server.spec.ts index a624a35..3e51223 100644 --- a/tests/git-server.spec.ts +++ b/tests/git-server.spec.ts @@ -290,6 +290,23 @@ describe('createGitHttpHandler', () => { expect(res.status).toBe(401); }); + it('should allow receive-pack ref discovery when discovery auth is disabled', async () => { + let authCalled = false; + const authHandler = createGitHttpHandler({ + backend, + authenticatePush: async () => { + authCalled = true; + return false; + }, + authenticateReceivePackDiscovery: false, + }); + + const req = new Request(`http://localhost/${TEST_DID}/${TEST_REPO}/info/refs?service=git-receive-pack`); + const res = await authHandler(req); + expect(res.status).toBe(200); + expect(authCalled).toBe(false); + }); + it('should not call auth for upload-pack (read operations)', async () => { let authCalled = false; const authHandler = createGitHttpHandler({ @@ -305,6 +322,31 @@ describe('createGitHttpHandler', () => { expect(res.status).toBe(200); expect(authCalled).toBe(false); }); + + it('should pass receive-pack ref updates to auth callback', async () => { + let refName: string | undefined; + const authHandler = createGitHttpHandler({ + backend, + authenticatePush: async (_request, _did, _repo, updates) => { + refName = updates?.[0]?.refName; + return false; + }, + }); + + const oldTarget = '0'.repeat(40); + const newTarget = '1'.repeat(40); + const command = `${oldTarget} ${newTarget} refs/heads/main\0report-status\n`; + const body = (command.length + 4).toString(16).padStart(4, '0') + command + '0000'; + + const req = new Request(`http://localhost/${TEST_DID}/${TEST_REPO}/git-receive-pack`, { + method : 'POST', + body, + headers : { 'Content-Type': 'application/x-git-receive-pack-request' }, + }); + const res = await authHandler(req); + expect(res.status).toBe(401); + expect(refName).toBe('refs/heads/main'); + }); }); describe('onPushComplete callback', () => { diff --git a/tests/helpers/cache-did-docs.ts b/tests/helpers/cache-did-docs.ts new file mode 100644 index 0000000..3ac23b0 --- /dev/null +++ b/tests/helpers/cache-did-docs.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env bun +import { readFileSync } from 'node:fs'; + +import { connectAgent } from '../../src/cli/agent.js'; +import { profileDataPath } from '../../src/profiles/config.js'; + +type DidCacheEntry = { + didDocument?: { id?: string }; + didDocumentMetadata?: Record<string, unknown>; +}; + +const [profileName, password, docsPath] = process.argv.slice(2); + +if (!profileName || !password || !docsPath) { + console.error('Usage: cache-did-docs.ts <profile> <password> <docs-json>'); + process.exit(1); +} + +try { + const entries = JSON.parse(readFileSync(docsPath, 'utf-8')) as DidCacheEntry[]; + const ctx = await connectAgent({ + password, + dataPath : profileDataPath(profileName), + sync : 'off', + registration : false, + }); + + const agent = ctx.enbox.agent as any; + for (const entry of entries) { + const did = entry.didDocument?.id; + if (!did) { continue; } + await agent.did.cache.set(did, { + didDocument : entry.didDocument, + didDocumentMetadata : entry.didDocumentMetadata ?? {}, + didResolutionMetadata : {}, + }); + } + + process.exit(0); +} catch (err) { + console.error((err as Error).message); + process.exit(1); +} diff --git a/tests/helpers/passive-dwn-server.ts b/tests/helpers/passive-dwn-server.ts new file mode 100644 index 0000000..80e51bf --- /dev/null +++ b/tests/helpers/passive-dwn-server.ts @@ -0,0 +1,350 @@ +import type { DidDocument, DidResolutionOptions, DidResolutionResult, DidResolver } from '@enbox/dids'; +import type { JsonRpcId } from '@enbox/dwn-clients'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Dialect } from '@enbox/dwn-sql-store'; + +import { Buffer } from 'node:buffer'; +import { createServer } from 'node:http'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Kysely } from 'kysely'; +import { + DataStream, + DurableEventLog, + Dwn, + EventEmitterWakePublisher, + Message, +} from '@enbox/dwn-sdk-js'; +import { + DidDht, + DidJwk, + DidKey, + DidWeb, + UniversalResolver, +} from '@enbox/dids'; +import { + createBunSqliteDatabase, + DataStoreSql, + MessageStoreSql, + ResumableTaskStoreSql, + runDwnStoreMigrations, + SqliteDialect, +} from '@enbox/dwn-sql-store'; + +export type SeedDidDocument = { + didDocument: DidDocument; + didDocumentMetadata?: Record<string, unknown>; +}; + +export type PassiveDwnServer = { + url: string; + port: number; + addDidDocument: (entry: SeedDidDocument) => void; + stop: () => Promise<void>; +}; + +export async function startPassiveDwnServer(options: { + dataPath: string; + didDocuments?: Iterable<SeedDidDocument>; +}): Promise<PassiveDwnServer> { + mkdirSync(options.dataPath, { recursive: true }); + + const didResolver = new SeededDidResolver(options.didDocuments ?? []); + const dwn = await createPassiveDwn(options.dataPath, didResolver); + const server = createServer((req, res) => { + handleRequest(dwn, req, res).catch((err) => { + sendJson(res, 500, { + jsonrpc : '2.0', + id : null, + error : { + code : -32603, + message : (err as Error).message, + }, + }); + }); + }); + + await new Promise<void>((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + return { + url : `http://127.0.0.1:${port}`, + port, + addDidDocument: (entry) => { didResolver.add(entry); }, + stop : async () => { + await new Promise<void>((resolveClose) => server.close(() => resolveClose())); + await dwn.close(); + }, + }; +} + +async function createPassiveDwn( + dataPath: string, + didResolver: DidResolver, +): Promise<Dwn> { + const sqliteDb = createBunSqliteDatabase(join(dataPath, 'dwn.sqlite')); + const dialect: Dialect = new SqliteDialect({ database: async () => sqliteDb }); + const migrationDb = new Kysely<Record<string, unknown>>({ dialect }); + await runDwnStoreMigrations(migrationDb, dialect); + + const wakePublisher = new EventEmitterWakePublisher(); + const messageStore = new MessageStoreSql(dialect, wakePublisher); + const dataStore = new DataStoreSql(dialect); + const resumableTaskStore = new ResumableTaskStoreSql(dialect); + const eventLog = new DurableEventLog(messageStore, wakePublisher); + + return Dwn.create({ + dataStore, + messageStore, + resumableTaskStore, + eventLog, + didResolver, + }); +} + +class SeededDidResolver implements DidResolver { + private readonly documents = new Map<string, SeedDidDocument>(); + private readonly fallback = new UniversalResolver({ + didResolvers : [DidDht, DidJwk, DidKey, DidWeb], + }); + + constructor(didDocuments: Iterable<SeedDidDocument>) { + for (const entry of didDocuments) { + this.add(entry); + } + } + + add(entry: SeedDidDocument): void { + this.documents.set(entry.didDocument.id, entry); + } + + async resolve(didUrl: string, options?: DidResolutionOptions): Promise<DidResolutionResult> { + const did = didUrl.split(/[?#]/, 1)[0]; + const entry = this.documents.get(did); + if (entry) { + return { + didDocument : entry.didDocument, + didDocumentMetadata : entry.didDocumentMetadata ?? {}, + didResolutionMetadata : {}, + }; + } + + return this.fallback.resolve(didUrl, options); + } +} + +async function handleRequest( + dwn: Dwn, + req: IncomingMessage, + res: ServerResponse, +): Promise<void> { + if (req.method === 'GET' && req.url?.split('?')[0] === '/info') { + sendJson(res, 200, { + maxFileSize : 250_000_000, + maxInFlight : 64, + registrationRequirements : [], + server : '@enbox/dwn-server', + sdkVersion : 'test', + url : requestBaseUrl(req), + version : 'test', + webSocketSupport : false, + }); + return; + } + + if (req.method !== 'POST') { + sendJson(res, 404, { error: 'not found' }); + return; + } + + const requestHeader = req.headers['dwn-request']; + const requestText = Array.isArray(requestHeader) ? requestHeader[0] : requestHeader; + if (!requestText) { + sendJson(res, 400, { error: 'missing dwn-request header' }); + return; + } + + const rpcRequest = JSON.parse(requestText) as { + id?: JsonRpcId; + method?: string; + params?: { target?: string; message?: any }; + }; + const data = await readBody(req); + const dataStream = data.byteLength > 0 ? DataStream.fromBytes(data) : undefined; + const target = rpcRequest.params?.target; + const message = rpcRequest.params?.message; + if (!target || !message) { + sendJsonRpcError(res, rpcRequest.id ?? null, -32602, 'missing target or message'); + return; + } + + if (rpcRequest.method === 'dwn.processMessage') { + debugDwn('process start', target, message); + const reply = await withDwnTimeout( + dwn.processMessage(target, message, dataStream ? { dataStream } : undefined), + 'processMessage', + target, + message, + ); + debugDwn(`process done status=${reply.status?.code ?? '<none>'}`, target, message); + await sendProcessMessageReply(res, rpcRequest.id ?? null, reply); + return; + } + + if (rpcRequest.method === 'dwn.applyReplicatedMessage') { + debugDwn('apply start', target, message); + const result = await withDwnTimeout( + dwn.applyReplicatedMessage(target, message, dataStream ? { dataStream } : undefined), + 'applyReplicatedMessage', + target, + message, + ); + debugDwn(`apply done kind=${result.kind ?? '<none>'}`, target, message); + sendJson(res, 200, { + jsonrpc : '2.0', + id : rpcRequest.id ?? null, + result : { result }, + }); + return; + } + + sendJsonRpcError(res, rpcRequest.id ?? null, -32601, `unsupported method: ${rpcRequest.method ?? ''}`); +} + +async function withDwnTimeout<T>( + promise: Promise<T>, + operation: string, + target: string, + message: any, +): Promise<T> { + let timeout: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + promise, + new Promise<T>((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${operation} timed out for ${describeDwnMessage(target, message)}`)); + }, 10_000); + }), + ]); + } finally { + if (timeout) { clearTimeout(timeout); } + } +} + +function debugDwn(event: string, target: string, message: any): void { + if (process.env.GITD_PASSIVE_DWN_DEBUG !== '1') { + return; + } + console.error(`[passive-dwn] ${event} ${describeDwnMessage(target, message)}`); +} + +function describeDwnMessage(target: string, message: any): string { + const descriptor = message?.descriptor ?? {}; + let author = '<anonymous>'; + try { + author = Message.getSigner(message) ?? '<anonymous>'; + } catch { + // Anonymous messages and malformed debug inputs can still be described. + } + + return [ + `target=${target}`, + `interface=${descriptor.interface ?? '<none>'}`, + `method=${descriptor.method ?? '<none>'}`, + `protocol=${descriptor.protocol ?? '<none>'}`, + `path=${descriptor.protocolPath ?? '<none>'}`, + `author=${author}`, + ].join(' '); +} + +async function sendProcessMessageReply( + res: ServerResponse, + id: JsonRpcId, + reply: any, +): Promise<void> { + const { reply: serializableReply, dataStream } = extractReplyDataStream(reply); + const envelope = { + jsonrpc : '2.0', + id, + result : { reply: serializableReply }, + }; + + if (dataStream) { + const bytes = await DataStream.toBytes(dataStream); + res.writeHead(200, { + 'content-type' : 'application/octet-stream', + 'dwn-response' : JSON.stringify(envelope), + }); + res.end(Buffer.from(bytes)); + return; + } + + sendJson(res, 200, envelope); +} + +function extractReplyDataStream(reply: any): { reply: any; dataStream?: ReadableStream<Uint8Array> } { + if (reply.entry?.data) { + return { + reply: { + ...reply, + entry: { + ...reply.entry, + data: undefined, + }, + }, + dataStream: reply.entry.data, + }; + } + + if (reply.record?.data) { + return { + reply: { + ...reply, + record: { + ...reply.record, + data: undefined, + }, + }, + dataStream: reply.record.data, + }; + } + + return { reply }; +} + +async function readBody(req: IncomingMessage): Promise<Uint8Array> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function sendJsonRpcError( + res: ServerResponse, + id: JsonRpcId, + code: number, + message: string, +): void { + sendJson(res, 200, { + jsonrpc : '2.0', + id, + error : { code, message }, + }); +} + +function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function requestBaseUrl(req: IncomingMessage): string { + const host = req.headers.host ?? '127.0.0.1'; + return `http://${host}`; +} diff --git a/tests/helpers/profile-bootstrap.ts b/tests/helpers/profile-bootstrap.ts new file mode 100644 index 0000000..107bf09 --- /dev/null +++ b/tests/helpers/profile-bootstrap.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun +import { connectAgent } from '../../src/cli/agent.js'; +import { profileDataPath, upsertProfile } from '../../src/profiles/config.js'; + +const [profileName, password] = process.argv.slice(2); + +if (!profileName || !password) { + console.error('Usage: profile-bootstrap.ts <profile> <password>'); + process.exit(1); +} + +try { + const result = await connectAgent({ + password, + dataPath : profileDataPath(profileName), + sync : 'off', + registration : false, + }); + + upsertProfile(profileName, { + name : profileName, + did : result.did, + createdAt : new Date().toISOString(), + }); + + const agent = result.enbox.agent as any; + const localDid = result.did === agent.agentDid?.uri + ? agent.agentDid + : await agent.did.get({ didUri: result.did, tenant: agent.agentDid?.uri }); + const portableDid = localDid ? await localDid.export() : undefined; + + console.log(JSON.stringify({ + profile : profileName, + did : result.did, + didDocument : portableDid?.document, + didDocumentMetadata : portableDid?.metadata, + })); + process.exit(0); +} catch (err) { + console.error((err as Error).message); + process.exit(1); +} diff --git a/tests/helpers/test-daemon.ts b/tests/helpers/test-daemon.ts index b582147..50b695e 100644 --- a/tests/helpers/test-daemon.ts +++ b/tests/helpers/test-daemon.ts @@ -35,6 +35,7 @@ import { formatAuthPassword, parseAuthPassword, } from '../../src/git-server/auth.js'; +import type { PushRefUpdate } from '../../src/git-server/push-updates.js'; // --------------------------------------------------------------------------- // Config from environment @@ -108,7 +109,12 @@ async function main(): Promise<void> { ownerDid : identity.did.uri, }); - const authenticatePush = async (request: Request, did: string, repo: string): Promise<boolean> => { + const authenticatePush = async ( + request: Request, + did: string, + repo: string, + updates?: readonly PushRefUpdate[], + ): Promise<boolean> => { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Basic ')) { return false; } @@ -133,7 +139,7 @@ async function main(): Promise<void> { const signatureBytes = new Uint8Array(Buffer.from(signed.signature, 'base64url')); if (!(await verifySignature(payload.did, tokenBytes, signatureBytes))) { return false; } - return authorizePush(payload.did, did, repo); + return authorizePush(payload.did, did, repo, updates); }; // Token generation — the credential helper calls this via /auth/token. diff --git a/tests/passive-dwn-server.spec.ts b/tests/passive-dwn-server.spec.ts new file mode 100644 index 0000000..38b3b30 --- /dev/null +++ b/tests/passive-dwn-server.spec.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { rmSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { HttpDwnRpcClient } from '@enbox/dwn-clients'; +import { + DataStream, + Jws, + ProtocolsConfigure, + RecordsQuery, + RecordsRead, + RecordsWrite, + TestDataGenerator, +} from '@enbox/dwn-sdk-js'; + +import { ForgeRepoDefinition } from '../src/repo.js'; +import { startPassiveDwnServer } from './helpers/passive-dwn-server.js'; + +const BASE = resolve('__TESTDATA__/passive-dwn-server'); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +describe('passive DWN RPC server helper', () => { + afterEach(() => { + rmSync(BASE, { recursive: true, force: true }); + }); + + it('serves standard DWN processMessage, streamed reads, and replicated apply over HTTP', async () => { + const owner = await TestDataGenerator.generatePersona(); + const server = await startPassiveDwnServer({ + dataPath : BASE, + didDocuments : [didDocumentForPersona(owner)], + }); + try { + const rpc = new HttpDwnRpcClient(); + const info = await rpc.getServerInfo(server.url); + expect(info.server).toBe('@enbox/dwn-server'); + expect(info.registrationRequirements).toEqual([]); + + const configure = await ProtocolsConfigure.create({ + definition : ForgeRepoDefinition, + signer : Jws.createSigner(owner), + }); + + const configureReply = await rpc.sendDwnRequest({ + dwnUrl : server.url, + targetDid : owner.did, + message : configure.message, + }); + expect(configureReply.status.code).toBe(202); + + const repoData = encoder.encode(JSON.stringify({ + name : 'demo', + defaultBranch : 'main', + dwnEndpoints : [server.url], + })); + const write = await RecordsWrite.create({ + protocol : ForgeRepoDefinition.protocol, + protocolPath : 'repo', + schema : ForgeRepoDefinition.types.repo.schema, + dataFormat : 'application/json', + data : repoData, + tags : { name: 'demo', visibility: 'public' }, + signer : Jws.createSigner(owner), + }); + + const writeReply = await rpc.sendDwnRequest({ + dwnUrl : server.url, + targetDid : owner.did, + message : write.message, + data : DataStream.fromBytes(repoData), + }); + expect(writeReply.status.code).toBe(202); + + const query = await RecordsQuery.create({ + signer : Jws.createSigner(owner), + filter : { + protocol : ForgeRepoDefinition.protocol, + protocolPath : 'repo', + tags : { name: 'demo' }, + }, + }); + const queryReply = await rpc.sendDwnRequest({ + dwnUrl : server.url, + targetDid : owner.did, + message : query.message, + }); + expect(queryReply.status.code).toBe(200); + expect(queryReply.entries).toHaveLength(1); + + const read = await RecordsRead.create({ + signer : Jws.createSigner(owner), + filter : { recordId: write.message.recordId }, + }); + const readReply = await rpc.sendDwnRequest({ + dwnUrl : server.url, + targetDid : owner.did, + message : read.message, + }); + expect(readReply.status.code).toBe(200); + expect(readReply.entry?.data).toBeDefined(); + const readBytes = await DataStream.toBytes(readReply.entry!.data!); + expect(JSON.parse(decoder.decode(readBytes))).toEqual({ + name : 'demo', + defaultBranch : 'main', + dwnEndpoints : [server.url], + }); + + const replicatedData = encoder.encode(JSON.stringify({ + name : 'replicated', + defaultBranch : 'main', + dwnEndpoints : [server.url], + })); + const replicatedWrite = await RecordsWrite.create({ + protocol : ForgeRepoDefinition.protocol, + protocolPath : 'repo', + schema : ForgeRepoDefinition.types.repo.schema, + dataFormat : 'application/json', + data : replicatedData, + tags : { name: 'replicated', visibility: 'public' }, + signer : Jws.createSigner(owner), + }); + + const applyResult = await rpc.applyReplicatedMessage({ + dwnUrl : server.url, + targetDid : owner.did, + message : replicatedWrite.message, + data : DataStream.fromBytes(replicatedData), + }); + expect(applyResult.kind).toBe('Applied'); + + const duplicateResult = await rpc.applyReplicatedMessage({ + dwnUrl : server.url, + targetDid : owner.did, + message : replicatedWrite.message, + }); + expect(duplicateResult.kind).toBe('Duplicate'); + } finally { + await server.stop(); + } + }); +}); + +function didDocumentForPersona(persona: Awaited<ReturnType<typeof TestDataGenerator.generatePersona>>) { + return { + didDocument: { + id : persona.did, + verificationMethod : [{ + id : persona.keyId, + type : 'JsonWebKey', + controller : persona.did, + publicKeyJwk : persona.keyPair.publicJwk, + }], + authentication : [persona.keyId], + assertionMethod: [persona.keyId], + }, + }; +} diff --git a/tests/profiles.spec.ts b/tests/profiles.spec.ts index 9ef0f9a..ca30b1e 100644 --- a/tests/profiles.spec.ts +++ b/tests/profiles.spec.ts @@ -170,6 +170,27 @@ describe('profile config', () => { } }); + it('should resolve from GITD_PROFILE env before ENBOX_PROFILE', () => { + const origGitd = process.env.GITD_PROFILE; + const origEnbox = process.env.ENBOX_PROFILE; + process.env.GITD_PROFILE = 'gitd-profile'; + process.env.ENBOX_PROFILE = 'env-profile'; + try { + expect(resolveProfile()).toBe('gitd-profile'); + } finally { + if (origGitd !== undefined) { + process.env.GITD_PROFILE = origGitd; + } else { + delete process.env.GITD_PROFILE; + } + if (origEnbox !== undefined) { + process.env.ENBOX_PROFILE = origEnbox; + } else { + delete process.env.ENBOX_PROFILE; + } + } + }); + it('should resolve from default profile', () => { upsertProfile('default-one', { name: 'default-one', did: 'did:d', createdAt: '2025-01-01T00:00:00Z' }); expect(resolveProfile()).toBe('default-one'); @@ -255,6 +276,28 @@ describe('connectAgent with profile dataPath', () => { // across separate process invocations. }); +describe('agent DWN endpoint resolution', () => { + it('should default to the hosted Enbox DWN endpoint', async () => { + const { resolveAgentDwnEndpoints } = await import('../src/cli/agent.js'); + expect(resolveAgentDwnEndpoints({} as NodeJS.ProcessEnv)).toEqual(['https://enbox-dwn.fly.dev']); + }); + + it('should use GITD_DWN_ENDPOINT for agent sync', async () => { + const { resolveAgentDwnEndpoints } = await import('../src/cli/agent.js'); + expect(resolveAgentDwnEndpoints({ + GITD_DWN_ENDPOINT: 'http://127.0.0.1:9381', + } as NodeJS.ProcessEnv)).toEqual(['http://127.0.0.1:9381']); + }); + + it('should prefer comma-separated GITD_DWN_ENDPOINTS over the singular endpoint', async () => { + const { resolveAgentDwnEndpoints } = await import('../src/cli/agent.js'); + expect(resolveAgentDwnEndpoints({ + GITD_DWN_ENDPOINT : 'https://ignored.example', + GITD_DWN_ENDPOINTS : ' http://127.0.0.1:9381, https://dwn.example ', + } as NodeJS.ProcessEnv)).toEqual(['http://127.0.0.1:9381', 'https://dwn.example']); + }); +}); + describe('resolveReposPath', () => { it('should fall back to ~/.enbox/profiles/default/repos/ without a profile', async () => { const { resolveReposPath } = await import('../src/cli/flags.js'); diff --git a/tests/protocols.spec.ts b/tests/protocols.spec.ts index 1b07ea4..0d6944c 100644 --- a/tests/protocols.spec.ts +++ b/tests/protocols.spec.ts @@ -47,16 +47,19 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.types.readme).toBeDefined(); expect(ForgeRepoDefinition.types.license).toBeDefined(); expect(ForgeRepoDefinition.types.maintainer).toBeDefined(); + expect(ForgeRepoDefinition.types.moderator).toBeDefined(); expect(ForgeRepoDefinition.types.triager).toBeDefined(); expect(ForgeRepoDefinition.types.contributor).toBeDefined(); expect(ForgeRepoDefinition.types.viewer).toBeDefined(); expect(ForgeRepoDefinition.types.topic).toBeDefined(); expect(ForgeRepoDefinition.types.submissionDecision).toBeDefined(); + expect(ForgeRepoDefinition.types.moderationEvent).toBeDefined(); expect(ForgeRepoDefinition.types.webhook).toBeDefined(); }); - it('should mark maintainer, triager, contributor, and viewer as roles', () => { + it('should mark maintainer, moderator, triager, contributor, and viewer as roles', () => { expect(ForgeRepoDefinition.structure.repo.maintainer.$role).toBe(true); + expect(ForgeRepoDefinition.structure.repo.moderator.$role).toBe(true); expect(ForgeRepoDefinition.structure.repo.triager.$role).toBe(true); expect(ForgeRepoDefinition.structure.repo.contributor.$role).toBe(true); expect(ForgeRepoDefinition.structure.repo.viewer.$role).toBe(true); @@ -87,6 +90,7 @@ describe('@enbox/gitd', () => { it('should require did tag on role records', () => { expect(ForgeRepoDefinition.structure.repo.maintainer.$tags?.$requiredTags).toContain('did'); + expect(ForgeRepoDefinition.structure.repo.moderator.$tags?.$requiredTags).toContain('did'); expect(ForgeRepoDefinition.structure.repo.triager.$tags?.$requiredTags).toContain('did'); expect(ForgeRepoDefinition.structure.repo.contributor.$tags?.$requiredTags).toContain('did'); expect(ForgeRepoDefinition.structure.repo.viewer.$tags?.$requiredTags).toContain('did'); @@ -110,6 +114,7 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.structure.repo.topic).toBeDefined(); expect(ForgeRepoDefinition.structure.repo.webhook).toBeDefined(); expect(ForgeRepoDefinition.structure.repo.bundle).toBeDefined(); + expect(ForgeRepoDefinition.structure.repo.moderationEvent).toBeDefined(); }); it('should enable $squash on bundle records', () => { @@ -151,6 +156,24 @@ describe('@enbox/gitd', () => { expect(maintainerAction?.can).toContain('create'); }); + it('should support immutable repo moderation events', () => { + const eventNode = ForgeRepoDefinition.structure.repo.moderationEvent; + expect(eventNode.$immutable).toBe(true); + expect(eventNode.$tags.$requiredTags).toEqual(['action', 'actorDid']); + expect(eventNode.$tags.action.enum).toContain('block'); + expect(eventNode.$tags.action.enum).toContain('hideComment'); + expect(eventNode.$tags.targetDid.type).toBe('string'); + expect(eventNode.$tags.targetKind.enum).toContain('prComment'); + expect(eventNode.$tags.reportStatus.enum).toEqual(['open', 'resolved', 'dismissed']); + + const anyoneAction = eventNode.$actions.find((a) => 'who' in a && a.who === 'anyone'); + const maintainerAction = eventNode.$actions.find((a) => 'role' in a && a.role === 'repo/maintainer'); + const moderatorAction = eventNode.$actions.find((a) => 'role' in a && a.role === 'repo/moderator'); + expect(anyoneAction?.can).toContain('read'); + expect(maintainerAction?.can).toContain('create'); + expect(moderatorAction?.can).toContain('create'); + }); + it('should wrap definition via defineProtocol()', () => { expect(ForgeRepoProtocol.definition).toBe(ForgeRepoDefinition); }); @@ -174,10 +197,15 @@ describe('@enbox/gitd', () => { expect(ForgeRefsDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); }); - it('should define the ref type', () => { + it('should define the ref and branch state types', () => { expect(ForgeRefsDefinition.types.ref).toBeDefined(); expect(ForgeRefsDefinition.types.ref.schema).toBe('https://enbox.org/schemas/forge/git-ref'); expect(ForgeRefsDefinition.types.ref.dataFormats).toContain('application/json'); + expect(ForgeRefsDefinition.types.branch.schema).toBe('https://enbox.org/schemas/forge/branch'); + expect(ForgeRefsDefinition.types.branch.dataFormats).toContain('application/json'); + expect(ForgeRefsDefinition.types.state.schema).toBe('https://enbox.org/schemas/forge/branch-state'); + expect(ForgeRefsDefinition.types.state.dataFormats).toContain('application/json'); + expect(ForgeRefsDefinition.types.bundle.dataFormats).toContain('application/x-git-bundle'); }); it('should use $ref to compose with repo protocol', () => { @@ -185,9 +213,12 @@ describe('@enbox/gitd', () => { expect(repoNode.$ref).toBe('repo:repo'); }); - it('should nest ref under repo via $ref', () => { + it('should nest ref and branch state under repo via $ref', () => { const repoNode = ForgeRefsDefinition.structure.repo as any; expect(repoNode.ref).toBeDefined(); + expect(repoNode.branch).toBeDefined(); + expect(repoNode.branch.state).toBeDefined(); + expect(repoNode.branch.bundle).toBeDefined(); }); it('should allow anyone to read refs', () => { @@ -228,6 +259,49 @@ describe('@enbox/gitd', () => { expect(refNode.$tags.target.type).toBe('string'); }); + it('should require branch ownership tags', () => { + const branchNode = (ForgeRefsDefinition.structure.repo as any).branch; + expect(branchNode.$tags.$requiredTags).toContain('refName'); + expect(branchNode.$tags.$requiredTags).toContain('ownerDid'); + expect(branchNode.$tags.$requiredTags).toContain('kind'); + expect(branchNode.$tags.kind.enum).toEqual(['contributor', 'protected', 'shared']); + expect(branchNode.$tags.$allowUndefinedTags).toBe(false); + }); + + it('should allow contributors to create branches and maintainers to delete them', () => { + const branchNode = (ForgeRefsDefinition.structure.repo as any).branch; + const contributorAction = branchNode.$actions.find((a: any) => a.role === 'repo:repo/contributor'); + const maintainerAction = branchNode.$actions.find((a: any) => a.role === 'repo:repo/maintainer'); + expect(contributorAction.can).toContain('create'); + expect(contributorAction.can).toContain('read'); + expect(maintainerAction.can).toContain('delete'); + }); + + it('should squash branch state through branch authors or maintainers', () => { + const stateNode = (ForgeRefsDefinition.structure.repo as any).branch.state; + const authorAction = stateNode.$actions.find((a: any) => a.who === 'author' && a.of === 'repo/branch'); + const maintainerAction = stateNode.$actions.find((a: any) => a.role === 'repo:repo/maintainer'); + expect(stateNode.$squash).toBe(true); + expect(authorAction.can).toContain('create'); + expect(authorAction.can).toContain('squash'); + expect(maintainerAction.can).toContain('create'); + expect(maintainerAction.can).toContain('squash'); + expect(stateNode.$tags.kind.enum).toEqual(['refUpdate', 'checkpoint']); + expect(stateNode.$tags.acceptedStateRecordId.type).toBe('string'); + }); + + it('should squash branch bundles through branch authors or maintainers', () => { + const bundleNode = (ForgeRefsDefinition.structure.repo as any).branch.bundle; + const authorAction = bundleNode.$actions.find((a: any) => a.who === 'author' && a.of === 'repo/branch'); + const maintainerAction = bundleNode.$actions.find((a: any) => a.role === 'repo:repo/maintainer'); + expect(bundleNode.$squash).toBe(true); + expect(authorAction.can).toContain('create'); + expect(authorAction.can).toContain('squash'); + expect(maintainerAction.can).toContain('create'); + expect(maintainerAction.can).toContain('squash'); + expect(bundleNode.$tags.$requiredTags).toContain('tipCommit'); + }); + it('should wrap definition via defineProtocol()', () => { expect(ForgeRefsProtocol.definition).toBe(ForgeRefsDefinition); }); @@ -320,8 +394,12 @@ describe('@enbox/gitd', () => { const actions = ForgeIssuesDefinition.structure.repo.issue.$actions!; const contributorAction = actions.find((a) => a.role === 'repo:repo/contributor'); const maintainerAction = actions.find((a) => a.role === 'repo:repo/maintainer'); + const moderatorAction = actions.find((a) => a.role === 'repo:repo/moderator'); expect(contributorAction).toBeDefined(); expect(maintainerAction).toBeDefined(); + expect(moderatorAction).toBeDefined(); + expect(moderatorAction!.can).toContain('create'); + expect(moderatorAction!.can).toContain('co-update'); }); it('should keep direct issue writes role-gated', () => { @@ -507,10 +585,12 @@ describe('@enbox/gitd', () => { const reviewActions = ForgePatchesDefinition.structure.repo.patch.review.$actions!; expect(reviewActions.find((a) => a.who === 'anyone')!.can).not.toContain('create'); expect(reviewActions.find((a) => a.role === 'repo:repo/contributor')!.can).toContain('create'); + expect(reviewActions.find((a) => a.role === 'repo:repo/moderator')!.can).toContain('create'); const commentActions = ForgePatchesDefinition.structure.repo.patch.review.reviewComment.$actions!; expect(commentActions.find((a) => a.who === 'anyone')!.can).not.toContain('create'); expect(commentActions.find((a) => a.role === 'repo:repo/contributor')!.can).toContain('create'); + expect(commentActions.find((a) => a.role === 'repo:repo/moderator')!.can).toContain('create'); }); it('should allow review comment authors and maintainers to update and delete review comments', () => { diff --git a/tests/push-policy.spec.ts b/tests/push-policy.spec.ts new file mode 100644 index 0000000..4c0469f --- /dev/null +++ b/tests/push-policy.spec.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'bun:test'; + +import { branchOwnerHash } from '../src/branch-state.js'; +import { createDwnPushAuthorizer } from '../src/git-server/push-authorizer.js'; +import { parseReceivePackUpdates } from '../src/git-server/push-updates.js'; + +const OWNER_DID = 'did:dht:owner'; +const MAINTAINER_DID = 'did:dht:maintainer'; +const CONTRIBUTOR_DID = 'did:dht:contributor'; +const OTHER_DID = 'did:dht:other'; +const REPO_NAME = 'demo'; +const CONTEXT_ID = 'ctx-demo'; + +const ZERO = '0'.repeat(40); +const OLD = '1'.repeat(40); +const NEW = '2'.repeat(40); + +describe('parseReceivePackUpdates', () => { + it('parses a create command with capabilities', () => { + const body = pkt(`${ZERO} ${NEW} refs/heads/main\0report-status side-band-64k\n`) + '0000PACK'; + expect(parseReceivePackUpdates(body)).toEqual([ + { oldTarget: null, newTarget: NEW, refName: 'refs/heads/main' }, + ]); + }); + + it('parses multiple update commands before the flush packet', () => { + const body = pkt(`${OLD} ${NEW} refs/heads/main\n`) + + pkt(`${NEW} ${ZERO} refs/heads/topic\n`) + + '0000PACK'; + + expect(parseReceivePackUpdates(body)).toEqual([ + { oldTarget: OLD, newTarget: NEW, refName: 'refs/heads/main' }, + { oldTarget: NEW, newTarget: null, refName: 'refs/heads/topic' }, + ]); + }); + + it('returns no updates for a flush-only receive-pack request', () => { + expect(parseReceivePackUpdates('0000')).toEqual([]); + }); +}); + +describe('createDwnPushAuthorizer branch policy', () => { + it('allows owners to update protected branches', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo(), + ownerDid : OWNER_DID, + }); + + await expect(authorize(OWNER_DID, OWNER_DID, REPO_NAME, [ + update('refs/heads/main'), + ])).resolves.toBe(true); + }); + + it('allows maintainers to update protected branches and tags', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ maintainers: [MAINTAINER_DID] }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(MAINTAINER_DID, OWNER_DID, REPO_NAME, [ + update('refs/heads/main'), + update('refs/tags/v1.0.0'), + ])).resolves.toBe(true); + }); + + it('allows contributors to advertise push refs before commands are known', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ contributors: [CONTRIBUTOR_DID] }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME)).resolves.toBe(true); + }); + + it('allows contributors to update their own contributor branch namespace', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ contributors: [CONTRIBUTOR_DID] }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update(`refs/heads/users/${branchOwnerHash(CONTRIBUTOR_DID)}/feature`), + ])).resolves.toBe(true); + }); + + it('queries remote owner DWN role records when from is provided', async () => { + const queries: Array<{ path: string; from?: string }> = []; + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ contributors: [CONTRIBUTOR_DID], queries }), + ownerDid : OWNER_DID, + from : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update(`refs/heads/users/${branchOwnerHash(CONTRIBUTOR_DID)}/feature`), + ])).resolves.toBe(true); + + expect(queries).toEqual([ + { path: 'repo', from: OWNER_DID }, + { path: 'repo/moderationEvent', from: OWNER_DID }, + { path: 'repo/maintainer', from: OWNER_DID }, + { path: 'repo/contributor', from: OWNER_DID }, + ]); + }); + + it('rejects blocked contributors even when their role record remains', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ + contributors: [CONTRIBUTOR_DID], + moderationEvents: [ + moderationEvent('block', CONTRIBUTOR_DID, '2026-06-23T00:00:00.000Z'), + ], + }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update(`refs/heads/users/${branchOwnerHash(CONTRIBUTOR_DID)}/feature`), + ])).resolves.toBe(false); + }); + + it('allows a contributor again after a later unblock event', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ + contributors: [CONTRIBUTOR_DID], + moderationEvents: [ + moderationEvent('block', CONTRIBUTOR_DID, '2026-06-23T00:00:00.000Z'), + moderationEvent('unblock', CONTRIBUTOR_DID, '2026-06-23T00:01:00.000Z'), + ], + }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update(`refs/heads/users/${branchOwnerHash(CONTRIBUTOR_DID)}/feature`), + ])).resolves.toBe(true); + }); + + it('rejects contributors updating protected branches, tags, or another contributor namespace', async () => { + const authorize = createDwnPushAuthorizer({ + repo : mockRepo({ contributors: [CONTRIBUTOR_DID] }), + ownerDid : OWNER_DID, + }); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update('refs/heads/main'), + ])).resolves.toBe(false); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update('refs/tags/v1.0.0'), + ])).resolves.toBe(false); + + await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ + update(`refs/heads/users/${branchOwnerHash(OTHER_DID)}/feature`), + ])).resolves.toBe(false); + }); +}); + +function pkt(payload: string): string { + return (payload.length + 4).toString(16).padStart(4, '0') + payload; +} + +function update(refName: string) { + return { oldTarget: OLD, newTarget: NEW, refName }; +} + +function moderationEvent(action: 'block' | 'unblock', targetDid: string, createdAt: string) { + return { + id: `${action}-${targetDid}-${createdAt}`, + dateCreated: createdAt, + tags: { action, targetDid }, + data: { + json: async () => ({ action, targetDid, createdAt }), + }, + }; +} + +function mockRepo(options: { + maintainers?: string[]; + contributors?: string[]; + moderationEvents?: any[]; + queries?: Array<{ path: string; from?: string }>; +} = {}) { + return { + records: { + query: async (path: string, query: any = {}) => { + options.queries?.push({ path, from: query.from }); + if (path === 'repo') { + return { + records: [{ contextId: CONTEXT_ID }], + }; + } + + if (path === 'repo/moderationEvent') { + const did = query.filter?.tags?.targetDid; + return { + records: (options.moderationEvents ?? []).filter((event) => event.tags?.targetDid === did), + }; + } + + if (path === 'repo/maintainer') { + const did = query.filter?.tags?.did; + return { + records: options.maintainers?.includes(did) ? [{ contextId: CONTEXT_ID }] : [], + }; + } + + if (path === 'repo/contributor') { + const did = query.filter?.tags?.did; + return { + records: options.contributors?.includes(did) ? [{ contextId: CONTEXT_ID }] : [], + }; + } + + return { records: [] }; + }, + }, + } as any; +} diff --git a/tests/ref-sync.spec.ts b/tests/ref-sync.spec.ts index 71de25b..a2c2504 100644 --- a/tests/ref-sync.spec.ts +++ b/tests/ref-sync.spec.ts @@ -10,7 +10,7 @@ import { execSync } from 'node:child_process'; import { rmSync } from 'node:fs'; import { GitBackend } from '../src/git-server/git-backend.js'; -import { readGitRefs } from '../src/git-server/ref-sync.js'; +import { createRefSyncer, readGitRefs } from '../src/git-server/ref-sync.js'; const TEST_BASE_PATH = '__TESTDATA__/ref-sync'; const TEST_DID = 'did:dht:refsynctest'; @@ -97,7 +97,115 @@ describe('readGitRefs', () => { } }); + it('should sync branch records and squashed branch checkpoints', async () => { + const mock = createMockRefsHandle(); + const repoContextId = 'repo-context-1'; + const syncer = createRefSyncer({ refs: mock.handle as any, repoContextId }); + + await syncer(TEST_DID, TEST_REPO, repoPath); + + const mirrorRefs = mock.recordsFor('repo/ref', repoContextId).map((record) => record._data); + expect(mirrorRefs.some((ref) => ref.name === 'refs/heads/main')).toBe(true); + expect(mirrorRefs.some((ref) => ref.name === 'refs/tags/v1.0.0')).toBe(true); + + const branches = mock.recordsFor('repo/branch', repoContextId); + const branchData = branches.map((record) => record._data); + expect(branchData.map((branch) => branch.refName).sort()).toEqual([ + 'refs/heads/feature-branch', + 'refs/heads/main', + ]); + expect(branchData.find((branch) => branch.refName === 'refs/heads/main')!.kind).toBe('protected'); + expect(branchData.find((branch) => branch.refName === 'refs/heads/feature-branch')!.kind).toBe('shared'); + + const mainBranch = branches.find((record) => record._data.refName === 'refs/heads/main')!; + const mainStates = mock.recordsFor('repo/branch/state', mainBranch.contextId); + expect(mainStates).toHaveLength(1); + expect(mainStates[0]._data.kind).toBe('checkpoint'); + expect(mainStates[0]._data.refName).toBe('refs/heads/main'); + expect(mainStates[0]._data.target).toMatch(/^[0-9a-f]{40}$/); + + const mainStateId = mainStates[0].id; + await syncer(TEST_DID, TEST_REPO, repoPath); + expect(mock.recordsFor('repo/branch/state', mainBranch.contextId).map((record) => record.id)).toEqual([mainStateId]); + + const workdir = `${TEST_BASE_PATH}/tmp-work`; + execSync('git checkout main', { cwd: workdir, stdio: 'pipe' }); + execSync('git commit --allow-empty -m "main branch checkpoint"', { cwd: workdir, stdio: 'pipe' }); + execSync('git push origin main', { cwd: workdir, stdio: 'pipe' }); + + await syncer(TEST_DID, TEST_REPO, repoPath); + const updatedMainStates = mock.recordsFor('repo/branch/state', mainBranch.contextId); + expect(updatedMainStates).toHaveLength(1); + expect(updatedMainStates[0].id).not.toBe(mainStateId); + expect(updatedMainStates[0]._data.target).not.toBe(mainStates[0]._data.target); + }); + it('should reject when git fails (e.g., invalid repo path)', async () => { await expect(readGitRefs('/nonexistent/path')).rejects.toThrow(); }); }); + +type MockRecord = { + id: string; + path: string; + contextId: string; + parentContextId: string; + tags: Record<string, unknown>; + _data: any; + dateCreated: string; + data: { json: () => Promise<any> }; + update: (options: { data: any; tags: Record<string, unknown> }) => Promise<void>; + delete: () => Promise<void>; +}; + +function createMockRefsHandle(): { handle: any; recordsFor: (path: string, parentContextId: string) => MockRecord[] } { + let nextId = 0; + const records: MockRecord[] = []; + + const recordsFor = (path: string, parentContextId: string): MockRecord[] => + records.filter((record) => record.path === path && record.parentContextId === parentContextId); + + const removeRecord = (record: MockRecord): void => { + const index = records.indexOf(record); + if (index >= 0) { + records.splice(index, 1); + } + }; + + const handle = { + records: { + query: async (path: string, options?: any) => ({ + records: recordsFor(path, options?.filter?.contextId), + }), + create: async (path: string, options: any) => { + const parentContextId = options.parentContextId; + if (options.squash) { + for (const record of [...recordsFor(path, parentContextId)]) { + removeRecord(record); + } + } + + const id = `record-${++nextId}`; + const record: MockRecord = { + id, + path, + parentContextId, + contextId : `context-${id}`, + tags : options.tags ?? {}, + _data : options.data, + dateCreated : '2026-06-22T00:00:00.000Z', + data : { json: async () => record._data }, + update : async (updateOptions) => { + record._data = updateOptions.data; + record.tags = updateOptions.tags; + }, + delete: async () => removeRecord(record), + }; + records.push(record); + return { status: { code: 202 }, record }; + }, + }, + }; + + return { handle, recordsFor }; +} diff --git a/tests/schemas.spec.ts b/tests/schemas.spec.ts index f7d6e4e..0d50121 100644 --- a/tests/schemas.spec.ts +++ b/tests/schemas.spec.ts @@ -339,6 +339,15 @@ describe('JSON Schemas', () => { ]); }); + it('moderation-event.json should define moderation actions and targets', () => { + const schema = readSchema('repo', 'moderation-event.json'); + expect(schema.required).toEqual(['action', 'actorDid', 'createdAt']); + expect(schema.properties.action.enum).toContain('block'); + expect(schema.properties.action.enum).toContain('deleteComment'); + expect(schema.properties.targetKind.enum).toContain('prComment'); + expect(schema.properties.reportStatus.enum).toEqual(['open', 'resolved', 'dismissed']); + }); + it('settings.json should restrict mergeStrategies items to merge, squash, rebase', () => { const schema = readSchema('repo', 'settings.json'); const items = schema.properties.mergeStrategies.items; @@ -587,6 +596,25 @@ describe('JSON Schemas', () => { const schema = readSchema('refs', 'git-ref.json'); expect(schema.properties.type.enum).toEqual(['branch', 'tag']); }); + + it('branch.json should require refName, ownerDid, and kind', () => { + const schema = readSchema('refs', 'branch.json'); + expect(schema.required).toContain('refName'); + expect(schema.required).toContain('ownerDid'); + expect(schema.required).toContain('kind'); + expect(schema.properties.kind.enum).toEqual(['contributor', 'protected', 'shared']); + }); + + it('branch-state.json should support updates and checkpoints', () => { + const schema = readSchema('refs', 'branch-state.json'); + expect(schema.required).toContain('kind'); + expect(schema.required).toContain('refName'); + expect(schema.required).toContain('actorDid'); + expect(schema.required).toContain('createdAt'); + expect(schema.properties.kind.enum).toEqual(['refUpdate', 'checkpoint']); + expect(schema.properties.newTarget.type).toEqual(['string', 'null']); + expect(schema.properties.target.type).toEqual(['string', 'null']); + }); }); describe('org schemas', () => { From ae7fd98364d1e454585f6ef8e34d5fc74225b08a Mon Sep 17 00:00:00 2001 From: Liran Cohen <c.liran.c@gmail.com> Date: Tue, 23 Jun 2026 13:13:24 +0000 Subject: [PATCH 3/5] Fix CI lint and coverage upload --- .github/workflows/ci.yml | 1 - src/cli/agent.ts | 6 +- src/cli/commands/init.ts | 4 +- src/cli/commands/issue.ts | 6 +- src/cli/commands/mod.ts | 22 +++---- src/cli/commands/pr.ts | 14 ++--- src/cli/commands/repo.ts | 4 +- src/cli/commands/serve-lifecycle.ts | 2 +- src/cli/commands/serve.ts | 39 ++++++------ src/cli/main.ts | 2 +- src/cli/moderation-state.ts | 6 +- src/cli/record-send.ts | 3 +- src/cli/repo-context.ts | 4 +- src/daemon/lifecycle.ts | 2 +- src/git-remote/main.ts | 4 +- src/git-server/bundle-restore.ts | 4 +- src/git-server/bundle-sync.ts | 15 ++--- src/git-server/http-handler.ts | 12 ++-- src/git-server/ref-sync.ts | 10 +-- src/git-server/remote-branch-sync.ts | 39 ++++++------ src/git-server/server.ts | 2 +- src/refs.ts | 18 +++--- tests/branch-state.spec.ts | 15 ++--- tests/bundle-restore.spec.ts | 4 +- tests/bundle-sync.spec.ts | 2 +- tests/cli.spec.ts | 8 +-- tests/daemon-lifecycle.spec.ts | 7 ++- tests/e2e-collaboration.spec.ts | 30 ++++----- tests/e2e-passive-dwn-sync.spec.ts | 93 ++++++++++++++++++---------- tests/e2e.spec.ts | 9 ++- tests/git-remote.spec.ts | 4 +- tests/helpers/passive-dwn-server.ts | 30 ++++----- tests/helpers/profile-bootstrap.ts | 6 +- tests/helpers/test-daemon.ts | 3 +- tests/passive-dwn-server.spec.ts | 10 +-- tests/push-policy.spec.ts | 32 +++++----- tests/ref-sync.spec.ts | 4 +- 37 files changed, 254 insertions(+), 222 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a116ff..6a6a5b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,6 @@ jobs: run: bun test .spec.ts --coverage --coverage-reporter=lcov --coverage-dir=coverage - name: Upload coverage to Coveralls - if: always() uses: coverallsapp/github-action@v2 with: file: coverage/lcov.info diff --git a/src/cli/agent.ts b/src/cli/agent.ts index ba7ba55..23b259a 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -261,11 +261,11 @@ export async function connectAgent(options: ConnectOptions): Promise<AgentContex identitySyncProtocols, registration : registrationEnabled ? { - onSuccess : () => { /* silent */ }, - onFailure : (err) => { console.error(`[dwn-registration] ${(err as Error).message}`); }, + onSuccess : (): void => { /* silent */ }, + onFailure : (err): void => { console.error(`[dwn-registration] ${(err as Error).message}`); }, onProviderAuthRequired : handleProviderAuth, registrationTokens : loadRegistrationTokens(dataPath), - onRegistrationTokens : (tokens) => { saveRegistrationTokens(dataPath, tokens); }, + onRegistrationTokens : (tokens): void => { saveRegistrationTokens(dataPath, tokens); }, } : undefined, }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index f10031a..2a3ecb8 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -76,8 +76,8 @@ export async function initCommand(ctx: AgentContext, args: string[]): Promise<vo defaultBranch : branch, dwnEndpoints, }, - published: visibility === 'public', - tags: { + published : visibility === 'public', + tags : { name, visibility, }, diff --git a/src/cli/commands/issue.ts b/src/cli/commands/issue.ts index b59ba86..62dc434 100644 --- a/src/cli/commands/issue.ts +++ b/src/cli/commands/issue.ts @@ -21,11 +21,11 @@ import { RecordsWrite } from '@enbox/dwn-sdk-js'; import { ForgeIssuesDefinition } from '../../issues.js'; import { recordIgnoredSubmission } from '../submission-decisions.js'; +import { bodyInit, configuredDwnEndpoints, jsonBody, messageSignerForContext, processMessageOnTargetEndpoints, sendRecordToTarget } from '../record-send.js'; +import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; import { findByShortId, shortId } from '../../github-shim/helpers.js'; import { flagValue, resolveRepoName, resolveRepoOwner } from '../flags.js'; -import { fromOpt, getRepoContext, getRepoContextForDid, getRepoContextId, resolveRepoProtocolRole } from '../repo-context.js'; -import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; -import { bodyInit, configuredDwnEndpoints, jsonBody, messageSignerForContext, processMessageOnTargetEndpoints, sendRecordToTarget } from '../record-send.js'; +import { fromOpt, getRepoContext, getRepoContextForDid, resolveRepoProtocolRole } from '../repo-context.js'; // --------------------------------------------------------------------------- // Sub-command dispatch diff --git a/src/cli/commands/mod.ts b/src/cli/commands/mod.ts index 0856cb4..e2b867b 100644 --- a/src/cli/commands/mod.ts +++ b/src/cli/commands/mod.ts @@ -12,10 +12,10 @@ import type { AgentContext } from '../agent.js'; import type { ModerationEventData } from '../../repo.js'; -import { getRepoContextForDid, getRepoContextId } from '../repo-context.js'; import { repoCommand } from './repo.js'; -import { flagValue, resolveRepoName, resolveRepoOwner } from '../flags.js'; import { sendRecordToTarget } from '../record-send.js'; +import { flagValue, resolveRepoName, resolveRepoOwner } from '../flags.js'; +import { getRepoContextForDid, getRepoContextId } from '../repo-context.js'; // --------------------------------------------------------------------------- // Sub-command dispatch @@ -81,8 +81,8 @@ async function moderationTargetDid( const reason = flagValue(args, '--reason') ?? flagValue(args, '-m'); await createModerationEvent(ctx, args, { action, - targetDid : did, - targetKind: 'repo', + targetDid : did, + targetKind : 'repo', reason, }); @@ -198,10 +198,10 @@ async function moderationInteractionLimit(ctx: AgentContext, args: string[]): Pr } await createModerationEvent(ctx, args, { - action: 'interactionLimit', - targetKind: 'repo', - interactionLimit: limit, - duration: flagValue(args, '--duration'), + action : 'interactionLimit', + targetKind : 'repo', + interactionLimit : limit, + duration : flagValue(args, '--duration'), }); console.log(`Set interaction limit: ${limit}.`); } @@ -227,7 +227,7 @@ async function createModerationEvent( const { status, record } = await ctx.repo.records.create('repo/moderationEvent' as any, { data, tags, - parentContextId : repo.contextId, + parentContextId: repo.contextId, ...(remote ? { protocolRole, store: false } : {}), } as any); @@ -246,8 +246,8 @@ async function createModerationEvent( function moderationTags(data: ModerationEventData): Record<string, string> { const tags: Record<string, string> = { - action: data.action, - actorDid: data.actorDid, + action : data.action, + actorDid : data.actorDid, }; if (data.targetDid) { tags.targetDid = data.targetDid; } if (data.targetKind) { tags.targetKind = data.targetKind; } diff --git a/src/cli/commands/pr.ts b/src/cli/commands/pr.ts index 3251a41..d3b37f1 100644 --- a/src/cli/commands/pr.ts +++ b/src/cli/commands/pr.ts @@ -35,12 +35,9 @@ import { RecordsWrite, } from '@enbox/dwn-sdk-js'; +import { ForgePatchesDefinition } from '../../patches.js'; import { recordIgnoredSubmission } from '../submission-decisions.js'; import { shortId } from '../../github-shim/helpers.js'; -import { flagValue, hasFlag, resolveRepoName, resolveRepoOwner } from '../flags.js'; -import { fromOpt, getRepoContext, getRepoContextForDid, getRepoContextId, resolveRepoProtocolRole } from '../repo-context.js'; -import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; -import { ForgePatchesDefinition } from '../../patches.js'; import { bodyInit, configuredDwnEndpoints, @@ -49,6 +46,9 @@ import { processMessageOnTargetEndpoints, sendRecordToTarget, } from '../record-send.js'; +import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; +import { flagValue, hasFlag, resolveRepoName, resolveRepoOwner } from '../flags.js'; +import { fromOpt, getRepoContext, getRepoContextForDid, resolveRepoProtocolRole } from '../repo-context.js'; // --------------------------------------------------------------------------- // Sub-command dispatch @@ -1331,9 +1331,9 @@ function endpointBackedRecord(ctx: AgentContext, target: PatchTarget, entry: any tags : descriptor.tags ?? {}, rawMessage : entry, dataSize : descriptor.dataSize ?? 0, - data: { - json: async () => JSON.parse(new TextDecoder().decode(await endpointRecordBytes(ctx, target, entry))), - blob: async () => new Blob( + data : { + json : async () => JSON.parse(new TextDecoder().decode(await endpointRecordBytes(ctx, target, entry))), + blob : async () => new Blob( [await endpointRecordBytes(ctx, target, entry)], { type: descriptor.dataFormat ?? 'application/octet-stream' }, ), diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index da3fa28..3aea9ad 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -19,10 +19,10 @@ import type { AgentContext } from '../agent.js'; import type { RepoContext } from '../repo-context.js'; -import { getRepoContext, getRepoContextId } from '../repo-context.js'; -import { flagValue, resolveRepoName } from '../flags.js'; import { getDwnEndpoints } from '../../git-server/did-service.js'; import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; +import { flagValue, resolveRepoName } from '../flags.js'; +import { getRepoContext, getRepoContextId } from '../repo-context.js'; // --------------------------------------------------------------------------- // Valid roles diff --git a/src/cli/commands/serve-lifecycle.ts b/src/cli/commands/serve-lifecycle.ts index fbb1ed8..9aa5b5a 100644 --- a/src/cli/commands/serve-lifecycle.ts +++ b/src/cli/commands/serve-lifecycle.ts @@ -16,8 +16,8 @@ import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; -import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../daemon/lifecycle.js'; import { flagValue } from '../flags.js'; +import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../daemon/lifecycle.js'; // --------------------------------------------------------------------------- // Command diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 7072ad1..b84a32f 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -21,28 +21,26 @@ * @module */ +import type { AgentContext } from '../agent.js'; import type { DidDocument } from '@enbox/dids'; import type { EnboxPlatformAgent } from '@enbox/agent'; -import type { CliRpcRequest, CliRpcResponse } from '../local-rpc.js'; import type { PushRefUpdate } from '../../git-server/push-updates.js'; - -import type { AgentContext } from '../agent.js'; +import type { CliRpcRequest, CliRpcResponse } from '../local-rpc.js'; import { rmSync } from 'node:fs'; import { createBundleSyncer } from '../../git-server/bundle-sync.js'; -import { dispatchAgentCommand } from '../dispatch.js'; -import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; import { createDidSignatureVerifier } from '../../git-server/verify.js'; import { createDwnPushAuthorizer } from '../../git-server/push-authorizer.js'; import { createGitServer } from '../../git-server/server.js'; import { createRefSyncer } from '../../git-server/ref-sync.js'; -import { fromOpt, getRepoContext, getRepoContextForDid } from '../repo-context.js'; +import { dispatchAgentCommand } from '../dispatch.js'; import { getVersion } from '../../version.js'; +import { isContributorBranchRef } from '../../branch-state.js'; import { restoreFromBundles } from '../../git-server/bundle-restore.js'; import { syncRemoteBranchPush } from '../../git-server/remote-branch-sync.js'; import { withRepoLock } from '../../git-server/repo-mutex.js'; -import { isContributorBranchRef } from '../../branch-state.js'; +import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; import { createPushAuthenticator, createPushTokenPayload, @@ -51,6 +49,7 @@ import { formatAuthPassword, } from '../../git-server/auth.js'; import { flagValue, hasFlag, parsePort, resolveReposPath } from '../flags.js'; +import { fromOpt, getRepoContext, getRepoContextForDid } from '../repo-context.js'; import { getDwnEndpoints, registerGitService, @@ -273,21 +272,21 @@ async function executeCliRpc(ctx: AgentContext, request: CliRpcRequest): Promise process.chdir(request.cwd); } - (process.stdout.write as any) = (chunk: unknown, ..._args: unknown[]) => { + (process.stdout.write as any) = (chunk: unknown, ..._args: unknown[]): boolean => { writeStdout(chunk); return true; }; - (process.stderr.write as any) = (chunk: unknown, ..._args: unknown[]) => { + (process.stderr.write as any) = (chunk: unknown, ..._args: unknown[]): boolean => { writeStderr(chunk); return true; }; - console.log = (...values: unknown[]) => { + console.log = (...values: unknown[]): void => { stdout += `${values.map(formatConsoleValue).join(' ')}\n`; }; - console.error = (...values: unknown[]) => { + console.error = (...values: unknown[]): void => { stderr += `${values.map(formatConsoleValue).join(' ')}\n`; }; - console.warn = (...values: unknown[]) => { + console.warn = (...values: unknown[]): void => { stderr += `${values.map(formatConsoleValue).join(' ')}\n`; }; (process as any).exit = (code?: number): never => { @@ -405,9 +404,9 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise<v let remoteAuthorizer = remoteAuthorizers.get(ownerDid); if (!remoteAuthorizer) { remoteAuthorizer = createDwnPushAuthorizer({ - repo : ctx.repo, + repo : ctx.repo, ownerDid, - from : fromOpt(ctx, ownerDid), + from : fromOpt(ctx, ownerDid), }); remoteAuthorizers.set(ownerDid, remoteAuthorizer); } @@ -471,11 +470,11 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise<v await syncRemoteBranchPush({ refs : ctx.refs, repoContextId : repoCtx.contextId, - targetDid : _did, - actorDid : ctx.did, + targetDid : _did, + actorDid : ctx.did, repoPath, - updates : pushContext?.updates ?? [], - sendRecord : createEndpointRecordSender(ctx, `remote branch writeback for ${_did}/${repoName}`), + updates : pushContext?.updates ?? [], + sendRecord : createEndpointRecordSender(ctx, `remote branch writeback for ${_did}/${repoName}`), }); debugLog(`[push-sync] remote branch writeback complete ${_did}/${repoName}`); } catch (err) { @@ -629,13 +628,13 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise<v port, pathPrefix, authenticatePush, - authenticateReceivePackDiscovery: false, + authenticateReceivePackDiscovery : false, onPushComplete, onRepoNotFound, onRepoAccess, onRequest, generateToken, - handleCliCommand: createCliRpcHandler(ctx), + handleCliCommand : createCliRpcHandler(ctx), }); // Register the git endpoint in the DID document (if public URL is provided). diff --git a/src/cli/main.ts b/src/cli/main.ts index 79038c1..9ac588a 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -86,9 +86,9 @@ import { cloneCommand } from './commands/clone.js'; import { connectAgent } from './agent.js'; import { dispatchAgentCommand } from './dispatch.js'; import { ensureDaemon } from '../daemon/lifecycle.js'; +import { forwardCliCommandIfAvailable } from './local-rpc.js'; import { serveDaemonCommand } from './commands/serve-lifecycle.js'; import { setupCommand } from './commands/setup.js'; -import { forwardCliCommandIfAvailable } from './local-rpc.js'; import { checkGit, requireGit, warnGit } from './preflight.js'; import { flagValue, hasFlag } from './flags.js'; import { profileDataPath, resolveProfile } from '../profiles/config.js'; diff --git a/src/cli/moderation-state.ts b/src/cli/moderation-state.ts index 38e3ffd..aa5ffc4 100644 --- a/src/cli/moderation-state.ts +++ b/src/cli/moderation-state.ts @@ -1,6 +1,6 @@ import type { AgentContext } from './agent.js'; -import type { RepoContext } from './repo-context.js'; import type { ModerationEventData } from '../repo.js'; +import type { RepoContext } from './repo-context.js'; import { shortId } from '../github-shim/helpers.js'; @@ -89,8 +89,8 @@ async function listModerationEvents( for (const record of records) { entries.push({ record, - data: {}, - tags: (record.tags ?? {}) as Record<string, string>, + data : {}, + tags : (record.tags ?? {}) as Record<string, string>, }); } return entries; diff --git a/src/cli/record-send.ts b/src/cli/record-send.ts index 7996ab4..f795d36 100644 --- a/src/cli/record-send.ts +++ b/src/cli/record-send.ts @@ -2,6 +2,7 @@ import type { AgentContext } from './agent.js'; import { HttpDwnRpcClient } from '@enbox/dwn-clients'; import { DataStream, type MessageSigner } from '@enbox/dwn-sdk-js'; + import { getDwnEndpoints } from '../git-server/did-service.js'; /** Send a locally composed `store:false` record to another DID. */ @@ -163,7 +164,7 @@ export async function messageSignerForContext(ctx: AgentContext): Promise<Messag return { keyId : signer.keyId, algorithm : signer.algorithm, - sign: (content: Uint8Array) => signer.sign({ data: content }), + sign : (content: Uint8Array) => signer.sign({ data: content }), }; } diff --git a/src/cli/repo-context.ts b/src/cli/repo-context.ts index 1c077fa..8a327b4 100644 --- a/src/cli/repo-context.ts +++ b/src/cli/repo-context.ts @@ -160,9 +160,9 @@ async function queryRepoRecordsByNameViaEndpoints( for (const endpoint of endpoints) { let errorMessage: string | undefined; const reply = await client.sendDwnRequest({ - dwnUrl : endpoint, + dwnUrl : endpoint, targetDid, - message : query.message, + message : query.message, }).catch((err) => { errorMessage = (err as Error).message; return undefined; diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index 1682011..0428013 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -16,8 +16,8 @@ import { spawn } from 'node:child_process'; import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { enboxHome, profilesDir } from '../profiles/config.js'; import { getVersion } from '../version.js'; +import { enboxHome, profilesDir } from '../profiles/config.js'; import { readLockfile, removeLockfile } from './lockfile.js'; import type { DaemonLock } from './lockfile.js'; diff --git a/src/git-remote/main.ts b/src/git-remote/main.ts index 0484322..478110b 100644 --- a/src/git-remote/main.ts +++ b/src/git-remote/main.ts @@ -20,10 +20,10 @@ * @module */ -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; import { parseDidUrl } from './parse-url.js'; import { resolveGitEndpoint } from './resolve.js'; diff --git a/src/git-server/bundle-restore.ts b/src/git-server/bundle-restore.ts index a8bc9b8..dd46fb7 100644 --- a/src/git-server/bundle-restore.ts +++ b/src/git-server/bundle-restore.ts @@ -15,10 +15,10 @@ * @module */ -import type { ForgeRepoProtocol } from '../repo.js'; -import type { ForgeRepoSchemaMap } from '../repo.js'; import type { ForgeRefsProtocol } from '../refs.js'; import type { ForgeRefsSchemaMap } from '../refs.js'; +import type { ForgeRepoProtocol } from '../repo.js'; +import type { ForgeRepoSchemaMap } from '../repo.js'; import type { TypedEnbox } from '@enbox/api'; import { DateSort } from '@enbox/dwn-sdk-js'; diff --git a/src/git-server/bundle-sync.ts b/src/git-server/bundle-sync.ts index 17ecb40..5fad422 100644 --- a/src/git-server/bundle-sync.ts +++ b/src/git-server/bundle-sync.ts @@ -19,18 +19,19 @@ * @module */ -import type { ForgeRepoProtocol } from '../repo.js'; -import type { ForgeRepoSchemaMap } from '../repo.js'; import type { ForgeRefsProtocol } from '../refs.js'; import type { ForgeRefsSchemaMap } from '../refs.js'; -import type { GitRef, OnPushComplete } from './ref-sync.js'; +import type { ForgeRepoProtocol } from '../repo.js'; +import type { ForgeRepoSchemaMap } from '../repo.js'; import type { TypedEnbox } from '@enbox/api'; +import type { GitRef, OnPushComplete } from './ref-sync.js'; import { DateSort } from '@enbox/dwn-sdk-js'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { tmpdir } from 'node:os'; import { readFile, stat, unlink } from 'node:fs/promises'; + import { branchDataForRef } from '../branch-state.js'; import { readGitRefs } from './ref-sync.js'; @@ -269,8 +270,8 @@ async function ensureBranchRecord( ownerDid : data.ownerDid, kind : data.kind, }, - parentContextId: repoContextId, - published : publish, + parentContextId : repoContextId, + published : publish, }); branchRecords.set(refName, record); return record; @@ -402,10 +403,10 @@ export async function createBranchBundle(repoPath: string, refName: string): Pro const fileInfo = await stat(bundlePath); return { - path: bundlePath, + path : bundlePath, refName, tipCommit, - size: fileInfo.size, + size : fileInfo.size, }; } diff --git a/src/git-server/http-handler.ts b/src/git-server/http-handler.ts index f1e7764..8b524e9 100644 --- a/src/git-server/http-handler.ts +++ b/src/git-server/http-handler.ts @@ -375,10 +375,10 @@ async function handleInfoRefs( return new Response(body, { status : 200, headers : { - 'Content-Type' : `application/x-${service}-advertisement`, - 'Cache-Control' : 'no-cache', - 'Content-Length': String(body.byteLength), - Connection : 'close', + 'Content-Type' : `application/x-${service}-advertisement`, + 'Cache-Control' : 'no-cache', + 'Content-Length' : String(body.byteLength), + Connection : 'close', }, }); } @@ -450,8 +450,8 @@ function spawnAndCollect( ): Promise<Uint8Array | null> { return new Promise((resolve, reject) => { const child = spawn('git', [service, '--stateless-rpc', '--advertise-refs', repoPath], { - env: process.env, - stdio: ['pipe', 'pipe', 'pipe'], + env : process.env, + stdio : ['pipe', 'pipe', 'pipe'], }); child.stdin?.end(); diff --git a/src/git-server/ref-sync.ts b/src/git-server/ref-sync.ts index e13dd50..d99ccca 100644 --- a/src/git-server/ref-sync.ts +++ b/src/git-server/ref-sync.ts @@ -17,14 +17,14 @@ * @module */ -import type { TypedEnbox } from '@enbox/api'; +import type { ForgeRefsProtocol } from '../refs.js'; import type { PushRefUpdate } from './push-updates.js'; +import type { TypedEnbox } from '@enbox/api'; +import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; import { spawn } from 'node:child_process'; import { branchDataForRef, reduceBranchState } from '../branch-state.js'; -import type { ForgeRefsProtocol } from '../refs.js'; -import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; // --------------------------------------------------------------------------- // Types @@ -193,8 +193,8 @@ async function ensureBranchRecord( ownerDid : data.ownerDid, kind : data.kind, }, - parentContextId: repoContextId, - published : publish, + parentContextId : repoContextId, + published : publish, }); branchRecords.set(refName, record); diff --git a/src/git-server/remote-branch-sync.ts b/src/git-server/remote-branch-sync.ts index 3e137a3..5abdbf5 100644 --- a/src/git-server/remote-branch-sync.ts +++ b/src/git-server/remote-branch-sync.ts @@ -10,17 +10,16 @@ * @module */ +import type { ForgeRefsProtocol } from '../refs.js'; +import type { PushRefUpdate } from './push-updates.js'; import type { TypedEnbox } from '@enbox/api'; +import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; import { randomUUID } from 'node:crypto'; import { readFile, unlink } from 'node:fs/promises'; -import type { ForgeRefsProtocol } from '../refs.js'; -import type { BranchStateData, ForgeRefsSchemaMap } from '../refs.js'; -import type { PushRefUpdate } from './push-updates.js'; - -import { branchDataForRef, isContributorBranchRef } from '../branch-state.js'; import { createBranchBundle } from './bundle-sync.js'; +import { branchDataForRef, isContributorBranchRef } from '../branch-state.js'; // --------------------------------------------------------------------------- // Types @@ -234,19 +233,19 @@ async function writeRemoteBranchRefUpdate(options: { const { refs, branchRecord, targetDid, actorDid, update, bundleRecordId, sendRecord } = options; const now = new Date().toISOString(); const data: BranchStateData = { - kind : 'refUpdate', - refName : update.refName, - oldTarget : update.oldTarget, - newTarget : update.newTarget, + kind : 'refUpdate', + refName : update.refName, + oldTarget : update.oldTarget, + newTarget : update.newTarget, actorDid, - createdAt : now, - nonce : randomUUID(), + createdAt : now, + nonce : randomUUID(), ...(bundleRecordId ? { bundleRecordId } : {}), }; const tags: Record<string, string> = { - kind : 'refUpdate', - refName : update.refName, + kind : 'refUpdate', + refName : update.refName, actorDid, }; if (update.oldTarget) { tags.oldTarget = update.oldTarget; } @@ -281,17 +280,17 @@ async function writeRemoteBranchCheckpoint(options: { const { refs, branchRecord, targetDid, actorDid, update, acceptedStateRecordId, sendRecord } = options; const now = new Date().toISOString(); const data: BranchStateData = { - kind : 'checkpoint', - refName : update.refName, - target : update.newTarget, + kind : 'checkpoint', + refName : update.refName, + target : update.newTarget, actorDid, acceptedStateRecordId, - acceptedAt : now, - createdAt : now, + acceptedAt : now, + createdAt : now, }; const tags: Record<string, string> = { - kind: 'checkpoint', - refName: update.refName, + kind : 'checkpoint', + refName : update.refName, actorDid, acceptedStateRecordId, }; diff --git a/src/git-server/server.ts b/src/git-server/server.ts index 3bbf825..8bdaf2c 100644 --- a/src/git-server/server.ts +++ b/src/git-server/server.ts @@ -25,8 +25,8 @@ import type { CliRpcRequest, CliRpcResponse } from '../cli/local-rpc.js'; import { createServer } from 'node:http'; -import { createGitHttpHandler } from './http-handler.js'; import { CLI_RPC_PATH } from '../cli/local-rpc.js'; +import { createGitHttpHandler } from './http-handler.js'; import { GitBackend } from './git-backend.js'; // --------------------------------------------------------------------------- diff --git a/src/refs.ts b/src/refs.ts index 4c35698..570de70 100644 --- a/src/refs.ts +++ b/src/refs.ts @@ -163,15 +163,15 @@ export const ForgeRefsDefinition = { { role: 'repo:repo/maintainer', can: ['create', 'squash'] }, ], $tags: { - $requiredTags : ['kind', 'refName'], - $allowUndefinedTags : false, - kind : { type: 'string', enum: ['refUpdate', 'checkpoint'] }, - refName : { type: 'string' }, - oldTarget : { type: 'string' }, - newTarget : { type: 'string' }, - target : { type: 'string' }, - actorDid : { type: 'string' }, - bundleRecordId : { type: 'string' }, + $requiredTags : ['kind', 'refName'], + $allowUndefinedTags : false, + kind : { type: 'string', enum: ['refUpdate', 'checkpoint'] }, + refName : { type: 'string' }, + oldTarget : { type: 'string' }, + newTarget : { type: 'string' }, + target : { type: 'string' }, + actorDid : { type: 'string' }, + bundleRecordId : { type: 'string' }, acceptedStateRecordId : { type: 'string' }, }, }, diff --git a/tests/branch-state.spec.ts b/tests/branch-state.spec.ts index 398d040..05d5a47 100644 --- a/tests/branch-state.spec.ts +++ b/tests/branch-state.spec.ts @@ -2,6 +2,9 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'bun:test'; +import type { BranchData } from '../src/refs.js'; +import type { BranchStateRecord } from '../src/branch-state.js'; + import { branchDataForRef, branchOwnerHash, @@ -11,8 +14,6 @@ import { reduceBranchState, validateBranchRecord, } from '../src/branch-state.js'; -import type { BranchData } from '../src/refs.js'; -import type { BranchStateRecord } from '../src/branch-state.js'; describe('branch state helpers', () => { const aliceDid = 'did:dht:alice'; @@ -171,11 +172,11 @@ function update( return { recordId, data: { - kind: 'refUpdate', + kind : 'refUpdate', refName, oldTarget, newTarget, - actorDid: 'did:dht:alice', + actorDid : 'did:dht:alice', createdAt, }, }; @@ -185,11 +186,11 @@ function checkpoint(recordId: string, refName: string, target: string | null, cr return { recordId, data: { - kind: 'checkpoint', + kind : 'checkpoint', refName, target, - actorDid: 'did:dht:alice', - acceptedAt: createdAt, + actorDid : 'did:dht:alice', + acceptedAt : createdAt, createdAt, }, }; diff --git a/tests/bundle-restore.spec.ts b/tests/bundle-restore.spec.ts index b1fa6e8..1c044d9 100644 --- a/tests/bundle-restore.spec.ts +++ b/tests/bundle-restore.spec.ts @@ -15,10 +15,10 @@ import { createTestIdentity } from './helpers/identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; -import { branchDataForRef, branchOwnerHash } from '../src/branch-state.js'; -import { createBranchBundle, createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { GitBackend } from '../src/git-server/git-backend.js'; import { restoreFromBundles } from '../src/git-server/bundle-restore.js'; +import { branchDataForRef, branchOwnerHash } from '../src/branch-state.js'; +import { createBranchBundle, createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { ForgeRefsProtocol } from '../src/refs.js'; import { ForgeRepoProtocol } from '../src/repo.js'; diff --git a/tests/bundle-sync.spec.ts b/tests/bundle-sync.spec.ts index 9be85fd..f7a2e8f 100644 --- a/tests/bundle-sync.spec.ts +++ b/tests/bundle-sync.spec.ts @@ -16,8 +16,8 @@ import { DateSort } from '@enbox/dwn-sdk-js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; -import { ForgeRepoProtocol } from '../src/repo.js'; import { ForgeRefsProtocol } from '../src/refs.js'; +import { ForgeRepoProtocol } from '../src/repo.js'; import { GitBackend } from '../src/git-server/git-backend.js'; import { createBundleSyncer, createFullBundle, createIncrementalBundle } from '../src/git-server/bundle-sync.js'; diff --git a/tests/cli.spec.ts b/tests/cli.spec.ts index e84c6c2..ca73d37 100644 --- a/tests/cli.spec.ts +++ b/tests/cli.spec.ts @@ -827,8 +827,8 @@ describe('gitd CLI commands', () => { filter: { contextId: repos[0].contextId }, }); const eventData = await Promise.all(events.map(async (record: any) => ({ - tags: record.tags as Record<string, string>, - data: await record.data.json(), + tags : record.tags as Record<string, string>, + data : await record.data.json(), }))); expect(eventData.some((entry) => @@ -877,8 +877,8 @@ describe('gitd CLI commands', () => { filter: { contextId: repos[0].contextId }, }); const eventData = await Promise.all(events.map(async (record: any) => ({ - tags: record.tags as Record<string, string>, - data: await record.data.json(), + tags : record.tags as Record<string, string>, + data : await record.data.json(), }))); expect(eventData.some((entry) => diff --git a/tests/daemon-lifecycle.spec.ts b/tests/daemon-lifecycle.spec.ts index 60b8b16..50fdd02 100644 --- a/tests/daemon-lifecycle.spec.ts +++ b/tests/daemon-lifecycle.spec.ts @@ -3,14 +3,15 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; -import { createGitServer } from '../src/git-server/server.js'; import { createServer } from 'node:http'; import { dirname, join } from 'node:path'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; + +import { createGitServer } from '../src/git-server/server.js'; import { getVersion } from '../src/version.js'; +import { profilesDir } from '../src/profiles/config.js'; import { daemonLogPath, daemonStatus, findGitdBin, stopDaemon } from '../src/daemon/lifecycle.js'; -import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { lockfilePath, readLockfile, removeLockfile, writeLockfile } from '../src/daemon/lockfile.js'; -import { profilesDir } from '../src/profiles/config.js'; // --------------------------------------------------------------------------- // Lockfile version field diff --git a/tests/e2e-collaboration.spec.ts b/tests/e2e-collaboration.spec.ts index 1405738..4adaa57 100644 --- a/tests/e2e-collaboration.spec.ts +++ b/tests/e2e-collaboration.spec.ts @@ -24,10 +24,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { exec as execCb } from 'node:child_process'; -import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { tmpdir } from 'node:os'; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { cachePortableDid } from './helpers/identity.js'; import { createTestIdentity } from './helpers/identity.js'; @@ -38,27 +38,27 @@ import { DidDht, DidJwk } from '@enbox/dids'; import type { AgentContext } from '../src/cli/agent.js'; import type { GitServer } from '../src/git-server/server.js'; +import type { PushRefUpdate } from '../src/git-server/push-updates.js'; -import { writeLockfile } from '../src/daemon/lockfile.js'; +import { branchOwnerHash } from '../src/branch-state.js'; import { createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { createDidSignatureVerifier } from '../src/git-server/verify.js'; import { createDwnPushAuthorizer } from '../src/git-server/push-authorizer.js'; import { createGitServer } from '../src/git-server/server.js'; import { createRefSyncer } from '../src/git-server/ref-sync.js'; -import { branchOwnerHash } from '../src/branch-state.js'; -import { issueCommand } from '../src/cli/commands/issue.js'; -import { modCommand } from '../src/cli/commands/mod.js'; -import { prCommand } from '../src/cli/commands/pr.js'; -import { restoreFromBundles } from '../src/git-server/bundle-restore.js'; -import { syncRemoteBranchPush } from '../src/git-server/remote-branch-sync.js'; import { ForgeIssuesProtocol } from '../src/issues.js'; import { ForgePatchesProtocol } from '../src/patches.js'; import { ForgeRefsProtocol } from '../src/refs.js'; import { ForgeRepoProtocol } from '../src/repo.js'; import { generatePushCredentials } from '../src/git-remote/credential-helper.js'; import { GitBackend } from '../src/git-server/git-backend.js'; -import type { PushRefUpdate } from '../src/git-server/push-updates.js'; +import { issueCommand } from '../src/cli/commands/issue.js'; +import { modCommand } from '../src/cli/commands/mod.js'; +import { prCommand } from '../src/cli/commands/pr.js'; +import { restoreFromBundles } from '../src/git-server/bundle-restore.js'; import { shortId } from '../src/github-shim/helpers.js'; +import { syncRemoteBranchPush } from '../src/git-server/remote-branch-sync.js'; +import { writeLockfile } from '../src/daemon/lockfile.js'; import { decodePushToken, DID_AUTH_USERNAME, @@ -467,7 +467,7 @@ describe('E2E: repo collaboration (maintainer + contributor + moderator)', () => ...localProtocol, records: { ...localProtocol.records, - query: async (path: string, options?: any) => { + query: async (path: string, options?: any): Promise<any> => { if (options?.from === aliceDid) { const { from: _from, ...rest } = options; return aliceProtocol.records.query(path, rest); @@ -1130,12 +1130,12 @@ describe('E2E: repo collaboration (maintainer + contributor + moderator)', () => }; bobHelperServer = await createGitServer({ - basePath : BOB_HELPER_REPOS_PATH, - port : 0, + basePath : BOB_HELPER_REPOS_PATH, + port : 0, authenticatePush, - onPushComplete: async (did, _repo, repoPath, pushContext) => { + onPushComplete : async (did, _repo, repoPath, pushContext) => { await syncRemoteBranchPush({ - refs : bobRefs, + refs : bobRefs, repoContextId, targetDid : did, actorDid : bobDid, @@ -1539,4 +1539,4 @@ describe('E2E: repo collaboration (maintainer + contributor + moderator)', () => } }, 30_000); - }); +}); diff --git a/tests/e2e-passive-dwn-sync.spec.ts b/tests/e2e-passive-dwn-sync.spec.ts index 1eca1c2..a3e89af 100644 --- a/tests/e2e-passive-dwn-sync.spec.ts +++ b/tests/e2e-passive-dwn-sync.spec.ts @@ -11,9 +11,8 @@ import { spawn, spawnSync } from 'node:child_process'; import { HttpDwnRpcClient } from '@enbox/dwn-clients'; import { RecordsQuery } from '@enbox/dwn-sdk-js'; -import { ForgeRepoDefinition } from '../src/repo.js'; -import { ForgeRefsDefinition } from '../src/refs.js'; import { branchOwnerHash } from '../src/branch-state.js'; +import { ForgeRepoDefinition } from '../src/repo.js'; import { startPassiveDwnServer } from './helpers/passive-dwn-server.js'; const BASE = resolve('__TESTDATA__/passive-dwn-sync-e2e'); @@ -168,9 +167,9 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { `did::${alice.did}/passive-demo`, bobClone, ], { - cwd : resolve('.'), - timeoutMs: 60_000, - env : { + cwd : resolve('.'), + timeoutMs : 60_000, + env : { ...testEnv('bob', passiveDwn.url), PATH: `${binDir}:${process.env.PATH ?? ''}`, }, @@ -181,7 +180,13 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { }; return result.status === 0; }, async () => { - return `Bob could not clone Alice repo through passive DWN\nstdout:\n${clone.stdout}\nstderr:\n${clone.stderr}\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`; + return [ + 'Bob could not clone Alice repo through passive DWN', + `stdout:\n${clone.stdout}`, + `stderr:\n${clone.stderr}`, + `helper stdout:\n${bobServe.stdout()}`, + `helper stderr:\n${bobServe.stderr()}`, + ].join('\n'); }, 60_000); expect(clone.stderr).toContain('(via LocalDwnHelper)'); @@ -202,16 +207,22 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { 'origin', `HEAD:${contributorRef}`, ], { - cwd : bobClone, - timeoutMs: 120_000, - env : { + cwd : bobClone, + timeoutMs : 120_000, + env : { ...testEnv('bob', passiveDwn.url), - PATH: `${binDir}:${process.env.PATH ?? ''}`, - GIT_TERMINAL_PROMPT: '0', + PATH : `${binDir}:${process.env.PATH ?? ''}`, + GIT_TERMINAL_PROMPT : '0', }, }); if (pushResult.status !== 0) { - throw new Error(`Bob contributor push failed with status ${pushResult.status}\nstdout:\n${pushResult.stdout}\nstderr:\n${pushResult.stderr}\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`); + throw new Error([ + `Bob contributor push failed with status ${pushResult.status}`, + `stdout:\n${pushResult.stdout}`, + `stderr:\n${pushResult.stderr}`, + `helper stdout:\n${bobServe.stdout()}`, + `helper stderr:\n${bobServe.stderr()}`, + ].join('\n')); } expect(pushResult.stderr).toContain('(via LocalDwnHelper)'); await waitFor(async () => { @@ -219,7 +230,11 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { && bobServe.stderr().includes('[dwn-apply] remote branch writeback') && bobServe.stderr().includes(': Applied'); }, async () => { - return `Bob contributor branch writeback did not apply to Alice passive DWN\nhelper stdout:\n${bobServe.stdout()}\nhelper stderr:\n${bobServe.stderr()}`; + return [ + 'Bob contributor branch writeback did not apply to Alice passive DWN', + `helper stdout:\n${bobServe.stdout()}`, + `helper stderr:\n${bobServe.stderr()}`, + ].join('\n'); }, 60_000); const issue = await runAsync('bun', [ @@ -326,7 +341,13 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { }; return result.status === 0; }, async () => { - return `Alice could not checkout Bob PR after sync from passive DWN\nstdout:\n${checkout.stdout}\nstderr:\n${checkout.stderr}\nhelper stdout:\n${aliceMergeServe.stdout()}\nhelper stderr:\n${aliceMergeServe.stderr()}`; + return [ + 'Alice could not checkout Bob PR after sync from passive DWN', + `stdout:\n${checkout.stdout}`, + `stderr:\n${checkout.stderr}`, + `helper stdout:\n${aliceMergeServe.stdout()}`, + `helper stderr:\n${aliceMergeServe.stderr()}`, + ].join('\n'); }, 90_000); const merge = await runAsync('bun', [ @@ -384,9 +405,9 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { `did::${alice.did}/passive-demo`, finalClone, ], { - cwd : resolve('.'), - timeoutMs: 60_000, - env : { + cwd : resolve('.'), + timeoutMs : 60_000, + env : { ...testEnv('bob', passiveDwn.url), PATH: `${binDir}:${process.env.PATH ?? ''}`, }, @@ -397,7 +418,13 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { }; return result.status === 0; }, async () => { - return `Bob could not clone Alice merged repo through passive DWN\nstdout:\n${finalResult.stdout}\nstderr:\n${finalResult.stderr}\nhelper stdout:\n${bobFinalServe.stdout()}\nhelper stderr:\n${bobFinalServe.stderr()}`; + return [ + 'Bob could not clone Alice merged repo through passive DWN', + `stdout:\n${finalResult.stdout}`, + `stderr:\n${finalResult.stderr}`, + `helper stdout:\n${bobFinalServe.stdout()}`, + `helper stderr:\n${bobFinalServe.stderr()}`, + ].join('\n'); }, 90_000); expect(finalResult.stderr).toContain('(via LocalDwnHelper)'); @@ -480,8 +507,8 @@ async function startServe( return { proc, - stdout: () => stdout, - stderr: () => stderr, + stdout : () => stdout, + stderr : () => stderr, }; } @@ -506,7 +533,11 @@ async function authenticatedRemoteUrl( throw new Error('auth token response was missing username or password'); } - return `http://${encodeURIComponent(creds.username)}:${encodeURIComponent(creds.password)}@127.0.0.1:${port}/${encodeURIComponent(ownerDid)}/${repoName}`; + const username = encodeURIComponent(creds.username); + const password = encodeURIComponent(creds.password); + const owner = encodeURIComponent(ownerDid); + + return `http://${username}:${password}@127.0.0.1:${port}/${owner}/${repoName}`; } function createAndPushMain(workdir: string, remoteUrl: string): void { @@ -640,9 +671,9 @@ async function runAsyncRaw( options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, ): Promise<{ status: number | null; stdout: string; stderr: string }> { const child = spawn(command, args, { - cwd : options.cwd ?? resolve('.'), - env : options.env ?? process.env, - stdio : ['ignore', 'pipe', 'pipe'], + cwd : options.cwd ?? resolve('.'), + env : options.env ?? process.env, + stdio : ['ignore', 'pipe', 'pipe'], }); let stdout = ''; @@ -686,13 +717,13 @@ function testEnv(profile: string, dwnEndpoint: string): NodeJS.ProcessEnv { return { ...process.env, ENBOX_HOME, - GITD_PROFILE : profile, - GITD_PASSWORD : PASSWORD, - GITD_DWN_ENDPOINT : dwnEndpoint, - GITD_DWN_REGISTRATION : 'off', - GITD_DID_REPUBLISH : 'off', - GITD_DEBUG : '1', - GITD_DID_RESOLUTION_TIMEOUT_MS: '1000', + GITD_PROFILE : profile, + GITD_PASSWORD : PASSWORD, + GITD_DWN_ENDPOINT : dwnEndpoint, + GITD_DWN_REGISTRATION : 'off', + GITD_DID_REPUBLISH : 'off', + GITD_DEBUG : '1', + GITD_DID_RESOLUTION_TIMEOUT_MS : '1000', }; } diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index b3dcb78..396aa6b 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -10,7 +10,11 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import type { AgentContext } from '../src/cli/agent.js'; import type { ChildProcess } from 'node:child_process'; +import type { GitServer } from '../src/git-server/server.js'; +import type { PushRefUpdate } from '../src/git-server/push-updates.js'; +import type { RepoContext } from '../src/cli/repo-context.js'; import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; @@ -23,11 +27,6 @@ import { DidJwk } from '@enbox/dids'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; -import type { AgentContext } from '../src/cli/agent.js'; -import type { GitServer } from '../src/git-server/server.js'; -import type { RepoContext } from '../src/cli/repo-context.js'; -import type { PushRefUpdate } from '../src/git-server/push-updates.js'; - import { createBundleSyncer } from '../src/git-server/bundle-sync.js'; import { createDidSignatureVerifier } from '../src/git-server/verify.js'; import { createDwnPushAuthorizer } from '../src/git-server/push-authorizer.js'; diff --git a/tests/git-remote.spec.ts b/tests/git-remote.spec.ts index 02e0c0f..22e3066 100644 --- a/tests/git-remote.spec.ts +++ b/tests/git-remote.spec.ts @@ -538,7 +538,7 @@ describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { { id: '#dwn', type: 'DecentralizedWebNode', serviceEndpoint: 'https://dwn.example.com' }, ], }, - didDocumentMetadata : {}, + didDocumentMetadata : {}, didResolutionMetadata : {}, } as any), }); @@ -561,7 +561,7 @@ describe('resolveGitEndpoint skips local daemon for non-owner DID', () => { { id: '#git', type: 'GitTransport', serviceEndpoint: 'https://git.example.com/repos' }, ], }, - didDocumentMetadata : {}, + didDocumentMetadata : {}, didResolutionMetadata : {}, } as any), }); diff --git a/tests/helpers/passive-dwn-server.ts b/tests/helpers/passive-dwn-server.ts index 80e51bf..4966f41 100644 --- a/tests/helpers/passive-dwn-server.ts +++ b/tests/helpers/passive-dwn-server.ts @@ -1,14 +1,22 @@ -import type { DidDocument, DidResolutionOptions, DidResolutionResult, DidResolver } from '@enbox/dids'; +import type { Dialect } from '@enbox/dwn-sql-store'; import type { JsonRpcId } from '@enbox/dwn-clients'; +import type { DidDocument, DidResolutionOptions, DidResolutionResult, DidResolver } from '@enbox/dids'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Dialect } from '@enbox/dwn-sql-store'; import { Buffer } from 'node:buffer'; import { createServer } from 'node:http'; -import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; import { Kysely } from 'kysely'; +import { + createBunSqliteDatabase, + DataStoreSql, + MessageStoreSql, + ResumableTaskStoreSql, + runDwnStoreMigrations, + SqliteDialect, +} from '@enbox/dwn-sql-store'; import { DataStream, DurableEventLog, @@ -23,14 +31,6 @@ import { DidWeb, UniversalResolver, } from '@enbox/dids'; -import { - createBunSqliteDatabase, - DataStoreSql, - MessageStoreSql, - ResumableTaskStoreSql, - runDwnStoreMigrations, - SqliteDialect, -} from '@enbox/dwn-sql-store'; export type SeedDidDocument = { didDocument: DidDocument; @@ -73,10 +73,10 @@ export async function startPassiveDwnServer(options: { const port = typeof address === 'object' && address ? address.port : 0; return { - url : `http://127.0.0.1:${port}`, + url : `http://127.0.0.1:${port}`, port, - addDidDocument: (entry) => { didResolver.add(entry); }, - stop : async () => { + addDidDocument : (entry): void => { didResolver.add(entry); }, + stop : async (): Promise<void> => { await new Promise<void>((resolveClose) => server.close(() => resolveClose())); await dwn.close(); }, @@ -110,7 +110,7 @@ async function createPassiveDwn( class SeededDidResolver implements DidResolver { private readonly documents = new Map<string, SeedDidDocument>(); private readonly fallback = new UniversalResolver({ - didResolvers : [DidDht, DidJwk, DidKey, DidWeb], + didResolvers: [DidDht, DidJwk, DidKey, DidWeb], }); constructor(didDocuments: Iterable<SeedDidDocument>) { diff --git a/tests/helpers/profile-bootstrap.ts b/tests/helpers/profile-bootstrap.ts index 107bf09..5621ef3 100644 --- a/tests/helpers/profile-bootstrap.ts +++ b/tests/helpers/profile-bootstrap.ts @@ -12,9 +12,9 @@ if (!profileName || !password) { try { const result = await connectAgent({ password, - dataPath : profileDataPath(profileName), - sync : 'off', - registration : false, + dataPath : profileDataPath(profileName), + sync : 'off', + registration : false, }); upsertProfile(profileName, { diff --git a/tests/helpers/test-daemon.ts b/tests/helpers/test-daemon.ts index 50b695e..f67dbe4 100644 --- a/tests/helpers/test-daemon.ts +++ b/tests/helpers/test-daemon.ts @@ -21,6 +21,8 @@ import { createTestIdentity } from './identity.js'; import { Enbox } from '@enbox/api'; import { EnboxUserAgent } from '@enbox/agent'; +import type { PushRefUpdate } from '../../src/git-server/push-updates.js'; + import { createDidSignatureVerifier } from '../../src/git-server/verify.js'; import { createDwnPushAuthorizer } from '../../src/git-server/push-authorizer.js'; import { createGitServer } from '../../src/git-server/server.js'; @@ -35,7 +37,6 @@ import { formatAuthPassword, parseAuthPassword, } from '../../src/git-server/auth.js'; -import type { PushRefUpdate } from '../../src/git-server/push-updates.js'; // --------------------------------------------------------------------------- // Config from environment diff --git a/tests/passive-dwn-server.spec.ts b/tests/passive-dwn-server.spec.ts index 38b3b30..9354d54 100644 --- a/tests/passive-dwn-server.spec.ts +++ b/tests/passive-dwn-server.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { rmSync } from 'node:fs'; import { resolve } from 'node:path'; +import { rmSync } from 'node:fs'; import { HttpDwnRpcClient } from '@enbox/dwn-clients'; import { @@ -29,7 +29,7 @@ describe('passive DWN RPC server helper', () => { it('serves standard DWN processMessage, streamed reads, and replicated apply over HTTP', async () => { const owner = await TestDataGenerator.generatePersona(); const server = await startPassiveDwnServer({ - dataPath : BASE, + dataPath : BASE, didDocuments : [didDocumentForPersona(owner)], }); try { @@ -142,7 +142,7 @@ describe('passive DWN RPC server helper', () => { }); }); -function didDocumentForPersona(persona: Awaited<ReturnType<typeof TestDataGenerator.generatePersona>>) { +function didDocumentForPersona(persona: Awaited<ReturnType<typeof TestDataGenerator.generatePersona>>): Record<string, unknown> { return { didDocument: { id : persona.did, @@ -152,8 +152,8 @@ function didDocumentForPersona(persona: Awaited<ReturnType<typeof TestDataGenera controller : persona.did, publicKeyJwk : persona.keyPair.publicJwk, }], - authentication : [persona.keyId], - assertionMethod: [persona.keyId], + authentication : [persona.keyId], + assertionMethod : [persona.keyId], }, }; } diff --git a/tests/push-policy.spec.ts b/tests/push-policy.spec.ts index 4c0469f..d39d17e 100644 --- a/tests/push-policy.spec.ts +++ b/tests/push-policy.spec.ts @@ -105,13 +105,13 @@ describe('createDwnPushAuthorizer branch policy', () => { it('rejects blocked contributors even when their role record remains', async () => { const authorize = createDwnPushAuthorizer({ - repo : mockRepo({ - contributors: [CONTRIBUTOR_DID], - moderationEvents: [ + repo: mockRepo({ + contributors : [CONTRIBUTOR_DID], + moderationEvents : [ moderationEvent('block', CONTRIBUTOR_DID, '2026-06-23T00:00:00.000Z'), ], }), - ownerDid : OWNER_DID, + ownerDid: OWNER_DID, }); await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ @@ -121,14 +121,14 @@ describe('createDwnPushAuthorizer branch policy', () => { it('allows a contributor again after a later unblock event', async () => { const authorize = createDwnPushAuthorizer({ - repo : mockRepo({ - contributors: [CONTRIBUTOR_DID], - moderationEvents: [ + repo: mockRepo({ + contributors : [CONTRIBUTOR_DID], + moderationEvents : [ moderationEvent('block', CONTRIBUTOR_DID, '2026-06-23T00:00:00.000Z'), moderationEvent('unblock', CONTRIBUTOR_DID, '2026-06-23T00:01:00.000Z'), ], }), - ownerDid : OWNER_DID, + ownerDid: OWNER_DID, }); await expect(authorize(CONTRIBUTOR_DID, OWNER_DID, REPO_NAME, [ @@ -160,17 +160,17 @@ function pkt(payload: string): string { return (payload.length + 4).toString(16).padStart(4, '0') + payload; } -function update(refName: string) { +function update(refName: string): { oldTarget: string; newTarget: string; refName: string } { return { oldTarget: OLD, newTarget: NEW, refName }; } -function moderationEvent(action: 'block' | 'unblock', targetDid: string, createdAt: string) { +function moderationEvent(action: 'block' | 'unblock', targetDid: string, createdAt: string): Record<string, unknown> { return { - id: `${action}-${targetDid}-${createdAt}`, - dateCreated: createdAt, - tags: { action, targetDid }, - data: { - json: async () => ({ action, targetDid, createdAt }), + id : `${action}-${targetDid}-${createdAt}`, + dateCreated : createdAt, + tags : { action, targetDid }, + data : { + json: async (): Promise<{ action: 'block' | 'unblock'; targetDid: string; createdAt: string }> => ({ action, targetDid, createdAt }), }, }; } @@ -180,7 +180,7 @@ function mockRepo(options: { contributors?: string[]; moderationEvents?: any[]; queries?: Array<{ path: string; from?: string }>; -} = {}) { +} = {}): Record<string, unknown> { return { records: { query: async (path: string, query: any = {}) => { diff --git a/tests/ref-sync.spec.ts b/tests/ref-sync.spec.ts index a2c2504..48ed1bf 100644 --- a/tests/ref-sync.spec.ts +++ b/tests/ref-sync.spec.ts @@ -174,10 +174,10 @@ function createMockRefsHandle(): { handle: any; recordsFor: (path: string, paren const handle = { records: { - query: async (path: string, options?: any) => ({ + query: async (path: string, options?: any): Promise<{ records: MockRecord[] }> => ({ records: recordsFor(path, options?.filter?.contextId), }), - create: async (path: string, options: any) => { + create: async (path: string, options: any): Promise<{ status: { code: number }; record: MockRecord }> => { const parentContextId = options.parentContextId; if (options.squash) { for (const record of [...recordsFor(path, parentContextId)]) { From d7f02cb47570396444595cf86539f3f2ad6faf7a Mon Sep 17 00:00:00 2001 From: Liran Cohen <c.liran.c@gmail.com> Date: Tue, 23 Jun 2026 13:21:17 +0000 Subject: [PATCH 4/5] Fix DHT GitTransport URL expectations --- tests/git-remote.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/git-remote.spec.ts b/tests/git-remote.spec.ts index 22e3066..d877c39 100644 --- a/tests/git-remote.spec.ts +++ b/tests/git-remote.spec.ts @@ -343,12 +343,12 @@ describeDht('resolveGitEndpoint (did:dht integration)', () => { expect(result.did).toBe(gitTransportDid); expect(result.source).toBe('GitTransport'); // URL includes the DID in the path for server-side routing. - expect(result.url).toBe(`${gitEndpointUrl}/${gitTransportDid}`); + expect(result.url).toBe(`${gitEndpointUrl}/${encodeURIComponent(gitTransportDid)}`); }); it('should append repo name after DID in GitTransport endpoint', async () => { const result = await resolveGitEndpoint(gitTransportDid, 'my-repo'); - expect(result.url).toBe(`${gitEndpointUrl}/${gitTransportDid}/my-repo`); + expect(result.url).toBe(`${gitEndpointUrl}/${encodeURIComponent(gitTransportDid)}/my-repo`); expect(result.source).toBe('GitTransport'); }); @@ -367,13 +367,13 @@ describeDht('resolveGitEndpoint (did:dht integration)', () => { it('should use GitTransport when both GitTransport and DWN services exist', async () => { const result = await resolveGitEndpoint(bothServicesDid); expect(result.source).toBe('GitTransport'); - expect(result.url).toBe(`${gitPriorityUrl}/${bothServicesDid}`); + expect(result.url).toBe(`${gitPriorityUrl}/${encodeURIComponent(bothServicesDid)}`); }); it('should use GitTransport with repo appended when both services exist', async () => { const result = await resolveGitEndpoint(bothServicesDid, 'test-repo'); expect(result.source).toBe('GitTransport'); - expect(result.url).toBe(`${gitPriorityUrl}/${bothServicesDid}/test-repo`); + expect(result.url).toBe(`${gitPriorityUrl}/${encodeURIComponent(bothServicesDid)}/test-repo`); }); }); From a1c032de768c423da44da719988a415825bad7cb Mon Sep 17 00:00:00 2001 From: Liran Cohen <c.liran.c@gmail.com> Date: Tue, 23 Jun 2026 14:19:09 +0000 Subject: [PATCH 5/5] Replace enbox.org domains with enbox.id --- ARCHITECTURE.md | 2 +- PLAN.md | 116 +++++++++++------------ README.md | 2 +- schemas/ci/check-run.json | 2 +- schemas/ci/check-suite.json | 2 +- schemas/issues/assignment.json | 2 +- schemas/issues/comment.json | 2 +- schemas/issues/issue-dependency.json | 2 +- schemas/issues/issue-field-value.json | 2 +- schemas/issues/issue-sub-issue.json | 2 +- schemas/issues/issue.json | 2 +- schemas/issues/label.json | 2 +- schemas/issues/reaction.json | 2 +- schemas/issues/status-change.json | 2 +- schemas/notifications/notification.json | 2 +- schemas/org/org-blocked-user.json | 2 +- schemas/org/org-custom-property.json | 2 +- schemas/org/org-issue-field.json | 2 +- schemas/org/org-issue-type.json | 2 +- schemas/org/org-member.json | 2 +- schemas/org/org-webhook.json | 2 +- schemas/org/org.json | 2 +- schemas/org/team-member.json | 2 +- schemas/org/team.json | 2 +- schemas/patches/merge-result.json | 2 +- schemas/patches/patch-status-change.json | 2 +- schemas/patches/patch.json | 2 +- schemas/patches/review-comment.json | 2 +- schemas/patches/review.json | 2 +- schemas/patches/revision.json | 2 +- schemas/refs/branch-state.json | 2 +- schemas/refs/branch.json | 2 +- schemas/refs/git-ref.json | 2 +- schemas/registry/attestation.json | 2 +- schemas/registry/package-version.json | 2 +- schemas/registry/package.json | 2 +- schemas/releases/release.json | 2 +- schemas/repo/collaborator.json | 2 +- schemas/repo/moderation-event.json | 2 +- schemas/repo/repo.json | 2 +- schemas/repo/settings.json | 2 +- schemas/repo/submission-decision.json | 2 +- schemas/repo/topic.json | 2 +- schemas/repo/webhook.json | 2 +- schemas/social/activity.json | 2 +- schemas/social/block.json | 2 +- schemas/social/email.json | 2 +- schemas/social/follow.json | 2 +- schemas/social/gist-comment.json | 2 +- schemas/social/gist-star.json | 2 +- schemas/social/gist.json | 2 +- schemas/social/gpg-key.json | 2 +- schemas/social/profile.json | 2 +- schemas/social/social-account.json | 2 +- schemas/social/ssh-key.json | 2 +- schemas/social/ssh-signing-key.json | 2 +- schemas/social/star.json | 2 +- schemas/wiki/wiki-history.json | 2 +- schemas/wiki/wiki-page.json | 2 +- src/ci.ts | 8 +- src/daemon/adapters/go.ts | 2 +- src/issues.ts | 22 ++--- src/notifications.ts | 4 +- src/org.ts | 22 ++--- src/patches.ts | 16 ++-- src/refs.ts | 10 +- src/registry.ts | 8 +- src/releases.ts | 6 +- src/repo.ts | 24 ++--- src/shims/go/proxy.ts | 24 ++--- src/shims/go/server.ts | 4 +- src/social.ts | 28 +++--- src/wiki.ts | 8 +- tests/github-shim.spec.ts | 18 ++-- tests/integration.spec.ts | 80 ++++++++-------- tests/protocols.spec.ts | 50 +++++----- tests/schemas.spec.ts | 2 +- tests/shims.spec.ts | 4 +- 78 files changed, 286 insertions(+), 286 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3acae1f..e4b91aa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,7 +45,7 @@ This document provides a high-level overview of how `gitd` works. For detailed p ## Protocols -`gitd` defines a set of DWN protocols under the `https://enbox.org/protocols/forge/` namespace. Each protocol is self-contained with its own types, schemas, and authorization rules. +`gitd` defines a set of DWN protocols under the `https://enbox.id/protocols/forge/` namespace. Each protocol is self-contained with its own types, schemas, and authorization rules. | Protocol | Purpose | |---|---| diff --git a/PLAN.md b/PLAN.md index c33750b..240f826 100644 --- a/PLAN.md +++ b/PLAN.md @@ -130,7 +130,7 @@ Features that require global aggregation (star counts, cross-DWN search, trendin ## 4. Protocol Architecture -All protocols use the `https://enbox.org/protocols/forge/` namespace. Each protocol definition follows the `@enbox/protocols` pattern: data types, SchemaMap, raw `ProtocolDefinition`, and typed protocol via `defineProtocol()`. +All protocols use the `https://enbox.id/protocols/forge/` namespace. Each protocol definition follows the `@enbox/protocols` pattern: data types, SchemaMap, raw `ProtocolDefinition`, and typed protocol via `defineProtocol()`. ### 4.1 Repository Protocol (`forge-repo`) @@ -138,19 +138,19 @@ The foundational protocol. Defines repository metadata, collaborator roles, and ```typescript export const ForgeRepoDefinition = { - protocol : 'https://enbox.org/protocols/forge/repo', + protocol : 'https://enbox.id/protocols/forge/repo', published : true, types : { - repo : { schema: 'https://enbox.org/schemas/forge/repo', dataFormats: ['application/json'] }, - settings : { schema: 'https://enbox.org/schemas/forge/settings', dataFormats: ['application/json'] }, + repo : { schema: 'https://enbox.id/schemas/forge/repo', dataFormats: ['application/json'] }, + settings : { schema: 'https://enbox.id/schemas/forge/settings', dataFormats: ['application/json'] }, readme : { dataFormats: ['text/markdown', 'text/plain'] }, license : { dataFormats: ['text/plain'] }, bundle : { dataFormats: ['application/x-git-bundle'] }, - maintainer : { schema: 'https://enbox.org/schemas/forge/collaborator', dataFormats: ['application/json'] }, - triager : { schema: 'https://enbox.org/schemas/forge/collaborator', dataFormats: ['application/json'] }, - contributor : { schema: 'https://enbox.org/schemas/forge/collaborator', dataFormats: ['application/json'] }, - topic : { schema: 'https://enbox.org/schemas/forge/topic', dataFormats: ['application/json'] }, - webhook : { schema: 'https://enbox.org/schemas/forge/webhook', dataFormats: ['application/json'], encryptionRequired: true }, + maintainer : { schema: 'https://enbox.id/schemas/forge/collaborator', dataFormats: ['application/json'] }, + triager : { schema: 'https://enbox.id/schemas/forge/collaborator', dataFormats: ['application/json'] }, + contributor : { schema: 'https://enbox.id/schemas/forge/collaborator', dataFormats: ['application/json'] }, + topic : { schema: 'https://enbox.id/schemas/forge/topic', dataFormats: ['application/json'] }, + webhook : { schema: 'https://enbox.id/schemas/forge/webhook', dataFormats: ['application/json'], encryptionRequired: true }, }, structure: { repo: { @@ -254,16 +254,16 @@ export type CollaboratorData = { ```typescript export const ForgeIssuesDefinition = { - protocol : 'https://enbox.org/protocols/forge/issues', + protocol : 'https://enbox.id/protocols/forge/issues', published : true, - uses : { repo: 'https://enbox.org/protocols/forge/repo' }, + uses : { repo: 'https://enbox.id/protocols/forge/repo' }, types : { - issue : { schema: 'https://enbox.org/schemas/forge/issue', dataFormats: ['application/json'] }, - comment : { schema: 'https://enbox.org/schemas/forge/comment', dataFormats: ['application/json'] }, - reaction : { schema: 'https://enbox.org/schemas/forge/reaction', dataFormats: ['application/json'] }, - label : { schema: 'https://enbox.org/schemas/forge/label', dataFormats: ['application/json'] }, - statusChange : { schema: 'https://enbox.org/schemas/forge/status-change', dataFormats: ['application/json'] }, - assignment : { schema: 'https://enbox.org/schemas/forge/assignment', dataFormats: ['application/json'] }, + issue : { schema: 'https://enbox.id/schemas/forge/issue', dataFormats: ['application/json'] }, + comment : { schema: 'https://enbox.id/schemas/forge/comment', dataFormats: ['application/json'] }, + reaction : { schema: 'https://enbox.id/schemas/forge/reaction', dataFormats: ['application/json'] }, + label : { schema: 'https://enbox.id/schemas/forge/label', dataFormats: ['application/json'] }, + statusChange : { schema: 'https://enbox.id/schemas/forge/status-change', dataFormats: ['application/json'] }, + assignment : { schema: 'https://enbox.id/schemas/forge/assignment', dataFormats: ['application/json'] }, }, structure: { repo: { @@ -393,16 +393,16 @@ Pull requests, code review, and merge tracking. ```typescript export const ForgePatchesDefinition = { - protocol : 'https://enbox.org/protocols/forge/patches', + protocol : 'https://enbox.id/protocols/forge/patches', published : true, - uses : { repo: 'https://enbox.org/protocols/forge/repo' }, + uses : { repo: 'https://enbox.id/protocols/forge/repo' }, types : { - patch : { schema: 'https://enbox.org/schemas/forge/patch', dataFormats: ['application/json'] }, - revision : { schema: 'https://enbox.org/schemas/forge/revision', dataFormats: ['application/json'] }, - review : { schema: 'https://enbox.org/schemas/forge/review', dataFormats: ['application/json'] }, - reviewComment : { schema: 'https://enbox.org/schemas/forge/review-comment', dataFormats: ['application/json'] }, - statusChange : { schema: 'https://enbox.org/schemas/forge/patch-status-change', dataFormats: ['application/json'] }, - mergeResult : { schema: 'https://enbox.org/schemas/forge/merge-result', dataFormats: ['application/json'] }, + patch : { schema: 'https://enbox.id/schemas/forge/patch', dataFormats: ['application/json'] }, + revision : { schema: 'https://enbox.id/schemas/forge/revision', dataFormats: ['application/json'] }, + review : { schema: 'https://enbox.id/schemas/forge/review', dataFormats: ['application/json'] }, + reviewComment : { schema: 'https://enbox.id/schemas/forge/review-comment', dataFormats: ['application/json'] }, + statusChange : { schema: 'https://enbox.id/schemas/forge/patch-status-change', dataFormats: ['application/json'] }, + mergeResult : { schema: 'https://enbox.id/schemas/forge/merge-result', dataFormats: ['application/json'] }, }, structure: { repo: { @@ -530,12 +530,12 @@ export type MergeResultData = { mergedBy: string }; ```typescript export const ForgeCiDefinition = { - protocol : 'https://enbox.org/protocols/forge/ci', + protocol : 'https://enbox.id/protocols/forge/ci', published : true, - uses : { repo: 'https://enbox.org/protocols/forge/repo' }, + uses : { repo: 'https://enbox.id/protocols/forge/repo' }, types : { - checkSuite : { schema: 'https://enbox.org/schemas/forge/check-suite', dataFormats: ['application/json'] }, - checkRun : { schema: 'https://enbox.org/schemas/forge/check-run', dataFormats: ['application/json'] }, + checkSuite : { schema: 'https://enbox.id/schemas/forge/check-suite', dataFormats: ['application/json'] }, + checkRun : { schema: 'https://enbox.id/schemas/forge/check-run', dataFormats: ['application/json'] }, artifact : { dataFormats: ['application/octet-stream', 'application/gzip', 'application/zip'] }, }, structure: { @@ -601,11 +601,11 @@ export const ForgeCiDefinition = { ```typescript export const ForgeReleasesDefinition = { - protocol : 'https://enbox.org/protocols/forge/releases', + protocol : 'https://enbox.id/protocols/forge/releases', published : true, - uses : { repo: 'https://enbox.org/protocols/forge/repo' }, + uses : { repo: 'https://enbox.id/protocols/forge/repo' }, types : { - release : { schema: 'https://enbox.org/schemas/forge/release', dataFormats: ['application/json'] }, + release : { schema: 'https://enbox.id/schemas/forge/release', dataFormats: ['application/json'] }, asset : { dataFormats: ['application/octet-stream', 'application/gzip', 'application/zip', 'application/x-tar'] }, signature : { dataFormats: ['application/pgp-signature', 'application/json'] }, }, @@ -662,13 +662,13 @@ export const ForgeReleasesDefinition = { ```typescript export const ForgeRegistryDefinition = { - protocol : 'https://enbox.org/protocols/forge/registry', + protocol : 'https://enbox.id/protocols/forge/registry', published : true, types : { - package : { schema: 'https://enbox.org/schemas/forge/package', dataFormats: ['application/json'] }, - version : { schema: 'https://enbox.org/schemas/forge/package-version', dataFormats: ['application/json'] }, + package : { schema: 'https://enbox.id/schemas/forge/package', dataFormats: ['application/json'] }, + version : { schema: 'https://enbox.id/schemas/forge/package-version', dataFormats: ['application/json'] }, tarball : { dataFormats: ['application/gzip', 'application/octet-stream'] }, - attestation : { schema: 'https://enbox.org/schemas/forge/attestation', dataFormats: ['application/json'] }, + attestation : { schema: 'https://enbox.id/schemas/forge/attestation', dataFormats: ['application/json'] }, }, structure: { package: { @@ -727,12 +727,12 @@ export const ForgeRegistryDefinition = { ```typescript export const ForgeSocialDefinition = { - protocol : 'https://enbox.org/protocols/forge/social', + protocol : 'https://enbox.id/protocols/forge/social', published : true, types : { - star : { schema: 'https://enbox.org/schemas/forge/star', dataFormats: ['application/json'] }, - follow : { schema: 'https://enbox.org/schemas/forge/follow', dataFormats: ['application/json'] }, - activity : { schema: 'https://enbox.org/schemas/forge/activity', dataFormats: ['application/json'] }, + star : { schema: 'https://enbox.id/schemas/forge/star', dataFormats: ['application/json'] }, + follow : { schema: 'https://enbox.id/schemas/forge/follow', dataFormats: ['application/json'] }, + activity : { schema: 'https://enbox.id/schemas/forge/activity', dataFormats: ['application/json'] }, }, structure: { star: { @@ -775,10 +775,10 @@ export const ForgeSocialDefinition = { ```typescript export const ForgeNotificationsDefinition = { - protocol : 'https://enbox.org/protocols/forge/notifications', + protocol : 'https://enbox.id/protocols/forge/notifications', published : false, // Private — notifications are personal types : { - notification: { schema: 'https://enbox.org/schemas/forge/notification', dataFormats: ['application/json'] }, + notification: { schema: 'https://enbox.id/schemas/forge/notification', dataFormats: ['application/json'] }, }, structure: { notification: { @@ -807,12 +807,12 @@ export const ForgeNotificationsDefinition = { ```typescript export const ForgeWikiDefinition = { - protocol : 'https://enbox.org/protocols/forge/wiki', + protocol : 'https://enbox.id/protocols/forge/wiki', published : true, - uses : { repo: 'https://enbox.org/protocols/forge/repo' }, + uses : { repo: 'https://enbox.id/protocols/forge/repo' }, types : { - page : { schema: 'https://enbox.org/schemas/forge/wiki-page', dataFormats: ['text/markdown'] }, - pageHistory : { schema: 'https://enbox.org/schemas/forge/wiki-history', dataFormats: ['application/json'] }, + page : { schema: 'https://enbox.id/schemas/forge/wiki-page', dataFormats: ['text/markdown'] }, + pageHistory : { schema: 'https://enbox.id/schemas/forge/wiki-history', dataFormats: ['application/json'] }, }, structure: { repo: { @@ -851,14 +851,14 @@ Wiki pages are mutable (updated in place), but each edit creates an `$immutable` ```typescript export const ForgeOrgDefinition = { - protocol : 'https://enbox.org/protocols/forge/org', + protocol : 'https://enbox.id/protocols/forge/org', published : true, types : { - org : { schema: 'https://enbox.org/schemas/forge/org', dataFormats: ['application/json'] }, - owner : { schema: 'https://enbox.org/schemas/forge/org-member', dataFormats: ['application/json'] }, - member : { schema: 'https://enbox.org/schemas/forge/org-member', dataFormats: ['application/json'] }, - team : { schema: 'https://enbox.org/schemas/forge/team', dataFormats: ['application/json'] }, - teamMember : { schema: 'https://enbox.org/schemas/forge/team-member', dataFormats: ['application/json'] }, + org : { schema: 'https://enbox.id/schemas/forge/org', dataFormats: ['application/json'] }, + owner : { schema: 'https://enbox.id/schemas/forge/org-member', dataFormats: ['application/json'] }, + member : { schema: 'https://enbox.id/schemas/forge/org-member', dataFormats: ['application/json'] }, + team : { schema: 'https://enbox.id/schemas/forge/team', dataFormats: ['application/json'] }, + teamMember : { schema: 'https://enbox.id/schemas/forge/team-member', dataFormats: ['application/json'] }, }, structure: { org: { @@ -946,7 +946,7 @@ Query the DWN for the repo record: ``` RecordsQuery { - protocol : 'https://enbox.org/protocols/forge/repo', + protocol : 'https://enbox.id/protocols/forge/repo', protocolPath : 'repo', filter : { tags: { name: '<repo-name>' } } } @@ -982,7 +982,7 @@ While git objects stay in native git storage, **branch ref pointers** can be mir ```typescript // Track branch heads for subscription-based push notifications types: { - ref: { schema: 'https://enbox.org/schemas/forge/git-ref', dataFormats: ['application/json'] } + ref: { schema: 'https://enbox.id/schemas/forge/git-ref', dataFormats: ['application/json'] } } // Data: { name: "refs/heads/main", target: "abc123def...", type: "branch" } ``` @@ -1119,10 +1119,10 @@ The DID is embedded in the npm scope: `@did:dht:abc123` → DID `did:dht:abc123` ```bash gitd shim go --port 4874 -GOPROXY=http://localhost:4874 go get did.enbox.org/did:dht:abc123/my-mod@v1.0.0 +GOPROXY=http://localhost:4874 go get did.enbox.id/did:dht:abc123/my-mod@v1.0.0 ``` -Module paths use `did.enbox.org/` as a virtual domain prefix. The shim generates `go.mod` files with DID-scoped dependencies mapped to `did.enbox.org/` paths: +Module paths use `did.enbox.id/` as a virtual domain prefix. The shim generates `go.mod` files with DID-scoped dependencies mapped to `did.enbox.id/` paths: - `GET /{module}/@v/list` → version listing - `GET /{module}/@v/{ver}.info` → version JSON (Version + Time) - `GET /{module}/@v/{ver}.mod` → generated go.mod @@ -1234,7 +1234,7 @@ await agent.dwn.processRequest({ target : ownerDid, messageType : DwnInterface.RecordsWrite, messageParams: { - protocol : 'https://enbox.org/protocols/forge/repo', + protocol : 'https://enbox.id/protocols/forge/repo', protocolPath : 'repo/maintainer', parentContextId : repoContextId, recipient : aliceDid, @@ -1264,7 +1264,7 @@ await agent.dwn.processRequest({ target : bobDid, // Writing to Bob's DWN messageType : DwnInterface.RecordsWrite, messageParams: { - protocol : 'https://enbox.org/protocols/forge/patches', + protocol : 'https://enbox.id/protocols/forge/patches', protocolPath : 'repo/patch', protocolRole : 'repo:repo/contributor', parentContextId : bobsRepoContextId, // Links to repo via $ref diff --git a/README.md b/README.md index a79daf5..25ce74f 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Local proxies that let existing tools talk to DWN without modification. Run them |---|---| | **GitHub API** | `gh repo view did:dht:abc/my-repo` | | **npm** | `npm install --registry=http://localhost:4873 @did:dht:abc/my-pkg` | -| **Go** | `GOPROXY=http://localhost:4874 go get did.enbox.org/did:dht:abc/my-mod` | +| **Go** | `GOPROXY=http://localhost:4874 go get did.enbox.id/did:dht:abc/my-mod` | | **OCI** | `docker pull localhost:5555/did:dht:abc/my-image:v1.0.0` | ## Web UI diff --git a/schemas/ci/check-run.json b/schemas/ci/check-run.json index 0ef6411..d412dfc 100644 --- a/schemas/ci/check-run.json +++ b/schemas/ci/check-run.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/check-run", + "$id": "https://enbox.id/schemas/forge/check-run", "title": "CheckRunData", "description": "An individual check run within a check suite", "type": "object", diff --git a/schemas/ci/check-suite.json b/schemas/ci/check-suite.json index 31c0423..621bfde 100644 --- a/schemas/ci/check-suite.json +++ b/schemas/ci/check-suite.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/check-suite", + "$id": "https://enbox.id/schemas/forge/check-suite", "title": "CheckSuiteData", "description": "A CI check suite associated with a branch", "type": "object", diff --git a/schemas/issues/assignment.json b/schemas/issues/assignment.json index 87c4f51..2f283b5 100644 --- a/schemas/issues/assignment.json +++ b/schemas/issues/assignment.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/assignment", + "$id": "https://enbox.id/schemas/forge/assignment", "title": "AssignmentData", "description": "An assignment of a user to an issue", "type": "object", diff --git a/schemas/issues/comment.json b/schemas/issues/comment.json index 3fe0639..ea3ba38 100644 --- a/schemas/issues/comment.json +++ b/schemas/issues/comment.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/comment", + "$id": "https://enbox.id/schemas/forge/comment", "title": "CommentData", "description": "A comment on an issue", "type": "object", diff --git a/schemas/issues/issue-dependency.json b/schemas/issues/issue-dependency.json index d76d5fc..713fb76 100644 --- a/schemas/issues/issue-dependency.json +++ b/schemas/issues/issue-dependency.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/issue-dependency", + "$id": "https://enbox.id/schemas/forge/issue-dependency", "title": "IssueDependencyData", "description": "A dependency edge from an issue to a blocking issue", "type": "object", diff --git a/schemas/issues/issue-field-value.json b/schemas/issues/issue-field-value.json index 0488bc6..144060f 100644 --- a/schemas/issues/issue-field-value.json +++ b/schemas/issues/issue-field-value.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/issue-field-value", + "$id": "https://enbox.id/schemas/forge/issue-field-value", "title": "IssueFieldValueData", "description": "A custom field value attached to an issue", "type": "object", diff --git a/schemas/issues/issue-sub-issue.json b/schemas/issues/issue-sub-issue.json index ac9d0ac..5f033a4 100644 --- a/schemas/issues/issue-sub-issue.json +++ b/schemas/issues/issue-sub-issue.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/issue-sub-issue", + "$id": "https://enbox.id/schemas/forge/issue-sub-issue", "title": "IssueSubIssueData", "description": "An ordered sub-issue edge from a parent issue to a child issue", "type": "object", diff --git a/schemas/issues/issue.json b/schemas/issues/issue.json index c335680..5525307 100644 --- a/schemas/issues/issue.json +++ b/schemas/issues/issue.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/issue", + "$id": "https://enbox.id/schemas/forge/issue", "title": "IssueData", "description": "An issue in a forge repository", "type": "object", diff --git a/schemas/issues/label.json b/schemas/issues/label.json index 7e44470..af2edb5 100644 --- a/schemas/issues/label.json +++ b/schemas/issues/label.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/label", + "$id": "https://enbox.id/schemas/forge/label", "title": "LabelData", "description": "A label applied to an issue", "type": "object", diff --git a/schemas/issues/reaction.json b/schemas/issues/reaction.json index 591fd7e..3c6eb21 100644 --- a/schemas/issues/reaction.json +++ b/schemas/issues/reaction.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/reaction", + "$id": "https://enbox.id/schemas/forge/reaction", "title": "ReactionData", "description": "An emoji reaction on an issue or comment", "type": "object", diff --git a/schemas/issues/status-change.json b/schemas/issues/status-change.json index 384afb8..62290a2 100644 --- a/schemas/issues/status-change.json +++ b/schemas/issues/status-change.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/status-change", + "$id": "https://enbox.id/schemas/forge/status-change", "title": "StatusChangeData", "description": "A status change on an issue", "type": "object", diff --git a/schemas/notifications/notification.json b/schemas/notifications/notification.json index 644c03e..1895dd1 100644 --- a/schemas/notifications/notification.json +++ b/schemas/notifications/notification.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/notification", + "$id": "https://enbox.id/schemas/forge/notification", "title": "NotificationData", "description": "A notification delivered to a user", "type": "object", diff --git a/schemas/org/org-blocked-user.json b/schemas/org/org-blocked-user.json index b2c9780..6322adc 100644 --- a/schemas/org/org-blocked-user.json +++ b/schemas/org/org-blocked-user.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-blocked-user", + "$id": "https://enbox.id/schemas/forge/org-blocked-user", "title": "OrgBlockedUserData", "description": "A user blocked from an organization", "type": "object", diff --git a/schemas/org/org-custom-property.json b/schemas/org/org-custom-property.json index bd32607..ae28cad 100644 --- a/schemas/org/org-custom-property.json +++ b/schemas/org/org-custom-property.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-custom-property", + "$id": "https://enbox.id/schemas/forge/org-custom-property", "title": "OrgCustomPropertyData", "description": "An organization-level repository custom property definition", "type": "object", diff --git a/schemas/org/org-issue-field.json b/schemas/org/org-issue-field.json index 66f2cab..32f8d65 100644 --- a/schemas/org/org-issue-field.json +++ b/schemas/org/org-issue-field.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-issue-field", + "$id": "https://enbox.id/schemas/forge/org-issue-field", "title": "OrgIssueFieldData", "description": "An organization-level custom issue field", "type": "object", diff --git a/schemas/org/org-issue-type.json b/schemas/org/org-issue-type.json index 0606acf..f74e1f9 100644 --- a/schemas/org/org-issue-type.json +++ b/schemas/org/org-issue-type.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-issue-type", + "$id": "https://enbox.id/schemas/forge/org-issue-type", "title": "OrgIssueTypeData", "description": "An organization-level issue type definition", "type": "object", diff --git a/schemas/org/org-member.json b/schemas/org/org-member.json index f975ef4..e6d448f 100644 --- a/schemas/org/org-member.json +++ b/schemas/org/org-member.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-member", + "$id": "https://enbox.id/schemas/forge/org-member", "title": "OrgMemberData", "description": "A member of an organization", "type": "object", diff --git a/schemas/org/org-webhook.json b/schemas/org/org-webhook.json index 137af6b..e4fe604 100644 --- a/schemas/org/org-webhook.json +++ b/schemas/org/org-webhook.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org-webhook", + "$id": "https://enbox.id/schemas/forge/org-webhook", "title": "OrgWebhookData", "description": "An organization webhook configuration specifying a callback URL, shared secret, subscribed events, and active status.", "type": "object", diff --git a/schemas/org/org.json b/schemas/org/org.json index d675144..67b8422 100644 --- a/schemas/org/org.json +++ b/schemas/org/org.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/org", + "$id": "https://enbox.id/schemas/forge/org", "title": "OrgData", "description": "An organization that owns repositories and has members", "type": "object", diff --git a/schemas/org/team-member.json b/schemas/org/team-member.json index 6efbc16..bf1b0eb 100644 --- a/schemas/org/team-member.json +++ b/schemas/org/team-member.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/team-member", + "$id": "https://enbox.id/schemas/forge/team-member", "title": "TeamMemberData", "description": "A member of a team within an organization", "type": "object", diff --git a/schemas/org/team.json b/schemas/org/team.json index 3c429ca..42d3dde 100644 --- a/schemas/org/team.json +++ b/schemas/org/team.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/team", + "$id": "https://enbox.id/schemas/forge/team", "title": "TeamData", "description": "A team within an organization", "type": "object", diff --git a/schemas/patches/merge-result.json b/schemas/patches/merge-result.json index 15b3360..63bceb4 100644 --- a/schemas/patches/merge-result.json +++ b/schemas/patches/merge-result.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/merge-result", + "$id": "https://enbox.id/schemas/forge/merge-result", "title": "MergeResultData", "description": "The result of merging a patch, recording who performed the merge.", "type": "object", diff --git a/schemas/patches/patch-status-change.json b/schemas/patches/patch-status-change.json index 09ecab3..74ade79 100644 --- a/schemas/patches/patch-status-change.json +++ b/schemas/patches/patch-status-change.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/patch-status-change", + "$id": "https://enbox.id/schemas/forge/patch-status-change", "title": "PatchStatusChangeData", "description": "A patch status change event with an optional reason.", "type": "object", diff --git a/schemas/patches/patch.json b/schemas/patches/patch.json index d32047d..da06a97 100644 --- a/schemas/patches/patch.json +++ b/schemas/patches/patch.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/patch", + "$id": "https://enbox.id/schemas/forge/patch", "title": "PatchData", "description": "A forge patch proposal containing a title, body, and optional sequence number.", "type": "object", diff --git a/schemas/patches/review-comment.json b/schemas/patches/review-comment.json index 7b3988f..cef7c39 100644 --- a/schemas/patches/review-comment.json +++ b/schemas/patches/review-comment.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/review-comment", + "$id": "https://enbox.id/schemas/forge/review-comment", "title": "ReviewCommentData", "description": "A comment on a patch review with a required body and optional diff hunk context.", "type": "object", diff --git a/schemas/patches/review.json b/schemas/patches/review.json index 2237a55..38e8d39 100644 --- a/schemas/patches/review.json +++ b/schemas/patches/review.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/review", + "$id": "https://enbox.id/schemas/forge/review", "title": "ReviewData", "description": "A patch review with an optional body.", "type": "object", diff --git a/schemas/patches/revision.json b/schemas/patches/revision.json index e37f95a..d38efb9 100644 --- a/schemas/patches/revision.json +++ b/schemas/patches/revision.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/revision", + "$id": "https://enbox.id/schemas/forge/revision", "title": "RevisionData", "description": "A patch revision containing an optional description and diff statistics.", "type": "object", diff --git a/schemas/refs/branch-state.json b/schemas/refs/branch-state.json index 78639d5..2120c43 100644 --- a/schemas/refs/branch-state.json +++ b/schemas/refs/branch-state.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/branch-state", + "$id": "https://enbox.id/schemas/forge/branch-state", "title": "BranchStateData", "description": "A branch ref update or authoritative checkpoint for a branch-owned git state log.", "type": "object", diff --git a/schemas/refs/branch.json b/schemas/refs/branch.json index aa15628..081cbec 100644 --- a/schemas/refs/branch.json +++ b/schemas/refs/branch.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/branch", + "$id": "https://enbox.id/schemas/forge/branch", "title": "BranchData", "description": "Stable metadata for a branch-owned git state log.", "type": "object", diff --git a/schemas/refs/git-ref.json b/schemas/refs/git-ref.json index 55daab7..7ea30c1 100644 --- a/schemas/refs/git-ref.json +++ b/schemas/refs/git-ref.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/git-ref", + "$id": "https://enbox.id/schemas/forge/git-ref", "title": "GitRefData", "description": "A git ref pointer (branch or tag) mirrored as a DWN record for subscription-based notifications.", "type": "object", diff --git a/schemas/registry/attestation.json b/schemas/registry/attestation.json index 87d1cdf..10f70aa 100644 --- a/schemas/registry/attestation.json +++ b/schemas/registry/attestation.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/attestation", + "$id": "https://enbox.id/schemas/forge/attestation", "title": "AttestationData", "description": "A signed attestation claim about a package or artifact", "type": "object", diff --git a/schemas/registry/package-version.json b/schemas/registry/package-version.json index fe83eba..1b2c7a3 100644 --- a/schemas/registry/package-version.json +++ b/schemas/registry/package-version.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/package-version", + "$id": "https://enbox.id/schemas/forge/package-version", "title": "PackageVersionData", "description": "A specific version of a registered package", "type": "object", diff --git a/schemas/registry/package.json b/schemas/registry/package.json index c2fdebd..4636742 100644 --- a/schemas/registry/package.json +++ b/schemas/registry/package.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/package", + "$id": "https://enbox.id/schemas/forge/package", "title": "PackageData", "description": "A package registered in the decentralized registry", "type": "object", diff --git a/schemas/releases/release.json b/schemas/releases/release.json index 0c6daeb..849a144 100644 --- a/schemas/releases/release.json +++ b/schemas/releases/release.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/release", + "$id": "https://enbox.id/schemas/forge/release", "title": "ReleaseData", "description": "A versioned release of a repository", "type": "object", diff --git a/schemas/repo/collaborator.json b/schemas/repo/collaborator.json index db633ae..c8e9f9d 100644 --- a/schemas/repo/collaborator.json +++ b/schemas/repo/collaborator.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/collaborator", + "$id": "https://enbox.id/schemas/forge/collaborator", "title": "CollaboratorData", "description": "A collaborator entry identifying a DID and optional alias, shared by maintainer, triager, contributor, and viewer roles.", "type": "object", diff --git a/schemas/repo/moderation-event.json b/schemas/repo/moderation-event.json index 7844551..a3337e1 100644 --- a/schemas/repo/moderation-event.json +++ b/schemas/repo/moderation-event.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/moderation-event", + "$id": "https://enbox.id/schemas/forge/moderation-event", "title": "ModerationEventData", "description": "Immutable repo-level moderation audit event.", "type": "object", diff --git a/schemas/repo/repo.json b/schemas/repo/repo.json index 6ef4da5..19ca297 100644 --- a/schemas/repo/repo.json +++ b/schemas/repo/repo.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/repo", + "$id": "https://enbox.id/schemas/forge/repo", "title": "RepoData", "description": "Repository metadata including name, description, default branch, and endpoints.", "type": "object", diff --git a/schemas/repo/settings.json b/schemas/repo/settings.json index 130eda8..f00d809 100644 --- a/schemas/repo/settings.json +++ b/schemas/repo/settings.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/settings", + "$id": "https://enbox.id/schemas/forge/settings", "title": "SettingsData", "description": "Repository settings including branch protection rules, merge strategies, and auto-delete behavior.", "type": "object", diff --git a/schemas/repo/submission-decision.json b/schemas/repo/submission-decision.json index 53d26a5..aec775f 100644 --- a/schemas/repo/submission-decision.json +++ b/schemas/repo/submission-decision.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/submission-decision", + "$id": "https://enbox.id/schemas/forge/submission-decision", "title": "SubmissionDecisionData", "description": "A repo-owner decision for an external issue or patch submission.", "type": "object", diff --git a/schemas/repo/topic.json b/schemas/repo/topic.json index 9bde325..bfd4b50 100644 --- a/schemas/repo/topic.json +++ b/schemas/repo/topic.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/topic", + "$id": "https://enbox.id/schemas/forge/topic", "title": "TopicData", "description": "A topic label associated with a repository.", "type": "object", diff --git a/schemas/repo/webhook.json b/schemas/repo/webhook.json index f6198a1..7304fbc 100644 --- a/schemas/repo/webhook.json +++ b/schemas/repo/webhook.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/webhook", + "$id": "https://enbox.id/schemas/forge/webhook", "title": "WebhookData", "description": "A webhook configuration specifying a callback URL, shared secret, subscribed events, and active status.", "type": "object", diff --git a/schemas/social/activity.json b/schemas/social/activity.json index 1282143..de8a5c3 100644 --- a/schemas/social/activity.json +++ b/schemas/social/activity.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/activity", + "$id": "https://enbox.id/schemas/forge/activity", "title": "ActivityData", "description": "A social activity event in the forge", "type": "object", diff --git a/schemas/social/block.json b/schemas/social/block.json index b1133e7..ef4d424 100644 --- a/schemas/social/block.json +++ b/schemas/social/block.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/block", + "$id": "https://enbox.id/schemas/forge/block", "title": "BlockData", "description": "A user blocked by the local actor", "type": "object", diff --git a/schemas/social/email.json b/schemas/social/email.json index e13566b..95ba50e 100644 --- a/schemas/social/email.json +++ b/schemas/social/email.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/email", + "$id": "https://enbox.id/schemas/forge/email", "title": "EmailData", "type": "object", "properties": { diff --git a/schemas/social/follow.json b/schemas/social/follow.json index 110ecaf..89f2fe8 100644 --- a/schemas/social/follow.json +++ b/schemas/social/follow.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/follow", + "$id": "https://enbox.id/schemas/forge/follow", "title": "FollowData", "description": "A follow relationship with another user", "type": "object", diff --git a/schemas/social/gist-comment.json b/schemas/social/gist-comment.json index ad13c6d..2589193 100644 --- a/schemas/social/gist-comment.json +++ b/schemas/social/gist-comment.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/gist-comment", + "$id": "https://enbox.id/schemas/forge/gist-comment", "title": "GistCommentData", "description": "A comment on a GitHub-compatible gist", "type": "object", diff --git a/schemas/social/gist-star.json b/schemas/social/gist-star.json index e693e99..cbc531d 100644 --- a/schemas/social/gist-star.json +++ b/schemas/social/gist-star.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/gist-star", + "$id": "https://enbox.id/schemas/forge/gist-star", "title": "GistStarData", "description": "A gist starred by a DID", "type": "object", diff --git a/schemas/social/gist.json b/schemas/social/gist.json index 844e9be..f069ce7 100644 --- a/schemas/social/gist.json +++ b/schemas/social/gist.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/gist", + "$id": "https://enbox.id/schemas/forge/gist", "title": "GistData", "description": "A GitHub-compatible gist owned by a DID", "type": "object", diff --git a/schemas/social/gpg-key.json b/schemas/social/gpg-key.json index 5deb5cf..5850f7c 100644 --- a/schemas/social/gpg-key.json +++ b/schemas/social/gpg-key.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/gpg-key", + "$id": "https://enbox.id/schemas/forge/gpg-key", "title": "GpgKeyData", "type": "object", "properties": { diff --git a/schemas/social/profile.json b/schemas/social/profile.json index 64b30d1..f6f6fb6 100644 --- a/schemas/social/profile.json +++ b/schemas/social/profile.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/profile", + "$id": "https://enbox.id/schemas/forge/profile", "title": "ProfileData", "type": "object", "properties": { diff --git a/schemas/social/social-account.json b/schemas/social/social-account.json index e031dd8..d4f3f67 100644 --- a/schemas/social/social-account.json +++ b/schemas/social/social-account.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/social-account", + "$id": "https://enbox.id/schemas/forge/social-account", "title": "SocialAccountData", "type": "object", "properties": { diff --git a/schemas/social/ssh-key.json b/schemas/social/ssh-key.json index bd733d8..f158f8a 100644 --- a/schemas/social/ssh-key.json +++ b/schemas/social/ssh-key.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/ssh-key", + "$id": "https://enbox.id/schemas/forge/ssh-key", "title": "SshKeyData", "type": "object", "properties": { diff --git a/schemas/social/ssh-signing-key.json b/schemas/social/ssh-signing-key.json index 287eff0..79eac0c 100644 --- a/schemas/social/ssh-signing-key.json +++ b/schemas/social/ssh-signing-key.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/ssh-signing-key", + "$id": "https://enbox.id/schemas/forge/ssh-signing-key", "title": "SshSigningKeyData", "type": "object", "properties": { diff --git a/schemas/social/star.json b/schemas/social/star.json index 3a2aff3..c8a5eab 100644 --- a/schemas/social/star.json +++ b/schemas/social/star.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/star", + "$id": "https://enbox.id/schemas/forge/star", "title": "StarData", "description": "A star marking a repository as a favorite", "type": "object", diff --git a/schemas/wiki/wiki-history.json b/schemas/wiki/wiki-history.json index c408b03..64f82e5 100644 --- a/schemas/wiki/wiki-history.json +++ b/schemas/wiki/wiki-history.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/wiki-history", + "$id": "https://enbox.id/schemas/forge/wiki-history", "title": "PageHistoryData", "description": "A historical edit record for a wiki page", "type": "object", diff --git a/schemas/wiki/wiki-page.json b/schemas/wiki/wiki-page.json index 0543fb5..5f3a96f 100644 --- a/schemas/wiki/wiki-page.json +++ b/schemas/wiki/wiki-page.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://enbox.org/schemas/forge/wiki-page", + "$id": "https://enbox.id/schemas/forge/wiki-page", "title": "PageData", "description": "Metadata for a wiki page whose content is stored as text/markdown", "type": "object", diff --git a/src/ci.ts b/src/ci.ts index cadfdf4..adb974f 100644 --- a/src/ci.ts +++ b/src/ci.ts @@ -57,18 +57,18 @@ export type ForgeCiSchemaMap = { // --------------------------------------------------------------------------- export const ForgeCiDefinition = { - protocol : 'https://enbox.org/protocols/forge/ci', + protocol : 'https://enbox.id/protocols/forge/ci', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { checkSuite: { - schema : 'https://enbox.org/schemas/forge/check-suite', + schema : 'https://enbox.id/schemas/forge/check-suite', dataFormats : ['application/json'], }, checkRun: { - schema : 'https://enbox.org/schemas/forge/check-run', + schema : 'https://enbox.id/schemas/forge/check-run', dataFormats : ['application/json'], }, artifact: { diff --git a/src/daemon/adapters/go.ts b/src/daemon/adapters/go.ts index 397f590..418c3e7 100644 --- a/src/daemon/adapters/go.ts +++ b/src/daemon/adapters/go.ts @@ -24,7 +24,7 @@ export const goAdapter: ShimAdapter = { portEnvVar : 'GITD_GO_SHIM_PORT', corsMethods : 'GET, OPTIONS', corsHeaders : 'Authorization, Accept', - usageHint : 'GOPROXY=http://localhost:{port} go get did.enbox.org/did:dht:<id>/<module>@v1.0.0', + usageHint : 'GOPROXY=http://localhost:{port} go get did.enbox.id/did:dht:<id>/<module>@v1.0.0', async handle(ctx: AgentContext, req: IncomingMessage, res: ServerResponse): Promise<void> { if (req.method !== 'GET') { diff --git a/src/issues.ts b/src/issues.ts index a9d8a34..f383ea0 100644 --- a/src/issues.ts +++ b/src/issues.ts @@ -96,46 +96,46 @@ export type ForgeIssuesSchemaMap = { // --------------------------------------------------------------------------- export const ForgeIssuesDefinition = { - protocol : 'https://enbox.org/protocols/forge/issues', + protocol : 'https://enbox.id/protocols/forge/issues', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { issue: { - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', dataFormats : ['application/json'], }, comment: { - schema : 'https://enbox.org/schemas/forge/comment', + schema : 'https://enbox.id/schemas/forge/comment', dataFormats : ['application/json'], }, reaction: { - schema : 'https://enbox.org/schemas/forge/reaction', + schema : 'https://enbox.id/schemas/forge/reaction', dataFormats : ['application/json'], }, label: { - schema : 'https://enbox.org/schemas/forge/label', + schema : 'https://enbox.id/schemas/forge/label', dataFormats : ['application/json'], }, statusChange: { - schema : 'https://enbox.org/schemas/forge/status-change', + schema : 'https://enbox.id/schemas/forge/status-change', dataFormats : ['application/json'], }, assignment: { - schema : 'https://enbox.org/schemas/forge/assignment', + schema : 'https://enbox.id/schemas/forge/assignment', dataFormats : ['application/json'], }, issueDependency: { - schema : 'https://enbox.org/schemas/forge/issue-dependency', + schema : 'https://enbox.id/schemas/forge/issue-dependency', dataFormats : ['application/json'], }, subIssue: { - schema : 'https://enbox.org/schemas/forge/issue-sub-issue', + schema : 'https://enbox.id/schemas/forge/issue-sub-issue', dataFormats : ['application/json'], }, issueFieldValue: { - schema : 'https://enbox.org/schemas/forge/issue-field-value', + schema : 'https://enbox.id/schemas/forge/issue-field-value', dataFormats : ['application/json'], }, }, diff --git a/src/notifications.ts b/src/notifications.ts index 052561a..af8cc94 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -41,11 +41,11 @@ export type ForgeNotificationsSchemaMap = { // --------------------------------------------------------------------------- export const ForgeNotificationsDefinition = { - protocol : 'https://enbox.org/protocols/forge/notifications', + protocol : 'https://enbox.id/protocols/forge/notifications', published : false, types : { notification: { - schema : 'https://enbox.org/schemas/forge/notification', + schema : 'https://enbox.id/schemas/forge/notification', dataFormats : ['application/json'], }, }, diff --git a/src/org.ts b/src/org.ts index e0c843b..4db66c0 100644 --- a/src/org.ts +++ b/src/org.ts @@ -148,48 +148,48 @@ export type ForgeOrgSchemaMap = { // --------------------------------------------------------------------------- export const ForgeOrgDefinition = { - protocol : 'https://enbox.org/protocols/forge/org', + protocol : 'https://enbox.id/protocols/forge/org', published : true, types : { org: { - schema : 'https://enbox.org/schemas/forge/org', + schema : 'https://enbox.id/schemas/forge/org', dataFormats : ['application/json'], }, owner: { - schema : 'https://enbox.org/schemas/forge/org-member', + schema : 'https://enbox.id/schemas/forge/org-member', dataFormats : ['application/json'], }, member: { - schema : 'https://enbox.org/schemas/forge/org-member', + schema : 'https://enbox.id/schemas/forge/org-member', dataFormats : ['application/json'], }, blockedUser: { - schema : 'https://enbox.org/schemas/forge/org-blocked-user', + schema : 'https://enbox.id/schemas/forge/org-blocked-user', dataFormats : ['application/json'], }, team: { - schema : 'https://enbox.org/schemas/forge/team', + schema : 'https://enbox.id/schemas/forge/team', dataFormats : ['application/json'], }, teamMember: { - schema : 'https://enbox.org/schemas/forge/team-member', + schema : 'https://enbox.id/schemas/forge/team-member', dataFormats : ['application/json'], }, webhook: { - schema : 'https://enbox.org/schemas/forge/org-webhook', + schema : 'https://enbox.id/schemas/forge/org-webhook', dataFormats : ['application/json'], encryptionRequired : true, }, issueField: { - schema : 'https://enbox.org/schemas/forge/org-issue-field', + schema : 'https://enbox.id/schemas/forge/org-issue-field', dataFormats : ['application/json'], }, issueType: { - schema : 'https://enbox.org/schemas/forge/org-issue-type', + schema : 'https://enbox.id/schemas/forge/org-issue-type', dataFormats : ['application/json'], }, customProperty: { - schema : 'https://enbox.org/schemas/forge/org-custom-property', + schema : 'https://enbox.id/schemas/forge/org-custom-property', dataFormats : ['application/json'], }, }, diff --git a/src/patches.ts b/src/patches.ts index 4098548..4d2488f 100644 --- a/src/patches.ts +++ b/src/patches.ts @@ -81,37 +81,37 @@ export type ForgePatchesSchemaMap = { // --------------------------------------------------------------------------- export const ForgePatchesDefinition = { - protocol : 'https://enbox.org/protocols/forge/patches', + protocol : 'https://enbox.id/protocols/forge/patches', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { patch: { - schema : 'https://enbox.org/schemas/forge/patch', + schema : 'https://enbox.id/schemas/forge/patch', dataFormats : ['application/json'], }, revision: { - schema : 'https://enbox.org/schemas/forge/revision', + schema : 'https://enbox.id/schemas/forge/revision', dataFormats : ['application/json'], }, revisionBundle: { dataFormats: ['application/x-git-bundle'], }, review: { - schema : 'https://enbox.org/schemas/forge/review', + schema : 'https://enbox.id/schemas/forge/review', dataFormats : ['application/json'], }, reviewComment: { - schema : 'https://enbox.org/schemas/forge/review-comment', + schema : 'https://enbox.id/schemas/forge/review-comment', dataFormats : ['application/json'], }, statusChange: { - schema : 'https://enbox.org/schemas/forge/patch-status-change', + schema : 'https://enbox.id/schemas/forge/patch-status-change', dataFormats : ['application/json'], }, mergeResult: { - schema : 'https://enbox.org/schemas/forge/merge-result', + schema : 'https://enbox.id/schemas/forge/merge-result', dataFormats : ['application/json'], }, }, diff --git a/src/refs.ts b/src/refs.ts index 570de70..4366dde 100644 --- a/src/refs.ts +++ b/src/refs.ts @@ -103,22 +103,22 @@ export type ForgeRefsSchemaMap = { // --------------------------------------------------------------------------- export const ForgeRefsDefinition = { - protocol : 'https://enbox.org/protocols/forge/refs', + protocol : 'https://enbox.id/protocols/forge/refs', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { ref: { - schema : 'https://enbox.org/schemas/forge/git-ref', + schema : 'https://enbox.id/schemas/forge/git-ref', dataFormats : ['application/json'], }, branch: { - schema : 'https://enbox.org/schemas/forge/branch', + schema : 'https://enbox.id/schemas/forge/branch', dataFormats : ['application/json'], }, state: { - schema : 'https://enbox.org/schemas/forge/branch-state', + schema : 'https://enbox.id/schemas/forge/branch-state', dataFormats : ['application/json'], }, bundle: { diff --git a/src/registry.ts b/src/registry.ts index 29394eb..45c932e 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -56,22 +56,22 @@ export type ForgeRegistrySchemaMap = { // --------------------------------------------------------------------------- export const ForgeRegistryDefinition = { - protocol : 'https://enbox.org/protocols/forge/registry', + protocol : 'https://enbox.id/protocols/forge/registry', published : true, types : { package: { - schema : 'https://enbox.org/schemas/forge/package', + schema : 'https://enbox.id/schemas/forge/package', dataFormats : ['application/json'], }, version: { - schema : 'https://enbox.org/schemas/forge/package-version', + schema : 'https://enbox.id/schemas/forge/package-version', dataFormats : ['application/json'], }, tarball: { dataFormats: ['application/gzip', 'application/octet-stream'], }, attestation: { - schema : 'https://enbox.org/schemas/forge/attestation', + schema : 'https://enbox.id/schemas/forge/attestation', dataFormats : ['application/json'], }, }, diff --git a/src/releases.ts b/src/releases.ts index f67d812..18e933f 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -59,14 +59,14 @@ export type ForgeReleasesSchemaMap = { // --------------------------------------------------------------------------- export const ForgeReleasesDefinition = { - protocol : 'https://enbox.org/protocols/forge/releases', + protocol : 'https://enbox.id/protocols/forge/releases', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { release: { - schema : 'https://enbox.org/schemas/forge/release', + schema : 'https://enbox.id/schemas/forge/release', dataFormats : ['application/json'], }, asset: { diff --git a/src/repo.ts b/src/repo.ts index 80dcd42..d9fa4ad 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -592,15 +592,15 @@ export type ForgeRepoSchemaMap = { // --------------------------------------------------------------------------- export const ForgeRepoDefinition = { - protocol : 'https://enbox.org/protocols/forge/repo', + protocol : 'https://enbox.id/protocols/forge/repo', published : true, types : { repo: { - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', dataFormats : ['application/json'], }, settings: { - schema : 'https://enbox.org/schemas/forge/settings', + schema : 'https://enbox.id/schemas/forge/settings', dataFormats : ['application/json'], }, readme: { @@ -610,42 +610,42 @@ export const ForgeRepoDefinition = { dataFormats: ['text/plain'], }, maintainer: { - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', dataFormats : ['application/json'], }, moderator: { - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', dataFormats : ['application/json'], }, triager: { - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', dataFormats : ['application/json'], }, contributor: { - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', dataFormats : ['application/json'], }, viewer: { - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', dataFormats : ['application/json'], }, topic: { - schema : 'https://enbox.org/schemas/forge/topic', + schema : 'https://enbox.id/schemas/forge/topic', dataFormats : ['application/json'], }, submissionDecision: { - schema : 'https://enbox.org/schemas/forge/submission-decision', + schema : 'https://enbox.id/schemas/forge/submission-decision', dataFormats : ['application/json'], }, moderationEvent: { - schema : 'https://enbox.org/schemas/forge/moderation-event', + schema : 'https://enbox.id/schemas/forge/moderation-event', dataFormats : ['application/json'], }, bundle: { dataFormats: ['application/x-git-bundle'], }, webhook: { - schema : 'https://enbox.org/schemas/forge/webhook', + schema : 'https://enbox.id/schemas/forge/webhook', dataFormats : ['application/json'], encryptionRequired : true, }, diff --git a/src/shims/go/proxy.ts b/src/shims/go/proxy.ts index 92cef91..41729e4 100644 --- a/src/shims/go/proxy.ts +++ b/src/shims/go/proxy.ts @@ -4,9 +4,9 @@ * * Implements the GOPROXY protocol (https://go.dev/ref/mod#goproxy-protocol) * for DID-scoped Go modules. Module paths use the format: - * `did.enbox.org/did:<method>:<id>/<module>` + * `did.enbox.id/did:<method>:<id>/<module>` * - * The `did.enbox.org` prefix is a virtual domain that the shim + * The `did.enbox.id` prefix is a virtual domain that the shim * intercepts. The DID and module name are extracted from the path. * * Endpoints: @@ -75,9 +75,9 @@ function gone(message: string): GoProxyResponse { * Go module paths are URL-encoded in the GOPROXY protocol — uppercase * letters become `!` + lowercase (Go module proxy encoding). * - * Format: `did.enbox.org/did:<method>:<id>/<module>` + * Format: `did.enbox.id/did:<method>:<id>/<module>` * - * The `did.enbox.org/` prefix is stripped by the time it reaches us + * The `did.enbox.id/` prefix is stripped by the time it reaches us * (it's part of the GOPROXY URL, not the request path). The path * starts with `did:<method>:<id>/<module>`. */ @@ -120,15 +120,15 @@ function stripV(version: string): string { * Generate a minimal `go.mod` file for a DID-scoped module. */ function generateGoMod(did: string, name: string, deps: Record<string, string>): string { - const modulePath = `did.enbox.org/${did}/${name}`; + const modulePath = `did.enbox.id/${did}/${name}`; const lines = [`module ${modulePath}`, '', 'go 1.21', '']; const depEntries = Object.entries(deps); if (depEntries.length > 0) { lines.push('require ('); for (const [dep, ver] of depEntries) { - // DID-scoped deps: did:dht:abc/utils → did.enbox.org/did:dht:abc/utils - const goPath = dep.startsWith('did:') ? `did.enbox.org/${dep}` : dep; + // DID-scoped deps: did:dht:abc/utils → did.enbox.id/did:dht:abc/utils + const goPath = dep.startsWith('did:') ? `did.enbox.id/${dep}` : dep; const goVer = ver.startsWith('v') ? ver : `v${ver}`; lines.push(`\t${goPath} ${goVer}`); } @@ -151,7 +151,7 @@ async function handleVersionList( ): Promise<GoProxyResponse> { const pkg = await resolvePackage(ctx, did, name, 'go'); if (!pkg) { - return gone(`module not found: did.enbox.org/${did}/${name}`); + return gone(`module not found: did.enbox.id/${did}/${name}`); } const versions = await listVersions(ctx, did, pkg.contextId); @@ -176,7 +176,7 @@ async function handleVersionInfo( ): Promise<GoProxyResponse> { const pkg = await resolvePackage(ctx, did, name, 'go'); if (!pkg) { - return gone(`module not found: did.enbox.org/${did}/${name}`); + return gone(`module not found: did.enbox.id/${did}/${name}`); } const ver = await resolveVersion(ctx, did, pkg.contextId, stripV(version)); @@ -200,7 +200,7 @@ async function handleGoMod( ): Promise<GoProxyResponse> { const pkg = await resolvePackage(ctx, did, name, 'go'); if (!pkg) { - return gone(`module not found: did.enbox.org/${did}/${name}`); + return gone(`module not found: did.enbox.id/${did}/${name}`); } const ver = await resolveVersion(ctx, did, pkg.contextId, stripV(version)); @@ -224,7 +224,7 @@ async function handleModuleZip( ): Promise<GoProxyResponse> { const pkg = await resolvePackage(ctx, did, name, 'go'); if (!pkg) { - return gone(`module not found: did.enbox.org/${did}/${name}`); + return gone(`module not found: did.enbox.id/${did}/${name}`); } const ver = await resolveVersion(ctx, did, pkg.contextId, stripV(version)); @@ -256,7 +256,7 @@ async function handleLatest( ): Promise<GoProxyResponse> { const pkg = await resolvePackage(ctx, did, name, 'go'); if (!pkg) { - return gone(`module not found: did.enbox.org/${did}/${name}`); + return gone(`module not found: did.enbox.id/${did}/${name}`); } const versions = await listVersions(ctx, did, pkg.contextId); diff --git a/src/shims/go/server.ts b/src/shims/go/server.ts index 849f1f5..de7b027 100644 --- a/src/shims/go/server.ts +++ b/src/shims/go/server.ts @@ -8,7 +8,7 @@ * gitd shim go [--port 4874] * * Then: - * GOPROXY=http://localhost:4874 go get did.enbox.org/did:dht:abc123/my-mod@v1.0.0 + * GOPROXY=http://localhost:4874 go get did.enbox.id/did:dht:abc123/my-mod@v1.0.0 * * @module */ @@ -74,7 +74,7 @@ export function startGoShim(options: GoShimOptions): Server { server.listen(port, () => { console.log(`[go-shim] Go module proxy running at http://localhost:${port}`); console.log('[go-shim] Usage:'); - console.log(` GOPROXY=http://localhost:${port} go get did.enbox.org/did:dht:<id>/<module>@v1.0.0`); + console.log(` GOPROXY=http://localhost:${port} go get did.enbox.id/did:dht:<id>/<module>@v1.0.0`); console.log(''); }); diff --git a/src/social.ts b/src/social.ts index 83537e7..19010e2 100644 --- a/src/social.ts +++ b/src/social.ts @@ -201,59 +201,59 @@ export type ForgeSocialSchemaMap = { // --------------------------------------------------------------------------- export const ForgeSocialDefinition = { - protocol : 'https://enbox.org/protocols/forge/social', + protocol : 'https://enbox.id/protocols/forge/social', published : true, types : { star: { - schema : 'https://enbox.org/schemas/forge/star', + schema : 'https://enbox.id/schemas/forge/star', dataFormats : ['application/json'], }, follow: { - schema : 'https://enbox.org/schemas/forge/follow', + schema : 'https://enbox.id/schemas/forge/follow', dataFormats : ['application/json'], }, block: { - schema : 'https://enbox.org/schemas/forge/block', + schema : 'https://enbox.id/schemas/forge/block', dataFormats : ['application/json'], }, sshKey: { - schema : 'https://enbox.org/schemas/forge/ssh-key', + schema : 'https://enbox.id/schemas/forge/ssh-key', dataFormats : ['application/json'], }, sshSigningKey: { - schema : 'https://enbox.org/schemas/forge/ssh-signing-key', + schema : 'https://enbox.id/schemas/forge/ssh-signing-key', dataFormats : ['application/json'], }, gpgKey: { - schema : 'https://enbox.org/schemas/forge/gpg-key', + schema : 'https://enbox.id/schemas/forge/gpg-key', dataFormats : ['application/json'], }, email: { - schema : 'https://enbox.org/schemas/forge/email', + schema : 'https://enbox.id/schemas/forge/email', dataFormats : ['application/json'], }, profile: { - schema : 'https://enbox.org/schemas/forge/profile', + schema : 'https://enbox.id/schemas/forge/profile', dataFormats : ['application/json'], }, socialAccount: { - schema : 'https://enbox.org/schemas/forge/social-account', + schema : 'https://enbox.id/schemas/forge/social-account', dataFormats : ['application/json'], }, gist: { - schema : 'https://enbox.org/schemas/forge/gist', + schema : 'https://enbox.id/schemas/forge/gist', dataFormats : ['application/json'], }, gistComment: { - schema : 'https://enbox.org/schemas/forge/gist-comment', + schema : 'https://enbox.id/schemas/forge/gist-comment', dataFormats : ['application/json'], }, gistStar: { - schema : 'https://enbox.org/schemas/forge/gist-star', + schema : 'https://enbox.id/schemas/forge/gist-star', dataFormats : ['application/json'], }, activity: { - schema : 'https://enbox.org/schemas/forge/activity', + schema : 'https://enbox.id/schemas/forge/activity', dataFormats : ['application/json'], }, }, diff --git a/src/wiki.ts b/src/wiki.ts index 558edfa..0c8776e 100644 --- a/src/wiki.ts +++ b/src/wiki.ts @@ -46,18 +46,18 @@ export type ForgeWikiSchemaMap = { // --------------------------------------------------------------------------- export const ForgeWikiDefinition = { - protocol : 'https://enbox.org/protocols/forge/wiki', + protocol : 'https://enbox.id/protocols/forge/wiki', published : true, uses : { - repo: 'https://enbox.org/protocols/forge/repo', + repo: 'https://enbox.id/protocols/forge/repo', }, types: { page: { - schema : 'https://enbox.org/schemas/forge/wiki-page', + schema : 'https://enbox.id/schemas/forge/wiki-page', dataFormats : ['text/markdown'], }, pageHistory: { - schema : 'https://enbox.org/schemas/forge/wiki-history', + schema : 'https://enbox.id/schemas/forge/wiki-history', dataFormats : ['application/json'], }, }, diff --git a/tests/github-shim.spec.ts b/tests/github-shim.spec.ts index 1bfa8e2..305d3b5 100644 --- a/tests/github-shim.spec.ts +++ b/tests/github-shim.spec.ts @@ -610,8 +610,8 @@ describe('GitHub API compatibility shim', () => { data: { name : ORG_NAME, description : 'Decentralized forge organization', - homepage : 'https://enbox.org', - avatar : 'https://enbox.org/avatar.png', + homepage : 'https://enbox.id', + avatar : 'https://enbox.id/avatar.png', }, }); const orgContextId = orgRec!.contextId ?? ''; @@ -934,13 +934,13 @@ describe('GitHub API compatibility shim', () => { const res = await handleShimRequest(ctx, orgUrl('/repos'), 'POST', { name : ORG_API_REPO_NAME, description : 'Organization route repository', - homepage : 'https://enbox.org/repos/org-api-repo', + homepage : 'https://enbox.id/repos/org-api-repo', visibility : 'public', }, null, shimOptions()); expect(res.status).toBe(201); const created = parse(res); expect(created.name).toBe(ORG_API_REPO_NAME); - expect(created.homepage).toBe('https://enbox.org/repos/org-api-repo'); + expect(created.homepage).toBe('https://enbox.id/repos/org-api-repo'); expect(created.visibility).toBe('public'); const list = parse(await handleShimRequest(ctx, orgUrl('/repos?type=public'))); @@ -1050,7 +1050,7 @@ describe('GitHub API compatibility shim', () => { has_projects : true, has_pull_requests : false, has_wiki : false, - homepage : 'https://enbox.org/repos/repo-lifecycle-renamed', + homepage : 'https://enbox.id/repos/repo-lifecycle-renamed', is_template : true, name : REPO_LIFECYCLE_RENAMED_REPO_NAME, private : false, @@ -1062,7 +1062,7 @@ describe('GitHub API compatibility shim', () => { expect(updated.name).toBe(REPO_LIFECYCLE_RENAMED_REPO_NAME); expect(updated.full_name).toBe(`${testDid}/${REPO_LIFECYCLE_RENAMED_REPO_NAME}`); expect(updated.description).toBe('Updated through PATCH /repos'); - expect(updated.homepage).toBe('https://enbox.org/repos/repo-lifecycle-renamed'); + expect(updated.homepage).toBe('https://enbox.id/repos/repo-lifecycle-renamed'); expect(updated.private).toBe(false); expect(updated.visibility).toBe('public'); expect(updated.default_branch).toBe('trunk'); @@ -2410,16 +2410,16 @@ describe('GitHub API compatibility shim', () => { expect(initialData.login).toBe(ORG_NAME); expect(initialData.type).toBe('Organization'); expect(initialData.members_url).toContain('/members{/member}'); - expect(initialData.blog).toBe('https://enbox.org'); + expect(initialData.blog).toBe('https://enbox.id'); const updated = await handleShimRequest(ctx, orgUrl(''), 'PATCH', { description : 'Updated decentralized forge organization', - blog : 'https://forge.enbox.org', + blog : 'https://forge.enbox.id', }); expect(updated.status).toBe(200); const updatedData = parse(updated); expect(updatedData.description).toBe('Updated decentralized forge organization'); - expect(updatedData.blog).toBe('https://forge.enbox.org'); + expect(updatedData.blog).toBe('https://forge.enbox.id'); const missing = await handleShimRequest(ctx, url('/orgs/missing-org')); expect(missing.status).toBe(404); diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 78d6d85..ee34deb 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -175,7 +175,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -197,7 +197,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'repo-1', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'repo-1', visibility: 'public' }, }); @@ -208,7 +208,7 @@ describe('gitd integration', () => { const write2 = await RecordsWrite.create({ protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', dataFormat : 'application/json', data, tags : { name: 'repo-2', visibility: 'public' }, @@ -226,7 +226,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -237,7 +237,7 @@ describe('gitd integration', () => { recipient : maintainer.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/maintainer', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: maintainer.did, alias: 'alice' })), tags : { did: maintainer.did }, parentContextId : repo.message.contextId, @@ -270,7 +270,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'public-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'public-repo', visibility: 'public' }, }); @@ -291,7 +291,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -302,7 +302,7 @@ describe('gitd integration', () => { recipient : contributor.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/contributor', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: contributor.did })), tags : { did: contributor.did }, parentContextId : repo.message.contextId, @@ -335,7 +335,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -346,7 +346,7 @@ describe('gitd integration', () => { recipient : maintainer.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/maintainer', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: maintainer.did })), tags : { did: maintainer.did }, parentContextId : repo.message.contextId, @@ -358,7 +358,7 @@ describe('gitd integration', () => { recipient : contributor.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/contributor', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: contributor.did })), tags : { did: contributor.did }, parentContextId : repo.message.contextId, @@ -375,7 +375,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Bug report', body: 'Something is broken' })), tags : { status: 'open' }, parentContextId : repoContextId, @@ -399,7 +399,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Feature request', body: 'Please add X' })), tags : { status: 'open' }, parentContextId : repoContextId, @@ -421,7 +421,7 @@ describe('gitd integration', () => { const write = await RecordsWrite.create({ protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', dataFormat : 'application/json', data, tags : { status: 'open' }, @@ -440,7 +440,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Bug', body: 'Broken' })), tags : { status: 'open' }, parentContextId : repoContextId, @@ -452,7 +452,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue/comment', - schema : 'https://enbox.org/schemas/forge/comment', + schema : 'https://enbox.id/schemas/forge/comment', data : encoder.encode(JSON.stringify({ body: 'I can reproduce this.' })), parentContextId : issue.message.contextId, protocolRole : 'repo:repo/contributor', @@ -476,7 +476,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Bug', body: 'Broken' })), tags : { status: 'open' }, parentContextId : repoContextId, @@ -488,7 +488,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue/label', - schema : 'https://enbox.org/schemas/forge/label', + schema : 'https://enbox.id/schemas/forge/label', data : encoder.encode(JSON.stringify({ name: 'bug', color: '#ff0000' })), tags : { name: 'bug', color: '#ff0000' }, parentContextId : issue.message.contextId, @@ -516,7 +516,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Bug', body: 'Fixed it' })), tags : { status: 'open' }, parentContextId : repoContextId, @@ -528,7 +528,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue/statusChange', - schema : 'https://enbox.org/schemas/forge/status-change', + schema : 'https://enbox.id/schemas/forge/status-change', data : encoder.encode(JSON.stringify({ reason: 'Fixed in PR #1' })), tags : { from: 'open', to: 'closed' }, parentContextId : issue.message.contextId, @@ -558,7 +558,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -568,7 +568,7 @@ describe('gitd integration', () => { recipient : maintainer.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/maintainer', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: maintainer.did })), tags : { did: maintainer.did }, parentContextId : repo.message.contextId, @@ -579,7 +579,7 @@ describe('gitd integration', () => { recipient : contributor.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/contributor', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: contributor.did })), tags : { did: contributor.did }, parentContextId : repo.message.contextId, @@ -595,7 +595,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch', - schema : 'https://enbox.org/schemas/forge/patch', + schema : 'https://enbox.id/schemas/forge/patch', data : encoder.encode(JSON.stringify({ title: 'Add feature X', body: 'This adds feature X.' })), tags : { status: 'open', baseBranch: 'main', headBranch: 'feature-x' }, parentContextId : repoContextId, @@ -618,7 +618,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch', - schema : 'https://enbox.org/schemas/forge/patch', + schema : 'https://enbox.id/schemas/forge/patch', data : encoder.encode(JSON.stringify({ title: 'Feature X', body: 'Adds X' })), tags : { status: 'open', baseBranch: 'main' }, parentContextId : repoContextId, @@ -630,7 +630,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/revision', - schema : 'https://enbox.org/schemas/forge/revision', + schema : 'https://enbox.id/schemas/forge/revision', data : encoder.encode(JSON.stringify({ diffStat: { additions: 50, deletions: 10, filesChanged: 3 }, })), @@ -653,7 +653,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch', - schema : 'https://enbox.org/schemas/forge/patch', + schema : 'https://enbox.id/schemas/forge/patch', data : encoder.encode(JSON.stringify({ title: 'Feature X', body: 'Adds X' })), tags : { status: 'open', baseBranch: 'main' }, parentContextId : repoContextId, @@ -665,7 +665,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/review', - schema : 'https://enbox.org/schemas/forge/review', + schema : 'https://enbox.id/schemas/forge/review', data : encoder.encode(JSON.stringify({ body: 'LGTM!' })), tags : { verdict: 'approve' }, parentContextId : patch.message.contextId, @@ -677,7 +677,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/mergeResult', - schema : 'https://enbox.org/schemas/forge/merge-result', + schema : 'https://enbox.id/schemas/forge/merge-result', data : encoder.encode(JSON.stringify({ mergedBy: maintainer.did })), tags : { mergeCommit: 'deadbeef', strategy: 'squash' }, parentContextId : patch.message.contextId, @@ -700,7 +700,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch', - schema : 'https://enbox.org/schemas/forge/patch', + schema : 'https://enbox.id/schemas/forge/patch', data : encoder.encode(JSON.stringify({ title: 'Feature X', body: 'Adds X' })), tags : { status: 'open', baseBranch: 'main' }, parentContextId : repoContextId, @@ -712,7 +712,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/mergeResult', - schema : 'https://enbox.org/schemas/forge/merge-result', + schema : 'https://enbox.id/schemas/forge/merge-result', data : encoder.encode(JSON.stringify({ mergedBy: maintainer.did })), tags : { mergeCommit: 'deadbeef', strategy: 'squash' }, parentContextId : patch.message.contextId, @@ -724,7 +724,7 @@ describe('gitd integration', () => { const write = await RecordsWrite.create({ protocol : ForgePatchesDefinition.protocol, protocolPath : 'repo/patch/mergeResult', - schema : 'https://enbox.org/schemas/forge/merge-result', + schema : 'https://enbox.id/schemas/forge/merge-result', dataFormat : 'application/json', data, tags : { mergeCommit: 'cafebabe', strategy: 'merge' }, @@ -758,7 +758,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'my-repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'my-repo', visibility: 'public' }, }); @@ -769,7 +769,7 @@ describe('gitd integration', () => { recipient : maintainer.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/maintainer', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: maintainer.did })), tags : { did: maintainer.did }, parentContextId : repo.message.contextId, @@ -780,7 +780,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeCiDefinition.protocol, protocolPath : 'repo/checkSuite', - schema : 'https://enbox.org/schemas/forge/check-suite', + schema : 'https://enbox.id/schemas/forge/check-suite', data : encoder.encode(JSON.stringify({ headBranch: 'main' })), tags : { commitSha: 'abc123', status: 'queued' }, parentContextId : repo.message.contextId, @@ -792,7 +792,7 @@ describe('gitd integration', () => { author : maintainer, protocol : ForgeCiDefinition.protocol, protocolPath : 'repo/checkSuite/checkRun', - schema : 'https://enbox.org/schemas/forge/check-run', + schema : 'https://enbox.id/schemas/forge/check-run', data : encoder.encode(JSON.stringify({ summary: 'Running lint...' })), tags : { name: 'lint', status: 'in_progress' }, parentContextId : suite.message.contextId, @@ -820,7 +820,7 @@ describe('gitd integration', () => { author : owner, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo', - schema : 'https://enbox.org/schemas/forge/repo', + schema : 'https://enbox.id/schemas/forge/repo', data : encoder.encode(JSON.stringify({ name: 'repo', defaultBranch: 'main', dwnEndpoints: [] })), tags : { name: 'repo', visibility: 'public' }, }); @@ -831,7 +831,7 @@ describe('gitd integration', () => { recipient : contributor.did, protocol : ForgeRepoDefinition.protocol, protocolPath : 'repo/contributor', - schema : 'https://enbox.org/schemas/forge/collaborator', + schema : 'https://enbox.id/schemas/forge/collaborator', data : encoder.encode(JSON.stringify({ did: contributor.did })), tags : { did: contributor.did }, parentContextId : repo.message.contextId, @@ -842,7 +842,7 @@ describe('gitd integration', () => { author : contributor, protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', data : encoder.encode(JSON.stringify({ title: 'Before revoke', body: 'Works' })), tags : { status: 'open' }, parentContextId : repo.message.contextId, @@ -861,7 +861,7 @@ describe('gitd integration', () => { const write = await RecordsWrite.create({ protocol : ForgeIssuesDefinition.protocol, protocolPath : 'repo/issue', - schema : 'https://enbox.org/schemas/forge/issue', + schema : 'https://enbox.id/schemas/forge/issue', dataFormat : 'application/json', data, tags : { status: 'open' }, diff --git a/tests/protocols.spec.ts b/tests/protocols.spec.ts index 0d6944c..e2590e3 100644 --- a/tests/protocols.spec.ts +++ b/tests/protocols.spec.ts @@ -33,7 +33,7 @@ describe('@enbox/gitd', () => { describe('ForgeRepoProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeRepoDefinition.protocol).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeRepoDefinition.protocol).toBe('https://enbox.id/protocols/forge/repo'); }); it('should be a published protocol', () => { @@ -185,7 +185,7 @@ describe('@enbox/gitd', () => { describe('ForgeRefsProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeRefsDefinition.protocol).toBe('https://enbox.org/protocols/forge/refs'); + expect(ForgeRefsDefinition.protocol).toBe('https://enbox.id/protocols/forge/refs'); }); it('should be a published protocol', () => { @@ -194,16 +194,16 @@ describe('@enbox/gitd', () => { it('should compose with Forge Repo via uses', () => { expect(ForgeRefsDefinition.uses).toBeDefined(); - expect(ForgeRefsDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeRefsDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define the ref and branch state types', () => { expect(ForgeRefsDefinition.types.ref).toBeDefined(); - expect(ForgeRefsDefinition.types.ref.schema).toBe('https://enbox.org/schemas/forge/git-ref'); + expect(ForgeRefsDefinition.types.ref.schema).toBe('https://enbox.id/schemas/forge/git-ref'); expect(ForgeRefsDefinition.types.ref.dataFormats).toContain('application/json'); - expect(ForgeRefsDefinition.types.branch.schema).toBe('https://enbox.org/schemas/forge/branch'); + expect(ForgeRefsDefinition.types.branch.schema).toBe('https://enbox.id/schemas/forge/branch'); expect(ForgeRefsDefinition.types.branch.dataFormats).toContain('application/json'); - expect(ForgeRefsDefinition.types.state.schema).toBe('https://enbox.org/schemas/forge/branch-state'); + expect(ForgeRefsDefinition.types.state.schema).toBe('https://enbox.id/schemas/forge/branch-state'); expect(ForgeRefsDefinition.types.state.dataFormats).toContain('application/json'); expect(ForgeRefsDefinition.types.bundle.dataFormats).toContain('application/x-git-bundle'); }); @@ -313,7 +313,7 @@ describe('@enbox/gitd', () => { describe('ForgeIssuesProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeIssuesDefinition.protocol).toBe('https://enbox.org/protocols/forge/issues'); + expect(ForgeIssuesDefinition.protocol).toBe('https://enbox.id/protocols/forge/issues'); }); it('should be a published protocol', () => { @@ -322,7 +322,7 @@ describe('@enbox/gitd', () => { it('should compose with Forge Repo via uses', () => { expect(ForgeIssuesDefinition.uses).toBeDefined(); - expect(ForgeIssuesDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeIssuesDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define all expected types', () => { @@ -428,7 +428,7 @@ describe('@enbox/gitd', () => { describe('ForgePatchesProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgePatchesDefinition.protocol).toBe('https://enbox.org/protocols/forge/patches'); + expect(ForgePatchesDefinition.protocol).toBe('https://enbox.id/protocols/forge/patches'); }); it('should be a published protocol', () => { @@ -436,7 +436,7 @@ describe('@enbox/gitd', () => { }); it('should compose with Forge Repo via uses', () => { - expect(ForgePatchesDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgePatchesDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define all expected types', () => { @@ -626,7 +626,7 @@ describe('@enbox/gitd', () => { describe('ForgeCiProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeCiDefinition.protocol).toBe('https://enbox.org/protocols/forge/ci'); + expect(ForgeCiDefinition.protocol).toBe('https://enbox.id/protocols/forge/ci'); }); it('should be a published protocol', () => { @@ -634,7 +634,7 @@ describe('@enbox/gitd', () => { }); it('should compose with Forge Repo via uses', () => { - expect(ForgeCiDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeCiDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define checkSuite, checkRun, and artifact types', () => { @@ -702,7 +702,7 @@ describe('@enbox/gitd', () => { describe('ForgeReleasesProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeReleasesDefinition.protocol).toBe('https://enbox.org/protocols/forge/releases'); + expect(ForgeReleasesDefinition.protocol).toBe('https://enbox.id/protocols/forge/releases'); }); it('should be a published protocol', () => { @@ -710,7 +710,7 @@ describe('@enbox/gitd', () => { }); it('should compose with Forge Repo via uses', () => { - expect(ForgeReleasesDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeReleasesDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define release, asset, and signature types', () => { @@ -775,7 +775,7 @@ describe('@enbox/gitd', () => { describe('ForgeRegistryProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeRegistryDefinition.protocol).toBe('https://enbox.org/protocols/forge/registry'); + expect(ForgeRegistryDefinition.protocol).toBe('https://enbox.id/protocols/forge/registry'); }); it('should be a published protocol', () => { @@ -857,7 +857,7 @@ describe('@enbox/gitd', () => { describe('ForgeSocialProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeSocialDefinition.protocol).toBe('https://enbox.org/protocols/forge/social'); + expect(ForgeSocialDefinition.protocol).toBe('https://enbox.id/protocols/forge/social'); }); it('should be a published protocol', () => { @@ -961,7 +961,7 @@ describe('@enbox/gitd', () => { describe('ForgeNotificationsProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeNotificationsDefinition.protocol).toBe('https://enbox.org/protocols/forge/notifications'); + expect(ForgeNotificationsDefinition.protocol).toBe('https://enbox.id/protocols/forge/notifications'); }); it('should be a private (not published) protocol', () => { @@ -1012,7 +1012,7 @@ describe('@enbox/gitd', () => { describe('ForgeWikiProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeWikiDefinition.protocol).toBe('https://enbox.org/protocols/forge/wiki'); + expect(ForgeWikiDefinition.protocol).toBe('https://enbox.id/protocols/forge/wiki'); }); it('should be a published protocol', () => { @@ -1020,7 +1020,7 @@ describe('@enbox/gitd', () => { }); it('should compose with Forge Repo via uses', () => { - expect(ForgeWikiDefinition.uses!.repo).toBe('https://enbox.org/protocols/forge/repo'); + expect(ForgeWikiDefinition.uses!.repo).toBe('https://enbox.id/protocols/forge/repo'); }); it('should define page and pageHistory types', () => { @@ -1077,7 +1077,7 @@ describe('@enbox/gitd', () => { describe('ForgeOrgProtocol', () => { it('should have the correct protocol URI', () => { - expect(ForgeOrgDefinition.protocol).toBe('https://enbox.org/protocols/forge/org'); + expect(ForgeOrgDefinition.protocol).toBe('https://enbox.id/protocols/forge/org'); }); it('should be a published protocol', () => { @@ -1181,22 +1181,22 @@ describe('@enbox/gitd', () => { const uniqueUris = new Set(uris); expect(uniqueUris.size).toBe(11); for (const uri of uris) { - expect(uri).toMatch(/^https:\/\/enbox\.org\/protocols\/forge\//); + expect(uri).toMatch(/^https:\/\/enbox\.id\/protocols\/forge\//); } }); - it('should use https://enbox.org/schemas/forge/ for all schema URIs', () => { + it('should use https://enbox.id/schemas/forge/ for all schema URIs', () => { for (const def of allDefinitions) { for (const [, typeConfig] of Object.entries(def.types)) { if ('schema' in typeConfig && typeConfig.schema !== undefined) { - expect(typeConfig.schema).toMatch(/^https:\/\/enbox\.org\/schemas\/forge\//); + expect(typeConfig.schema).toMatch(/^https:\/\/enbox\.id\/schemas\/forge\//); } } } }); it('should only reference repo protocol in uses declarations', () => { - const repoUri = 'https://enbox.org/protocols/forge/repo'; + const repoUri = 'https://enbox.id/protocols/forge/repo'; for (const def of allDefinitions) { if (def.uses !== undefined) { for (const [, uri] of Object.entries(def.uses)) { @@ -1209,7 +1209,7 @@ describe('@enbox/gitd', () => { it('should have notifications as the only private protocol', () => { const privateProtocols = allDefinitions.filter((d) => d.published === false); expect(privateProtocols).toHaveLength(1); - expect(privateProtocols[0].protocol).toBe('https://enbox.org/protocols/forge/notifications'); + expect(privateProtocols[0].protocol).toBe('https://enbox.id/protocols/forge/notifications'); }); it('should have all Protocol wrappers referencing their definitions', () => { diff --git a/tests/schemas.spec.ts b/tests/schemas.spec.ts index 0d50121..730d14d 100644 --- a/tests/schemas.spec.ts +++ b/tests/schemas.spec.ts @@ -56,7 +56,7 @@ describe('JSON Schemas', () => { const schema = readSchema(subdir, file); expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#'); expect(schema.$id).toBeDefined(); - expect(schema.$id).toMatch(/^https:\/\/enbox\.org\/schemas\/forge\//); + expect(schema.$id).toMatch(/^https:\/\/enbox\.id\/schemas\/forge\//); expect(schema.type).toBe('object'); expect(schema.title).toBeDefined(); expect(typeof schema.title).toBe('string'); diff --git a/tests/shims.spec.ts b/tests/shims.spec.ts index 3cf12e0..4e3819d 100644 --- a/tests/shims.spec.ts +++ b/tests/shims.spec.ts @@ -505,7 +505,7 @@ describe('Package manager shims', () => { expect(res.status).toBe(200); const body = res.body as string; - expect(body).toContain(`module did.enbox.org/${testDid}/my-mod`); + expect(body).toContain(`module did.enbox.id/${testDid}/my-mod`); expect(body).toContain('go 1.21'); }); @@ -518,7 +518,7 @@ describe('Package manager shims', () => { const body = res.body as string; expect(body).toContain('require ('); - expect(body).toContain(`did.enbox.org/${testDid}/my-mod`); + expect(body).toContain(`did.enbox.id/${testDid}/my-mod`); }); });