@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.
@@ -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
+ }