@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.
- package/README.md +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
package/src/github-mirror-ui.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
// GitHub UI mirror (scorecard R13) — a GitHub-style React app (Bun-bundled) over
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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) };
|
package/src/github-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|