@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,95 @@
1
+ // GitHub webhook emission (scorecard R17). Real GitHub fires webhooks on PR/review/
2
+ // comment activity AND across the rest of the surface (release/repository/fork/member/
3
+ // push/discussion/…) — `pull_request` (opened/edited/closed), `pull_request_review`
4
+ // (submitted), `issue_comment` (created), and many more — so an app's webhook handler
5
+ // runs. The twin builds the real GitHub webhook envelope (with the X-GitHub-Event header
6
+ // + the HMAC X-Hub-Signature-256 when a secret is configured), delivers it to registered
7
+ // endpoints (injected delivery: fake in tests, HTTP POST live), and LOGS each delivery so
8
+ // it can be listed + redelivered (real GitHub's deliveries log). Deterministic: caller
9
+ // supplies occurredAt; ids are derived from a stable per-process sequence.
10
+ import { createHmac } from 'node:crypto';
11
+ import type { GithubWriteEvent } from './github-twin.ts';
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 } };
14
+ // Delivery receives the resolved headers too (X-GitHub-Event, X-GitHub-Delivery,
15
+ // X-Hub-Signature-256) so a faithful consumer can verify the signature.
16
+ export type GithubWebhookDelivery = (url: string, headerEvent: string, payload: GithubWebhook, headers: Record<string, string>) => Promise<void> | void;
17
+
18
+ // A registered endpoint + its (optional) signing secret.
19
+ type Endpoint = { url: string; secret?: string };
20
+ const registry: Endpoint[] = [];
21
+ export function registerGithubWebhook(url: string, secret?: string): void {
22
+ const existing = registry.find((e) => e.url === url);
23
+ if (existing) existing.secret = secret;
24
+ else registry.push({ url, secret });
25
+ }
26
+ export function clearGithubWebhooks(): void { registry.length = 0; deliveries.length = 0; deliverySeq = 0; }
27
+ export function listGithubWebhooks(): string[] { return registry.map((e) => e.url); }
28
+
29
+ // The deliveries log — one entry per (endpoint, event) delivery, with the exact payload
30
+ // JSON so a redelivery replays it byte-for-byte (real GitHub keeps deliveries for redelivery).
31
+ export type GithubDelivery = { id: string; url: string; event: string; action: string; payloadJson: string; deliveredAt?: string; secret?: string };
32
+ const deliveries: GithubDelivery[] = [];
33
+ let deliverySeq = 0;
34
+ export function listGithubDeliveries(): GithubDelivery[] { return deliveries.map((d) => ({ ...d })); }
35
+
36
+ /** The HMAC-SHA256 signature GitHub sends in X-Hub-Signature-256 (sha256=<hex>). */
37
+ export function signGithubPayload(secret: string, payloadJson: string): string {
38
+ return `sha256=${createHmac('sha256', secret).update(payloadJson).digest('hex')}`;
39
+ }
40
+
41
+ const httpDelivery: GithubWebhookDelivery = async (url, _headerEvent, payload, headers) => {
42
+ try { await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(payload) }); }
43
+ catch { /* fire-and-forget, like real GitHub */ }
44
+ };
45
+
46
+ function buildPayload(write: GithubWriteEvent): GithubWebhook {
47
+ return {
48
+ event: write.event,
49
+ action: write.action,
50
+ number: write.number,
51
+ repository: { full_name: write.repository },
52
+ ...(write.event === 'pull_request' ? { pull_request: write.pr ?? { number: write.number } } : {}),
53
+ ...(write.event === 'issues' ? { issue: { number: write.number } } : {}),
54
+ ...(write.event === 'pull_request_review' ? { review: { body: write.body ?? '' }, pull_request: { number: write.number } } : {}),
55
+ ...(write.event === 'issue_comment' ? { comment: { body: write.body ?? '' } } : {}),
56
+ };
57
+ }
58
+
59
+ function headersFor(event: string, deliveryId: string, payloadJson: string, secret?: string): Record<string, string> {
60
+ const headers: Record<string, string> = { 'x-github-event': event, 'x-github-delivery': deliveryId };
61
+ if (secret) headers['x-hub-signature-256'] = signGithubPayload(secret, payloadJson);
62
+ return headers;
63
+ }
64
+
65
+ /** Emit a GitHub webhook for a twin write to registered endpoints. No-op when none registered. */
66
+ export async function emitGithubEvent(
67
+ write: GithubWriteEvent,
68
+ opts: { occurredAt?: string; deliver?: GithubWebhookDelivery } = {},
69
+ ): Promise<GithubWebhook[]> {
70
+ if (registry.length === 0) return [];
71
+ const deliver = opts.deliver ?? httpDelivery;
72
+ const payload = buildPayload(write);
73
+ const payloadJson = JSON.stringify(payload);
74
+ const out: GithubWebhook[] = [];
75
+ for (const ep of registry) {
76
+ const id = `${++deliverySeq}`;
77
+ deliveries.push({ id, url: ep.url, event: write.event, action: write.action, payloadJson, deliveredAt: opts.occurredAt, secret: ep.secret });
78
+ const headers = headersFor(write.event, id, payloadJson, ep.secret);
79
+ await deliver(ep.url, write.event, payload, headers);
80
+ out.push(payload);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Redeliver a logged delivery by id — replays the EXACT payload to its endpoint. */
86
+ export async function redeliverGithubDelivery(id: string, opts: { deliver?: GithubWebhookDelivery } = {}): Promise<boolean> {
87
+ const d = deliveries.find((x) => x.id === id);
88
+ if (!d) return false;
89
+ const deliver = opts.deliver ?? httpDelivery;
90
+ const newId = `${++deliverySeq}`;
91
+ deliveries.push({ ...d, id: newId });
92
+ const headers = headersFor(d.event, newId, d.payloadJson, d.secret);
93
+ await deliver(d.url, d.event, JSON.parse(d.payloadJson) as GithubWebhook, headers);
94
+ return true;
95
+ }
@@ -0,0 +1,187 @@
1
+ // GitHub GraphQL API (v4) — a FAITHFUL SUBSET over the SAME projection the REST twin serves.
2
+ // GitHub's GraphQL graph is enormous; this models a deliberately small, real slice and is
3
+ // HONEST about its partial coverage: a field the twin does not model raises an
4
+ // `undefinedField`-style GraphQL error (the same error class GitHub raises), rather than
5
+ // fabricating a value. The op is resolved against `githubState` (the kernel projection) so it
6
+ // is consistent with the REST surface byte-for-byte.
7
+ //
8
+ // Modeled subset:
9
+ // query { viewer { login name } }
10
+ // query { repository(owner, name) { name nameWithOwner isPrivate
11
+ // pullRequests(first) { totalCount nodes { number title state } }
12
+ // issues(first) { totalCount nodes { number title state } } } }
13
+ // query { node(id) { ... on PullRequest { number title } } } — id = "PR_<owner>/<repo>#<n>"
14
+ // mutation { addComment(input: { subjectId, body }) { commentEdge { node { id body } } } }
15
+ // Anything else (other root fields, unmodeled selection fields) → a typed GraphQL error.
16
+ import { applyGithubWrite } from './github-twin.ts';
17
+ import { githubState, type GithubState } from './github-twin.ts';
18
+
19
+ export type GraphqlResult = { data?: Record<string, unknown> | null; errors?: Array<{ message: string; type?: string }> };
20
+
21
+ // A field the twin doesn't model — surfaced as GitHub's `undefinedField` error class (honest
22
+ // partial coverage). The query still fails rather than returning a fabricated value.
23
+ function unmodeled(field: string, onType: string): { message: string; type: string } {
24
+ return { message: `Field '${field}' doesn't exist on type '${onType}' in the twin's modeled GraphQL subset`, type: 'undefinedField' };
25
+ }
26
+
27
+ // The fields the twin models per type — a selection naming anything else is an honest error.
28
+ const VIEWER_FIELDS = new Set(['login', 'name', '__typename']);
29
+ // `repo` appears as a token from the `...repo` fragment spread gh's RepositoryInfo query uses; the
30
+ // fragment's own fields live in a separate `fragment repo on Repository {…}` block (validated below).
31
+ const REPO_FIELDS = new Set([
32
+ 'name', 'nameWithOwner', 'isPrivate', 'pullRequests', 'issues', '__typename',
33
+ // RepositoryInfo (gh pr create / gh repo view): the `repo` fragment + repo-level merge settings.
34
+ 'id', 'owner', 'login', 'hasIssuesEnabled', 'description', 'hasWikiEnabled', 'viewerPermission',
35
+ 'defaultBranchRef', 'parent', 'mergeCommitAllowed', 'rebaseMergeAllowed', 'squashMergeAllowed', 'repo',
36
+ ]);
37
+ const PR_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename',
38
+ // PR connection node fields gh pr view/list select (modeled from the PR projection).
39
+ 'url', 'baseRefName', 'headRefName', 'isCrossRepository', 'headRepositoryOwner', 'login', 'name']);
40
+ const ISSUE_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename']);
41
+ // Relay connection-level fields valid alongside nodes/totalCount (not object fields).
42
+ const CONNECTION_FIELDS = new Set(['totalCount', 'nodes', 'edges', 'pageInfo', 'node', 'cursor', 'endCursor', 'hasNextPage', '__typename']);
43
+
44
+ // Extract the inner-brace selection-set field names for a given parent (very small parser —
45
+ // sufficient for the modeled subset; it reads identifiers, ignoring args/aliases/nesting we
46
+ // resolve explicitly). Returns the top-level selected names under the FIRST `{...}` after the
47
+ // parent token.
48
+ function selectionFields(query: string, parentToken: string): string[] {
49
+ const idx = query.indexOf(parentToken);
50
+ if (idx < 0) return [];
51
+ // Skip an optional argument list `(...)` after the parent token BEFORE locating the selection
52
+ // set — real gh queries put object args there (e.g. `orderBy: { field: CREATED_AT }`), and the
53
+ // naive `indexOf('{')` would otherwise mistake that argument brace for the selection set.
54
+ let p = idx + parentToken.length;
55
+ while (p < query.length && /\s/.test(query[p]!)) p++;
56
+ if (query[p] === '(') { let d = 0; for (; p < query.length; p++) { if (query[p] === '(') d++; else if (query[p] === ')') { d--; if (d === 0) { p++; break; } } } }
57
+ const open = query.indexOf('{', p);
58
+ if (open < 0) return [];
59
+ let depth = 0; let end = -1;
60
+ for (let i = open; i < query.length; i++) { if (query[i] === '{') depth++; else if (query[i] === '}') { depth--; if (depth === 0) { end = i; break; } } }
61
+ if (end < 0) return [];
62
+ let inner = query.slice(open + 1, end);
63
+ // iteratively strip ALL nested blocks (and arg parens) so we only see THIS level's fields.
64
+ let prev = '';
65
+ while (prev !== inner) { prev = inner; inner = inner.replace(/\{[^{}]*\}/g, ' '); }
66
+ inner = inner.replace(/\([^()]*\)/g, ' ');
67
+ return [...inner.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)].map((m) => m[0]).filter((t) => t !== 'on');
68
+ }
69
+
70
+ // Resolve an argument value for `argName` from the query text: a string literal `arg:"x"` or
71
+ // a variable reference `arg:$v` (looked up in `variables`). Returns undefined when absent.
72
+ function argValue(query: string, argName: string, variables: Record<string, unknown>): string | undefined {
73
+ const lit = new RegExp(`${argName}:\\s*"([^"]*)"`).exec(query);
74
+ if (lit) return lit[1];
75
+ const ref = new RegExp(`${argName}:\\s*\\$([A-Za-z_][A-Za-z0-9_]*)`).exec(query);
76
+ if (ref && variables[ref[1]!] !== undefined) return String(variables[ref[1]!]);
77
+ return undefined;
78
+ }
79
+
80
+ function repoToNodes(state: GithubState, repo: string) {
81
+ const owner = repo.split('/')[0]!;
82
+ const prs = state.prs.filter((p) => p.repository === repo).map((p) => ({
83
+ number: p.number, title: p.title ?? null, state: (p.merged ? 'MERGED' : (p.state ?? 'open')).toUpperCase(), id: `PR_${repo}#${p.number}`,
84
+ // PR connection node fields gh's `pr view`/`pr list` request — all from the SAME PR projection.
85
+ url: `https://github.com/${repo}/pull/${p.number}`, baseRefName: p.base_ref ?? null, headRefName: p.head_ref ?? null,
86
+ isCrossRepository: false, headRepositoryOwner: { id: `U_${owner}`, login: owner, name: null },
87
+ }));
88
+ const issues = state.issues.filter((i) => i.repository === repo).map((i) => ({ number: i.number, title: i.title ?? null, state: (i.state ?? 'open').toUpperCase(), id: `I_${repo}#${i.number}` }));
89
+ return { prs, issues };
90
+ }
91
+
92
+ /** Resolve a GraphQL operation against the github world (the SAME projection as REST). */
93
+ export async function resolveGithubGraphql(req: { query: string; variables?: Record<string, unknown>; root?: string }): Promise<GraphqlResult> {
94
+ const query = req.query;
95
+ const vars = req.variables ?? {};
96
+ const state = githubState(req.root);
97
+ const isMutation = /^\s*mutation\b/.test(query);
98
+
99
+ // ── mutation addComment(input: { subjectId, body }) ──────────────────────────────────────
100
+ if (isMutation) {
101
+ if (/\baddComment\b/.test(query)) {
102
+ // subjectId/body come from variables or inline input. subjectId "PR_<repo>#<n>" or "I_…".
103
+ const subjectId = String(vars.subjectId ?? argValue(query, 'subjectId', vars) ?? '');
104
+ const body = String(vars.body ?? argValue(query, 'body', vars) ?? '');
105
+ const m = /^(PR|I)_(.+)#(\d+)$/.exec(subjectId);
106
+ if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${subjectId}'`, type: 'NOT_FOUND' }] };
107
+ const repo = m[2]!; const number = Number(m[3]);
108
+ // addComment maps onto the SAME write path the REST twin uses (issue comment).
109
+ const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/issues/${number}/comments`, body: JSON.stringify({ body }), root: req.root });
110
+ if (res.response.status !== 201) return { errors: [{ message: 'addComment failed', type: 'UNPROCESSABLE' }] };
111
+ const cid = (res.response.body as { id?: number }).id;
112
+ return { data: { addComment: { commentEdge: { node: { id: `IC_${repo}#${cid}`, body } } } } };
113
+ }
114
+ // mutation createPullRequest(input: { repositoryId, baseRefName, headRefName, title, body, draft })
115
+ // — gh pr create's mutation. Maps onto the SAME REST create path; returns the new PR's node id+url.
116
+ if (/\bcreatePullRequest\b/.test(query)) {
117
+ const input = (vars.input ?? {}) as Record<string, unknown>;
118
+ const m = /^R_(.+)$/.exec(String(input.repositoryId ?? ''));
119
+ if (!m) return { errors: [{ message: `Could not resolve to a Repository with the global id of '${String(input.repositoryId ?? '')}'`, type: 'NOT_FOUND' }] };
120
+ const repo = m[1]!;
121
+ const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/pulls`, body: JSON.stringify({ title: input.title, head: input.headRefName, base: input.baseRefName, body: input.body, draft: input.draft === true }), root: req.root });
122
+ if (res.response.status !== 201) return { errors: [{ message: 'createPullRequest failed', type: 'UNPROCESSABLE' }] };
123
+ const number = (res.response.body as { number?: number }).number;
124
+ return { data: { createPullRequest: { pullRequest: { id: `PR_${repo}#${number}`, url: `https://github.com/${repo}/pull/${number}` } } } };
125
+ }
126
+ return { errors: [{ message: 'Only addComment + createPullRequest are modeled in the twin GraphQL mutation subset', type: 'undefinedField' }] };
127
+ }
128
+
129
+ // ── query — resolve the recognized root fields; reject unmodeled ones honestly ────────────
130
+ const rootFields = selectionFields(query, query.match(/^\s*query/) ? 'query' : '{');
131
+ // viewer
132
+ if (/\bviewer\b/.test(query)) {
133
+ const bad = selectionFields(query, 'viewer').find((f) => !VIEWER_FIELDS.has(f));
134
+ if (bad) return { errors: [unmodeled(bad, 'User')] };
135
+ return { data: { viewer: { login: 'octocat', name: state.userProfile.name ?? 'The Octocat' } } };
136
+ }
137
+ // node(id)
138
+ if (/\bnode\b/.test(query)) {
139
+ const id = String(vars.id ?? argValue(query, 'id', vars) ?? '');
140
+ const m = /^(PR|I)_(.+)#(\d+)$/.exec(id);
141
+ if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${id}'`, type: 'NOT_FOUND' }] };
142
+ const repo = m[2]!; const number = Number(m[3]);
143
+ const { prs, issues } = repoToNodes(state, repo);
144
+ const obj = m[1] === 'PR' ? prs.find((p) => p.number === number) : issues.find((i) => i.number === number);
145
+ if (!obj) return { errors: [{ message: `Could not resolve to a node with the global id of '${id}'`, type: 'NOT_FOUND' }] };
146
+ return { data: { node: { number: obj.number, title: obj.title, state: obj.state, id } } };
147
+ }
148
+ // repository(owner, name)
149
+ if (/\brepository\b/.test(query)) {
150
+ const owner = String(vars.owner ?? argValue(query, 'owner', vars) ?? '');
151
+ const name = String(vars.name ?? argValue(query, 'name', vars) ?? '');
152
+ const repo = `${owner}/${name}`;
153
+ const bad = selectionFields(query, 'repository').find((f) => !REPO_FIELDS.has(f));
154
+ if (bad) return { errors: [unmodeled(bad, 'Repository')] };
155
+ const known = state.repos.find((r) => r.full_name === repo);
156
+ const { prs, issues } = repoToNodes(state, repo);
157
+ // unmodeled subfields under pullRequests/issues nodes are honest errors too (connection-level
158
+ // fields like pageInfo/edges are allowed; only unknown PullRequest/Issue node fields error).
159
+ if (/\bpullRequests\b/.test(query)) { const b = selectionFields(query, 'pullRequests').find((f) => !PR_FIELDS.has(f) && !CONNECTION_FIELDS.has(f)); if (b) return { errors: [unmodeled(b, 'PullRequest')] }; }
160
+ if (/\bissues\b/.test(query)) { const b = selectionFields(query, 'issues').find((f) => !ISSUE_FIELDS.has(f) && !CONNECTION_FIELDS.has(f)); if (b) return { errors: [unmodeled(b, 'Issue')] }; }
161
+ // Honor the connection filter args gh sends: pullRequests(headRefName, states).
162
+ const headRefName = argValue(query, 'headRefName', vars);
163
+ const statesArg = vars.states ?? vars.state;
164
+ const prStates = Array.isArray(statesArg) ? statesArg.map((s) => String(s).toUpperCase()) : undefined;
165
+ const prNodes = prs
166
+ .filter((p) => headRefName === undefined || p.headRefName === headRefName)
167
+ .filter((p) => prStates === undefined || prStates.includes(p.state));
168
+ // The repo `fragment` fields (gh's RepositoryInfo); derived from the SAME repo projection REST
169
+ // serves. viewerPermission is ADMIN — the twin's loopback caller is the unauthenticated owner.
170
+ const repoFragment = {
171
+ id: `R_${repo}`, name, owner: { login: owner },
172
+ hasIssuesEnabled: known?.has_issues ?? true, description: known?.description ?? null,
173
+ hasWikiEnabled: known?.has_wiki ?? true, viewerPermission: 'ADMIN',
174
+ defaultBranchRef: { name: known?.default_branch ?? 'main' },
175
+ };
176
+ const repository: Record<string, unknown> = {
177
+ ...repoFragment, nameWithOwner: repo, isPrivate: known?.private ?? false,
178
+ parent: null, mergeCommitAllowed: true, rebaseMergeAllowed: true, squashMergeAllowed: true,
179
+ pullRequests: { totalCount: prNodes.length, nodes: prNodes, pageInfo: { hasNextPage: false, endCursor: null } },
180
+ issues: { totalCount: issues.length, nodes: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })), pageInfo: { hasNextPage: false, endCursor: null } },
181
+ };
182
+ return { data: { repository } };
183
+ }
184
+ // any other top-level field is outside the modeled subset.
185
+ const unknownRoot = rootFields.find((f) => !['viewer', 'repository', 'node'].includes(f));
186
+ return { errors: [unmodeled(unknownRoot ?? rootFields[0] ?? 'unknown', 'Query')] };
187
+ }