@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-events.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { createHmac } from 'node:crypto';
|
|
11
11
|
import type { GithubWriteEvent } from './github-twin.ts';
|
|
12
12
|
|
|
13
|
-
export type GithubWebhook = { event: string; action: string; number: number; repository: { full_name: string }; pull_request?: Record<string, unknown>; issue?: { number: number }; comment?: { body: string }; review?: { body: string } };
|
|
13
|
+
export type GithubWebhook = { event: string; action: string; number: number; repository: { full_name: string }; pull_request?: Record<string, unknown>; issue?: { number: number }; comment?: { body: string }; review?: { body: string }; ref?: string; before?: string; after?: string; created?: boolean; deleted?: boolean };
|
|
14
14
|
// Delivery receives the resolved headers too (X-GitHub-Event, X-GitHub-Delivery,
|
|
15
15
|
// X-Hub-Signature-256) so a faithful consumer can verify the signature.
|
|
16
16
|
export type GithubWebhookDelivery = (url: string, headerEvent: string, payload: GithubWebhook, headers: Record<string, string>) => Promise<void> | void;
|
|
@@ -53,6 +53,16 @@ function buildPayload(write: GithubWriteEvent): GithubWebhook {
|
|
|
53
53
|
...(write.event === 'issues' ? { issue: { number: write.number } } : {}),
|
|
54
54
|
...(write.event === 'pull_request_review' ? { review: { body: write.body ?? '' }, pull_request: { number: write.number } } : {}),
|
|
55
55
|
...(write.event === 'issue_comment' ? { comment: { body: write.body ?? '' } } : {}),
|
|
56
|
+
// push events (git-receive-pack / contents writes): GitHub's ref/before/after triple
|
|
57
|
+
// plus the created/deleted flags consumers branch on (derived exactly as GitHub derives
|
|
58
|
+
// them: created ⇔ before is the zero sha, deleted ⇔ after is). `forced` would need
|
|
59
|
+
// ancestry knowledge the event does not carry — omitted rather than fabricated.
|
|
60
|
+
...(write.event === 'push' && write.ref !== undefined
|
|
61
|
+
? {
|
|
62
|
+
ref: write.ref, before: write.before ?? '', after: write.after ?? '',
|
|
63
|
+
created: write.before === '0'.repeat(40), deleted: write.after === '0'.repeat(40),
|
|
64
|
+
}
|
|
65
|
+
: {}),
|
|
56
66
|
};
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// GitHub twin git smart-HTTP — serves the REAL git wire protocol over the pack's REAL bare
|
|
2
|
+
// repos (github-git-plane.ts), so an UNMODIFIED `git` CLI can clone from and push to the twin:
|
|
3
|
+
//
|
|
4
|
+
// GET /:owner/:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
|
5
|
+
// POST /:owner/:repo[.git]/git-upload-pack
|
|
6
|
+
// POST /:owner/:repo[.git]/git-receive-pack
|
|
7
|
+
//
|
|
8
|
+
// Implementation choice — `git upload-pack|receive-pack --stateless-rpc` SPAWNED DIRECTLY,
|
|
9
|
+
// not `git http-backend` (CGI). Both are first-party; direct spawn is the simpler one to
|
|
10
|
+
// GATE: http-backend adds a CGI translation layer (GIT_PROJECT_ROOT/PATH_INFO/QUERY_STRING/
|
|
11
|
+
// REMOTE_USER env plumbing + CGI response-header parsing) whose plumbing would live in the
|
|
12
|
+
// twin and need its own tests, while contributing no protocol behavior of its own —
|
|
13
|
+
// http-backend itself execs exactly these two commands with exactly these flags
|
|
14
|
+
// (gitprotocol-http(5), "Smart Service git-upload-pack"). Direct spawn leaves ONE seam
|
|
15
|
+
// (this module) a mutation saboteur can kill and the fidelity oracle (a real `git` client)
|
|
16
|
+
// can prove end-to-end. The only protocol framing the twin adds itself is the documented
|
|
17
|
+
// service-advertisement prefix — one pkt-line (`# service=$servicename` LF) plus a
|
|
18
|
+
// flush-pkt, per gitprotocol-http(5) "smart_reply" — everything else on the wire is
|
|
19
|
+
// produced by real git.
|
|
20
|
+
//
|
|
21
|
+
// Reconciliation (the heart of the upgrade): after a receive-pack POST, ref changes in the
|
|
22
|
+
// bare repo are FOLDED into the kernel event log (git_ref.* + branch.* actions — the same
|
|
23
|
+
// operations the Git Data REST writes append), so a pushed branch is immediately visible to
|
|
24
|
+
// the REST plane: GET git/refs, GET branches, PR head/base resolution. Objects are NOT
|
|
25
|
+
// copied into kernel rows — the bare repo IS the object store (one store, not two); the Git
|
|
26
|
+
// Data REST reads fall back to it (github-twin.ts).
|
|
27
|
+
//
|
|
28
|
+
// Each branch-ref change also yields a `push` webhook event (ref/before/after), which the
|
|
29
|
+
// server emits through the pack's EXISTING emitGithubEvent pipeline — a git push fires the
|
|
30
|
+
// same webhook machinery a REST contents write does.
|
|
31
|
+
//
|
|
32
|
+
// Offline + deterministic: everything here is local files + local `git` subprocesses (no
|
|
33
|
+
// network, no rate budget). Failure to spawn git throws loudly (github-git-plane.ts).
|
|
34
|
+
import { gunzipSync } from 'node:zlib';
|
|
35
|
+
import { applyTwinWrite } from '@volter/twin';
|
|
36
|
+
import {
|
|
37
|
+
bareRepoDir, ensureBareRepo, hasBareRepo, listGitPlaneRefs, runGit, type PlaneRef,
|
|
38
|
+
} from './github-git-plane.ts';
|
|
39
|
+
import { closePrsForDeletedBranch, githubState, syncPrHeadsToBranchTip, type GithubWriteEvent } from './github-twin.ts';
|
|
40
|
+
|
|
41
|
+
const SERVICE = 'github';
|
|
42
|
+
|
|
43
|
+
export type GitSmartHttpRoute =
|
|
44
|
+
| { kind: 'info-refs'; repo: string }
|
|
45
|
+
| { kind: 'upload-pack'; repo: string }
|
|
46
|
+
| { kind: 'receive-pack'; repo: string };
|
|
47
|
+
|
|
48
|
+
/** Parse a smart-HTTP pathname; null when the path is not a git-protocol route. */
|
|
49
|
+
export function parseGitSmartHttpPath(method: string, pathname: string): GitSmartHttpRoute | null {
|
|
50
|
+
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(pathname);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const owner = decodeURIComponent(m[1]!);
|
|
53
|
+
const name = decodeURIComponent(m[2]!);
|
|
54
|
+
// A name the plane's path validation would reject can never be a twin repo — fall through
|
|
55
|
+
// to the REST handler's vendor-shaped 404 instead of throwing a 500 out of bareRepoDir.
|
|
56
|
+
const SEG = /^[A-Za-z0-9_.-]+$/;
|
|
57
|
+
if (!SEG.test(owner) || !SEG.test(name) || owner === '.' || owner === '..' || name === '.' || name === '..') return null;
|
|
58
|
+
const repo = `${owner}/${name}`;
|
|
59
|
+
if (m[3] === 'info/refs') return method === 'GET' ? { kind: 'info-refs', repo } : null;
|
|
60
|
+
if (method !== 'POST') return null;
|
|
61
|
+
return m[3] === 'git-upload-pack' ? { kind: 'upload-pack', repo } : { kind: 'receive-pack', repo };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type GitSmartHttpResponse = {
|
|
65
|
+
status: number;
|
|
66
|
+
contentType: string;
|
|
67
|
+
body: Uint8Array;
|
|
68
|
+
/** push webhook events (receive-pack only) — the caller emits them via emitGithubEvent. */
|
|
69
|
+
webhooks: GithubWriteEvent[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// One pkt-line: 4-byte hex length (including the length itself) + payload (gitprotocol-common(5)).
|
|
73
|
+
function pktLine(payload: string): Buffer {
|
|
74
|
+
const data = Buffer.from(payload, 'utf8');
|
|
75
|
+
return Buffer.concat([Buffer.from((data.length + 4).toString(16).padStart(4, '0'), 'utf8'), data]);
|
|
76
|
+
}
|
|
77
|
+
const FLUSH_PKT = Buffer.from('0000', 'utf8');
|
|
78
|
+
|
|
79
|
+
function plain(status: number, message: string): GitSmartHttpResponse {
|
|
80
|
+
return { status, contentType: 'text/plain; charset=utf-8', body: Buffer.from(`${message}\n`, 'utf8'), webhooks: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The vendor-shaped miss: git clients render a 404 here as "repository not found". */
|
|
84
|
+
function repoNotFound(): GitSmartHttpResponse {
|
|
85
|
+
return plain(404, 'Repository not found.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The bare repo must exist to be served. A repo the CONTROL plane knows (created via REST
|
|
89
|
+
// before the git plane existed, or pulled from real GitHub) is materialized on first
|
|
90
|
+
// protocol contact — creating a repo via REST initializes its bare repo either way.
|
|
91
|
+
function resolveRepoDir(repo: string, root?: string): string | null {
|
|
92
|
+
if (hasBareRepo(repo, root)) return bareRepoDir(repo, root);
|
|
93
|
+
const known = githubState(root).repos.find((r) => r.full_name === repo);
|
|
94
|
+
if (!known) return null;
|
|
95
|
+
return ensureBareRepo(repo, root, known.default_branch ?? 'main');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serviceEnv(gitProtocol?: string): Record<string, string> | undefined {
|
|
99
|
+
// Protocol v2 negotiation rides the Git-Protocol header end-to-end (gitprotocol-http(5)).
|
|
100
|
+
return gitProtocol ? { GIT_PROTOCOL: gitProtocol } : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Handle one git smart-HTTP request. Transport-agnostic (the Bun server bridges Request →
|
|
105
|
+
* this) so tests and saboteurs hit the same seam the wire does. Returns null for non-git
|
|
106
|
+
* routes (the caller falls through to the REST handler).
|
|
107
|
+
*/
|
|
108
|
+
export async function handleGithubGitSmartHttp(req: {
|
|
109
|
+
method: string;
|
|
110
|
+
pathname: string;
|
|
111
|
+
query?: URLSearchParams;
|
|
112
|
+
body?: Uint8Array;
|
|
113
|
+
root?: string;
|
|
114
|
+
readOnly?: boolean;
|
|
115
|
+
/** request Content-Encoding (git clients gzip large POST bodies) */
|
|
116
|
+
contentEncoding?: string;
|
|
117
|
+
/** request Git-Protocol header (protocol v2 negotiation) */
|
|
118
|
+
gitProtocol?: string;
|
|
119
|
+
occurredAt?: string;
|
|
120
|
+
}): Promise<GitSmartHttpResponse | null> {
|
|
121
|
+
const route = parseGitSmartHttpPath(req.method, req.pathname);
|
|
122
|
+
if (!route) return null;
|
|
123
|
+
|
|
124
|
+
// ── GET info/refs — the smart service advertisement ─────────────────────────────────────
|
|
125
|
+
if (route.kind === 'info-refs') {
|
|
126
|
+
const service = req.query?.get('service') ?? '';
|
|
127
|
+
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
|
|
128
|
+
// Only the smart protocol is served. 403 is the FIRST-PARTY refusal status: git's own
|
|
129
|
+
// http-backend answers "403 Forbidden — Dumb HTTP protocol not supported" when dumb
|
|
130
|
+
// HTTP is disabled (http-backend.c), and GitHub refuses dumb clients the same way.
|
|
131
|
+
return plain(403, 'Dumb HTTP protocol is not supported: pass ?service=git-upload-pack or ?service=git-receive-pack.');
|
|
132
|
+
}
|
|
133
|
+
if (service === 'git-receive-pack' && req.readOnly) return plain(403, 'twin is read-only (mirror mode); pushes are rejected');
|
|
134
|
+
const dir = resolveRepoDir(route.repo, req.root);
|
|
135
|
+
if (!dir) return repoNotFound();
|
|
136
|
+
const cmd = service === 'git-upload-pack' ? 'upload-pack' : 'receive-pack';
|
|
137
|
+
const res = runGit(dir, [cmd, '--stateless-rpc', '--advertise-refs', '.'], { env: serviceEnv(req.gitProtocol) });
|
|
138
|
+
if (res.code !== 0) throw new Error(`github twin git plane: \`git ${cmd} --advertise-refs\` failed (exit ${res.code}): ${res.stderr.trim()}`);
|
|
139
|
+
return {
|
|
140
|
+
status: 200,
|
|
141
|
+
contentType: `application/x-${service}-advertisement`,
|
|
142
|
+
body: Buffer.concat([pktLine(`# service=${service}\n`), FLUSH_PKT, res.stdout]),
|
|
143
|
+
webhooks: [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── POST git-upload-pack / git-receive-pack — the stateless RPC ─────────────────────────
|
|
148
|
+
// Read-only refusal comes FIRST on both routes (same ordering as the advertisement): a
|
|
149
|
+
// mirror answers 403 to a push whether or not the repo exists.
|
|
150
|
+
if (route.kind === 'receive-pack' && req.readOnly) return plain(403, 'twin is read-only (mirror mode); pushes are rejected');
|
|
151
|
+
const dir = resolveRepoDir(route.repo, req.root);
|
|
152
|
+
if (!dir) return repoNotFound();
|
|
153
|
+
let input: Uint8Array = req.body ?? new Uint8Array();
|
|
154
|
+
if ((req.contentEncoding ?? '').includes('gzip')) {
|
|
155
|
+
// Bounded + non-throwing: a malformed or bomb-sized body is the CLIENT's protocol error
|
|
156
|
+
// (400), never an unhandled 500. 256 MiB comfortably covers any local twin push.
|
|
157
|
+
try { input = gunzipSync(input, { maxOutputLength: 256 * 1024 * 1024 }); }
|
|
158
|
+
catch { return plain(400, 'Invalid or oversized gzip request body.'); }
|
|
159
|
+
}
|
|
160
|
+
const cmd = route.kind === 'upload-pack' ? 'upload-pack' : 'receive-pack';
|
|
161
|
+
const before = route.kind === 'receive-pack' ? listGitPlaneRefs(route.repo, req.root) : [];
|
|
162
|
+
const res = runGit(dir, [cmd, '--stateless-rpc', '.'], { stdin: input, env: serviceEnv(req.gitProtocol) });
|
|
163
|
+
if (res.code !== 0) {
|
|
164
|
+
// A garbage/aborted request makes the service exit non-zero. Whatever it managed to say
|
|
165
|
+
// rides back IN-BAND (that is how the wire reports protocol errors — the client renders
|
|
166
|
+
// it); with nothing on stdout, answer a 400 naming the failure. Never an unhandled 500.
|
|
167
|
+
if (res.stdout.length === 0) return plain(400, `git ${cmd} rejected the request: ${res.stderr.trim() || 'protocol error'}`);
|
|
168
|
+
}
|
|
169
|
+
const webhooks = route.kind === 'receive-pack'
|
|
170
|
+
? await reconcileGitPlaneRefs(route.repo, before, { ...(req.root !== undefined ? { root: req.root } : {}), ...(req.occurredAt !== undefined ? { occurredAt: req.occurredAt } : {}) })
|
|
171
|
+
: [];
|
|
172
|
+
return { status: 200, contentType: `application/x-git-${cmd}-result`, body: res.stdout, webhooks };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ZERO_SHA = '0'.repeat(40);
|
|
176
|
+
|
|
177
|
+
// Per-write ordinal folded into every reconcile write's fields: the kernel dedupes actions
|
|
178
|
+
// by content + occurredAt millisecond, so a ref REVISITING a value under a pinned clock
|
|
179
|
+
// (force-push A→B→A replayed in one ms) would silently collide with its own earlier action
|
|
180
|
+
// and leave the projection at B while the bare repo says A (docs/ADDING_A_TWIN.md §5 — the
|
|
181
|
+
// upstashredis `rev` pattern). Strictly increasing within the process; harmless extra field.
|
|
182
|
+
let reconcileSeq = 0;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fold the bare repo's ref changes since `before` into the kernel event log — the SAME
|
|
186
|
+
* git_ref.* / branch.* operations the Git Data REST writes append, so the control plane and
|
|
187
|
+
* the git plane converge on one ref record. Returns the `push` webhook events (one per
|
|
188
|
+
* changed branch/tag ref, GitHub-style ref/before/after).
|
|
189
|
+
*/
|
|
190
|
+
export async function reconcileGitPlaneRefs(
|
|
191
|
+
repo: string,
|
|
192
|
+
before: PlaneRef[],
|
|
193
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
194
|
+
): Promise<GithubWriteEvent[]> {
|
|
195
|
+
const root = opts.root;
|
|
196
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
197
|
+
const after = listGitPlaneRefs(repo, root);
|
|
198
|
+
const beforeBy = new Map(before.map((r) => [r.ref, r]));
|
|
199
|
+
const afterBy = new Map(after.map((r) => [r.ref, r]));
|
|
200
|
+
const events: GithubWriteEvent[] = [];
|
|
201
|
+
|
|
202
|
+
const branchName = (ref: string): string | null => (ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : null);
|
|
203
|
+
|
|
204
|
+
for (const r of after) {
|
|
205
|
+
const prev = beforeBy.get(r.ref);
|
|
206
|
+
if (prev && prev.object_sha === r.object_sha) continue;
|
|
207
|
+
await applyTwinWrite(SERVICE, {
|
|
208
|
+
operation: prev ? 'git_ref.update' : 'git_ref.create', subjectType: 'git_ref', subjectId: `gitref:${repo}#${r.ref}`,
|
|
209
|
+
fields: { repository: repo, ref: r.ref, object_sha: r.object_sha, object_type: r.object_type === 'tag' ? 'tag' : 'commit', rev: ++reconcileSeq },
|
|
210
|
+
occurredAt, actor: { kind: 'agent' },
|
|
211
|
+
}, root);
|
|
212
|
+
const name = branchName(r.ref);
|
|
213
|
+
if (name) {
|
|
214
|
+
await applyTwinWrite(SERVICE, {
|
|
215
|
+
operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${name}`,
|
|
216
|
+
fields: { repository: repo, name, commit_sha: r.object_sha, rev: ++reconcileSeq },
|
|
217
|
+
occurredAt, actor: { kind: 'agent' },
|
|
218
|
+
}, root);
|
|
219
|
+
// GitHub's `synchronize`: a push to a PR's head branch moves the PR head — the old
|
|
220
|
+
// head's statuses/reviews stop matching (exact-head re-earn).
|
|
221
|
+
for (const n of await syncPrHeadsToBranchTip(repo, name, r.object_sha, root, occurredAt)) {
|
|
222
|
+
events.push({ event: 'pull_request', action: 'synchronize', repository: repo, number: n });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: prev?.object_sha ?? ZERO_SHA, after: r.object_sha });
|
|
226
|
+
}
|
|
227
|
+
for (const r of before) {
|
|
228
|
+
if (afterBy.has(r.ref)) continue;
|
|
229
|
+
await applyTwinWrite(SERVICE, {
|
|
230
|
+
operation: 'git_ref.delete', subjectType: 'git_ref', subjectId: `gitref:${repo}#${r.ref}`,
|
|
231
|
+
fields: { repository: repo, ref: r.ref, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
232
|
+
}, root);
|
|
233
|
+
const name = branchName(r.ref);
|
|
234
|
+
if (name) {
|
|
235
|
+
await applyTwinWrite(SERVICE, {
|
|
236
|
+
operation: 'branch.delete', subjectType: 'branch', subjectId: `branch:${repo}#${name}`,
|
|
237
|
+
fields: { repository: repo, name, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
238
|
+
}, root);
|
|
239
|
+
// Real GitHub CLOSES an open PR whose head branch is deleted (shared with the REST
|
|
240
|
+
// DELETE .../git/refs path — both converge on the same helper).
|
|
241
|
+
for (const n of await closePrsForDeletedBranch(repo, name, root, occurredAt)) {
|
|
242
|
+
events.push({ event: 'pull_request', action: 'closed', repository: repo, number: n });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: r.object_sha, after: ZERO_SHA });
|
|
246
|
+
}
|
|
247
|
+
return events;
|
|
248
|
+
}
|