@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.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +232 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +104 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +170 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +225 -95
- package/src/github-git-http.ts +48 -84
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +50 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +193 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -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/
|
|
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.
|
|
95
|
-
// appears
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
await page.getByRole('
|
|
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, '
|
|
101
|
-
await
|
|
102
|
-
await atPath(page, '/issues');
|
|
106
|
+
await gone(page, 'alpha-pr-body-marker');
|
|
107
|
+
await atPath(page, '/acme/twin-journey/issues');
|
|
103
108
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
await page
|
|
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.
|
|
135
|
-
//
|
|
136
|
-
|
|
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('
|
|
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,
|
|
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
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
309
|
-
//
|
|
310
|
-
//
|
|
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')
|
|
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
|
|
318
|
-
|
|
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:
|
|
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)
|
|
337
|
-
//
|
|
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
|
-
|
|
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)
|
package/src/github-mirror-ui.ts
CHANGED
|
@@ -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="
|
|
18
|
-
<body><div id="root"></div><script type="module" src="
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
+
}
|
package/src/github-server.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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),
|
package/src/github-shared.ts
CHANGED
|
@@ -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/
|
|
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}`
|