@volter/twin-github 0.1.0 → 0.1.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.
@@ -1,18 +1,16 @@
1
- // GitHub UI mirror (scorecard R13) — a GitHub-style React app (Bun-bundled) over
2
- // the twin's github world. Serves `/api/state` in the shape the client renders
3
- // (pull requests grouped by repo the per-repo selector), built from the twin's
4
- // own evidence + local-write state. The client is the shared world UI-mirror design.
5
- //
6
- // Honesty: the github world is an EVIDENCE mirror — observed data carries PR
7
- // metadata + review/comment COUNTS but not their content; local simulator/fork
8
- // writes carry titles/bodies. So review/comment timeline entries are surfaced from
9
- // the observed counts and labelled as such; PR titles/bodies appear when present.
10
- import { discussionCategoriesFor, githubState } from './github-twin.ts';
11
- // eslint note: the mirror reads the full twin state below; the destructure picks the
12
- // collections each screen needs.
1
+ // GitHub UI mirror (scorecard R13) — a GitHub-style React app (Bun-bundled) over the
2
+ // twin's github world. A PURE FRONTEND (runtime contract R3): this module is the shell +
3
+ // its assets and one listener that mounts the pack's OWN fetch adapter beside them. It
4
+ // imports no handler and no twin-internal module, and holds no mirror-private data path:
5
+ // the client reads the world through the twin's store door (`GET /twin/store/mirror`, the
6
+ // ONE named projection `github-mirror-state.ts` builds server-side), and any write a
7
+ // console makes goes through the vendor's own REST API on the same origin — the wire a
8
+ // real GitHub client speaks, so the mirror renders a twin or a real account unchanged,
9
+ // pointed at any origin by configuration. The client is the shared world UI-mirror design.
10
+ import { createGithubTwinFetch } from './github-server.ts';
13
11
 
14
- const CLIENT_ENTRY = new URL('../client/github-mirror.tsx', import.meta.url).pathname;
15
- const CLIENT_CSS = new URL('../client/github-mirror.css', import.meta.url).pathname;
12
+ 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)
13
+ const CLIENT_CSS = () => new URL('../client/github-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
16
14
 
17
15
  const APP_SHELL = `<!doctype html>
18
16
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -22,7 +20,7 @@ const APP_SHELL = `<!doctype html>
22
20
  let clientBundle: Promise<string> | null = null;
23
21
  export function buildGithubMirrorClient(): Promise<string> {
24
22
  if (!clientBundle) {
25
- clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY], target: 'browser', minify: true })
23
+ clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY()], target: 'browser', minify: true })
26
24
  .then(async (result) => {
27
25
  if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'github UI mirror client build failed');
28
26
  return result.outputs[0]!.text();
@@ -32,362 +30,43 @@ export function buildGithubMirrorClient(): Promise<string> {
32
30
  return clientBundle;
33
31
  }
34
32
 
35
- const OBSERVED = 'content not mirrored by the evidence twin';
33
+ // GitHub's REST surface and the console's own routes share ONE path grammar (real GitHub
34
+ // splits them across its API host and its web host): `/pulls`, `/notifications`,
35
+ // `/projects/…` and `/{owner}/{repo}/…` are both an API path and a console deep link. On
36
+ // this one host a request is the console's when it is a DOCUMENT NAVIGATION — the browser
37
+ // asks for `text/html` — and the twin's otherwise (an API client and the console's own
38
+ // store read ask for JSON), the ordinary history-fallback rule every SPA host applies.
39
+ function wantsDocument(request: Request): boolean {
40
+ return request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html');
41
+ }
36
42
 
37
- /** Build the UI-mirror `/api/state` payload from the twin's github world. */
38
- export function githubMirrorState(root?: string): Record<string, unknown> {
39
- 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);
40
- // CI rollup per PR head_sha from commit statuses + check runs (a LOCAL CI construct;
41
- // observed-only PRs carry no head_sha CI, so this stays null for them — honesty).
42
- // Precedence mirrors GitHub's rollup: any failure/error → 'failure'; any pending/queued/
43
- // in_progress → 'pending'; all success/neutral → 'success'.
44
- const ciStatusFor = (repository: string, headSha?: string): string | undefined => {
45
- if (!headSha) return undefined;
46
- const relStatuses = statuses.filter((s) => s.repository === repository && s.sha === headSha);
47
- const relChecks = checkRuns.filter((c) => c.repository === repository && c.head_sha === headSha);
48
- if (!relStatuses.length && !relChecks.length) return undefined;
49
- const states: string[] = [
50
- ...relStatuses.map((s) => s.state),
51
- ...relChecks.map((c) => (c.status === 'completed' ? (c.conclusion ?? 'neutral') : 'pending')),
52
- ];
53
- if (states.some((s) => s === 'failure' || s === 'error')) return 'failure';
54
- if (states.some((s) => s === 'pending')) return 'pending';
55
- return 'success';
56
- };
57
- const pullRequests = prs.map((pr) => {
58
- // A PR's merge state: an explicit merged write wins; otherwise the recorded state,
59
- // defaulting to open. We never fabricate a title for observed-only PRs.
60
- const state = pr.merged ? 'merged' : (pr.state ?? 'open');
61
- // milestone: a local PATCH stores the NUMBER; surface its title from state.milestones.
62
- const ms = pr.milestone !== undefined ? milestones.find((m) => m.repository === pr.repository && m.number === pr.milestone) : undefined;
63
- return {
64
- repo: pr.repository,
65
- number: pr.number,
66
- title: pr.title,
67
- body: pr.body,
68
- state,
69
- merged: pr.merged ?? false,
70
- mergedAt: pr.merged_at,
71
- mergeCommit: pr.merge_commit_sha,
72
- url: `https://github.com/${pr.repository}/pull/${pr.number}`,
73
- baseBranch: pr.base_ref,
74
- headSha: pr.head_sha,
75
- changedFiles: pr.changed_files,
76
- commitsCount: pr.commits,
77
- commentsCount: pr.comment_count,
78
- reviewCount: pr.review_count,
79
- reviewCommentsCount: foldedComments.filter((c) => c.repository === pr.repository && c.number === pr.number && c.kind === 'review').length,
80
- labels: (pr.labels ?? []).map((name) => ({ name })),
81
- assignees: pr.assignees ?? [],
82
- // requested reviewers (LOCAL writes): bare logins distinct from submitted reviews.
83
- requestedReviewers: pr.requested_reviewers ?? [],
84
- // milestone title (LOCAL write); undefined for observed-only PRs.
85
- milestone: ms?.title,
86
- // additions/deletions line counts: roll up from a carried file list on LOCAL writes;
87
- // undefined for observed-only PRs (the evidence fold never carries line counts).
88
- additions: pr.additions,
89
- deletions: pr.deletions,
90
- // CI rollup over head_sha statuses + check-runs; undefined when no CI evidence.
91
- ciStatus: ciStatusFor(pr.repository, pr.head_sha),
92
- };
93
- });
94
- // Per-commit detail + per-file diffs ride on LOCAL PR writes only (observed PR evidence
95
- // carries COUNTS, never content) — so these lists are empty for observed-only PRs, keeping
96
- // the mirror honest. Each entry is tagged with repo+number so the client can scope it to
97
- // the selected PR, mirroring the GitHub conversation's Commits + Files-changed sections.
98
- const commits = prs.flatMap((pr) =>
99
- (pr.commits_list ?? []).map((c) => ({
100
- repo: pr.repository,
101
- number: pr.number,
102
- sha: c.sha,
103
- message: c.message,
104
- author: c.author_name,
105
- date: c.author_date,
106
- url: `https://github.com/${pr.repository}/commit/${c.sha}`,
107
- })),
108
- );
109
- const files = prs.flatMap((pr) =>
110
- (pr.files ?? []).map((f) => ({
111
- repo: pr.repository,
112
- number: pr.number,
113
- filename: f.filename,
114
- status: f.status,
115
- additions: f.additions,
116
- deletions: f.deletions,
117
- changes: f.changes,
118
- // patch: the unified-diff hunk text (LOCAL writes only) — the Files-changed diff view
119
- // renders these as +/- lines; observed PRs carry no per-file diff so this is undefined.
120
- patch: f.patch,
121
- })),
122
- );
123
- // First-class issues (a LOCAL construct) grouped per repo via the `repo` field, like PRs.
124
- const issuesOut = issues.map((iss) => {
125
- const ms = iss.milestone !== undefined ? milestones.find((m) => m.repository === iss.repository && m.number === iss.milestone) : undefined;
126
- return {
127
- repo: iss.repository,
128
- number: iss.number,
129
- title: iss.title,
130
- body: iss.body,
131
- state: iss.state ?? 'open',
132
- url: `https://github.com/${iss.repository}/issues/${iss.number}`,
133
- createdAt: iss.created_at,
134
- updatedAt: iss.updated_at,
135
- labels: (iss.labels ?? []).map((name) => ({ name })),
136
- assignees: iss.assignees ?? [],
137
- milestone: ms?.title,
138
- stateReason: iss.state_reason ?? undefined,
139
- locked: iss.locked ?? false,
140
- // linked PRs/issues (a LOCAL relation): surface as typed references the client links.
141
- linked: (iss.linked ?? []).map((l) => ({
142
- type: l.type,
143
- number: l.number,
144
- url: l.type === 'pull_request'
145
- ? `https://github.com/${iss.repository}/pull/${l.number}`
146
- : `https://github.com/${iss.repository}/issues/${l.number}`,
147
- })),
148
- // issue comments (kind:'issue' on the shared comment space) — the detail page renders
149
- // these in its timeline; observed comments carry no body (labelled, never fabricated).
150
- comments: foldedComments
151
- .filter((c) => c.kind === 'issue' && c.repository === iss.repository && c.number === iss.number)
152
- .map((c) => ({ id: c.id, author: c.body === undefined ? '(observed)' : 'simulator', body: c.body ?? `Comment observed — ${OBSERVED}.`, createdAt: c.created_at })),
153
- // a derived timeline of state-change events (labeled/assigned/milestoned/locked/closed)
154
- // mirroring the issue's current modeled state — the detail page renders it as a feed.
155
- timeline: [
156
- ...(iss.labels ?? []).map((name) => ({ event: 'labeled', detail: name })),
157
- ...(iss.assignees ?? []).map((login) => ({ event: 'assigned', detail: `@${login}` })),
158
- ...(iss.locked ? [{ event: 'locked', detail: iss.active_lock_reason ?? '' }] : []),
159
- ...(iss.state === 'closed' ? [{ event: 'closed', detail: iss.state_reason ?? '' }] : []),
160
- ],
161
- };
162
- });
163
- // Reviews carry state/body for LOCAL writes; observed reviews carry neither, so we
164
- // label them honestly as observed-only rather than fabricating review content.
165
- const reviews = foldedReviews.map((r) => {
166
- const observed = r.state === undefined && r.body === undefined;
167
- return {
168
- repo: r.repository,
169
- number: r.number,
170
- reviewer: observed ? '(observed)' : 'simulator',
171
- state: r.state ?? 'reviewed',
172
- body: r.body ?? (observed ? `Review observed — ${OBSERVED}.` : ''),
173
- submittedAt: r.submitted_at,
174
- observed,
175
- };
176
- });
177
- // Issue comments and review (diff-thread) comments are split by kind so the client can
178
- // render them in their right place. Observed comments carry no body — label them.
179
- const toComment = (c: (typeof foldedComments)[number]) => {
180
- const observed = c.body === undefined;
181
- return {
182
- repo: c.repository,
183
- number: c.number,
184
- author: observed ? '(observed)' : 'simulator',
185
- body: c.body ?? (observed ? `Comment observed — ${OBSERVED}.` : ''),
186
- createdAt: c.created_at,
187
- observed,
188
- };
189
- };
190
- const comments = foldedComments.filter((c) => c.kind === 'issue').map(toComment);
191
- const reviewComments = foldedComments.filter((c) => c.kind === 'review').map(toComment);
192
- // Actions (workflows + runs + jobs) — a LOCAL construct (running real CI is the only Actions
193
- // non-goal; the run/job OBJECTS are modeled). Surfaced per-repo like PRs/issues. Runs carry
194
- // status/conclusion the client renders as badges; jobs are scoped to a run by run_id.
195
- const workflows = foldedWorkflows.map((w) => ({
196
- repo: w.repository,
197
- id: w.id,
198
- name: w.name,
199
- path: w.path,
200
- state: w.state ?? 'active',
201
- }));
202
- const runs = workflowRuns.map((r) => {
203
- const wf = foldedWorkflows.find((w) => w.repository === r.repository && w.id === r.workflow_id);
204
- return {
205
- repo: r.repository,
206
- id: r.id,
207
- workflowId: r.workflow_id,
208
- workflowName: wf?.name ?? r.name,
209
- name: r.name,
210
- runNumber: r.run_number,
211
- event: r.event,
212
- status: r.status,
213
- conclusion: r.conclusion,
214
- headSha: r.head_sha,
215
- headBranch: r.head_branch,
216
- createdAt: r.created_at,
217
- updatedAt: r.updated_at,
218
- url: `https://github.com/${r.repository}/actions/runs/${r.id}`,
219
- jobCount: jobs.filter((j) => j.repository === r.repository && j.run_id === r.id).length,
220
- };
221
- });
222
- const runJobs = jobs.map((j) => ({
223
- repo: j.repository,
224
- id: j.id,
225
- runId: j.run_id,
226
- name: j.name,
227
- status: j.status,
228
- conclusion: j.conclusion,
229
- steps: j.steps.map((s) => ({ name: s.name, status: s.status, conclusion: s.conclusion, number: s.number })),
230
- }));
231
- // Releases (a LOCAL construct — there is no observed release evidence; release ASSETS are
232
- // metadata only, the binary bytes are the declared Non-goal). Surfaced per-repo like PRs.
233
- // Each release carries its tag/name/draft/prerelease flags + its asset catalog; tags are
234
- // the published-release tag list (derived, no git blob bytes).
235
- const releases = foldedReleases.map((r) => ({
236
- repo: r.repository,
237
- id: r.id,
238
- tagName: r.tag_name,
239
- name: r.name,
240
- body: r.body,
241
- draft: r.draft ?? false,
242
- prerelease: r.prerelease ?? false,
243
- createdAt: r.created_at,
244
- publishedAt: r.published_at,
245
- targetCommitish: r.target_commitish,
246
- url: `https://github.com/${r.repository}/releases/tag/${encodeURIComponent(r.tag_name)}`,
247
- assets: releaseAssets
248
- .filter((a) => a.repository === r.repository && a.release_id === r.id)
249
- .map((a) => ({ id: a.id, name: a.name, label: a.label, contentType: a.content_type, size: a.size ?? 0, downloadCount: a.download_count ?? 0 })),
250
- }));
251
- const tagList = tags.map((t) => ({ repo: t.repository, name: t.name, commitSha: t.commit_sha }));
252
- // Discussions (a LOCAL construct — no observed discussion evidence; modeled over REST,
253
- // honestly noted as a deviation since real GitHub Discussions are GraphQL). Each discussion
254
- // carries its category (resolved from the per-repo category list), state/lock, the chosen
255
- // answer pointer, and its comments/replies (with the is_answer flag). Surfaced per-repo.
256
- const discussionRepos = new Set(foldedDiscussions.map((d) => d.repository));
257
- const categoryByRepoSlug = new Map<string, { name: string; emoji?: string; isAnswerable: boolean }>();
258
- 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 });
259
- const discussions = foldedDiscussions.map((d) => {
260
- const cat = d.category_slug ? categoryByRepoSlug.get(`${d.repository}#${d.category_slug}`) : undefined;
261
- const threadComments = foldedDiscussionComments.filter((c) => c.repository === d.repository && c.discussion_number === d.number);
262
- return {
263
- repo: d.repository,
264
- number: d.number,
265
- title: d.title,
266
- body: d.body,
267
- state: d.locked ? 'locked' : (d.state ?? 'open'),
268
- locked: d.locked ?? false,
269
- categorySlug: d.category_slug,
270
- categoryName: cat?.name,
271
- categoryEmoji: cat?.emoji,
272
- isAnswerable: cat?.isAnswerable ?? false,
273
- answerCommentId: d.answer_comment_id ?? null,
274
- isAnswered: d.answer_comment_id != null,
275
- commentsCount: threadComments.length,
276
- createdAt: d.created_at,
277
- updatedAt: d.updated_at,
278
- url: `https://github.com/${d.repository}/discussions/${d.number}`,
279
- comments: threadComments.map((c) => ({
280
- id: c.id,
281
- parentId: c.parent_id ?? null,
282
- body: c.body,
283
- isAnswer: c.is_answer ?? false,
284
- createdAt: c.created_at,
285
- })),
286
- };
287
- });
288
- // Repo contents (a LOCAL construct) — the Code/file browser tree. Each entry carries its
289
- // path + size; the browser renders the directory tree per repo (no blob bytes rendered).
290
- const contents = foldedContents.map((c) => ({ repo: c.repository, path: c.path, size: c.size, sha: c.sha, branch: c.branch ?? 'main' }));
291
- // Notifications (a LOCAL construct) — the inbox screen. unread flag + reason + subject.
292
- const notifications = foldedNotifications.map((n) => ({ id: n.id, repo: n.repository, title: n.subject_title, type: n.subject_type, reason: n.reason, unread: n.unread, updatedAt: n.updated_at }));
293
- // Deployments (a LOCAL construct) — the repo's Deployments/Environments screen. Each
294
- // deployment carries its environment + ref + the LATEST status (the deployment's state),
295
- // mirroring GitHub's Environments view (most-recent deployment per environment, with state).
296
- const deployments = foldedDeployments.map((d) => {
297
- const statusesForDep = foldedDeploymentStatuses.filter((s) => s.deployment_id === d.id).sort((a, b) => b.id - a.id);
298
- const latest = statusesForDep[0];
299
- return {
300
- repo: d.repository,
301
- id: d.id,
302
- ref: d.ref,
303
- sha: d.sha,
304
- environment: d.environment,
305
- task: d.task,
306
- description: d.description ?? undefined,
307
- production: d.production_environment ?? false,
308
- transient: d.transient_environment ?? false,
309
- createdAt: d.created_at,
310
- state: latest?.state ?? 'pending',
311
- environmentUrl: latest?.environment_url,
312
- url: `https://github.com/${d.repository}/deployments`,
313
- statuses: statusesForDep.map((s) => ({ id: s.id, state: s.state, description: s.description ?? undefined, createdAt: s.created_at })),
314
- };
315
- });
316
- // Environments (a LOCAL construct) — protection-rule summaries per environment.
317
- const environments = foldedEnvironments.map((e) => ({
318
- repo: e.repository,
319
- name: e.name,
320
- waitTimer: e.wait_timer,
321
- reviewers: e.reviewers ?? [],
322
- protected: Boolean(e.wait_timer) || Boolean(e.reviewers?.length),
323
- }));
324
- // ── Projects v2 board (a LOCAL construct) — the Project board screen. Each project carries
325
- // its views (board/table) + its Status field's options as COLUMNS, and its items grouped
326
- // into those columns by their Status field value (the board's swim-lanes). Data-coupled:
327
- // seeding a project + a Status field + items moves the column counts the board renders.
328
- const projectBoards = foldedProjects.map((p) => {
329
- const fields = foldedProjectFields.filter((f) => f.project_id === p.id);
330
- const statusField = fields.find((f) => f.data_type === 'single_select') ?? fields.find((f) => /status/i.test(f.name));
331
- const columnNames = statusField?.options?.map((o) => o.name) ?? [];
332
- const items = foldedProjectItems.filter((i) => i.project_id === p.id);
333
- const columns = columnNames.map((name) => ({
334
- name,
335
- cards: items
336
- .filter((i) => String((i.field_values as Record<string, unknown>)[statusField!.name] ?? '') === name)
337
- .map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })),
338
- }));
339
- // items with no (or an unrecognized) status land in a "No Status" lane (GitHub behavior).
340
- const noStatus = items.filter((i) => !columnNames.includes(String((i.field_values as Record<string, unknown>)[statusField?.name ?? ''] ?? '')));
341
- return {
342
- id: p.id, number: p.number, title: p.title, owner: p.owner, closed: p.closed ?? false,
343
- views: foldedProjectViews.filter((v) => v.project_id === p.id).map((v) => ({ number: v.number, name: v.name, layout: v.layout })),
344
- fields: fields.map((f) => ({ name: f.name, dataType: f.data_type })),
345
- itemCount: items.length,
346
- columns,
347
- noStatus: { name: 'No Status', cards: noStatus.map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })) },
348
- url: `https://github.com/${p.owner_type === 'organization' ? 'orgs/' : 'users/'}${p.owner}/projects/${p.number}`,
349
- };
350
- });
351
- // ── Insights / Pulse / Contributors (DERIVED per repo from the modeled PRs/issues/commits) —
352
- // the Pulse screen's counts (merged/opened PRs, opened/closed issues) + a contributors
353
- // leaderboard from the per-commit authors the twin tracks. Data-coupled: each new PR/issue
354
- // moves a Pulse count; each new commit author moves a contributor's commit total.
355
- const repoNames = new Set<string>([...prs.map((p) => p.repository), ...issues.map((i) => i.repository), ...foldedRepos.map((r) => r.full_name)]);
356
- const insights = [...repoNames].sort().map((repo) => {
357
- const repoPrs = prs.filter((p) => p.repository === repo);
358
- const repoIssues = issues.filter((i) => i.repository === repo);
359
- const contribCounts = new Map<string, number>();
360
- 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); }
361
- return {
362
- repo,
363
- pulse: {
364
- mergedPrs: repoPrs.filter((p) => p.merged).length,
365
- openPrs: repoPrs.filter((p) => !p.merged && (p.state ?? 'open') === 'open').length,
366
- openedIssues: repoIssues.filter((i) => (i.state ?? 'open') === 'open').length,
367
- closedIssues: repoIssues.filter((i) => i.state === 'closed').length,
368
- },
369
- contributors: [...contribCounts.entries()].map(([login, commits]) => ({ login, commits })).sort((a, b) => b.commits - a.commits),
370
- };
371
- });
372
- // ── Settings (DERIVED per repo from the modeled repo config + collaborators/webhooks/branches)
373
- // the repo Settings screens (General / Collaborators / Webhooks / Branches). Data-coupled:
374
- // adding a collaborator or webhook moves the counts/rows the settings screen renders.
375
- const settings = foldedRepos.map((r) => ({
376
- repo: r.full_name,
377
- general: { defaultBranch: r.default_branch, private: r.private ?? false, hasIssues: r.has_issues ?? true, hasProjects: r.has_projects ?? true, hasWiki: r.has_wiki ?? true, hasDiscussions: r.has_discussions ?? false, description: r.description ?? null },
378
- collaborators: foldedCollaborators.filter((c) => c.repository === r.full_name).map((c) => ({ login: c.login, permission: c.permission })),
379
- webhooks: foldedWebhooks.filter((h) => h.repository === r.full_name).map((h) => ({ id: h.id, url: h.url, active: h.active, events: h.events })),
380
- branches: foldedBranches.filter((b) => b.repository === r.full_name).map((b) => ({ name: b.name, protected: b.protected ?? false })),
381
- }));
382
- return {
383
- github: { pullRequests, issues: issuesOut, reviews, commits, comments, reviewComments, files, workflows, runs, jobs: runJobs, releases, tags: tagList, discussions, contents, notifications, deployments, environments, projectBoards, insights, settings },
384
- fetchedAt: new Date().toISOString(),
385
- };
43
+ // The git smart-HTTP grammar (`/{owner}/{repo}/info/refs`, `…/git-upload-pack`,
44
+ // `…/git-receive-pack`): the git plane stays on the twin's API host — the console host
45
+ // carries no clone/push lane, so a git client here gets the plain refusal, never the shell.
46
+ function isGitSmartHttpPath(pathname: string): boolean {
47
+ return /^\/[^/]+\/[^/]+\/(?:info\/refs|git-upload-pack|git-receive-pack)$/.test(pathname);
386
48
  }
387
49
 
388
- /** Serve the GitHub UI mirror (React app) + its `/api/state` backing the client. */
50
+ /**
51
+ * Serve the GitHub UI mirror (React app) with the twin's own fetch adapter mounted beside it.
52
+ *
53
+ * Every data path on this host IS the twin: reads fold from stored state, writes go through
54
+ * the twin's real write path (webhooks emitted like every write), the doors answer — the
55
+ * exact closure `createGithubTwinServer` serves, so the mirror host and the API host cannot
56
+ * drift. There is no mirror-private route: `/api/state` (the old fold) answers with the
57
+ * vendor's own `Not Found` envelope, because GitHub has no such endpoint.
58
+ */
389
59
  export function createGithubMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
60
+ const twin = createGithubTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}) });
390
61
  const server = Bun.serve({
62
+ // LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
63
+ // wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
64
+ // on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
65
+ // specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
66
+ // verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
67
+ // mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
68
+ // on loopback, so the verify's fetches deterministically reach THIS server.
69
+ hostname: '127.0.0.1',
391
70
  port: options.port ?? 0,
392
71
  idleTimeout: 60,
393
72
  async fetch(request) {
@@ -397,15 +76,17 @@ export function createGithubMirrorServer(options: { root?: string; port?: number
397
76
  catch (error) { return new Response(String(error), { status: 500 }); }
398
77
  }
399
78
  if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
400
- return new Response(Bun.file(CLIENT_CSS), { headers: { 'content-type': 'text/css; charset=utf-8' } });
401
- }
402
- if (request.method === 'GET' && url.pathname === '/api/state') {
403
- return Response.json(githubMirrorState(options.root));
79
+ return new Response(Bun.file(CLIENT_CSS()), { headers: { 'content-type': 'text/css; charset=utf-8' } });
404
80
  }
405
- if (request.method === 'GET') {
81
+ // The uniform doors (`/twin`, `/twin/store/<name>`) are the twin's, whoever asks.
82
+ if (url.pathname === '/twin' || url.pathname.startsWith('/twin/')) return twin(request);
83
+ if (isGitSmartHttpPath(url.pathname)) return new Response('not found', { status: 404 });
84
+ // The console: its root and every document navigation (deep links included).
85
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '' || wantsDocument(request))) {
406
86
  return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
407
87
  }
408
- return new Response('method not allowed', { status: 405 });
88
+ // Everything else is the vendor's wire: the mounted adapter answers as the API host would.
89
+ return twin(request);
409
90
  },
410
91
  });
411
92
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
@@ -2,34 +2,138 @@
2
2
  // `@octokit/rest` client (pointed at this baseUrl) works unmodified (R1). Reads
3
3
  // fold observed evidence + action overlay; writes (simulator/fork) become local
4
4
  // actions and fire GitHub webhooks (R17). Mirror mode rejects writes (R4).
5
+ // The git SMART-HTTP protocol (clone/fetch/push by an unmodified `git` CLI) is served
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.
5
9
  import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
10
+ import { worldNow, statefulTwinManifest} from '@volter/twin';
6
11
  import { emitGithubEvent } from './github-events.ts';
12
+ import { handleGithubGitSmartHttp, parseGitSmartHttpPath } from './github-git-http.ts';
13
+ import { githubMirrorState } from './github-mirror-state.ts';
14
+ import { GITHUB_MIRROR_STORE } from './github-shared.ts';
7
15
 
8
- export function createGithubTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
16
+ // THE STORE DOOR (runtime contract R5c; mirror purity R3): named, deterministic projections
17
+ // over stored state, served at `GET /twin/store/<name>` and listed in the manifest as
18
+ // `stores` — the kernel adapter's door (`createTwinFetchFromHandler`), carried here by the
19
+ // pack's own fetch because github's wire exceeds the handler shape (the git smart-HTTP
20
+ // plane's binary pkt-line streams). GitHub's ONE store is `mirror`: everything the mirror
21
+ // console renders, projected from the folded state (`github-mirror-state.ts`), so the
22
+ // mirror reads twin state ONLY over this door — never by importing the handler. The
23
+ // door's shapes — the manifest's `stores` + `doors.store`, the 404 body for an unknown
24
+ // name — are the kernel door's, byte for byte.
25
+ const STORE_DOOR = '/twin/store/';
26
+
27
+ /** Options shared by the fetch handler and the Bun.serve wrapper around it. `port` is a BIND
28
+ * concern the fetch ignores; it stays in one shape so a caller configures the twin once
29
+ * whichever way it is mounted. */
30
+ export type GithubTwinOptions = { root?: string; port?: number; readOnly?: boolean };
31
+
32
+ /**
33
+ * The whole GitHub serve path as a plain `(Request) => Response` — the `/twin` manifest, the
34
+ * git smart-HTTP bridge, REST reads and the write path with its webhook emission. NOTHING
35
+ * about it is port-bound, and `createGithubTwinServer` is one line of `Bun.serve` around it,
36
+ * so the standalone (R1) and any in-process lane execute the SAME bytes of serving code.
37
+ *
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.
49
+ */
50
+ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Request) => Promise<Response> {
9
51
  const readOnly = options.readOnly ?? false;
52
+ const stores: Record<string, () => unknown> = { [GITHUB_MIRROR_STORE]: () => githubMirrorState(options.root) };
53
+ const storeNames = Object.keys(stores).sort();
54
+ return async function githubTwinFetch(request: Request): Promise<Response> {
55
+ const url = new URL(request.url);
56
+ const cleanPath = url.pathname.replace(/\/+$/, '') || '/';
57
+ // GET /twin — the discovery manifest (education inside the twin). It EDUCATES the
58
+ // kernel door's way: the store names + the door's path ride on the manifest.
59
+ if (request.method === 'GET' && cleanPath === '/twin') {
60
+ const manifest = statefulTwinManifest({ vendor: 'github', twinOf: 'the GitHub REST pull-request API', stores: 'repositories, pull requests, reviews and comments' });
61
+ const doors = manifest.doors !== null && typeof manifest.doors === 'object' ? (manifest.doors as Record<string, unknown>) : {};
62
+ return Response.json({ ...manifest, stores: storeNames, doors: { ...doors, store: 'GET /twin/store/<name>' } });
63
+ }
64
+ // GET /twin/store/<name> — the store door (see STORE_DOOR above).
65
+ if (request.method === 'GET' && cleanPath.startsWith(STORE_DOOR)) {
66
+ const name = cleanPath.slice(STORE_DOOR.length);
67
+ const store = stores[name];
68
+ if (store === undefined) return Response.json({ error: 'unknown store', store: name, stores: storeNames }, { status: 404 });
69
+ return Response.json(store());
70
+ }
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.
74
+ 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();
81
+ const git = await handleGithubGitSmartHttp({
82
+ method: request.method,
83
+ pathname: url.pathname,
84
+ query: url.searchParams,
85
+ ...(request.method === 'POST' ? { body: new Uint8Array(await request.arrayBuffer()) } : {}),
86
+ ...(options.root !== undefined ? { root: options.root } : {}),
87
+ readOnly,
88
+ contentEncoding: request.headers.get('content-encoding') ?? '',
89
+ gitProtocol: request.headers.get('git-protocol') ?? '',
90
+ occurredAt,
91
+ });
92
+ 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 */ } }
96
+ return new Response(git.body as BodyInit, { status: git.status, headers: { 'content-type': git.contentType, 'cache-control': 'no-cache' } });
97
+ }
98
+ }
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 });
105
+ }
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 });
127
+ }
128
+ return Response.json({ message: `method ${request.method} not supported` }, { status: 405 });
129
+ };
130
+ }
131
+
132
+ export function createGithubTwinServer(options: GithubTwinOptions): { port: number; stop: () => void } {
10
133
  const server = Bun.serve({
11
134
  port: options.port ?? 0,
12
135
  idleTimeout: 60,
13
- async fetch(request) {
14
- const url = new URL(request.url);
15
- const path = url.pathname + url.search;
16
- if (request.method === 'GET') {
17
- if (url.pathname === '/' || url.pathname === '') return Response.json({ service: 'github', readOnly, evidence_only: true });
18
- const { status, body } = handleGithubRequest({ method: 'GET', path, ...(options.root !== undefined ? { root: options.root } : {}) });
19
- // 204 (e.g. "check if PR merged") carries no body.
20
- if (status === 204 || body === undefined) return new Response(null, { status });
21
- return Response.json(body as Record<string, unknown>, { status });
22
- }
23
- if (request.method === 'POST' || request.method === 'PATCH' || request.method === 'PUT' || request.method === 'DELETE') {
24
- const text = await request.text();
25
- const { response, webhook } = await applyGithubWrite({ method: request.method, path, body: text, ...(options.root !== undefined ? { root: options.root } : {}), readOnly });
26
- if (webhook) await emitGithubEvent(webhook, { occurredAt: new Date().toISOString() });
27
- // 204 (e.g. workflow_dispatch) carries no body.
28
- if (response.status === 204 || response.body === undefined) return new Response(null, { status: response.status });
29
- return Response.json(response.body as Record<string, unknown>, { status: response.status });
30
- }
31
- return Response.json({ message: `method ${request.method} not supported` }, { status: 405 });
32
- },
136
+ fetch: createGithubTwinFetch(options),
33
137
  });
34
138
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
35
139
  }