@volter/twin-github 0.1.2 → 2.0.1

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 +232 -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 +104 -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 +170 -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 +225 -95
  161. package/src/github-git-http.ts +48 -84
  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 +50 -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 +193 -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
@@ -27,7 +27,7 @@
27
27
  // journey `goto()`s a PR path directly (no clicking) and proves back/forward (popstate).
28
28
  import { describe, test } from 'bun:test';
29
29
  import type { Page } from 'playwright';
30
- import { requireBrowser, runUiJourney, JOURNEY_TIMEOUT_MS, visible, gone, atPath, waitForMount } from '@volter/twin-tooling';
30
+ import { requireBrowser, runUiJourney, JOURNEY_TIMEOUT_MS, visible, gone, atPath, waitForMount } from '@volter/world-tooling';
31
31
 
32
32
  const JOURNEY_LABEL = 'github UI journey';
33
33
  import { applyGithubWrite } from './github-twin.ts';
@@ -79,8 +79,8 @@ describe('github UI journey (pilot, TWIN-47 dev/02)', () => {
79
79
  occurredAt: '2026-05-03T00:00:00Z',
80
80
  });
81
81
  },
82
- serve: (root) => {
83
- const server = createGithubMirrorServer({ root, port: 0 });
82
+ serve: async (root) => {
83
+ const server = await createGithubMirrorServer({ root, port: 0 });
84
84
  return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
85
85
  },
86
86
  journey: async (page) => {
@@ -91,51 +91,35 @@ describe('github UI journey (pilot, TWIN-47 dev/02)', () => {
91
91
  await visible(page, 'Twin Journey Beta PR');
92
92
  await atPath(page, '/pulls');
93
93
 
94
- // 2. Click the Issues TAB (a real <button>, role-reachable) → the seeded issue title
95
- // appears AND the pulls-only markers (the seeded PR titles) are gone — proves the
96
- // click handler fired and the view actually swapped (hydration is live, not SSR
97
- // residue left over from the initial HTML). URL: /issues (no issue opened yet).
98
- await page.getByRole('button', { name: /^Issues/ }).click();
94
+ // 2. Open the Alpha PR from the list by its title link (GitHub's list rows are links) →
95
+ // its body appears, content the list row never renders. Opening a PR routes to ITS
96
+ // OWN repo's numbered path, even from the cross-repo list — /acme/twin-journey/pull/1.
97
+ await gone(page, 'alpha-pr-body-marker');
98
+ await page.getByRole('link', { name: 'Twin Journey Alpha PR' }).click();
99
+ await visible(page, 'alpha-pr-body-marker');
100
+ await atPath(page, '/acme/twin-journey/pull/1');
101
+
102
+ // 3. The pull request page carries its repository's navigation: its Issues tab swaps the
103
+ // view (the PR's body is gone, the seeded issue shows). URL: /acme/twin-journey/issues.
104
+ await page.getByRole('link', { name: /^Issues/ }).click();
99
105
  await visible(page, 'Twin Journey Tracking Issue');
100
- await gone(page, 'Twin Journey Alpha PR');
101
- await gone(page, 'Twin Journey Beta PR');
102
- await atPath(page, '/issues');
106
+ await gone(page, 'alpha-pr-body-marker');
107
+ await atPath(page, '/acme/twin-journey/issues');
103
108
 
104
- // 2b. Click the seeded issue's row (dev/02: github.journey.issue_detail — the Issues
105
- // row is a role-reachable <button>, not a bare onClick <div>). With only one seeded
106
- // issue, its detail pane is already shown as the split-pane default (same
107
- // single-item fallback the PR view uses, so no "gone before click" assertion here
108
- // — that's covered by the multi-PR case below); the URL is what proves the click
109
- // actually navigated: it becomes the issue's own repo-scoped detail path — issues
110
- // and PRs share one number counter, so this repo's 3rd object (after the 2 seeded
111
- // PRs) is issue #3.
109
+ // 3b. Click the seeded issue's row (a role-reachable <button>): the URL becomes the
110
+ // issue's own path — issues and PRs share one number counter, so it is #3.
112
111
  await visible(page, 'tracking-issue-body-marker');
113
112
  await page.getByRole('button', { name: /Twin Journey Tracking Issue/ }).click();
114
113
  await atPath(page, '/acme/twin-journey/issues/3');
115
114
 
116
- // 3. Click back to Pull requests — the PR titles reappear (round-trip proves the tab
117
- // state, not a one-way navigation, actually drives the render). URL: back to /pulls
118
- // (no PR opened in this run yet, so no numbered path).
119
- await page.getByRole('button', { name: /^Pull requests/ }).click();
120
- await visible(page, 'Twin Journey Alpha PR');
121
- await atPath(page, '/pulls');
122
-
123
- // 4. Click the Alpha PR's row (a real <button>, role-reachable via its title text) →
124
- // its body appears in the detail pane — content the list row NEVER renders (the
125
- // list row only shows title/repo/state/meta, never pr.body). This proves selection
126
- // state flows PR-specific data through a live store-door fetch (`GET /twin/store/mirror`), not a static shell.
127
- // URL: opening a PR always routes to ITS OWN repo's numbered path, even from the
128
- // "all repos" list — /acme/twin-journey/pull/1.
129
- await gone(page, 'alpha-pr-body-marker'); // not shown pre-selection (Beta sorts first)
130
- await page.getByRole('button', { name: /Twin Journey Alpha PR/ }).click();
131
- await visible(page, 'alpha-pr-body-marker');
132
- await atPath(page, '/acme/twin-journey/pull/1');
115
+ // 4. Back to Pull requests through the repository tab: the list shows both PRs again.
116
+ await page.getByRole('link', { name: /^Pull requests/ }).click();
117
+ await visible(page, 'Twin Journey Beta PR');
118
+ await atPath(page, '/acme/twin-journey/pulls');
133
119
 
134
- // 5. Click the Beta PR's row → its review body (seeded via a REAL review write)
135
- // becomes visible — proves the review data-coupling reaches the live DOM too, and
136
- // that switching selection re-renders (Alpha's body is no longer shown). URL moves
137
- // to PR #2's path.
138
- await page.getByRole('button', { name: /Twin Journey Beta PR/ }).click();
120
+ // 5. Open the Beta PR → its review body (seeded via a REAL review write) renders in the
121
+ // timeline, and Alpha's body is not shown. URL moves to PR #2's path.
122
+ await page.getByRole('link', { name: 'Twin Journey Beta PR' }).click();
139
123
  await visible(page, 'beta-review-body-marker');
140
124
  await gone(page, 'alpha-pr-body-marker');
141
125
  await atPath(page, '/acme/twin-journey/pull/2');
@@ -162,8 +146,8 @@ describe('github UI journey — deep link + back/forward (TWIN-49 / H3, dev/02)'
162
146
  occurredAt: '2026-05-10T01:00:00Z',
163
147
  });
164
148
  },
165
- serve: (root) => {
166
- const server = createGithubMirrorServer({ root, port: 0 });
149
+ serve: async (root) => {
150
+ const server = await createGithubMirrorServer({ root, port: 0 });
167
151
  return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
168
152
  },
169
153
  journey: async (page) => {
@@ -179,7 +163,7 @@ describe('github UI journey — deep link + back/forward (TWIN-49 / H3, dev/02)'
179
163
 
180
164
  // Click to another view (real navigation, pushState) — proves the deep-linked page is
181
165
  // still live/interactive, not a dead static render.
182
- await page.getByRole('button', { name: /^Issues/ }).click();
166
+ await page.getByRole('link', { name: /^Issues/ }).click();
183
167
  await atPath(page, '/acme/deep-link-journey/issues');
184
168
  await gone(page, 'deep-link-beta-body-marker');
185
169
 
@@ -20,7 +20,12 @@ const OBSERVED = 'content not mirrored by the evidence twin';
20
20
 
21
21
  /** The `mirror` store: the whole console payload, projected from the twin's github world. */
22
22
  export function githubMirrorState(root?: string): Record<string, unknown> {
23
- const { prs, issues, reviews: foldedReviews, comments: foldedComments, statuses, checkRuns, milestones, workflows: foldedWorkflows, workflowRuns, jobs, releases: foldedReleases, releaseAssets, tags, discussions: foldedDiscussions, discussionComments: foldedDiscussionComments, discussionCategories: customCategories, contents: foldedContents, notifications: foldedNotifications, deployments: foldedDeployments, deploymentStatuses: foldedDeploymentStatuses, environments: foldedEnvironments, projects: foldedProjects, projectFields: foldedProjectFields, projectItems: foldedProjectItems, projectViews: foldedProjectViews, repos: foldedRepos, collaborators: foldedCollaborators, webhooks: foldedWebhooks, branches: foldedBranches } = githubState(root);
23
+ const { prs: foldedPrs, issues, reviews: foldedReviews, comments: foldedComments, statuses, checkRuns, milestones, workflows: foldedWorkflows, workflowRuns, jobs, releases: foldedReleases, releaseAssets, tags, discussions: foldedDiscussions, discussionComments: foldedDiscussionComments, contents: foldedContents, notifications: foldedNotifications, deployments: foldedDeployments, deploymentStatuses: foldedDeploymentStatuses, environments: foldedEnvironments, projects: foldedProjects, projectFields: foldedProjectFields, projectItems: foldedProjectItems, projectViews: foldedProjectViews, repos: foldedRepos, collaborators: foldedCollaborators, webhooks: foldedWebhooks, branches: foldedBranches } = githubState(root);
24
+ // Only PULL REQUESTS are pull requests. The fold's `prs` map also holds the shells it
25
+ // materializes for the review/comment counters, which hang off issue numbers too — the
26
+ // mirror is a door onto the same world the REST list serves, and the two must agree about
27
+ // what a pull request is.
28
+ const prs = foldedPrs.filter((p) => p.is_pull_request);
24
29
  // CI rollup per PR head_sha from commit statuses + check runs (a LOCAL CI construct;
25
30
  // observed-only PRs carry no head_sha CI, so this stays null for them — honesty).
26
31
  // Precedence mirrors GitHub's rollup: any failure/error → 'failure'; any pending/queued/
@@ -75,35 +80,10 @@ export function githubMirrorState(root?: string): Record<string, unknown> {
75
80
  ciStatus: ciStatusFor(pr.repository, pr.head_sha),
76
81
  };
77
82
  });
78
- // Per-commit detail + per-file diffs ride on LOCAL PR writes only (observed PR evidence
79
- // carries COUNTS, never content) — so these lists are empty for observed-only PRs, keeping
80
- // the mirror honest. Each entry is tagged with repo+number so the client can scope it to
81
- // the selected PR, mirroring the GitHub conversation's Commits + Files-changed sections.
82
- const commits = prs.flatMap((pr) =>
83
- (pr.commits_list ?? []).map((c) => ({
84
- repo: pr.repository,
85
- number: pr.number,
86
- sha: c.sha,
87
- message: c.message,
88
- author: c.author_name,
89
- date: c.author_date,
90
- url: `${GITHUB_WEB_ORIGIN}/${pr.repository}/commit/${c.sha}`,
91
- })),
92
- );
93
- const files = prs.flatMap((pr) =>
94
- (pr.files ?? []).map((f) => ({
95
- repo: pr.repository,
96
- number: pr.number,
97
- filename: f.filename,
98
- status: f.status,
99
- additions: f.additions,
100
- deletions: f.deletions,
101
- changes: f.changes,
102
- // patch: the unified-diff hunk text (LOCAL writes only) — the Files-changed diff view
103
- // renders these as +/- lines; observed PRs carry no per-file diff so this is undefined.
104
- patch: f.patch,
105
- })),
106
- );
83
+ // Per-commit detail and per-file diffs live in the git plane, which this projection does not read: the console
84
+ // reads them over REST (client/pulls-rest.ts).
85
+ const commits: never[] = [];
86
+ const files: never[] = [];
107
87
  // First-class issues (a LOCAL construct) grouped per repo via the `repo` field, like PRs.
108
88
  const issuesOut = issues.map((iss) => {
109
89
  const ms = iss.milestone !== undefined ? milestones.find((m) => m.repository === iss.repository && m.number === iss.milestone) : undefined;
@@ -121,14 +101,6 @@ export function githubMirrorState(root?: string): Record<string, unknown> {
121
101
  milestone: ms?.title,
122
102
  stateReason: iss.state_reason ?? undefined,
123
103
  locked: iss.locked ?? false,
124
- // linked PRs/issues (a LOCAL relation): surface as typed references the client links.
125
- linked: (iss.linked ?? []).map((l) => ({
126
- type: l.type,
127
- number: l.number,
128
- url: l.type === 'pull_request'
129
- ? `${GITHUB_WEB_ORIGIN}${githubPullPath(iss.repository, l.number)}`
130
- : `${GITHUB_WEB_ORIGIN}${githubIssuePath(iss.repository, l.number)}`,
131
- })),
132
104
  // issue comments (kind:'issue' on the shared comment space) — the detail page renders
133
105
  // these in its timeline; observed comments carry no body (labelled, never fabricated).
134
106
  comments: foldedComments
@@ -239,7 +211,7 @@ export function githubMirrorState(root?: string): Record<string, unknown> {
239
211
  // answer pointer, and its comments/replies (with the is_answer flag). Surfaced per-repo.
240
212
  const discussionRepos = new Set(foldedDiscussions.map((d) => d.repository));
241
213
  const categoryByRepoSlug = new Map<string, { name: string; emoji?: string; isAnswerable: boolean }>();
242
- for (const r of discussionRepos) for (const c of discussionCategoriesFor(r, customCategories)) categoryByRepoSlug.set(`${r}#${c.slug}`, { name: c.name, emoji: c.emoji, isAnswerable: c.is_answerable ?? false });
214
+ for (const r of discussionRepos) for (const c of discussionCategoriesFor(r)) categoryByRepoSlug.set(`${r}#${c.slug}`, { name: c.name, emoji: c.emoji, isAnswerable: c.is_answerable ?? false });
243
215
  const discussions = foldedDiscussions.map((d) => {
244
216
  const cat = d.category_slug ? categoryByRepoSlug.get(`${d.repository}#${d.category_slug}`) : undefined;
245
217
  const threadComments = foldedDiscussionComments.filter((c) => c.repository === d.repository && c.discussion_number === d.number);
@@ -305,43 +277,32 @@ export function githubMirrorState(root?: string): Record<string, unknown> {
305
277
  reviewers: e.reviewers ?? [],
306
278
  protected: Boolean(e.wait_timer) || Boolean(e.reviewers?.length),
307
279
  }));
308
- // ── Projects v2 board (a LOCAL construct) — the Project board screen. Each project carries
309
- // its views (board/table) + its Status field's options as COLUMNS, and its items grouped
310
- // into those columns by their Status field value (the board's swim-lanes). Data-coupled:
311
- // seeding a project + a Status field + items moves the column counts the board renders.
280
+ // ── Projects v2 boards — the Project board screen. Each project carries its views and its first single-select
281
+ // field's options as COLUMNS, with its items grouped into them by that field's value; an item with none lands
282
+ // in "No Status" (GitHub's board). Data-coupled: a field, an item or a value moves what the board renders.
312
283
  const projectBoards = foldedProjects.map((p) => {
313
284
  const fields = foldedProjectFields.filter((f) => f.project_id === p.id);
314
- const statusField = fields.find((f) => f.data_type === 'single_select') ?? fields.find((f) => /status/i.test(f.name));
315
- const columnNames = statusField?.options?.map((o) => o.name) ?? [];
285
+ const statusField = fields.find((f) => f.data_type === 'single_select');
316
286
  const items = foldedProjectItems.filter((i) => i.project_id === p.id);
317
- const columns = columnNames.map((name) => ({
318
- name,
319
- cards: items
320
- .filter((i) => String((i.field_values as Record<string, unknown>)[statusField!.name] ?? '') === name)
321
- .map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })),
322
- }));
323
- // items with no (or an unrecognized) status land in a "No Status" lane (GitHub behavior).
324
- const noStatus = items.filter((i) => !columnNames.includes(String((i.field_values as Record<string, unknown>)[statusField?.name ?? ''] ?? '')));
287
+ const card = (i: (typeof items)[number]) => ({ id: i.id, title: i.title ?? `#${i.content_number ?? i.id}`, contentType: i.content_type });
288
+ const optionOf = (i: (typeof items)[number]) => statusField?.options?.find((o) => o.id === i.field_values?.[statusField.id]);
325
289
  return {
326
290
  id: p.id, number: p.number, title: p.title, owner: p.owner, closed: p.closed ?? false,
327
291
  views: foldedProjectViews.filter((v) => v.project_id === p.id).map((v) => ({ number: v.number, name: v.name, layout: v.layout })),
328
292
  fields: fields.map((f) => ({ name: f.name, dataType: f.data_type })),
329
293
  itemCount: items.length,
330
- columns,
331
- noStatus: { name: 'No Status', cards: noStatus.map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })) },
294
+ columns: (statusField?.options ?? []).map((o) => ({ name: o.name, cards: items.filter((i) => optionOf(i)?.id === o.id).map(card) })),
295
+ noStatus: { name: 'No Status', cards: items.filter((i) => !optionOf(i)).map(card) },
332
296
  url: `${GITHUB_WEB_ORIGIN}/${p.owner_type === 'organization' ? 'orgs/' : 'users/'}${p.owner}/projects/${p.number}`,
333
297
  };
334
298
  });
335
299
  // ── Insights / Pulse / Contributors (DERIVED per repo from the modeled PRs/issues/commits) —
336
- // the Pulse screen's counts (merged/opened PRs, opened/closed issues) + a contributors
337
- // leaderboard from the per-commit authors the twin tracks. Data-coupled: each new PR/issue
338
- // moves a Pulse count; each new commit author moves a contributor's commit total.
300
+ // the Pulse screen's counts (merged/opened PRs, opened/closed issues). Data-coupled: each new
301
+ // PR/issue moves a Pulse count.
339
302
  const repoNames = new Set<string>([...prs.map((p) => p.repository), ...issues.map((i) => i.repository), ...foldedRepos.map((r) => r.full_name)]);
340
303
  const insights = [...repoNames].sort().map((repo) => {
341
304
  const repoPrs = prs.filter((p) => p.repository === repo);
342
305
  const repoIssues = issues.filter((i) => i.repository === repo);
343
- const contribCounts = new Map<string, number>();
344
- for (const pr of repoPrs) for (const c of pr.commits_list ?? []) { const a = c.author_name ?? '(unknown)'; contribCounts.set(a, (contribCounts.get(a) ?? 0) + 1); }
345
306
  return {
346
307
  repo,
347
308
  pulse: {
@@ -350,7 +311,8 @@ export function githubMirrorState(root?: string): Record<string, unknown> {
350
311
  openedIssues: repoIssues.filter((i) => (i.state ?? 'open') === 'open').length,
351
312
  closedIssues: repoIssues.filter((i) => i.state === 'closed').length,
352
313
  },
353
- contributors: [...contribCounts.entries()].map(([login, commits]) => ({ login, commits })).sort((a, b) => b.commits - a.commits),
314
+ // commit authors live in the git plane, which this projection does not read
315
+ contributors: [],
354
316
  };
355
317
  });
356
318
  // ── Settings (DERIVED per repo from the modeled repo config + collaborators/webhooks/branches)
@@ -7,6 +7,9 @@
7
7
  // console makes goes through the vendor's own REST API on the same origin — the wire a
8
8
  // real GitHub client speaks, so the mirror renders a twin or a real account unchanged,
9
9
  // pointed at any origin by configuration. The client is the shared world UI-mirror design.
10
+ import { readFile } from 'node:fs/promises';
11
+ import { bundleClient, fileResponse } from '@volter/world-core';
12
+ import { serveHttp } from '@volter/world-core';
10
13
  import { createGithubTwinFetch } from './github-server.ts';
11
14
 
12
15
  const CLIENT_ENTRY = () => new URL('../client/github-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
@@ -14,17 +17,15 @@ const CLIENT_CSS = () => new URL('../client/github-mirror.css', import.meta.url)
14
17
 
15
18
  const APP_SHELL = `<!doctype html>
16
19
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
17
- <title>GitHub UI mirror (twin)</title><link rel="stylesheet" href="/assets/styles.css"></head>
18
- <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
20
+ <base href="/"><title>GitHub UI mirror (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
21
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
19
22
 
23
+ // cache: the built client bundle, memoized per process — a build artifact, never twin state (the tree
24
+ // holds what this UI renders); cleared on a failed build so the next request rebuilds.
20
25
  let clientBundle: Promise<string> | null = null;
21
26
  export function buildGithubMirrorClient(): Promise<string> {
22
27
  if (!clientBundle) {
23
- clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY()], target: 'browser', minify: true })
24
- .then(async (result) => {
25
- if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'github UI mirror client build failed');
26
- return result.outputs[0]!.text();
27
- })
28
+ clientBundle = bundleClient(CLIENT_ENTRY())
28
29
  .catch((error) => { clientBundle = null; throw error; });
29
30
  }
30
31
  return clientBundle;
@@ -56,9 +57,9 @@ function isGitSmartHttpPath(pathname: string): boolean {
56
57
  * drift. There is no mirror-private route: `/api/state` (the old fold) answers with the
57
58
  * vendor's own `Not Found` envelope, because GitHub has no such endpoint.
58
59
  */
59
- export function createGithubMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
60
+ export async function createGithubMirrorServer(options: { root?: string; port?: number }): Promise<{ port: number; stop: () => void }> {
60
61
  const twin = createGithubTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}) });
61
- const server = Bun.serve({
62
+ const server = await serveHttp({
62
63
  // LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
63
64
  // wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
64
65
  // on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
@@ -76,7 +77,7 @@ export function createGithubMirrorServer(options: { root?: string; port?: number
76
77
  catch (error) { return new Response(String(error), { status: 500 }); }
77
78
  }
78
79
  if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
79
- return new Response(Bun.file(CLIENT_CSS()), { headers: { 'content-type': 'text/css; charset=utf-8' } });
80
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
80
81
  }
81
82
  // The uniform doors (`/twin`, `/twin/store/<name>`) are the twin's, whoever asks.
82
83
  if (url.pathname === '/twin' || url.pathname.startsWith('/twin/')) return twin(request);
@@ -95,3 +96,8 @@ export function createGithubMirrorServer(options: { root?: string; port?: number
95
96
  export function githubMirrorHtml(): string {
96
97
  return APP_SHELL;
97
98
  }
99
+
100
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
101
+ export function githubMirrorStyles(): Promise<string> {
102
+ return readFile(CLIENT_CSS(), 'utf8');
103
+ }
@@ -4,11 +4,25 @@
4
4
  // actions and fire GitHub webhooks (R17). Mirror mode rejects writes (R4).
5
5
  // The git SMART-HTTP protocol (clone/fetch/push by an unmodified `git` CLI) is served
6
6
  // FIRST — its routes (/:owner/:repo/info/refs, git-upload-pack, git-receive-pack) are
7
- // binary pkt-line streams bridged to real `git` subprocesses (github-git-http.ts); a
8
- // receive-pack push emits `push` webhooks through the same emitGithubEvent pipeline.
9
- import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
10
- import { worldNow, statefulTwinManifest} from '@volter/twin';
11
- import { emitGithubEvent } from './github-events.ts';
7
+ // binary pkt-line streams over the kernel's git library (github-git-http.ts); a push is
8
+ // announced as an API commit's is (semantics/shared.ts announce).
9
+ import { bindSemantics, compileSurface, coreFor, createDerivedFetch, crossCutting, matchOperation, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
10
+ import surface from './generated/surface.gen.json' with { type: 'json' };
11
+ import { manifest, spanning } from './manifest.ts';
12
+ import { githubSemantics } from './semantics/index.ts';
13
+ import { githubGraphql } from './semantics/graphql.ts';
14
+ import { createGraphqlWire } from './graphql-wire.ts';
15
+ import { githubState, handleGithubDoor, revokedToken } from './github-twin.ts';
16
+ import { announce } from './semantics/shared.ts';
17
+ import { answerVendorErrors, RefusedWriteError } from '@volter/world-core';
18
+ import { worldNow, statefulTwinManifest} from '@volter/world-core';
19
+ import { createHash } from 'node:crypto';
20
+ import { githubOAuthFlow } from './screens/oauth.tsx';
21
+ import { githubSessionFlow } from './screens/session.tsx';
22
+ import { githubNpmRegistry } from './npm-registry.ts';
23
+ import { githubAppInstallationFlow } from './screens/app-installation.tsx';
24
+ import { githubAppManifestFlow } from './screens/app-manifest.tsx';
25
+ import { assetUploads } from './semantics/releases.ts';
12
26
  import { handleGithubGitSmartHttp, parseGitSmartHttpPath } from './github-git-http.ts';
13
27
  import { githubMirrorState } from './github-mirror-state.ts';
14
28
  import { GITHUB_MIRROR_STORE } from './github-shared.ts';
@@ -27,7 +41,7 @@ const STORE_DOOR = '/twin/store/';
27
41
  /** Options shared by the fetch handler and the Bun.serve wrapper around it. `port` is a BIND
28
42
  * concern the fetch ignores; it stays in one shape so a caller configures the twin once
29
43
  * whichever way it is mounted. */
30
- export type GithubTwinOptions = { root?: string; port?: number; readOnly?: boolean };
44
+ export type GithubTwinOptions = { root?: string; port?: number; readOnly?: boolean; clock?: () => string };
31
45
 
32
46
  /**
33
47
  * The whole GitHub serve path as a plain `(Request) => Response` — the `/twin` manifest, the
@@ -35,23 +49,37 @@ export type GithubTwinOptions = { root?: string; port?: number; readOnly?: boole
35
49
  * about it is port-bound, and `createGithubTwinServer` is one line of `Bun.serve` around it,
36
50
  * so the standalone (R1) and any in-process lane execute the SAME bytes of serving code.
37
51
  *
38
- * The GIT PLANE is a BUN-SHELL CAPABILITY, not a serve-path prerequisite: real bare repos
39
- * on a real filesystem driven by real `git` subprocesses (github-git-plane.ts —
40
- * `Bun.spawnSync(['git', …])` + `node:fs` under `worldPaths('github')`, bypassing the
41
- * WorldStore seam by design: "the bare repo IS the object store, one store not two").
42
- * Every plane touch on the REST read path is EXISTENCE-GUARDED (`hasBareRepo` →
43
- * `existsSync`), so on a shell with no plane (workerd: no `git`, empty virtual fs) the
44
- * plane is simply ABSENT — reads answer from the folded observed/action state alone, which
45
- * is exactly what a pull-fed remote mirror serves (R14: `syncGithubFromRemote` folds PRs,
46
- * reviews, comments and issues; none of that needs the plane). The git smart-HTTP branch
47
- * is the one route that cannot degrade: it refuses loudly where `git` cannot be spawned,
48
- * because a clone/push against a plane-less shell has no honest answer but no.
52
+ * The GIT PLANE is the KERNEL'S GIT LIBRARY (docs/contributing/architecture.md#the-engine-slot):
53
+ * one content-addressed object store per world over the blob seam, refs as world state, smart HTTP
54
+ * in pure JS — Bun and workerd alike, no `git` binary. Every plane touch on the REST read path is
55
+ * still existence-guarded (`hasBareRepo`), so a world with no repositories answers from the folded
56
+ * state alone, which is exactly what a pull-fed remote mirror serves (R14: `syncGithubFromRemote` folds PRs,
57
+ * reviews, comments and issues; none of that needs the plane).
49
58
  */
59
+ /** A release asset's upload: its raw bytes and media type go to the upload operation beside a request with no body,
60
+ * since the operation reads them as the file (semantics/releases.ts). */
61
+ async function uploadingAsset(request: Request, inner: (request: Request) => Promise<Response>): Promise<Response> {
62
+ const bytes = new Uint8Array(await request.arrayBuffer());
63
+ const type = request.headers.get('content-type');
64
+ const headers = new Headers(request.headers);
65
+ headers.delete('content-type'); headers.delete('content-length');
66
+ return assetUploads.run({ bytes, type }, () => inner(new Request(request.url, { method: request.method, headers })));
67
+ }
68
+
69
+ /** GitHub's refusal of a request body that is not JSON. */
70
+ function malformedJson(): Response {
71
+ return Response.json({ message: 'invalid JSON body' }, { status: 400 });
72
+ }
73
+
50
74
  export function createGithubTwinFetch(options: GithubTwinOptions): (request: Request) => Promise<Response> {
51
75
  const readOnly = options.readOnly ?? false;
52
76
  const stores: Record<string, () => unknown> = { [GITHUB_MIRROR_STORE]: () => githubMirrorState(options.root) };
53
77
  const storeNames = Object.keys(stores).sort();
54
- return async function githubTwinFetch(request: Request): Promise<Response> {
78
+ const now = options.clock ?? worldNow;
79
+ const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
80
+ // The twin's doors, in front of GitHub's API: discovery, the store door, the git plane and the stand-ins for acts
81
+ // GitHub does in its web UI (github-twin.ts handleGithubDoor).
82
+ const doors = async (request: Request): Promise<Response | undefined> => {
55
83
  const url = new URL(request.url);
56
84
  const cleanPath = url.pathname.replace(/\/+$/, '') || '/';
57
85
  // GET /twin — the discovery manifest (education inside the twin). It EDUCATES the
@@ -68,16 +96,10 @@ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Req
68
96
  if (store === undefined) return Response.json({ error: 'unknown store', store: name, stores: storeNames }, { status: 404 });
69
97
  return Response.json(store());
70
98
  }
71
- const path = url.pathname + url.search;
72
- // git smart-HTTP (binary protocol, not JSON) — try first; null means "not a git route".
73
- // Route-parse BEFORE touching the body so ordinary REST POSTs are never double-buffered.
99
+ // git smart-HTTP (binary protocol, not JSON). Route-parse BEFORE touching the body so ordinary REST POSTs are
100
+ // never double-buffered.
74
101
  if ((request.method === 'GET' || request.method === 'POST') && parseGitSmartHttpPath(request.method, url.pathname)) {
75
- // Plane-less shells (workerd) cannot spawn git: refuse the protocol loudly instead
76
- // of dying mid-pkt-line. The REST evidence surface below still serves.
77
- if (typeof Bun === 'undefined' || typeof Bun.spawnSync !== 'function') {
78
- return Response.json({ message: 'git smart-HTTP is not served on this deployment (no git plane); the REST evidence surface is' }, { status: 501 });
79
- }
80
- const occurredAt = worldNow();
102
+ const occurredAt = now();
81
103
  const git = await handleGithubGitSmartHttp({
82
104
  method: request.method,
83
105
  pathname: url.pathname,
@@ -90,47 +112,123 @@ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Req
90
112
  occurredAt,
91
113
  });
92
114
  if (git) {
93
- // Webhook delivery is fire-and-forget (real GitHub: a delivery failure never fails
94
- // the push) — a dead emit pipeline reddens the webhook verifies, not the protocol.
95
- for (const webhook of git.webhooks) { try { await emitGithubEvent(webhook, { occurredAt }); } catch { /* fire-and-forget */ } }
115
+ // a push moves branches as an API commit does: GitHub's own processes read it (workflows, secret scanning,
116
+ // Dependabot, rulesets) and its webhooks are delivered (semantics/shared.ts announce)
117
+ if (git.webhooks.length) {
118
+ const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), GIT_OPERATION, { ...scope, clock: () => occurredAt });
119
+ await announce(ctx, git.webhooks);
120
+ }
96
121
  return new Response(git.body as BodyInit, { status: git.status, headers: { 'content-type': git.contentType, 'cache-control': 'no-cache' } });
97
122
  }
98
123
  }
99
- if (request.method === 'GET') {
100
- if (url.pathname === '/' || url.pathname === '') return Response.json({ service: 'github', readOnly, evidence_only: true });
101
- const { status, body } = handleGithubRequest({ method: 'GET', path, ...(options.root !== undefined ? { root: options.root } : {}) });
102
- // 204 (e.g. "check if PR merged") carries no body.
103
- if (status === 204 || body === undefined) return new Response(null, { status });
104
- return Response.json(body as Record<string, unknown>, { status });
124
+ return readOnly ? undefined : handleGithubDoor(request, { ...scope, now });
125
+ };
126
+ // An archived repository is read-only (docs.github.com/repositories/archiving-a-github-repository/archiving-repositories/
127
+ // about-archiving-repositories): GitHub refuses a write to it, except the repository's own edit (which unarchives it),
128
+ // deleting it, and a person's own watch of it.
129
+ const cross = crossCutting(manifest, { readOnly, ...scope });
130
+ const readOnlyWhenArchived = (request: Request, next: () => Promise<Response>): Promise<Response> => {
131
+ if (request.method === 'GET' || request.method === 'HEAD') return next();
132
+ const seg = new URL(request.url).pathname.split('/').filter(Boolean);
133
+ if (seg[0] !== 'repos' || !seg[2] || seg.length === 3 || (seg.length === 4 && seg[3] === 'subscription')) return next();
134
+ if (!githubState(scope.root).repos.find((r) => r.full_name === `${seg[1]}/${seg[2]}`)?.archived) return next();
135
+ return Promise.resolve(Response.json({ message: 'Repository was archived so is read-only.', documentation_url: 'https://docs.github.com/articles/about-archiving-repositories' }, { status: 403 }));
136
+ };
137
+ // a refused or failed write at the head (kernel head.ts) answers in the vendor's own error shape
138
+ // The derived dispatch owns the REST wire: semantics handlers, then the derived core; an operation neither
139
+ // serves answers GitHub's Not Found.
140
+ const inner = createDerivedFetch({
141
+ surface: githubSurface,
142
+ handlers: bindSemantics(manifest, githubSemantics({ readOnly }), scope),
143
+ core: coreFor(manifest, scope),
144
+ around: (call, next) => readOnlyWhenArchived(call.request, () => cross(call, next)),
145
+ gap: () => vendorError(manifest, manifest.notFound),
146
+ });
147
+ // GraphQL is a second wire over the same state: the vendor's schema and the pack's resolvers, which get
148
+ // the handler interface for their request (semanticsContext). A query selecting a field nothing models
149
+ // answers GitHub's undefinedField error; a read-only twin refuses a mutation as it refuses any write.
150
+ const graphqlSemantics = githubGraphql();
151
+ const graphqlWire = createGraphqlWire({
152
+ loadSdl: async () => ((await import('./generated/graphql-sdl.gen.json', { with: { type: 'json' } })).default as { sdl: string }).sdl,
153
+ resolvers: graphqlSemantics.resolvers,
154
+ stored: graphqlSemantics.stored,
155
+ context: async (request) => ({ ctx: await semanticsContext(manifest, request, GRAPHQL_OPERATION, { ...scope, clock: now }) }),
156
+ unmodeled: ({ type, field }) => Response.json({ errors: [{ message: `Field '${field}' doesn't exist on type '${type}' in the twin's modeled GraphQL subset`, type: 'undefinedField' }] }),
157
+ });
158
+ const graphql = async (request: Request): Promise<Response> => {
159
+ if (readOnly) {
160
+ const { query } = (await request.clone().json().catch(() => ({}))) as { query?: unknown };
161
+ if (typeof query === 'string' && /^\s*mutation\b/.test(query)) return Response.json({ message: 'twin is read-only (mirror mode); start in simulator/fork to write' }, { status: 403 });
105
162
  }
106
- if (request.method === 'POST' || request.method === 'PATCH' || request.method === 'PUT' || request.method === 'DELETE') {
107
- const text = await request.text();
108
- // THE WORLD INSTANT, threaded into the write (R9). Without it `applyGithubWrite` falls back
109
- // to `new Date().toISOString()`, and every wall-clock stamp it lands — `created_at`/
110
- // `updated_at` on repos, issues, comments, reviews, statuses — differs between two
111
- // identical worlds. The same instant then stamps the webhook deliveries below, so a write
112
- // and its delivery can never disagree about when they happened.
113
- const occurredAt = worldNow();
114
- const outcome = await applyGithubWrite({ method: request.method, path, body: text, ...(options.root !== undefined ? { root: options.root } : {}), readOnly, occurredAt });
115
- // Defensive 500 (never a thrown socket error) when a handler yields no response —
116
- // real servers answer 500 to a handler fault; a throw here would surface as an
117
- // unhandled rejection in embedding test runners instead of an HTTP status.
118
- const response = outcome?.response;
119
- if (!response) return Response.json({ message: 'twin handler returned no response' }, { status: 500 });
120
- // Fire-and-forget, like real GitHub: a webhook delivery failure never fails the write.
121
- for (const event of [...(outcome.webhook ? [outcome.webhook] : []), ...(outcome.webhooks ?? [])]) {
122
- try { await emitGithubEvent(event, { occurredAt }); } catch { /* fire-and-forget */ }
123
- }
124
- // 204 (e.g. workflow_dispatch) carries no body.
125
- if (response.status === 204 || response.body === undefined) return new Response(null, { status: response.status });
126
- return Response.json(response.body as Record<string, unknown>, { status: response.status });
163
+ return graphqlWire(request);
164
+ };
165
+ // GitHub reads a write's body as JSON whatever its content type says (manifest.body.json), and
166
+ // answers 400 to one that does not parse.
167
+ const routes = compileSurface(githubSurface);
168
+ const rest = async (request: Request): Promise<Response> => {
169
+ const url = new URL(request.url);
170
+ const matched = readOnly || request.method === 'GET' || request.method === 'HEAD' ? undefined : matchOperation(routes, request.method, url.pathname, url.searchParams);
171
+ if (!matched) return inner(request);
172
+ // a release asset's body is the file itself, posted to the release's upload_url on uploads.github.com, not to the
173
+ // API host (docs.github.com/rest/releases/assets#upload-a-release-asset). Where the documentation stops and the
174
+ // twin decides: api.github.com answers the path as a route it does not serve, GitHub's 404; any other host is the
175
+ // twin's own address (a World's mount, a local port), where the two GitHub hosts cannot be told apart.
176
+ if (matched.operation.id === 'repos/upload-release-asset') {
177
+ if (url.hostname === 'api.github.com') return Response.json({ message: 'Not Found', documentation_url: 'https://docs.github.com/rest', status: '404' }, { status: 404 });
178
+ return uploadingAsset(request, inner);
127
179
  }
128
- return Response.json({ message: `method ${request.method} not supported` }, { status: 405 });
180
+ const text = await request.clone().text();
181
+ if (!text) return inner(request);
182
+ try { JSON.parse(text); } catch { return malformedJson(); }
183
+ return inner(request);
129
184
  };
185
+ // github.com's OAuth web flow (a hosted flow: the consent screen and the code exchange) sits beside the API
186
+ // the sign-in every page sends a visitor to who is not signed in (src/screens/session.tsx)
187
+ const session = githubSessionFlow({ ...(options.root !== undefined ? { root: options.root } : {}), now });
188
+ const oauth = githubOAuthFlow({ ...(options.root !== undefined ? { root: options.root } : {}), now });
189
+ // and a GitHub App's installation page (src/screens/app-installation.tsx)
190
+ const appInstall = githubAppInstallationFlow({ ...(options.root !== undefined ? { root: options.root } : {}), now });
191
+ // and the page that registers a GitHub App from a manifest (src/screens/app-manifest.tsx)
192
+ const appManifest = githubAppManifestFlow({ ...(options.root !== undefined ? { root: options.root } : {}), now });
193
+ // and GitHub Packages' npm registry, where `npm publish` puts a package (src/npm-registry.ts)
194
+ const npmRegistry = githubNpmRegistry({ ...(options.root !== undefined ? { root: options.root } : {}), now });
195
+ const wires = async (request: Request): Promise<Response> => {
196
+ const path = new URL(request.url).pathname.replace(/\/+$/, '');
197
+ const flow = readOnly ? undefined : (await session(request)) ?? (await oauth(request)) ?? (await appInstall(request)) ?? (await appManifest(request)) ?? (await npmRegistry(request));
198
+ if (flow) return flow;
199
+ return request.method === 'POST' && (path === '/graphql' || path === '/api/graphql') ? graphql(request) : rest(request);
200
+ };
201
+ const served = async (request: Request): Promise<Response> => (await doors(request)) ?? wires(request);
202
+ const vended = answerVendorErrors(served, (error) => Response.json({ message: error instanceof RefusedWriteError ? `Validation Failed: ${error.message}` : error.message, errors: [{ resource: 'Write', code: error instanceof RefusedWriteError ? 'refused' : 'failed', message: error.message }], documentation_url: 'https://docs.github.com/rest' }, { status: 422 }));
203
+ // Every answer carries GitHub's own headers for when and which request it was: Date on the World's clock (a response
204
+ // dated by the host's clock contradicts the World's time) and an x-github-request-id in GitHub's colon-separated hex
205
+ // form, derived from the instant, the request and a counter so the same request twice gets two ids.
206
+ let answered = 0;
207
+ // which part serves each spec operation (the derived dispatch's owners), for the pack's report
208
+ return Object.assign(async (request: Request): Promise<Response> => {
209
+ const token = (request.headers.get('authorization') ?? '').replace(/^(?:bearer|token)\s+/i, '').trim() || undefined;
210
+ const res = revokedToken(token, options.root) ? vendorError(manifest, { status: 401, message: 'Bad credentials' }) : await vended(request);
211
+ const at = now();
212
+ const headers = new Headers(res.headers);
213
+ headers.set('date', new Date(at).toUTCString());
214
+ if (!headers.has('x-github-request-id')) {
215
+ const h = createHash('sha256').update(`${at}:${request.method}:${request.url}:${answered++}`).digest('hex').toUpperCase();
216
+ headers.set('x-github-request-id', `${h.slice(0, 4)}:${h.slice(4, 9)}:${h.slice(9, 15)}:${h.slice(15, 21)}:${h.slice(21, 29)}`);
217
+ }
218
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
219
+ }, { owners: inner.owners });
130
220
  }
131
221
 
132
- export function createGithubTwinServer(options: GithubTwinOptions): { port: number; stop: () => void } {
133
- const server = Bun.serve({
222
+ /** The GraphQL wire's requests, as the handler interface names them. */
223
+ const GRAPHQL_OPERATION = { id: 'graphql', method: 'POST', path: '/graphql', class: 'action' };
224
+ /** A git push, as the handler interface names it. */
225
+ const GIT_OPERATION = { id: 'git-receive-pack', method: 'POST', path: '/{owner}/{repo}.git/git-receive-pack', class: 'action' };
226
+
227
+ // The REST surface, with the path parameters GitHub lets hold slashes (manifest.ts `spanning`).
228
+ const githubSurface = { ...surface, spanning };
229
+
230
+ export async function createGithubTwinServer(options: GithubTwinOptions): Promise<{ port: number; stop: () => void }> {
231
+ const server = await serveHttp({
134
232
  port: options.port ?? 0,
135
233
  idleTimeout: 60,
136
234
  fetch: createGithubTwinFetch(options),
@@ -1,7 +1,7 @@
1
1
  // GitHub SHARED helpers — pure shape functions both sides of the wire need: the twin's
2
2
  // mirror-state projection (server side, `github-mirror-state.ts`) and the mirror's React
3
3
  // client (`client/github-mirror.tsx`). Plain data in → plain data out; no state access,
4
- // no `@volter/twin`, no Bun/node imports — this module rides into the browser bundle
4
+ // no `@volter/world-core`, no Bun/node imports — this module rides into the browser bundle
5
5
  // unchanged.
6
6
 
7
7
  /** The ONE named store the mirror renders from: `GET /twin/store/${GITHUB_MIRROR_STORE}`