@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.
Files changed (215) hide show
  1. package/README.md +88 -36
  2. package/client/github-mirror.css +277 -319
  3. package/client/github-mirror.d.ts +418 -0
  4. package/client/github-mirror.js +485 -0
  5. package/client/github-mirror.tsx +153 -357
  6. package/client/pulls-rest.ts +159 -0
  7. package/client/pulls-workspace.tsx +841 -0
  8. package/dist/client/github-mirror.bundle.js +239 -0
  9. package/dist/client/github-mirror.css +916 -0
  10. package/dist/client/github-mirror.d.ts +418 -0
  11. package/dist/client/github-mirror.js +485 -0
  12. package/dist/client/github-mirror.tsx +1315 -0
  13. package/dist/client/pulls-rest.d.ts +42 -0
  14. package/dist/client/pulls-rest.js +140 -0
  15. package/dist/client/pulls-rest.ts +159 -0
  16. package/dist/client/pulls-workspace.bundle.js +22 -0
  17. package/dist/client/pulls-workspace.d.ts +114 -0
  18. package/dist/client/pulls-workspace.js +418 -0
  19. package/dist/client/pulls-workspace.tsx +841 -0
  20. package/dist/src/cli.d.ts +2 -0
  21. package/dist/src/cli.js +40 -0
  22. package/dist/src/generated/graphql-sdl.gen.json +1 -0
  23. package/dist/src/generated/graphql.gen.json +1 -0
  24. package/dist/src/generated/surface.gen.json +1 -0
  25. package/dist/src/generated/ui.gen.json +1 -0
  26. package/dist/src/github-budget.d.ts +69 -0
  27. package/dist/src/github-budget.js +172 -0
  28. package/dist/src/github-capabilities.d.ts +5 -0
  29. package/dist/src/github-capabilities.js +4468 -0
  30. package/dist/src/github-conformance.d.ts +43 -0
  31. package/dist/src/github-conformance.js +76 -0
  32. package/dist/src/github-connector.d.ts +307 -0
  33. package/dist/src/github-connector.js +1398 -0
  34. package/dist/src/github-events.d.ts +41 -0
  35. package/dist/src/github-events.js +230 -0
  36. package/dist/src/github-git-http.d.ts +49 -0
  37. package/dist/src/github-git-http.js +185 -0
  38. package/dist/src/github-git-plane.d.ts +114 -0
  39. package/dist/src/github-git-plane.js +407 -0
  40. package/dist/src/github-mirror-state.d.ts +2 -0
  41. package/dist/src/github-mirror-state.js +335 -0
  42. package/dist/src/github-mirror-ui.d.ts +20 -0
  43. package/dist/src/github-mirror-ui.js +101 -0
  44. package/dist/src/github-server.d.ts +14 -0
  45. package/dist/src/github-server.js +240 -0
  46. package/dist/src/github-shared.d.ts +12 -0
  47. package/dist/src/github-shared.js +21 -0
  48. package/dist/src/github-twin.d.ts +1411 -0
  49. package/dist/src/github-twin.js +4084 -0
  50. package/dist/src/github-ui-conformance.d.ts +4 -0
  51. package/dist/src/github-ui-conformance.js +105 -0
  52. package/dist/src/github-ui-structure.d.ts +18 -0
  53. package/dist/src/github-ui-structure.js +251 -0
  54. package/dist/src/graphql-wire.d.ts +22 -0
  55. package/dist/src/graphql-wire.js +89 -0
  56. package/dist/src/index.d.ts +17 -0
  57. package/dist/src/index.js +101 -0
  58. package/dist/src/manifest.d.ts +8 -0
  59. package/dist/src/manifest.js +597 -0
  60. package/dist/src/npm-registry.d.ts +7 -0
  61. package/dist/src/npm-registry.js +47 -0
  62. package/dist/src/screens/app-installation.d.ts +3 -0
  63. package/dist/src/screens/app-installation.js +166 -0
  64. package/dist/src/screens/app-manifest.d.ts +3 -0
  65. package/dist/src/screens/app-manifest.js +81 -0
  66. package/dist/src/screens/oauth.d.ts +15 -0
  67. package/dist/src/screens/oauth.js +257 -0
  68. package/dist/src/screens/session.d.ts +8 -0
  69. package/dist/src/screens/session.js +159 -0
  70. package/dist/src/semantics/actions.d.ts +2 -0
  71. package/dist/src/semantics/actions.js +413 -0
  72. package/dist/src/semantics/activity.d.ts +4 -0
  73. package/dist/src/semantics/activity.js +161 -0
  74. package/dist/src/semantics/apps.d.ts +2 -0
  75. package/dist/src/semantics/apps.js +144 -0
  76. package/dist/src/semantics/branches.d.ts +2 -0
  77. package/dist/src/semantics/branches.js +136 -0
  78. package/dist/src/semantics/checks.d.ts +2 -0
  79. package/dist/src/semantics/checks.js +176 -0
  80. package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
  81. package/dist/src/semantics/code-scanning-upload.js +97 -0
  82. package/dist/src/semantics/codespaces.d.ts +2 -0
  83. package/dist/src/semantics/codespaces.js +58 -0
  84. package/dist/src/semantics/commits.d.ts +2 -0
  85. package/dist/src/semantics/commits.js +109 -0
  86. package/dist/src/semantics/contents.d.ts +2 -0
  87. package/dist/src/semantics/contents.js +131 -0
  88. package/dist/src/semantics/deployments.d.ts +2 -0
  89. package/dist/src/semantics/deployments.js +127 -0
  90. package/dist/src/semantics/gists.d.ts +2 -0
  91. package/dist/src/semantics/gists.js +86 -0
  92. package/dist/src/semantics/git.d.ts +2 -0
  93. package/dist/src/semantics/git.js +235 -0
  94. package/dist/src/semantics/graphql.d.ts +7 -0
  95. package/dist/src/semantics/graphql.js +512 -0
  96. package/dist/src/semantics/index.d.ts +5 -0
  97. package/dist/src/semantics/index.js +62 -0
  98. package/dist/src/semantics/issues.d.ts +2 -0
  99. package/dist/src/semantics/issues.js +456 -0
  100. package/dist/src/semantics/keys.d.ts +2 -0
  101. package/dist/src/semantics/keys.js +66 -0
  102. package/dist/src/semantics/labels.d.ts +2 -0
  103. package/dist/src/semantics/labels.js +100 -0
  104. package/dist/src/semantics/meta.d.ts +12 -0
  105. package/dist/src/semantics/meta.js +144 -0
  106. package/dist/src/semantics/notifications.d.ts +4 -0
  107. package/dist/src/semantics/notifications.js +62 -0
  108. package/dist/src/semantics/orgs.d.ts +2 -0
  109. package/dist/src/semantics/orgs.js +420 -0
  110. package/dist/src/semantics/packages.d.ts +2 -0
  111. package/dist/src/semantics/packages.js +55 -0
  112. package/dist/src/semantics/pages.d.ts +2 -0
  113. package/dist/src/semantics/pages.js +176 -0
  114. package/dist/src/semantics/projects.d.ts +2 -0
  115. package/dist/src/semantics/projects.js +239 -0
  116. package/dist/src/semantics/pulls.d.ts +2 -0
  117. package/dist/src/semantics/pulls.js +462 -0
  118. package/dist/src/semantics/push-reactions.d.ts +101 -0
  119. package/dist/src/semantics/push-reactions.js +513 -0
  120. package/dist/src/semantics/releases.d.ts +19 -0
  121. package/dist/src/semantics/releases.js +231 -0
  122. package/dist/src/semantics/repo-invitations.d.ts +2 -0
  123. package/dist/src/semantics/repo-invitations.js +98 -0
  124. package/dist/src/semantics/repos.d.ts +17 -0
  125. package/dist/src/semantics/repos.js +390 -0
  126. package/dist/src/semantics/rulesets.d.ts +2 -0
  127. package/dist/src/semantics/rulesets.js +111 -0
  128. package/dist/src/semantics/search.d.ts +2 -0
  129. package/dist/src/semantics/search.js +84 -0
  130. package/dist/src/semantics/security.d.ts +2 -0
  131. package/dist/src/semantics/security.js +177 -0
  132. package/dist/src/semantics/shared.d.ts +61 -0
  133. package/dist/src/semantics/shared.js +151 -0
  134. package/dist/src/semantics/users.d.ts +2 -0
  135. package/dist/src/semantics/users.js +255 -0
  136. package/dist/src/semantics/webhooks.d.ts +2 -0
  137. package/dist/src/semantics/webhooks.js +107 -0
  138. package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
  139. package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
  140. package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
  141. package/dist/test-fixtures/github-comment-schema.json +702 -0
  142. package/dist/test-fixtures/github-known-deviations.json +94 -0
  143. package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
  144. package/dist/test-fixtures/github-openapi-operations.json +362 -0
  145. package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
  146. package/dist/test-fixtures/github-pull-schema.json +3601 -0
  147. package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
  148. package/dist/test-fixtures/github-review-schema.json +236 -0
  149. package/package.json +20 -11
  150. package/src/cli.ts +7 -6
  151. package/src/generated/graphql-sdl.gen.json +1 -0
  152. package/src/generated/graphql.gen.json +1 -0
  153. package/src/generated/surface.gen.json +1 -0
  154. package/src/generated/ui.gen.json +1 -0
  155. package/src/github-a11y-snapshot.uitest.ts +5 -5
  156. package/src/github-budget.ts +4 -4
  157. package/src/github-capabilities.ts +1993 -556
  158. package/src/github-conformance.ts +12 -7
  159. package/src/github-connector.ts +108 -96
  160. package/src/github-events.ts +223 -95
  161. package/src/github-git-http.ts +47 -83
  162. package/src/github-git-plane.ts +247 -385
  163. package/src/github-journey.uitest.ts +28 -44
  164. package/src/github-mirror-state.ts +23 -61
  165. package/src/github-mirror-ui.ts +16 -10
  166. package/src/github-server.ts +158 -60
  167. package/src/github-shared.ts +1 -1
  168. package/src/github-twin.ts +1525 -4494
  169. package/src/github-ui-conformance.ts +7 -8
  170. package/src/github-ui-structure.ts +19 -18
  171. package/src/graphql-wire.ts +115 -0
  172. package/src/index.ts +47 -7
  173. package/src/manifest.ts +605 -0
  174. package/src/npm-registry.ts +43 -0
  175. package/src/screens/app-installation.tsx +217 -0
  176. package/src/screens/app-manifest.tsx +97 -0
  177. package/src/screens/oauth.tsx +258 -0
  178. package/src/screens/session.tsx +186 -0
  179. package/src/semantics/actions.ts +395 -0
  180. package/src/semantics/activity.ts +171 -0
  181. package/src/semantics/apps.ts +133 -0
  182. package/src/semantics/branches.ts +133 -0
  183. package/src/semantics/checks.ts +163 -0
  184. package/src/semantics/code-scanning-upload.ts +92 -0
  185. package/src/semantics/codespaces.ts +58 -0
  186. package/src/semantics/commits.ts +109 -0
  187. package/src/semantics/contents.ts +112 -0
  188. package/src/semantics/deployments.ts +116 -0
  189. package/src/semantics/gists.ts +85 -0
  190. package/src/semantics/git.ts +226 -0
  191. package/src/semantics/graphql.ts +505 -0
  192. package/src/semantics/index.ts +68 -0
  193. package/src/semantics/issues.ts +434 -0
  194. package/src/semantics/keys.ts +66 -0
  195. package/src/semantics/labels.ts +91 -0
  196. package/src/semantics/meta.ts +139 -0
  197. package/src/semantics/notifications.ts +58 -0
  198. package/src/semantics/orgs.ts +383 -0
  199. package/src/semantics/packages.ts +59 -0
  200. package/src/semantics/pages.ts +154 -0
  201. package/src/semantics/projects.ts +246 -0
  202. package/src/semantics/pulls.ts +421 -0
  203. package/src/semantics/push-reactions.ts +471 -0
  204. package/src/semantics/releases.ts +213 -0
  205. package/src/semantics/repo-invitations.ts +112 -0
  206. package/src/semantics/repos.ts +373 -0
  207. package/src/semantics/rulesets.ts +107 -0
  208. package/src/semantics/search.ts +83 -0
  209. package/src/semantics/security.ts +153 -0
  210. package/src/semantics/shared.ts +181 -0
  211. package/src/semantics/users.ts +252 -0
  212. package/src/semantics/webhooks.ts +101 -0
  213. package/test-fixtures/github-known-deviations.json +7 -8
  214. package/test-fixtures/github-openapi-operations.json +46 -227
  215. 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`. Two explicit out-of-scope carve-outs (outOfScope): real repo CODE/FILE
14
- // BYTES (git blob content — large+redundant; API objects are covered) and running real CI
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 { mkdtempSync, rmSync } from 'node:fs';
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/twin-tooling';
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/out-of-scope.
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
- return res.response.status === 201 && (res.response.body as { number?: number }).number === 1;
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
- const sub = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls/1/reviews`, body: JSON.stringify({ event: 'APPROVE', body: 'lgtm' }), root });
152
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1/reviews`, root });
153
- return ok(sub.response.status) && ok(list.status) && isArr(list.body) && list.body.length === 1;
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
- return ok(res.response.status) && (res.response.body as { labels?: unknown[] }).labels?.length === 1;
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 state + ready_for_review', 'api', 'common', () =>
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
- const ready = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ draft: false }), root });
264
- if ((ready.response.body as { draft?: boolean }).draft !== false) return false;
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: update branch / mergeability + conflicts', 'api', 'common', () =>
295
- withRoot(async (root) => {
296
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'ub', head: 'a', base: 'main' }), root });
297
- // a dirty PR reports mergeable:false; update-branch advances head + marks it clean.
298
- await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/pulls/1`, body: JSON.stringify({ mergeable_state: 'dirty' }), root });
299
- const dirty = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
300
- if ((dirty.body as { mergeable?: boolean | null }).mergeable !== false) return false;
301
- const before = (dirty.body as { head?: { sha?: string } }).head?.sha;
302
- const upd = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/pulls/1/update-branch`, body: '{}', root });
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 clean = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pulls/1`, root });
305
- return (clean.body as { mergeable?: boolean }).mergeable === true && (clean.body as { mergeable_state?: string }).mergeable_state === 'clean' && (clean.body as { head?: { sha?: string } }).head?.sha !== before;
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 c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues/1/comments`, body: JSON.stringify({ body: 'me too' }), root });
331
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/comments`, root });
332
- return c.response.status === 201 && ok(list.status) && isArr(list.body) && list.body.length === 1;
333
- })),
334
- done('github.issues.labels_assignees_milestone', 'issues', 'Issues: labels/assignees/milestone', 'api', 'core', () =>
335
- withRoot(async (root) => {
336
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'la' }), root });
337
- const res = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ labels: ['p1'], assignees: ['ann'] }), root });
338
- return ok(res.response.status) && (res.response.body as { assignees?: unknown[] }).assignees?.length === 1;
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 applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'sr' }), root });
350
- const r = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/issues/1`, body: JSON.stringify({ state: 'closed', state_reason: 'not_planned' }), root });
351
- const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1`, root });
352
- return ok(r.response.status) && (r.response.body as { state_reason?: string }).state_reason === 'not_planned' && (get.body as { state?: string }).state === 'closed';
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
- // a missing name 422s; create → listed; duplicate name 422s; edit; delete → gone.
357
- if ((await applyGithubWrite({ method: 'POST', path: `/orgs/octo/issue-types`, body: '{}', root })).response.status !== 422) return false;
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 list = handleGithubRequest({ method: 'GET', path: `/orgs/octo/issue-types`, root });
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: 'PATCH', path: `/orgs/octo/issue-types/${id}`, body: JSON.stringify({ description: 'updated' }), root });
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
- const tl = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/issues/1/timeline`, root });
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 }>).map((e) => e.event);
444
- return events.includes('labeled') && events.includes('assigned') && events.includes('closed');
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.checks.runs', 'checks', 'Check runs: create + list for a ref', 'api', 'core', () =>
481
- withRoot(async (root) => {
482
- const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'build', head_sha: 'sha9', conclusion: 'success' }), root });
483
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/commits/sha9/check-runs`, root });
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 cr = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/check-runs`, body: JSON.stringify({ name: 'lint', head_sha: 'shaA', status: 'in_progress' }), root });
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: register + list/get workflows', 'api', 'niche', () =>
993
+ done('github.actions.workflows', 'actions', 'Actions: a pushed workflow file registers; list/get workflows', 'api', 'niche', () =>
513
994
  withRoot(async (root) => {
514
- const reg = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/workflows/ci.yml`, body: JSON.stringify({ name: 'CI', path: '.github/workflows/ci.yml' }), root });
515
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/workflows`, root });
516
- return ok(reg.response.status) && ok(list.status) && (list.body as { total_count?: number }).total_count === 1;
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/workflows/ci.yml`, body: JSON.stringify({ name: 'CI', path: '.github/workflows/ci.yml' }), root });
521
- const d = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
522
- const runs = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/runs`, root });
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/workflows/ci.yml`, body: JSON.stringify({ name: 'CI', path: '.github/workflows/ci.yml' }), root });
528
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
529
- const runs = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/runs`, root });
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/${REPO}/actions/runs/${runId}/jobs`, root });
532
- const cancel = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/runs/${runId}/cancel`, body: '{}', root });
533
- const after = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/runs/${runId}`, root });
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/workflows/ci.yml`, body: JSON.stringify({ name: 'CI', path: '.github/workflows/ci.yml' }), root });
539
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/workflows/ci.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
540
- const runs = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/runs`, root });
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/${REPO}/actions/runs/${runId}/logs`, root });
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/${REPO}/actions/runs/999999/logs`, root }).status === 404;
546
- })),
547
- done('github.actions.artifacts', 'actions', 'Actions: artifacts register/list/get/delete (metadata)', 'api', 'niche', () =>
548
- withRoot(async (root) => {
549
- const bad = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/artifacts`, body: '{}', root });
550
- if (bad.response.status !== 422) return false;
551
- const c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/artifacts`, body: JSON.stringify({ name: 'build-output', size_in_bytes: 2048, run_id: 5 }), root });
552
- if (!(c.response.status === 201 && (c.response.body as { name?: string }).name === 'build-output')) return false;
553
- const id = (c.response.body as { id?: number }).id!;
554
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/artifacts`, root });
555
- if (!(ok(list.status) && (list.body as { total_count?: number }).total_count === 1)) return false;
556
- const one = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/actions/artifacts/${id}`, root });
557
- if (!(ok(one.status) && (one.body as { size_in_bytes?: number }).size_in_bytes === 2048)) return false;
558
- const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/actions/artifacts/${id}`, root });
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
- const bad = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/caches`, body: '{}', root });
608
- if (bad.response.status !== 422) return false;
609
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/caches`, body: JSON.stringify({ key: 'node-modules-abc', ref: 'refs/heads/main', size_in_bytes: 1024 }), root });
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/${REPO}/actions/caches?key=node-modules-abc`, root });
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/${REPO}/actions/caches`, root });
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
- await applyGithubWrite({ method: 'POST', path: `/orgs/octo/repos`, body: JSON.stringify({ name: 'demo', default_branch: 'main' }), root });
648
- const list = handleGithubRequest({ method: 'GET', path: `/repos/octo/demo/branches`, root });
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
- todo('github.repos.protection_required_reviews_enforced', 'repos', 'Repos: required_pull_request_reviews ENFORCED on merge (config persists; merge does not yet refuse on it)', 'api', 'niche'),
711
- done('github.repos.collaborators', 'repos', 'Repos: collaborators + permissions', 'api', 'common', () =>
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
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/collaborators`, root });
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 c = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/hooks`, body: JSON.stringify({ events: ['push', 'pull_request'], config: { url: 'https://e.x/hook', content_type: 'json' } }), root });
728
- if (!(c.response.status === 201 && (c.response.body as { events?: string[] }).events?.length === 2)) return false;
729
- const id = (c.response.body as { id?: number }).id!;
730
- const e = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/hooks/${id}`, body: JSON.stringify({ active: false }), root });
731
- if (!(ok(e.response.status) && (e.response.body as { active?: boolean }).active === false)) return false;
732
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/hooks`, root });
733
- if (!(ok(list.status) && isArr(list.body) && list.body.length === 1)) return false;
734
- const del = await applyGithubWrite({ method: 'DELETE', path: `/repos/${REPO}/hooks/${id}`, root });
735
- const gone = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/hooks/${id}`, root });
736
- return del.response.status === 204 && gone.status === 404;
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
- outOfScope('github.repos.blob_bytes', 'repos', 'Repos: raw file/blob BYTES (git object contents)', 'api', 'niche',
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 = Bun.serve({ 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'); } });
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); clearGithubWebhooks(); rmTmp(work, { recursive: true, force: 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
- todo('github.repos.contents_backed_by_git_plane', 'repos', 'Repos: the contents API (PUT/DELETE .../contents/:path) writes REAL git objects + moves the branch ref — today it is still a disconnected side-store the git plane never sees', 'api', 'common'),
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: assets register/list/get (metadata; bytes out of scope)', 'api', 'common', () =>
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: `/repos/${REPO}/releases/${relId}/assets`, body: JSON.stringify({ name: 'app.zip', content_type: 'application/zip', size: 1024 }), root });
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; PATCH auto-vivifies + round-trips the profile fields.
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: invite via membership, then the member appears + the 204 membership check passes.
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
- const members = handleGithubRequest({ method: 'GET', path: `/orgs/octo/members`, root });
1231
- return ok(members.status) && isArr(members.body) && members.body.length === 1 && (members.body[0] as { login?: string }).login === 'alice'
1232
- && handleGithubRequest({ method: 'GET', path: `/orgs/octo/members/alice`, root }).status === 204;
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 → appears in members + membership get shows role.
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
- const mem = handleGithubRequest({ method: 'GET', path: `/orgs/octo/teams/core-team/members`, root });
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/alice`, body: JSON.stringify({ permission: 'push' }), root });
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 + thread subscriptions', 'api', 'common', () =>
2144
+ done('github.notifications.list', 'notifications', 'Notifications: an @mention lands in the inbox; list/mark-read', 'api', 'common', () =>
1411
2145
  withRoot(async (root) => {
1412
- const n = await applyGithubWrite({ method: 'POST', path: `/notifications`, body: JSON.stringify({ repository: REPO, subject_title: 'New PR', subject_type: 'PullRequest', reason: 'review_requested' }), root });
1413
- if (n.response.status !== 201) return false;
1414
- const id = (n.response.body as { id?: string }).id!;
1415
- const list = handleGithubRequest({ method: 'GET', path: `/notifications`, root });
1416
- if (!(ok(list.status) && isArr(list.body) && list.body.length === 1 && (list.body[0] as { unread?: boolean }).unread === true)) return false;
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
- return ok(received.status) && isArr(received.body) && received.body.length === 2
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
- // Seed two notifications in different repos; the per-repo inbox returns only that repo's.
1529
- const n = await applyGithubWrite({ method: 'POST', path: '/notifications', body: JSON.stringify({ repository: 'octo/a', subject_title: 'PR in a', subject_type: 'PullRequest' }), root });
1530
- await applyGithubWrite({ method: 'POST', path: '/notifications', body: JSON.stringify({ repository: 'octo/b', subject_title: 'Issue in b', subject_type: 'Issue' }), root });
1531
- const nid = (n.response.body as { id?: string }).id!;
1532
- const inbox = handleGithubRequest({ method: 'GET', path: '/repos/octo/a/notifications', root });
1533
- if (!(ok(inbox.status) && isArr(inbox.body) && inbox.body.length === 1 && (inbox.body[0] as { repository?: { full_name?: string } }).repository?.full_name === 'octo/a')) return false;
1534
- // thread subscription: a subscribed thread reads subscribed:true; DELETE clears it (and
1535
- // marks the thread read, so it drops out of the unread per-repo inbox).
1536
- const sub = handleGithubRequest({ method: 'GET', path: `/notifications/threads/${nid}/subscription`, root });
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: '/repos/octo/a/notifications', root });
1542
- return del.response.status === 204 && ok(after.status) && isArr(after.body) && after.body.length === 0;
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 out of scope).
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/alice`, body: JSON.stringify({ permission: 'push' }), root });
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
- // seed a package + two versions, list/get, delete one version then the package.
1837
- const c = await applyGithubWrite({ method: 'POST', path: '/user/packages/npm/my-lib', body: JSON.stringify({ visibility: 'public', versions: ['1.0.0', '1.1.0'] }), root });
1838
- if (!(c.response.status === 201 && (c.response.body as { version_count?: number }).version_count === 2)) return false;
1839
- const list = handleGithubRequest({ method: 'GET', path: '/user/packages?package_type=npm', root });
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 (handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/pages`, root }).status !== 404) return false;
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 out-of-scope; the RESOURCE
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
- // The alert RESOURCES + their state machines (open→dismissed/fixed/resolved) are modeled as
1899
- // kernel-folded CRUD + state transitions; the actual SCAN that generates them is infra the
1900
- // offline twin can't run (a simulator seeds the alert objects, like the rest of the twin).
1901
- done('github.security.code_scanning', 'security', 'Code scanning: alerts + analyses', 'api', 'niche', () =>
1902
- withRoot(async (root) => {
1903
- const a = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/code-scanning/alerts`, body: JSON.stringify({ rule_id: 'js/sql-injection', rule_severity: 'error' }), root });
1904
- if (!(a.response.status === 201 && (a.response.body as { number?: number }).number === 1 && (a.response.body as { state?: string }).state === 'open')) return false;
1905
- const n = (a.response.body as { number?: number }).number!;
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/${REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed' }), root })).response.status !== 422) return false;
1908
- if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'bogus' }), root })).response.status !== 422) return false;
1909
- const dm = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/code-scanning/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'false positive' }), root });
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/${REPO}/code-scanning/alerts?state=open`, root });
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
- // analyses: create → list → delete.
1915
- const an = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/code-scanning/analyses`, body: JSON.stringify({ ref: 'refs/heads/main', results_count: 3, rules_count: 50 }), root });
1916
- if (!(an.response.status === 201 && (an.response.body as { results_count?: number }).results_count === 3)) return false;
1917
- const aid = (an.response.body as { id?: number }).id!;
1918
- const list = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/code-scanning/analyses`, root });
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/${REPO}/code-scanning/alerts/999`, body: JSON.stringify({ state: 'open' }), root });
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 + security updates', 'api', 'niche', () =>
1926
- withRoot(async (root) => {
1927
- const a = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/dependabot/alerts`, body: JSON.stringify({ package_name: 'lodash', ecosystem: 'npm', severity: 'critical', ghsa_id: 'GHSA-x' }), root });
1928
- if (!(a.response.status === 201 && (a.response.body as { state?: string }).state === 'open' && (a.response.body as { security_advisory?: { ghsa_id?: string } }).security_advisory?.ghsa_id === 'GHSA-x')) return false;
1929
- const n = (a.response.body as { number?: number }).number!;
1930
- if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'dismissed' }), root })).response.status !== 422) return false;
1931
- const dm = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'dismissed', dismissed_reason: 'tolerable_risk' }), root });
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/${REPO}/dependabot/alerts/${n}`, body: JSON.stringify({ state: 'open' }), root });
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/${REPO}/dependabot/alerts?state=dismissed`, root });
1937
- const get = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/dependabot/alerts/${n}`, root });
1938
- return ok(dismissed.status) && isArr(dismissed.body) && dismissed.body.length === 0 && ok(get.status) && (get.body as { severity?: unknown; security_vulnerability?: { severity?: string } }).security_vulnerability?.severity === 'critical';
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
- const a = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/secret-scanning/alerts`, body: JSON.stringify({ secret_type: 'github_pat' }), root });
1943
- if (!(a.response.status === 201 && (a.response.body as { state?: string }).state === 'open')) return false;
1944
- const n = (a.response.body as { number?: number }).number!;
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/${REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved' }), root })).response.status !== 422) return false;
1947
- if ((await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved', resolution: 'bogus' }), root })).response.status !== 422) return false;
1948
- const rs = await applyGithubWrite({ method: 'PATCH', path: `/repos/${REPO}/secret-scanning/alerts/${n}`, body: JSON.stringify({ state: 'resolved', resolution: 'revoked' }), root });
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/${REPO}/secret-scanning/alerts/${n}/locations`, root });
1951
- const open = handleGithubRequest({ method: 'GET', path: `/repos/${REPO}/secret-scanning/alerts?state=open`, root });
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
- // seed an installation (twin construct — real installs can't be observed offline), list,
2028
- // get one, then mint an installation access token bound to its permissions.
2029
- const inst = await applyGithubWrite({ method: 'POST', path: '/app/installations', body: JSON.stringify({ account_login: 'octo-org', app_id: 7, app_slug: 'ci-bot', permissions: { contents: 'read', issues: 'write' }, events: ['push'] }), root });
2030
- if (!(inst.response.status === 201 && (inst.response.body as { account?: { login?: string } }).account?.login === 'octo-org')) return false;
2031
- const id = (inst.response.body as { id?: number }).id!;
2032
- const list = handleGithubRequest({ method: 'GET', path: '/app/installations', root });
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.oauth.flow', 'auth', 'OAuth app authorization + token scopes', 'api', 'niche', () =>
2043
- withRoot(async (root) => {
2044
- // The API half of the OAuth flow: exchange a code for a token bound to scopes, then a
2045
- // scope-gated call returns 401 (no token), 403 (missing scope), 200 (has scope); revoke.
2046
- // The hosted CONSENT SCREEN (a browser UI, not an API) is out-of-scope (carved below).
2047
- if ((await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'cid' }), root })).response.status !== 422) return false;
2048
- const ex = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'cid', code: 'abc', scope: 'repo read:org' }), root });
2049
- if (!(ok(ex.response.status) && (ex.response.body as { token_type?: string }).token_type === 'bearer' && (ex.response.body as { scope?: string }).scope === 'repo,read:org')) return false;
2050
- const token = (ex.response.body as { access_token?: string }).access_token!;
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: core write events (pull_request/issues/comment/review)', 'connector', 'common', () =>
2146
- withRoot(async (root) => {
2147
- const a = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/pulls`, body: JSON.stringify({ title: 'w', head: 'a', base: 'main' }), root });
2148
- const b = await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/issues`, body: JSON.stringify({ title: 'w' }), root });
2149
- return a.webhook?.event === 'pull_request' && a.webhook.action === 'opened' && b.webhook?.event === 'issues' && b.webhook.action === 'opened';
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
- const mem = await applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/collaborators/bob`, body: JSON.stringify({ permission: 'push' }), root });
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: deliveries log + redelivery + signatures', 'connector', 'common', () =>
2170
- withRoot(async (root) => {
2171
- const { clearGithubWebhooks, registerGithubWebhook, emitGithubEvent, listGithubDeliveries, redeliverGithubDelivery, signGithubPayload } = await import('./github-events.ts');
2172
- clearGithubWebhooks();
2173
- registerGithubWebhook('https://e.x/hook', 's3cr3t');
2174
- const captured: Array<{ headers: Record<string, string>; body: string }> = [];
2175
- const deliver = (_url: string, headerEvent: string, payload: unknown, headers: Record<string, string>) => { captured.push({ headers, body: JSON.stringify(payload) }); };
2176
- await emitGithubEvent({ event: 'pull_request', action: 'opened', repository: REPO, number: 1 }, { deliver });
2177
- // a delivery is logged with an id + signature; the signature verifies against the secret.
2178
- const deliveries = listGithubDeliveries();
2179
- if (deliveries.length !== 1) return false;
2180
- const d = deliveries[0]!;
2181
- if (d.event !== 'pull_request' || typeof d.id !== 'string') return false;
2182
- const expectedSig = signGithubPayload('s3cr3t', d.payloadJson);
2183
- if (captured[0]?.headers['x-hub-signature-256'] !== expectedSig) return false;
2184
- // redelivery replays the SAME payload to the endpoint (a second captured delivery).
2185
- const re = await redeliverGithubDelivery(d.id, { deliver });
2186
- clearGithubWebhooks();
2187
- return re === true && captured.length === 2 && captured[1]!.body === captured[0]!.body;
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 { pushPendingGithubActions } = await import('./github-connector.ts');
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
- const execute = {
2310
- async request(route: string) {
2311
- calls.push(route);
2312
- if (route === 'POST /repos/{owner}/{repo}/pulls') return { status: 201, data: { number: 42 } };
2313
- if (route === 'POST /repos/{owner}/{repo}/issues') return { status: 201, data: { number: 77 } };
2314
- return { status: 201, data: { id: 1 } };
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: register a workflow + dispatch it (real run + job) — assert the
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 applyGithubWrite({ method: 'PUT', path: `/repos/${REPO}/actions/workflows/probe.yml`, body: JSON.stringify({ name: 'Probe CI', path: '.github/workflows/probe.yml' }), root });
2381
- await applyGithubWrite({ method: 'POST', path: `/repos/${REPO}/actions/workflows/probe.yml/dispatches`, body: JSON.stringify({ ref: 'main' }), root });
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: `/repos/${REPO}/releases/${id}/assets`, body: JSON.stringify({ name: 'probe.zip', content_type: 'application/zip', size: 2048 }), root });
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 applyGithubWrite({ method: 'PUT', path: '/repos/octo/demo/collaborators/bob', body: JSON.stringify({ permission: 'push' }), root });
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: seed a real notification thread — assert it appears unread
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) => applyGithubWrite({ method: 'POST', path: `/notifications`, body: JSON.stringify({ repository: REPO, subject_title: 'Notification Probe', subject_type: 'PullRequest', reason: 'review_requested' }), 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 an impossibility warranting outOfScope). See GITHUB_AREAS
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'),