@volter/twin-github 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
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.
13
+
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;
16
+
17
+ const APP_SHELL = `<!doctype html>
18
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
19
+ <title>GitHub UI mirror (twin)</title><link rel="stylesheet" href="/assets/styles.css"></head>
20
+ <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
21
+
22
+ let clientBundle: Promise<string> | null = null;
23
+ export function buildGithubMirrorClient(): Promise<string> {
24
+ if (!clientBundle) {
25
+ clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY], target: 'browser', minify: true })
26
+ .then(async (result) => {
27
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'github UI mirror client build failed');
28
+ return result.outputs[0]!.text();
29
+ })
30
+ .catch((error) => { clientBundle = null; throw error; });
31
+ }
32
+ return clientBundle;
33
+ }
34
+
35
+ const OBSERVED = 'content not mirrored by the evidence twin';
36
+
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
+ };
386
+ }
387
+
388
+ /** Serve the GitHub UI mirror (React app) + its `/api/state` backing the client. */
389
+ export function createGithubMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
390
+ const server = Bun.serve({
391
+ port: options.port ?? 0,
392
+ idleTimeout: 60,
393
+ async fetch(request) {
394
+ const url = new URL(request.url);
395
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
396
+ try { return new Response(await buildGithubMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
397
+ catch (error) { return new Response(String(error), { status: 500 }); }
398
+ }
399
+ 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));
404
+ }
405
+ if (request.method === 'GET') {
406
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
407
+ }
408
+ return new Response('method not allowed', { status: 405 });
409
+ },
410
+ });
411
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
412
+ }
413
+
414
+ export function githubMirrorHtml(): string {
415
+ return APP_SHELL;
416
+ }
@@ -0,0 +1,35 @@
1
+ // GitHub twin HTTP server — serve the real GitHub REST API over HTTP so the real
2
+ // `@octokit/rest` client (pointed at this baseUrl) works unmodified (R1). Reads
3
+ // fold observed evidence + action overlay; writes (simulator/fork) become local
4
+ // actions and fire GitHub webhooks (R17). Mirror mode rejects writes (R4).
5
+ import { applyGithubWrite, handleGithubRequest } from './github-twin.ts';
6
+ import { emitGithubEvent } from './github-events.ts';
7
+
8
+ export function createGithubTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
9
+ const readOnly = options.readOnly ?? false;
10
+ const server = Bun.serve({
11
+ port: options.port ?? 0,
12
+ 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
+ },
33
+ });
34
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
35
+ }