@volter/twin-github 0.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +230 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +101 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +159 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +223 -95
- package/src/github-git-http.ts +47 -83
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +47 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +186 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -10,18 +10,25 @@
|
|
|
10
10
|
// Data API, Repos/Orgs/Teams/Users, Gists, Search, Notifications, Packages, Pages, code
|
|
11
11
|
// scanning, GraphQL, Apps/OAuth, Webhooks, plus the web UI screens. The twin currently
|
|
12
12
|
// models the PR/Issue/Checks/Actions core (REST objects) + a UI mirror; everything else is
|
|
13
|
-
// an honest `todo`.
|
|
14
|
-
//
|
|
13
|
+
// an honest `todo`. Raw repo file BYTES are a filed todo (github.repos.blob_bytes); running real
|
|
14
|
+
// CI compute is what the real root does, not an API.
|
|
15
15
|
// COMPUTE (executing Actions — infra, not API; the run/job OBJECTS are covered and modeled).
|
|
16
|
-
import
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { serveHttp, twinResources, worldNow } from '@volter/world-core';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
20
|
import { tmpdir } from 'node:os';
|
|
21
|
+
import { gzipSync } from 'node:zlib';
|
|
18
22
|
import { join } from 'node:path';
|
|
19
|
-
import { checkCapabilities, uiDataCoupled, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/
|
|
23
|
+
import { checkCapabilities, uiDataCoupled, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
20
24
|
import { githubMirrorState } from './github-mirror-state.ts';
|
|
21
|
-
import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
|
|
25
|
+
import { applyGithubWrite, githubState, handleGithubRequest, totpCode, totpStep } from './github-twin.ts';
|
|
22
26
|
import { handleGithubGitSmartHttp } from './github-git-http.ts';
|
|
27
|
+
import { githubNpmRegistry } from './npm-registry.ts';
|
|
28
|
+
import { githubAppInstallationFlow } from './screens/app-installation.tsx';
|
|
29
|
+
import { githubAppManifestFlow } from './screens/app-manifest.tsx';
|
|
30
|
+
import { githubSessionFlow } from './screens/session.tsx';
|
|
23
31
|
import { createGithubTwinServer } from './github-server.ts';
|
|
24
|
-
import { clearGithubWebhooks, registerGithubWebhook } from './github-events.ts';
|
|
25
32
|
|
|
26
33
|
// ── git-plane helpers: spawn the REAL git CLI (async — verifies that also run the twin's
|
|
27
34
|
// HTTP server must keep the event loop free; a spawnSync would deadlock the clone against
|
|
@@ -64,26 +71,130 @@ const ok = (status: number) => status >= 200 && status < 300;
|
|
|
64
71
|
// GET returns the expected status (and optional body-shape check passes).
|
|
65
72
|
const getOk = (path: string, shape?: (body: unknown) => boolean) => () =>
|
|
66
73
|
withRoot(async (root) => {
|
|
67
|
-
const res = handleGithubRequest({ method: 'GET', path, root });
|
|
74
|
+
const res = await handleGithubRequest({ method: 'GET', path, root });
|
|
68
75
|
return ok(res.status) && (shape ? shape(res.body) : true);
|
|
69
76
|
});
|
|
70
77
|
|
|
71
78
|
// ── shorthands ───────────────────────────────────────────────────────────────────────────
|
|
72
79
|
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
73
80
|
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
74
|
-
const outOfScope = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], reason: string): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo', outOfScope: reason });
|
|
75
81
|
|
|
76
82
|
const REPO = 'octo/demo';
|
|
83
|
+
// A repository with history, created as a person creates one, and files pushed to its main branch: what GitHub reacts
|
|
84
|
+
// to by registering workflows, scanning for secrets and reading dependency manifests. Answers the last commit's sha.
|
|
85
|
+
const HISTORY_REPO = 'octocat/ci-demo';
|
|
86
|
+
async function pushFiles(root: string, files: Record<string, string>): Promise<string> {
|
|
87
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}`, root })).status === 404) await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'ci-demo', auto_init: true }), root });
|
|
88
|
+
let sha = '';
|
|
89
|
+
for (const [file, text] of Object.entries(files)) {
|
|
90
|
+
const put = await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/${file}`, body: JSON.stringify({ message: `Add ${file}`, content: Buffer.from(text).toString('base64') }), root });
|
|
91
|
+
sha = (put.response.body as { commit?: { sha?: string } }).commit?.sha ?? sha;
|
|
92
|
+
}
|
|
93
|
+
return sha;
|
|
94
|
+
}
|
|
95
|
+
const workflowFile = (name: string, steps = ''): string => `name: ${name}\non: workflow_dispatch\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n${steps} - run: npm test\n`;
|
|
96
|
+
/** Another person's token, minted where GitHub's own settings page would. */
|
|
97
|
+
const mintToken = async (root: string, login: string): Promise<string> => ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
98
|
+
/** A person signed in at github.com/login, as a browser is: the session cookie the sign-in sets (src/screens/session.tsx). */
|
|
99
|
+
const signIn = async (root: string, login = 'octocat'): Promise<string> => {
|
|
100
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/password`, body: JSON.stringify({ password: 'probe-password' }), root });
|
|
101
|
+
const res = await githubSessionFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/session', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ login, password: 'probe-password', return_to: '/' }).toString() }));
|
|
102
|
+
return (res?.headers.getSetCookie() ?? []).map((c) => c.split(';')[0]).join('; ');
|
|
103
|
+
};
|
|
104
|
+
/** A dispatched run of a pushed workflow, finished by the runner. Answers the run's id. */
|
|
105
|
+
async function finishedRun(root: string, file: string): Promise<number> {
|
|
106
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/${file}/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
107
|
+
const id = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/workflows/${file}/runs`, root })).body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
108
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/complete`, body: JSON.stringify({ conclusion: 'success' }), root });
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
/** A code-scanning upload as a scanner makes it: SARIF, gzip-compressed and Base64-encoded. */
|
|
112
|
+
const sarifOf = (results: Array<{ rule: string; level: string; uri: string; line: number }>): string => gzipSync(JSON.stringify({ version: '2.1.0', runs: [{ tool: { driver: { name: 'CodeQL', rules: results.map((r) => ({ id: r.rule })) } }, results: results.map((r) => ({ ruleId: r.rule, level: r.level, message: { text: r.rule }, locations: [{ physicalLocation: { artifactLocation: { uri: r.uri }, region: { startLine: r.line } } }] })) }] })).toString('base64');
|
|
113
|
+
/** A CI provider's GitHub App installed on the repository's account (its organization made first when it is not the
|
|
114
|
+
* World's user), and the installation token its check runs are written with: only an app writes check runs. */
|
|
115
|
+
async function ciToken(root: string, repo: string): Promise<string> {
|
|
116
|
+
const owner = repo.split('/')[0]!;
|
|
117
|
+
if (owner !== 'octocat') await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: owner }), root, cookie: await signIn(root) });
|
|
118
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/apps', body: JSON.stringify({ slug: 'probe-ci', name: 'Probe CI', owner, permissions: { checks: 'write' }, events: ['check_suite'], setup_url: 'https://ci.probe/setup' }), root });
|
|
119
|
+
const installed = await githubAppInstallationFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/apps/probe-ci/installations/new', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: await signIn(root) }, body: new URLSearchParams({ install: '1', target_login: owner, repository_selection: 'all' }).toString() }));
|
|
120
|
+
const installation = new URL(installed!.headers.get('location')!).searchParams.get('installation_id');
|
|
121
|
+
return ((await applyGithubWrite({ method: 'POST', path: `/app/installations/${installation}/access_tokens`, body: '{}', root, token: appJwt('probe-ci') })).response.body as { token: string }).token;
|
|
122
|
+
}
|
|
123
|
+
/** The JWT an app signs to speak for itself (semantics/apps.ts): its client ID, issued now, for nine minutes. */
|
|
124
|
+
function appJwt(slug: string): string {
|
|
125
|
+
const now = Math.floor(Date.parse(worldNow()) / 1000);
|
|
126
|
+
const part = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
127
|
+
return [part({ alg: 'RS256', typ: 'JWT' }), part({ iss: `Iv1.${createHash('sha256').update(`app:${slug}`).digest('hex').slice(0, 16)}`, iat: now - 60, exp: now + 480 }), 'c2lnbmF0dXJl'].join('.');
|
|
128
|
+
}
|
|
129
|
+
/** A collaborator as GitHub makes one: invited to the repository, then accepting with their own sign-in. */
|
|
130
|
+
async function collaborator(root: string, repo: string, login: string, permission: string): Promise<void> {
|
|
131
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${repo}/collaborators/${login}`, body: JSON.stringify({ permission }), root });
|
|
132
|
+
await acceptInvitation(root, repo, login);
|
|
133
|
+
}
|
|
134
|
+
/** The invitee accepts their pending invitation to a repository; answers the accept's outcome. */
|
|
135
|
+
async function acceptInvitation(root: string, repo: string, login: string): Promise<Awaited<ReturnType<typeof applyGithubWrite>> | undefined> {
|
|
136
|
+
const token = await mintToken(root, login);
|
|
137
|
+
const invites = (await handleGithubRequest({ method: 'GET', path: '/user/repository_invitations', root, token })).body as Array<{ id: number; repository: { full_name: string } }>;
|
|
138
|
+
const invite = invites.find((i) => i.repository.full_name === repo);
|
|
139
|
+
return invite ? applyGithubWrite({ method: 'PATCH', path: `/user/repository_invitations/${invite.id}`, root, token }) : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** A person other than the World's user @mentions it in a new issue, which lands in its inbox. */
|
|
142
|
+
async function mentionedIn(root: string, repo: string, title: string): Promise<void> {
|
|
143
|
+
const alice = await mintToken(root, 'alice');
|
|
144
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/issues`, body: JSON.stringify({ title, body: '@octocat can you look?' }), root, token: alice });
|
|
145
|
+
}
|
|
77
146
|
const isArr = (b: unknown): b is unknown[] => Array.isArray(b);
|
|
78
147
|
|
|
79
148
|
// The real GitHub surface. Anything not here is still a gap (keep growing it). Entries with
|
|
80
|
-
// verify() + expected:'done' are what we currently claim; everything else is todo
|
|
149
|
+
// verify() + expected:'done' are what we currently claim; everything else is todo.
|
|
150
|
+
|
|
151
|
+
/** The verifies below drive the twin the way @octokit/rest does — `request('METHOD /path', { data })`
|
|
152
|
+
* resolving `{ status, headers, data }` and REJECTING with `.status` on a non-2xx — over plain fetch.
|
|
153
|
+
* The real SDK stays test-only (github-sdk.integration.test.ts; architecture guardrail: a vendor
|
|
154
|
+
* SDK is a devDependency and never enters a runtime file). */
|
|
155
|
+
function octoClient({ baseUrl }: { baseUrl: string }) {
|
|
156
|
+
return {
|
|
157
|
+
async request(route: string, options?: Record<string, unknown>): Promise<{ status: number; headers: { link?: string }; data: any }> {
|
|
158
|
+
const sp = route.indexOf(' ');
|
|
159
|
+
const method = route.slice(0, sp); const path = route.slice(sp + 1);
|
|
160
|
+
// Octokit's parameter rule: `data` is the whole body when given; otherwise every parameter
|
|
161
|
+
// not consumed by the route IS the body (`request('POST /orgs/octo/repos', { name })`).
|
|
162
|
+
const body = options?.data !== undefined ? options.data : options && Object.keys(options).length ? options : undefined;
|
|
163
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
164
|
+
method,
|
|
165
|
+
headers: { accept: 'application/vnd.github+json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
166
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
167
|
+
});
|
|
168
|
+
const text = await res.text();
|
|
169
|
+
let data: any; try { data = text ? JSON.parse(text) : undefined; } catch { data = text; }
|
|
170
|
+
const out = { status: res.status, headers: { ...(res.headers.get('link') ? { link: res.headers.get('link')! } : {}) }, data };
|
|
171
|
+
if (res.status >= 400) { const err = Object.assign(new Error(`HTTP ${res.status} ${method} ${path}`), { status: res.status, response: out }); throw err; }
|
|
172
|
+
return out;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The invitee accepts an organization invitation: signs in (a personal token) and PATCHes their membership active. */
|
|
178
|
+
async function acceptInvite(root: string, org: string, login: string): Promise<string> {
|
|
179
|
+
const token = ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
180
|
+
await applyGithubWrite({ method: 'PATCH', path: `/user/memberships/orgs/${org}`, body: JSON.stringify({ state: 'active' }), root, token });
|
|
181
|
+
return token;
|
|
182
|
+
}
|
|
183
|
+
|
|
81
184
|
export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
82
185
|
// ── Pull Requests ──────────────────────────────────────────────────────────────────────
|
|
83
186
|
done('github.pulls.create', 'pulls', 'PRs: create (POST .../pulls → 201)', 'api', 'core', () =>
|
|
84
187
|
withRoot(async (root) => {
|
|
85
188
|
const res = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'PR', head: 'feat', base: 'main' }), root });
|
|
86
|
-
|
|
189
|
+
const pr = res.response.body as any;
|
|
190
|
+
const self = await handleGithubRequest({ method: 'GET', path: '/user', root });
|
|
191
|
+
const got = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
192
|
+
const listed = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls`, root });
|
|
193
|
+
return res.response.status === 201 && pr.number === 1
|
|
194
|
+
&& pr.user?.login === (self.body as any).login && pr.user?.type === 'User'
|
|
195
|
+
&& pr.base?.repo?.full_name === REPO && pr.node_id === `PR_${REPO}#1`
|
|
196
|
+
&& (got.body as any).user?.login === pr.user.login && (got.body as any).node_id === pr.node_id
|
|
197
|
+
&& (listed.body as any[])[0]?.base?.repo?.full_name === REPO && (listed.body as any[])[0]?.node_id === pr.node_id;
|
|
87
198
|
})),
|
|
88
199
|
done('github.pulls.head_sha_resolves', 'pulls', 'PRs: head.sha resolves to the head branch tip (not the branch name)', 'api', 'core', () =>
|
|
89
200
|
withRoot(async (root) => {
|
|
@@ -107,10 +218,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
107
218
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'A', head: 'feat', base: 'main' }), root });
|
|
108
219
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'B', head: 'fix', base: 'main' }), root });
|
|
109
220
|
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/2`, body: JSON.stringify({ state: 'closed' }), root });
|
|
110
|
-
const open = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=open`, root });
|
|
111
|
-
const byHead = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?head=octo:feat`, root });
|
|
112
|
-
const closed = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=closed`, root });
|
|
113
|
-
const all = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=all`, root });
|
|
221
|
+
const open = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=open`, root });
|
|
222
|
+
const byHead = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?head=octo:feat`, root });
|
|
223
|
+
const closed = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=closed`, root });
|
|
224
|
+
const all = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls?state=all`, root });
|
|
114
225
|
return isArr(open.body) && open.body.length === 1 && (open.body[0] as { number?: number }).number === 1
|
|
115
226
|
&& isArr(byHead.body) && byHead.body.length === 1 && (byHead.body[0] as { number?: number }).number === 1
|
|
116
227
|
&& isArr(closed.body) && closed.body.length === 1 && (closed.body[0] as { number?: number }).number === 2
|
|
@@ -119,14 +230,14 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
119
230
|
done('github.pulls.get', 'pulls', 'PRs: get one (GET .../pulls/:n)', 'api', 'core', () =>
|
|
120
231
|
withRoot(async (root) => {
|
|
121
232
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'PR', head: 'feat', base: 'main' }), root });
|
|
122
|
-
const res = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
233
|
+
const res = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
123
234
|
return ok(res.status) && (res.body as { number?: number }).number === 1;
|
|
124
235
|
})),
|
|
125
236
|
done('github.pulls.list', 'pulls', 'PRs: list (GET .../pulls, paginated)', 'api', 'core', () =>
|
|
126
237
|
withRoot(async (root) => {
|
|
127
238
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'A', head: 'a', base: 'main' }), root });
|
|
128
239
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'B', head: 'b', base: 'main' }), root });
|
|
129
|
-
const res = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls`, root });
|
|
240
|
+
const res = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls`, root });
|
|
130
241
|
return ok(res.status) && isArr(res.body) && res.body.length === 2;
|
|
131
242
|
})),
|
|
132
243
|
done('github.pulls.edit', 'pulls', 'PRs: edit title/body/state (PATCH .../pulls/:n)', 'api', 'core', () =>
|
|
@@ -138,19 +249,39 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
138
249
|
done('github.pulls.merge', 'pulls', 'PRs: merge (PUT .../pulls/:n/merge → 200; optional exact-head sha precondition → 409 after a push)', 'api', 'core', () =>
|
|
139
250
|
withRoot(async (root) => {
|
|
140
251
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'm', head: 'a', base: 'main' }), root });
|
|
141
|
-
const pr = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
252
|
+
const pr = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
142
253
|
const head = (pr.body as { head?: { sha?: string } }).head?.sha;
|
|
143
254
|
const stale = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ sha: `${head}-stale` }), root });
|
|
144
255
|
const res = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ sha: head }), root });
|
|
145
|
-
const merged = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/merge`, root });
|
|
256
|
+
const merged = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/merge`, root });
|
|
146
257
|
return stale.response.status === 409 && ok(res.response.status) && (res.response.body as { merged?: boolean }).merged === true && merged.status === 204;
|
|
147
258
|
})),
|
|
148
|
-
done('github.pulls.reviews', 'pulls', 'PRs: reviews submit + list (APPROVE/COMMENT/REQUEST_CHANGES)', 'api', 'core', () =>
|
|
259
|
+
done('github.pulls.reviews', 'pulls', 'PRs: reviews submit + list + get ONE by its id (GET .../pulls/:n/reviews/:rid) (APPROVE/COMMENT/REQUEST_CHANGES)', 'api', 'core', () =>
|
|
149
260
|
withRoot(async (root) => {
|
|
150
261
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'r', head: 'a', base: 'main' }), root });
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
262
|
+
// The submit VERB and the stored STATE are different words on real GitHub, and the whole
|
|
263
|
+
// point of a review is which one it is: APPROVE→APPROVED, REQUEST_CHANGES→
|
|
264
|
+
// CHANGES_REQUESTED, COMMENT→COMMENTED. A twin that echoes the verb back serves
|
|
265
|
+
// `state: "APPROVE"`, which matches no merge gate, no `state === 'APPROVED'` branch and
|
|
266
|
+
// no GraphQL PullRequestReviewState value — an approval nobody can see.
|
|
267
|
+
const wants: Array<[string, string]> = [['APPROVE', 'APPROVED'], ['REQUEST_CHANGES', 'CHANGES_REQUESTED'], ['COMMENT', 'COMMENTED']];
|
|
268
|
+
for (const [event, state] of wants) {
|
|
269
|
+
const sub = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event, body: `said ${event}` }), root });
|
|
270
|
+
if (!(ok(sub.response.status) && (sub.response.body as { state?: string }).state === state)) return false;
|
|
271
|
+
}
|
|
272
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
273
|
+
if (!(ok(list.status) && isArr(list.body) && list.body.length === 3)) return false;
|
|
274
|
+
// The list is the same stored reviews the submits answered with — read back, not echoed.
|
|
275
|
+
const states = (list.body as Array<{ state?: string }>).map((r) => r.state);
|
|
276
|
+
if (!wants.every(([, state], i) => states[i] === state)) return false;
|
|
277
|
+
// A review has its own address, and it must answer with THAT review. The twin used to
|
|
278
|
+
// let `reviews/:rid` fall into the list branch, so asking for one review returned all
|
|
279
|
+
// three — a caller reading `body[0]` got whichever review happened to be first.
|
|
280
|
+
const rid = (list.body as Array<{ id?: number }>)[1]?.id;
|
|
281
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews/${rid}`, root });
|
|
282
|
+
if (!(ok(one.status) && (one.body as { id?: number; state?: string }).id === rid && (one.body as { state?: string }).state === 'CHANGES_REQUESTED')) return false;
|
|
283
|
+
// …and a review id nobody submitted is Not Found, not the list again.
|
|
284
|
+
return (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews/999999`, root })).status === 404;
|
|
154
285
|
})),
|
|
155
286
|
done('github.pulls.files_from_git_plane', 'pulls', 'PRs: files/changedFiles derive from the REAL git diff (base tip…head tip in the plane) when the PR carries no diff evidence — merge-gate scope classification reads actual paths, never 0', 'api', 'common', () =>
|
|
156
287
|
withRoot(async (root) => {
|
|
@@ -168,7 +299,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
168
299
|
const pr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'F', head: 'feat', base: 'main' }), root });
|
|
169
300
|
if (pr.response.status !== 201) return false;
|
|
170
301
|
// GET /pulls/:n/files answers the REAL one-file diff with status + counts.
|
|
171
|
-
const files = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
302
|
+
const files = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
172
303
|
if (!(ok(files.status) && isArr(files.body) && files.body.length === 1)) return false;
|
|
173
304
|
const f = files.body[0] as { filename?: string; status?: string; additions?: number };
|
|
174
305
|
if (!(f.filename === 'scripts/gate.ts' && f.status === 'added' && f.additions === 1)) return false;
|
|
@@ -182,7 +313,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
182
313
|
const advance = await mk('docs/onmain.md', 'later\n', base.commit, base.tree);
|
|
183
314
|
const ff = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/git/refs/heads/main`, body: JSON.stringify({ sha: advance.commit }), root });
|
|
184
315
|
if (ff.response.status !== 200) return false;
|
|
185
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
316
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
186
317
|
if (!(ok(after.status) && isArr(after.body) && after.body.length === 1)) return false;
|
|
187
318
|
return (after.body[0] as { filename?: string }).filename === 'scripts/gate.ts';
|
|
188
319
|
})),
|
|
@@ -199,9 +330,9 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
199
330
|
const shaB = 'b'.repeat(40);
|
|
200
331
|
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/git/refs/heads/feat`, body: JSON.stringify({ sha: shaB }), root });
|
|
201
332
|
if (upd.response.status !== 200) return false;
|
|
202
|
-
const pr = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
333
|
+
const pr = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
|
|
203
334
|
if ((pr.body as { head?: { sha?: string } }).head?.sha !== shaB) return false;
|
|
204
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
335
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
205
336
|
const first = (list.body as Array<{ commit_id?: string }>)[0]!;
|
|
206
337
|
if (first.commit_id !== shaA) return false; // pinned to the head it judged
|
|
207
338
|
// A fresh review binds the NEW head; a caller-pinned commit_id is honored verbatim.
|
|
@@ -214,7 +345,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
214
345
|
withRoot(async (root) => {
|
|
215
346
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'c', head: 'a', base: 'main' }), root });
|
|
216
347
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments`, body: JSON.stringify({ body: 'nit', path: 'a.ts', line: 10, side: 'RIGHT' }), root });
|
|
217
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
348
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
218
349
|
if (!(c.response.status === 201 && ok(list.status) && isArr(list.body) && (list.body[0] as { path?: string }).path === 'a.ts')) return false;
|
|
219
350
|
// The READ is how a seat finds a finding, and the REPLY is how it answers one: GitHub's
|
|
220
351
|
// own reply route takes the comment id alone and inherits the root's diff anchor.
|
|
@@ -225,43 +356,167 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
225
356
|
// a reply to a comment that isn't there is a 404, not a new thread
|
|
226
357
|
const orphan = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments/99999/replies`, body: JSON.stringify({ body: 'nobody home' }), root });
|
|
227
358
|
if (orphan.response.status !== 404) return false;
|
|
228
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
359
|
+
const after = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root }));
|
|
229
360
|
return isArr(after.body) && after.body.length === 2 && (after.body[1] as { in_reply_to_id?: number }).in_reply_to_id === cid;
|
|
230
361
|
})),
|
|
362
|
+
done('github.pulls.review_comment_get_edit_delete', 'pulls', 'PRs: one review comment by its own id — GET / PATCH / DELETE (204) at .../pulls/comments/:id', 'api', 'core', () =>
|
|
363
|
+
withRoot(async (root) => {
|
|
364
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'p', head: 'a', base: 'main' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
365
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/comments`, body: JSON.stringify({ body: 'nit', path: 'a.ts', line: 3, side: 'RIGHT' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
366
|
+
const cid = (c.response.body as { id?: number }).id;
|
|
367
|
+
const got = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/${cid}`, root });
|
|
368
|
+
// the per-comment door serves the SAME object as the thread list — diff anchoring included
|
|
369
|
+
if (!(ok(got.status) && (got.body as { body?: string }).body === 'nit' && (got.body as { path?: string }).path === 'a.ts')) return false;
|
|
370
|
+
const ed = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/comments/${cid}`, body: JSON.stringify({ body: 'nit, expanded' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
371
|
+
if (!(ok(ed.response.status) && (ed.response.body as { body?: string }).body === 'nit, expanded' && (ed.response.body as { path?: string }).path === 'a.ts')) return false;
|
|
372
|
+
const thread = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
373
|
+
if (!(isArr(thread.body) && thread.body.length === 1 && (thread.body[0] as { body?: string }).body === 'nit, expanded')) return false;
|
|
374
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/pulls/comments/${cid}`, root, occurredAt: '2026-04-04T00:00:00Z' });
|
|
375
|
+
if (del.response.status !== 204) return false;
|
|
376
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/comments`, root });
|
|
377
|
+
return isArr(after.body) && after.body.length === 0
|
|
378
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/99999`, root })).status === 404;
|
|
379
|
+
})),
|
|
380
|
+
done('github.pulls.list_order', 'pulls', 'PRs: list default order (sort=created&direction=desc — newest first) + direction', 'api', 'core', () =>
|
|
381
|
+
withRoot(async (root) => {
|
|
382
|
+
const nums = async (q: string): Promise<number[]> => ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls${q}`, root })).body as Array<{ number: number }>).map((p) => p.number);
|
|
383
|
+
const eq = (a: number[], b: number[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
384
|
+
for (const h of ['a', 'b', 'c']) await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: h, head: h, base: 'main' }), root, occurredAt: `2026-04-0${['a', 'b', 'c'].indexOf(h) + 1}T00:00:00Z` });
|
|
385
|
+
// Real GitHub's default is NEWEST FIRST. A consumer that reads page one for "the latest
|
|
386
|
+
// pull requests" is handed the oldest by a twin that answers ascending — the number IS
|
|
387
|
+
// the creation order here (one monotonic per-repo counter), so this order is grounded.
|
|
388
|
+
return eq(await nums(''), [3, 2, 1]) && eq(await nums('?sort=created'), [3, 2, 1]) && eq(await nums('?direction=asc'), [1, 2, 3])
|
|
389
|
+
&& eq(await nums('?state=all&direction=asc'), [1, 2, 3]);
|
|
390
|
+
})),
|
|
391
|
+
done('github.pulls.timeline', 'pulls', 'PRs: the timeline at the pull request\'s issue address (closed/reopened/merged/reviewed/commented)', 'api', 'common', () =>
|
|
392
|
+
withRoot(async (root) => {
|
|
393
|
+
// A pull request IS an issue and answers at the issue address, so its timeline is served
|
|
394
|
+
// there. It was EMPTY: the timeline was built from the issue construct alone, and a pull
|
|
395
|
+
// request has no row in it. Everything asserted here is read out of stored state — the
|
|
396
|
+
// recorded open↔closed transitions, the merge write, the submitted review, the comment.
|
|
397
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'tl', head: 'a', base: 'main' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
398
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'one nit' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
399
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event: 'APPROVE', body: 'ship it' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
400
|
+
// closed, reopened, then merged — the story returns to a state it already held, which is
|
|
401
|
+
// the only shape that can tell a real history from one derived off the present state.
|
|
402
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ state: 'closed' }), root, occurredAt: '2026-04-04T00:00:00Z' });
|
|
403
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ state: 'open' }), root, occurredAt: '2026-04-05T00:00:00Z' });
|
|
404
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ merge_method: 'squash' }), root, occurredAt: '2026-04-06T00:00:00Z' });
|
|
405
|
+
const tl = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/timeline`, root });
|
|
406
|
+
if (!(ok(tl.status) && isArr(tl.body) && tl.body.length > 0)) return false;
|
|
407
|
+
const events = tl.body as Array<{ event?: string; state?: string; body?: string | null; commit_id?: string | null }>;
|
|
408
|
+
const names = events.map((e) => e.event);
|
|
409
|
+
for (const want of ['closed', 'reopened', 'merged', 'reviewed', 'commented']) if (!names.includes(want)) return false;
|
|
410
|
+
// Closed BEFORE reopened: order is the whole point of a history.
|
|
411
|
+
if (!(names.indexOf('closed') < names.indexOf('reopened'))) return false;
|
|
412
|
+
// The timeline's `reviewed` state is the SAME stored review the reviews API serves, lower
|
|
413
|
+
// -cased the way real GitHub's timeline spells it — not a second copy that could drift.
|
|
414
|
+
// Both doors are pinned to the VENDOR's word now: the caller submitted the verb APPROVE
|
|
415
|
+
// and GitHub records the state APPROVED, so the reviews API says APPROVED and the
|
|
416
|
+
// timeline says approved. (Cross-door agreement alone was satisfied while the twin
|
|
417
|
+
// echoed the raw verb through both; the literal states are what a merge gate reads.)
|
|
418
|
+
const apiState = ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root })).body as Array<{ state?: string }>)[0]?.state;
|
|
419
|
+
const tlState = events.find((e) => e.event === 'reviewed')?.state;
|
|
420
|
+
if (!(apiState === 'APPROVED' && tlState === 'approved' && tlState === apiState.toLowerCase())) return false;
|
|
421
|
+
if (events.find((e) => e.event === 'commented')?.body !== 'one nit') return false;
|
|
422
|
+
if (!events.find((e) => e.event === 'merged')?.commit_id) return false;
|
|
423
|
+
// `merged` is a typed issue event on real GitHub, so the events door carries it too.
|
|
424
|
+
const ev = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events`, root });
|
|
425
|
+
return isArr(ev.body) && (ev.body as Array<{ event?: string }>).some((e) => e.event === 'merged');
|
|
426
|
+
})),
|
|
427
|
+
todo('github.pulls.timeline_unmodeled', 'pulls', 'PRs: timeline entries this twin holds no state for — committed, head_ref_force_pushed/deleted/restored, review_requested/removed, ready_for_review, converted_to_draft, base_ref_changed, auto_merge_enabled/disabled. The twin records the CURRENT reviewer/draft/base values, never the transitions, so each of these would be a fabricated event rather than a remembered one', 'api', 'niche'),
|
|
428
|
+
todo('github.pulls.list_filters_unmodeled', 'pulls', 'PRs: list sort=updated|popularity|long-running. The PR construct carries no updated_at (declared deviation) and no reaction/comment count, so those three orders would be a guess; state/head/base, direction and sort=created are served', 'api', 'niche'),
|
|
429
|
+
done('github.pulls.read_requires_parent', 'pulls', 'PRs: a sub-resource READ or WRITE addressed at a pull request nobody opened is 404, never an empty list — and an ISSUE number is not a pull-request number, in either direction', 'api', 'core', () =>
|
|
430
|
+
withRoot(async (root) => {
|
|
431
|
+
// The pulls half of write_requires_parent / issues.read_requires_parent. `200 []` for
|
|
432
|
+
// the reviews of pull request 4041 says the pull request is real and simply unreviewed,
|
|
433
|
+
// so a typo'd number, a stale link and a genuinely quiet pull request are one answer and
|
|
434
|
+
// the mistake never surfaces. GET .../pulls/4041 already 404'd, which made it worse: the
|
|
435
|
+
// object was missing and every one of its sub-resources was serene.
|
|
436
|
+
const subs = ['reviews', 'comments', 'files', 'commits', 'requested_reviewers', 'threads'];
|
|
437
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'an issue, not a pull request' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
438
|
+
// …and the issue picks up a comment, which is what materializes a counter SHELL row in
|
|
439
|
+
// the pull-request table for its number. That shell is why the test is `is_pull_request`
|
|
440
|
+
// and not "a row exists": before this, the shell made the whole pulls family answer for
|
|
441
|
+
// an issue — a merge on it even promoted the issue into a pull request.
|
|
442
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'commented' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
443
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'a pull request', head: 'a', base: 'main' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
444
|
+
for (const s of subs) {
|
|
445
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/4041/${s}`, root })).status !== 404) return false;
|
|
446
|
+
// An ISSUE number is not a pull-request number: every pull request is an issue, but
|
|
447
|
+
// the relationship does not run the other way, so this family refuses it.
|
|
448
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/${s}`, root })).status !== 404) return false;
|
|
449
|
+
// The pull request that WAS opened answers 200 on every one of them.
|
|
450
|
+
if (!ok((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/2/${s}`, root })).status)) return false;
|
|
451
|
+
}
|
|
452
|
+
// The write doors owe the same answer, and these four looked the row up by id alone —
|
|
453
|
+
// so an issue with a comment could be merged, auto-merged, given reviewers and had its
|
|
454
|
+
// branch updated as a pull request.
|
|
455
|
+
const writes: Array<[string, string, string]> = [
|
|
456
|
+
['PUT', 'merge', '{}'], ['PUT', 'auto-merge', '{}'],
|
|
457
|
+
['POST', 'requested_reviewers', '{"reviewers":["alice"]}'], ['PUT', 'update-branch', '{}'],
|
|
458
|
+
];
|
|
459
|
+
for (const [method, door, body] of writes) {
|
|
460
|
+
if ((await applyGithubWrite({ method, path: `/repos/${REPO}/pulls/1/${door}`, body, root, occurredAt: '2026-04-04T00:00:00Z' })).response.status !== 404) return false;
|
|
461
|
+
if ((await applyGithubWrite({ method, path: `/repos/${REPO}/pulls/4041/${door}`, body, root, occurredAt: '2026-04-04T00:00:00Z' })).response.status !== 404) return false;
|
|
462
|
+
}
|
|
463
|
+
// …and refusing left the issue an issue: nothing promoted it into the pulls family.
|
|
464
|
+
return (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root })).status === 404
|
|
465
|
+
&& ok((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root })).status);
|
|
466
|
+
})),
|
|
231
467
|
done('github.pulls.requested_reviewers', 'pulls', 'PRs: request/remove reviewers', 'api', 'core', () =>
|
|
232
468
|
withRoot(async (root) => {
|
|
233
469
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'rr', head: 'a', base: 'main' }), root });
|
|
234
470
|
const add = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/requested_reviewers`, body: JSON.stringify({ reviewers: ['alice'] }), root });
|
|
235
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/requested_reviewers`, root });
|
|
471
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/requested_reviewers`, root });
|
|
236
472
|
return add.response.status === 201 && ok(get.status) && (get.body as { users?: unknown[] }).users?.length === 1;
|
|
237
473
|
})),
|
|
238
474
|
done('github.pulls.files', 'pulls', 'PRs: files changed (per-file diff list)', 'api', 'core', () =>
|
|
239
475
|
withRoot(async (root) => {
|
|
240
476
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'f', head: 'a', base: 'main', files: [{ filename: 'x.ts', additions: 3, deletions: 1 }] }), root });
|
|
241
|
-
const res = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
477
|
+
const res = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/files`, root });
|
|
242
478
|
return ok(res.status) && isArr(res.body) && (res.body[0] as { filename?: string }).filename === 'x.ts';
|
|
243
479
|
})),
|
|
244
480
|
done('github.pulls.commits', 'pulls', 'PRs: commits list (per-commit detail)', 'api', 'core', () =>
|
|
245
481
|
withRoot(async (root) => {
|
|
246
482
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'cm', head: 'a', base: 'main', commits: [{ sha: 'deadbeef', message: 'init' }] }), root });
|
|
247
|
-
const res = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/commits`, root });
|
|
483
|
+
const res = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/commits`, root });
|
|
248
484
|
return ok(res.status) && isArr(res.body) && res.body.length === 1;
|
|
249
485
|
})),
|
|
250
486
|
done('github.pulls.labels_assignees_milestone', 'pulls', 'PRs: labels/assignees/milestone via PATCH', 'api', 'core', () =>
|
|
251
487
|
withRoot(async (root) => {
|
|
488
|
+
// Names three subjects, asserts three (and the read-back agrees with the write).
|
|
489
|
+
type Pr = { labels?: Array<{ name?: string }>; assignees?: Array<{ login?: string }>; milestone?: { number?: number } | null };
|
|
490
|
+
const ms = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/milestones`, body: JSON.stringify({ title: 'v1' }), root });
|
|
491
|
+
const msNum = (ms.response.body as { number?: number }).number;
|
|
252
492
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'la', head: 'a', base: 'main' }), root });
|
|
253
|
-
const res = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ labels: ['bug'], assignees: ['bob'] }), root });
|
|
254
|
-
|
|
493
|
+
const res = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ labels: ['bug'], assignees: ['bob'], milestone: msNum }), root });
|
|
494
|
+
const seen = (b: unknown) => `${((b as Pr).labels ?? []).map((l) => l.name).join(',')}|${((b as Pr).assignees ?? []).map((a) => a.login).join(',')}|${(b as Pr).milestone?.number ?? ''}`;
|
|
495
|
+
return ok(res.response.status) && seen(res.response.body) === `bug|bob|${msNum}`
|
|
496
|
+
&& seen((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root })).body) === `bug|bob|${msNum}`;
|
|
255
497
|
})),
|
|
256
|
-
done('github.pulls.draft', 'pulls', 'PRs: draft
|
|
498
|
+
done('github.pulls.draft', 'pulls', 'PRs: native GraphQL draft/ready transitions preserve the PR and block draft merges', 'api', 'common', () =>
|
|
257
499
|
withRoot(async (root) => {
|
|
258
500
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'd', head: 'a', base: 'main', draft: true }), root });
|
|
259
501
|
if ((c.response.body as { draft?: boolean }).draft !== true) return false;
|
|
260
502
|
// a draft cannot merge → 405; ready_for_review (draft:false) then merges.
|
|
261
503
|
const blocked = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: '{}', root });
|
|
262
504
|
if (blocked.response.status !== 405) return false;
|
|
263
|
-
|
|
264
|
-
|
|
505
|
+
// GraphQL through the twin's own door, as a client sends it
|
|
506
|
+
const gql = async (query: string, variables: Record<string, unknown>) => (await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables }), root })).response.body as { data?: any; errors?: unknown[] };
|
|
507
|
+
const ready = await gql('mutation($input:MarkPullRequestReadyForReviewInput!){markPullRequestReadyForReview(input:$input){pullRequest{isDraft}}}', { input: { pullRequestId: `PR_${REPO}#1` } });
|
|
508
|
+
if (ready.errors || ready.data?.markPullRequestReadyForReview?.pullRequest?.isDraft !== false) return false;
|
|
509
|
+
const restDraft = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ draft: true }), root });
|
|
510
|
+
if ((restDraft.response.body as { draft?: boolean }).draft !== false) return false;
|
|
511
|
+
// the conversion announces pull_request converted_to_draft: a hook's delivery records it
|
|
512
|
+
const hook = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/hooks`, body: JSON.stringify({ events: ['pull_request'], config: { url: 'http://127.0.0.1:9/draft' } }), root })).response.body as { id: number };
|
|
513
|
+
const draft = await gql('mutation($input:ConvertPullRequestToDraftInput!){convertPullRequestToDraft(input:$input){pullRequest{id number isDraft}}}', { input: { pullRequestId: `PR_${REPO}#1` } });
|
|
514
|
+
const returned = draft.data?.convertPullRequestToDraft?.pullRequest;
|
|
515
|
+
const deliveries = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/hooks/${hook.id}/deliveries`, root })).body as Array<{ event?: string; action?: string }>;
|
|
516
|
+
if (draft.errors || returned?.id !== `PR_${REPO}#1` || returned?.number !== 1 || returned?.isDraft !== true || !deliveries.some((d) => d.event === 'pull_request' && d.action === 'converted_to_draft')) return false;
|
|
517
|
+
if (((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root })).body as { draft?: boolean }).draft !== true) return false;
|
|
518
|
+
if ((await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: '{}', root })).response.status !== 405) return false;
|
|
519
|
+
await gql('mutation($input:MarkPullRequestReadyForReviewInput!){markPullRequestReadyForReview(input:$input){pullRequest{isDraft}}}', { input: { pullRequestId: `PR_${REPO}#1` } });
|
|
265
520
|
const merged = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: '{}', root });
|
|
266
521
|
return merged.response.status === 200;
|
|
267
522
|
})),
|
|
@@ -275,7 +530,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
275
530
|
if (reply.response.status !== 201) return false;
|
|
276
531
|
const resolve = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/comments/${cid}/resolve`, body: '{}', root });
|
|
277
532
|
if (!(ok(resolve.response.status) && (resolve.response.body as { resolved?: boolean }).resolved === true)) return false;
|
|
278
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/threads`, root });
|
|
533
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/threads`, root });
|
|
279
534
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { is_resolved?: boolean }).is_resolved === true)) return false;
|
|
280
535
|
const unresolve = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/pulls/1/comments/${cid}/resolve`, root });
|
|
281
536
|
return ok(unresolve.response.status) && (unresolve.response.body as { resolved?: boolean }).resolved === false;
|
|
@@ -291,18 +546,30 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
291
546
|
const m = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/merge`, body: JSON.stringify({ merge_method: 'rebase' }), root });
|
|
292
547
|
return m.response.status === 200 && (m.response.body as { merged?: boolean }).merged === true;
|
|
293
548
|
})),
|
|
294
|
-
done('github.pulls.update_branch', 'pulls', 'PRs:
|
|
295
|
-
withRoot(async (root) => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
await
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
549
|
+
done('github.pulls.update_branch', 'pulls', 'PRs: mergeability from the branches\' history + update branch', 'api', 'common', () =>
|
|
550
|
+
withRoot(async (root) => {
|
|
551
|
+
const R = HISTORY_REPO;
|
|
552
|
+
const put = (path: string, text: string, branch: string, sha?: string) => applyGithubWrite({ method: 'PUT', path: `/repos/${R}/contents/${path}`, body: JSON.stringify({ message: `edit ${path}`, content: Buffer.from(text).toString('base64'), branch, ...(sha ? { sha } : {}) }), root });
|
|
553
|
+
const shaOf = async (path: string, ref: string) => ((await handleGithubRequest({ method: 'GET', path: `/repos/${R}/contents/${path}?ref=${ref}`, root })).body as { sha: string }).sha;
|
|
554
|
+
await pushFiles(root, { 'notes.txt': 'line one\n' });
|
|
555
|
+
const base = ((await handleGithubRequest({ method: 'GET', path: `/repos/${R}/git/ref/heads/main`, root })).body as { object: { sha: string } }).object.sha;
|
|
556
|
+
for (const name of ['clash', 'extra']) await applyGithubWrite({ method: 'POST', path: `/repos/${R}/git/refs`, body: JSON.stringify({ ref: `refs/heads/${name}`, sha: base }), root });
|
|
557
|
+
// one branch changes the line main changes too; the other adds a file of its own
|
|
558
|
+
await put('notes.txt', 'line one, from the branch\n', 'clash', await shaOf('notes.txt', 'clash'));
|
|
559
|
+
await put('extra.txt', 'more\n', 'extra');
|
|
560
|
+
await put('notes.txt', 'line one, from main\n', 'main', await shaOf('notes.txt', 'main'));
|
|
561
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${R}/pulls`, body: JSON.stringify({ title: 'clash', head: 'clash', base: 'main' }), root });
|
|
562
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${R}/pulls`, body: JSON.stringify({ title: 'extra', head: 'extra', base: 'main' }), root });
|
|
563
|
+
const dirty = await handleGithubRequest({ method: 'GET', path: `/repos/${R}/pulls/1`, root });
|
|
564
|
+
if ((dirty.body as { mergeable?: boolean | null }).mergeable !== false || (dirty.body as { mergeable_state?: string }).mergeable_state !== 'dirty') return false;
|
|
565
|
+
const cleanPr = await handleGithubRequest({ method: 'GET', path: `/repos/${R}/pulls/2`, root });
|
|
566
|
+
if ((cleanPr.body as { mergeable?: boolean }).mergeable !== true) return false;
|
|
567
|
+
// bringing the clean one up to date merges main into its branch
|
|
568
|
+
const before = (cleanPr.body as { head?: { sha?: string } }).head?.sha;
|
|
569
|
+
const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${R}/pulls/2/update-branch`, body: '{}', root });
|
|
303
570
|
if (upd.response.status !== 202) return false;
|
|
304
|
-
const
|
|
305
|
-
return (
|
|
571
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${R}/pulls/2`, root });
|
|
572
|
+
return (after.body as { mergeable_state?: string }).mergeable_state === 'clean' && (after.body as { head?: { sha?: string } }).head?.sha !== before;
|
|
306
573
|
})),
|
|
307
574
|
|
|
308
575
|
// ── Issues ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -314,57 +581,223 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
314
581
|
done('github.issues.get_list', 'issues', 'Issues: get + list', 'api', 'core', () =>
|
|
315
582
|
withRoot(async (root) => {
|
|
316
583
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'i1' }), root });
|
|
317
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues`, root });
|
|
318
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
584
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues`, root });
|
|
585
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
319
586
|
return ok(list.status) && isArr(list.body) && list.body.length === 1 && ok(one.status);
|
|
320
587
|
})),
|
|
588
|
+
done('github.issues.list_filters', 'issues', 'Issues: list filters (state default open, labels, assignee, since, sort/direction)', 'api', 'core', () =>
|
|
589
|
+
withRoot(async (root) => {
|
|
590
|
+
const nums = async (q: string) => ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues${q}`, root })).body as Array<{ number: number }>).map((i) => i.number);
|
|
591
|
+
const eq = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
592
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'f1', labels: ['bug'], assignees: ['ann'] }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
593
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'f2' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
594
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/2`, body: JSON.stringify({ state: 'closed' }), root, occurredAt: '2026-04-05T00:00:00Z' });
|
|
595
|
+
// The DEFAULT is open — a closed issue in the unfiltered list is the bug that misleads.
|
|
596
|
+
return eq(await nums(''), [1]) && eq(await nums('?state=closed'), [2]) && eq(await nums('?state=all'), [2, 1])
|
|
597
|
+
&& eq(await nums('?state=all&labels=bug'), [1]) && eq(await nums('?state=all&assignee=ann'), [1])
|
|
598
|
+
&& eq(await nums('?state=all&assignee=none'), [2]) && eq(await nums('?state=all&since=2026-04-04T00:00:00Z'), [2])
|
|
599
|
+
&& eq(await nums('?state=all&sort=created&direction=asc'), [1, 2])
|
|
600
|
+
// sort=comments orders by the count the issue actually carries — two comments on #2,
|
|
601
|
+
// none on #1 — which is only answerable because the count is derived from the comment
|
|
602
|
+
// store rather than hardcoded 0.
|
|
603
|
+
&& (await (async () => {
|
|
604
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/2/comments`, body: JSON.stringify({ body: 'a' }), root, occurredAt: '2026-04-06T00:00:00Z' });
|
|
605
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/2/comments`, body: JSON.stringify({ body: 'b' }), root, occurredAt: '2026-04-07T00:00:00Z' });
|
|
606
|
+
return eq(await nums('?state=all&sort=comments'), [2, 1]) && eq(await nums('?state=all&sort=comments&direction=asc'), [1, 2]);
|
|
607
|
+
})());
|
|
608
|
+
})),
|
|
609
|
+
todo('github.issues.list_filters_unmodeled', 'issues', 'Issues: list filters creator/mentioned (the issue author and body mentions are unmodeled — declared deviation issue.user). sort=comments IS served: the issue carries its real comment count', 'api', 'niche'),
|
|
321
610
|
done('github.issues.edit_state', 'issues', 'Issues: edit + close/reopen (state)', 'api', 'core', () =>
|
|
322
611
|
withRoot(async (root) => {
|
|
323
612
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'e' }), root });
|
|
324
613
|
const res = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'closed' }), root });
|
|
325
614
|
return ok(res.response.status) && (res.response.body as { state?: string }).state === 'closed';
|
|
326
615
|
})),
|
|
327
|
-
done('github.issues.comments', 'issues', 'Issues: comments create + list', 'api', 'core', () =>
|
|
328
|
-
withRoot(async (root) => {
|
|
329
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'c' }), root });
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
616
|
+
done('github.issues.comments', 'issues', 'Issues: comments create + list, and the issue\'s own `comments` count', 'api', 'core', () =>
|
|
617
|
+
withRoot(async (root) => {
|
|
618
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'c' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
619
|
+
const count = async (): Promise<unknown> => ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root })).body as { comments?: number }).comments;
|
|
620
|
+
// Before any comment: the honest zero, which is also what a hardcoded 0 would say — so
|
|
621
|
+
// the assertion that matters is the one AFTER the write.
|
|
622
|
+
if (await count() !== 0) return false;
|
|
623
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'me too' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
624
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/comments`, root });
|
|
625
|
+
if (!(c.response.status === 201 && ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
626
|
+
// The count and the list are one answer: an issue whose thread has a comment may not
|
|
627
|
+
// read `comments: 0` — every consumer that decides whether to open a thread reads it.
|
|
628
|
+
if (await count() !== 1) return false;
|
|
629
|
+
const cid = (c.response.body as { id?: number }).id;
|
|
630
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/comments/${cid}`, root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
631
|
+
return del.response.status === 204 && await count() === 0;
|
|
632
|
+
})),
|
|
633
|
+
done('github.issues.comment_get_edit_delete', 'issues', 'Issues: one comment by its own id — GET / PATCH (body + updated_at) / DELETE (204) at .../issues/comments/:id', 'api', 'core', () =>
|
|
634
|
+
withRoot(async (root) => {
|
|
635
|
+
// The per-comment door and the thread list must serve ONE object: an edit through the
|
|
636
|
+
// door has to be what the list shows, and a delete has to empty the list. Two doors that
|
|
637
|
+
// disagree about the same comment is the defect this asserts against.
|
|
638
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'c' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
639
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'first draft' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
640
|
+
const cid = (c.response.body as { id?: number }).id;
|
|
641
|
+
const got = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/comments/${cid}`, root });
|
|
642
|
+
if (!(ok(got.status) && (got.body as { body?: string }).body === 'first draft')) return false;
|
|
643
|
+
const ed = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/comments/${cid}`, body: JSON.stringify({ body: 'second draft' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
644
|
+
const edited = ed.response.body as { body?: string; created_at?: string; updated_at?: string };
|
|
645
|
+
if (!(ok(ed.response.status) && edited.body === 'second draft' && edited.updated_at === '2026-04-03T00:00:00Z' && edited.created_at === '2026-04-02T00:00:00Z')) return false;
|
|
646
|
+
// the THREAD shows the edit, and shows exactly one comment (an edit is not a second comment)
|
|
647
|
+
const thread = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/comments`, root });
|
|
648
|
+
if (!(isArr(thread.body) && thread.body.length === 1 && (thread.body[0] as { body?: string }).body === 'second draft')) return false;
|
|
649
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/comments/${cid}`, root, occurredAt: '2026-04-04T00:00:00Z' });
|
|
650
|
+
if (del.response.status !== 204) return false;
|
|
651
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/comments/${cid}`, root })).status !== 404) return false;
|
|
652
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/comments`, root });
|
|
653
|
+
return isArr(after.body) && after.body.length === 0
|
|
654
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/comments/99999`, root })).status === 404;
|
|
655
|
+
})),
|
|
656
|
+
done('github.issues.write_requires_parent', 'issues', 'Issues/PRs: a sub-resource write addressed at an id nobody minted is 404, never a success against nothing', 'api', 'core', () =>
|
|
657
|
+
withRoot(async (root) => {
|
|
658
|
+
// The worst failure shape a twin has: 201 for a comment on an issue that does not exist,
|
|
659
|
+
// whose object is then unreachable from every read door. Every write whose PATH names a
|
|
660
|
+
// parent has to refuse when the parent is absent.
|
|
661
|
+
const refuses = async (method: string, path: string, body: unknown) =>
|
|
662
|
+
(await applyGithubWrite({ method, path, body: JSON.stringify(body), root })).response.status === 404;
|
|
663
|
+
return await refuses('POST', `/repos/${REPO}/issues/77/comments`, { body: 'x' })
|
|
664
|
+
&& await refuses('POST', `/repos/${REPO}/pulls/77/comments`, { body: 'x', path: 'a.ts', line: 1 })
|
|
665
|
+
&& await refuses('POST', `/repos/${REPO}/pulls/77/reviews`, { event: 'APPROVE' })
|
|
666
|
+
&& await refuses('POST', `/repos/${REPO}/issues/77/reactions`, { content: '+1' })
|
|
667
|
+
&& await refuses('POST', `/repos/${REPO}/issues/comments/77/reactions`, { content: '+1' })
|
|
668
|
+
&& await refuses('PATCH', `/repos/${REPO}/pulls/77`, { title: 'x' })
|
|
669
|
+
&& await refuses('POST', `/repos/${REPO}/issues/77/labels`, { labels: ['bug'] })
|
|
670
|
+
&& await refuses('PUT', `/repos/${REPO}/issues/77/lock`, { lock_reason: 'spam' })
|
|
671
|
+
&& await refuses('POST', `/repos/${REPO}/issues/77/sub_issues`, { sub_issue_id: 78 })
|
|
672
|
+
// …while a TOP-LEVEL create in an empty world is supposed to succeed: it names no parent.
|
|
673
|
+
&& (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'top-level' }), root })).response.status === 201;
|
|
674
|
+
})),
|
|
675
|
+
done('github.issues.read_requires_parent', 'issues', 'Issues/PRs: a sub-resource READ addressed at an issue nobody filed is 404, never an empty list — and every one of those doors answers for a pull request number, which IS an issue', 'api', 'core', () =>
|
|
676
|
+
withRoot(async (root) => {
|
|
677
|
+
// The read half of write_requires_parent, and the same lie told the other way round:
|
|
678
|
+
// `200 []` for the timeline of issue 4041 says the issue is real and simply quiet, so a
|
|
679
|
+
// typo'd number, a dead link and a genuinely empty issue are one answer and nothing ever
|
|
680
|
+
// surfaces the mistake. GET .../issues/4041 itself already 404'd, which made it worse —
|
|
681
|
+
// the object was missing and its sub-resources were serene.
|
|
682
|
+
const subs = ['timeline', 'events', 'comments', 'labels', 'reactions', 'sub_issues'];
|
|
683
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'filed' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
684
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'a pull request', head: 'a', base: 'main' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
685
|
+
for (const s of subs) {
|
|
686
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/4041/${s}`, root })).status !== 404) return false;
|
|
687
|
+
// The issue that WAS filed answers 200 on every one of them…
|
|
688
|
+
if (!ok((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/${s}`, root })).status)) return false;
|
|
689
|
+
// …and so does the pull request, at its issue address: a pull request IS an issue, and
|
|
690
|
+
// `labels` used to be the one door in this family that forgot it and 404'd a real PR.
|
|
691
|
+
if (!ok((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/2/${s}`, root })).status)) return false;
|
|
692
|
+
}
|
|
693
|
+
return true;
|
|
694
|
+
})),
|
|
695
|
+
done('github.issues.pull_requests_are_issues', 'issues', 'Issues: the list/get include pull requests (each carrying the `pull_request` key), and GET .../pulls returns only pull requests', 'api', 'core', () =>
|
|
696
|
+
withRoot(async (root) => {
|
|
697
|
+
// The relationship is ONE-DIRECTIONAL and the twin used to have it backwards: an issue
|
|
698
|
+
// that had been commented on appeared in GET .../pulls, and no pull request appeared in
|
|
699
|
+
// GET .../issues. Both directions are asserted here so neither can drift back.
|
|
700
|
+
type Row = { number?: number; pull_request?: unknown };
|
|
701
|
+
const rows = (b: unknown) => (b as Row[]) ?? [];
|
|
702
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'an issue' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
703
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'a comment on it' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
704
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'a pull request', head: 'feat', base: 'main' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
705
|
+
const pulls = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls`, root });
|
|
706
|
+
if (!(isArr(pulls.body) && rows(pulls.body).length === 1 && rows(pulls.body)[0]!.number === 2)) return false;
|
|
707
|
+
const issues = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues`, root });
|
|
708
|
+
const got = rows(issues.body);
|
|
709
|
+
if (got.length !== 2) return false;
|
|
710
|
+
if (got.filter((i) => i.pull_request).map((i) => i.number).join(',') !== '2') return false;
|
|
711
|
+
if (got.filter((i) => !i.pull_request).map((i) => i.number).join(',') !== '1') return false;
|
|
712
|
+
// …and the pull request answers at its ISSUE address too, still carrying the key.
|
|
713
|
+
const asIssue = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/2`, root });
|
|
714
|
+
return ok(asIssue.status) && !!(asIssue.body as Row).pull_request;
|
|
715
|
+
})),
|
|
716
|
+
done('github.issues.labels_assignees_milestone', 'issues', 'Issues: labels/assignees/milestone — on create, on PATCH, and via the .../labels sub-resource', 'api', 'core', () =>
|
|
717
|
+
withRoot(async (root) => {
|
|
718
|
+
// Three subjects named, three subjects asserted, on every door that writes them. The
|
|
719
|
+
// old verify PATCHed and checked assignees.length alone — which is how a create that
|
|
720
|
+
// dropped all three, and an unserved .../labels route, both survived a green claim.
|
|
721
|
+
type Iss = { labels?: Array<{ name?: string }>; assignees?: Array<{ login?: string }>; milestone?: { number?: number } | null };
|
|
722
|
+
const names = (b: unknown) => ((b as Iss).labels ?? []).map((l) => l.name).join(',');
|
|
723
|
+
const logins = (b: unknown) => ((b as Iss).assignees ?? []).map((a) => a.login).join(',');
|
|
724
|
+
const ms = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/milestones`, body: JSON.stringify({ title: 'v1' }), root });
|
|
725
|
+
const msNum = (ms.response.body as { number?: number }).number;
|
|
726
|
+
// CREATE carries the trio.
|
|
727
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'la', labels: ['p1'], assignees: ['ann'], milestone: msNum }), root });
|
|
728
|
+
if (c.response.status !== 201) return false;
|
|
729
|
+
if (!(names(c.response.body) === 'p1' && logins(c.response.body) === 'ann' && (c.response.body as Iss).milestone?.number === msNum)) return false;
|
|
730
|
+
// …and the read agrees with the create response.
|
|
731
|
+
const got = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
732
|
+
if (!(names(got.body) === 'p1' && logins(got.body) === 'ann' && (got.body as Iss).milestone?.number === msNum)) return false;
|
|
733
|
+
// PATCH REPLACES all three (milestone: null clears it).
|
|
734
|
+
const patched = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['p2', 'bug'], assignees: ['bob'], milestone: null }), root });
|
|
735
|
+
if (!ok(patched.response.status)) return false;
|
|
736
|
+
if (!(names(patched.response.body) === 'p2,bug' && logins(patched.response.body) === 'bob' && (patched.response.body as Iss).milestone === null)) return false;
|
|
737
|
+
// The labels sub-resource: add (union) → list → remove one → remove all.
|
|
738
|
+
const add = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/labels`, body: JSON.stringify({ labels: ['p2', 'p3'] }), root });
|
|
739
|
+
if (!(ok(add.response.status) && isArr(add.response.body) && add.response.body.map((l) => (l as { name?: string }).name).join(',') === 'p2,bug,p3')) return false;
|
|
740
|
+
const listed = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/labels`, root });
|
|
741
|
+
if (!(ok(listed.status) && isArr(listed.body) && listed.body.length === 3)) return false;
|
|
742
|
+
const one = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/labels/bug`, root });
|
|
743
|
+
if (!(ok(one.response.status) && isArr(one.response.body) && one.response.body.map((l) => (l as { name?: string }).name).join(',') === 'p2,p3')) return false;
|
|
744
|
+
// a label that is not on the issue → 404, and the sub-resource writes the SAME field
|
|
745
|
+
// the issue object reads (one model, two doors).
|
|
746
|
+
if ((await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/labels/nope`, root })).response.status !== 404) return false;
|
|
747
|
+
if (names((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root })).body) !== 'p2,p3') return false;
|
|
748
|
+
const all = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/labels`, root });
|
|
749
|
+
return all.response.status === 204 && names((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root })).body) === '';
|
|
339
750
|
})),
|
|
340
751
|
done('github.issues.timeline_crossref', 'issues', 'Issues: timeline/events (cross-reference links)', 'api', 'common', () =>
|
|
341
752
|
withRoot(async (root) => {
|
|
342
753
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'link' }), root });
|
|
343
754
|
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ _twin_linked: [{ type: 'pull_request', number: 2 }] }), root });
|
|
344
|
-
const res = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/timeline`, root });
|
|
755
|
+
const res = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/timeline`, root });
|
|
345
756
|
return ok(res.status) && isArr(res.body) && res.body.length === 1;
|
|
346
757
|
})),
|
|
347
|
-
done('github.issues.state_reasons', 'issues', 'Issues: state reasons (completed/not_planned/duplicate)', 'api', 'common', () =>
|
|
348
|
-
withRoot(async (root) => {
|
|
349
|
-
await
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
758
|
+
done('github.issues.state_reasons', 'issues', 'Issues: state reasons (completed/not_planned/duplicate, and reopened on reopen)', 'api', 'common', () =>
|
|
759
|
+
withRoot(async (root) => {
|
|
760
|
+
const sr = async (): Promise<{ state?: string; state_reason?: string | null }> => (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root })).body as { state?: string; state_reason?: string | null };
|
|
761
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'sr' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
762
|
+
const r = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'closed', state_reason: 'not_planned' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
763
|
+
if (!(ok(r.response.status) && (r.response.body as { state_reason?: string }).state_reason === 'not_planned' && (await sr()).state === 'closed')) return false;
|
|
764
|
+
// Closing is only half of it. REOPEN, and the reason must move with the state: real
|
|
765
|
+
// GitHub sets state_reason to `reopened`. Asserting only the close leaves the twin free
|
|
766
|
+
// to serve an issue that reads "open, and not_planned" — a contradiction, and the exact
|
|
767
|
+
// shape of the defect this verify now covers.
|
|
768
|
+
const back = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'open' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
769
|
+
if (!(ok(back.response.status) && (back.response.body as { state_reason?: string }).state_reason === 'reopened')) return false;
|
|
770
|
+
if (!((await sr()).state === 'open' && (await sr()).state_reason === 'reopened')) return false;
|
|
771
|
+
// An explicit reason still wins over the default, in either direction.
|
|
772
|
+
const dup = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'closed', state_reason: 'duplicate' }), root, occurredAt: '2026-04-04T00:00:00Z' });
|
|
773
|
+
if (!(ok(dup.response.status) && (await sr()).state_reason === 'duplicate')) return false;
|
|
774
|
+
// And the DEFAULT close — no reason given, which is what nearly every close is — is
|
|
775
|
+
// `completed` on real GitHub, the matching half of the reopen rule above. Left null, a
|
|
776
|
+
// consumer that tells done from abandoned by completed-vs-not_planned sees neither.
|
|
777
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'plain close' }), root, occurredAt: '2026-04-05T00:00:00Z' });
|
|
778
|
+
const plain = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/2`, body: JSON.stringify({ state: 'closed' }), root, occurredAt: '2026-04-06T00:00:00Z' });
|
|
779
|
+
if (!(ok(plain.response.status) && (plain.response.body as { state_reason?: string }).state_reason === 'completed')) return false;
|
|
780
|
+
const two = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/2`, root })).body as { state?: string; state_reason?: string | null };
|
|
781
|
+
// Only the TRANSITION defaults: re-stating `closed` on an already-closed issue must not
|
|
782
|
+
// overwrite the reason it was closed for.
|
|
783
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'closed' }), root, occurredAt: '2026-04-07T00:00:00Z' });
|
|
784
|
+
return two.state === 'closed' && two.state_reason === 'completed' && (await sr()).state_reason === 'duplicate';
|
|
353
785
|
})),
|
|
354
786
|
done('github.issues.types', 'issues', 'Issues: issue types (org-level)', 'api', 'niche', () =>
|
|
355
787
|
withRoot(async (root) => {
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/issue-types`, body: JSON.stringify({ name: 'Bug', color: 'red', description: 'A defect' }), root });
|
|
359
|
-
if (!(c.response.status === 201 && (c.response.body as { name?: string }).name === 'Bug')) return false;
|
|
360
|
-
const id = (c.response.body as { id?: number }).id!;
|
|
788
|
+
// name and is_enabled are required (422 without); create → listed; duplicate name 422s; a PUT
|
|
789
|
+
// replaces it; delete → gone.
|
|
361
790
|
if ((await applyGithubWrite({ method: 'POST', path: `/orgs/octo/issue-types`, body: JSON.stringify({ name: 'Bug' }), root })).response.status !== 422) return false;
|
|
362
|
-
const
|
|
791
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/issue-types`, body: JSON.stringify({ name: 'Bug', is_enabled: true, color: 'red', description: 'A defect' }), root });
|
|
792
|
+
if (!(c.response.status === 200 && (c.response.body as { name?: string }).name === 'Bug')) return false;
|
|
793
|
+
const id = (c.response.body as { id?: number }).id!;
|
|
794
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/orgs/octo/issue-types`, body: JSON.stringify({ name: 'Bug', is_enabled: true }), root })).response.status !== 422) return false;
|
|
795
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/issue-types`, root });
|
|
363
796
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
364
|
-
const e = await applyGithubWrite({ method: '
|
|
797
|
+
const e = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/issue-types/${id}`, body: JSON.stringify({ name: 'Bug', is_enabled: true, description: 'updated' }), root });
|
|
365
798
|
if ((e.response.body as { description?: string }).description !== 'updated') return false;
|
|
366
799
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/issue-types/${id}`, root });
|
|
367
|
-
const after = handleGithubRequest({ method: 'GET', path: `/orgs/octo/issue-types`, root });
|
|
800
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/issue-types`, root });
|
|
368
801
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 0;
|
|
369
802
|
})),
|
|
370
803
|
done('github.issues.subissues', 'issues', 'Issues: sub-issues (parent/child hierarchy)', 'api', 'common', () =>
|
|
@@ -373,10 +806,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
373
806
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'child' }), root });
|
|
374
807
|
const add = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/sub_issues`, body: JSON.stringify({ sub_issue_number: 2 }), root });
|
|
375
808
|
if (add.response.status !== 201) return false;
|
|
376
|
-
const subs = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/sub_issues`, root });
|
|
809
|
+
const subs = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/sub_issues`, root });
|
|
377
810
|
if (!(ok(subs.status) && isArr(subs.body) && subs.body.length === 1 && (subs.body[0] as { number?: number }).number === 2)) return false;
|
|
378
811
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/sub_issue`, body: JSON.stringify({ sub_issue_number: 2 }), root });
|
|
379
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/sub_issues`, root });
|
|
812
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/sub_issues`, root });
|
|
380
813
|
return ok(del.response.status) && isArr(after.body) && after.body.length === 0;
|
|
381
814
|
})),
|
|
382
815
|
done('github.issues.reactions', 'issues', 'Issues/comments: reactions (emoji)', 'api', 'common', () =>
|
|
@@ -387,12 +820,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
387
820
|
if (bad.response.status !== 422) return false;
|
|
388
821
|
const r = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/reactions`, body: JSON.stringify({ content: 'heart' }), root });
|
|
389
822
|
if (r.response.status !== 201) return false;
|
|
390
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/reactions`, root });
|
|
823
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/reactions`, root });
|
|
391
824
|
// comment reactions too: create a comment, react to it.
|
|
392
825
|
const cm = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'hi' }), root });
|
|
393
826
|
const cid = (cm.response.body as { id?: number }).id!;
|
|
394
827
|
const cr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/comments/${cid}/reactions`, body: JSON.stringify({ content: 'rocket' }), root });
|
|
395
|
-
const clist = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/comments/${cid}/reactions`, root });
|
|
828
|
+
const clist = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/comments/${cid}/reactions`, root });
|
|
396
829
|
return ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { content?: string }).content === 'heart'
|
|
397
830
|
&& cr.response.status === 201 && isArr(clist.body) && clist.body.length === 1;
|
|
398
831
|
})),
|
|
@@ -401,10 +834,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
401
834
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'lock' }), root });
|
|
402
835
|
const lock = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/issues/1/lock`, body: JSON.stringify({ lock_reason: 'off-topic' }), root });
|
|
403
836
|
if (lock.response.status !== 204) return false;
|
|
404
|
-
const locked = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
837
|
+
const locked = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
405
838
|
if (!((locked.body as { locked?: boolean }).locked === true && (locked.body as { active_lock_reason?: string }).active_lock_reason === 'off-topic')) return false;
|
|
406
839
|
const unlock = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/lock`, root });
|
|
407
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
840
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
408
841
|
return unlock.response.status === 204 && (after.body as { locked?: boolean }).locked === false;
|
|
409
842
|
})),
|
|
410
843
|
done('github.issues.transfer', 'issues', 'Issues: transfer to another repo', 'api', 'niche', () =>
|
|
@@ -416,9 +849,9 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
416
849
|
const t = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/transfer`, body: JSON.stringify({ new_repository: 'octo/other' }), root });
|
|
417
850
|
if (!(t.response.status === 201 && (t.response.body as { title?: string }).title === 'moves')) return false;
|
|
418
851
|
const newNum = (t.response.body as { number?: number }).number!;
|
|
419
|
-
const moved = handleGithubRequest({ method: 'GET', path: `/repos/octo/other/issues/${newNum}`, root });
|
|
852
|
+
const moved = await handleGithubRequest({ method: 'GET', path: `/repos/octo/other/issues/${newNum}`, root });
|
|
420
853
|
if (!(ok(moved.status) && (moved.body as { title?: string }).title === 'moves')) return false;
|
|
421
|
-
const src = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
854
|
+
const src = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
422
855
|
return (src.body as { state?: string }).state === 'closed' && (src.body as { state_reason?: string }).state_reason === 'transferred';
|
|
423
856
|
})),
|
|
424
857
|
done('github.issues.pin', 'issues', 'Issues: pin/unpin', 'api', 'niche', () =>
|
|
@@ -428,20 +861,30 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
428
861
|
if ((await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/issues/99/pin`, body: '{}', root })).response.status !== 404) return false;
|
|
429
862
|
const pin = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/issues/1/pin`, body: '{}', root });
|
|
430
863
|
if (pin.response.status !== 204) return false;
|
|
431
|
-
const pinned = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
864
|
+
const pinned = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
432
865
|
if ((pinned.body as { pinned?: boolean }).pinned !== true) return false;
|
|
433
866
|
const unpin = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/issues/1/pin`, root });
|
|
434
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
867
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
|
|
435
868
|
return unpin.response.status === 204 && (after.body as { pinned?: boolean }).pinned === false;
|
|
436
869
|
})),
|
|
437
870
|
done('github.issues.full_timeline', 'issues', 'Issues: full timeline events (labeled/assigned/renamed/…)', 'api', 'common', () =>
|
|
438
871
|
withRoot(async (root) => {
|
|
439
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'tl' }), root });
|
|
440
|
-
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['bug'], assignees: ['bob'], state: 'closed', state_reason: 'completed' }), root });
|
|
441
|
-
|
|
872
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'tl' }), root, occurredAt: '2026-04-01T00:00:00Z' });
|
|
873
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['bug'], assignees: ['bob'], state: 'closed', state_reason: 'completed' }), root, occurredAt: '2026-04-02T00:00:00Z' });
|
|
874
|
+
// …then REOPEN it. Closing and reopening are the two most consequential things that
|
|
875
|
+
// happen to an issue and the only ones that leave no trace in the present state: a
|
|
876
|
+
// timeline derived from `state` alone loses both and still reads complete.
|
|
877
|
+
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'open' }), root, occurredAt: '2026-04-03T00:00:00Z' });
|
|
878
|
+
const tl = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/timeline`, root });
|
|
442
879
|
if (!(ok(tl.status) && isArr(tl.body))) return false;
|
|
443
|
-
const events = (tl.body as Array<{ event?: string }>)
|
|
444
|
-
|
|
880
|
+
const events = (tl.body as Array<{ event?: string; state_reason?: string | null }>);
|
|
881
|
+
const names = events.map((e) => e.event);
|
|
882
|
+
if (!(names.includes('labeled') && names.includes('assigned') && names.includes('closed') && names.includes('reopened'))) return false;
|
|
883
|
+
if (events.find((e) => e.event === 'closed')?.state_reason !== 'completed') return false;
|
|
884
|
+
// the typed events endpoint is built from the same history, so it carries them too
|
|
885
|
+
const ev = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/events`, root });
|
|
886
|
+
const evNames = (ev.body as Array<{ event?: string }>).map((e) => e.event);
|
|
887
|
+
return isArr(ev.body) && evNames.includes('closed') && evNames.includes('reopened');
|
|
445
888
|
})),
|
|
446
889
|
|
|
447
890
|
// ── Labels / Milestones ──────────────────────────────────────────────────────────────────
|
|
@@ -449,9 +892,18 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
449
892
|
withRoot(async (root) => {
|
|
450
893
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/milestones`, body: JSON.stringify({ title: 'v1' }), root });
|
|
451
894
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/milestones/1`, body: JSON.stringify({ state: 'closed' }), root });
|
|
452
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/milestones`, root });
|
|
895
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/milestones`, root });
|
|
453
896
|
return c.response.status === 201 && ok(e.response.status) && ok(list.status) && isArr(list.body) && list.body.length === 1;
|
|
454
897
|
})),
|
|
898
|
+
done('github.milestones.counts', 'fields', 'Milestones: open_issues and closed_issues count the issues and pull requests in the milestone', 'api', 'common', () =>
|
|
899
|
+
withRoot(async (root) => {
|
|
900
|
+
await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'plan' }), root });
|
|
901
|
+
const ms = (await applyGithubWrite({ method: 'POST', path: '/repos/octocat/plan/milestones', body: JSON.stringify({ title: '1.0' }), root })).response.body as { number: number };
|
|
902
|
+
for (const title of ['a', 'b', 'c']) await applyGithubWrite({ method: 'POST', path: '/repos/octocat/plan/issues', body: JSON.stringify({ title, milestone: ms.number }), root });
|
|
903
|
+
await applyGithubWrite({ method: 'PATCH', path: '/repos/octocat/plan/issues/2', body: JSON.stringify({ state: 'closed' }), root });
|
|
904
|
+
const got = (await handleGithubRequest({ method: 'GET', path: `/repos/octocat/plan/milestones/${ms.number}`, root })).body as { open_issues: number; closed_issues: number };
|
|
905
|
+
return got.open_issues === 2 && got.closed_issues === 1;
|
|
906
|
+
})),
|
|
455
907
|
done('github.labels.crud', 'fields', 'Labels: repo label CRUD (color/description)', 'api', 'core', () =>
|
|
456
908
|
withRoot(async (root) => {
|
|
457
909
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/labels`, body: JSON.stringify({ name: 'bug', color: 'd73a4a', description: 'Something broken' }), root });
|
|
@@ -461,102 +913,131 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
461
913
|
if (dup.response.status !== 422) return false;
|
|
462
914
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/labels/bug`, body: JSON.stringify({ new_name: 'defect', color: '000000' }), root });
|
|
463
915
|
if (!(ok(e.response.status) && (e.response.body as { name?: string }).name === 'defect')) return false;
|
|
464
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels`, root });
|
|
465
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels/defect`, root });
|
|
916
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels`, root });
|
|
917
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels/defect`, root });
|
|
466
918
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && ok(one.status))) return false;
|
|
467
919
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/labels/defect`, root });
|
|
468
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels/defect`, root });
|
|
920
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/labels/defect`, root });
|
|
469
921
|
return del.response.status === 204 && gone.status === 404;
|
|
470
922
|
})),
|
|
471
923
|
|
|
924
|
+
done('github.labels.defaults', 'fields', 'Labels: a new repository has GitHub\'s default labels (bug, documentation, …, wontfix), marked default', 'api', 'common', () =>
|
|
925
|
+
withRoot(async (root) => {
|
|
926
|
+
await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'fresh' }), root });
|
|
927
|
+
const labels = (await handleGithubRequest({ method: 'GET', path: '/repos/octocat/fresh/labels', root })).body as Array<{ name: string; default: boolean }>;
|
|
928
|
+
const dup = await applyGithubWrite({ method: 'POST', path: '/repos/octocat/fresh/labels', body: JSON.stringify({ name: 'bug' }), root });
|
|
929
|
+
return labels.length === 9 && labels.every((l) => l.default) && labels.some((l) => l.name === 'good first issue') && dup.response.status === 422;
|
|
930
|
+
})),
|
|
472
931
|
// ── Commit statuses + Checks ─────────────────────────────────────────────────────────────
|
|
473
932
|
done('github.statuses.create_combined', 'checks', 'Commit statuses: create + combined + list', 'api', 'core', () =>
|
|
474
933
|
withRoot(async (root) => {
|
|
475
934
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/statuses/abc123`, body: JSON.stringify({ state: 'success', context: 'ci' }), root });
|
|
476
|
-
const combined = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/status`, root });
|
|
477
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/statuses`, root });
|
|
935
|
+
const combined = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/status`, root });
|
|
936
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/statuses`, root });
|
|
478
937
|
return c.response.status === 201 && ok(combined.status) && ok(list.status) && isArr(list.body) && list.body.length === 1;
|
|
479
938
|
})),
|
|
480
|
-
done('github.
|
|
481
|
-
withRoot(async (root) => {
|
|
482
|
-
|
|
483
|
-
const
|
|
939
|
+
done('github.pulls.from_fork', 'pulls', 'PRs: a pull request from a fork (head owner:branch) reads the fork\'s branch, follows pushes to it, and merges its commits into the base', 'api', 'core', () =>
|
|
940
|
+
withRoot(async (root) => {
|
|
941
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
942
|
+
const ada = await mintToken(root, 'ada');
|
|
943
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/forks`, body: '{}', root, token: ada });
|
|
944
|
+
const fork = 'ada/ci-demo';
|
|
945
|
+
const write = async (text: string) => ((await applyGithubWrite({ method: 'PUT', path: `/repos/${fork}/contents/b.txt`, body: JSON.stringify({ message: 'b', content: Buffer.from(text).toString('base64'), ...(text === 'b2\n' ? { sha: ((await handleGithubRequest({ method: 'GET', path: `/repos/${fork}/contents/b.txt`, root, token: ada })).body as { sha: string }).sha } : {}) }), root, token: ada })).response.body as { commit: { sha: string } }).commit.sha;
|
|
946
|
+
const first = await write('b\n');
|
|
947
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'grace:main', base: 'main' }), root, token: ada })).response.status !== 422) return false;
|
|
948
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'ada:main', base: 'main' }), root, token: ada })).response.body as { number: number; head: { label: string; sha: string; repo?: { full_name: string } } };
|
|
949
|
+
if (pr.head.label !== 'ada:main' || pr.head.repo?.full_name !== fork || pr.head.sha !== first) return false;
|
|
950
|
+
const second = await write('b2\n');
|
|
951
|
+
const now = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}`, root })).body as { head: { sha: string }; commits: number };
|
|
952
|
+
const merged = await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root });
|
|
953
|
+
const commit = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/git/commits/${(merged.response.body as { sha: string }).sha}`, root })).body as { tree: { sha: string } };
|
|
954
|
+
const tree = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/git/trees/${commit.tree.sha}`, root })).body as { tree: Array<{ path: string }> };
|
|
955
|
+
return now.head.sha === second && now.commits === 2 && merged.response.status === 200 && tree.tree.some((e) => e.path === 'b.txt');
|
|
956
|
+
})),
|
|
957
|
+
done('github.checks.runs', 'checks', 'Check runs: an installed app creates them (a person\'s token is refused) + list for a ref', 'api', 'core', () =>
|
|
958
|
+
withRoot(async (root) => {
|
|
959
|
+
const token = await ciToken(root, REPO);
|
|
960
|
+
const byPerson = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'build', head_sha: 'sha9', conclusion: 'success' }), root });
|
|
961
|
+
if (byPerson.response.status !== 403) return false;
|
|
962
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'build', head_sha: 'sha9', conclusion: 'success' }), root, token });
|
|
963
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/sha9/check-runs`, root });
|
|
484
964
|
return c.response.status === 201 && ok(list.status) && (list.body as { total_count?: number }).total_count === 1;
|
|
485
965
|
})),
|
|
486
966
|
done('github.checks.suites', 'checks', 'Check suites: list/get/rerequest + GET .../check-runs/:id', 'api', 'common', () =>
|
|
487
967
|
withRoot(async (root) => {
|
|
488
|
-
const cr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'build', head_sha: 'shaS', conclusion: 'success' }), root });
|
|
968
|
+
const cr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'build', head_sha: 'shaS', conclusion: 'success' }), root, token: await ciToken(root, REPO) });
|
|
489
969
|
const runId = (cr.response.body as { id?: number }).id!;
|
|
490
970
|
const suiteId = (cr.response.body as { check_suite?: { id?: number } }).check_suite?.id!;
|
|
491
971
|
// GET a single check run; list/get the suite; suite rolls up to success; rerequest 201s.
|
|
492
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-runs/${runId}`, root });
|
|
972
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-runs/${runId}`, root });
|
|
493
973
|
if (!(ok(one.status) && (one.body as { id?: number }).id === runId)) return false;
|
|
494
|
-
const suites = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/shaS/check-suites`, root });
|
|
974
|
+
const suites = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/shaS/check-suites`, root });
|
|
495
975
|
if (!(ok(suites.status) && (suites.body as { total_count?: number }).total_count === 1)) return false;
|
|
496
|
-
const suite = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-suites/${suiteId}`, root });
|
|
976
|
+
const suite = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-suites/${suiteId}`, root });
|
|
497
977
|
if (!(ok(suite.status) && (suite.body as { conclusion?: string }).conclusion === 'success')) return false;
|
|
498
978
|
const re = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-suites/${suiteId}/rerequest`, body: '{}', root });
|
|
499
979
|
return re.response.status === 201;
|
|
500
980
|
})),
|
|
501
981
|
done('github.checks.annotations', 'checks', 'Check runs: annotations + output details', 'api', 'common', () =>
|
|
502
982
|
withRoot(async (root) => {
|
|
503
|
-
const
|
|
983
|
+
const token = await ciToken(root, REPO);
|
|
984
|
+
const cr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'lint', head_sha: 'shaA', status: 'in_progress' }), root, token });
|
|
504
985
|
const id = (cr.response.body as { id?: number }).id!;
|
|
505
|
-
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/check-runs/${id}`, body: JSON.stringify({ conclusion: 'failure', output: { title: 'Lint', summary: '1 issue', annotations: [{ path: 'a.ts', start_line: 3, end_line: 3, annotation_level: 'warning', message: 'unused var' }] } }), root });
|
|
986
|
+
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/check-runs/${id}`, body: JSON.stringify({ conclusion: 'failure', output: { title: 'Lint', summary: '1 issue', annotations: [{ path: 'a.ts', start_line: 3, end_line: 3, annotation_level: 'warning', message: 'unused var' }] } }), root, token });
|
|
506
987
|
if (!(ok(upd.response.status) && (upd.response.body as { output?: { annotations_count?: number } }).output?.annotations_count === 1)) return false;
|
|
507
|
-
const anns = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-runs/${id}/annotations`, root });
|
|
988
|
+
const anns = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/check-runs/${id}/annotations`, root });
|
|
508
989
|
return ok(anns.status) && isArr(anns.body) && anns.body.length === 1 && (anns.body[0] as { annotation_level?: string }).annotation_level === 'warning';
|
|
509
990
|
})),
|
|
510
991
|
|
|
511
992
|
// ── Actions ──────────────────────────────────────────────────────────────────────────────
|
|
512
|
-
done('github.actions.workflows', 'actions', 'Actions:
|
|
993
|
+
done('github.actions.workflows', 'actions', 'Actions: a pushed workflow file registers; list/get workflows', 'api', 'niche', () =>
|
|
513
994
|
withRoot(async (root) => {
|
|
514
|
-
|
|
515
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
516
|
-
|
|
995
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
996
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/workflows`, root });
|
|
997
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/workflows/ci.yml`, root });
|
|
998
|
+
return ok(list.status) && (list.body as { total_count?: number }).total_count === 1 && (one.body as { name?: string }).name === 'CI';
|
|
517
999
|
})),
|
|
518
1000
|
done('github.actions.dispatch_runs', 'actions', 'Actions: dispatch workflow → queued run; list runs', 'api', 'niche', () =>
|
|
519
1001
|
withRoot(async (root) => {
|
|
520
|
-
await
|
|
521
|
-
const d = await applyGithubWrite({ method: 'POST', path: `/repos/${
|
|
522
|
-
const runs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1002
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
1003
|
+
const d = await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
1004
|
+
const runs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root });
|
|
523
1005
|
return d.response.status === 204 && ok(runs.status) && (runs.body as { total_count?: number }).total_count === 1;
|
|
524
1006
|
})),
|
|
525
1007
|
done('github.actions.run_jobs', 'actions', 'Actions: run jobs + get run + rerun/cancel transitions', 'api', 'niche', () =>
|
|
526
1008
|
withRoot(async (root) => {
|
|
527
|
-
await
|
|
528
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${
|
|
529
|
-
const runs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1009
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
1010
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
1011
|
+
const runs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root });
|
|
530
1012
|
const runId = (runs.body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
531
|
-
const jobs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
532
|
-
const cancel = await applyGithubWrite({ method: 'POST', path: `/repos/${
|
|
533
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1013
|
+
const jobs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${runId}/jobs`, root });
|
|
1014
|
+
const cancel = await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/runs/${runId}/cancel`, body: '{}', root });
|
|
1015
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${runId}`, root });
|
|
534
1016
|
return ok(jobs.status) && (jobs.body as { total_count?: number }).total_count === 1 && cancel.response.status === 202 && (after.body as { conclusion?: string }).conclusion === 'cancelled';
|
|
535
1017
|
})),
|
|
536
1018
|
done('github.actions.logs', 'actions', 'Actions: download run logs (302 redirect to signed URL)', 'api', 'niche', () =>
|
|
537
1019
|
withRoot(async (root) => {
|
|
538
|
-
await
|
|
539
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${
|
|
540
|
-
const runs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1020
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
1021
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
1022
|
+
const runs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root });
|
|
541
1023
|
const runId = (runs.body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
542
1024
|
// a real run's logs 302-redirect to a download URL; an unknown run → 404.
|
|
543
|
-
const logs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1025
|
+
const logs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${runId}/logs`, root });
|
|
544
1026
|
if (!(logs.status === 302 && typeof (logs.body as { location?: string }).location === 'string')) return false;
|
|
545
|
-
return handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
546
|
-
})),
|
|
547
|
-
done('github.actions.artifacts', 'actions', 'Actions: artifacts
|
|
548
|
-
withRoot(async (root) => {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/artifacts/${id}`, root });
|
|
1027
|
+
return (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/999999/logs`, root })).status === 404;
|
|
1028
|
+
})),
|
|
1029
|
+
done('github.actions.artifacts', 'actions', 'Actions: what a finished run uploaded: artifacts list/get/delete (metadata)', 'api', 'niche', () =>
|
|
1030
|
+
withRoot(async (root) => {
|
|
1031
|
+
await pushFiles(root, { '.github/workflows/build.yml': workflowFile('Build', ' - uses: actions/upload-artifact@v4\n with:\n name: build-output\n path: dist/\n') });
|
|
1032
|
+
const run = await finishedRun(root, 'build.yml');
|
|
1033
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/artifacts`, root });
|
|
1034
|
+
const artifacts = (list.body as { total_count?: number; artifacts?: Array<{ id: number; name: string; workflow_run?: { id?: number } }> });
|
|
1035
|
+
if (!(ok(list.status) && artifacts.total_count === 1 && artifacts.artifacts![0]!.name === 'build-output' && artifacts.artifacts![0]!.workflow_run?.id === run)) return false;
|
|
1036
|
+
const id = artifacts.artifacts![0]!.id;
|
|
1037
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/artifacts/${id}`, root });
|
|
1038
|
+
if (!(ok(one.status) && (one.body as { name?: string }).name === 'build-output')) return false;
|
|
1039
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${HISTORY_REPO}/actions/artifacts/${id}`, root });
|
|
1040
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/artifacts/${id}`, root });
|
|
560
1041
|
return del.response.status === 204 && gone.status === 404;
|
|
561
1042
|
})),
|
|
562
1043
|
done('github.actions.secrets', 'actions', 'Actions: secrets (repo + org; value never returned)', 'api', 'niche', () =>
|
|
@@ -566,18 +1047,18 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
566
1047
|
if (c.response.status !== 201) return false;
|
|
567
1048
|
const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/secrets/API_KEY`, body: JSON.stringify({ encrypted_value: 'yyy', key_id: '1' }), root });
|
|
568
1049
|
if (upd.response.status !== 204) return false;
|
|
569
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets`, root });
|
|
1050
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets`, root });
|
|
570
1051
|
const secrets = (list.body as { secrets?: Array<Record<string, unknown>> }).secrets ?? [];
|
|
571
1052
|
if (!(ok(list.status) && secrets.length === 1 && secrets[0]!.name === 'API_KEY' && !('value' in secrets[0]!))) return false;
|
|
572
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets/API_KEY`, root });
|
|
1053
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets/API_KEY`, root });
|
|
573
1054
|
if (!(ok(get.status) && (get.body as { name?: string }).name === 'API_KEY')) return false;
|
|
574
1055
|
// org-scope secret is a separate namespace.
|
|
575
1056
|
const org = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/actions/secrets/ORG_TOKEN`, body: JSON.stringify({ encrypted_value: 'z' }), root });
|
|
576
1057
|
if (org.response.status !== 201) return false;
|
|
577
|
-
const orgList = handleGithubRequest({ method: 'GET', path: `/orgs/octo/actions/secrets`, root });
|
|
1058
|
+
const orgList = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/actions/secrets`, root });
|
|
578
1059
|
if (!(ok(orgList.status) && (orgList.body as { total_count?: number }).total_count === 1)) return false;
|
|
579
1060
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/actions/secrets/API_KEY`, root });
|
|
580
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets/API_KEY`, root }).status === 404;
|
|
1061
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/secrets/API_KEY`, root })).status === 404;
|
|
581
1062
|
})),
|
|
582
1063
|
done('github.actions.variables', 'actions', 'Actions: variables (repo + org; value returned)', 'api', 'niche', () =>
|
|
583
1064
|
withRoot(async (root) => {
|
|
@@ -588,32 +1069,30 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
588
1069
|
// duplicate → 409.
|
|
589
1070
|
const dup = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/variables`, body: JSON.stringify({ name: 'NODE_ENV', value: 'x' }), root });
|
|
590
1071
|
if (dup.response.status !== 409) return false;
|
|
591
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root });
|
|
1072
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root });
|
|
592
1073
|
if (!(ok(get.status) && (get.body as { value?: string }).value === 'production')) return false;
|
|
593
1074
|
// PATCH the value; re-read reflects it.
|
|
594
1075
|
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/actions/variables/NODE_ENV`, body: JSON.stringify({ value: 'staging' }), root });
|
|
595
1076
|
if (upd.response.status !== 204) return false;
|
|
596
|
-
const get2 = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root });
|
|
1077
|
+
const get2 = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root });
|
|
597
1078
|
if ((get2.body as { value?: string }).value !== 'staging') return false;
|
|
598
1079
|
// org-scope variable.
|
|
599
1080
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/actions/variables`, body: JSON.stringify({ name: 'REGION', value: 'us' }), root });
|
|
600
|
-
const orgGet = handleGithubRequest({ method: 'GET', path: `/orgs/octo/actions/variables/REGION`, root });
|
|
1081
|
+
const orgGet = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/actions/variables/REGION`, root });
|
|
601
1082
|
if ((orgGet.body as { value?: string }).value !== 'us') return false;
|
|
602
1083
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root });
|
|
603
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root }).status === 404;
|
|
1084
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/variables/NODE_ENV`, root })).status === 404;
|
|
604
1085
|
})),
|
|
605
|
-
done('github.actions.caches', 'actions', 'Actions: caches list/delete', 'api', 'niche', () =>
|
|
1086
|
+
done('github.actions.caches', 'actions', 'Actions: the caches a finished run saved: list/delete', 'api', 'niche', () =>
|
|
606
1087
|
withRoot(async (root) => {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
await
|
|
610
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/caches`, body: JSON.stringify({ key: 'node-modules-def', size_in_bytes: 2048 }), root });
|
|
611
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/caches`, root });
|
|
1088
|
+
await pushFiles(root, { '.github/workflows/build.yml': workflowFile('Build', ' - uses: actions/cache@v4\n with:\n key: node-modules-abc\n - uses: actions/cache@v4\n with:\n key: node-modules-def\n') });
|
|
1089
|
+
await finishedRun(root, 'build.yml');
|
|
1090
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/caches`, root });
|
|
612
1091
|
if (!(ok(list.status) && (list.body as { total_count?: number }).total_count === 2)) return false;
|
|
613
1092
|
// delete by key → removes one; list shrinks.
|
|
614
|
-
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${
|
|
1093
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${HISTORY_REPO}/actions/caches?key=node-modules-abc`, root });
|
|
615
1094
|
if (!(del.response.status === 200 && (del.response.body as { total_count?: number }).total_count === 1)) return false;
|
|
616
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1095
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/caches`, root });
|
|
617
1096
|
return (after.body as { total_count?: number }).total_count === 1;
|
|
618
1097
|
})),
|
|
619
1098
|
done('github.actions.environments', 'actions', 'Actions: deployment environments (alias of environments.crud)', 'api', 'niche', () =>
|
|
@@ -621,11 +1100,9 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
621
1100
|
// environments are reachable under the repo; create + list round-trips.
|
|
622
1101
|
const c = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/environments/staging`, body: JSON.stringify({ wait_timer: 5 }), root });
|
|
623
1102
|
if (c.response.status !== 200) return false;
|
|
624
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments`, root });
|
|
1103
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments`, root });
|
|
625
1104
|
return ok(list.status) && (list.body as { total_count?: number }).total_count === 1 && (list.body as { environments?: Array<{ name?: string }> }).environments?.[0]?.name === 'staging';
|
|
626
1105
|
})),
|
|
627
|
-
outOfScope('github.actions.compute', 'actions', 'Actions: real CI COMPUTE (executing the workflow)', 'api', 'niche',
|
|
628
|
-
'Out of scope: running real CI compute is infra, not API. The run/job OBJECTS (queued/rerun/cancel) are modeled (covered).'),
|
|
629
1106
|
|
|
630
1107
|
// ── Repos / settings / branches ──────────────────────────────────────────────────────────
|
|
631
1108
|
done('github.repos.crud', 'repos', 'Repos: create/get/update/delete + settings', 'api', 'common', () =>
|
|
@@ -634,41 +1111,42 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
634
1111
|
if (!(c.response.status === 201 && (c.response.body as { full_name?: string }).full_name === 'octo/demo')) return false;
|
|
635
1112
|
const dup = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
636
1113
|
if (dup.response.status !== 422) return false;
|
|
637
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
1114
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
638
1115
|
if (!(ok(get.status) && (get.body as { private?: boolean }).private === true)) return false;
|
|
639
1116
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/octo/demo`, body: JSON.stringify({ description: 'updated', has_wiki: false }), root });
|
|
640
1117
|
if (!(ok(e.response.status) && (e.response.body as { description?: string }).description === 'updated')) return false;
|
|
641
1118
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo`, root });
|
|
642
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
1119
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
643
1120
|
return del.response.status === 204 && gone.status === 404;
|
|
644
1121
|
})),
|
|
645
1122
|
done('github.repos.branches', 'repos', 'Repos: branches list/get + default branch', 'api', 'common', () =>
|
|
646
1123
|
withRoot(async (root) => {
|
|
647
|
-
|
|
648
|
-
|
|
1124
|
+
// auto_init makes the initial commit, so the default branch exists
|
|
1125
|
+
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo', default_branch: 'main', auto_init: true }), root });
|
|
1126
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches`, root });
|
|
649
1127
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { name?: string }).name === 'main')) return false;
|
|
650
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1128
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
651
1129
|
return ok(one.status) && (one.body as { name?: string }).name === 'main' && typeof (one.body as { commit?: { sha?: string } }).commit?.sha === 'string';
|
|
652
1130
|
})),
|
|
653
1131
|
done('github.repos.branch_protection', 'repos', 'Repos: branch protection rules + rulesets', 'api', 'niche', () =>
|
|
654
1132
|
withRoot(async (root) => {
|
|
655
|
-
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1133
|
+
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo', auto_init: true }), root });
|
|
656
1134
|
// Real GitHub 422s a protection PUT missing any of the four required (nullable) keys.
|
|
657
1135
|
const partial = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({ required_status_checks: null }), root });
|
|
658
1136
|
if (partial.response.status !== 422) return false;
|
|
659
1137
|
const p = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({ required_status_checks: null, enforce_admins: false, required_pull_request_reviews: null, restrictions: null }), root });
|
|
660
1138
|
if (!(ok(p.response.status) && (p.response.body as { enabled?: boolean }).enabled === true)) return false;
|
|
661
|
-
const b = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1139
|
+
const b = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
662
1140
|
if ((b.body as { protected?: boolean }).protected !== true) return false;
|
|
663
1141
|
const d = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
664
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1142
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
665
1143
|
return d.response.status === 204 && (after.body as { protected?: boolean }).protected === false;
|
|
666
1144
|
})),
|
|
667
1145
|
done('github.repos.branch_protection_required_checks', 'repos', 'Repos: branch protection PERSISTS its config — required-check contexts, strict, enforce_admins — and serves it back (the merge-gate read path)', 'api', 'common', () =>
|
|
668
1146
|
withRoot(async (root) => {
|
|
669
1147
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
670
1148
|
// GET protection on an unprotected branch → GitHub's 404 "Branch not protected".
|
|
671
|
-
const bare = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
1149
|
+
const bare = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
672
1150
|
if (!(bare.status === 404 && (bare.body as { message?: string }).message === 'Branch not protected')) return false;
|
|
673
1151
|
// PUT the twin-sdlc-shaped config: four required contexts + enforce_admins.
|
|
674
1152
|
const put = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/branches/main/protection`, body: JSON.stringify({
|
|
@@ -679,69 +1157,258 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
679
1157
|
if (!(ok(put.response.status) && putBody.required_status_checks?.contexts?.length === 4 && putBody.enforce_admins?.enabled === true)) return false;
|
|
680
1158
|
// The BRANCH summary carries the persisted contexts + the real enforcement_level
|
|
681
1159
|
// semantics (everyone == enforce_admins) — exactly what review-prerequisites.ts reads.
|
|
682
|
-
const branch = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1160
|
+
const branch = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
683
1161
|
const prot = (branch.body as { protection?: { required_status_checks?: { contexts?: string[]; enforcement_level?: string } } }).protection;
|
|
684
1162
|
if (!(prot?.required_status_checks?.contexts?.includes('agent-review') && prot.required_status_checks.enforcement_level === 'everyone')) return false;
|
|
685
1163
|
// Sub-resource reads + PATCH round-trip; enforce_admins DELETE flips the level.
|
|
686
|
-
const rsc = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection/required_status_checks`, root });
|
|
1164
|
+
const rsc = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection/required_status_checks`, root });
|
|
687
1165
|
if (!(ok(rsc.status) && (rsc.body as { contexts?: string[] }).contexts?.length === 4 && (rsc.body as { strict?: boolean }).strict === false)) return false;
|
|
688
1166
|
const patched = await applyGithubWrite({ method: 'PATCH', path: `/repos/octo/demo/branches/main/protection/required_status_checks`, body: JSON.stringify({ strict: true, contexts: ['test'] }), root });
|
|
689
1167
|
if (!(ok(patched.response.status) && (patched.response.body as { strict?: boolean }).strict === true && (patched.response.body as { contexts?: string[] }).contexts?.length === 1)) return false;
|
|
690
1168
|
const dropAdmins = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/branches/main/protection/enforce_admins`, root });
|
|
691
1169
|
if (dropAdmins.response.status !== 204) return false;
|
|
692
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1170
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
693
1171
|
const lvl = (after.body as { protection?: { required_status_checks?: { enforcement_level?: string } } }).protection?.required_status_checks?.enforcement_level;
|
|
694
1172
|
if (lvl !== 'non_admins') return false;
|
|
695
1173
|
// Protection SURVIVES a branch delete→re-create (real GitHub: classic protection is a
|
|
696
1174
|
// repo-level pattern rule — deleting `main` and re-pushing it comes back protected).
|
|
697
|
-
const mainSha = (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root }).body as { commit?: { sha?: string } }).commit?.sha ?? 'a'.repeat(40);
|
|
1175
|
+
const mainSha = ((await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root })).body as { commit?: { sha?: string } }).commit?.sha ?? 'a'.repeat(40);
|
|
698
1176
|
await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/main', sha: mainSha }), root });
|
|
699
1177
|
const delRef = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/git/refs/heads/main`, root });
|
|
700
1178
|
if (delRef.response.status !== 204) return false;
|
|
701
1179
|
const recreate = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/git/refs`, body: JSON.stringify({ ref: 'refs/heads/main', sha: 'b'.repeat(40) }), root });
|
|
702
1180
|
if (recreate.response.status !== 201) return false;
|
|
703
|
-
const survived = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
1181
|
+
const survived = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
704
1182
|
if (!(ok(survived.status) && ((survived.body as { required_status_checks?: { contexts?: string[] } }).required_status_checks?.contexts?.length === 1))) return false;
|
|
705
1183
|
// DELETE protection → the summary reads off again (and protection GET 404s).
|
|
706
1184
|
await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
707
|
-
const off = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
1185
|
+
const off = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main/protection`, root });
|
|
708
1186
|
return off.status === 404;
|
|
709
1187
|
})),
|
|
710
|
-
|
|
711
|
-
|
|
1188
|
+
done('github.repos.protection_required_reviews_enforced', 'repos', 'Repos: required_pull_request_reviews enforced on merge: approvals from reviewers with write access, admins exempt unless enforce_admins', 'api', 'niche', () =>
|
|
1189
|
+
withRoot(async (root) => {
|
|
1190
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
1191
|
+
const ada = await mintToken(root, 'ada');
|
|
1192
|
+
await collaborator(root, HISTORY_REPO, 'ada', 'push');
|
|
1193
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/branches/main/protection`, body: JSON.stringify({ required_status_checks: null, enforce_admins: false, required_pull_request_reviews: { required_approving_review_count: 1 }, restrictions: null }), root });
|
|
1194
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
1195
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/b', sha: main }), root, token: ada });
|
|
1196
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'b', content: Buffer.from('b\n').toString('base64'), branch: 'b' }), root, token: ada });
|
|
1197
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'b', base: 'main' }), root, token: ada })).response.body as { number: number };
|
|
1198
|
+
const merge = (token?: string) => applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root, ...(token ? { token } : {}) });
|
|
1199
|
+
const refused = await merge(ada);
|
|
1200
|
+
if (refused.response.status !== 405 || (refused.response.body as { message: string }).message !== 'At least 1 approving review is required by reviewers with write access.') return false;
|
|
1201
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/reviews`, body: JSON.stringify({ event: 'APPROVE' }), root });
|
|
1202
|
+
return (await merge(ada)).response.status === 200;
|
|
1203
|
+
})),
|
|
1204
|
+
done('github.repos.transfer', 'repos', 'Repos: transfer to an organization the admin belongs to: issues, pull requests, releases, workflows and history move under the new name, keeping their ids; a non-admin is refused', 'api', 'common', () =>
|
|
1205
|
+
withRoot(async (root) => {
|
|
1206
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
1207
|
+
const issue = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/issues`, body: JSON.stringify({ title: 'moves' }), root })).response.body as { id: number; number: number };
|
|
1208
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'new-home' }), root, cookie: await signIn(root) });
|
|
1209
|
+
const bob = await mintToken(root, 'bob');
|
|
1210
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/transfer`, body: JSON.stringify({ new_owner: 'new-home' }), root, token: bob })).response.status !== 403) return false;
|
|
1211
|
+
const moved = await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/transfer`, body: JSON.stringify({ new_owner: 'new-home' }), root });
|
|
1212
|
+
if (moved.response.status !== 202 || (moved.response.body as { full_name: string }).full_name !== 'new-home/ci-demo') return false;
|
|
1213
|
+
const got = (await handleGithubRequest({ method: 'GET', path: `/repos/new-home/ci-demo/issues/${issue.number}`, root })).body as { id: number; title: string };
|
|
1214
|
+
const workflows = (await handleGithubRequest({ method: 'GET', path: '/repos/new-home/ci-demo/actions/workflows', root })).body as { total_count: number };
|
|
1215
|
+
await applyGithubWrite({ method: 'PUT', path: '/repos/new-home/ci-demo/contents/.github/workflows/ci.yml', body: JSON.stringify({ message: 'edit', content: Buffer.from(workflowFile('CI', ' - run: npm run lint\n')).toString('base64'), sha: ((await handleGithubRequest({ method: 'GET', path: '/repos/new-home/ci-demo/contents/.github/workflows/ci.yml', root })).body as { sha: string }).sha }), root });
|
|
1216
|
+
const after = (await handleGithubRequest({ method: 'GET', path: '/repos/new-home/ci-demo/actions/workflows', root })).body as { total_count: number };
|
|
1217
|
+
return got.id === issue.id && got.title === 'moves' && workflows.total_count === 1 && after.total_count === 1
|
|
1218
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}`, root })).status !== 200;
|
|
1219
|
+
})),
|
|
1220
|
+
done('github.repos.transfer_leaves_installations', 'repos', 'Repos: a transferred repository leaves the previous owner\'s app installations that selected it, and each app gets installation_repositories removed', 'api', 'niche', () =>
|
|
1221
|
+
withRoot(async (root) => {
|
|
1222
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
1223
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/apps', body: JSON.stringify({ slug: 'repo-bot', name: 'Repo Bot', owner: 'octocat', permissions: { contents: 'read' }, events: [], hook_attributes: { url: 'http://127.0.0.1:9/hook' } }), root });
|
|
1224
|
+
await githubAppInstallationFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/apps/repo-bot/installations/new', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: await signIn(root) }, body: new URLSearchParams({ install: '1', repository_selection: 'selected', repositories: 'ci-demo' }).toString() }));
|
|
1225
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'new-home' }), root, cookie: await signIn(root) });
|
|
1226
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/transfer`, body: JSON.stringify({ new_owner: 'new-home' }), root });
|
|
1227
|
+
const deliveries = (await handleGithubRequest({ method: 'GET', path: '/app/hook/deliveries', root, token: appJwt('repo-bot') })).body as Array<{ id: number; event: string; action: string | null }>;
|
|
1228
|
+
const left = deliveries.find((d) => d.event === 'installation_repositories' && d.action === 'removed');
|
|
1229
|
+
const detail = left && (await handleGithubRequest({ method: 'GET', path: `/app/hook/deliveries/${left.id}`, root, token: appJwt('repo-bot') })).body as { request: { payload: { repositories_removed: Array<{ full_name: string }> } } };
|
|
1230
|
+
return !!detail && detail.request.payload.repositories_removed[0]?.full_name === HISTORY_REPO;
|
|
1231
|
+
})),
|
|
1232
|
+
done('github.repos.collaborators', 'repos', 'Repos: collaborators (invited, then accepted) + permissions', 'api', 'common', () =>
|
|
712
1233
|
withRoot(async (root) => {
|
|
713
1234
|
const add = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/alice`, body: JSON.stringify({ permission: 'maintain' }), root });
|
|
714
|
-
if (add.response.status !== 201) return false;
|
|
715
|
-
|
|
1235
|
+
if (add.response.status !== 201 || (add.response.body as { invitee?: { login?: string } }).invitee?.login !== 'alice') return false;
|
|
1236
|
+
// an invitee is not a collaborator until they accept
|
|
1237
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice`, root })).status !== 404) return false;
|
|
1238
|
+
if ((await acceptInvitation(root, REPO, 'alice'))?.response.status !== 204) return false;
|
|
1239
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators`, root });
|
|
716
1240
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
717
|
-
const perm = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice/permission`, root });
|
|
718
|
-
if ((perm.body as { permission?: string }).permission !== 'maintain') return false;
|
|
719
|
-
const check = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice`, root });
|
|
1241
|
+
const perm = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice/permission`, root });
|
|
1242
|
+
if ((perm.body as { permission?: string }).permission !== 'write' || (perm.body as { role_name?: string }).role_name !== 'maintain') return false;
|
|
1243
|
+
const check = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice`, root });
|
|
720
1244
|
if (check.status !== 204) return false;
|
|
721
1245
|
const rm = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/collaborators/alice`, root });
|
|
722
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice`, root });
|
|
1246
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators/alice`, root });
|
|
723
1247
|
return rm.response.status === 204 && gone.status === 404;
|
|
724
1248
|
})),
|
|
725
|
-
done('github.repos.webhooks', 'repos', 'Repos: webhook config CRUD', 'api', 'common', () =>
|
|
1249
|
+
done('github.repos.webhooks', 'repos', 'Repos: native webhook config CRUD, validation and signing-secret update semantics', 'api', 'common', () =>
|
|
726
1250
|
withRoot(async (root) => {
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
1251
|
+
const { createHmac } = await import('node:crypto');
|
|
1252
|
+
const secret = 'config-capability-only-secret';
|
|
1253
|
+
const received: Array<{ headers: Headers; body: string }> = [];
|
|
1254
|
+
const sink = await serveHttp({ port: 0, async fetch(request) { received.push({ headers: request.headers, body: await request.text() }); return new Response(null, { status: 204 }); } });
|
|
1255
|
+
const server = await createGithubTwinServer({ root });
|
|
1256
|
+
const sdk = octoClient({ baseUrl: `http://127.0.0.1:${server.port}` });
|
|
1257
|
+
const api = (method: string, path: string, body?: Record<string, unknown>) => sdk.request(`${method} ${path}`, body === undefined ? undefined : { data: body });
|
|
1258
|
+
const signed = () => { const d = received.at(-1)!; assert.equal(d.headers.get('x-hub-signature-256'), `sha256=${createHmac('sha256', secret).update(d.body).digest('hex')}`); };
|
|
1259
|
+
try {
|
|
1260
|
+
await api('POST', '/orgs/octo/repos', { name: 'demo' });
|
|
1261
|
+
const url = `http://127.0.0.1:${sink.port}/hook`;
|
|
1262
|
+
const hook = (await api('POST', `/repos/${REPO}/hooks`, { events: ['push', 'pull_request'], config: { url, secret } })).data as any;
|
|
1263
|
+
const path = `/repos/${REPO}/hooks/${hook.id}`;
|
|
1264
|
+
await assert.rejects(api('POST', `/repos/${REPO}/hooks`, { events: ['pull_request'], config: { url, secret } }), { status: 422 });
|
|
1265
|
+
const disjoint = (await api('POST', `/repos/${REPO}/hooks`, { events: ['issue_comment'], config: { url, secret } })).data as any;
|
|
1266
|
+
await assert.rejects(api('PATCH', `/repos/${REPO}/hooks/${disjoint.id}`, { add_events: ['push'], config: { secret } }), { status: 422 });
|
|
1267
|
+
assert.deepEqual(((await api('GET', `/repos/${REPO}/hooks/${disjoint.id}`)).data as any).events, ['issue_comment']);
|
|
1268
|
+
await api('DELETE', `/repos/${REPO}/hooks/${disjoint.id}`);
|
|
1269
|
+
const concurrent = await Promise.allSettled([0, 1].map(() => api('POST', `/repos/${REPO}/hooks`, { events: ['*'], config: { url: `${url}/concurrent`, secret } })));
|
|
1270
|
+
assert.equal(concurrent.filter(r => r.status === 'fulfilled').length, 1, 'duplicate admission shares the kernel write lock');
|
|
1271
|
+
const admitted = concurrent.find(r => r.status === 'fulfilled') as PromiseFulfilledResult<any>;
|
|
1272
|
+
await assert.rejects(api('PATCH', `/repos/${REPO}/hooks/${admitted.value.data.id}/config`, { url }), { status: 422 });
|
|
1273
|
+
await api('DELETE', `/repos/${REPO}/hooks/${admitted.value.data.id}`);
|
|
1274
|
+
// Independent native handlers share the filesystem-backed state. Concurrent PATCH
|
|
1275
|
+
// must validate the resulting config and derive add_events under the same lock.
|
|
1276
|
+
const armPath = join(root, 'hook-patch-arm');
|
|
1277
|
+
const reachedPath = join(root, 'hook-patch-reached');
|
|
1278
|
+
const releasePath = join(root, 'hook-patch-release');
|
|
1279
|
+
// Scheduling only: stop before the outermost lock, let the other native writer
|
|
1280
|
+
// finish, then delegate every store operation to the real filesystem implementation.
|
|
1281
|
+
const childSource = `
|
|
1282
|
+
import { createGithubTwinServer } from ${JSON.stringify(new URL('./github-server.ts', import.meta.url).href)};
|
|
1283
|
+
import { FsWorldStore, setActiveWorldStore } from '@volter/world-core';
|
|
1284
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
1285
|
+
import { join } from 'node:path';
|
|
1286
|
+
const root = process.argv[1];
|
|
1287
|
+
class ScheduledStore extends FsWorldStore {
|
|
1288
|
+
withLock(path, fn, opts) {
|
|
1289
|
+
const arm = join(root, 'hook-patch-arm');
|
|
1290
|
+
if (process.argv[2] === '0' && path.endsWith('/branch-ancestry.lock') && existsSync(arm) && /decideAndAppendAction|applyTwinWriteAtomic/.test(new Error().stack ?? '')) {
|
|
1291
|
+
unlinkSync(arm);
|
|
1292
|
+
writeFileSync(join(root, 'hook-patch-reached'), 'reached');
|
|
1293
|
+
const until = Date.now() + 5000;
|
|
1294
|
+
while (!existsSync(join(root, 'hook-patch-release'))) {
|
|
1295
|
+
if (Date.now() >= until) throw new Error('Native PATCH scheduling barrier timed out');
|
|
1296
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return super.withLock(path, fn, opts);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
setActiveWorldStore(new ScheduledStore());
|
|
1303
|
+
const server = await createGithubTwinServer({ root }); console.log(server.port);
|
|
1304
|
+
`;
|
|
1305
|
+
const children: ReturnType<typeof Bun.spawn>[] = [];
|
|
1306
|
+
const clients: ReturnType<typeof octoClient>[] = [];
|
|
1307
|
+
try {
|
|
1308
|
+
for (let i = 0; i < 2; i++) {
|
|
1309
|
+
const child = Bun.spawn(['bun', '--eval', childSource, root, String(i)], { stdout: 'pipe', stderr: 'ignore', env: process.env });
|
|
1310
|
+
children.push(child);
|
|
1311
|
+
const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
|
|
1312
|
+
const first = await reader.read(); reader.releaseLock();
|
|
1313
|
+
const port = Number(new TextDecoder().decode(first.value).trim());
|
|
1314
|
+
assert.ok(Number.isInteger(port) && port > 0);
|
|
1315
|
+
clients.push(octoClient({ baseUrl: `http://127.0.0.1:${port}` }));
|
|
1316
|
+
}
|
|
1317
|
+
const a = (await api('POST', `/repos/${REPO}/hooks`, { events: ['push'], config: { url: `${url}/race-a`, secret } })).data as any;
|
|
1318
|
+
const b = (await api('POST', `/repos/${REPO}/hooks`, { events: ['issues'], config: { url: `${url}/race-b`, secret } })).data as any;
|
|
1319
|
+
const racePath = `/repos/${REPO}/hooks/${a.id}`;
|
|
1320
|
+
const interleave = async (pausedBody: Record<string, unknown>, competingPath: string, competingBody: Record<string, unknown>) => {
|
|
1321
|
+
for (const path of [armPath, reachedPath, releasePath]) rmSync(path, { force: true });
|
|
1322
|
+
writeFileSync(armPath, 'armed');
|
|
1323
|
+
const pending = clients[0]!.request(`PATCH ${racePath}`, { data: pausedBody }).then(
|
|
1324
|
+
(response: { status: number }) => ({ status: response.status }),
|
|
1325
|
+
(error: { status: number }) => ({ status: error.status }),
|
|
1326
|
+
);
|
|
1327
|
+
try {
|
|
1328
|
+
const until = Date.now() + 5000;
|
|
1329
|
+
while (!existsSync(reachedPath)) {
|
|
1330
|
+
assert.ok(Date.now() < until, 'child must reach the pre-lock scheduling barrier');
|
|
1331
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1332
|
+
}
|
|
1333
|
+
assert.equal((await clients[1]!.request(`PATCH ${competingPath}`, { data: competingBody })).status, 200);
|
|
1334
|
+
} finally { writeFileSync(releasePath, 'released'); }
|
|
1335
|
+
return pending;
|
|
1336
|
+
};
|
|
1337
|
+
assert.equal((await interleave({ add_events: ['issues'], config: { secret } }, racePath, { add_events: ['issue_comment'], config: { secret } })).status, 200);
|
|
1338
|
+
assert.deepEqual(((await api('GET', racePath)).data as any).events.sort(), ['push', 'issues', 'issue_comment'].sort(), 'relative events use state after the competing write');
|
|
1339
|
+
await api('PATCH', racePath, { events: ['push'], config: { url: `${url}/race-a`, secret } });
|
|
1340
|
+
assert.equal((await interleave({ events: ['issues'], config: { secret } }, `${racePath}/config`, { url: `${url}/race-b` })).status, 422, 'duplicate validation uses the competing writer’s current URL');
|
|
1341
|
+
const afterConflict = (await api('GET', racePath)).data as any;
|
|
1342
|
+
assert.equal(afterConflict.config.url, `${url}/race-b`); assert.deepEqual(afterConflict.events, ['push']);
|
|
1343
|
+
|
|
1344
|
+
for (let round = 0; round < 12; round++) {
|
|
1345
|
+
await api('PATCH', racePath, { events: ['push'], config: { url: `${url}/race-a`, secret } });
|
|
1346
|
+
const attempted = await Promise.allSettled([
|
|
1347
|
+
clients[0]!.request(`PATCH ${racePath}/config`, { data: { url: `${url}/race-b` } }),
|
|
1348
|
+
clients[1]!.request(`PATCH ${racePath}`, { data: { events: ['issues'], config: { secret } } }),
|
|
1349
|
+
]);
|
|
1350
|
+
assert.equal(attempted.filter(r => r.status === 'fulfilled').length, 1, 'concurrent config and events cannot produce a duplicate');
|
|
1351
|
+
const rejected = attempted.find(r => r.status === 'rejected') as PromiseRejectedResult;
|
|
1352
|
+
assert.equal(rejected.reason.status, 422);
|
|
1353
|
+
const stored = (await api('GET', racePath)).data as any;
|
|
1354
|
+
assert.ok(!(stored.config.url === `${url}/race-b` && stored.events.includes('issues')));
|
|
1355
|
+
}
|
|
1356
|
+
await api('PATCH', racePath, { events: ['push'], config: { url: `${url}/race-a`, secret } });
|
|
1357
|
+
for (const pair of [['issues', 'issue_comment'], ['pull_request', 'pull_request_review'], ['create', 'delete'], ['release', 'fork']]) {
|
|
1358
|
+
const writes = await Promise.all(pair.map((event, index) => clients[index]!.request(`PATCH ${racePath}`, { data: { add_events: [event], config: { secret } } })));
|
|
1359
|
+
assert.ok(writes.every((w: { status: number }) => w.status === 200));
|
|
1360
|
+
const stored = (await api('GET', racePath)).data as any;
|
|
1361
|
+
assert.ok(pair.every(event => stored.events.includes(event)), 'relative event additions retain both concurrent updates');
|
|
1362
|
+
}
|
|
1363
|
+
await api('DELETE', racePath); await api('DELETE', `/repos/${REPO}/hooks/${b.id}`);
|
|
1364
|
+
} finally {
|
|
1365
|
+
for (const child of children) child.kill();
|
|
1366
|
+
await Promise.all(children.map(child => child.exited));
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
assert.equal(hook.config.content_type, 'form');
|
|
1370
|
+
assert.ok(!JSON.stringify(hook).includes(secret));
|
|
1371
|
+
await api('POST', `${path}/pings`);
|
|
1372
|
+
const form = received.at(-1)!;
|
|
1373
|
+
assert.equal(form.headers.get('content-type'), 'application/x-www-form-urlencoded');
|
|
1374
|
+
assert.ok(JSON.parse(new URLSearchParams(form.body).get('payload')!).hook_id === hook.id);
|
|
1375
|
+
signed();
|
|
1376
|
+
const config = (await api('PATCH', `${path}/config`, { content_type: 'json' })).data as any;
|
|
1377
|
+
assert.equal(config.secret, '********');
|
|
1378
|
+
await api('POST', `${path}/pings`); signed();
|
|
1379
|
+
assert.equal(received.at(-1)!.headers.get('content-type'), 'application/json');
|
|
1380
|
+
const edited = (await api('PATCH', path, { active: false, add_events: ['issue_comment'], remove_events: ['push'] })).data as any;
|
|
1381
|
+
assert.equal(edited.active, false);
|
|
1382
|
+
assert.deepEqual(edited.events, ['pull_request', 'issue_comment']);
|
|
1383
|
+
await api('POST', `${path}/pings`);
|
|
1384
|
+
assert.equal(received.at(-1)!.headers.get('x-hub-signature-256'), null, 'full-hook PATCH omission removes secret');
|
|
1385
|
+
await api('PATCH', `${path}/config`, { secret });
|
|
1386
|
+
await api('POST', `${path}/pings`); signed();
|
|
1387
|
+
await api('PATCH', `${path}/config`, { secret: '' });
|
|
1388
|
+
await api('POST', `${path}/pings`);
|
|
1389
|
+
assert.equal(received.at(-1)!.headers.get('x-hub-signature-256'), null, 'explicit empty clears secret');
|
|
1390
|
+
for (const invalid of [{ config: { url: 'file:///tmp/not-a-hook' } }, { config: { url, content_type: 'xml' } }, { config: { url, secret: 42 } }, { name: 'other', config: { url } }, { events: 'push', config: { url } }, { events: [3], config: { url } }, { active: 'true', config: { url } }]) {
|
|
1391
|
+
await assert.rejects(api('POST', `/repos/${REPO}/hooks`, invalid), { status: 422 });
|
|
1392
|
+
}
|
|
1393
|
+
await assert.rejects(api('POST', `/repos/${REPO}/hooks`, { config: { url, insecure_ssl: '1' } }), { status: 501 });
|
|
1394
|
+
await assert.rejects(api('PATCH', `${path}/config`, { insecure_ssl: 1 }), { status: 501 });
|
|
1395
|
+
await assert.rejects(api('PATCH', `${path}/config`, { insecure_ssl: 'invalid' }), { status: 422 });
|
|
1396
|
+
assert.equal(((await api('GET', `/repos/${REPO}/hooks`)).data as any[]).length, 1, 'invalid creates never store hooks');
|
|
1397
|
+
for (const route of [path, `${path}/config`, '/twin/store/mirror']) assert.ok(!JSON.stringify((await api('GET', route)).data).includes(secret));
|
|
1398
|
+
await assert.rejects(api('DELETE', `${path}/config`), { status: 404 });
|
|
1399
|
+
await assert.rejects(api('PATCH', `${path}/config/extra`, { secret }), { status: 404 });
|
|
1400
|
+
assert.equal((await api('DELETE', path)).status, 204);
|
|
1401
|
+
await assert.rejects(api('GET', path), { status: 404 });
|
|
1402
|
+
return true;
|
|
1403
|
+
} finally { server.stop(); sink.stop(true); }
|
|
737
1404
|
})),
|
|
738
1405
|
done('github.repos.topics', 'repos', 'Repos: topics', 'api', 'common', () =>
|
|
739
1406
|
withRoot(async (root) => {
|
|
740
1407
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
741
1408
|
const set = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/topics`, body: JSON.stringify({ names: ['ai', 'twin'] }), root });
|
|
742
1409
|
if (!(ok(set.response.status) && (set.response.body as { names?: string[] }).names?.length === 2)) return false;
|
|
743
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/topics`, root });
|
|
744
|
-
const onRepo = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
1410
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/topics`, root });
|
|
1411
|
+
const onRepo = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo`, root });
|
|
745
1412
|
return ok(get.status) && (get.body as { names?: string[] }).names?.includes('twin') === true && (onRepo.body as { topics?: string[] }).topics?.length === 2;
|
|
746
1413
|
})),
|
|
747
1414
|
done('github.repos.forks', 'repos', 'Repos: forks list/create', 'api', 'common', () =>
|
|
@@ -749,7 +1416,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
749
1416
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
750
1417
|
const f = await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/forks`, body: JSON.stringify({ organization: 'me' }), root });
|
|
751
1418
|
if (!(f.response.status === 202 && (f.response.body as { fork?: boolean }).fork === true && (f.response.body as { full_name?: string }).full_name === 'me/demo')) return false;
|
|
752
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/forks`, root });
|
|
1419
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/forks`, root });
|
|
753
1420
|
return ok(list.status) && isArr(list.body) && list.body.length === 1;
|
|
754
1421
|
})),
|
|
755
1422
|
done('github.repos.contents', 'repos', 'Repos: contents API (get/put/delete file objects)', 'api', 'common', () =>
|
|
@@ -757,16 +1424,15 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
757
1424
|
const content = Buffer.from('hello world').toString('base64');
|
|
758
1425
|
const put = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/README.md`, body: JSON.stringify({ message: 'add', content }), root });
|
|
759
1426
|
if (!(put.response.status === 201 && (put.response.body as { content?: { path?: string } }).content?.path === 'README.md')) return false;
|
|
760
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contents/README.md`, root });
|
|
1427
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contents/README.md`, root });
|
|
761
1428
|
if (!(ok(get.status) && (get.body as { content?: string }).content === content && (get.body as { encoding?: string }).encoding === 'base64')) return false;
|
|
762
1429
|
const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/README.md`, body: JSON.stringify({ message: 'upd', content: Buffer.from('v2').toString('base64') }), root });
|
|
763
1430
|
if (upd.response.status !== 200) return false;
|
|
764
1431
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/contents/README.md`, body: JSON.stringify({ message: 'rm' }), root });
|
|
765
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contents/README.md`, root });
|
|
1432
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contents/README.md`, root });
|
|
766
1433
|
return ok(del.response.status) && gone.status === 404;
|
|
767
1434
|
})),
|
|
768
|
-
|
|
769
|
-
'Out of scope: actual repository code/file contents are large + redundant; the metadata/API objects are modeled; the bytes are out of scope.'),
|
|
1435
|
+
todo('github.repos.blob_bytes', 'repos', 'Repos: serve raw file/blob BYTES (git object contents) for stored files, not only the metadata objects', 'api', 'niche'),
|
|
770
1436
|
|
|
771
1437
|
// ── Git Data API ─────────────────────────────────────────────────────────────────────────
|
|
772
1438
|
done('github.git.refs', 'git', 'Git Data: refs (create/get/update/delete branches+tags)', 'api', 'niche', () =>
|
|
@@ -783,15 +1449,15 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
783
1449
|
const dup = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha }), root });
|
|
784
1450
|
if (dup.response.status !== 422) return false;
|
|
785
1451
|
// get one via .../git/ref/:ref; update its sha (PATCH); delete (DELETE) then 404.
|
|
786
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
1452
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
787
1453
|
if (!(ok(get.status) && (get.body as { object?: { sha?: string } }).object?.sha === sha)) return false;
|
|
788
1454
|
const sha2 = 'b'.repeat(40);
|
|
789
1455
|
const upd = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/git/refs/heads/feat`, body: JSON.stringify({ sha: sha2 }), root });
|
|
790
1456
|
if (!(upd.response.status === 200 && (upd.response.body as { object?: { sha?: string } }).object?.sha === sha2)) return false;
|
|
791
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/refs/heads`, root });
|
|
1457
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/refs/heads`, root });
|
|
792
1458
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
793
1459
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/git/refs/heads/feat`, root });
|
|
794
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
1460
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
795
1461
|
if (!(del.response.status === 204 && gone.status === 404)) return false;
|
|
796
1462
|
// Dirty-state pin (§9): delete → RE-CREATE must resurrect, not stay tombstoned forever
|
|
797
1463
|
// (the delete→re-push flow; a permanent tombstone once made recreated refs invisible
|
|
@@ -800,7 +1466,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
800
1466
|
const sha3 = 'c'.repeat(40);
|
|
801
1467
|
const again = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: sha3 }), root });
|
|
802
1468
|
if (again.response.status !== 201) return false;
|
|
803
|
-
const back = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
1469
|
+
const back = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/ref/heads/feat`, root });
|
|
804
1470
|
return ok(back.status) && (back.body as { object?: { sha?: string } }).object?.sha === sha3;
|
|
805
1471
|
})),
|
|
806
1472
|
done('github.git.commits', 'git', 'Git Data: commit objects (get/create; REAL git shas)', 'api', 'niche', () =>
|
|
@@ -817,7 +1483,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
817
1483
|
const sha = (c.response.body as { sha?: string }).sha!;
|
|
818
1484
|
// The sha is a REAL git object id (the plane stored a real commit object under it).
|
|
819
1485
|
if (!/^[0-9a-f]{40}$/.test(sha)) return false;
|
|
820
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/commits/${sha}`, root });
|
|
1486
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/commits/${sha}`, root });
|
|
821
1487
|
const b = get.body as { tree?: { sha?: string }; parents?: unknown[]; author?: { name?: string } };
|
|
822
1488
|
return ok(get.status) && b.tree?.sha === tree && b.parents?.length === 1 && b.author?.name === 'Ann';
|
|
823
1489
|
})),
|
|
@@ -829,7 +1495,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
829
1495
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/trees`, body: JSON.stringify({ tree: [{ path: 'a.ts', mode: '100644', type: 'blob', sha: blobSha }] }), root });
|
|
830
1496
|
if (c.response.status !== 201) return false;
|
|
831
1497
|
const sha = (c.response.body as { sha?: string }).sha!;
|
|
832
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/trees/${sha}`, root });
|
|
1498
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/trees/${sha}`, root });
|
|
833
1499
|
const entries = (get.body as { tree?: Array<{ path?: string; type?: string }> }).tree ?? [];
|
|
834
1500
|
return ok(get.status) && entries.length === 1 && entries[0]!.path === 'a.ts' && entries[0]!.type === 'blob';
|
|
835
1501
|
})),
|
|
@@ -841,7 +1507,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
841
1507
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/blobs`, body: JSON.stringify({ content, encoding: 'base64' }), root });
|
|
842
1508
|
if (c.response.status !== 201) return false;
|
|
843
1509
|
const sha = (c.response.body as { sha?: string }).sha!;
|
|
844
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/blobs/${sha}`, root });
|
|
1510
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/blobs/${sha}`, root });
|
|
845
1511
|
const b = get.body as { content?: string; encoding?: string; size?: number };
|
|
846
1512
|
return ok(get.status) && b.content === content && b.encoding === 'base64' && b.size === Buffer.from('hello blob').length;
|
|
847
1513
|
})),
|
|
@@ -853,7 +1519,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
853
1519
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/tags`, body: JSON.stringify({ tag: 'v1.0.0', message: 'release', object: objSha, type: 'commit', tagger: { name: 'Ann' } }), root });
|
|
854
1520
|
if (!(c.response.status === 201 && (c.response.body as { tag?: string }).tag === 'v1.0.0')) return false;
|
|
855
1521
|
const sha = (c.response.body as { sha?: string }).sha!;
|
|
856
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/tags/${sha}`, root });
|
|
1522
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/tags/${sha}`, root });
|
|
857
1523
|
const b = get.body as { object?: { sha?: string; type?: string }; message?: string };
|
|
858
1524
|
if (!(ok(get.status) && b.object?.sha === objSha && b.message === 'release')) return false;
|
|
859
1525
|
// creating a refs/tags ref pointing at the annotated tag → object.type 'tag'.
|
|
@@ -899,16 +1565,16 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
899
1565
|
const { tmpdir: osTmp } = await import('node:os');
|
|
900
1566
|
const { join: j } = await import('node:path');
|
|
901
1567
|
const work = mkTmp(j(osTmp(), 'gh-cap-git-'));
|
|
902
|
-
const server = createGithubTwinServer({ root });
|
|
1568
|
+
const server = await createGithubTwinServer({ root });
|
|
903
1569
|
const deliveries: Array<Record<string, unknown>> = [];
|
|
904
|
-
const hook =
|
|
905
|
-
clearGithubWebhooks();
|
|
906
|
-
registerGithubWebhook(`http://127.0.0.1:${hook.port}/hook`);
|
|
1570
|
+
const hook = await serveHttp({ port: 0, async fetch(req) { deliveries.push({ event: req.headers.get('x-github-event'), ...(await req.json() as Record<string, unknown>) }); return new Response('ok'); } });
|
|
907
1571
|
try {
|
|
908
1572
|
const base = `http://127.0.0.1:${server.port}`;
|
|
909
1573
|
// Creating the repo over REST initializes its REAL bare repo; the clone sees it empty.
|
|
910
1574
|
const created = await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
911
1575
|
if (created.status !== 201) return false;
|
|
1576
|
+
const registered = await fetch(`${base}/repos/octo/demo/hooks`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'web', events: ['push'], config: { url: `http://127.0.0.1:${hook.port}/hook`, content_type: 'json' } }) });
|
|
1577
|
+
if (registered.status !== 201) return false;
|
|
912
1578
|
if ((await gitc(work, 'clone', `${base}/octo/demo.git`, 'c1')).code !== 0) return false;
|
|
913
1579
|
const c1 = j(work, 'c1');
|
|
914
1580
|
writeTmp(j(c1, 'a.txt'), 'clone-push\n');
|
|
@@ -917,17 +1583,17 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
917
1583
|
if ((await gitc(c1, 'push', 'origin', 'HEAD:refs/heads/main')).code !== 0) return false;
|
|
918
1584
|
const tip = (await gitc(c1, 'rev-parse', 'HEAD')).stdout;
|
|
919
1585
|
// REST agreement: ref, commit object (plane fallback), branch tip — all the PUSHED sha.
|
|
920
|
-
const ref = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/ref/heads/main`, root });
|
|
1586
|
+
const ref = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/ref/heads/main`, root });
|
|
921
1587
|
if (!(ok(ref.status) && (ref.body as { object?: { sha?: string } }).object?.sha === tip)) return false;
|
|
922
|
-
const commit = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/commits/${tip}`, root });
|
|
1588
|
+
const commit = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/git/commits/${tip}`, root });
|
|
923
1589
|
if (!(ok(commit.status) && (commit.body as { message?: string }).message === 'first')) return false;
|
|
924
|
-
const branch = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
1590
|
+
const branch = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches/main`, root });
|
|
925
1591
|
if (!(ok(branch.status) && (branch.body as { commit?: { sha?: string } }).commit?.sha === tip)) return false;
|
|
926
1592
|
// The push rode the EXISTING webhook pipeline: a `push` delivery with ref/before/after.
|
|
927
1593
|
const push = deliveries.find((d) => d.event === 'push' && d.ref === 'refs/heads/main');
|
|
928
1594
|
return push !== undefined && push.before === '0'.repeat(40) && push.after === tip;
|
|
929
1595
|
} finally {
|
|
930
|
-
server.stop(); hook.stop(true);
|
|
1596
|
+
server.stop(); hook.stop(true); rmTmp(work, { recursive: true, force: true });
|
|
931
1597
|
}
|
|
932
1598
|
})),
|
|
933
1599
|
done('github.git.rest_authored_chain_fetchable', 'git', 'Git Data REST ↔ protocol agreement: a REST blob→tree→commit→ref chain is fetchable by real git; a dangling ref is NOT advertised', 'api', 'core', () =>
|
|
@@ -936,7 +1602,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
936
1602
|
const { tmpdir: osTmp } = await import('node:os');
|
|
937
1603
|
const { join: j } = await import('node:path');
|
|
938
1604
|
const work = mkTmp(j(osTmp(), 'gh-cap-chain-'));
|
|
939
|
-
const server = createGithubTwinServer({ root });
|
|
1605
|
+
const server = await createGithubTwinServer({ root });
|
|
940
1606
|
try {
|
|
941
1607
|
const base = `http://127.0.0.1:${server.port}`;
|
|
942
1608
|
await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
@@ -980,7 +1646,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
980
1646
|
const { tmpdir: osTmp } = await import('node:os');
|
|
981
1647
|
const { join: j } = await import('node:path');
|
|
982
1648
|
const work = mkTmp(j(osTmp(), 'gh-cap-sync-'));
|
|
983
|
-
const server = createGithubTwinServer({ root });
|
|
1649
|
+
const server = await createGithubTwinServer({ root });
|
|
984
1650
|
try {
|
|
985
1651
|
const base = `http://127.0.0.1:${server.port}`;
|
|
986
1652
|
await fetch(`${base}/orgs/octo/repos`, { method: 'POST', body: JSON.stringify({ name: 'demo' }) });
|
|
@@ -1002,14 +1668,57 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1002
1668
|
if ((await gitc(c1, 'push', 'origin', 'HEAD:refs/heads/topic')).code !== 0) return false;
|
|
1003
1669
|
const tip2 = (await gitc(c1, 'rev-parse', 'HEAD')).stdout;
|
|
1004
1670
|
if (tip1 === tip2) return false;
|
|
1005
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/pulls/${n}`, root });
|
|
1671
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/pulls/${n}`, root });
|
|
1006
1672
|
return ok(after.status) && (after.body as { head?: { sha?: string } }).head?.sha === tip2;
|
|
1007
1673
|
} finally {
|
|
1008
1674
|
server.stop(); rmTmp(work, { recursive: true, force: true });
|
|
1009
1675
|
}
|
|
1010
1676
|
})),
|
|
1011
1677
|
todo('github.git.protected_branch_push_rules', 'git', 'Git smart-HTTP: receive-pack enforces branch protection (force-push/deletion refusal on protected branches)', 'api', 'niche'),
|
|
1012
|
-
|
|
1678
|
+
done('github.repos.contents_backed_by_git_plane', 'repos', 'Repos: Contents edits update Git objects and branch refs, preserving other branches and historical reads', 'api', 'common', () =>
|
|
1679
|
+
withRoot(async root => {
|
|
1680
|
+
const write = async (method: string, path: string, body: unknown) => (await applyGithubWrite({ method, path, body: JSON.stringify(body), root })).response;
|
|
1681
|
+
const read = async (path: string) => { const r = await handleGithubRequest({ method: 'GET', path, root }); return { ...r, body: r.body as { content?: string; object?: { sha?: string } } }; };
|
|
1682
|
+
await write('POST', '/orgs/octo/repos', { name: 'contents-proof' });
|
|
1683
|
+
const path = '/repos/octo/contents-proof';
|
|
1684
|
+
const first = await write('PUT', `${path}/contents/a.txt`, { message: 'base', content: Buffer.from('base').toString('base64') });
|
|
1685
|
+
const start = first.body as { content?: { sha: string }; commit?: { sha: string } };
|
|
1686
|
+
if (first.status !== 201 || !start.content?.sha || !start.commit?.sha) return false;
|
|
1687
|
+
if ((await write('POST', `${path}/git/refs`, { ref: 'refs/heads/topic', sha: start.commit.sha })).status !== 201) return false;
|
|
1688
|
+
const committer = { name: 'Committer', email: 'committer@example.test', date: '2025-02-03T04:05:06Z' };
|
|
1689
|
+
const edit = await write('PUT', `${path}/contents/a.txt`, { branch: 'topic', message: 'edit', committer, sha: start.content.sha, content: Buffer.from('topic').toString('base64') });
|
|
1690
|
+
const changed = edit.body as { content?: { sha: string }; commit?: { sha: string } };
|
|
1691
|
+
if (edit.status !== 200 || !changed.content?.sha || !changed.commit?.sha || changed.commit.sha === start.commit.sha) return false;
|
|
1692
|
+
for (const ref of ['main', start.commit.sha]) if ((await read(`${path}/contents/a.txt?ref=${ref}`)).body?.content !== Buffer.from('base').toString('base64')) return false;
|
|
1693
|
+
if ((await read(`${path}/git/ref/heads/topic`)).body?.object?.sha !== changed.commit.sha) return false;
|
|
1694
|
+
const server = await createGithubTwinServer({ root }); const work = mkdtempSync(join(tmpdir(), 'gh-contents-proof-'));
|
|
1695
|
+
try {
|
|
1696
|
+
if ((await gitc(work, 'clone', '-q', `http://127.0.0.1:${server.port}/octo/contents-proof.git`, 'clone')).code !== 0) return false;
|
|
1697
|
+
if ((await gitc(join(work, 'clone'), 'show', 'origin/topic:a.txt')).stdout !== 'topic') return false;
|
|
1698
|
+
if ((await gitc(join(work, 'clone'), 'show', 'HEAD:a.txt')).stdout !== 'base') return false;
|
|
1699
|
+
const identity = (await gitc(join(work, 'clone'), 'show', '-s', '--format=%an|%ae|%aI|%cn|%ce|%cI', changed.commit.sha)).stdout.replaceAll('+00:00', 'Z');
|
|
1700
|
+
assert.equal(identity, 'Committer|committer@example.test|2025-02-03T04:05:06Z|Committer|committer@example.test|2025-02-03T04:05:06Z', 'Contents commit identity and date defaults');
|
|
1701
|
+
const pr = await write('POST', `${path}/pulls`, { head: 'topic', base: 'main', title: 'Contents proof' });
|
|
1702
|
+
assert.equal(pr.status, 201);
|
|
1703
|
+
const continuation = await applyGithubWrite({ method: 'PUT', path: `${path}/contents/extra.txt`, body: JSON.stringify({ branch: 'topic', message: 'continue PR', content: 'eA==' }), root });
|
|
1704
|
+
const continuationSha = (continuation.response.body as { commit: { sha: string } }).commit.sha;
|
|
1705
|
+
assert.equal(continuation.response.status, 201);
|
|
1706
|
+
assert.ok(continuation.webhooks?.some(e => e.event === 'push' && e.ref === 'refs/heads/topic' && e.before === changed.commit!.sha && e.after === continuationSha), 'Contents push ref/before/after');
|
|
1707
|
+
assert.ok(continuation.webhooks?.some(e => e.event === 'pull_request' && e.action === 'synchronize' && e.number === (pr.body as { number: number }).number), 'Contents PR synchronize');
|
|
1708
|
+
if ((await write('PUT', `${path}/contents/a.txt`, { branch: 'topic', message: 'stale', sha: start.content.sha, content: 'YmFk' })).status !== 409) return false;
|
|
1709
|
+
if ((await write('DELETE', `${path}/contents/a.txt`, { branch: 'topic', message: 'delete', sha: changed.content.sha })).status !== 200) return false;
|
|
1710
|
+
if ((await read(`${path}/contents/a.txt?ref=topic`)).status !== 404 || (await read(`${path}/contents/a.txt?ref=${changed.commit.sha}`)).body?.content !== Buffer.from('topic').toString('base64')) return false;
|
|
1711
|
+
assert.equal((await write('PUT', `${path}/contents/a.txt`, { branch: 'topic', message: 'recreate', content: 'cmVjcmVhdGVk' })).status, 201);
|
|
1712
|
+
assert.ok(githubState(root).contents.some(f => f.repository === 'octo/contents-proof' && f.branch === 'topic' && f.path === 'a.txt' && f.content_b64 === 'cmVjcmVhdGVk'), 'Contents recreation clears mirror tombstone');
|
|
1713
|
+
const tree = await write('POST', `${path}/git/trees`, { tree: [{ path: 'submodule', mode: '160000', type: 'commit', sha: start.commit.sha }] });
|
|
1714
|
+
const gitlink = await write('POST', `${path}/git/commits`, { message: 'gitlink', tree: (tree.body as { sha: string }).sha, parents: [start.commit.sha] });
|
|
1715
|
+
const gitlinkSha = (gitlink.body as { sha: string }).sha;
|
|
1716
|
+
assert.equal((await write('PATCH', `${path}/git/refs/heads/main`, { sha: gitlinkSha })).status, 200);
|
|
1717
|
+
for (const p of ['submodule', 'submodule/child.txt']) assert.equal((await write('PUT', `${path}/contents/${p}`, { message: 'collision', content: 'eQ==' })).status, 422, `Contents submodule collision: ${p}`);
|
|
1718
|
+
assert.equal((await read(`${path}/git/ref/heads/main`)).body?.object?.sha, gitlinkSha, 'Contents refusal preserves ref');
|
|
1719
|
+
return true;
|
|
1720
|
+
} finally { server.stop(); rmSync(work, { recursive: true, force: true }); }
|
|
1721
|
+
})),
|
|
1013
1722
|
todo('github.git.trees_recursive_param', 'git', 'Git Data: GET .../git/trees/:sha?recursive=1 (full recursive listing)', 'api', 'niche'),
|
|
1014
1723
|
todo('github.git.shallow_partial_clone', 'git', 'Git smart-HTTP: shallow (--depth) + partial (--filter) clones asserted end-to-end (upload-pack supports them natively; unasserted)', 'api', 'niche'),
|
|
1015
1724
|
todo('github.git.lfs', 'git', 'Git LFS: the batch API + object storage', 'api', 'niche'),
|
|
@@ -1023,43 +1732,54 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1023
1732
|
const id = (c.response.body as { id?: number; tag_name?: string }).id!;
|
|
1024
1733
|
if ((c.response.body as { html_url?: string }).html_url?.includes('releases/tag/v1.0.0') !== true) return false;
|
|
1025
1734
|
// GET one, by id, asserting tag_name/name round-trip.
|
|
1026
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${id}`, root });
|
|
1735
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${id}`, root });
|
|
1027
1736
|
if (!(ok(one.status) && (one.body as { tag_name?: string }).tag_name === 'v1.0.0' && (one.body as { name?: string }).name === 'First')) return false;
|
|
1028
1737
|
// GET latest (published) + GET by tag.
|
|
1029
|
-
const latest = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/latest`, root });
|
|
1030
|
-
const byTag = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/tags/v1.0.0`, root });
|
|
1738
|
+
const latest = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/latest`, root });
|
|
1739
|
+
const byTag = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/tags/v1.0.0`, root });
|
|
1031
1740
|
if (!(ok(latest.status) && (latest.body as { tag_name?: string }).tag_name === 'v1.0.0' && ok(byTag.status) && (byTag.body as { id?: number }).id === id)) return false;
|
|
1032
1741
|
// PATCH (edit name) round-trips; list returns the release; DELETE 204s then 404s.
|
|
1033
1742
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/releases/${id}`, body: JSON.stringify({ name: 'Renamed' }), root });
|
|
1034
1743
|
if (!(ok(e.response.status) && (e.response.body as { name?: string }).name === 'Renamed')) return false;
|
|
1035
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases`, root });
|
|
1744
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases`, root });
|
|
1036
1745
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1037
1746
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/releases/${id}`, root });
|
|
1038
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${id}`, root });
|
|
1747
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${id}`, root });
|
|
1039
1748
|
return del.response.status === 204 && gone.status === 404;
|
|
1040
1749
|
})),
|
|
1041
|
-
done('github.releases.assets', 'releases', 'Releases:
|
|
1750
|
+
done('github.releases.assets', 'releases', 'Releases: an asset uploaded as its raw bytes with its Content-Type to the release\'s upload_url on uploads.github.com (the API host 404s; a second of one name refused); list/get', 'api', 'common', () =>
|
|
1042
1751
|
withRoot(async (root) => {
|
|
1043
1752
|
const r = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/releases`, body: JSON.stringify({ tag_name: 'v2', name: 'Two' }), root });
|
|
1044
1753
|
const relId = (r.response.body as { id?: number }).id!;
|
|
1045
|
-
const a = await applyGithubWrite({ method: 'POST', path:
|
|
1046
|
-
if (!(a.response.status === 201 && (a.response.body as { name?: string }).name === 'app.zip' && (a.response.body as { size?: number }).size === 1024)) return false;
|
|
1754
|
+
const a = await applyGithubWrite({ method: 'POST', path: `https://uploads.github.com/repos/${REPO}/releases/${relId}/assets?name=app.zip`, body: 'z'.repeat(1024), contentType: 'application/zip', root });
|
|
1755
|
+
if (!(a.response.status === 201 && (a.response.body as { name?: string }).name === 'app.zip' && (a.response.body as { size?: number }).size === 1024 && (a.response.body as { content_type?: string }).content_type === 'application/zip')) return false;
|
|
1756
|
+
if ((await applyGithubWrite({ method: 'POST', path: `https://uploads.github.com/repos/${REPO}/releases/${relId}/assets?name=app.zip`, body: 'z', contentType: 'application/zip', root })).response.status !== 422) return false;
|
|
1757
|
+
// the upload is served on the release's upload_url host, not the API's
|
|
1758
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/releases/${relId}/assets?name=app2.zip`, body: 'z', contentType: 'application/zip', root })).response.status !== 404) return false;
|
|
1759
|
+
if ((r.response.body as { upload_url?: string }).upload_url !== `https://uploads.github.com/repos/${REPO}/releases/${relId}/assets{?name,label}`) return false;
|
|
1047
1760
|
const assetId = (a.response.body as { id?: number }).id!;
|
|
1048
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${relId}/assets`, root });
|
|
1049
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/assets/${assetId}`, root });
|
|
1761
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${relId}/assets`, root });
|
|
1762
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/assets/${assetId}`, root });
|
|
1050
1763
|
// The asset rides on the release object too (assets:[...]).
|
|
1051
|
-
const rel = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${relId}`, root });
|
|
1764
|
+
const rel = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/releases/${relId}`, root });
|
|
1052
1765
|
return ok(list.status) && isArr(list.body) && list.body.length === 1 && ok(one.status) && (one.body as { name?: string }).name === 'app.zip'
|
|
1053
1766
|
&& ok(rel.status) && ((rel.body as { assets?: unknown[] }).assets?.length === 1);
|
|
1054
1767
|
})),
|
|
1768
|
+
done('github.repos.license_template', 'repos', 'Repos: license_template commits the license GitHub serves, its year and holder filled', 'api', 'common', () =>
|
|
1769
|
+
withRoot(async (root) => {
|
|
1770
|
+
await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'licensed', license_template: 'mit' }), root, occurredAt: '2026-01-05T09:00:00Z' });
|
|
1771
|
+
const file = await handleGithubRequest({ method: 'GET', path: '/repos/octocat/licensed/contents/LICENSE', root });
|
|
1772
|
+
const text = Buffer.from(String((file.body as { content?: string }).content ?? ''), 'base64').toString();
|
|
1773
|
+
return ok(file.status) && text.startsWith('The MIT License (MIT)\n\nCopyright (c) 2026 ') && !text.includes('[year]') && !text.includes('[fullname]') && text.includes('Permission is hereby granted');
|
|
1774
|
+
})),
|
|
1055
1775
|
done('github.tags.list', 'releases', 'Tags: list (GET /tags) + git refs (GET /git/refs/tags)', 'api', 'common', () =>
|
|
1056
1776
|
withRoot(async (root) => {
|
|
1057
1777
|
// A published release creates its tag; a draft does NOT (real GitHub behavior).
|
|
1058
1778
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/releases`, body: JSON.stringify({ tag_name: 'v3.0.0', name: 'Three' }), root });
|
|
1059
1779
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/releases`, body: JSON.stringify({ tag_name: 'v4.0.0-draft', name: 'Draft', draft: true }), root });
|
|
1060
|
-
const tags = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/tags`, root });
|
|
1780
|
+
const tags = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/tags`, root });
|
|
1061
1781
|
if (!(ok(tags.status) && isArr(tags.body) && tags.body.length === 1 && (tags.body[0] as { name?: string }).name === 'v3.0.0')) return false;
|
|
1062
|
-
const ref = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/refs/tags/v3.0.0`, root });
|
|
1782
|
+
const ref = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/git/refs/tags/v3.0.0`, root });
|
|
1063
1783
|
return ok(ref.status) && (ref.body as { ref?: string }).ref === 'refs/tags/v3.0.0' && (ref.body as { object?: { type?: string } }).object?.type === 'commit';
|
|
1064
1784
|
})),
|
|
1065
1785
|
|
|
@@ -1067,73 +1787,73 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1067
1787
|
done('github.projects.crud', 'projects', 'Projects v2: project CRUD', 'api', 'niche', () =>
|
|
1068
1788
|
withRoot(async (root) => {
|
|
1069
1789
|
// missing title 422s; create → 201 with number 1; get by number; PATCH closes; DELETE → gone.
|
|
1070
|
-
if ((await applyGithubWrite({ method: 'POST', path: `/orgs/octo/projectsV2`, body: '{}', root })).response.status !== 422) return false;
|
|
1071
|
-
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'Roadmap', short_description: 'Q3' }), root });
|
|
1790
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/_twin/orgs/octo/projectsV2`, body: '{}', root })).response.status !== 422) return false;
|
|
1791
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/_twin/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'Roadmap', short_description: 'Q3' }), root });
|
|
1072
1792
|
if (!(c.response.status === 201 && (c.response.body as { number?: number }).number === 1)) return false;
|
|
1073
1793
|
const pid = (c.response.body as { id?: number }).id!;
|
|
1074
|
-
const byNum = handleGithubRequest({ method: 'GET', path: `/orgs/octo/projectsV2/1`, root });
|
|
1794
|
+
const byNum = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/projectsV2/1`, root });
|
|
1075
1795
|
if (!(ok(byNum.status) && (byNum.body as { title?: string }).title === 'Roadmap')) return false;
|
|
1076
|
-
const e = await applyGithubWrite({ method: 'PATCH', path: `/projectsV2/${pid}`, body: JSON.stringify({ closed: true, title: 'Done' }), root });
|
|
1796
|
+
const e = await applyGithubWrite({ method: 'PATCH', path: `/_twin/projectsV2/${pid}`, body: JSON.stringify({ closed: true, title: 'Done' }), root });
|
|
1077
1797
|
if (!(ok(e.response.status) && (e.response.body as { closed?: boolean }).closed === true && (e.response.body as { title?: string }).title === 'Done')) return false;
|
|
1078
|
-
const list = handleGithubRequest({ method: 'GET', path: `/orgs/octo/projectsV2`, root });
|
|
1798
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/projectsV2`, root });
|
|
1079
1799
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1080
|
-
const del = await applyGithubWrite({ method: 'DELETE', path: `/projectsV2/${pid}`, root });
|
|
1081
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}`, root });
|
|
1800
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/_twin/projectsV2/${pid}`, root });
|
|
1801
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}`, root });
|
|
1082
1802
|
return del.response.status === 204 && gone.status === 404;
|
|
1083
1803
|
})),
|
|
1084
1804
|
done('github.projects.items', 'projects', 'Projects v2: items add/remove + field values', 'api', 'niche', () =>
|
|
1085
1805
|
withRoot(async (root) => {
|
|
1086
|
-
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1806
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/_twin/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1087
1807
|
const pid = (c.response.body as { id?: number }).id!;
|
|
1088
1808
|
// a draft item needs a title (422 without); add → 201; a status field value sets via PATCH.
|
|
1089
|
-
if ((await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/items`, body: '{}', root })).response.status !== 422) return false;
|
|
1090
|
-
const add = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Ship it' }), root });
|
|
1809
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/items`, body: '{}', root })).response.status !== 422) return false;
|
|
1810
|
+
const add = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Ship it' }), root });
|
|
1091
1811
|
if (!(add.response.status === 201 && (add.response.body as { content_type?: string }).content_type === 'DraftIssue')) return false;
|
|
1092
1812
|
const iid = (add.response.body as { id?: number }).id!;
|
|
1093
|
-
const linked = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'Issue', content_id: 42, content_repository: 'octo/demo' }), root });
|
|
1813
|
+
const linked = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'Issue', content_id: 42, content_repository: 'octo/demo' }), root });
|
|
1094
1814
|
if (linked.response.status !== 201) return false;
|
|
1095
|
-
const setv = await applyGithubWrite({ method: 'PATCH', path: `/projectsV2/${pid}/items/${iid}`, body: JSON.stringify({ field_id: 'status', value: 'In Progress' }), root });
|
|
1815
|
+
const setv = await applyGithubWrite({ method: 'PATCH', path: `/_twin/projectsV2/${pid}/items/${iid}`, body: JSON.stringify({ field_id: 'status', value: 'In Progress' }), root });
|
|
1096
1816
|
if (!(ok(setv.response.status) && (setv.response.body as { field_values?: Record<string, unknown> }).field_values?.status === 'In Progress')) return false;
|
|
1097
|
-
const list = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/items`, root });
|
|
1817
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/items`, root });
|
|
1098
1818
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 2)) return false;
|
|
1099
|
-
const del = await applyGithubWrite({ method: 'DELETE', path: `/projectsV2/${pid}/items/${iid}`, root });
|
|
1100
|
-
const after = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/items`, root });
|
|
1819
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/_twin/projectsV2/${pid}/items/${iid}`, root });
|
|
1820
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/items`, root });
|
|
1101
1821
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
|
|
1102
1822
|
})),
|
|
1103
1823
|
done('github.projects.fields', 'projects', 'Projects v2: custom fields (status/iteration/single-select)', 'api', 'niche', () =>
|
|
1104
1824
|
withRoot(async (root) => {
|
|
1105
|
-
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1825
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/_twin/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1106
1826
|
const pid = (c.response.body as { id?: number }).id!;
|
|
1107
1827
|
// missing data_type 422s; an invalid type 422s; single-select carries options; iteration carries config.
|
|
1108
|
-
if ((await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'X' }), root })).response.status !== 422) return false;
|
|
1109
|
-
if ((await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'X', data_type: 'nope' }), root })).response.status !== 422) return false;
|
|
1110
|
-
const ss = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Status', data_type: 'single_select', single_select_options: [{ name: 'Todo' }, { name: 'Done', color: 'GREEN' }] }), root });
|
|
1828
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'X' }), root })).response.status !== 422) return false;
|
|
1829
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'X', data_type: 'nope' }), root })).response.status !== 422) return false;
|
|
1830
|
+
const ss = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Status', data_type: 'single_select', single_select_options: [{ name: 'Todo' }, { name: 'Done', color: 'GREEN' }] }), root });
|
|
1111
1831
|
if (!(ss.response.status === 201 && (ss.response.body as { options?: unknown[] }).options?.length === 2)) return false;
|
|
1112
|
-
const it = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Sprint', data_type: 'iteration', iterations: [{ title: 'S1', duration: 14 }] }), root });
|
|
1832
|
+
const it = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Sprint', data_type: 'iteration', iterations: [{ title: 'S1', duration: 14 }] }), root });
|
|
1113
1833
|
if (!(it.response.status === 201 && (((it.response.body as { configuration?: { iterations?: unknown[] } }).configuration?.iterations?.length) === 1))) return false;
|
|
1114
1834
|
const fid = (ss.response.body as { id?: number }).id!;
|
|
1115
|
-
const list = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/fields`, root });
|
|
1835
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/fields`, root });
|
|
1116
1836
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 2)) return false;
|
|
1117
|
-
const del = await applyGithubWrite({ method: 'DELETE', path: `/projectsV2/${pid}/fields/${fid}`, root });
|
|
1118
|
-
const after = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/fields`, root });
|
|
1837
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/_twin/projectsV2/${pid}/fields/${fid}`, root });
|
|
1838
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/fields`, root });
|
|
1119
1839
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
|
|
1120
1840
|
})),
|
|
1121
1841
|
done('github.projects.views', 'projects', 'Projects v2: views (board/table/roadmap)', 'api', 'niche', () =>
|
|
1122
1842
|
withRoot(async (root) => {
|
|
1123
|
-
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1843
|
+
const c = await applyGithubWrite({ method: 'POST', path: `/_twin/orgs/octo/projectsV2`, body: JSON.stringify({ title: 'P' }), root });
|
|
1124
1844
|
const pid = (c.response.body as { id?: number }).id!;
|
|
1125
1845
|
// an invalid layout 422s; board/table/roadmap each create; PATCH relayouts; DELETE removes.
|
|
1126
|
-
if ((await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'V', layout: 'pie' }), root })).response.status !== 422) return false;
|
|
1127
|
-
const board = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Board', layout: 'board' }), root });
|
|
1846
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'V', layout: 'pie' }), root })).response.status !== 422) return false;
|
|
1847
|
+
const board = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Board', layout: 'board' }), root });
|
|
1128
1848
|
if (!(board.response.status === 201 && (board.response.body as { number?: number }).number === 1 && (board.response.body as { layout?: string }).layout === 'board')) return false;
|
|
1129
|
-
const table = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Table', layout: 'table' }), root });
|
|
1849
|
+
const table = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Table', layout: 'table' }), root });
|
|
1130
1850
|
if (table.response.status !== 201) return false;
|
|
1131
|
-
const upd = await applyGithubWrite({ method: 'PATCH', path: `/projectsV2/${pid}/views/1`, body: JSON.stringify({ layout: 'roadmap' }), root });
|
|
1851
|
+
const upd = await applyGithubWrite({ method: 'PATCH', path: `/_twin/projectsV2/${pid}/views/1`, body: JSON.stringify({ layout: 'roadmap' }), root });
|
|
1132
1852
|
if (!(ok(upd.response.status) && (upd.response.body as { layout?: string }).layout === 'roadmap')) return false;
|
|
1133
|
-
const list = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/views`, root });
|
|
1853
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/views`, root });
|
|
1134
1854
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 2)) return false;
|
|
1135
|
-
const del = await applyGithubWrite({ method: 'DELETE', path: `/projectsV2/${pid}/views/2`, root });
|
|
1136
|
-
const after = handleGithubRequest({ method: 'GET', path: `/projectsV2/${pid}/views`, root });
|
|
1855
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/_twin/projectsV2/${pid}/views/2`, root });
|
|
1856
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/_twin/projectsV2/${pid}/views`, root });
|
|
1137
1857
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
|
|
1138
1858
|
})),
|
|
1139
1859
|
|
|
@@ -1153,20 +1873,20 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1153
1873
|
const badCat = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/discussions`, body: JSON.stringify({ title: 'x', category: 'nope' }), root });
|
|
1154
1874
|
if (noTitle.response.status !== 422 || badCat.response.status !== 422) return false;
|
|
1155
1875
|
// GET one + list round-trips title/category.
|
|
1156
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1157
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions`, root });
|
|
1876
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1877
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions`, root });
|
|
1158
1878
|
if (!(ok(one.status) && (one.body as { title?: string }).title === 'Welcome' && isArr(list.body) && list.body.length === 1)) return false;
|
|
1159
1879
|
// edit (title + close) round-trips; delete 204s then 404s.
|
|
1160
1880
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/discussions/1`, body: JSON.stringify({ title: 'Renamed', state: 'closed' }), root });
|
|
1161
1881
|
if (!(ok(e.response.status) && (e.response.body as { title?: string; state?: string }).title === 'Renamed' && (e.response.body as { state?: string }).state === 'closed')) return false;
|
|
1162
1882
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/discussions/1`, root });
|
|
1163
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1883
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1164
1884
|
return del.response.status === 204 && gone.status === 404;
|
|
1165
1885
|
})),
|
|
1166
1886
|
done('github.discussions.categories', 'discussions', 'Discussions: categories (seeded defaults + custom + answerable Q&A)', 'api', 'niche', () =>
|
|
1167
1887
|
withRoot(async (root) => {
|
|
1168
1888
|
// the repo's category list seeds GitHub's defaults; Q&A is the answerable one.
|
|
1169
|
-
const cats = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/categories`, root });
|
|
1889
|
+
const cats = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/categories`, root });
|
|
1170
1890
|
if (!(ok(cats.status) && isArr(cats.body))) return false;
|
|
1171
1891
|
const qa = (cats.body as Array<{ slug?: string; is_answerable?: boolean }>).find((c) => c.slug === 'q-a');
|
|
1172
1892
|
const general = (cats.body as Array<{ slug?: string; is_answerable?: boolean }>).find((c) => c.slug === 'general');
|
|
@@ -1174,7 +1894,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1174
1894
|
// a custom category is creatable + appears in the list with its slug.
|
|
1175
1895
|
const made = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/discussions/categories`, body: JSON.stringify({ name: 'Polls', is_answerable: false }), root });
|
|
1176
1896
|
if (!(made.response.status === 201 && (made.response.body as { slug?: string }).slug === 'polls')) return false;
|
|
1177
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/categories`, root });
|
|
1897
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/categories`, root });
|
|
1178
1898
|
return isArr(after.body) && (after.body as Array<{ slug?: string }>).some((c) => c.slug === 'polls');
|
|
1179
1899
|
})),
|
|
1180
1900
|
done('github.discussions.comments', 'discussions', 'Discussions: comments + threaded replies', 'api', 'niche', () =>
|
|
@@ -1190,8 +1910,8 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1190
1910
|
const reply = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/discussions/1/comments`, body: JSON.stringify({ body: 'thanks', parent_id: cid }), root });
|
|
1191
1911
|
if (!(reply.response.status === 201 && (reply.response.body as { parent_id?: number }).parent_id === cid)) return false;
|
|
1192
1912
|
// list returns both, and the discussion's comment count reflects them.
|
|
1193
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1/comments`, root });
|
|
1194
|
-
const disc = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1913
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1/comments`, root });
|
|
1914
|
+
const disc = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1195
1915
|
return ok(list.status) && isArr(list.body) && list.body.length === 2 && (disc.body as { comments?: number }).comments === 2;
|
|
1196
1916
|
})),
|
|
1197
1917
|
done('github.discussions.answers', 'discussions', 'Discussions: mark/unmark accepted answer (Q&A only)', 'api', 'niche', () =>
|
|
@@ -1203,11 +1923,11 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1203
1923
|
// mark → comment.is_answer true + the discussion points at it.
|
|
1204
1924
|
const mark = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/discussions/1/comments/${cid}/answer`, body: '{}', root });
|
|
1205
1925
|
if (!(ok(mark.response.status) && (mark.response.body as { is_answer?: boolean }).is_answer === true)) return false;
|
|
1206
|
-
const disc = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1926
|
+
const disc = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1207
1927
|
if ((disc.body as { answer_comment_id?: number }).answer_comment_id !== cid) return false;
|
|
1208
1928
|
// unmark clears both.
|
|
1209
1929
|
const unmark = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/discussions/1/comments/${cid}/answer`, root });
|
|
1210
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1930
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/discussions/1`, root });
|
|
1211
1931
|
if (!(ok(unmark.response.status) && (unmark.response.body as { is_answer?: boolean }).is_answer === false && (after.body as { answer_comment_id?: number | null }).answer_comment_id === null)) return false;
|
|
1212
1932
|
// a NON-answerable (General) discussion rejects an answer mark → 422 (vendor-faithful).
|
|
1213
1933
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/discussions`, body: JSON.stringify({ title: 'chat', category: 'general' }), root });
|
|
@@ -1219,17 +1939,29 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1219
1939
|
// ── Orgs / Teams / Members / Users ───────────────────────────────────────────────────────
|
|
1220
1940
|
done('github.orgs.crud', 'orgs', 'Orgs: get/update + members', 'api', 'niche', () =>
|
|
1221
1941
|
withRoot(async (root) => {
|
|
1222
|
-
// an unknown org 404s
|
|
1223
|
-
if (handleGithubRequest({ method: 'GET', path: `/orgs/octo`, root }).status !== 404) return false;
|
|
1942
|
+
// an unknown org 404s (a PATCH too); created on github.com, a PATCH round-trips the profile fields.
|
|
1943
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/orgs/octo`, root })).status !== 404) return false;
|
|
1944
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/orgs/octo`, body: JSON.stringify({ description: 'x' }), root })).response.status !== 404) return false;
|
|
1945
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'octo', name: 'Octo' }), root, cookie: await signIn(root) });
|
|
1224
1946
|
const upd = await applyGithubWrite({ method: 'PATCH', path: `/orgs/octo`, body: JSON.stringify({ description: 'The Octo org', billing_email: 'pay@octo.dev' }), root });
|
|
1225
1947
|
if (!(ok(upd.response.status) && (upd.response.body as { description?: string }).description === 'The Octo org')) return false;
|
|
1226
|
-
const get = handleGithubRequest({ method: 'GET', path: `/orgs/octo`, root });
|
|
1948
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/orgs/octo`, root });
|
|
1227
1949
|
if (!(ok(get.status) && (get.body as { type?: string }).type === 'Organization' && (get.body as { description?: string }).description === 'The Octo org')) return false;
|
|
1228
|
-
// members:
|
|
1950
|
+
// members: an invitation is pending until accepted; then the member appears + the 204 membership check passes.
|
|
1229
1951
|
await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/alice`, body: JSON.stringify({ role: 'admin' }), root });
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1952
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members/alice`, root })).status !== 404) return false;
|
|
1953
|
+
await acceptInvite(root, 'octo', 'alice');
|
|
1954
|
+
const members = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members?role=admin`, root });
|
|
1955
|
+
if (!(ok(members.status) && isArr(members.body) && members.body.some((u) => (u as { login?: string }).login === 'alice')
|
|
1956
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members/alice`, root })).status === 204)) return false;
|
|
1957
|
+
// role filters the list as GitHub does; an unknown role is a validation error, on read and on write.
|
|
1958
|
+
await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/bob`, body: JSON.stringify({ role: 'member' }), root });
|
|
1959
|
+
await acceptInvite(root, 'octo', 'bob');
|
|
1960
|
+
// octocat created the org on github.com and is its first admin
|
|
1961
|
+
const logins = async (q: string) => ((await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members${q}`, root })).body as { login: string }[]).map((u) => u.login).sort().join(',');
|
|
1962
|
+
return await logins('?role=admin') === 'alice,octocat' && await logins('?role=member') === 'bob' && await logins('?role=all') === 'alice,bob,octocat'
|
|
1963
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members?role=owner`, root })).status === 422
|
|
1964
|
+
&& (await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/carol`, body: JSON.stringify({ role: 'owner' }), root })).response.status === 422;
|
|
1233
1965
|
})),
|
|
1234
1966
|
done('github.orgs.membership', 'orgs', 'Orgs: membership (invite/role/remove) + outside collaborators', 'api', 'niche', () =>
|
|
1235
1967
|
withRoot(async (root) => {
|
|
@@ -1238,20 +1970,20 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1238
1970
|
if (!(ok(inv.response.status) && (inv.response.body as { role?: string }).role === 'member')) return false;
|
|
1239
1971
|
const promote = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/bob`, body: JSON.stringify({ role: 'admin' }), root });
|
|
1240
1972
|
if ((promote.response.body as { role?: string }).role !== 'admin') return false;
|
|
1241
|
-
const get = handleGithubRequest({ method: 'GET', path: `/orgs/octo/memberships/bob`, root });
|
|
1973
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/memberships/bob`, root });
|
|
1242
1974
|
if ((get.body as { role?: string }).role !== 'admin') return false;
|
|
1243
1975
|
// remove → member gone (404 check + delete idempotency: second delete 404s).
|
|
1244
1976
|
const rm = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/members/bob`, root });
|
|
1245
1977
|
if (rm.response.status !== 204) return false;
|
|
1246
|
-
if (handleGithubRequest({ method: 'GET', path: `/orgs/octo/members/bob`, root }).status !== 404) return false;
|
|
1978
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/orgs/octo/members/bob`, root })).status !== 404) return false;
|
|
1247
1979
|
if ((await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/members/bob`, root })).response.status !== 404) return false;
|
|
1248
1980
|
// outside collaborators: add → listed → remove → gone.
|
|
1249
1981
|
const oc = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/outside_collaborators/carol`, body: '{}', root });
|
|
1250
1982
|
if (oc.response.status !== 204) return false;
|
|
1251
|
-
const ocList = handleGithubRequest({ method: 'GET', path: `/orgs/octo/outside_collaborators`, root });
|
|
1983
|
+
const ocList = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/outside_collaborators`, root });
|
|
1252
1984
|
if (!(isArr(ocList.body) && ocList.body.length === 1 && (ocList.body[0] as { login?: string }).login === 'carol')) return false;
|
|
1253
1985
|
const ocRm = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/outside_collaborators/carol`, root });
|
|
1254
|
-
const after = handleGithubRequest({ method: 'GET', path: `/orgs/octo/outside_collaborators`, root });
|
|
1986
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/outside_collaborators`, root });
|
|
1255
1987
|
return ocRm.response.status === 204 && isArr(after.body) && after.body.length === 0;
|
|
1256
1988
|
})),
|
|
1257
1989
|
done('github.orgs.repos', 'orgs', 'Orgs: list org repositories', 'api', 'common', () =>
|
|
@@ -1260,9 +1992,9 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1260
1992
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'sec', private: true }), root });
|
|
1261
1993
|
await applyGithubWrite({ method: 'POST', path: `/orgs/other/repos`, body: JSON.stringify({ name: 'x' }), root });
|
|
1262
1994
|
// list scopes to the org (not the unrelated 'other/x'); ?type=private filters.
|
|
1263
|
-
const all = handleGithubRequest({ method: 'GET', path: `/orgs/octo/repos`, root });
|
|
1995
|
+
const all = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/repos`, root });
|
|
1264
1996
|
if (!(ok(all.status) && isArr(all.body) && all.body.length === 2 && (all.body as Array<{ full_name?: string }>).every((r) => r.full_name!.startsWith('octo/')))) return false;
|
|
1265
|
-
const priv = handleGithubRequest({ method: 'GET', path: `/orgs/octo/repos?type=private`, root });
|
|
1997
|
+
const priv = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/repos?type=private`, root });
|
|
1266
1998
|
return isArr(priv.body) && priv.body.length === 1 && (priv.body[0] as { name?: string }).name === 'sec';
|
|
1267
1999
|
})),
|
|
1268
2000
|
done('github.orgs.webhooks', 'orgs', 'Orgs: org-level webhooks CRUD', 'api', 'niche', () =>
|
|
@@ -1274,10 +2006,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1274
2006
|
const id = (c.response.body as { id?: number }).id!;
|
|
1275
2007
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/orgs/octo/hooks/${id}`, body: JSON.stringify({ active: false }), root });
|
|
1276
2008
|
if (!(ok(e.response.status) && (e.response.body as { active?: boolean }).active === false)) return false;
|
|
1277
|
-
const list = handleGithubRequest({ method: 'GET', path: `/orgs/octo/hooks`, root });
|
|
2009
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/hooks`, root });
|
|
1278
2010
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1279
2011
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/hooks/${id}`, root });
|
|
1280
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/orgs/octo/hooks/${id}`, root }).status === 404;
|
|
2012
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/orgs/octo/hooks/${id}`, root })).status === 404;
|
|
1281
2013
|
})),
|
|
1282
2014
|
done('github.teams.crud', 'orgs', 'Teams: CRUD + membership + repo access', 'api', 'niche', () =>
|
|
1283
2015
|
withRoot(async (root) => {
|
|
@@ -1286,25 +2018,27 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1286
2018
|
if (!(c.response.status === 201 && (c.response.body as { slug?: string }).slug === 'core-team')) return false;
|
|
1287
2019
|
// duplicate name (same slug) → 422.
|
|
1288
2020
|
if ((await applyGithubWrite({ method: 'POST', path: `/orgs/octo/teams`, body: JSON.stringify({ name: 'core-team' }), root })).response.status !== 422) return false;
|
|
1289
|
-
const get = handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team`, root });
|
|
2021
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team`, root });
|
|
1290
2022
|
if (!(ok(get.status) && (get.body as { privacy?: string }).privacy === 'closed')) return false;
|
|
1291
2023
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/orgs/octo/teams/core-team`, body: JSON.stringify({ description: 'the core team' }), root });
|
|
1292
2024
|
if ((e.response.body as { description?: string }).description !== 'the core team') return false;
|
|
1293
|
-
// membership: add maintainer →
|
|
2025
|
+
// membership: add maintainer → pending until dana joins the organization she is invited to, then a member
|
|
1294
2026
|
const m = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/teams/core-team/memberships/dana`, body: JSON.stringify({ role: 'maintainer' }), root });
|
|
1295
|
-
if ((m.response.body as { role?: string }).role !== 'maintainer') return false;
|
|
1296
|
-
|
|
2027
|
+
if ((m.response.body as { role?: string; state?: string }).role !== 'maintainer' || (m.response.body as { state?: string }).state !== 'pending') return false;
|
|
2028
|
+
if (((await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/members`, root })).body as unknown[]).length !== 0) return false;
|
|
2029
|
+
await applyGithubWrite({ method: 'PATCH', path: '/user/memberships/orgs/octo', body: JSON.stringify({ state: 'active' }), root, token: await mintToken(root, 'dana') });
|
|
2030
|
+
const mem = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/members`, root });
|
|
1297
2031
|
if (!(isArr(mem.body) && mem.body.length === 1 && (mem.body[0] as { login?: string }).login === 'dana')) return false;
|
|
1298
2032
|
// repo access: grant push → 204 check passes + repo appears in team repos.
|
|
1299
2033
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'svc' }), root });
|
|
1300
2034
|
const g = await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/teams/core-team/repos/octo/svc`, body: JSON.stringify({ permission: 'push' }), root });
|
|
1301
2035
|
if (g.response.status !== 204) return false;
|
|
1302
|
-
if (handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/repos/octo/svc`, root }).status !== 204) return false;
|
|
1303
|
-
const repos = handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/repos`, root });
|
|
2036
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/repos/octo/svc`, root })).status !== 204) return false;
|
|
2037
|
+
const repos = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/repos`, root });
|
|
1304
2038
|
if (!(isArr(repos.body) && repos.body.length === 1 && (repos.body[0] as { full_name?: string }).full_name === 'octo/svc')) return false;
|
|
1305
2039
|
// delete team → 404 thereafter.
|
|
1306
2040
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/teams/core-team`, root });
|
|
1307
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team`, root }).status === 404;
|
|
2041
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team`, root })).status === 404;
|
|
1308
2042
|
})),
|
|
1309
2043
|
done('github.teams.discussions', 'orgs', 'Teams: team discussions + comments', 'api', 'niche', () =>
|
|
1310
2044
|
withRoot(async (root) => {
|
|
@@ -1318,39 +2052,39 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1318
2052
|
// comments: create → per-discussion number 1; edit; list; delete → list shrinks.
|
|
1319
2053
|
const c = await applyGithubWrite({ method: 'POST', path: `/orgs/octo/teams/eng/discussions/1/comments`, body: JSON.stringify({ body: '+1' }), root });
|
|
1320
2054
|
if (!(c.response.status === 201 && (c.response.body as { number?: number }).number === 1)) return false;
|
|
1321
|
-
const cl = handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1/comments`, root });
|
|
2055
|
+
const cl = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1/comments`, root });
|
|
1322
2056
|
if (!(isArr(cl.body) && cl.body.length === 1 && (cl.body[0] as { body?: string }).body === '+1')) return false;
|
|
1323
2057
|
const cdel = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/teams/eng/discussions/1/comments/1`, root });
|
|
1324
2058
|
if (cdel.response.status !== 204) return false;
|
|
1325
|
-
const cl2 = handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1/comments`, root });
|
|
2059
|
+
const cl2 = await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1/comments`, root });
|
|
1326
2060
|
if (!(isArr(cl2.body) && cl2.body.length === 0)) return false;
|
|
1327
2061
|
// delete the discussion → 404 thereafter (and its comments orphan-pruned).
|
|
1328
2062
|
const ddel = await applyGithubWrite({ method: 'DELETE', path: `/orgs/octo/teams/eng/discussions/1`, root });
|
|
1329
|
-
return ddel.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1`, root }).status === 404;
|
|
2063
|
+
return ddel.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/eng/discussions/1`, root })).status === 404;
|
|
1330
2064
|
})),
|
|
1331
2065
|
done('github.users.profile', 'users', 'Users: get profile + authenticated user', 'api', 'common', () =>
|
|
1332
2066
|
withRoot(async (root) => {
|
|
1333
|
-
const u = handleGithubRequest({ method: 'GET', path: `/users/octocat`, root });
|
|
2067
|
+
const u = await handleGithubRequest({ method: 'GET', path: `/users/octocat`, root });
|
|
1334
2068
|
if (!(ok(u.status) && (u.body as { login?: string }).login === 'octocat' && typeof (u.body as { id?: number }).id === 'number')) return false;
|
|
1335
|
-
const me = handleGithubRequest({ method: 'GET', path: `/user`, root });
|
|
2069
|
+
const me = await handleGithubRequest({ method: 'GET', path: `/user`, root });
|
|
1336
2070
|
return ok(me.status) && (me.body as { login?: string }).login === 'octocat';
|
|
1337
2071
|
})),
|
|
1338
2072
|
done('github.users.followers', 'users', 'Users: followers/following', 'api', 'niche', () =>
|
|
1339
2073
|
withRoot(async (root) => {
|
|
1340
2074
|
// not following → 404 check; follow → 204 check + appears in /user/following.
|
|
1341
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root }).status !== 404) return false;
|
|
2075
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root })).status !== 404) return false;
|
|
1342
2076
|
const f = await applyGithubWrite({ method: 'PUT', path: `/user/following/ann`, body: '{}', root });
|
|
1343
2077
|
if (f.response.status !== 204) return false;
|
|
1344
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root }).status !== 204) return false;
|
|
1345
|
-
const following = handleGithubRequest({ method: 'GET', path: `/user/following`, root });
|
|
2078
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root })).status !== 204) return false;
|
|
2079
|
+
const following = await handleGithubRequest({ method: 'GET', path: `/user/following`, root });
|
|
1346
2080
|
if (!(isArr(following.body) && following.body.length === 1 && (following.body[0] as { login?: string }).login === 'ann')) return false;
|
|
1347
2081
|
// ann's followers now include octocat (the only modeled actor).
|
|
1348
|
-
const followers = handleGithubRequest({ method: 'GET', path: `/users/ann/followers`, root });
|
|
2082
|
+
const followers = await handleGithubRequest({ method: 'GET', path: `/users/ann/followers`, root });
|
|
1349
2083
|
if (!(isArr(followers.body) && followers.body.length === 1 && (followers.body[0] as { login?: string }).login === 'octocat')) return false;
|
|
1350
2084
|
// unfollow → check 404 again + list empties.
|
|
1351
2085
|
const uf = await applyGithubWrite({ method: 'DELETE', path: `/user/following/ann`, root });
|
|
1352
|
-
const after = handleGithubRequest({ method: 'GET', path: `/user/following`, root });
|
|
1353
|
-
return uf.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root }).status === 404 && isArr(after.body) && after.body.length === 0;
|
|
2086
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/user/following`, root });
|
|
2087
|
+
return uf.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/user/following/ann`, root })).status === 404 && isArr(after.body) && after.body.length === 0;
|
|
1354
2088
|
})),
|
|
1355
2089
|
|
|
1356
2090
|
// ── Gists ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -1359,14 +2093,14 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1359
2093
|
const c = await applyGithubWrite({ method: 'POST', path: `/gists`, body: JSON.stringify({ description: 'snip', public: true, files: { 'a.ts': { content: 'export const a=1' } } }), root });
|
|
1360
2094
|
if (c.response.status !== 201) return false;
|
|
1361
2095
|
const id = (c.response.body as { id?: string }).id!;
|
|
1362
|
-
const get = handleGithubRequest({ method: 'GET', path: `/gists/${id}`, root });
|
|
2096
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/gists/${id}`, root });
|
|
1363
2097
|
if (!(ok(get.status) && (get.body as { files?: Record<string, unknown> }).files?.['a.ts'])) return false;
|
|
1364
2098
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/gists/${id}`, body: JSON.stringify({ description: 'renamed' }), root });
|
|
1365
2099
|
if (!(ok(e.response.status) && (e.response.body as { description?: string }).description === 'renamed')) return false;
|
|
1366
|
-
const list = handleGithubRequest({ method: 'GET', path: `/gists`, root });
|
|
2100
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/gists`, root });
|
|
1367
2101
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1368
2102
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/gists/${id}`, root });
|
|
1369
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/gists/${id}`, root });
|
|
2103
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/gists/${id}`, root });
|
|
1370
2104
|
return del.response.status === 204 && gone.status === 404;
|
|
1371
2105
|
})),
|
|
1372
2106
|
|
|
@@ -1375,56 +2109,56 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1375
2109
|
withRoot(async (root) => {
|
|
1376
2110
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'fix login bug' }), root });
|
|
1377
2111
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'add login', head: 'a', base: 'main' }), root });
|
|
1378
|
-
const all = handleGithubRequest({ method: 'GET', path: `/search/issues?q=${encodeURIComponent('login')}`, root });
|
|
2112
|
+
const all = await handleGithubRequest({ method: 'GET', path: `/search/issues?q=${encodeURIComponent('login')}`, root });
|
|
1379
2113
|
if (!(ok(all.status) && (all.body as { total_count?: number }).total_count === 2)) return false;
|
|
1380
|
-
const prsOnly = handleGithubRequest({ method: 'GET', path: `/search/issues?q=${encodeURIComponent('login is:pr')}`, root });
|
|
2114
|
+
const prsOnly = await handleGithubRequest({ method: 'GET', path: `/search/issues?q=${encodeURIComponent('login is:pr')}`, root });
|
|
1381
2115
|
const items = (prsOnly.body as { items?: Array<{ pull_request?: unknown }> }).items ?? [];
|
|
1382
2116
|
return (prsOnly.body as { total_count?: number }).total_count === 1 && items.every((i) => 'pull_request' in i);
|
|
1383
2117
|
})),
|
|
1384
2118
|
done('github.search.code', 'search', 'Search: code', 'api', 'common', () =>
|
|
1385
2119
|
withRoot(async (root) => {
|
|
1386
2120
|
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/src/util.ts`, body: JSON.stringify({ message: 'm', content: Buffer.from('x').toString('base64') }), root });
|
|
1387
|
-
const r = handleGithubRequest({ method: 'GET', path: `/search/code?q=${encodeURIComponent('util')}`, root });
|
|
2121
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/search/code?q=${encodeURIComponent('util')}`, root });
|
|
1388
2122
|
return ok(r.status) && (r.body as { total_count?: number }).total_count === 1 && ((r.body as { items?: Array<{ path?: string }> }).items?.[0]?.path === 'src/util.ts');
|
|
1389
2123
|
})),
|
|
1390
2124
|
done('github.search.repos', 'search', 'Search: repos', 'api', 'common', () =>
|
|
1391
2125
|
withRoot(async (root) => {
|
|
1392
2126
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'searchme', description: 'a findable repo' }), root });
|
|
1393
|
-
const r = handleGithubRequest({ method: 'GET', path: `/search/repositories?q=${encodeURIComponent('searchme')}`, root });
|
|
2127
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/search/repositories?q=${encodeURIComponent('searchme')}`, root });
|
|
1394
2128
|
return ok(r.status) && (r.body as { total_count?: number }).total_count === 1;
|
|
1395
2129
|
})),
|
|
1396
2130
|
done('github.search.users', 'search', 'Search: users', 'api', 'common', () =>
|
|
1397
2131
|
withRoot(async (root) => {
|
|
1398
|
-
await
|
|
1399
|
-
const r = handleGithubRequest({ method: 'GET', path: `/search/users?q=${encodeURIComponent('alice')}`, root });
|
|
2132
|
+
await collaborator(root, REPO, 'alice', 'push');
|
|
2133
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/search/users?q=${encodeURIComponent('alice')}`, root });
|
|
1400
2134
|
return ok(r.status) && (r.body as { total_count?: number }).total_count === 1 && ((r.body as { items?: Array<{ login?: string }> }).items?.[0]?.login === 'alice');
|
|
1401
2135
|
})),
|
|
1402
2136
|
done('github.search.commits', 'search', 'Search: commits', 'api', 'common', () =>
|
|
1403
2137
|
withRoot(async (root) => {
|
|
1404
2138
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'p', head: 'a', base: 'main', commits: [{ sha: 'abcd', message: 'initial commit' }] }), root });
|
|
1405
|
-
const r = handleGithubRequest({ method: 'GET', path: `/search/commits?q=${encodeURIComponent('initial')}`, root });
|
|
2139
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/search/commits?q=${encodeURIComponent('initial')}`, root });
|
|
1406
2140
|
return ok(r.status) && (r.body as { total_count?: number }).total_count === 1;
|
|
1407
2141
|
})),
|
|
1408
2142
|
|
|
1409
2143
|
// ── Notifications ────────────────────────────────────────────────────────────────────────
|
|
1410
|
-
done('github.notifications.list', 'notifications', 'Notifications: list/mark-read
|
|
2144
|
+
done('github.notifications.list', 'notifications', 'Notifications: an @mention lands in the inbox; list/mark-read', 'api', 'common', () =>
|
|
1411
2145
|
withRoot(async (root) => {
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
2146
|
+
await pushFiles(root, {});
|
|
2147
|
+
await mentionedIn(root, HISTORY_REPO, 'New PR');
|
|
2148
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/notifications`, root });
|
|
2149
|
+
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { unread?: boolean; reason?: string }).unread === true && (list.body[0] as { reason?: string }).reason === 'mention')) return false;
|
|
2150
|
+
const id = (list.body[0] as { id: string }).id;
|
|
1417
2151
|
const read = await applyGithubWrite({ method: 'PATCH', path: `/notifications/threads/${id}`, body: '{}', root });
|
|
1418
2152
|
if (read.response.status !== 205) return false;
|
|
1419
|
-
const after = handleGithubRequest({ method: 'GET', path: `/notifications`, root });
|
|
1420
|
-
const allList = handleGithubRequest({ method: 'GET', path: `/notifications?all=true`, root });
|
|
2153
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/notifications`, root });
|
|
2154
|
+
const allList = await handleGithubRequest({ method: 'GET', path: `/notifications?all=true`, root });
|
|
1421
2155
|
return isArr(after.body) && after.body.length === 0 && isArr(allList.body) && allList.body.length === 1;
|
|
1422
2156
|
})),
|
|
1423
2157
|
|
|
1424
2158
|
// ── Platform meta endpoints (stateless reference catalogs) ───────────────────────────────
|
|
1425
2159
|
done('github.meta.rate_limit', 'meta', 'Rate limit: GET /rate_limit envelope (resources + rate)', 'api', 'core', () =>
|
|
1426
2160
|
withRoot(async (root) => {
|
|
1427
|
-
const r = handleGithubRequest({ method: 'GET', path: `/rate_limit`, root });
|
|
2161
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/rate_limit`, root });
|
|
1428
2162
|
const b = r.body as { resources?: { core?: { limit?: number; remaining?: number } }; rate?: { limit?: number } };
|
|
1429
2163
|
return ok(r.status) && b.resources?.core?.limit === 5000 && b.resources?.core?.remaining === 5000 && b.rate?.limit === 5000;
|
|
1430
2164
|
})),
|
|
@@ -1439,31 +2173,31 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1439
2173
|
})),
|
|
1440
2174
|
done('github.meta.gitignore', 'meta', 'gitignore templates: list + GET one (name/source)', 'api', 'niche', () =>
|
|
1441
2175
|
withRoot(async (root) => {
|
|
1442
|
-
const list = handleGithubRequest({ method: 'GET', path: `/gitignore/templates`, root });
|
|
2176
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/gitignore/templates`, root });
|
|
1443
2177
|
if (!(ok(list.status) && isArr(list.body) && (list.body as string[]).includes('Node'))) return false;
|
|
1444
|
-
const one = handleGithubRequest({ method: 'GET', path: `/gitignore/templates/Node`, root });
|
|
2178
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/gitignore/templates/Node`, root });
|
|
1445
2179
|
const b = one.body as { name?: string; source?: string };
|
|
1446
2180
|
if (!(ok(one.status) && b.name === 'Node' && typeof b.source === 'string' && b.source.length > 0)) return false;
|
|
1447
|
-
return handleGithubRequest({ method: 'GET', path: `/gitignore/templates/Nope`, root }).status === 404;
|
|
2181
|
+
return (await handleGithubRequest({ method: 'GET', path: `/gitignore/templates/Nope`, root })).status === 404;
|
|
1448
2182
|
})),
|
|
1449
2183
|
done('github.meta.licenses', 'meta', 'Licenses: list + GET one (key/spdx_id)', 'api', 'niche', () =>
|
|
1450
2184
|
withRoot(async (root) => {
|
|
1451
|
-
const list = handleGithubRequest({ method: 'GET', path: `/licenses`, root });
|
|
2185
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/licenses`, root });
|
|
1452
2186
|
if (!(ok(list.status) && isArr(list.body) && (list.body as Array<{ key?: string }>).some((l) => l.key === 'mit'))) return false;
|
|
1453
|
-
const one = handleGithubRequest({ method: 'GET', path: `/licenses/mit`, root });
|
|
2187
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/licenses/mit`, root });
|
|
1454
2188
|
const b = one.body as { spdx_id?: string; key?: string };
|
|
1455
2189
|
if (!(ok(one.status) && b.key === 'mit' && b.spdx_id === 'MIT')) return false;
|
|
1456
|
-
return handleGithubRequest({ method: 'GET', path: `/licenses/nope`, root }).status === 404;
|
|
2190
|
+
return (await handleGithubRequest({ method: 'GET', path: `/licenses/nope`, root })).status === 404;
|
|
1457
2191
|
})),
|
|
1458
2192
|
done('github.meta.emojis', 'meta', 'Emojis: GET /emojis name→asset-url map', 'api', 'niche', () =>
|
|
1459
2193
|
withRoot(async (root) => {
|
|
1460
|
-
const r = handleGithubRequest({ method: 'GET', path: `/emojis`, root });
|
|
2194
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/emojis`, root });
|
|
1461
2195
|
const b = r.body as Record<string, string>;
|
|
1462
2196
|
return ok(r.status) && typeof b['+1'] === 'string' && b['rocket']!.startsWith('https://');
|
|
1463
2197
|
})),
|
|
1464
2198
|
done('github.meta.service_meta', 'meta', 'Meta: GET /meta service metadata envelope', 'api', 'niche', () =>
|
|
1465
2199
|
withRoot(async (root) => {
|
|
1466
|
-
const r = handleGithubRequest({ method: 'GET', path: `/meta`, root });
|
|
2200
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/meta`, root });
|
|
1467
2201
|
const b = r.body as { verifiable_password_authentication?: boolean; hooks?: unknown[] };
|
|
1468
2202
|
return ok(r.status) && typeof b.verifiable_password_authentication === 'boolean' && Array.isArray(b.hooks);
|
|
1469
2203
|
})),
|
|
@@ -1473,73 +2207,82 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1473
2207
|
withRoot(async (root) => {
|
|
1474
2208
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1475
2209
|
// not starred yet → 404 on the membership check.
|
|
1476
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root }).status !== 404) return false;
|
|
2210
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root })).status !== 404) return false;
|
|
1477
2211
|
const star = await applyGithubWrite({ method: 'PUT', path: `/user/starred/octo/demo`, root });
|
|
1478
2212
|
if (!(star.response.status === 204 && star.webhook?.event === 'star' && star.webhook.action === 'created')) return false;
|
|
1479
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root }).status !== 204) return false;
|
|
1480
|
-
const gz = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/stargazers`, root });
|
|
2213
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root })).status !== 204) return false;
|
|
2214
|
+
const gz = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/stargazers`, root });
|
|
1481
2215
|
if (!(ok(gz.status) && isArr(gz.body) && gz.body.length === 1 && (gz.body[0] as { login?: string }).login === 'octocat')) return false;
|
|
1482
|
-
const mine = handleGithubRequest({ method: 'GET', path: `/user/starred`, root });
|
|
2216
|
+
const mine = await handleGithubRequest({ method: 'GET', path: `/user/starred`, root });
|
|
1483
2217
|
if (!(ok(mine.status) && isArr(mine.body) && mine.body.length === 1)) return false;
|
|
1484
2218
|
const un = await applyGithubWrite({ method: 'DELETE', path: `/user/starred/octo/demo`, root });
|
|
1485
|
-
return un.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root }).status === 404;
|
|
2219
|
+
return un.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/user/starred/octo/demo`, root })).status === 404;
|
|
1486
2220
|
})),
|
|
1487
2221
|
done('github.activity.watching', 'activity', 'Watching: PUT/DELETE/GET .../subscription + subscribers', 'api', 'common', () =>
|
|
1488
2222
|
withRoot(async (root) => {
|
|
1489
2223
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1490
2224
|
// not watching yet → 404.
|
|
1491
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root }).status !== 404) return false;
|
|
2225
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root })).status !== 404) return false;
|
|
1492
2226
|
const w = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/subscription`, root });
|
|
1493
2227
|
const wb = w.response.body as { subscribed?: boolean };
|
|
1494
2228
|
if (!(ok(w.response.status) && wb.subscribed === true)) return false;
|
|
1495
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root });
|
|
2229
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root });
|
|
1496
2230
|
if (!(ok(get.status) && (get.body as { subscribed?: boolean }).subscribed === true)) return false;
|
|
1497
|
-
const subs = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscribers`, root });
|
|
2231
|
+
const subs = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscribers`, root });
|
|
1498
2232
|
if (!(ok(subs.status) && isArr(subs.body) && subs.body.length === 1)) return false;
|
|
1499
2233
|
const un = await applyGithubWrite({ method: 'DELETE', path: `/repos/octo/demo/subscription`, root });
|
|
1500
|
-
return un.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root }).status === 404;
|
|
2234
|
+
return un.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/subscription`, root })).status === 404;
|
|
1501
2235
|
})),
|
|
1502
2236
|
done('github.activity.feeds', 'activity', 'Activity: feeds + received/public events streams', 'api', 'niche', () =>
|
|
1503
2237
|
withRoot(async (root) => {
|
|
1504
2238
|
// GET /feeds returns the feed-URL catalog (with timeline + _links). The event streams
|
|
1505
2239
|
// (public timeline, per-user events, received_events) are DERIVED from modeled objects:
|
|
1506
2240
|
// a merged PR + an issue produce PullRequestEvent + IssuesEvent in the stream.
|
|
1507
|
-
const feeds = handleGithubRequest({ method: 'GET', path: '/feeds', root });
|
|
2241
|
+
const feeds = await handleGithubRequest({ method: 'GET', path: '/feeds', root });
|
|
1508
2242
|
if (!(ok(feeds.status) && typeof (feeds.body as { timeline_url?: string }).timeline_url === 'string' && (feeds.body as { _links?: object })._links)) return false;
|
|
1509
2243
|
await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/pulls`, body: JSON.stringify({ title: 'p', head: 'a', base: 'main' }), root });
|
|
1510
2244
|
await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/pulls/1/merge`, body: '{}', root });
|
|
1511
2245
|
await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/issues`, body: JSON.stringify({ title: 'i' }), root });
|
|
1512
|
-
const pub = handleGithubRequest({ method: 'GET', path: '/events', root });
|
|
2246
|
+
const pub = await handleGithubRequest({ method: 'GET', path: '/events', root });
|
|
1513
2247
|
if (!(ok(pub.status) && isArr(pub.body) && pub.body.length === 2)) return false;
|
|
1514
2248
|
const types = new Set((pub.body as Array<{ type?: string }>).map((e) => e.type));
|
|
1515
2249
|
if (!(types.has('PullRequestEvent') && types.has('IssuesEvent'))) return false;
|
|
1516
2250
|
// octo OWNS octo/demo → its received_events stream carries both; an unknown user's is empty.
|
|
1517
|
-
const received = handleGithubRequest({ method: 'GET', path: '/users/octo/received_events', root });
|
|
1518
|
-
const userEv = handleGithubRequest({ method: 'GET', path: '/users/octo/events', root });
|
|
1519
|
-
const empty = handleGithubRequest({ method: 'GET', path: '/users/nobody/events', root });
|
|
1520
|
-
const repoEv = handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/events', root });
|
|
1521
|
-
|
|
2251
|
+
const received = await handleGithubRequest({ method: 'GET', path: '/users/octo/received_events', root });
|
|
2252
|
+
const userEv = await handleGithubRequest({ method: 'GET', path: '/users/octo/events', root });
|
|
2253
|
+
const empty = await handleGithubRequest({ method: 'GET', path: '/users/nobody/events', root });
|
|
2254
|
+
const repoEv = await handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/events', root });
|
|
2255
|
+
if (!(ok(received.status) && isArr(received.body) && received.body.length === 2
|
|
1522
2256
|
&& ok(userEv.status) && isArr(userEv.body) && userEv.body.length === 2
|
|
1523
2257
|
&& ok(empty.status) && isArr(empty.body) && empty.body.length === 0
|
|
1524
|
-
&& ok(repoEv.status) && isArr(repoEv.body) && repoEv.body.length === 2;
|
|
2258
|
+
&& ok(repoEv.status) && isArr(repoEv.body) && repoEv.body.length === 2)) return false;
|
|
2259
|
+
// Commenting on the ISSUE materializes a counter shell in the PR store — it is not a
|
|
2260
|
+
// pull request, and it may not fire a PullRequestEvent. The stream reads the same
|
|
2261
|
+
// pull-request marker `GET .../pulls` does; two doors onto one world, one answer.
|
|
2262
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/octo/demo/issues/2/comments`, body: JSON.stringify({ body: 'me too' }), root });
|
|
2263
|
+
const after = await handleGithubRequest({ method: 'GET', path: '/events', root });
|
|
2264
|
+
const prEvents = (after.body as Array<{ type?: string; payload?: { number?: number } }>).filter((e) => e.type === 'PullRequestEvent');
|
|
2265
|
+
return isArr(after.body) && after.body.length === 3 && prEvents.length === 1 && prEvents[0]!.payload?.number === 1;
|
|
1525
2266
|
})),
|
|
1526
2267
|
done('github.activity.notifications_repo', 'activity', 'Activity: per-repo notifications + thread subscriptions', 'api', 'niche', () =>
|
|
1527
2268
|
withRoot(async (root) => {
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
await applyGithubWrite({ method: 'POST', path: '/
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2269
|
+
// mentions in two repositories; the per-repo inbox returns only that repo's thread
|
|
2270
|
+
await pushFiles(root, {});
|
|
2271
|
+
await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'other', auto_init: true }), root });
|
|
2272
|
+
await mentionedIn(root, HISTORY_REPO, 'PR in ci-demo');
|
|
2273
|
+
await mentionedIn(root, 'octocat/other', 'Issue in other');
|
|
2274
|
+
const inbox = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/notifications`, root });
|
|
2275
|
+
if (!(ok(inbox.status) && isArr(inbox.body) && inbox.body.length === 1 && (inbox.body[0] as { repository?: { full_name?: string } }).repository?.full_name === HISTORY_REPO)) return false;
|
|
2276
|
+
const nid = (inbox.body[0] as { id: string }).id;
|
|
2277
|
+
// a mentioned person is subscribed; ignoring mutes the thread and takes it out of the inbox; unsubscribing clears it
|
|
2278
|
+
const sub = await handleGithubRequest({ method: 'GET', path: `/notifications/threads/${nid}/subscription`, root });
|
|
1537
2279
|
if (!(ok(sub.status) && (sub.body as { subscribed?: boolean }).subscribed === true)) return false;
|
|
1538
2280
|
const ignore = await applyGithubWrite({ method: 'PUT', path: `/notifications/threads/${nid}/subscription`, body: JSON.stringify({ ignored: true }), root });
|
|
1539
2281
|
if (!(ok(ignore.response.status) && (ignore.response.body as { ignored?: boolean }).ignored === true)) return false;
|
|
1540
2282
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/notifications/threads/${nid}/subscription`, root });
|
|
1541
|
-
const after = handleGithubRequest({ method: 'GET', path:
|
|
1542
|
-
|
|
2283
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/notifications`, root });
|
|
2284
|
+
const cleared = await handleGithubRequest({ method: 'GET', path: `/notifications/threads/${nid}/subscription`, root });
|
|
2285
|
+
return del.response.status === 204 && ok(after.status) && isArr(after.body) && after.body.length === 0 && (cleared.body as { subscribed?: boolean }).subscribed === false;
|
|
1543
2286
|
})),
|
|
1544
2287
|
|
|
1545
2288
|
// ── Repo metadata (derived read endpoints) ───────────────────────────────────────────────
|
|
@@ -1547,21 +2290,21 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1547
2290
|
withRoot(async (root) => {
|
|
1548
2291
|
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/src/a.ts`, body: JSON.stringify({ message: 'm', content: Buffer.from('x'.repeat(40)).toString('base64') }), root });
|
|
1549
2292
|
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/main.py`, body: JSON.stringify({ message: 'm', content: Buffer.from('y'.repeat(10)).toString('base64') }), root });
|
|
1550
|
-
const r = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/languages`, root });
|
|
2293
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/languages`, root });
|
|
1551
2294
|
const b = r.body as Record<string, number>;
|
|
1552
2295
|
return ok(r.status) && b.TypeScript === 40 && b.Python === 10;
|
|
1553
2296
|
})),
|
|
1554
2297
|
done('github.repos.contributors', 'repos', 'Repos: contributors (GET .../contributors, commit-author tallies)', 'api', 'common', () =>
|
|
1555
2298
|
withRoot(async (root) => {
|
|
1556
2299
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'p', head: 'a', base: 'main', commits: [{ sha: 'a1', message: 'c1', author_name: 'alice' }, { sha: 'a2', message: 'c2', author_name: 'alice' }, { sha: 'b1', message: 'c3', author_name: 'bob' }] }), root });
|
|
1557
|
-
const r = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contributors`, root });
|
|
2300
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/contributors`, root });
|
|
1558
2301
|
const arr = r.body as Array<{ login?: string; contributions?: number }>;
|
|
1559
2302
|
return ok(r.status) && arr.length === 2 && arr[0]!.login === 'alice' && arr[0]!.contributions === 2 && arr[1]!.login === 'bob';
|
|
1560
2303
|
})),
|
|
1561
2304
|
done('github.repos.compare', 'repos', 'Repos: compare commits (GET .../compare/:base...:head)', 'api', 'common', () =>
|
|
1562
2305
|
withRoot(async (root) => {
|
|
1563
2306
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'p', head: 'feat-sha', base: 'main', commits: [{ sha: 'c1', message: 'one' }], files: [{ filename: 'x.ts', additions: 2, deletions: 0 }] }), root });
|
|
1564
|
-
const r = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/compare/main...feat-sha`, root });
|
|
2307
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/compare/main...feat-sha`, root });
|
|
1565
2308
|
const b = r.body as { total_commits?: number; commits?: unknown[]; files?: Array<{ filename?: string }>; status?: string };
|
|
1566
2309
|
return ok(r.status) && b.total_commits === 1 && b.status === 'ahead' && (b.files?.[0]?.filename === 'x.ts');
|
|
1567
2310
|
})),
|
|
@@ -1572,27 +2315,27 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1572
2315
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/commits/abc123/comments`, body: JSON.stringify({ body: 'nice fix', path: 'a.ts', line: 5 }), root });
|
|
1573
2316
|
if (!(c.response.status === 201 && (c.response.body as { commit_id?: string }).commit_id === 'abc123' && (c.response.body as { path?: string }).path === 'a.ts')) return false;
|
|
1574
2317
|
const id = (c.response.body as { id?: number }).id!;
|
|
1575
|
-
const onCommit = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/comments`, root });
|
|
2318
|
+
const onCommit = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/abc123/comments`, root });
|
|
1576
2319
|
if (!(ok(onCommit.status) && isArr(onCommit.body) && onCommit.body.length === 1)) return false;
|
|
1577
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments/${id}`, root });
|
|
2320
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments/${id}`, root });
|
|
1578
2321
|
if (!(ok(one.status) && (one.body as { body?: string }).body === 'nice fix')) return false;
|
|
1579
2322
|
// edit body; delete → repo-wide list empties.
|
|
1580
2323
|
const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/comments/${id}`, body: JSON.stringify({ body: 'edited' }), root });
|
|
1581
2324
|
if ((e.response.body as { body?: string }).body !== 'edited') return false;
|
|
1582
2325
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/comments/${id}`, root });
|
|
1583
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments`, root });
|
|
2326
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments`, root });
|
|
1584
2327
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 0
|
|
1585
|
-
&& handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments/${id}`, root }).status === 404;
|
|
2328
|
+
&& (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/comments/${id}`, root })).status === 404;
|
|
1586
2329
|
})),
|
|
1587
2330
|
done('github.repos.readme', 'repos', 'Repos: GET .../readme (rendered + raw)', 'api', 'niche', () =>
|
|
1588
2331
|
withRoot(async (root) => {
|
|
1589
2332
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1590
2333
|
// no README yet → 404; write README.md then it returns the base64 content object.
|
|
1591
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/readme`, root }).status !== 404) return false;
|
|
2334
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/readme`, root })).status !== 404) return false;
|
|
1592
2335
|
const b64 = Buffer.from('# Demo\nhello').toString('base64');
|
|
1593
2336
|
const put = await applyGithubWrite({ method: 'PUT', path: `/repos/octo/demo/contents/README.md`, body: JSON.stringify({ message: 'add readme', content: b64 }), root });
|
|
1594
2337
|
if (put.response.status !== 201) return false;
|
|
1595
|
-
const r = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/readme`, root });
|
|
2338
|
+
const r = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/readme`, root });
|
|
1596
2339
|
return ok(r.status) && (r.body as { name?: string }).name === 'README.md' && (r.body as { encoding?: string }).encoding === 'base64'
|
|
1597
2340
|
&& Buffer.from(String((r.body as { content?: string }).content ?? ''), 'base64').toString() === '# Demo\nhello';
|
|
1598
2341
|
})),
|
|
@@ -1600,14 +2343,14 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1600
2343
|
withRoot(async (root) => {
|
|
1601
2344
|
// traffic on a missing repo → 404; on a real repo → vendor-shaped zero-history payloads
|
|
1602
2345
|
// (per-account traffic is not observable offline — honestly empty, never fabricated).
|
|
1603
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/views`, root }).status !== 404) return false;
|
|
2346
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/views`, root })).status !== 404) return false;
|
|
1604
2347
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1605
|
-
const views = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/views`, root });
|
|
2348
|
+
const views = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/views`, root });
|
|
1606
2349
|
if (!(ok(views.status) && (views.body as { count?: number }).count === 0 && isArr((views.body as { views?: unknown[] }).views))) return false;
|
|
1607
|
-
const clones = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/clones`, root });
|
|
2350
|
+
const clones = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/clones`, root });
|
|
1608
2351
|
if (!(ok(clones.status) && isArr((clones.body as { clones?: unknown[] }).clones))) return false;
|
|
1609
|
-
const refs = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/popular/referrers`, root });
|
|
1610
|
-
const paths = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/popular/paths`, root });
|
|
2352
|
+
const refs = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/popular/referrers`, root });
|
|
2353
|
+
const paths = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/traffic/popular/paths`, root });
|
|
1611
2354
|
return ok(refs.status) && isArr(refs.body) && ok(paths.status) && isArr(paths.body);
|
|
1612
2355
|
})),
|
|
1613
2356
|
done('github.repos.deploy_keys', 'repos', 'Repos: deploy keys CRUD', 'api', 'niche', () =>
|
|
@@ -1618,12 +2361,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1618
2361
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/keys`, body: JSON.stringify({ title: 'deploy', key: 'ssh-ed25519 AAAA', read_only: false }), root });
|
|
1619
2362
|
if (!(c.response.status === 201 && (c.response.body as { read_only?: boolean }).read_only === false)) return false;
|
|
1620
2363
|
const id = (c.response.body as { id?: number }).id!;
|
|
1621
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys/${id}`, root });
|
|
2364
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys/${id}`, root });
|
|
1622
2365
|
if (!(ok(one.status) && (one.body as { title?: string }).title === 'deploy')) return false;
|
|
1623
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys`, root });
|
|
2366
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys`, root });
|
|
1624
2367
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1625
2368
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/keys/${id}`, root });
|
|
1626
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys/${id}`, root }).status === 404;
|
|
2369
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/keys/${id}`, root })).status === 404;
|
|
1627
2370
|
})),
|
|
1628
2371
|
done('github.repos.autolinks', 'repos', 'Repos: autolink references CRUD', 'api', 'niche', () =>
|
|
1629
2372
|
withRoot(async (root) => {
|
|
@@ -1632,21 +2375,21 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1632
2375
|
const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/autolinks`, body: JSON.stringify({ key_prefix: 'TICKET-', url_template: 'https://jira/<num>', is_alphanumeric: false }), root });
|
|
1633
2376
|
if (!(c.response.status === 201 && (c.response.body as { is_alphanumeric?: boolean }).is_alphanumeric === false)) return false;
|
|
1634
2377
|
const id = (c.response.body as { id?: number }).id!;
|
|
1635
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks/${id}`, root });
|
|
2378
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks/${id}`, root });
|
|
1636
2379
|
if (!(ok(one.status) && (one.body as { key_prefix?: string }).key_prefix === 'TICKET-')) return false;
|
|
1637
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks`, root });
|
|
2380
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks`, root });
|
|
1638
2381
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1639
2382
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/autolinks/${id}`, root });
|
|
1640
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks/${id}`, root }).status === 404;
|
|
2383
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/autolinks/${id}`, root })).status === 404;
|
|
1641
2384
|
})),
|
|
1642
2385
|
done('github.repos.tarball', 'repos', 'Repos: tar/zipball archive download', 'api', 'niche', () =>
|
|
1643
2386
|
withRoot(async (root) => {
|
|
1644
|
-
// archive of a missing repo → 404; a real repo → 302 redirect to codeload (bytes
|
|
1645
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/tarball`, root }).status !== 404) return false;
|
|
2387
|
+
// archive of a missing repo → 404; a real repo → 302 redirect to codeload (serving the bytes is a todo).
|
|
2388
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/tarball`, root })).status !== 404) return false;
|
|
1646
2389
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo', default_branch: 'main' }), root });
|
|
1647
|
-
const tar = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/tarball`, root });
|
|
2390
|
+
const tar = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/tarball`, root });
|
|
1648
2391
|
if (!(tar.status === 302 && String((tar.body as { location?: string }).location).includes('legacy.tar.gz'))) return false;
|
|
1649
|
-
const zip = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/zipball/v1.0.0`, root });
|
|
2392
|
+
const zip = await handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/zipball/v1.0.0`, root });
|
|
1650
2393
|
return zip.status === 302 && String((zip.body as { location?: string }).location).includes('legacy.zip/v1.0.0');
|
|
1651
2394
|
})),
|
|
1652
2395
|
done('github.repos.rulesets', 'repos', 'Repos: rulesets (the newer branch/tag rule engine)', 'api', 'niche', () =>
|
|
@@ -1658,12 +2401,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1658
2401
|
const id = (c.response.body as { id?: number }).id!;
|
|
1659
2402
|
const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/rulesets/${id}`, body: JSON.stringify({ enforcement: 'disabled' }), root });
|
|
1660
2403
|
if (!(ok(upd.response.status) && (upd.response.body as { enforcement?: string }).enforcement === 'disabled')) return false;
|
|
1661
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${id}`, root });
|
|
2404
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${id}`, root });
|
|
1662
2405
|
if (!(ok(one.status) && (one.body as { name?: string }).name === 'main-protection')) return false;
|
|
1663
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets`, root });
|
|
2406
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets`, root });
|
|
1664
2407
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1665
2408
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/rulesets/${id}`, root });
|
|
1666
|
-
return del.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${id}`, root }).status === 404;
|
|
2409
|
+
return del.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${id}`, root })).status === 404;
|
|
1667
2410
|
})),
|
|
1668
2411
|
|
|
1669
2412
|
// ── Deployments / Environments ───────────────────────────────────────────────────────────
|
|
@@ -1676,11 +2419,11 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1676
2419
|
if (!(c.response.status === 201 && (c.response.body as { environment?: string }).environment === 'production')) return false;
|
|
1677
2420
|
const id = (c.response.body as { id?: number }).id!;
|
|
1678
2421
|
// get one + list (newest-first) + ?environment filter.
|
|
1679
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments/${id}`, root });
|
|
2422
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments/${id}`, root });
|
|
1680
2423
|
if (!(ok(one.status) && (one.body as { id?: number }).id === id)) return false;
|
|
1681
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments`, root });
|
|
2424
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments`, root });
|
|
1682
2425
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
1683
|
-
const filtered = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments?environment=staging`, root });
|
|
2426
|
+
const filtered = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments?environment=staging`, root });
|
|
1684
2427
|
if (!(isArr(filtered.body) && filtered.body.length === 0)) return false;
|
|
1685
2428
|
// statuses: invalid state → 422; status on a missing deployment → 404.
|
|
1686
2429
|
const badState = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/deployments/${id}/statuses`, body: JSON.stringify({ state: 'nope' }), root });
|
|
@@ -1690,7 +2433,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1690
2433
|
const s1 = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/deployments/${id}/statuses`, body: JSON.stringify({ state: 'in_progress' }), root });
|
|
1691
2434
|
const s2 = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/deployments/${id}/statuses`, body: JSON.stringify({ state: 'success', environment_url: 'https://app.x' }), root });
|
|
1692
2435
|
if (!(s1.response.status === 201 && s2.response.status === 201)) return false;
|
|
1693
|
-
const statuses = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments/${id}/statuses`, root });
|
|
2436
|
+
const statuses = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/deployments/${id}/statuses`, root });
|
|
1694
2437
|
// newest-first → the latest (success) leads.
|
|
1695
2438
|
return ok(statuses.status) && isArr(statuses.body) && statuses.body.length === 2 && (statuses.body[0] as { state?: string }).state === 'success';
|
|
1696
2439
|
})),
|
|
@@ -1701,11 +2444,11 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1701
2444
|
if (!(c.response.status === 200 && (c.response.body as { name?: string }).name === 'production')) return false;
|
|
1702
2445
|
const rules = (c.response.body as { protection_rules?: Array<{ type?: string }> }).protection_rules ?? [];
|
|
1703
2446
|
if (!(rules.some((r) => r.type === 'wait_timer') && rules.some((r) => r.type === 'required_reviewers'))) return false;
|
|
1704
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments/production`, root });
|
|
1705
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments`, root });
|
|
2447
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments/production`, root });
|
|
2448
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments`, root });
|
|
1706
2449
|
if (!(ok(get.status) && ok(list.status) && (list.body as { total_count?: number }).total_count === 1)) return false;
|
|
1707
2450
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/environments/production`, root });
|
|
1708
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments/production`, root });
|
|
2451
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/environments/production`, root });
|
|
1709
2452
|
return del.response.status === 204 && gone.status === 404;
|
|
1710
2453
|
})),
|
|
1711
2454
|
|
|
@@ -1718,12 +2461,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1718
2461
|
const a1 = await applyGithubWrite({ method: 'POST', path: `/user/emails`, body: JSON.stringify({ emails: ['me@x.io'] }), root });
|
|
1719
2462
|
if (!(a1.response.status === 201 && isArr(a1.response.body) && (a1.response.body[0] as { primary?: boolean }).primary === true)) return false;
|
|
1720
2463
|
await applyGithubWrite({ method: 'POST', path: `/user/emails`, body: JSON.stringify({ emails: ['alt@x.io'] }), root });
|
|
1721
|
-
const list = handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
|
|
2464
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
|
|
1722
2465
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 2)) return false;
|
|
1723
|
-
const pub = handleGithubRequest({ method: 'GET', path: `/user/public_emails`, root });
|
|
2466
|
+
const pub = await handleGithubRequest({ method: 'GET', path: `/user/public_emails`, root });
|
|
1724
2467
|
if (!(ok(pub.status) && isArr(pub.body) && pub.body.length === 1 && (pub.body[0] as { email?: string }).email === 'me@x.io')) return false;
|
|
1725
2468
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/user/emails`, body: JSON.stringify({ emails: ['alt@x.io'] }), root });
|
|
1726
|
-
const after = handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
|
|
2469
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
|
|
1727
2470
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
|
|
1728
2471
|
})),
|
|
1729
2472
|
done('github.user.keys', 'users', 'User: SSH + GPG keys CRUD', 'api', 'niche', () =>
|
|
@@ -1733,38 +2476,38 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1733
2476
|
const ssh = await applyGithubWrite({ method: 'POST', path: `/user/keys`, body: JSON.stringify({ title: 'laptop', key: 'ssh-ed25519 AAAA' }), root });
|
|
1734
2477
|
if (!(ssh.response.status === 201 && (ssh.response.body as { title?: string }).title === 'laptop')) return false;
|
|
1735
2478
|
const sid = (ssh.response.body as { id?: number }).id!;
|
|
1736
|
-
if (!(ok(handleGithubRequest({ method: 'GET', path: `/user/keys/${sid}`, root }).status))) return false;
|
|
1737
|
-
const sshList = handleGithubRequest({ method: 'GET', path: `/user/keys`, root });
|
|
2479
|
+
if (!(ok((await handleGithubRequest({ method: 'GET', path: `/user/keys/${sid}`, root })).status))) return false;
|
|
2480
|
+
const sshList = await handleGithubRequest({ method: 'GET', path: `/user/keys`, root });
|
|
1738
2481
|
if (!(isArr(sshList.body) && sshList.body.length === 1)) return false;
|
|
1739
2482
|
const sdel = await applyGithubWrite({ method: 'DELETE', path: `/user/keys/${sid}`, root });
|
|
1740
|
-
if (!(sdel.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/user/keys/${sid}`, root }).status === 404)) return false;
|
|
2483
|
+
if (!(sdel.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/user/keys/${sid}`, root })).status === 404)) return false;
|
|
1741
2484
|
if ((await applyGithubWrite({ method: 'POST', path: `/user/gpg_keys`, body: '{}', root })).response.status !== 422) return false;
|
|
1742
2485
|
const gpg = await applyGithubWrite({ method: 'POST', path: `/user/gpg_keys`, body: JSON.stringify({ armored_public_key: '-----BEGIN PGP-----\nabc\n-----END PGP-----' }), root });
|
|
1743
2486
|
if (!(gpg.response.status === 201 && typeof (gpg.response.body as { key_id?: string }).key_id === 'string')) return false;
|
|
1744
2487
|
const gid = (gpg.response.body as { id?: number }).id!;
|
|
1745
|
-
const gpgList = handleGithubRequest({ method: 'GET', path: `/user/gpg_keys`, root });
|
|
2488
|
+
const gpgList = await handleGithubRequest({ method: 'GET', path: `/user/gpg_keys`, root });
|
|
1746
2489
|
if (!(isArr(gpgList.body) && gpgList.body.length === 1)) return false;
|
|
1747
2490
|
const gdel = await applyGithubWrite({ method: 'DELETE', path: `/user/gpg_keys/${gid}`, root });
|
|
1748
|
-
return gdel.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/user/gpg_keys/${gid}`, root }).status === 404;
|
|
2491
|
+
return gdel.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/user/gpg_keys/${gid}`, root })).status === 404;
|
|
1749
2492
|
})),
|
|
1750
2493
|
done('github.user.update', 'users', 'User: PATCH /user (update authenticated profile)', 'api', 'niche', () =>
|
|
1751
2494
|
withRoot(async (root) => {
|
|
1752
2495
|
const r = await applyGithubWrite({ method: 'PATCH', path: `/user`, body: JSON.stringify({ name: 'Octo Cat', bio: 'builder', company: '@octo' }), root });
|
|
1753
2496
|
if (!(ok(r.response.status) && (r.response.body as { name?: string }).name === 'Octo Cat' && (r.response.body as { bio?: string }).bio === 'builder')) return false;
|
|
1754
|
-
const get = handleGithubRequest({ method: 'GET', path: `/user`, root });
|
|
2497
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/user`, root });
|
|
1755
2498
|
return ok(get.status) && (get.body as { name?: string }).name === 'Octo Cat' && (get.body as { company?: string }).company === '@octo' && (get.body as { login?: string }).login === 'octocat';
|
|
1756
2499
|
})),
|
|
1757
2500
|
done('github.user.blocking', 'users', 'User: block/unblock users', 'api', 'niche', () =>
|
|
1758
2501
|
withRoot(async (root) => {
|
|
1759
2502
|
// not blocked → check 404; block → 204; appears in list + check 204; unblock → check 404.
|
|
1760
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root }).status !== 404) return false;
|
|
2503
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root })).status !== 404) return false;
|
|
1761
2504
|
const b = await applyGithubWrite({ method: 'PUT', path: `/user/blocks/spammer`, body: '{}', root });
|
|
1762
2505
|
if (b.response.status !== 204) return false;
|
|
1763
|
-
if (handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root }).status !== 204) return false;
|
|
1764
|
-
const list = handleGithubRequest({ method: 'GET', path: `/user/blocks`, root });
|
|
2506
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root })).status !== 204) return false;
|
|
2507
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/user/blocks`, root });
|
|
1765
2508
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { login?: string }).login === 'spammer')) return false;
|
|
1766
2509
|
const u = await applyGithubWrite({ method: 'DELETE', path: `/user/blocks/spammer`, root });
|
|
1767
|
-
return u.response.status === 204 && handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root }).status === 404;
|
|
2510
|
+
return u.response.status === 204 && (await handleGithubRequest({ method: 'GET', path: `/user/blocks/spammer`, root })).status === 404;
|
|
1768
2511
|
})),
|
|
1769
2512
|
|
|
1770
2513
|
// ── Issues (remaining surface) ───────────────────────────────────────────────────────────
|
|
@@ -1773,23 +2516,23 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1773
2516
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'ev' }), root });
|
|
1774
2517
|
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['bug'], assignees: ['bob'], state: 'closed', state_reason: 'completed' }), root });
|
|
1775
2518
|
// per-issue typed events carry numeric ids; repo-wide list aggregates them; get-by-id works.
|
|
1776
|
-
const perIssue = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/events`, root });
|
|
2519
|
+
const perIssue = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/events`, root });
|
|
1777
2520
|
if (!(ok(perIssue.status) && isArr(perIssue.body) && perIssue.body.length >= 3)) return false;
|
|
1778
|
-
const repoWide = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events`, root });
|
|
2521
|
+
const repoWide = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events`, root });
|
|
1779
2522
|
if (!(ok(repoWide.status) && isArr(repoWide.body) && repoWide.body.length >= 3 && (repoWide.body[0] as { issue?: { number?: number } }).issue?.number === 1)) return false;
|
|
1780
2523
|
const eid = (perIssue.body[0] as { id?: number }).id!;
|
|
1781
|
-
const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events/${eid}`, root });
|
|
2524
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events/${eid}`, root });
|
|
1782
2525
|
if (!(ok(one.status) && (one.body as { id?: number }).id === eid && typeof (one.body as { event?: string }).event === 'string')) return false;
|
|
1783
|
-
return handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events/999999`, root }).status === 404;
|
|
2526
|
+
return (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/events/999999`, root })).status === 404;
|
|
1784
2527
|
})),
|
|
1785
2528
|
done('github.issues.assignees_check', 'issues', 'Issues: assignable users list + check', 'api', 'niche', () =>
|
|
1786
2529
|
withRoot(async (root) => {
|
|
1787
2530
|
await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo' }), root });
|
|
1788
|
-
await
|
|
2531
|
+
await collaborator(root, REPO, 'alice', 'push');
|
|
1789
2532
|
// an assignable collaborator → check 204; a non-collaborator → 404; the list includes alice.
|
|
1790
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees/alice`, root }).status !== 204) return false;
|
|
1791
|
-
if (handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees/nobody`, root }).status !== 404) return false;
|
|
1792
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees`, root });
|
|
2533
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees/alice`, root })).status !== 204) return false;
|
|
2534
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees/nobody`, root })).status !== 404) return false;
|
|
2535
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/assignees`, root });
|
|
1793
2536
|
return ok(list.status) && isArr(list.body) && list.body.some((u) => (u as { login?: string }).login === 'alice');
|
|
1794
2537
|
})),
|
|
1795
2538
|
|
|
@@ -1803,11 +2546,11 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1803
2546
|
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, body: JSON.stringify({ content: 'nope' }), root })).response.status !== 422) return false;
|
|
1804
2547
|
const r = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, body: JSON.stringify({ content: 'rocket' }), root });
|
|
1805
2548
|
if (r.response.status !== 201) return false;
|
|
1806
|
-
const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, root });
|
|
2549
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, root });
|
|
1807
2550
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { content?: string }).content === 'rocket')) return false;
|
|
1808
2551
|
const rid = (r.response.body as { id?: number }).id!;
|
|
1809
2552
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/pulls/comments/${cid}/reactions/${rid}`, root });
|
|
1810
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, root });
|
|
2553
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/comments/${cid}/reactions`, root });
|
|
1811
2554
|
return del.response.status === 204 && isArr(after.body) && after.body.length === 0;
|
|
1812
2555
|
})),
|
|
1813
2556
|
done('github.pulls.review_dismiss', 'pulls', 'PRs: dismiss a review + update a review body', 'api', 'niche', () =>
|
|
@@ -1819,12 +2562,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1819
2562
|
if ((await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/reviews/${rid}/dismissals`, body: '{}', root })).response.status !== 422) return false;
|
|
1820
2563
|
const dm = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/reviews/${rid}/dismissals`, body: JSON.stringify({ message: 'stale' }), root });
|
|
1821
2564
|
if (!(ok(dm.response.status) && (dm.response.body as { state?: string }).state === 'DISMISSED' && dm.webhook?.action === 'dismissed')) return false;
|
|
1822
|
-
const listed = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
2565
|
+
const listed = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
1823
2566
|
if (!(ok(listed.status) && isArr(listed.body) && (listed.body[0] as { state?: string }).state === 'DISMISSED')) return false;
|
|
1824
2567
|
// update the review body (PUT .../reviews/:rid); 422 without a body; read-back reflects it.
|
|
1825
2568
|
if ((await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/reviews/${rid}`, body: '{}', root })).response.status !== 422) return false;
|
|
1826
2569
|
const up = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/reviews/${rid}`, body: JSON.stringify({ body: 'edited note' }), root });
|
|
1827
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
2570
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
|
|
1828
2571
|
// a dismiss on an unknown review id 404s.
|
|
1829
2572
|
const missing = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/reviews/9999/dismissals`, body: JSON.stringify({ message: 'x' }), root });
|
|
1830
2573
|
return ok(up.response.status) && (after.body as Array<{ body?: string }>)[0]?.body === 'edited note' && missing.response.status === 404;
|
|
@@ -1833,122 +2576,134 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1833
2576
|
// ── Packages / Pages ─────────────────────────────────────────────────────────────────────
|
|
1834
2577
|
done('github.packages.list', 'packages', 'Packages: list/get/delete versions', 'api', 'niche', () =>
|
|
1835
2578
|
withRoot(async (root) => {
|
|
1836
|
-
//
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
const
|
|
2579
|
+
// npm publish two versions to GitHub Packages, list/get, delete one version then the package.
|
|
2580
|
+
const doc = { name: '@octocat/my-lib', versions: { '1.0.0': { version: '1.0.0' }, '1.1.0': { version: '1.1.0' } } };
|
|
2581
|
+
const published = await githubNpmRegistry({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://npm.pkg.github.com/@octocat/my-lib', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(doc) }));
|
|
2582
|
+
const pkg = await handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib', root });
|
|
2583
|
+
if (!(published?.status === 201 && (pkg.body as { version_count?: number }).version_count === 2)) return false;
|
|
2584
|
+
const list = await handleGithubRequest({ method: 'GET', path: '/user/packages?package_type=npm', root });
|
|
1840
2585
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { name?: string }).name === 'my-lib')) return false;
|
|
1841
|
-
const versions = handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib/versions', root });
|
|
2586
|
+
const versions = await handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib/versions', root });
|
|
1842
2587
|
if (!(ok(versions.status) && isArr(versions.body) && versions.body.length === 2)) return false;
|
|
1843
2588
|
const vid = (versions.body as Array<{ id?: number }>)[0]!.id!;
|
|
1844
|
-
const one = handleGithubRequest({ method: 'GET', path: `/user/packages/npm/my-lib/versions/${vid}`, root });
|
|
2589
|
+
const one = await handleGithubRequest({ method: 'GET', path: `/user/packages/npm/my-lib/versions/${vid}`, root });
|
|
1845
2590
|
if (!(ok(one.status) && (one.body as { id?: number }).id === vid)) return false;
|
|
1846
2591
|
const delV = await applyGithubWrite({ method: 'DELETE', path: `/user/packages/npm/my-lib/versions/${vid}`, root });
|
|
1847
|
-
const after = handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib/versions', root });
|
|
2592
|
+
const after = await handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib/versions', root });
|
|
1848
2593
|
if (!(delV.response.status === 204 && isArr(after.body) && after.body.length === 1)) return false;
|
|
1849
2594
|
const delP = await applyGithubWrite({ method: 'DELETE', path: '/user/packages/npm/my-lib', root });
|
|
1850
|
-
const gone = handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib', root });
|
|
2595
|
+
const gone = await handleGithubRequest({ method: 'GET', path: '/user/packages/npm/my-lib', root });
|
|
1851
2596
|
// a delete of an unknown package 404s.
|
|
1852
2597
|
const missing = await applyGithubWrite({ method: 'DELETE', path: '/user/packages/npm/nope', root });
|
|
1853
2598
|
return delP.response.status === 204 && gone.status === 404 && missing.response.status === 404;
|
|
1854
2599
|
})),
|
|
1855
|
-
done('github.pages.get', 'pages', 'Pages: site config + builds', 'api', 'niche', () =>
|
|
2600
|
+
done('github.pages.get', 'pages', 'Pages: site config + builds; none for a repository that does not exist', 'api', 'niche', () =>
|
|
1856
2601
|
withRoot(async (root) => {
|
|
1857
|
-
// no site → 404; create (POST) → 201 building; update source (PUT) → 204; request a build.
|
|
1858
|
-
if (
|
|
2602
|
+
// no repository → no site; no site → 404; create (POST) → 201 building; update source (PUT) → 204; request a build.
|
|
2603
|
+
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pages`, body: '{}', root })).response.status !== 404) return false;
|
|
2604
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: REPO.split('/')[0] }), root, cookie: await signIn(root) });
|
|
2605
|
+
await applyGithubWrite({ method: 'POST', path: `/orgs/${REPO.split('/')[0]}/repos`, body: JSON.stringify({ name: REPO.split('/')[1] }), root });
|
|
2606
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root })).status !== 404) return false;
|
|
1859
2607
|
const create = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pages`, body: JSON.stringify({ source: { branch: 'gh-pages', path: '/' } }), root });
|
|
1860
2608
|
if (!(create.response.status === 201 && (create.response.body as { source?: { branch?: string } }).source?.branch === 'gh-pages')) return false;
|
|
1861
2609
|
// creating twice 409s.
|
|
1862
2610
|
if ((await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pages`, body: '{}', root })).response.status !== 409) return false;
|
|
1863
2611
|
const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pages`, body: JSON.stringify({ cname: 'docs.example.com', source: { branch: 'main', path: '/docs' } }), root });
|
|
1864
2612
|
if (upd.response.status !== 204) return false;
|
|
1865
|
-
const site = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root });
|
|
2613
|
+
const site = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root });
|
|
1866
2614
|
if (!(ok(site.status) && (site.body as { cname?: string }).cname === 'docs.example.com' && (site.body as { source?: { path?: string } }).source?.path === '/docs')) return false;
|
|
1867
2615
|
const build = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pages/builds`, body: '{}', root });
|
|
1868
2616
|
if (build.response.status !== 202) return false;
|
|
1869
|
-
const builds = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages/builds`, root });
|
|
1870
|
-
const latest = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages/builds/latest`, root });
|
|
2617
|
+
const builds = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages/builds`, root });
|
|
2618
|
+
const latest = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages/builds/latest`, root });
|
|
1871
2619
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/pages`, root });
|
|
1872
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root });
|
|
2620
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root });
|
|
1873
2621
|
return ok(builds.status) && isArr(builds.body) && builds.body.length === 1 && ok(latest.status) && (latest.body as { status?: string }).status === 'built' && del.response.status === 204 && gone.status === 404;
|
|
1874
2622
|
})),
|
|
1875
2623
|
done('github.codespaces.crud', 'codespaces', 'Codespaces: list/create/start/stop/delete', 'api', 'niche', () =>
|
|
1876
2624
|
withRoot(async (root) => {
|
|
1877
2625
|
// create requires a repo (422 without); create → 201 Available; stop → Shutdown; start →
|
|
1878
|
-
// Available; delete → 202 then gone. (The VM COMPUTE is
|
|
2626
|
+
// Available; delete → 202 then gone. (The VM COMPUTE is what the real root does; the RESOURCE
|
|
1879
2627
|
// lifecycle/state machine IS modeled and verified here.)
|
|
1880
2628
|
if ((await applyGithubWrite({ method: 'POST', path: '/user/codespaces', body: '{}', root })).response.status !== 422) return false;
|
|
1881
2629
|
const c = await applyGithubWrite({ method: 'POST', path: '/user/codespaces', body: JSON.stringify({ repository: REPO, machine: 'standardLinux32gb', ref: 'feature' }), root });
|
|
1882
2630
|
if (!(c.response.status === 201 && (c.response.body as { state?: string }).state === 'Available')) return false;
|
|
1883
2631
|
const name = (c.response.body as { name?: string }).name!;
|
|
1884
|
-
const list = handleGithubRequest({ method: 'GET', path: '/user/codespaces', root });
|
|
2632
|
+
const list = await handleGithubRequest({ method: 'GET', path: '/user/codespaces', root });
|
|
1885
2633
|
if (!(ok(list.status) && (list.body as { total_count?: number }).total_count === 1)) return false;
|
|
1886
2634
|
const stop = await applyGithubWrite({ method: 'POST', path: `/user/codespaces/${name}/stop`, body: '{}', root });
|
|
1887
2635
|
if (!(ok(stop.response.status) && (stop.response.body as { state?: string }).state === 'Shutdown')) return false;
|
|
1888
2636
|
const start = await applyGithubWrite({ method: 'POST', path: `/user/codespaces/${name}/start`, body: '{}', root });
|
|
1889
2637
|
if (!(ok(start.response.status) && (start.response.body as { state?: string }).state === 'Available')) return false;
|
|
1890
|
-
const get = handleGithubRequest({ method: 'GET', path: `/user/codespaces/${name}`, root });
|
|
2638
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/user/codespaces/${name}`, root });
|
|
1891
2639
|
if (!(ok(get.status) && (get.body as { git_status?: { ref?: string } }).git_status?.ref === 'feature')) return false;
|
|
1892
2640
|
const del = await applyGithubWrite({ method: 'DELETE', path: `/user/codespaces/${name}`, root });
|
|
1893
|
-
const gone = handleGithubRequest({ method: 'GET', path: `/user/codespaces/${name}`, root });
|
|
2641
|
+
const gone = await handleGithubRequest({ method: 'GET', path: `/user/codespaces/${name}`, root });
|
|
1894
2642
|
return del.response.status === 202 && gone.status === 404;
|
|
1895
2643
|
})),
|
|
1896
2644
|
|
|
1897
2645
|
// ── Security (code scanning / dependabot / secret scanning) ──────────────────────────────
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
const
|
|
1904
|
-
if (
|
|
1905
|
-
const
|
|
2646
|
+
// GitHub raises the alerts itself, from what is pushed and what a scanner uploads; their state
|
|
2647
|
+
// machines (open→dismissed/fixed/resolved) are what a person moves.
|
|
2648
|
+
done('github.security.code_scanning', 'security', 'Code scanning: an uploaded analysis opens alerts; alerts + analyses', 'api', 'niche', () =>
|
|
2649
|
+
withRoot(async (root) => {
|
|
2650
|
+
const sha = await pushFiles(root, { 'src/q.js': 'db.query(`SELECT * FROM t WHERE id = ${id}`);\n' });
|
|
2651
|
+
const up = await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/code-scanning/sarifs`, body: JSON.stringify({ commit_sha: sha, ref: 'refs/heads/main', sarif: sarifOf([{ rule: 'js/sql-injection', level: 'error', uri: 'src/q.js', line: 1 }]) }), root });
|
|
2652
|
+
if (up.response.status !== 202) return false;
|
|
2653
|
+
const opened = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/code-scanning/alerts/1`, root });
|
|
2654
|
+
if (!(ok(opened.status) && (opened.body as { state?: string }).state === 'open')) return false;
|
|
2655
|
+
const n = 1;
|
|
1906
2656
|
// dismiss requires a valid reason (422 without / invalid); dismissing reads back dismissed.
|
|
1907
|
-
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
1908
|
-
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
1909
|
-
const dm = await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
2657
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed' }), root })).response.status !== 422) return false;
|
|
2658
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'bogus' }), root })).response.status !== 422) return false;
|
|
2659
|
+
const dm = await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'false positive' }), root });
|
|
1910
2660
|
if (!(ok(dm.response.status) && (dm.response.body as { state?: string }).state === 'dismissed' && (dm.response.body as { dismissed_reason?: string }).dismissed_reason === 'false positive')) return false;
|
|
1911
2661
|
// filter by state; the dismissed alert is excluded from ?state=open.
|
|
1912
|
-
const open = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
2662
|
+
const open = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/code-scanning/alerts?state=open`, root });
|
|
1913
2663
|
if (!(ok(open.status) && isArr(open.body) && open.body.length === 0)) return false;
|
|
1914
|
-
//
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
const
|
|
1918
|
-
const
|
|
1919
|
-
const delA = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/code-scanning/analyses/${aid}`, root });
|
|
1920
|
-
const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/code-scanning/analyses`, root });
|
|
2664
|
+
// the upload made one analysis: list → delete.
|
|
2665
|
+
const list = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/code-scanning/analyses`, root });
|
|
2666
|
+
const aid = isArr(list.body) ? (list.body[0] as { id?: number }).id : undefined;
|
|
2667
|
+
const delA = await applyGithubWrite({ method: 'DELETE', path: `/repos/${HISTORY_REPO}/code-scanning/analyses/${aid}`, root });
|
|
2668
|
+
const after = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/code-scanning/analyses`, root });
|
|
1921
2669
|
// an unknown alert PATCH 404s.
|
|
1922
|
-
const missing = await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
2670
|
+
const missing = await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/code-scanning/alerts/999`, body: JSON.stringify({ state: 'open' }), root });
|
|
1923
2671
|
return ok(list.status) && isArr(list.body) && list.body.length === 1 && delA.response.status === 200 && isArr(after.body) && after.body.length === 0 && missing.response.status === 404;
|
|
1924
2672
|
})),
|
|
1925
|
-
done('github.security.dependabot', 'security', 'Dependabot: alerts +
|
|
1926
|
-
withRoot(async (root) => {
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
if ((await
|
|
1931
|
-
|
|
2673
|
+
done('github.security.dependabot', 'security', 'Dependabot: alerts turned on (vulnerability-alerts) read the manifests, and a vulnerable dependency pushed opens an alert; alerts + dismissals', 'api', 'niche', () =>
|
|
2674
|
+
withRoot(async (root) => {
|
|
2675
|
+
await pushFiles(root, { 'package.json': JSON.stringify({ name: 'demo', dependencies: { minimist: '1.2.0' } }) });
|
|
2676
|
+
// off until turned on, which reads the default branch's manifests at once
|
|
2677
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/vulnerability-alerts`, root })).status !== 404) return false;
|
|
2678
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts`, root })).status !== 403) return false;
|
|
2679
|
+
if ((await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/vulnerability-alerts`, root })).response.status !== 204) return false;
|
|
2680
|
+
if ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/vulnerability-alerts`, root })).status !== 204) return false;
|
|
2681
|
+
const a = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts/1`, root });
|
|
2682
|
+
if (!(ok(a.status) && (a.body as { state?: string }).state === 'open' && (a.body as { security_advisory?: { ghsa_id?: string } }).security_advisory?.ghsa_id === 'GHSA-xvch-5gv4-984h')) return false;
|
|
2683
|
+
const n = 1;
|
|
2684
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'dismissed' }), root })).response.status !== 422) return false;
|
|
2685
|
+
const dm = await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'tolerable_risk' }), root });
|
|
1932
2686
|
if (!(ok(dm.response.status) && (dm.response.body as { state?: string }).state === 'dismissed')) return false;
|
|
1933
2687
|
// reopen → state open + dismissal cleared.
|
|
1934
|
-
const re = await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
2688
|
+
const re = await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'open' }), root });
|
|
1935
2689
|
if (!(ok(re.response.status) && (re.response.body as { state?: string }).state === 'open' && (re.response.body as { dismissed_reason?: unknown }).dismissed_reason === null)) return false;
|
|
1936
|
-
const dismissed = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1937
|
-
const get = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1938
|
-
return ok(dismissed.status) && isArr(dismissed.body) && dismissed.body.length === 0 && ok(get.status) && (get.body as {
|
|
2690
|
+
const dismissed = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts?state=dismissed`, root });
|
|
2691
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts/${n}`, root });
|
|
2692
|
+
return ok(dismissed.status) && isArr(dismissed.body) && dismissed.body.length === 0 && ok(get.status) && (get.body as { security_vulnerability?: { severity?: string } }).security_vulnerability?.severity === 'critical';
|
|
1939
2693
|
})),
|
|
1940
|
-
done('github.security.secret_scanning', 'security', 'Secret scanning: alerts', 'api', 'niche', () =>
|
|
2694
|
+
done('github.security.secret_scanning', 'security', 'Secret scanning: a pushed secret opens an alert; alerts + locations', 'api', 'niche', () =>
|
|
1941
2695
|
withRoot(async (root) => {
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2696
|
+
await pushFiles(root, { '.env': 'GITHUB_TOKEN=ghp_Demo01234567890123456789abcdefghijkl\n' });
|
|
2697
|
+
const a = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts/1`, root });
|
|
2698
|
+
if (!(ok(a.status) && (a.body as { state?: string }).state === 'open')) return false;
|
|
2699
|
+
const n = 1;
|
|
1945
2700
|
// resolving requires a valid resolution (422 without/invalid); resolved reads back resolved.
|
|
1946
|
-
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
1947
|
-
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
1948
|
-
const rs = await applyGithubWrite({ method: 'PATCH', path: `/repos/${
|
|
2701
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved' }), root })).response.status !== 422) return false;
|
|
2702
|
+
if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved', resolution: 'bogus' }), root })).response.status !== 422) return false;
|
|
2703
|
+
const rs = await applyGithubWrite({ method: 'PATCH', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved', resolution: 'revoked' }), root });
|
|
1949
2704
|
if (!(ok(rs.response.status) && (rs.response.body as { state?: string }).state === 'resolved' && (rs.response.body as { resolution?: string }).resolution === 'revoked')) return false;
|
|
1950
|
-
const locs = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
1951
|
-
const open = handleGithubRequest({ method: 'GET', path: `/repos/${
|
|
2705
|
+
const locs = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts/${n}/locations`, root });
|
|
2706
|
+
const open = await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/secret-scanning/alerts?state=open`, root });
|
|
1952
2707
|
return ok(locs.status) && isArr(locs.body) && locs.body.length === 1 && ok(open.status) && isArr(open.body) && open.body.length === 0;
|
|
1953
2708
|
})),
|
|
1954
2709
|
|
|
@@ -1958,6 +2713,10 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1958
2713
|
const gql = (query: string, variables?: Record<string, unknown>) => applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables }), root });
|
|
1959
2714
|
// seed a repo + PR + issue via REST, then read them back through GraphQL (SAME projection).
|
|
1960
2715
|
await applyGithubWrite({ method: 'POST', path: '/orgs/octo/repos', body: JSON.stringify({ name: 'demo', private: true }), root });
|
|
2716
|
+
// the repo has a git plane, so the PR's head branch must exist there first (a missing head is GitHub's 422)
|
|
2717
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/branches', root })).body as Array<{ name: string; commit: { sha: string } }>).find((b) => b.name === 'main');
|
|
2718
|
+
if (!main) return false;
|
|
2719
|
+
await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/git/refs', body: JSON.stringify({ ref: 'refs/heads/a', sha: main.commit.sha }), root });
|
|
1961
2720
|
await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/pulls', body: JSON.stringify({ title: 'Add X', head: 'a', base: 'main' }), root });
|
|
1962
2721
|
await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/issues', body: JSON.stringify({ title: 'A bug' }), root });
|
|
1963
2722
|
// viewer
|
|
@@ -1974,7 +2733,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1974
2733
|
// appears in the REST issue-comments list (consistency across surfaces).
|
|
1975
2734
|
const mut = await gql('mutation($s:ID!,$b:String!){ addComment(input:{subjectId:$s,body:$b}){ commentEdge { node { id body } } } }', { s: 'I_octo/demo#2', b: 'from graphql' });
|
|
1976
2735
|
if ((mut.response.body as any).data?.addComment?.commentEdge?.node?.body !== 'from graphql') return false;
|
|
1977
|
-
const restComments = handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/issues/2/comments', root });
|
|
2736
|
+
const restComments = await handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/issues/2/comments', root });
|
|
1978
2737
|
if (!(ok(restComments.status) && isArr(restComments.body) && restComments.body.length === 1 && (restComments.body[0] as { body?: string }).body === 'from graphql')) return false;
|
|
1979
2738
|
// HONESTY: an UNMODELED field raises a typed GraphQL error (not a fabricated value).
|
|
1980
2739
|
const bad = await gql('query { repository(owner:"octo",name:"demo"){ stargazerCount } }');
|
|
@@ -2002,6 +2761,60 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2002
2761
|
const p = (await gql(pq, { o: 'octo', n: 'demo' })).response.body as any;
|
|
2003
2762
|
return p.data?.repository?.pullRequests?.totalCount === 0 && !p.errors;
|
|
2004
2763
|
})),
|
|
2764
|
+
done('github.graphql.create_discussion', 'api', 'GraphQL discussion category lookup and createDiscussion, with persisted replies and repository-scoped category validation', 'api', 'common', () =>
|
|
2765
|
+
withRoot(async (root) => {
|
|
2766
|
+
const gql = async (query: string, variables?: Record<string, unknown>) => (await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables }), root, occurredAt: '2026-02-03T04:05:06.000Z' })).response.body as any;
|
|
2767
|
+
for (const name of ['demo', 'other']) await applyGithubWrite({ method: 'POST', path: '/orgs/octo/repos', body: JSON.stringify({ name }), root });
|
|
2768
|
+
const categories = 'query($name:String!,$first:Int!,$after:String){ repository(owner:"octo",name:$name){ id discussionCategories(first:$first,after:$after){ nodes { id name slug isAnswerable } pageInfo { hasNextPage endCursor } } } }';
|
|
2769
|
+
const repo = (await gql(categories, { name: 'demo', first: 100 })).data?.repository;
|
|
2770
|
+
const cat = repo?.discussionCategories?.nodes?.find((c: any) => c.slug === 'general');
|
|
2771
|
+
if (!cat?.id || cat.name !== 'General' || cat.isAnswerable !== false) return false;
|
|
2772
|
+
const first = (await gql(categories, { name: 'demo', first: 1 })).data?.repository?.discussionCategories;
|
|
2773
|
+
const next = (await gql(categories, { name: 'demo', first: 1, after: first?.pageInfo?.endCursor })).data?.repository?.discussionCategories;
|
|
2774
|
+
if (first?.nodes?.length !== 1 || first.pageInfo.hasNextPage !== true || next?.nodes?.length !== 1 || next.nodes[0].id === first.nodes[0].id) return false;
|
|
2775
|
+
const mutation = 'mutation($input:CreateDiscussionInput!){ createDiscussion(input:$input){ clientMutationId discussion { id number title body createdAt category { id slug } } } }';
|
|
2776
|
+
const input = { repositoryId: repo.id, categoryId: cat.id, title: 'Usage tips', body: 'Try the help command', clientMutationId: 'request-7' };
|
|
2777
|
+
const made = (await gql(mutation, { input })).data?.createDiscussion;
|
|
2778
|
+
const d = made?.discussion;
|
|
2779
|
+
assert.equal(d?.createdAt, '2026-02-03T04:05:06.000Z', 'GraphQL creation keeps the World instant');
|
|
2780
|
+
assert.equal(githubState(root).discussions[0]?.created_at, d.createdAt, 'stored creation keeps the same World instant');
|
|
2781
|
+
if (made?.clientMutationId !== 'request-7' || !d?.id || d.title !== input.title || d.body !== input.body || d.category?.id !== cat.id) return false;
|
|
2782
|
+
const read = () => gql('query { repository(owner:"octo",name:"demo"){ discussions(first:100){ nodes { id title body category { id slug } comments { nodes { body } } } } } }');
|
|
2783
|
+
const stored = (await read()).data?.repository?.discussions?.nodes?.find((x: any) => x.id === d.id);
|
|
2784
|
+
if (stored?.body !== input.body || stored?.category?.id !== cat.id) return false;
|
|
2785
|
+
const reply = await gql('mutation($d:ID!,$b:String!){ addDiscussionComment(input:{discussionId:$d,body:$b}){ comment { body } } }', { d: d.id, b: 'Thanks for the tip' });
|
|
2786
|
+
if (reply.data?.addDiscussionComment?.comment?.body !== 'Thanks for the tip' || !(await read()).data?.repository?.discussions?.nodes?.[0]?.comments?.nodes?.some((c: any) => c.body === 'Thanks for the tip')) return false;
|
|
2787
|
+
for (const changed of [{ categoryId: 'bad' }, { repositoryId: 'R_octo/other' }, { repositoryId: 'R_octo/missing' }, { title: null }, { body: undefined }, { title: 42 }, { clientMutationId: 42 }, { unsupportedInput: 'bad' }]) {
|
|
2788
|
+
if (!(await gql(mutation, { input: { ...input, ...changed } })).errors?.length) return false;
|
|
2789
|
+
}
|
|
2790
|
+
for (const invalid of [mutation.replace('number title', 'unsupportedField number title'), mutation.replace('number title', 'name number title'), mutation.replace('category { id slug }', 'category { unsupportedField }'), mutation.replace('category { id slug }', 'category { emoji }')]) {
|
|
2791
|
+
assert.ok((await gql(invalid, { input })).errors?.length, 'unknown selection refuses before mutation');
|
|
2792
|
+
assert.equal(githubState(root).discussions.length, 1, 'unknown selection never stores a discussion');
|
|
2793
|
+
}
|
|
2794
|
+
assert.ok((await gql(categories, { name: 'missing', first: 100 })).errors?.length, 'missing repository does not invent categories');
|
|
2795
|
+
const inline = 'mutation($r:ID!,$c:ID!,$t:String!,$b:String!){ createDiscussion(input:{repositoryId:$r,categoryId:$c,title:$t,body:$b}){ discussion { title body } } }';
|
|
2796
|
+
for (const [t, b] of [[null, 'body'], ['title', 42]]) {
|
|
2797
|
+
assert.ok((await gql(inline, { r: repo.id, c: cat.id, t, b })).errors?.length, 'inline input preserves variable types');
|
|
2798
|
+
assert.equal(githubState(root).discussions.length, 1, 'invalid typed variable never writes');
|
|
2799
|
+
}
|
|
2800
|
+
const text = 'A "quoted" tip\nwith a second line';
|
|
2801
|
+
const literal = await gql(`mutation { createDiscussion(input:{repositoryId:${JSON.stringify(repo.id)},categoryId:${JSON.stringify(cat.id)},title:"Quoted",body:${JSON.stringify(text)}}){ discussion { body } } }`);
|
|
2802
|
+
assert.equal(literal.data?.createDiscussion?.discussion?.body, text, 'GraphQL string escapes round-trip');
|
|
2803
|
+
if ((await read()).data?.repository?.discussions?.nodes?.length !== 2) return false;
|
|
2804
|
+
// Real transport proves the mutation reaches the server's existing webhook pipeline.
|
|
2805
|
+
const deliveries: Array<{ event: string | null; body: any }> = [];
|
|
2806
|
+
const hook = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) { deliveries.push({ event: req.headers.get('x-github-event'), body: await req.json() }); return new Response('ok'); } });
|
|
2807
|
+
const server = await createGithubTwinServer({ root });
|
|
2808
|
+
try {
|
|
2809
|
+
const registered = await fetch(`http://127.0.0.1:${server.port}/repos/octo/demo/hooks`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'web', events: ['discussion'], config: { url: `http://127.0.0.1:${hook.port}/hook`, content_type: 'json' } }) });
|
|
2810
|
+
assert.equal(registered.status, 201);
|
|
2811
|
+
const response = await fetch(`http://127.0.0.1:${server.port}/graphql`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: mutation, variables: { input: { ...input, title: 'Webhook discussion' } } }) });
|
|
2812
|
+
const result = await response.json() as any;
|
|
2813
|
+
assert.equal(result.data?.createDiscussion?.discussion?.title, 'Webhook discussion');
|
|
2814
|
+
assert.ok(deliveries.some((d) => d.event === 'discussion' && d.body.action === 'created' && d.body.repository?.full_name === 'octo/demo' && d.body.number === result.data.createDiscussion.discussion.number), 'GraphQL creation delivers the discussion webhook');
|
|
2815
|
+
return true;
|
|
2816
|
+
} finally { server.stop(); hook.stop(true); }
|
|
2817
|
+
})),
|
|
2005
2818
|
done('github.graphql.create_pull_request', 'api', 'GraphQL createPullRequest mutation + pullRequests(headRefName,states) connection (gh pr create/view/list)', 'api', 'niche', () =>
|
|
2006
2819
|
withRoot(async (root) => {
|
|
2007
2820
|
const gql = (query: string, variables?: Record<string, unknown>) => applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables }), root });
|
|
@@ -2012,7 +2825,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2012
2825
|
const m = (await gql(mutation, { input: { repositoryId: 'R_octo/demo', baseRefName: 'main', headRefName: 'feat', title: 'Add X', body: 'B', draft: false } })).response.body as any;
|
|
2013
2826
|
if (m.data?.createPullRequest?.pullRequest?.id !== 'PR_octo/demo#1') return false;
|
|
2014
2827
|
// the PR is now real on the REST surface too (cross-surface consistency).
|
|
2015
|
-
const rest = handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/pulls/1', root });
|
|
2828
|
+
const rest = await handleGithubRequest({ method: 'GET', path: '/repos/octo/demo/pulls/1', root });
|
|
2016
2829
|
if (!(ok(rest.status) && (rest.body as any).head?.ref === 'feat')) return false;
|
|
2017
2830
|
// pullRequests(headRefName, states) filters: matching branch returns the node; a miss is empty.
|
|
2018
2831
|
const byBranch = 'query($o:String!,$n:String!,$h:String!,$s:[PullRequestState!]){ repository(owner:$o,name:$n){ pullRequests(headRefName:$h, states:$s, first:30, orderBy:{field:CREATED_AT,direction:DESC}){ totalCount nodes { number headRefName baseRefName url state } pageInfo { hasNextPage } } } }';
|
|
@@ -2022,32 +2835,148 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2022
2835
|
const miss = (await gql(byBranch, { o: 'octo', n: 'demo', h: 'nope', s: ['OPEN'] })).response.body as any;
|
|
2023
2836
|
return miss.data?.repository?.pullRequests?.totalCount === 0 && !miss.errors;
|
|
2024
2837
|
})),
|
|
2025
|
-
done('github.apps.installations', 'api', 'GitHub Apps: installations + installation tokens', 'api', 'niche', () =>
|
|
2026
|
-
withRoot(async (root) => {
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
|
|
2030
|
-
if (
|
|
2031
|
-
const
|
|
2032
|
-
const
|
|
2838
|
+
done('github.apps.installations', 'api', 'GitHub Apps: installed on their page, installations + installation tokens', 'api', 'niche', () =>
|
|
2839
|
+
withRoot(async (root) => {
|
|
2840
|
+
// register an app (twin door: GitHub's settings UI has no API), install it on its page
|
|
2841
|
+
// (src/screens/app-installation.tsx) for one repository, then list, get one, and mint a token.
|
|
2842
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/_twin/apps', body: JSON.stringify({ slug: 'ci-bot', name: 'CI Bot', permissions: { contents: 'read', issues: 'write' }, events: ['push'], setup_url: 'http://app.local/setup' }), root })).response.status !== 201) return false;
|
|
2843
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'demo' }), root })).response.status !== 201) return false;
|
|
2844
|
+
const cookie = await signIn(root);
|
|
2845
|
+
const page = await handleGithubRequest({ method: 'GET', path: '/apps/ci-bot/installations/new', root, cookie });
|
|
2846
|
+
if (!(page.status === 200 && typeof page.body === 'string' && page.body.includes('Install CI Bot') && page.body.includes('Only select repositories'))) return false;
|
|
2847
|
+
const { createGithubTwinFetch } = await import('./github-server.ts');
|
|
2848
|
+
const inst = await createGithubTwinFetch({ root })(new Request('https://github.com/apps/ci-bot/installations/new', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie }, body: 'install=1&target_login=octocat&repository_selection=selected&repositories=demo&state=s1' }));
|
|
2849
|
+
const back = new URL(inst.headers.get('location') ?? 'http://x/');
|
|
2850
|
+
if (!(inst.status === 302 && back.origin === 'http://app.local' && back.searchParams.get('setup_action') === 'install' && back.searchParams.get('state') === 's1')) return false;
|
|
2851
|
+
const id = Number(back.searchParams.get('installation_id'));
|
|
2852
|
+
const list = await handleGithubRequest({ method: 'GET', path: '/app/installations', root, token: appJwt('ci-bot') });
|
|
2033
2853
|
if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
|
|
2034
|
-
const get = handleGithubRequest({ method: 'GET', path: `/app/installations/${id}`, root });
|
|
2035
|
-
if (!(ok(get.status) && (get.body as { app_slug?: string }).app_slug === 'ci-bot')) return false;
|
|
2036
|
-
const tok = await applyGithubWrite({ method: 'POST', path: `/app/installations/${id}/access_tokens`, body: JSON.stringify({ permissions: { contents: 'read' } }), root });
|
|
2854
|
+
const get = await handleGithubRequest({ method: 'GET', path: `/app/installations/${id}`, root, token: appJwt('ci-bot') });
|
|
2855
|
+
if (!(ok(get.status) && (get.body as { app_slug?: string }).app_slug === 'ci-bot' && (get.body as { repository_selection?: string }).repository_selection === 'selected')) return false;
|
|
2856
|
+
const tok = await applyGithubWrite({ method: 'POST', path: `/app/installations/${id}/access_tokens`, body: JSON.stringify({ permissions: { contents: 'read' } }), root, token: appJwt('ci-bot') });
|
|
2037
2857
|
if (!(tok.response.status === 201 && typeof (tok.response.body as { token?: string }).token === 'string' && (tok.response.body as { token?: string }).token!.startsWith('ghs_') && (tok.response.body as { permissions?: { contents?: string } }).permissions?.contents === 'read')) return false;
|
|
2038
2858
|
// a token for an unknown installation 404s.
|
|
2039
|
-
const missing = await applyGithubWrite({ method: 'POST', path: '/app/installations/9999/access_tokens', body: '{}', root });
|
|
2859
|
+
const missing = await applyGithubWrite({ method: 'POST', path: '/app/installations/9999/access_tokens', body: '{}', root, token: appJwt('ci-bot') });
|
|
2040
2860
|
return missing.response.status === 404;
|
|
2041
2861
|
})),
|
|
2042
|
-
done('github.
|
|
2043
|
-
withRoot(async (root) => {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
const
|
|
2862
|
+
done('github.apps.jwt', 'apps', 'Apps: the app speaks for itself with a JWT of its client ID: GET /app, its webhook deliveries (naming their installation), and deleting an installation; no JWT, no answer', 'api', 'niche', () =>
|
|
2863
|
+
withRoot(async (root) => {
|
|
2864
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/apps', body: JSON.stringify({ slug: 'jwt-bot', name: 'JWT Bot', owner: 'octocat', permissions: { contents: 'read' }, events: [], hook_attributes: { url: 'http://127.0.0.1:9/hook' } }), root });
|
|
2865
|
+
const installed = await githubAppInstallationFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/apps/jwt-bot/installations/new', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: await signIn(root) }, body: new URLSearchParams({ install: '1' }).toString() }));
|
|
2866
|
+
if (installed?.status !== 302) return false;
|
|
2867
|
+
const id = ((await handleGithubRequest({ method: 'GET', path: '/app/installations', root, token: appJwt('jwt-bot') })).body as { id: number }[])[0]!.id;
|
|
2868
|
+
if ((await handleGithubRequest({ method: 'GET', path: '/app', root })).status !== 401) return false;
|
|
2869
|
+
const me = (await handleGithubRequest({ method: 'GET', path: '/app', root, token: appJwt('jwt-bot') })).body as { slug: string };
|
|
2870
|
+
const deliveries = (await handleGithubRequest({ method: 'GET', path: '/app/hook/deliveries', root, token: appJwt('jwt-bot') })).body as Array<{ id: number; event: string; installation_id: number | null }>;
|
|
2871
|
+
const one = (await handleGithubRequest({ method: 'GET', path: `/app/hook/deliveries/${deliveries[0]!.id}`, root, token: appJwt('jwt-bot') })).body as { request: { payload: { installation: { id: number } } } };
|
|
2872
|
+
const del = await applyGithubWrite({ method: 'DELETE', path: `/app/installations/${id}`, root, token: appJwt('jwt-bot') });
|
|
2873
|
+
const left = (await handleGithubRequest({ method: 'GET', path: '/app/installations', root, token: appJwt('jwt-bot') })).body as unknown[];
|
|
2874
|
+
return me.slug === 'jwt-bot' && deliveries[0]!.event === 'installation' && deliveries[0]!.installation_id === id && one.request.payload.installation.id === id && del.response.status === 204 && left.length === 0;
|
|
2875
|
+
})),
|
|
2876
|
+
done('github.apps.installation_events', 'apps', 'Apps: an app installed on a repository gets the repository\'s events it subscribes to on its own webhook, with the installation\'s id', 'api', 'niche', () =>
|
|
2877
|
+
withRoot(async (root) => {
|
|
2878
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
2879
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/apps', body: JSON.stringify({ slug: 'pr-bot', name: 'PR Bot', owner: 'octocat', permissions: { checks: 'write', pull_requests: 'read' }, events: ['pull_request'], hook_attributes: { url: 'http://127.0.0.1:9/hook' } }), root });
|
|
2880
|
+
await githubAppInstallationFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/apps/pr-bot/installations/new', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: await signIn(root) }, body: new URLSearchParams({ install: '1', repository_selection: 'selected', repositories: 'ci-demo' }).toString() }));
|
|
2881
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
2882
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/b', sha: main }), root });
|
|
2883
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'b', content: Buffer.from('b\n').toString('base64'), branch: 'b' }), root });
|
|
2884
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'b', base: 'main' }), root });
|
|
2885
|
+
const installation = ((await handleGithubRequest({ method: 'GET', path: '/app/installations', root, token: appJwt('pr-bot') })).body as { id: number }[])[0]!.id;
|
|
2886
|
+
const deliveries = (await handleGithubRequest({ method: 'GET', path: '/app/hook/deliveries', root, token: appJwt('pr-bot') })).body as Array<{ id: number; event: string; action: string | null; installation_id: number | null }>;
|
|
2887
|
+
const pr = deliveries.find((d) => d.event === 'pull_request' && d.action === 'opened');
|
|
2888
|
+
return !!pr && pr.installation_id === installation;
|
|
2889
|
+
})),
|
|
2890
|
+
done('github.apps.manifest', 'apps', 'Apps: registered from a manifest posted to an organization\'s new-app page; the code GitHub redirects back with converts once into the app\'s id, PEM and secrets', 'ui', 'niche', () =>
|
|
2891
|
+
withRoot(async (root) => {
|
|
2892
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'octo' }), root, cookie: await signIn(root) });
|
|
2893
|
+
const manifest = JSON.stringify({ name: 'Octo Linter', url: 'https://lint.example', redirect_url: 'https://lint.example/done', hook_attributes: { url: 'http://127.0.0.1:9/hook' }, default_permissions: { checks: 'write' }, default_events: ['pull_request'] });
|
|
2894
|
+
const page = githubAppManifestFlow({ root, now: () => '2026-01-01T00:00:00.000Z' });
|
|
2895
|
+
const cookie = await signIn(root);
|
|
2896
|
+
const post = (body: Record<string, string>) => page(new Request('https://github.com/organizations/octo/settings/apps/new?state=s1', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie }, body: new URLSearchParams(body).toString() }));
|
|
2897
|
+
const shown = await post({ manifest });
|
|
2898
|
+
if (shown?.status !== 200 || !(await shown.text()).includes('Create GitHub App for')) return false;
|
|
2899
|
+
const made = await post({ manifest, create: '1' });
|
|
2900
|
+
const back = new URL(made?.headers.get('location') ?? 'http://x/');
|
|
2901
|
+
if (made?.status !== 302 || back.origin + back.pathname !== 'https://lint.example/done' || back.searchParams.get('state') !== 's1') return false;
|
|
2902
|
+
const code = back.searchParams.get('code')!;
|
|
2903
|
+
const app = await applyGithubWrite({ method: 'POST', path: `/app-manifests/${code}/conversions`, body: '{}', root });
|
|
2904
|
+
const body = app.response.body as { slug: string; owner: { login: string; type: string }; pem: string; webhook_secret: string; client_secret: string };
|
|
2905
|
+
const again = await applyGithubWrite({ method: 'POST', path: `/app-manifests/${code}/conversions`, body: '{}', root });
|
|
2906
|
+
return app.response.status === 201 && body.slug === 'octo-linter' && body.owner.login === 'octo' && body.owner.type === 'Organization' && body.pem.startsWith('-----BEGIN RSA PRIVATE KEY-----')
|
|
2907
|
+
&& typeof body.webhook_secret === 'string' && typeof body.client_secret === 'string' && again.response.status === 404;
|
|
2908
|
+
})),
|
|
2909
|
+
done('github.discussions.category_by_slug', 'discussions', 'Discussions: GraphQL Repository.discussionCategory(slug:) finds one category (null for no such slug)', 'api', 'niche', () =>
|
|
2910
|
+
withRoot(async (root) => {
|
|
2911
|
+
await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'talk', has_discussions: true }), root });
|
|
2912
|
+
const q = async (slug: string) => (await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query: `query { repository(owner: "octocat", name: "talk") { id discussionCategory(slug: "${slug}") { id name isAnswerable } } }` }), root })).response;
|
|
2913
|
+
const qa = ((await q('q-a')).body as { data: { repository: { id: string; discussionCategory: { name: string; isAnswerable: boolean } | null } } }).data.repository;
|
|
2914
|
+
const none = ((await q('nope')).body as { data: { repository: { discussionCategory: unknown } } }).data.repository.discussionCategory;
|
|
2915
|
+
return qa.discussionCategory?.name === 'Q&A' && qa.discussionCategory.isAnswerable && typeof qa.id === 'string' && none === null;
|
|
2916
|
+
})),
|
|
2917
|
+
done('github.projects.collaborators', 'projects', 'Projects: a person is granted a role on an organization project (GraphQL updateProjectV2Collaborators), found by Query.user', 'api', 'niche', () =>
|
|
2918
|
+
withRoot(async (root) => {
|
|
2919
|
+
await mintToken(root, 'ada');
|
|
2920
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'octo' }), root, cookie: await signIn(root) });
|
|
2921
|
+
const gql = async (query: string, variables: Record<string, unknown> = {}) => (await applyGithubWrite({ method: 'POST', path: '/graphql', body: JSON.stringify({ query, variables }), root })).response.body as { data: any; errors?: unknown[] };
|
|
2922
|
+
const ids = (await gql('query { organization(login: "octo") { id } user(login: "ada") { id } }')).data;
|
|
2923
|
+
const pid = (await gql('mutation($o: ID!) { createProjectV2(input: {ownerId: $o, title: "Board"}) { projectV2 { id } } }', { o: ids.organization.id })).data.createProjectV2.projectV2.id;
|
|
2924
|
+
const granted = (await gql(`mutation { updateProjectV2Collaborators(input: { projectId: "${pid}", collaborators: [{ userId: "${ids.user.id}", role: WRITER }] }) { collaborators { nodes { ... on User { login } } } } }`)).data;
|
|
2925
|
+
const nobody = await gql('query { user(login: "nobody") { id } }');
|
|
2926
|
+
return granted.updateProjectV2Collaborators.collaborators.nodes[0]?.login === 'ada' && (nobody.errors?.length ?? 0) === 1;
|
|
2927
|
+
})),
|
|
2928
|
+
done('github.session.two_factor', 'auth', 'Sign-in: a person with two-factor authentication gives the code their TOTP app shows after the password; a new organization is its signed-in creator\'s', 'ui', 'niche', () =>
|
|
2929
|
+
withRoot(async (root) => {
|
|
2930
|
+
// the new-organization page sends a visitor not signed in to the sign-in
|
|
2931
|
+
const { createGithubTwinFetch } = await import('./github-server.ts');
|
|
2932
|
+
const visitor = await createGithubTwinFetch({ root })(new Request('https://github.com/_twin/orgs', { method: 'POST', body: JSON.stringify({ login: 'octo' }) }));
|
|
2933
|
+
if (!(visitor.status === 302 && visitor.headers.get('location') === '/login?return_to=%2Faccount%2Forganizations%2Fnew')) return false;
|
|
2934
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/users/mona/password', body: JSON.stringify({ password: 'probe-password' }), root });
|
|
2935
|
+
const secret = ((await applyGithubWrite({ method: 'POST', path: '/_twin/users/mona/two-factor', body: '{}', root })).response.body as { secret: string }).secret;
|
|
2936
|
+
if (!/^[A-Z2-7]{32}$/.test(secret)) return false;
|
|
2937
|
+
const at = '2026-01-01T00:00:00.000Z';
|
|
2938
|
+
const flow = githubSessionFlow({ root, now: () => at });
|
|
2939
|
+
const form = (path: string, body: Record<string, string>, cookie?: string) => flow(new Request(`https://github.com${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', ...(cookie ? { cookie } : {}) }, body: new URLSearchParams(body).toString() }));
|
|
2940
|
+
const cookies = (res: Response | undefined) => (res?.headers.getSetCookie() ?? []).map((c) => c.split(';')[0]).join('; ');
|
|
2941
|
+
// the right password sets no session: GitHub asks for the second factor
|
|
2942
|
+
const first = await form('/session', { login: 'mona', password: 'probe-password', return_to: '/account/organizations/new' });
|
|
2943
|
+
if (!(first?.status === 302 && first.headers.get('location') === '/sessions/two-factor/app' && !cookies(first).includes('user_session'))) return false;
|
|
2944
|
+
const pending = cookies(first);
|
|
2945
|
+
const page = await flow(new Request('https://github.com/sessions/two-factor/app', { headers: { cookie: pending } }));
|
|
2946
|
+
if (!(page?.status === 200 && (await page.text()).includes('name="app_otp"'))) return false;
|
|
2947
|
+
// a wrong code shows GitHub's message; the code the app shows signs her in and returns her
|
|
2948
|
+
const wrong = await form('/sessions/two-factor', { app_otp: '000000' === totpCode(secret, totpStep(at)) ? '111111' : '000000' }, pending);
|
|
2949
|
+
if (!(wrong?.status === 200 && (await wrong.text()).includes('Two-factor authentication failed.'))) return false;
|
|
2950
|
+
// her authenticator app (the twin's door) shows a six-digit code for the World clock's now
|
|
2951
|
+
if (!/^\d{6}$/.test(((await handleGithubRequest({ method: 'GET', path: '/_twin/users/mona/totp', root })).body as { code: string }).code)) return false;
|
|
2952
|
+
const signed = await form('/sessions/two-factor', { app_otp: totpCode(secret, totpStep(at)) }, pending);
|
|
2953
|
+
if (!(signed?.status === 302 && signed.headers.get('location') === '/account/organizations/new' && cookies(signed).includes('user_session='))) return false;
|
|
2954
|
+
// the same code is never taken twice
|
|
2955
|
+
const again = cookies(await form('/session', { login: 'mona', password: 'probe-password', return_to: '/' }));
|
|
2956
|
+
if (!(await (await form('/sessions/two-factor', { app_otp: totpCode(secret, totpStep(at)) }, again))!.text()).includes('Two-factor authentication failed.')) return false;
|
|
2957
|
+
const cookie = cookies(signed).split('; ').filter((c) => c.startsWith('user_session=')).join('; ');
|
|
2958
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'octo' }), root, cookie })).response.status !== 201) return false;
|
|
2959
|
+
return ((await handleGithubRequest({ method: 'GET', path: '/orgs/octo/members?role=admin', root })).body as { login: string }[]).some((u) => u.login === 'mona');
|
|
2960
|
+
})),
|
|
2961
|
+
done('github.oauth.flow', 'auth', 'OAuth web flow: consent, code exchange, token scopes', 'api', 'niche', () =>
|
|
2962
|
+
withRoot(async (root) => {
|
|
2963
|
+
// The OAuth web flow (src/screens/oauth.tsx): the user's consent issues a code bound to the scopes they
|
|
2964
|
+
// saw, the application exchanges it once for a token (GitHub's default answer is form-encoded), and a
|
|
2965
|
+
// made-up or reused code is bad_verification_code.
|
|
2966
|
+
const consent = { client_id: 'cid', redirect_uri: 'http://app.local/callback', scope: 'repo read:org', state: 's1' };
|
|
2967
|
+
const cookie = await signIn(root);
|
|
2968
|
+
const page = await handleGithubRequest({ method: 'GET', path: `/login/oauth/authorize?${new URLSearchParams(consent)}`, root, cookie });
|
|
2969
|
+
if (!(page.status === 200 && typeof page.body === 'string' && page.body.includes('Authorize octocat'))) return false;
|
|
2970
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/login/oauth/authorize', body: JSON.stringify({ ...consent, authorize: '1' }), root, cookie })).response.status !== 302) return false;
|
|
2971
|
+
const code = String(twinResources('github', root).find((r) => r.type === 'oauth_code')?.code ?? '');
|
|
2972
|
+
const made = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'cid', code: 'abc' }), root });
|
|
2973
|
+
if (new URLSearchParams(String(made.response.body)).get('error') !== 'bad_verification_code') return false;
|
|
2974
|
+
const ex = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'cid', code }), root });
|
|
2975
|
+
const granted = new URLSearchParams(String(ex.response.body));
|
|
2976
|
+
if (!(ok(ex.response.status) && granted.get('token_type') === 'bearer' && granted.get('scope') === 'read:org,repo')) return false;
|
|
2977
|
+
const token = granted.get('access_token')!;
|
|
2978
|
+
const again = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'cid', code }), root });
|
|
2979
|
+
if (new URLSearchParams(String(again.response.body)).get('error') !== 'bad_verification_code') return false;
|
|
2051
2980
|
// no token → 401; valid token lacking scope → 403; with scope → 200.
|
|
2052
2981
|
if ((await applyGithubWrite({ method: 'POST', path: '/_twin/oauth/call', body: JSON.stringify({ token: 'bad', required_scope: 'repo' }), root })).response.status !== 401) return false;
|
|
2053
2982
|
if ((await applyGithubWrite({ method: 'POST', path: '/_twin/oauth/call', body: JSON.stringify({ token, required_scope: 'admin:org' }), root })).response.status !== 403) return false;
|
|
@@ -2058,6 +2987,274 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2058
2987
|
const afterRevoke = await applyGithubWrite({ method: 'POST', path: '/_twin/oauth/call', body: JSON.stringify({ token, required_scope: 'repo' }), root });
|
|
2059
2988
|
return rev.response.status === 204 && afterRevoke.response.status === 401;
|
|
2060
2989
|
})),
|
|
2990
|
+
done('github.orgs.two_factor_requirement', 'orgs', 'Orgs: the two-factor requirement, reported to owners only', 'api', 'niche', () =>
|
|
2991
|
+
withRoot(async (root) => {
|
|
2992
|
+
// GitHub sets it in its settings UI (the twin's construct stands in) and reports it only to the org's owners.
|
|
2993
|
+
await applyGithubWrite({ method: 'POST', path: '/_twin/orgs', body: JSON.stringify({ login: 'octo', name: 'Octo' }), root, cookie: await signIn(root) });
|
|
2994
|
+
await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/alice`, body: JSON.stringify({ role: 'admin' }), root });
|
|
2995
|
+
await applyGithubWrite({ method: 'PUT', path: `/orgs/octo/memberships/bob`, body: JSON.stringify({ role: 'member' }), root });
|
|
2996
|
+
const [alice, bob] = [await acceptInvite(root, 'octo', 'alice'), await acceptInvite(root, 'octo', 'bob')];
|
|
2997
|
+
const seen = async (token?: string) => ((await handleGithubRequest({ method: 'GET', path: '/orgs/octo', root, ...(token ? { token } : {}) })).body as { two_factor_requirement_enabled?: boolean }).two_factor_requirement_enabled;
|
|
2998
|
+
if (await seen(alice) !== false) return false;
|
|
2999
|
+
if ((await applyGithubWrite({ method: 'PUT', path: '/_twin/orgs/octo/two-factor-requirement', body: JSON.stringify({ enabled: 'yes' }), root })).response.status !== 422) return false;
|
|
3000
|
+
await applyGithubWrite({ method: 'PUT', path: '/_twin/orgs/octo/two-factor-requirement', body: JSON.stringify({ enabled: true }), root });
|
|
3001
|
+
return await seen(alice) === true && await seen(bob) === undefined && await seen() === undefined;
|
|
3002
|
+
})),
|
|
3003
|
+
done('github.actions.environment_approvals', 'actions', 'Actions: a run\'s gated environment reviewed by its required reviewer, and who started the run', 'api', 'niche', () =>
|
|
3004
|
+
withRoot(async (root) => {
|
|
3005
|
+
const mint = async (login: string) => ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
3006
|
+
const [alice, bob] = [await mint('alice'), await mint('bob')];
|
|
3007
|
+
await pushFiles(root, { '.github/workflows/deploy.yml': workflowFile('Deploy') });
|
|
3008
|
+
const env = (await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/environments/production`, body: JSON.stringify({ reviewers: [{ type: 'User', reviewer: { login: 'bob' } }] }), root })).response.body as { id: number };
|
|
3009
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/deploy.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root, token: alice });
|
|
3010
|
+
const run = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root })).body as { workflow_runs: { id: number; actor?: { login?: string } | null }[] }).workflow_runs[0]!;
|
|
3011
|
+
const review = (token: string, state = 'approved') => applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/runs/${run.id}/pending_deployments`, body: JSON.stringify({ environment_ids: [env.id], state, comment: 'ship it' }), root, token });
|
|
3012
|
+
if (run.actor?.login !== 'alice' || (await review(alice)).response.status !== 403 || (await review(bob, 'maybe')).response.status !== 422) return false;
|
|
3013
|
+
if ((await review(bob)).response.status !== 200) return false;
|
|
3014
|
+
const approvals = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${run.id}/approvals`, root })).body as { state: string; user: { login: string }; environments: { name: string }[] }[];
|
|
3015
|
+
return approvals.length === 1 && approvals[0]!.state === 'approved' && approvals[0]!.user.login === 'bob' && approvals[0]!.environments[0]!.name === 'production';
|
|
3016
|
+
})),
|
|
3017
|
+
done('github.actions.runner_completes_run', 'actions', 'Actions: the runner finishes a run (success or failure), never a run whose gated environment was rejected', 'api', 'niche', () =>
|
|
3018
|
+
withRoot(async (root) => {
|
|
3019
|
+
const mint = async (login: string) => ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
3020
|
+
const bob = await mint('bob');
|
|
3021
|
+
await pushFiles(root, { '.github/workflows/deploy.yml': workflowFile('Deploy') });
|
|
3022
|
+
const env = (await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/environments/production`, body: JSON.stringify({ reviewers: [{ type: 'User', reviewer: { login: 'bob' } }] }), root })).response.body as { id: number };
|
|
3023
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/deploy.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
3024
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/deploy.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
3025
|
+
const runs = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root })).body as { workflow_runs: { id: number }[] }).workflow_runs;
|
|
3026
|
+
const [a, b] = runs.map((r) => r.id);
|
|
3027
|
+
const complete = (id: number, conclusion: string) => applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/complete`, body: JSON.stringify({ conclusion }), root });
|
|
3028
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/runs/${b}/pending_deployments`, body: JSON.stringify({ environment_ids: [env.id], state: 'rejected', comment: 'no' }), root, token: bob });
|
|
3029
|
+
if ((await complete(a!, 'maybe')).response.status !== 422 || (await complete(b!, 'success')).response.status !== 409) return false;
|
|
3030
|
+
if ((await complete(a!, 'success')).response.status !== 200 || (await complete(a!, 'failure')).response.status !== 409) return false;
|
|
3031
|
+
const run = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${a}`, root })).body as { status: string; conclusion: string };
|
|
3032
|
+
return run.status === 'completed' && run.conclusion === 'success';
|
|
3033
|
+
})),
|
|
3034
|
+
done('github.actions.pull_request_runs', 'actions', 'Actions: a pull request runs the workflows that listen for pull_request, one job per job of the file; a first-time contributor\'s run from a fork waits in action_required until someone with write access approves it', 'api', 'common', () =>
|
|
3035
|
+
withRoot(async (root) => {
|
|
3036
|
+
await pushFiles(root, { '.github/workflows/ci.yml': 'name: CI\non: [push, pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: npm test\n' });
|
|
3037
|
+
const ada = await mintToken(root, 'ada');
|
|
3038
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/forks`, body: '{}', root, token: ada });
|
|
3039
|
+
await applyGithubWrite({ method: 'PUT', path: '/repos/ada/ci-demo/contents/b.txt', body: JSON.stringify({ message: 'b', content: Buffer.from('b\n').toString('base64') }), root, token: ada });
|
|
3040
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'ada:main', base: 'main' }), root, token: ada });
|
|
3041
|
+
const runs = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs?event=pull_request`, root })).body as { workflow_runs: Array<{ id: number; status: string; conclusion: string | null }> }).workflow_runs;
|
|
3042
|
+
if (runs.length !== 1 || runs[0]!.status !== 'completed' || runs[0]!.conclusion !== 'action_required') return false;
|
|
3043
|
+
const approve = (token?: string) => applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/runs/${runs[0]!.id}/approve`, body: '{}', root, ...(token ? { token } : {}) });
|
|
3044
|
+
if ((await approve(ada)).response.status !== 403 || (await approve()).response.status !== 201 || (await approve()).response.status !== 403) return false;
|
|
3045
|
+
const jobs = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${runs[0]!.id}/jobs`, root })).body as { jobs: Array<{ name: string; status: string }> };
|
|
3046
|
+
return jobs.jobs.length === 1 && jobs.jobs[0]!.name === 'test' && jobs.jobs[0]!.status === 'queued';
|
|
3047
|
+
})),
|
|
3048
|
+
done('github.actions.check_runs', 'actions', 'Actions: each job reports a check run on its commit, named for the job, under the GitHub Actions suite, which meets a branch protection\'s required check once the job succeeds', 'api', 'common', () =>
|
|
3049
|
+
withRoot(async (root) => {
|
|
3050
|
+
await pushFiles(root, { '.github/workflows/ci.yml': workflowFile('CI') });
|
|
3051
|
+
const id = await finishedRun(root, 'ci.yml');
|
|
3052
|
+
const run = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${id}`, root })).body as { head_sha: string; check_suite_url: string };
|
|
3053
|
+
const checks = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/commits/${run.head_sha}/check-runs`, root })).body as { check_runs: Array<{ name: string; conclusion: string; app: { slug: string } | null; check_suite: { id: number } }> };
|
|
3054
|
+
const job = checks.check_runs.find((c) => c.name === 'build');
|
|
3055
|
+
return !!job && job.conclusion === 'success' && job.app?.slug === 'github-actions' && run.check_suite_url.endsWith(`/check-suites/${job.check_suite.id}`);
|
|
3056
|
+
})),
|
|
3057
|
+
done('github.pulls.closing_keywords', 'pulls', 'PRs: a pull request merged into the default branch closes the issues its body or commits link with a closing keyword', 'api', 'core', () =>
|
|
3058
|
+
withRoot(async (root) => {
|
|
3059
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3060
|
+
for (const title of ['one', 'two', 'three']) await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/issues`, body: JSON.stringify({ title }), root });
|
|
3061
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3062
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/fix', sha: main }), root });
|
|
3063
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'Add b\n\nFixes #2', content: Buffer.from('b\n').toString('base64'), branch: 'fix' }), root });
|
|
3064
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'fix', base: 'main', body: 'Closes #1, mentions #3' }), root })).response.body as { number: number };
|
|
3065
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root });
|
|
3066
|
+
const state = async (n: number) => ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/issues/${n}`, root })).body as { state: string }).state;
|
|
3067
|
+
return (await state(1)) === 'closed' && (await state(2)) === 'closed' && (await state(3)) === 'open';
|
|
3068
|
+
})),
|
|
3069
|
+
done('github.issues.closed_by_commit', 'issues', 'Issues: a commit pushed to the default branch closes the issues its message links with a closing keyword; one on another branch does not', 'api', 'common', () =>
|
|
3070
|
+
withRoot(async (root) => {
|
|
3071
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3072
|
+
for (const title of ['one', 'two']) await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/issues`, body: JSON.stringify({ title }), root });
|
|
3073
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3074
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/side', sha: main }), root });
|
|
3075
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/c.txt`, body: JSON.stringify({ message: 'c\n\nFixes #2', content: Buffer.from('c\n').toString('base64'), branch: 'side' }), root });
|
|
3076
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'b\n\nCloses #1', content: Buffer.from('b\n').toString('base64') }), root });
|
|
3077
|
+
const state = async (n: number) => ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/issues/${n}`, root })).body as { state: string }).state;
|
|
3078
|
+
return (await state(1)) === 'closed' && (await state(2)) === 'open';
|
|
3079
|
+
})),
|
|
3080
|
+
done('github.pulls.merge_is_a_push', 'pulls', 'PRs: a merge moves the base branch as a push does: Dependabot re-reads the manifests it changed', 'api', 'common', () =>
|
|
3081
|
+
withRoot(async (root) => {
|
|
3082
|
+
await pushFiles(root, { 'package.json': JSON.stringify({ name: 'demo', dependencies: { minimist: '1.2.5' } }) });
|
|
3083
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/vulnerability-alerts`, root });
|
|
3084
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3085
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/bump', sha: main }), root });
|
|
3086
|
+
const file = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/contents/package.json?ref=bump`, root })).body as { sha: string };
|
|
3087
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/package.json`, body: JSON.stringify({ message: 'Bump minimist', content: Buffer.from(JSON.stringify({ name: 'demo', dependencies: { minimist: '1.2.8' } })).toString('base64'), sha: file.sha, branch: 'bump' }), root });
|
|
3088
|
+
const open = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts`, root })).body as Array<{ state: string }>)[0]?.state;
|
|
3089
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'Bump', head: 'bump', base: 'main' }), root })).response.body as { number: number };
|
|
3090
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root });
|
|
3091
|
+
const after = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/dependabot/alerts`, root })).body as Array<{ state: string }>)[0]?.state;
|
|
3092
|
+
return open === 'open' && after === 'fixed';
|
|
3093
|
+
})),
|
|
3094
|
+
done('github.actions.push_filters', 'actions', 'Actions: a pushed tag runs the workflows whose push filters take it; branch and tag filters decide which refs a push workflow runs for', 'api', 'common', () =>
|
|
3095
|
+
withRoot(async (root) => {
|
|
3096
|
+
const sha = await pushFiles(root, {
|
|
3097
|
+
'.github/workflows/release.yml': "name: Release\non:\n push:\n tags:\n - 'v*'\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: npm pack\n",
|
|
3098
|
+
'.github/workflows/ci.yml': 'name: CI\non:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: npm test\n',
|
|
3099
|
+
});
|
|
3100
|
+
const runs = async (q = '') => ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs${q}`, root })).body as { workflow_runs: Array<{ name: string }> }).workflow_runs;
|
|
3101
|
+
if ((await runs()).some((r) => r.name === 'Release')) return false;
|
|
3102
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/tags/v1.0.0', sha }), root });
|
|
3103
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/tags/nightly', sha }), root });
|
|
3104
|
+
const tagged = await runs('?branch=v1.0.0');
|
|
3105
|
+
return tagged.length === 1 && tagged[0]!.name === 'Release' && (await runs('?branch=nightly')).length === 0;
|
|
3106
|
+
})),
|
|
3107
|
+
done('github.actions.github_token', 'actions', 'Actions: the runner picks up a queued run and its job gets a GITHUB_TOKEN with the workflow\'s permissions, acting as github-actions[bot], until the run finishes', 'api', 'niche', () =>
|
|
3108
|
+
withRoot(async (root) => {
|
|
3109
|
+
await pushFiles(root, { '.github/workflows/ci.yml': 'name: CI\non: workflow_dispatch\npermissions:\n contents: write\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: npm test\n' });
|
|
3110
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
3111
|
+
const id = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root })).body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
3112
|
+
const start = await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/start`, body: '{}', root });
|
|
3113
|
+
const { token, permissions } = start.response.body as { token: string; permissions: Record<string, string> };
|
|
3114
|
+
if (permissions.contents !== 'write' || (await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/start`, body: '{}', root })).response.status !== 409) return false;
|
|
3115
|
+
const run = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${id}`, root })).body as { status: string };
|
|
3116
|
+
const issue = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/issues`, body: JSON.stringify({ title: 'from CI' }), root, token })).response.body as { user?: { login: string } };
|
|
3117
|
+
return run.status === 'in_progress' && issue.user?.login === 'github-actions[bot]';
|
|
3118
|
+
})),
|
|
3119
|
+
done('github.releases.author', 'releases', 'Releases: a release names its author and an asset its uploader, a person or an app\'s bot', 'api', 'common', () =>
|
|
3120
|
+
withRoot(async (root) => {
|
|
3121
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3122
|
+
const ada = await mintToken(root, 'ada');
|
|
3123
|
+
await collaborator(root, HISTORY_REPO, 'ada', 'push');
|
|
3124
|
+
const rel = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/releases`, body: JSON.stringify({ tag_name: 'v1' }), root, token: ada })).response.body as { id: number; author: { login: string } | null };
|
|
3125
|
+
const asset = (await applyGithubWrite({ method: 'POST', path: `https://uploads.github.com/repos/${HISTORY_REPO}/releases/${rel.id}/assets?name=a.tgz`, body: 'a', contentType: 'application/gzip', root })).response.body as { uploader: { login: string } | null };
|
|
3126
|
+
return rel.author?.login === 'ada' && asset.uploader?.login === 'octocat';
|
|
3127
|
+
})),
|
|
3128
|
+
done('github.pulls.merge_moves_branch', 'pulls', 'PRs: after a merge the base branch and its ref name the merge commit', 'api', 'core', () =>
|
|
3129
|
+
withRoot(async (root) => {
|
|
3130
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3131
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3132
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/b', sha: main }), root });
|
|
3133
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'b', content: Buffer.from('b\n').toString('base64'), branch: 'b' }), root });
|
|
3134
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'b', head: 'b', base: 'main' }), root })).response.body as { number: number };
|
|
3135
|
+
const merged = (await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root })).response.body as { sha: string };
|
|
3136
|
+
const branch = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3137
|
+
const ref = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/git/ref/heads/main`, root })).body as { object: { sha: string } }).object.sha;
|
|
3138
|
+
return branch === merged.sha && ref === merged.sha;
|
|
3139
|
+
})),
|
|
3140
|
+
done('github.releases.generated_notes', 'releases', 'Releases: generated release notes list the pull requests merged since the previous release and the new contributors, from generate-notes or generate_release_notes', 'api', 'common', () =>
|
|
3141
|
+
withRoot(async (root) => {
|
|
3142
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3143
|
+
const ada = await mintToken(root, 'ada');
|
|
3144
|
+
await collaborator(root, HISTORY_REPO, 'ada', 'push');
|
|
3145
|
+
const main = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/branches/main`, root })).body as { commit: { sha: string } }).commit.sha;
|
|
3146
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/b', sha: main }), root, token: ada });
|
|
3147
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/contents/b.txt`, body: JSON.stringify({ message: 'b', content: Buffer.from('b\n').toString('base64'), branch: 'b' }), root, token: ada });
|
|
3148
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pulls`, body: JSON.stringify({ title: 'Add b', head: 'b', base: 'main' }), root, token: ada })).response.body as { number: number };
|
|
3149
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${HISTORY_REPO}/pulls/${pr.number}/merge`, body: JSON.stringify({ merge_method: 'squash' }), root });
|
|
3150
|
+
const notes = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/releases/generate-notes`, body: JSON.stringify({ tag_name: 'v1.0.0', target_commitish: 'main' }), root })).response.body as { name: string; body: string };
|
|
3151
|
+
const rel = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/releases`, body: JSON.stringify({ tag_name: 'v1.0.0', generate_release_notes: true }), root })).response.body as { name: string; body: string };
|
|
3152
|
+
const next = (await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/releases/generate-notes`, body: JSON.stringify({ tag_name: 'v1.1.0', target_commitish: 'main' }), root })).response.body as { body: string };
|
|
3153
|
+
return notes.name === 'v1.0.0' && notes.body.includes(`* Add b by @ada in https://github.com/${HISTORY_REPO}/pull/${pr.number}`) && notes.body.includes('@ada made their first contribution')
|
|
3154
|
+
&& rel.body === notes.body && rel.name === 'v1.0.0' && !next.body.includes('Add b') && next.body.includes('compare/v1.0.0...v1.1.0');
|
|
3155
|
+
})),
|
|
3156
|
+
done('github.actions.run_artifacts', 'actions', 'Actions: a run\'s artifacts are uploaded while it runs, and listed for the run (by name too)', 'api', 'niche', () =>
|
|
3157
|
+
withRoot(async (root) => {
|
|
3158
|
+
await pushFiles(root, { '.github/workflows/build.yml': workflowFile('Build', ' - uses: actions/upload-artifact@v4\n with:\n name: dist\n') });
|
|
3159
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/build.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
3160
|
+
const id = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root })).body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
3161
|
+
const list = async () => ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${id}/artifacts?name=dist`, root })).body as { total_count: number }).total_count;
|
|
3162
|
+
if ((await list()) !== 0) return false;
|
|
3163
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/start`, body: '{}', root });
|
|
3164
|
+
if ((await list()) !== 1) return false;
|
|
3165
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/complete`, body: JSON.stringify({ conclusion: 'success' }), root });
|
|
3166
|
+
return (await list()) === 1;
|
|
3167
|
+
})),
|
|
3168
|
+
done('github.pages.deployments', 'pages', 'Pages: a workflow deploys its uploaded artifact with the run\'s GITHUB_TOKEN (pages: write); the deployment succeeds and the site is built', 'api', 'niche', () =>
|
|
3169
|
+
withRoot(async (root) => {
|
|
3170
|
+
const wf = "name: Pages\non: workflow_dispatch\npermissions:\n pages: write\n id-token: write\njobs:\n deploy:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/upload-pages-artifact@v3\n with:\n path: '.'\n - uses: actions/deploy-pages@v4\n";
|
|
3171
|
+
const sha = await pushFiles(root, { 'index.html': '<h1>hi</h1>', '.github/workflows/pages.yml': wf });
|
|
3172
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pages`, body: JSON.stringify({ build_type: 'workflow' }), root });
|
|
3173
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/pages.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
3174
|
+
const id = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs`, root })).body as { workflow_runs: { id: number }[] }).workflow_runs[0]!.id;
|
|
3175
|
+
const token = ((await applyGithubWrite({ method: 'POST', path: `/_twin/repos/${HISTORY_REPO}/actions/runs/${id}/start`, body: '{}', root })).response.body as { token: string }).token;
|
|
3176
|
+
const artifact = ((await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/actions/runs/${id}/artifacts`, root, token })).body as { artifacts: { id: number; name: string }[] }).artifacts[0]!;
|
|
3177
|
+
const deploy = (body: Record<string, unknown>) => applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/pages/deployments`, body: JSON.stringify(body), root, token });
|
|
3178
|
+
if ((await deploy({ artifact_id: artifact.id, pages_build_version: sha })).response.status !== 422) return false;
|
|
3179
|
+
const d = await deploy({ artifact_id: artifact.id, pages_build_version: sha, oidc_token: 'oidc' });
|
|
3180
|
+
const status = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/pages/deployments/${(d.response.body as { id: number }).id}`, root })).body as { status: string };
|
|
3181
|
+
const site = (await handleGithubRequest({ method: 'GET', path: `/repos/${HISTORY_REPO}/pages`, root })).body as { status: string };
|
|
3182
|
+
return artifact.name === 'github-pages' && d.response.status === 200 && status.status === 'succeed' && site.status === 'built';
|
|
3183
|
+
})),
|
|
3184
|
+
done('github.repos.sync_fork', 'repos', 'Repos: a fork\'s branch synced with its upstream (merge-upstream): fast-forwarded, or none when it is not behind', 'api', 'common', () =>
|
|
3185
|
+
withRoot(async (root) => {
|
|
3186
|
+
await pushFiles(root, { 'a.txt': 'a\n' });
|
|
3187
|
+
const ada = await mintToken(root, 'ada');
|
|
3188
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/forks`, body: '{}', root, token: ada });
|
|
3189
|
+
const tip = await pushFiles(root, { 'b.txt': 'b\n' });
|
|
3190
|
+
const sync = async () => (await applyGithubWrite({ method: 'POST', path: '/repos/ada/ci-demo/merge-upstream', body: JSON.stringify({ branch: 'main' }), root, token: ada })).response.body as { merge_type: string };
|
|
3191
|
+
const first = await sync();
|
|
3192
|
+
const branch = ((await handleGithubRequest({ method: 'GET', path: '/repos/ada/ci-demo/branches/main', root })).body as { commit: { sha: string } }).commit.sha;
|
|
3193
|
+
return first.merge_type === 'fast-forward' && branch === tip && (await sync()).merge_type === 'none';
|
|
3194
|
+
})),
|
|
3195
|
+
done('github.pulls.merge_attribution', 'pulls', 'PRs: created_at, merged_by, and a merge commit authored by the person who merged and committed by GitHub', 'api', 'core', () =>
|
|
3196
|
+
withRoot(async (root) => {
|
|
3197
|
+
const mint = async (login: string) => ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
3198
|
+
const [alice, bob] = [await mint('alice'), await mint('bob')];
|
|
3199
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'PR', head: 'feat', base: 'main' }), root, token: alice, occurredAt: '2026-06-01T10:00:00Z' })).response.body as { number: number; created_at?: string };
|
|
3200
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/${pr.number}/merge`, body: '{}', root, token: bob, occurredAt: '2026-06-01T11:00:00Z' });
|
|
3201
|
+
const got = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/${pr.number}`, root })).body as { created_at?: string; merged_by?: { login?: string } | null };
|
|
3202
|
+
return pr.created_at === '2026-06-01T10:00:00Z' && got.created_at === '2026-06-01T10:00:00Z' && got.merged_by?.login === 'bob';
|
|
3203
|
+
})),
|
|
3204
|
+
done('github.repos.ruleset_enforcement', 'repos', 'Repos: a ruleset\'s required review refuses an unapproved merge, lets a listed bypass actor through, and logs each evaluation as a rule suite', 'api', 'core', () =>
|
|
3205
|
+
withRoot(async (root) => {
|
|
3206
|
+
const org = REPO.split('/')[0]!;
|
|
3207
|
+
await applyGithubWrite({ method: 'PUT', path: `/orgs/${org}/memberships/alice`, body: JSON.stringify({ role: 'admin' }), root });
|
|
3208
|
+
await applyGithubWrite({ method: 'PUT', path: `/orgs/${org}/memberships/bob`, body: JSON.stringify({ role: 'member' }), root });
|
|
3209
|
+
const [alice, bob] = [await acceptInvite(root, org, 'alice'), await acceptInvite(root, org, 'bob')];
|
|
3210
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/rulesets`, body: JSON.stringify({ name: 'main', target: 'branch', enforcement: 'active', conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
3211
|
+
bypass_actors: [{ actor_id: 1, actor_type: 'OrganizationAdmin', bypass_mode: 'always' }], rules: [{ type: 'pull_request', parameters: { required_approving_review_count: 1 } }] }), root });
|
|
3212
|
+
const open = async (token: string) => ((await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'PR', head: 'feat', base: 'main' }), root, token })).response.body as { number: number }).number;
|
|
3213
|
+
const merge = (n: number, token: string) => applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/${n}/merge`, body: '{}', root, token });
|
|
3214
|
+
const one = await open(bob);
|
|
3215
|
+
if ((await merge(one, bob)).response.status !== 405) return false;
|
|
3216
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/${one}/reviews`, body: JSON.stringify({ event: 'APPROVE' }), root, token: alice });
|
|
3217
|
+
if ((await merge(one, bob)).response.status !== 200) return false;
|
|
3218
|
+
const two = await open(alice);
|
|
3219
|
+
if ((await merge(two, alice)).response.status !== 200) return false;
|
|
3220
|
+
const suites = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/rule-suites?time_period=month&rule_suite_result=all`, root })).body as { result: string; actor_name: string }[];
|
|
3221
|
+
return suites.map((x) => `${x.result}:${x.actor_name}`).join(',') === 'bypass:alice,pass:bob,fail:bob';
|
|
3222
|
+
})),
|
|
3223
|
+
done('github.repos.ruleset_history', 'repos', 'Repos: a ruleset\'s versions with who wrote each and the ruleset as it stood, and GET /user/:account_id naming them', 'api', 'niche', () =>
|
|
3224
|
+
withRoot(async (root) => {
|
|
3225
|
+
const alice = ((await applyGithubWrite({ method: 'POST', path: '/_twin/users/alice/tokens', body: '{}', root })).response.body as { token: string }).token;
|
|
3226
|
+
const made = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/rulesets`, body: JSON.stringify({ name: 'main', target: 'branch', enforcement: 'active', bypass_actors: [{ actor_id: 1, actor_type: 'OrganizationAdmin', bypass_mode: 'always' }], rules: [] }), root, token: alice })).response.body as { id: number };
|
|
3227
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/rulesets/${made.id}`, body: JSON.stringify({ bypass_actors: [] }), root, token: alice });
|
|
3228
|
+
const history = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${made.id}/history`, root })).body as { version_id: number; actor: { id: number } }[];
|
|
3229
|
+
const first = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/rulesets/${made.id}/history/1`, root })).body as { state: { bypass_actors: unknown[] } };
|
|
3230
|
+
const who = (await handleGithubRequest({ method: 'GET', path: `/user/${history[0]!.actor.id}`, root })).body as { login?: string };
|
|
3231
|
+
return history.length === 2 && history[0]!.version_id === 2 && first.state.bypass_actors.length === 1 && who.login === 'alice';
|
|
3232
|
+
})),
|
|
3233
|
+
done('github.auth.personal-tokens', 'auth', 'Personal access tokens: writes and GET /user belong to the token\'s user', 'api', 'niche', () =>
|
|
3234
|
+
withRoot(async (root) => {
|
|
3235
|
+
// GitHub mints personal tokens in its settings UI; the twin's construct mints one per user. A request carrying
|
|
3236
|
+
// it is that user's: the pull request they open and the review they submit name them, and GET /user does.
|
|
3237
|
+
const mint = async (login: string) => ((await applyGithubWrite({ method: 'POST', path: `/_twin/users/${login}/tokens`, body: '{}', root })).response.body as { token: string }).token;
|
|
3238
|
+
const [alice, bob] = [await mint('alice'), await mint('bob')];
|
|
3239
|
+
if ((await applyGithubWrite({ method: 'POST', path: '/_twin/users/not a login/tokens', body: '{}', root })).response.status !== 422) return false;
|
|
3240
|
+
if (((await handleGithubRequest({ method: 'GET', path: '/user', root, token: alice })).body as { login?: string }).login !== 'alice') return false;
|
|
3241
|
+
const pr = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'PR', head: 'feat', base: 'main' }), root, token: alice })).response.body as { number: number; user?: { login?: string }; head?: { sha?: string } };
|
|
3242
|
+
const review = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/${pr.number}/reviews`, body: JSON.stringify({ event: 'APPROVE' }), root, token: bob })).response.body as { user?: { login?: string } };
|
|
3243
|
+
// an unknown token keeps the twin's default identities
|
|
3244
|
+
const other = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/${pr.number}/reviews`, body: JSON.stringify({ event: 'COMMENT', body: 'x' }), root, token: 'twin-fake-github-token' })).response.body as { user?: { login?: string } };
|
|
3245
|
+
// the pull request is found from its head commit
|
|
3246
|
+
const byCommit = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/${pr.head?.sha}/pulls`, root });
|
|
3247
|
+
if (!(pr.user?.login === 'alice' && review.user?.login === 'bob' && other.user?.login === 'octocat'
|
|
3248
|
+
&& ok(byCommit.status) && (byCommit.body as { number: number; user?: { login?: string } }[]).some((p) => p.number === pr.number && p.user?.login === 'alice'))) return false;
|
|
3249
|
+
// once merged into the default branch, the commit answers with its merged pull request
|
|
3250
|
+
if (!ok((await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/${pr.number}/merge`, body: '{}', root, token: bob })).response.status)) return false;
|
|
3251
|
+
const merged = (await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/${pr.head?.sha}/pulls`, root })).body as { number: number; merged_at?: string | null }[];
|
|
3252
|
+
if (!merged.some((p) => p.number === pr.number && !!p.merged_at)) return false;
|
|
3253
|
+
// a deployment and its status name who created them
|
|
3254
|
+
const dep = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/deployments`, body: JSON.stringify({ ref: 'main', environment: 'production' }), root, token: alice })).response.body as { id: number; creator?: { login?: string } | null };
|
|
3255
|
+
const st = (await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/deployments/${dep.id}/statuses`, body: JSON.stringify({ state: 'success' }), root, token: bob })).response.body as { creator?: { login?: string } | null };
|
|
3256
|
+
return dep.creator?.login === 'alice' && st.creator?.login === 'bob';
|
|
3257
|
+
})),
|
|
2061
3258
|
|
|
2062
3259
|
// ── Next honest gaps (top-down GitHub surface NOT yet modeled — keeps the denominator
|
|
2063
3260
|
// honest; a high % against a thin list would be misleading). Each is a real product
|
|
@@ -2070,7 +3267,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2070
3267
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'bug' }), root });
|
|
2071
3268
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/git/refs`, body: JSON.stringify({ ref: 'refs/heads/feat', sha: shaA }), root });
|
|
2072
3269
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'Fix', head: 'feat', base: 'main', body: 'Closes #1' }), root });
|
|
2073
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'test', head_sha: shaA, status: 'completed', conclusion: 'success' }), root });
|
|
3270
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'test', head_sha: shaA, status: 'completed', conclusion: 'success' }), root, token: await ciToken(root, REPO) });
|
|
2074
3271
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/statuses/${shaA}`, body: JSON.stringify({ state: 'pending', context: 'agent-review' }), root });
|
|
2075
3272
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/statuses/${shaA}`, body: JSON.stringify({ state: 'success', context: 'agent-review' }), root });
|
|
2076
3273
|
await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/2`, body: JSON.stringify({ labels: ['human-required'] }), root });
|
|
@@ -2142,11 +3339,152 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2142
3339
|
todo('github.apps.webhook_config', 'api', 'GitHub Apps: app webhook config + deliveries + manifest conversion', 'api', 'niche'),
|
|
2143
3340
|
|
|
2144
3341
|
// ── Webhooks (event delivery) ────────────────────────────────────────────────────────────
|
|
2145
|
-
done('github.webhooks.core_events', 'webhooks', 'Webhooks:
|
|
2146
|
-
withRoot(async (root) => {
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
3342
|
+
done('github.webhooks.core_events', 'webhooks', 'Webhooks: native PR, issue, comment and review source snapshots', 'connector', 'common', () =>
|
|
3343
|
+
withRoot(async (root) => {
|
|
3344
|
+
const received: Array<{ event: string | null; payload: any }> = [];
|
|
3345
|
+
let pausePush: (() => Promise<void>) | undefined;
|
|
3346
|
+
let releasePush = () => {};
|
|
3347
|
+
const sink = await serveHttp({ port: 0, async fetch(request) {
|
|
3348
|
+
const event = request.headers.get('x-github-event');
|
|
3349
|
+
received.push({ event, payload: await request.json() });
|
|
3350
|
+
if (event === 'push' && pausePush) { const pause = pausePush; pausePush = undefined; await pause(); }
|
|
3351
|
+
return new Response(null, { status: 204 });
|
|
3352
|
+
} });
|
|
3353
|
+
const server = await createGithubTwinServer({ root });
|
|
3354
|
+
const sdk = octoClient({ baseUrl: `http://127.0.0.1:${server.port}` });
|
|
3355
|
+
const api = async (method: string, path: string, body?: Record<string, unknown>): Promise<any> => (await sdk.request(`${method} ${path}`, body === undefined ? undefined : { data: body })).data;
|
|
3356
|
+
const event = (kind: string, action: string): any => {
|
|
3357
|
+
const found = received.filter(d => d.event === kind && d.payload.action === action).at(-1);
|
|
3358
|
+
assert.ok(found, `${kind}:${action} must reach native HTTP receiver`);
|
|
3359
|
+
assert.equal(found.payload.repository.full_name, REPO);
|
|
3360
|
+
return found.payload;
|
|
3361
|
+
};
|
|
3362
|
+
try {
|
|
3363
|
+
await api('POST', '/orgs/octo/repos', { name: 'demo' });
|
|
3364
|
+
await api('POST', `/repos/${REPO}/hooks`, { events: ['push', 'pull_request', 'issues', 'issue_comment', 'pull_request_review', 'pull_request_review_comment'], config: { url: `http://127.0.0.1:${sink.port}/github`, content_type: 'json' } });
|
|
3365
|
+
const initial = await api('PUT', `/repos/${REPO}/contents/a.ts`, { message: 'initial', content: Buffer.from('one\n').toString('base64') });
|
|
3366
|
+
await api('POST', `/repos/${REPO}/git/refs`, { ref: 'refs/heads/topic', sha: initial.commit.sha });
|
|
3367
|
+
const changed = await api('PUT', `/repos/${REPO}/contents/a.ts`, { branch: 'topic', message: 'topic', sha: initial.content.sha, content: Buffer.from('two\n').toString('base64') });
|
|
3368
|
+
const pr = await api('POST', `/repos/${REPO}/pulls`, { title: 'Native source', head: 'topic', base: 'main', draft: true });
|
|
3369
|
+
const pullPath = `/repos/${REPO}/pulls/${pr.number}`;
|
|
3370
|
+
const opened = event('pull_request', 'opened').pull_request;
|
|
3371
|
+
assert.equal(opened.number, pr.number); assert.equal(opened.head.sha, changed.commit.sha); assert.equal(opened.user.login, 'octocat');
|
|
3372
|
+
assert.equal(opened.html_url, pr.html_url); assert.equal(opened.draft, true);
|
|
3373
|
+
assert.ok(Number.isSafeInteger(opened.id) && opened.id > 0); assert.equal(opened.id, pr.id);
|
|
3374
|
+
assert.ok(typeof opened.updated_at === 'string' && Number.isFinite(Date.parse(opened.updated_at)));
|
|
3375
|
+
assert.equal(event('pull_request', 'opened').sender.login, 'octocat');
|
|
3376
|
+
await api('PATCH', pullPath, { title: 'Changed title' });
|
|
3377
|
+
assert.equal(event('pull_request', 'edited').pull_request.title, 'Changed title');
|
|
3378
|
+
await api('POST', '/graphql', { query: 'mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{isDraft}}}', variables: { id: pr.node_id } });
|
|
3379
|
+
await collaborator(root, REPO, 'reviewer', 'push');
|
|
3380
|
+
await api('POST', `${pullPath}/requested_reviewers`, { reviewers: ['reviewer'] });
|
|
3381
|
+
assert.equal(event('pull_request', 'review_requested').pull_request.requested_reviewers[0].login, 'reviewer');
|
|
3382
|
+
await api('DELETE', `${pullPath}/requested_reviewers`, { reviewers: ['reviewer'] });
|
|
3383
|
+
assert.deepEqual(event('pull_request', 'review_request_removed').pull_request.requested_reviewers, []);
|
|
3384
|
+
const review = await api('POST', `${pullPath}/reviews`, { body: 'Review source', event: 'APPROVE' });
|
|
3385
|
+
assert.deepEqual(event('pull_request_review', 'submitted').review, { ...review, state: 'approved' });
|
|
3386
|
+
assert.equal(review.state, 'APPROVED', 'REST review state remains native uppercase');
|
|
3387
|
+
assert.equal(review.user.login, 'octocat'); assert.equal(review.commit_id, changed.commit.sha); assert.ok(review.submitted_at);
|
|
3388
|
+
await api('PUT', `${pullPath}/reviews/${review.id}`, { body: 'Edited review' });
|
|
3389
|
+
assert.equal(event('pull_request_review', 'edited').review.body, 'Edited review');
|
|
3390
|
+
await api('PUT', `${pullPath}/reviews/${review.id}/dismissals`, { message: 'Withdrawn' });
|
|
3391
|
+
assert.equal(event('pull_request_review', 'dismissed').review.state, 'dismissed');
|
|
3392
|
+
assert.equal(event('pull_request_review', 'dismissed').sender.login, 'octocat');
|
|
3393
|
+
const inline = await api('POST', `${pullPath}/comments`, { body: 'Inline source', path: 'a.ts', line: 1, side: 'RIGHT', commit_id: initial.commit.sha });
|
|
3394
|
+
assert.deepEqual(event('pull_request_review_comment', 'created').comment, inline);
|
|
3395
|
+
assert.equal(inline.commit_id, initial.commit.sha, 'inline source targets ancestor A while the PR head is B');
|
|
3396
|
+
assert.equal(inline.user.login, 'octocat'); assert.ok(inline.updated_at); assert.ok(inline.html_url);
|
|
3397
|
+
const reply = await api('POST', `${pullPath}/comments/${inline.id}/replies`, { body: 'Reply source' });
|
|
3398
|
+
assert.deepEqual(event('pull_request_review_comment', 'created').comment, reply); assert.equal(reply.in_reply_to_id, inline.id); assert.equal(reply.commit_id, initial.commit.sha);
|
|
3399
|
+
const batch = await api('POST', `${pullPath}/reviews`, { event: 'COMMENT', commit_id: initial.commit.sha, comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'Batch on ancestor' }] });
|
|
3400
|
+
const batchComment = (await api('GET', `${pullPath}/comments`)).find((comment: any) => comment.pull_request_review_id === batch.id);
|
|
3401
|
+
assert.equal(batchComment.commit_id, initial.commit.sha);
|
|
3402
|
+
assert.equal(event('pull_request_review', 'submitted').review.id, batch.id);
|
|
3403
|
+
assert.equal(event('pull_request_review', 'submitted').review.commit_id, initial.commit.sha);
|
|
3404
|
+
const edited = await api('PATCH', `/repos/${REPO}/pulls/comments/${inline.id}`, { body: 'Edited inline' });
|
|
3405
|
+
assert.deepEqual(event('pull_request_review_comment', 'edited').comment, edited);
|
|
3406
|
+
await api('DELETE', `/repos/${REPO}/pulls/comments/${inline.id}`);
|
|
3407
|
+
assert.deepEqual(event('pull_request_review_comment', 'deleted').comment, edited);
|
|
3408
|
+
const comment = await api('POST', `/repos/${REPO}/issues/${pr.number}/comments`, { body: 'Ordinary source' });
|
|
3409
|
+
assert.equal(comment.user.login, 'octocat');
|
|
3410
|
+
assert.equal(event('issue_comment', 'created').comment.user.login, 'octocat');
|
|
3411
|
+
assert.equal(event('issue_comment', 'created').sender.login, 'octocat');
|
|
3412
|
+
assert.deepEqual(event('issue_comment', 'created').comment, comment);
|
|
3413
|
+
assert.equal(event('issue_comment', 'created').issue.pull_request.url, pr.url);
|
|
3414
|
+
assert.equal(event('issue_comment', 'created').pull_request, undefined, 'ordinary comment retains issue classification');
|
|
3415
|
+
const commentEdit = await api('PATCH', `/repos/${REPO}/issues/comments/${comment.id}`, { body: 'Edited ordinary' });
|
|
3416
|
+
assert.deepEqual(event('issue_comment', 'edited').comment, commentEdit);
|
|
3417
|
+
await api('DELETE', `/repos/${REPO}/issues/comments/${comment.id}`);
|
|
3418
|
+
assert.deepEqual(event('issue_comment', 'deleted').comment, commentEdit);
|
|
3419
|
+
const issue = await api('POST', `/repos/${REPO}/issues`, { title: 'Not a PR' });
|
|
3420
|
+
assert.deepEqual(event('issues', 'opened').issue, issue);
|
|
3421
|
+
await api('POST', `/repos/${REPO}/issues/${issue.number}/comments`, { body: 'Issue source' });
|
|
3422
|
+
assert.equal(event('issue_comment', 'created').issue.pull_request, undefined);
|
|
3423
|
+
await api('POST', '/graphql', { query: 'mutation($id:ID!){convertPullRequestToDraft(input:{pullRequestId:$id}){pullRequest{isDraft}}}', variables: { id: pr.node_id } });
|
|
3424
|
+
await api('POST', '/graphql', { query: 'mutation($id:ID!){r:markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{isDraft}} d:convertPullRequestToDraft(input:{pullRequestId:$id}){pullRequest{isDraft}}}', variables: { id: pr.node_id } });
|
|
3425
|
+
assert.equal(event('pull_request', 'ready_for_review').pull_request.draft, false);
|
|
3426
|
+
assert.equal(event('pull_request', 'converted_to_draft').pull_request.draft, true);
|
|
3427
|
+
let pushArrived!: () => void;
|
|
3428
|
+
const arrived = new Promise<void>(resolve => { pushArrived = resolve; });
|
|
3429
|
+
const held = new Promise<void>(resolve => { releasePush = resolve; });
|
|
3430
|
+
pausePush = () => { pushArrived(); return held; };
|
|
3431
|
+
const beforeDelayed = received.length;
|
|
3432
|
+
const firstWrite = api('PUT', `/repos/${REPO}/contents/a.ts`, { branch: 'topic', message: 'delayed A', sha: changed.content.sha, content: Buffer.from('three\n').toString('base64') });
|
|
3433
|
+
await arrived;
|
|
3434
|
+
const atA = await api('GET', `${pullPath}`);
|
|
3435
|
+
const fileAtA = await api('GET', `/repos/${REPO}/contents/a.ts?ref=topic`);
|
|
3436
|
+
const secondWrite = await api('PUT', `/repos/${REPO}/contents/a.ts`, { branch: 'topic', message: 'later B', sha: fileAtA.sha, content: Buffer.from('four\n').toString('base64') });
|
|
3437
|
+
releasePush();
|
|
3438
|
+
const firstResult = await firstWrite;
|
|
3439
|
+
assert.equal(firstResult.commit.sha, atA.head.sha);
|
|
3440
|
+
const syncs = received.slice(beforeDelayed).filter(d => d.event === 'pull_request' && d.payload.action === 'synchronize').map(d => d.payload.pull_request);
|
|
3441
|
+
assert.equal(syncs.length, 2);
|
|
3442
|
+
assert.deepEqual(syncs.map(p => p.head.sha).sort(), [firstResult.commit.sha, secondWrite.commit.sha].sort(), 'delayed callback must not substitute the later head');
|
|
3443
|
+
assert.equal(syncs.find(p => p.head.sha === firstResult.commit.sha).updated_at, atA.updated_at);
|
|
3444
|
+
await api('POST', '/graphql', { query: 'mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{isDraft}}}', variables: { id: pr.node_id } });
|
|
3445
|
+
const merged = await api('PUT', `${pullPath}/merge`, { merge_method: 'merge' });
|
|
3446
|
+
assert.equal(event('pull_request', 'closed').pull_request.merged, true);
|
|
3447
|
+
assert.equal(event('pull_request', 'closed').pull_request.merge_commit_sha, merged.sha);
|
|
3448
|
+
// Import through the real connector contract, then address the served native IDs.
|
|
3449
|
+
// This distinguishes vendor-origin subjects from locally created comment:<id> rows.
|
|
3450
|
+
const { syncGithubFromReal } = await import('./github-connector.ts');
|
|
3451
|
+
const importedPr = 77;
|
|
3452
|
+
const createdAt = '2026-03-01T09:00:00Z';
|
|
3453
|
+
const vendorUpdatedAt = '2026-03-02T10:00:00Z';
|
|
3454
|
+
const executor = { async request(route: string, params: Record<string, unknown> = {}) {
|
|
3455
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls') return { status: 200, data: params.state === 'closed' ? [] : [{ number: importedPr, title: 'Imported source', state: 'open', head: { sha: initial.commit.sha, ref: 'imported' }, base: { ref: 'main' }, updated_at: vendorUpdatedAt, user: { login: 'upstream-author', type: 'User' } }] };
|
|
3456
|
+
if (route === 'GET /repos/{owner}/{repo}/issues') return { status: 200, data: [] };
|
|
3457
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews') return { status: 200, data: [{ id: 901, state: 'COMMENTED', body: 'Imported review', submitted_at: createdAt, commit_id: initial.commit.sha, user: { login: 'review-author', type: 'User' } }] };
|
|
3458
|
+
if (route === 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments') return { status: 200, data: [{ id: 902, pull_request_review_id: 901, path: 'a.ts', line: 1, body: 'Imported inline', created_at: createdAt, commit_id: initial.commit.sha, user: { login: 'inline-author', type: 'User' } }] };
|
|
3459
|
+
if (route === 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments') return { status: 200, data: [{ id: 903, body: 'Imported ordinary', created_at: createdAt, updated_at: vendorUpdatedAt, user: { login: 'comment-author', type: 'User' } }] };
|
|
3460
|
+
return { status: 404, data: { message: 'Not Found' } };
|
|
3461
|
+
} };
|
|
3462
|
+
await syncGithubFromReal(executor, { owner: 'octo', repo: 'demo', root, state: 'all', occurredAt: '2026-03-03T00:00:00Z' });
|
|
3463
|
+
for (const kind of ['ordinary', 'inline'] as const) {
|
|
3464
|
+
const listPath = kind === 'ordinary' ? `/repos/${REPO}/issues/${importedPr}/comments` : `/repos/${REPO}/pulls/${importedPr}/comments`;
|
|
3465
|
+
const comments = await api('GET', listPath);
|
|
3466
|
+
assert.equal(comments.length, 1);
|
|
3467
|
+
const original = comments[0];
|
|
3468
|
+
const originalAuthor = kind === 'ordinary' ? 'comment-author' : 'inline-author';
|
|
3469
|
+
assert.equal(original.user.login, originalAuthor);
|
|
3470
|
+
if (kind === 'ordinary') { assert.equal(original.created_at, createdAt); assert.equal(original.updated_at, vendorUpdatedAt); }
|
|
3471
|
+
const itemPath = `/repos/${REPO}/${kind === 'ordinary' ? 'issues' : 'pulls'}/comments/${original.id}`;
|
|
3472
|
+
const eventKind = kind === 'ordinary' ? 'issue_comment' : 'pull_request_review_comment';
|
|
3473
|
+
const changed = await api('PATCH', itemPath, { body: `Edited imported ${kind}` });
|
|
3474
|
+
assert.equal(changed.id, original.id); assert.equal(changed.body, `Edited imported ${kind}`); assert.equal(changed.user.login, originalAuthor);
|
|
3475
|
+
assert.equal(changed.created_at, original.created_at); assert.notEqual(changed.updated_at, original.updated_at);
|
|
3476
|
+
assert.deepEqual(await api('GET', itemPath), changed);
|
|
3477
|
+
assert.deepEqual(await api('GET', listPath), [changed], 'edit updates the imported subject rather than duplicating it');
|
|
3478
|
+
assert.deepEqual(event(eventKind, 'edited').comment, changed);
|
|
3479
|
+
assert.equal(event(eventKind, 'edited').sender.login, 'octocat');
|
|
3480
|
+
await api('DELETE', itemPath);
|
|
3481
|
+
assert.deepEqual(event(eventKind, 'deleted').comment, changed);
|
|
3482
|
+
assert.equal(event(eventKind, 'deleted').sender.login, 'octocat');
|
|
3483
|
+
await assert.rejects(api('GET', itemPath), { status: 404 });
|
|
3484
|
+
assert.deepEqual(await api('GET', listPath), [], 'delete tombstones the imported subject');
|
|
3485
|
+
}
|
|
3486
|
+
return true;
|
|
3487
|
+
} finally { releasePush(); server.stop(); sink.stop(true); }
|
|
2150
3488
|
})),
|
|
2151
3489
|
done('github.webhooks.full_events', 'webhooks', 'Webhooks: full event-type coverage (push/release/check_run/workflow_run/…)', 'connector', 'common', () =>
|
|
2152
3490
|
withRoot(async (root) => {
|
|
@@ -2159,32 +3497,133 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2159
3497
|
checks.push([repo.webhook?.event ?? '', repo.webhook?.action ?? '']);
|
|
2160
3498
|
const fork = await applyGithubWrite({ method: 'POST', path: `/repos/octo/wh/forks`, body: JSON.stringify({ organization: 'me' }), root });
|
|
2161
3499
|
checks.push([fork.webhook?.event ?? '', fork.webhook?.action ?? '']);
|
|
2162
|
-
|
|
3500
|
+
// a collaborator is added when they accept the invitation
|
|
3501
|
+
await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/bob`, body: JSON.stringify({ permission: 'push' }), root });
|
|
3502
|
+
const mem = (await acceptInvitation(root, REPO, 'bob'))!;
|
|
2163
3503
|
checks.push([mem.webhook?.event ?? '', mem.webhook?.action ?? '']);
|
|
2164
3504
|
const push = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/contents/f.txt`, body: JSON.stringify({ message: 'm', content: 'eA==' }), root });
|
|
2165
3505
|
checks.push([push.webhook?.event ?? '', '']);
|
|
2166
3506
|
const expected: Record<string, string> = { release: 'published', repository: 'created', fork: 'created', member: 'added', push: '' };
|
|
2167
3507
|
return checks.every(([e, a]) => e in expected && (expected[e] === '' || expected[e] === a)) && new Set(checks.map((c) => c[0])).size === 5;
|
|
2168
3508
|
})),
|
|
2169
|
-
done('github.webhooks.delivery', 'webhooks', 'Webhooks:
|
|
2170
|
-
withRoot(async (root) => {
|
|
2171
|
-
const {
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
const
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
3509
|
+
done('github.webhooks.delivery', 'webhooks', 'Webhooks: signed delivery, durable replay, recipient scoping and truthful failures', 'connector', 'common', () =>
|
|
3510
|
+
withRoot(async (root) => {
|
|
3511
|
+
const { createHmac } = await import('node:crypto');
|
|
3512
|
+
const secret = 'capability-only-hook-secret';
|
|
3513
|
+
const captured: Array<{ headers: Headers; body: string }> = [];
|
|
3514
|
+
let sinkStatus = 204;
|
|
3515
|
+
const sink = await serveHttp({ port: 0, async fetch(request) {
|
|
3516
|
+
captured.push({ headers: request.headers, body: await request.text() });
|
|
3517
|
+
return new Response(null, { status: sinkStatus });
|
|
3518
|
+
} });
|
|
3519
|
+
let server = await createGithubTwinServer({ root });
|
|
3520
|
+
let sdk = octoClient({ baseUrl: `http://127.0.0.1:${server.port}` });
|
|
3521
|
+
const api = (method: string, path: string, body?: Record<string, unknown>) => sdk.request(`${method} ${path}`, body === undefined ? undefined : { data: body });
|
|
3522
|
+
try {
|
|
3523
|
+
await api('POST', '/orgs/octo/repos', { name: 'demo' });
|
|
3524
|
+
const hook = (await api('POST', `/repos/${REPO}/hooks`, { events: ['pull_request'], config: { url: `http://127.0.0.1:${sink.port}/github`, content_type: 'json', secret } })).data as any;
|
|
3525
|
+
assert.ok(!JSON.stringify(hook).includes(secret), 'native hook response masks signing material');
|
|
3526
|
+
const pull = (await api('POST', `/repos/${REPO}/pulls`, { title: 'Native source', head: 'a'.repeat(40), base: 'main', draft: true })).data as any;
|
|
3527
|
+
const path = `/repos/${REPO}/hooks/${hook.id}/deliveries`;
|
|
3528
|
+
const original = ((await api('GET', path)).data as any[]).find(d => d.event === 'pull_request');
|
|
3529
|
+
assert.ok(original, 'native write reaches native persisted hook');
|
|
3530
|
+
assert.equal(original.status_code, 204);
|
|
3531
|
+
assert.equal(original.status, 'OK');
|
|
3532
|
+
const first = captured.find(d => d.headers.get('x-github-event') === 'pull_request')!;
|
|
3533
|
+
assert.ok(first, 'real HTTP receiver received native write');
|
|
3534
|
+
assert.equal(first.headers.get('x-hub-signature-256'), `sha256=${createHmac('sha256', secret).update(first.body).digest('hex')}`);
|
|
3535
|
+
const payload = JSON.parse(first.body);
|
|
3536
|
+
assert.equal(payload.pull_request.number, pull.number);
|
|
3537
|
+
assert.equal(payload.pull_request.head.sha, pull.head.sha);
|
|
3538
|
+
assert.equal(payload.pull_request.user.login, 'octocat');
|
|
3539
|
+
assert.equal(payload.pull_request.draft, true);
|
|
3540
|
+
// A fresh server has no registration side channel. Stored hook and delivery suffice.
|
|
3541
|
+
server.stop(); server = await createGithubTwinServer({ root });
|
|
3542
|
+
sdk = octoClient({ baseUrl: `http://127.0.0.1:${server.port}` });
|
|
3543
|
+
const detail = (await api('GET', `${path}/${original.id}`)).data as any;
|
|
3544
|
+
assert.deepEqual(detail.request.payload, payload);
|
|
3545
|
+
assert.equal((await api('POST', `${path}/${original.id}/attempts`)).status, 202);
|
|
3546
|
+
const replay = captured.at(-1)!;
|
|
3547
|
+
assert.equal(replay.body, first.body);
|
|
3548
|
+
assert.equal(replay.headers.get('x-github-delivery'), first.headers.get('x-github-delivery'));
|
|
3549
|
+
assert.equal(replay.headers.get('x-hub-signature-256'), first.headers.get('x-hub-signature-256'));
|
|
3550
|
+
const replayRecord = ((await api('GET', path)).data as any[])[0]!;
|
|
3551
|
+
assert.equal(replayRecord.redelivery, true);
|
|
3552
|
+
assert.notEqual(replayRecord.id, original.id);
|
|
3553
|
+
assert.equal(replayRecord.guid, original.guid);
|
|
3554
|
+
// Filtering must exclude actual HTTP delivery, not merely hide delivery rows.
|
|
3555
|
+
let before = captured.length;
|
|
3556
|
+
await api('POST', `/repos/${REPO}/issues`, { title: 'Unsubscribed event' });
|
|
3557
|
+
await api('POST', '/orgs/octo/repos', { name: 'other' });
|
|
3558
|
+
await api('POST', '/repos/octo/other/pulls', { title: 'Other repository', head: 'b'.repeat(40), base: 'main' });
|
|
3559
|
+
assert.equal(captured.length, before, 'event and repository subscriptions exclude delivery');
|
|
3560
|
+
const hookPath = `/repos/${REPO}/hooks/${hook.id}`;
|
|
3561
|
+
const second = (await api('POST', `/repos/${REPO}/hooks`, { active: false, events: ['pull_request'], config: { url: `http://127.0.0.1:${sink.port}/other-hook`, content_type: 'json' } })).data as any;
|
|
3562
|
+
for (const method of ['GET', 'POST']) {
|
|
3563
|
+
const suffix = method === 'POST' ? '/attempts' : '';
|
|
3564
|
+
await assert.rejects(api(method, `/repos/${REPO}/hooks/${second.id}/deliveries/${original.id}${suffix}`), { status: 404 });
|
|
3565
|
+
}
|
|
3566
|
+
assert.equal(await withRoot(async (otherRoot) => {
|
|
3567
|
+
const otherServer = await createGithubTwinServer({ root: otherRoot });
|
|
3568
|
+
const other = octoClient({ baseUrl: `http://127.0.0.1:${otherServer.port}` });
|
|
3569
|
+
try {
|
|
3570
|
+
await other.request('POST /orgs/octo/repos', { name: 'demo' });
|
|
3571
|
+
const h = (await other.request(`POST /repos/${REPO}/hooks`, { config: { url: `http://127.0.0.1:${sink.port}/other-world`, content_type: 'json' } })).data as any;
|
|
3572
|
+
await assert.rejects(other.request(`GET /repos/${REPO}/hooks/${h.id}/deliveries/${original.id}`), { status: 404 });
|
|
3573
|
+
await assert.rejects(other.request(`POST /repos/${REPO}/hooks/${h.id}/deliveries/${original.id}/attempts`), { status: 404 });
|
|
3574
|
+
return true;
|
|
3575
|
+
} finally { otherServer.stop(); }
|
|
3576
|
+
}), true, 'a World cannot read or replay another World delivery');
|
|
3577
|
+
before = captured.length;
|
|
3578
|
+
await api('PATCH', hookPath, { active: false, config: { secret } });
|
|
3579
|
+
await api('PATCH', `/repos/${REPO}/pulls/${pull.number}`, { title: 'Inactive sink' });
|
|
3580
|
+
assert.equal(captured.length, before, 'inactive persisted hooks exclude delivery');
|
|
3581
|
+
await api('PATCH', hookPath, { active: true, config: { secret } });
|
|
3582
|
+
sinkStatus = 503;
|
|
3583
|
+
assert.equal((await api('PATCH', `/repos/${REPO}/pulls/${pull.number}`, { title: 'Receiver refuses' })).status, 200);
|
|
3584
|
+
let latest = ((await api('GET', path)).data as any[])[0]!;
|
|
3585
|
+
assert.equal(latest.status_code, 503); assert.notEqual(latest.status, 'OK');
|
|
3586
|
+
const mirror = await createGithubTwinServer({ root, readOnly: true });
|
|
3587
|
+
try {
|
|
3588
|
+
const ro = octoClient({ baseUrl: `http://127.0.0.1:${mirror.port}` });
|
|
3589
|
+
before = captured.length;
|
|
3590
|
+
assert.equal((await ro.request(`GET ${path}/${original.id}`)).status, 200);
|
|
3591
|
+
for (const target of [`${path}/${original.id}/attempts`, `${hookPath}/pings`]) await assert.rejects(ro.request(`POST ${target}`), { status: 403 });
|
|
3592
|
+
assert.equal(captured.length, before, 'mirror cannot send a replay or ping');
|
|
3593
|
+
} finally { mirror.stop(); }
|
|
3594
|
+
const unavailable = await serveHttp({ port: 0, fetch: () => new Response(null) });
|
|
3595
|
+
const unavailableUrl = `http://127.0.0.1:${unavailable.port}/closed-receiver`;
|
|
3596
|
+
unavailable.stop(true);
|
|
3597
|
+
await api('PATCH', `${hookPath}/config`, { url: unavailableUrl });
|
|
3598
|
+
assert.equal((await api('PATCH', `/repos/${REPO}/pulls/${pull.number}`, { title: 'Transport refuses' })).status, 200);
|
|
3599
|
+
latest = ((await api('GET', path)).data as any[])[0]!;
|
|
3600
|
+
assert.equal(latest.status_code, 0); assert.notEqual(latest.status, 'OK');
|
|
3601
|
+
// GitHub REST 2026-03-10 classifies status by HTTP range, and cursors exclude
|
|
3602
|
+
// the already-returned delivery. Octokit 22.0.1 consumes the native response.
|
|
3603
|
+
const successes = (await api('GET', `${path}?status=success`)).data as any[];
|
|
3604
|
+
const failures = (await api('GET', `${path}?status=failure`)).data as any[];
|
|
3605
|
+
assert.ok(successes.length > 0 && successes.every(d => d.status_code >= 200 && d.status_code <= 399));
|
|
3606
|
+
assert.ok(failures.some(d => d.status_code === 503) && failures.every(d => d.status_code >= 400 && d.status_code <= 599));
|
|
3607
|
+
assert.ok(!failures.some(d => d.id === latest.id), 'transport failures are outside HTTP-status filters');
|
|
3608
|
+
await assert.rejects(api('GET', `${path}?status=other`), { status: 422 });
|
|
3609
|
+
const firstPage = await api('GET', `${path}?per_page=1`);
|
|
3610
|
+
const firstRow = (firstPage.data as any[])[0]!;
|
|
3611
|
+
assert.equal((firstPage.data as any[]).length, 1);
|
|
3612
|
+
assert.equal(firstRow.id, latest.id);
|
|
3613
|
+
assert.ok(firstPage.headers.link?.includes('rel="next"'));
|
|
3614
|
+
const nextPage = (await api('GET', `${path}?per_page=1&cursor=${firstRow.id}`)).data as any[];
|
|
3615
|
+
assert.equal(nextPage.length, 1); assert.ok(nextPage[0].id < firstRow.id);
|
|
3616
|
+
await api('PATCH', `${hookPath}/config`, { url: `http://127.0.0.1:${sink.port}/github` });
|
|
3617
|
+
before = captured.length;
|
|
3618
|
+
await api('DELETE', hookPath);
|
|
3619
|
+
await api('PATCH', `/repos/${REPO}/pulls/${pull.number}`, { title: 'Deleted hook' });
|
|
3620
|
+
assert.equal(captured.length, before, 'deleted hooks do not receive actual HTTP calls');
|
|
3621
|
+
await assert.rejects(api('GET', path), { status: 404 });
|
|
3622
|
+
assert.equal(githubState(root).webhooks.find(h => h.id === hook.id), undefined);
|
|
3623
|
+
// The other remaining hook is inactive. No recipient or successful delivery is fabricated.
|
|
3624
|
+
assert.equal(((await api('GET', `/repos/${REPO}/hooks/${second.id}/deliveries`)).data as any[]).filter(d => d.event !== 'ping').length, 0);
|
|
3625
|
+
return true;
|
|
3626
|
+
} finally { server.stop(); sink.stop(true); }
|
|
2188
3627
|
})),
|
|
2189
3628
|
|
|
2190
3629
|
// ── Connector (pull/push) ────────────────────────────────────────────────────────────────
|
|
@@ -2205,8 +3644,8 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2205
3644
|
const res = await syncGithubFromReal(execute, { owner: 'octo', repo: 'demo', root, occurredAt: '2026-01-01T00:00:00Z' });
|
|
2206
3645
|
if (res.observed < 2 || res.issues !== 1) return false;
|
|
2207
3646
|
// Read the folded PR + issue back through the twin with their REAL content.
|
|
2208
|
-
const pr = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/5`, root });
|
|
2209
|
-
const iss = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/9`, root });
|
|
3647
|
+
const pr = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/5`, root });
|
|
3648
|
+
const iss = await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/9`, root });
|
|
2210
3649
|
return ok(pr.status) && (pr.body as { title?: string }).title === 'Real title'
|
|
2211
3650
|
&& ok(iss.status) && (iss.body as { title?: string }).title === 'Bug';
|
|
2212
3651
|
})),
|
|
@@ -2300,21 +3739,19 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2300
3739
|
})),
|
|
2301
3740
|
done('github.connector.push', 'connector', 'Connector: push reconcile (apply desired-state writes)', 'connector', 'common', () =>
|
|
2302
3741
|
withRoot(async (root) => {
|
|
2303
|
-
const {
|
|
3742
|
+
const { performGithubAction } = await import('./github-connector.ts');
|
|
3743
|
+
const { performEntries, writeRoot } = await import('@volter/world-core');
|
|
2304
3744
|
// Author LOCAL writes in the twin (a PR + an issue), then push them to the real vendor
|
|
2305
3745
|
// via a fake executor — the connector maps each pending action to its REST call.
|
|
2306
3746
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'push me', head: 'a', base: 'main' }), root });
|
|
2307
3747
|
await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'push issue' }), root });
|
|
2308
3748
|
const calls: string[] = [];
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
},
|
|
2316
|
-
};
|
|
2317
|
-
const res = await pushPendingGithubActions(execute, { root, occurredAt: '2026-01-02T00:00:00Z' });
|
|
3749
|
+
writeRoot('github', { url: 'https://api.github.test', deploy: 'hold', credential: join(root, '.volter/credentials/github.json') } as never, root);
|
|
3750
|
+
const res = await performEntries({ service: 'github', root, checks: [], adapters: { perform: performGithubAction }, execute: async request => {
|
|
3751
|
+
const suffix = request.path.endsWith('/issues') ? 'issues' : 'pulls';
|
|
3752
|
+
calls.push(`POST /repos/{owner}/{repo}/${suffix}`);
|
|
3753
|
+
return { status: 201, headers: {}, body: JSON.stringify({ number: suffix === 'issues' ? 77 : 42 }) };
|
|
3754
|
+
}});
|
|
2318
3755
|
return res.pushed >= 2 && calls.includes('POST /repos/{owner}/{repo}/pulls') && calls.includes('POST /repos/{owner}/{repo}/issues');
|
|
2319
3756
|
})),
|
|
2320
3757
|
|
|
@@ -2349,7 +3786,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2349
3786
|
// "Review count 1" over a reviews page answering two.
|
|
2350
3787
|
fetch: async (root) => ({
|
|
2351
3788
|
...githubMirrorState(root),
|
|
2352
|
-
served: (handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root }).body as unknown[]).length,
|
|
3789
|
+
served: ((await handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root })).body as unknown[]).length,
|
|
2353
3790
|
}),
|
|
2354
3791
|
assert: (m) => {
|
|
2355
3792
|
const pr = (m.github as any).pullRequests.find((p: any) => p.title === 'Conversation Probe');
|
|
@@ -2372,13 +3809,13 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2372
3809
|
return !!i && (i.labels as Array<{ name: string }>).some((l) => l.name === 'triage') && (i.assignees as string[]).includes('dave');
|
|
2373
3810
|
},
|
|
2374
3811
|
})),
|
|
2375
|
-
// Actions runs view:
|
|
3812
|
+
// Actions runs view: push a workflow + dispatch it (real run + job) — assert the
|
|
2376
3813
|
// fetched run's workflowName resolves + it carries the real jobCount.
|
|
2377
3814
|
done('github.ui.actions_view', 'ui', 'Actions runs view (runs + jobs + status badges) (data-coupled)', 'ui', 'niche', uiDataCoupled<string, Record<string, unknown>>({
|
|
2378
3815
|
withRoot,
|
|
2379
3816
|
seed: async (root) => {
|
|
2380
|
-
await
|
|
2381
|
-
await applyGithubWrite({ method: 'POST', path: `/repos/${
|
|
3817
|
+
await pushFiles(root, { '.github/workflows/probe.yml': workflowFile('Probe CI') });
|
|
3818
|
+
await applyGithubWrite({ method: 'POST', path: `/repos/${HISTORY_REPO}/actions/workflows/probe.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
|
|
2382
3819
|
},
|
|
2383
3820
|
fetch: async (root) => githubMirrorState(root),
|
|
2384
3821
|
assert: (m) => (m.github as any).runs.some((r: any) => r.workflowName === 'Probe CI' && r.jobCount === 1),
|
|
@@ -2433,12 +3870,12 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2433
3870
|
seed: async (root) => {
|
|
2434
3871
|
const c = await applyGithubWrite({ method: 'POST', path: '/orgs/octo/projectsV2', body: JSON.stringify({ title: 'Roadmap' }), root });
|
|
2435
3872
|
const pid = (c.response.body as { id?: number }).id!;
|
|
2436
|
-
await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Status', data_type: 'single_select', single_select_options: [{ name: 'Todo' }, { name: 'Done' }] }), root });
|
|
2437
|
-
await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Board', layout: 'board' }), root });
|
|
2438
|
-
const i1 = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Task A' }), root });
|
|
2439
|
-
const i2 = await applyGithubWrite({ method: 'POST', path: `/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Task B' }), root });
|
|
2440
|
-
await applyGithubWrite({ method: 'PATCH', path: `/projectsV2/${pid}/items/${(i1.response.body as { id?: number }).id}`, body: JSON.stringify({ field_id: 'Status', value: 'Todo' }), root });
|
|
2441
|
-
await applyGithubWrite({ method: 'PATCH', path: `/projectsV2/${pid}/items/${(i2.response.body as { id?: number }).id}`, body: JSON.stringify({ field_id: 'Status', value: 'Done' }), root });
|
|
3873
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/fields`, body: JSON.stringify({ name: 'Status', data_type: 'single_select', single_select_options: [{ name: 'Todo' }, { name: 'Done' }] }), root });
|
|
3874
|
+
await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/views`, body: JSON.stringify({ name: 'Board', layout: 'board' }), root });
|
|
3875
|
+
const i1 = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Task A' }), root });
|
|
3876
|
+
const i2 = await applyGithubWrite({ method: 'POST', path: `/_twin/projectsV2/${pid}/items`, body: JSON.stringify({ content_type: 'DraftIssue', title: 'Task B' }), root });
|
|
3877
|
+
await applyGithubWrite({ method: 'PATCH', path: `/_twin/projectsV2/${pid}/items/${(i1.response.body as { id?: number }).id}`, body: JSON.stringify({ field_id: 'Status', value: 'Todo' }), root });
|
|
3878
|
+
await applyGithubWrite({ method: 'PATCH', path: `/_twin/projectsV2/${pid}/items/${(i2.response.body as { id?: number }).id}`, body: JSON.stringify({ field_id: 'Status', value: 'Done' }), root });
|
|
2442
3879
|
},
|
|
2443
3880
|
fetch: async (root) => githubMirrorState(root),
|
|
2444
3881
|
assert: (m) => {
|
|
@@ -2473,7 +3910,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2473
3910
|
seed: async (root) => {
|
|
2474
3911
|
const r = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/releases`, body: JSON.stringify({ tag_name: 'v-probe', name: 'Release Probe', body: 'notes' }), root });
|
|
2475
3912
|
const id = (r.response.body as { id?: number }).id;
|
|
2476
|
-
await applyGithubWrite({ method: 'POST', path:
|
|
3913
|
+
await applyGithubWrite({ method: 'POST', path: `https://uploads.github.com/repos/${REPO}/releases/${id}/assets?name=probe.zip`, body: 'z'.repeat(2048), contentType: 'application/zip', root });
|
|
2477
3914
|
},
|
|
2478
3915
|
fetch: async (root) => githubMirrorState(root),
|
|
2479
3916
|
assert: (m) => {
|
|
@@ -2518,7 +3955,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2518
3955
|
withRoot,
|
|
2519
3956
|
seed: async (root) => {
|
|
2520
3957
|
await applyGithubWrite({ method: 'POST', path: '/orgs/octo/repos', body: JSON.stringify({ name: 'demo', private: true }), root });
|
|
2521
|
-
await
|
|
3958
|
+
await collaborator(root, 'octo/demo', 'bob', 'push');
|
|
2522
3959
|
await applyGithubWrite({ method: 'POST', path: '/repos/octo/demo/hooks', body: JSON.stringify({ config: { url: 'https://e.x/h' }, events: ['push'] }), root });
|
|
2523
3960
|
},
|
|
2524
3961
|
fetch: async (root) => githubMirrorState(root),
|
|
@@ -2548,11 +3985,11 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2548
3985
|
&& g.contents.some((c: any) => String(c.path).includes('zephyrus-search-term'));
|
|
2549
3986
|
},
|
|
2550
3987
|
})),
|
|
2551
|
-
// Notifications inbox:
|
|
3988
|
+
// Notifications inbox: an @mention makes a real notification thread — assert it appears unread
|
|
2552
3989
|
// with the seeded subject title (an unseeded root has zero notification threads).
|
|
2553
3990
|
done('github.ui.notifications', 'ui', 'Notifications inbox screen (data-coupled)', 'ui', 'common', uiDataCoupled<string, Record<string, unknown>>({
|
|
2554
3991
|
withRoot,
|
|
2555
|
-
seed: (root) =>
|
|
3992
|
+
seed: async (root) => { await pushFiles(root, {}); await mentionedIn(root, HISTORY_REPO, 'Notification Probe'); },
|
|
2556
3993
|
fetch: async (root) => githubMirrorState(root),
|
|
2557
3994
|
assert: (m) => (m.github as any).notifications.some((n: any) => n.title === 'Notification Probe' && n.unread === true),
|
|
2558
3995
|
})),
|
|
@@ -2584,7 +4021,7 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2584
4021
|
// These whole vendor product areas had NO manifest entry of any status before this pass —
|
|
2585
4022
|
// not todo'd, not carved out — so they never surfaced in checkCapabilities gaps and the
|
|
2586
4023
|
// coverage denominator structurally couldn't include them. Filed as honest todos (all
|
|
2587
|
-
// genuinely buildable; none is
|
|
4024
|
+
// genuinely buildable; none is anything but a todo). See GITHUB_AREAS
|
|
2588
4025
|
// below + github-capabilities.test.ts's area-census meta-test, which is what makes a future
|
|
2589
4026
|
// whole-area omission fail the gate instead of waiting for another review pass.
|
|
2590
4027
|
todo('github.copilot.seats', 'copilot', 'Copilot: manage Business/Enterprise seat assignments (list/add/remove billed seats)', 'api', 'niche'),
|