@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,644 @@
1
+ // GitHub CONNECTOR — the live-vendor pull/push lifecycle for the GitHub twin.
2
+ //
3
+ // This is the missing category: code that talks to REAL GitHub (pull) and pushes a
4
+ // twin change back (push), with the same code path exercised offline. The vendor I/O
5
+ // is an INJECTED octokit-like executor (the auth-boundary, hard-problem #6): the
6
+ // kernel and twin hold NO token.
7
+ // - offline/tests pass a fake executor (deterministic, no network),
8
+ // - live runs pass `liveGithubExecute(token)` (the user's own PAT, via the real
9
+ // @octokit/rest REST client) — NEVER imported here, so this pack takes no SDK
10
+ // dependency and never opens a socket on its own.
11
+ // Mirrors the Linear/Slack connector pattern: pull → fold evidence; push pending
12
+ // actions → injected executor → confirmAction (suppress the local projection).
13
+ //
14
+ // CONTENT ON PULL (the GitHub-specific contract): the `github` world is a CONTENT
15
+ // mirror for the text GitHub's REST API actually returns. An OBSERVED pull folds the
16
+ // PR/issue title, body, state and the BODIES of reviews/comments — alongside the
17
+ // metadata (number, repo, base ref, head sha, file/commit COUNTS). Pull NEVER fabricates
18
+ // text it didn't receive; the one excluded thing is the Non-goal — actual repository
19
+ // file CONTENTS (git blob bytes) — which the pull does not fetch. Content-bearing twin
20
+ // state thus comes from BOTH the observed fold and LOCAL writes, which push reconciles.
21
+ import { appendEvent, confirmAction, pendingActions, syncPull } from '@volter/twin';
22
+ import type { SyncResource, WorldServiceEvent } from '@volter/twin';
23
+
24
+ const SERVICE = 'github';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // The injected executor (auth-boundary).
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * The minimal real-GitHub REST surface the connector needs. A real `@octokit/rest`
32
+ * client is structurally adaptable to this (its `request(route, params)` returns
33
+ * `{ status, data }`) — the consumer wires it; this pack never imports it. `request`
34
+ * is the single credentialed boundary: in tests it's a fake, live it's the user's
35
+ * token-bound octokit. It maps a REST `route` ("METHOD /path") + params to a result.
36
+ */
37
+ export interface GithubExecute {
38
+ request(
39
+ route: string,
40
+ params?: Record<string, unknown>,
41
+ ): Promise<{ status: number; data: any }>;
42
+ }
43
+
44
+ /**
45
+ * A live executor against the real GitHub REST API (token = the user's own PAT).
46
+ * Constructed with the real @octokit/rest in PROD by the CALLER and passed in; this
47
+ * helper shows the shape without importing the SDK. Kept tiny + dependency-free: it
48
+ * uses `fetch`, so the pack pulls in no network client. Live runs may instead pass a
49
+ * real `new Octokit({ auth }).request` bound into a `{ request }` object.
50
+ */
51
+ export function liveGithubExecute(token: string, baseUrl = 'https://api.github.com'): GithubExecute {
52
+ return {
53
+ async request(route, params = {}) {
54
+ const sp = route.indexOf(' ');
55
+ const method = route.slice(0, sp);
56
+ let path = route.slice(sp + 1);
57
+ const rest: Record<string, unknown> = {};
58
+ // Substitute {param} path templates; remaining params become the QUERY STRING on
59
+ // GET/HEAD and the JSON body otherwise. (GET has no body, so leftover params were
60
+ // silently DROPPED before — every list read got GitHub's defaults, state=open
61
+ // per_page=30, which is how merged PRs froze out of observation: the poll's
62
+ // state:'closed' never reached the API. octokit does this same split.)
63
+ for (const [k, v] of Object.entries(params)) {
64
+ const token = `{${k}}`;
65
+ if (path.includes(token)) path = path.replace(token, encodeURIComponent(String(v)));
66
+ else rest[k] = v;
67
+ }
68
+ const isRead = method === 'GET' || method === 'HEAD';
69
+ if (isRead && Object.keys(rest).length > 0) {
70
+ const qs = new URLSearchParams();
71
+ for (const [k, v] of Object.entries(rest)) qs.set(k, String(v));
72
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
73
+ }
74
+ const res = await fetch(`${baseUrl}${path}`, {
75
+ method,
76
+ headers: {
77
+ Authorization: `Bearer ${token}`,
78
+ Accept: 'application/vnd.github+json',
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ ...(isRead ? {} : { body: JSON.stringify(rest) }),
82
+ });
83
+ return { status: res.status, data: res.status === 204 ? undefined : await res.json() };
84
+ },
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // PULL (real → twin): fold OBSERVED content + metadata.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ // The shape of an observed PR. Carries the CONTENT the REST PR object returns (title/
93
+ // body/state) plus metadata (counts + refs) and the BODIES of reviews/comments. The
94
+ // only excluded thing is the Non-goal: actual repository file CONTENTS are never pulled.
95
+ type ObservedReview = { state?: string; body?: string; submittedAt?: string };
96
+ type ObservedComment = { body?: string; createdAt?: string };
97
+ type ObservedPr = {
98
+ id: string; // owner/repo#number
99
+ number: number;
100
+ repository: string;
101
+ title?: string;
102
+ body?: string;
103
+ state?: string;
104
+ draft?: boolean;
105
+ merged?: boolean;
106
+ mergeCommit?: string;
107
+ baseRef?: string;
108
+ headSha?: string;
109
+ changedFiles?: number;
110
+ commitsCount?: number;
111
+ reviewCount: number;
112
+ commentCount: number;
113
+ reviews: ObservedReview[];
114
+ comments: ObservedComment[];
115
+ };
116
+
117
+ // The shape of an observed ISSUE (GET .../issues with PRs excluded). Carries content
118
+ // (title/body/state) — issues exist on pull as first-class objects, not just via local
119
+ // writes. Numbers share the per-repo PR/issue space (real GitHub).
120
+ type ObservedIssue = {
121
+ id: string; // owner/repo#issue:number
122
+ number: number;
123
+ repository: string;
124
+ title?: string;
125
+ body?: string;
126
+ state?: string;
127
+ };
128
+
129
+ /**
130
+ * Pull OBSERVED PRs for a repo via the injected executor. Folds the CONTENT the REST
131
+ * PR object returns (title/body/state) plus metadata (counts/refs) and the BODIES of
132
+ * each review + issue comment. The ONLY thing not pulled is the Non-goal — actual
133
+ * repository file CONTENTS (per-file diffs/blob bytes are not fetched here).
134
+ */
135
+ export async function pullGithubPrs(
136
+ execute: GithubExecute,
137
+ opts: { owner: string; repo: string; state?: 'open' | 'closed' | 'all'; perPage?: number },
138
+ ): Promise<ObservedPr[]> {
139
+ const { owner, repo } = opts;
140
+ const repository = `${owner}/${repo}`;
141
+ const list = await execute.request('GET /repos/{owner}/{repo}/pulls', {
142
+ owner, repo, state: opts.state ?? 'open', per_page: opts.perPage ?? 30,
143
+ });
144
+ if (list.status >= 400) throw new Error(`github pull failed (list pulls): HTTP ${list.status}`);
145
+ const nodes: any[] = Array.isArray(list.data) ? list.data : [];
146
+ const out: ObservedPr[] = [];
147
+ for (const n of nodes) {
148
+ const number = Number(n.number);
149
+ // Reviews + comments: fold the BODIES (content) and count. A real connector would
150
+ // page these; the fake returns the list, which we map to body-bearing entries.
151
+ const reviewsRes = await execute.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { owner, repo, pull_number: number });
152
+ const commentsRes = await execute.request('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { owner, repo, issue_number: number });
153
+ const reviewNodes: any[] = Array.isArray(reviewsRes.data) ? reviewsRes.data : [];
154
+ const commentNodes: any[] = Array.isArray(commentsRes.data) ? commentsRes.data : [];
155
+ const pr: ObservedPr = {
156
+ id: `${repository}#${number}`,
157
+ number,
158
+ repository,
159
+ reviewCount: reviewNodes.length,
160
+ commentCount: commentNodes.length,
161
+ reviews: reviewNodes.map((r) => {
162
+ const review: ObservedReview = {};
163
+ if (r.state !== undefined && r.state !== null) review.state = String(r.state);
164
+ if (r.body !== undefined && r.body !== null) review.body = String(r.body);
165
+ if (r.submitted_at !== undefined && r.submitted_at !== null) review.submittedAt = String(r.submitted_at);
166
+ return review;
167
+ }),
168
+ comments: commentNodes.map((c) => {
169
+ const comment: ObservedComment = {};
170
+ if (c.body !== undefined && c.body !== null) comment.body = String(c.body);
171
+ if (c.created_at !== undefined && c.created_at !== null) comment.createdAt = String(c.created_at);
172
+ return comment;
173
+ }),
174
+ };
175
+ // CONTENT: title/body/state are real text the REST PR object returns — fold them.
176
+ if (n.title !== undefined && n.title !== null) pr.title = String(n.title);
177
+ if (n.body !== undefined && n.body !== null) pr.body = String(n.body);
178
+ if (n.state !== undefined && n.state !== null) pr.state = String(n.state);
179
+ // draft + merge state: the REST PR object's `draft` flag and merge status are
180
+ // first-class forge facts (a validator gates delivery on them), so fold them like
181
+ // state. GitHub marks a merged PR as state:closed with merged_at set, so derive
182
+ // `merged` from merged_at (or the explicit `merged` on the single-PR GET).
183
+ if (n.draft !== undefined && n.draft !== null) pr.draft = Boolean(n.draft);
184
+ if (n.merged !== undefined || n.merged_at !== undefined) pr.merged = Boolean(n.merged ?? n.merged_at);
185
+ if (pr.merged && n.merge_commit_sha !== undefined && n.merge_commit_sha !== null) pr.mergeCommit = String(n.merge_commit_sha);
186
+ // base/head refs ride along when present.
187
+ if (n.base?.ref !== undefined) pr.baseRef = String(n.base.ref);
188
+ if (n.head?.sha !== undefined) pr.headSha = String(n.head.sha);
189
+ // changed-files / commits COUNTS are metadata; per-file diffs are NOT pulled (Non-goal).
190
+ if (n.changed_files !== undefined) pr.changedFiles = Number(n.changed_files);
191
+ if (n.commits !== undefined) pr.commitsCount = Number(n.commits);
192
+ out.push(pr);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Pull OBSERVED ISSUES for a repo via the injected executor (GET /repos/:o/:r/issues),
199
+ * EXCLUDING pull requests — the issues endpoint returns PRs too (each PR is an issue),
200
+ * distinguished by a `pull_request` field, which we filter out so PRs only flow through
201
+ * pullGithubPrs. Folds each issue's content (title/body/state). Numbers share the
202
+ * per-repo PR/issue space (real GitHub).
203
+ */
204
+ export async function pullGithubIssues(
205
+ execute: GithubExecute,
206
+ opts: { owner: string; repo: string; state?: 'open' | 'closed' | 'all'; perPage?: number },
207
+ ): Promise<ObservedIssue[]> {
208
+ const { owner, repo } = opts;
209
+ const repository = `${owner}/${repo}`;
210
+ const list = await execute.request('GET /repos/{owner}/{repo}/issues', {
211
+ owner, repo, state: opts.state ?? 'open', per_page: opts.perPage ?? 30,
212
+ });
213
+ if (list.status >= 400) throw new Error(`github pull failed (list issues): HTTP ${list.status}`);
214
+ const nodes: any[] = Array.isArray(list.data) ? list.data : [];
215
+ const out: ObservedIssue[] = [];
216
+ for (const n of nodes) {
217
+ // The issues endpoint includes PRs (they carry a `pull_request` object) — exclude them.
218
+ if (n.pull_request !== undefined && n.pull_request !== null) continue;
219
+ const number = Number(n.number);
220
+ const iss: ObservedIssue = { id: `${repository}#issue:${number}`, number, repository };
221
+ if (n.title !== undefined && n.title !== null) iss.title = String(n.title);
222
+ if (n.body !== undefined && n.body !== null) iss.body = String(n.body);
223
+ if (n.state !== undefined && n.state !== null) iss.state = String(n.state);
224
+ out.push(iss);
225
+ }
226
+ return out;
227
+ }
228
+
229
+ // Map observed PRs to the generic SyncResource[] shape (the Linear/Slack-shared pull
230
+ // contract). Carries CONTENT (title/body/state) now that pull is a content mirror, plus
231
+ // the refs. baseRef/headSha ride under their twin names so the github fold (which reads
232
+ // data.changed.baseRef.after / .headSha.after) picks them up.
233
+ export function observedPrsToResources(prs: ObservedPr[]): SyncResource[] {
234
+ return prs.map((p) => {
235
+ const fields: SyncResource['fields'] = { number: p.number, repository: p.repository };
236
+ if (p.title !== undefined) fields.title = p.title;
237
+ if (p.body !== undefined) fields.body = p.body;
238
+ if (p.state !== undefined) fields.state = p.state;
239
+ // draft + merge state ride along so the generic delta connector (which diffs these
240
+ // SyncResources) captures a draft→ready or open→merged transition — and the github
241
+ // fold reads f.draft / f.merged / f.mergeCommit back onto the materialized PR.
242
+ if (p.draft !== undefined) fields.draft = p.draft;
243
+ if (p.merged !== undefined) fields.merged = p.merged;
244
+ if (p.mergeCommit !== undefined) fields.mergeCommit = p.mergeCommit;
245
+ if (p.baseRef !== undefined) fields.baseRef = p.baseRef;
246
+ if (p.headSha !== undefined) fields.headSha = p.headSha;
247
+ return { type: 'pull_request', id: p.id, fields };
248
+ });
249
+ }
250
+
251
+ // Map observed issues to SyncResource[] (content-bearing: title/body/state).
252
+ export function observedIssuesToResources(issues: ObservedIssue[]): SyncResource[] {
253
+ return issues.map((i) => {
254
+ const fields: SyncResource['fields'] = { number: i.number, repository: i.repository };
255
+ if (i.title !== undefined) fields.title = i.title;
256
+ if (i.body !== undefined) fields.body = i.body;
257
+ if (i.state !== undefined) fields.state = i.state;
258
+ return { type: 'issue', id: i.id, fields };
259
+ });
260
+ }
261
+
262
+ // A content-hashed, deterministic id for a folded evidence snapshot — never random,
263
+ // never Date.now. Re-folding the SAME observed evidence yields the same id, so
264
+ // appendEvent dedups it (idempotent pull). A changed observation yields a new id.
265
+ function evidenceHash(parts: unknown[]): string {
266
+ // Reuse the kernel's hashing via a stable JSON of the parts; createHash isn't
267
+ // re-exported, so use a small FNV-1a over the canonical string (deterministic).
268
+ const s = JSON.stringify(parts);
269
+ let h = 0x811c9dc5;
270
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
271
+ return (h >>> 0).toString(16).padStart(8, '0');
272
+ }
273
+
274
+ // Fold ONE observed PR as a `github.pull_request` event — the exact shape githubState()
275
+ // reads (content + metadata top-level + base/head under `changed`). This carries the
276
+ // COUNTS the generic delta path can't (githubState reads changedFiles/commitsCount
277
+ // top-level) and the CONTENT (title/body/state) the pull now mirrors. Idempotent via a
278
+ // content hash in the event id + idempotencyKey: a re-pull of identical observation
279
+ // appends nothing; a changed observation appends a new event.
280
+ function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): boolean {
281
+ const data: Record<string, unknown> = {
282
+ number: pr.number,
283
+ repository: pr.repository,
284
+ changed: {
285
+ ...(pr.baseRef !== undefined ? { baseRef: { after: pr.baseRef } } : {}),
286
+ ...(pr.headSha !== undefined ? { headSha: { after: pr.headSha } } : {}),
287
+ },
288
+ };
289
+ if (pr.title !== undefined) data.title = pr.title;
290
+ if (pr.body !== undefined) data.body = pr.body;
291
+ if (pr.state !== undefined) data.state = pr.state;
292
+ if (pr.draft !== undefined) data.draft = pr.draft;
293
+ if (pr.merged !== undefined) data.merged = pr.merged;
294
+ if (pr.mergeCommit !== undefined) data.mergeCommit = pr.mergeCommit;
295
+ if (pr.changedFiles !== undefined) data.changedFiles = pr.changedFiles;
296
+ if (pr.commitsCount !== undefined) data.commitsCount = pr.commitsCount;
297
+ // draft/merged/mergeCommit are in the hash so a draft→ready or open→merged transition
298
+ // re-folds (a new event) rather than being deduped away as an unchanged observation.
299
+ const hash = evidenceHash([pr.number, pr.repository, pr.title, pr.body, pr.state, pr.draft, pr.merged, pr.mergeCommit, pr.baseRef, pr.headSha, pr.changedFiles, pr.commitsCount]);
300
+ const id = `github:pull_request:${pr.id}:${hash}`;
301
+ const event = {
302
+ id,
303
+ service: SERVICE,
304
+ type: 'github.pull_request',
305
+ schemaVersion: 1,
306
+ idempotencyKey: id,
307
+ occurredAt,
308
+ observedAt: occurredAt,
309
+ origin: 'connector',
310
+ subject: { type: 'pull_request', id: pr.id },
311
+ data,
312
+ } as unknown as WorldServiceEvent;
313
+ return appendEvent(event, root).appended;
314
+ }
315
+
316
+ // Fold each review/comment as its own event, now CARRYING CONTENT (review state/body,
317
+ // comment body) — githubState() reads these to derive review_count/comment_count AND to
318
+ // surface the real text. The event id hashes the content so a changed body re-folds and
319
+ // an unchanged one is idempotent (a deterministic id per (pr, kind, index, content)).
320
+ function foldObservedExistence(pr: ObservedPr, occurredAt: string, root?: string): number {
321
+ let appended = 0;
322
+ const emit = (type: string, key: string, extra: Record<string, unknown>): void => {
323
+ const hash = evidenceHash([type, key, extra]);
324
+ const id = `github:${type}:${pr.id}:${key}:${hash}`;
325
+ const event = {
326
+ id,
327
+ service: SERVICE,
328
+ type: `github.${type}`,
329
+ schemaVersion: 1,
330
+ idempotencyKey: id,
331
+ occurredAt,
332
+ observedAt: occurredAt,
333
+ origin: 'connector',
334
+ subject: { type, id: `${pr.id}:${key}` },
335
+ data: { repository: pr.repository, number: pr.number, ...extra },
336
+ } as unknown as WorldServiceEvent;
337
+ if (appendEvent(event, root).appended) appended += 1;
338
+ };
339
+ pr.reviews.forEach((r, i) => emit('pull_request_review', `rev${i}`, {
340
+ ...(r.state !== undefined ? { state: r.state } : {}),
341
+ ...(r.body !== undefined ? { body: r.body } : {}),
342
+ ...(r.submittedAt !== undefined ? { submitted_at: r.submittedAt } : {}),
343
+ }));
344
+ pr.comments.forEach((c, i) => emit('issue_comment', `c${i}`, {
345
+ ...(c.body !== undefined ? { body: c.body } : {}),
346
+ ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
347
+ }));
348
+ return appended;
349
+ }
350
+
351
+ // Fold ONE observed issue as a `github.issue` event carrying its content (title/body/
352
+ // state). githubState()'s observed fold reads these into a first-class issue. Idempotent
353
+ // via a content hash in the id: a re-pull of identical content appends nothing.
354
+ function foldObservedIssue(iss: ObservedIssue, occurredAt: string, root?: string): boolean {
355
+ const data: Record<string, unknown> = { number: iss.number, repository: iss.repository };
356
+ if (iss.title !== undefined) data.title = iss.title;
357
+ if (iss.body !== undefined) data.body = iss.body;
358
+ if (iss.state !== undefined) data.state = iss.state;
359
+ const hash = evidenceHash([iss.number, iss.repository, iss.title, iss.body, iss.state]);
360
+ const id = `github:issue:${iss.id}:${hash}`;
361
+ const event = {
362
+ id,
363
+ service: SERVICE,
364
+ type: 'github.issue',
365
+ schemaVersion: 1,
366
+ idempotencyKey: id,
367
+ occurredAt,
368
+ observedAt: occurredAt,
369
+ origin: 'connector',
370
+ subject: { type: 'issue', id: iss.id },
371
+ data,
372
+ } as unknown as WorldServiceEvent;
373
+ return appendEvent(event, root).appended;
374
+ }
375
+
376
+ /**
377
+ * PULL + FOLD: pull a repo's OBSERVED PRs AND issues and fold them into the twin (mirror
378
+ * seeding). Folds CONTENT (PR/issue title/body/state, review state/body, comment body)
379
+ * plus metadata (counts/refs + review/comment existence) — only the Non-goal (repo file
380
+ * CONTENTS) is excluded. Also threads the same resources through `syncPull` so the generic
381
+ * shadow-diff dedup path is exercised (the Linear/Slack-shared contract); the github fold
382
+ * carries the counts/content the generic delta path drops. PRs and issues share ONE
383
+ * per-repo number space (real GitHub). Re-pulling identical observations appends nothing.
384
+ */
385
+ export async function syncGithubFromReal(
386
+ execute: GithubExecute,
387
+ opts: { owner: string; repo: string; root?: string; occurredAt: string; state?: 'open' | 'closed' | 'all'; perPage?: number },
388
+ ): Promise<{ observed: number; deltasAppended: number; eventsAppended: number; issues: number }> {
389
+ const pullOpts = {
390
+ owner: opts.owner,
391
+ repo: opts.repo,
392
+ ...(opts.state ? { state: opts.state } : {}),
393
+ ...(opts.perPage ? { perPage: opts.perPage } : {}),
394
+ };
395
+ const prs = await pullGithubPrs(execute, pullOpts);
396
+ const issues = await pullGithubIssues(execute, pullOpts);
397
+ // Generic shadow-diff fold (content-bearing resources: title/body/state + refs).
398
+ const pull = syncPull({
399
+ service: SERVICE,
400
+ resources: [...observedPrsToResources(prs), ...observedIssuesToResources(issues)],
401
+ occurredAt: opts.occurredAt,
402
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
403
+ });
404
+ // GitHub fold (carries counts + review/comment content the delta drops, plus issues).
405
+ let eventsAppended = 0;
406
+ for (const pr of prs) {
407
+ if (foldObservedPr(pr, opts.occurredAt, opts.root)) eventsAppended += 1;
408
+ eventsAppended += foldObservedExistence(pr, opts.occurredAt, opts.root);
409
+ }
410
+ for (const iss of issues) {
411
+ if (foldObservedIssue(iss, opts.occurredAt, opts.root)) eventsAppended += 1;
412
+ }
413
+ return { observed: pull.observed, deltasAppended: pull.deltasAppended, eventsAppended, issues: issues.length };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // PUSH (twin → real): enact pending LOCAL writes, then confirm.
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /**
421
+ * Push ONE pending GitHub action to the real vendor via the injected executor. Maps
422
+ * the twin's local write (recorded by applyGithubWrite as an action with an
423
+ * `operation` + `fields`) to the matching REST call. The injected `execute.request`
424
+ * is the SOLE credentialed boundary. Returns the real external id when the API gives
425
+ * one (PR/issue/milestone number, review/comment/status/check id, merge sha). Throws
426
+ * on a non-2xx so a failure is never silent.
427
+ *
428
+ * Covers EVERY write operation applyGithubWrite emits, each faithfully mapped to its
429
+ * GitHub REST call (method/path/body):
430
+ * pull_request.create POST .../pulls
431
+ * pull_request.update PATCH .../pulls/:n (+ PATCH .../issues/:n
432
+ * for label/assignee/milestone fields)
433
+ * pull_request.merge PUT .../pulls/:n/merge
434
+ * pull_request.request_reviewers POST .../pulls/:n/requested_reviewers
435
+ * pull_request.remove_requested_reviewers DELETE .../pulls/:n/requested_reviewers
436
+ * pull_request_review.submit POST .../pulls/:n/reviews
437
+ * pull_request_review_comment.create POST .../pulls/:n/comments
438
+ * issue.create POST .../issues
439
+ * issue.update PATCH .../issues/:n
440
+ * issue_comment.create POST .../issues/:n/comments
441
+ * commit_status.create POST .../statuses/:sha
442
+ * check_run.create POST .../check-runs
443
+ * milestone.create POST .../milestones
444
+ * milestone.update PATCH .../milestones/:n
445
+ * Pushing sends the LOCAL content (titles/bodies/diffs the fork authored) — that is
446
+ * legitimate (only PULL must not fabricate content). Unknown ops FAIL LOUDLY (throw).
447
+ */
448
+ export async function pushGithubAction(
449
+ execute: GithubExecute,
450
+ action: { operation?: string; subject: { type: string; id: string }; fields?: Record<string, unknown> },
451
+ ): Promise<{ externalId: string }> {
452
+ const f = action.fields ?? {};
453
+ const repository = String(f.repository ?? action.subject.id.split('#')[0]);
454
+ const [owner, repo] = repository.split('/');
455
+ const op = action.operation ?? '';
456
+
457
+ const ensureOk = (res: { status: number }, label: string): void => {
458
+ if (res.status >= 400) throw new Error(`github push failed (${label}): HTTP ${res.status}`);
459
+ };
460
+
461
+ if (op === 'pull_request.create') {
462
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls', {
463
+ owner, repo, title: f.title, body: f.body, head: f.head_sha, base: f.base_ref,
464
+ });
465
+ ensureOk(res, 'pulls.create');
466
+ return { externalId: String(res.data?.number ?? '') };
467
+ }
468
+ if (op === 'pull_request.update') {
469
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
470
+ const params: Record<string, unknown> = { owner, repo, pull_number: number };
471
+ if (f.title !== undefined) params.title = f.title;
472
+ if (f.body !== undefined) params.body = f.body;
473
+ if (f.base_ref !== undefined) params.base = f.base_ref;
474
+ if (f.state !== undefined) params.state = f.state;
475
+ // labels/assignees/milestone live on the issue resource, but PATCH .../pulls/:n is the
476
+ // endpoint applyGithubWrite routes a PR edit through; GitHub accepts these on the issue
477
+ // PATCH. The PR PATCH itself ignores labels/assignees/milestone, so route those on the
478
+ // matching issue PATCH (PRs ARE issues for that endpoint) to faithfully persist them.
479
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', params);
480
+ ensureOk(res, 'pulls.update');
481
+ if (f.labels !== undefined || f.assignees !== undefined || f.milestone !== undefined) {
482
+ const issueParams: Record<string, unknown> = { owner, repo, issue_number: number };
483
+ if (f.labels !== undefined) issueParams.labels = f.labels;
484
+ if (f.assignees !== undefined) issueParams.assignees = f.assignees;
485
+ if (f.milestone !== undefined) issueParams.milestone = f.milestone;
486
+ const issueRes = await execute.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', issueParams);
487
+ ensureOk(issueRes, 'pulls.update.issue_fields');
488
+ }
489
+ return { externalId: String(res.data?.number ?? number) };
490
+ }
491
+ if (op === 'pull_request.merge') {
492
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
493
+ const params: Record<string, unknown> = { owner, repo, pull_number: number };
494
+ // The twin merges with the default (merge) method; pass through any supplied detail.
495
+ if (f.commit_title !== undefined) params.commit_title = f.commit_title;
496
+ if (f.commit_message !== undefined) params.commit_message = f.commit_message;
497
+ if (f.merge_method !== undefined) params.merge_method = f.merge_method;
498
+ if (f.head_sha !== undefined) params.sha = f.head_sha;
499
+ const res = await execute.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', params);
500
+ ensureOk(res, 'pulls.merge');
501
+ // The merge endpoint returns the merge commit sha (not the PR number).
502
+ return { externalId: String(res.data?.sha ?? f.merge_commit_sha ?? '') };
503
+ }
504
+ if (op === 'pull_request.request_reviewers') {
505
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
506
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers', {
507
+ owner, repo, pull_number: number, reviewers: f.requested_reviewers ?? [],
508
+ });
509
+ ensureOk(res, 'pulls.request_reviewers');
510
+ return { externalId: String(res.data?.number ?? number) };
511
+ }
512
+ if (op === 'pull_request.remove_requested_reviewers') {
513
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
514
+ const res = await execute.request('DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers', {
515
+ owner, repo, pull_number: number, reviewers: f.requested_reviewers ?? [],
516
+ });
517
+ ensureOk(res, 'pulls.remove_requested_reviewers');
518
+ return { externalId: String(res.data?.number ?? number) };
519
+ }
520
+ if (op === 'pull_request_review.submit') {
521
+ const number = Number(f.number);
522
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
523
+ owner, repo, pull_number: number, event: f.state, body: f.body,
524
+ });
525
+ ensureOk(res, 'reviews.submit');
526
+ return { externalId: String(res.data?.id ?? '') };
527
+ }
528
+ if (op === 'pull_request_review_comment.create') {
529
+ const number = Number(f.number);
530
+ const params: Record<string, unknown> = {
531
+ owner, repo, pull_number: number, body: f.body,
532
+ };
533
+ // Diff-anchoring fields ride along when the local write set them (the real REST API
534
+ // needs path + line/side + commit_id to anchor a review comment to the diff).
535
+ if (f.path !== undefined) params.path = f.path;
536
+ if (f.line !== undefined) params.line = f.line;
537
+ if (f.side !== undefined) params.side = f.side;
538
+ if (f.start_line !== undefined) params.start_line = f.start_line;
539
+ if (f.head_sha !== undefined) params.commit_id = f.head_sha;
540
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/comments', params);
541
+ ensureOk(res, 'pulls.review_comment.create');
542
+ return { externalId: String(res.data?.id ?? '') };
543
+ }
544
+ if (op === 'issue.create') {
545
+ const res = await execute.request('POST /repos/{owner}/{repo}/issues', {
546
+ owner, repo, title: f.title, body: f.body,
547
+ });
548
+ ensureOk(res, 'issues.create');
549
+ return { externalId: String(res.data?.number ?? '') };
550
+ }
551
+ if (op === 'issue.update') {
552
+ const number = Number(f.number ?? action.subject.id.split('#issue:').pop());
553
+ const params: Record<string, unknown> = { owner, repo, issue_number: number };
554
+ if (f.title !== undefined) params.title = f.title;
555
+ if (f.body !== undefined) params.body = f.body;
556
+ if (f.state !== undefined) params.state = f.state;
557
+ if (f.labels !== undefined) params.labels = f.labels;
558
+ if (f.assignees !== undefined) params.assignees = f.assignees;
559
+ if (f.milestone !== undefined) params.milestone = f.milestone;
560
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', params);
561
+ ensureOk(res, 'issues.update');
562
+ return { externalId: String(res.data?.number ?? number) };
563
+ }
564
+ if (op === 'issue_comment.create') {
565
+ const number = Number(f.number);
566
+ const res = await execute.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
567
+ owner, repo, issue_number: number, body: f.body,
568
+ });
569
+ ensureOk(res, 'issue_comment.create');
570
+ return { externalId: String(res.data?.id ?? '') };
571
+ }
572
+ if (op === 'commit_status.create') {
573
+ const sha = String(f.sha ?? '');
574
+ const params: Record<string, unknown> = { owner, repo, sha, state: f.state };
575
+ if (f.context !== undefined) params.context = f.context;
576
+ if (f.description !== undefined) params.description = f.description;
577
+ if (f.target_url !== undefined) params.target_url = f.target_url;
578
+ const res = await execute.request('POST /repos/{owner}/{repo}/statuses/{sha}', params);
579
+ ensureOk(res, 'statuses.create');
580
+ return { externalId: String(res.data?.id ?? '') };
581
+ }
582
+ if (op === 'check_run.create') {
583
+ const params: Record<string, unknown> = { owner, repo, name: f.name, head_sha: f.head_sha };
584
+ if (f.status !== undefined) params.status = f.status;
585
+ if (f.conclusion !== undefined) params.conclusion = f.conclusion;
586
+ if (f.details_url !== undefined) params.details_url = f.details_url;
587
+ if (f.started_at !== undefined) params.started_at = f.started_at;
588
+ if (f.completed_at !== undefined) params.completed_at = f.completed_at;
589
+ const res = await execute.request('POST /repos/{owner}/{repo}/check-runs', params);
590
+ ensureOk(res, 'check_runs.create');
591
+ return { externalId: String(res.data?.id ?? '') };
592
+ }
593
+ if (op === 'milestone.create') {
594
+ const params: Record<string, unknown> = { owner, repo, title: f.title };
595
+ if (f.description !== undefined) params.description = f.description;
596
+ if (f.state !== undefined) params.state = f.state;
597
+ if (f.due_on !== undefined) params.due_on = f.due_on;
598
+ const res = await execute.request('POST /repos/{owner}/{repo}/milestones', params);
599
+ ensureOk(res, 'milestones.create');
600
+ return { externalId: String(res.data?.number ?? '') };
601
+ }
602
+ if (op === 'milestone.update') {
603
+ const number = Number(f.number ?? action.subject.id.split('#milestone:').pop());
604
+ const params: Record<string, unknown> = { owner, repo, milestone_number: number };
605
+ if (f.title !== undefined) params.title = f.title;
606
+ if (f.description !== undefined) params.description = f.description;
607
+ if (f.state !== undefined) params.state = f.state;
608
+ if (f.due_on !== undefined) params.due_on = f.due_on;
609
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/milestones/{milestone_number}', params);
610
+ ensureOk(res, 'milestones.update');
611
+ return { externalId: String(res.data?.number ?? number) };
612
+ }
613
+ throw new Error(`github push: unsupported operation "${op}" for subject ${action.subject.type}`);
614
+ }
615
+
616
+ /**
617
+ * PUSH (all): push every pending LOCAL GitHub action to real GitHub via the injected
618
+ * executor and CONFIRM each on success — `confirmAction` records the confirmed fields
619
+ * as an observed event and maps the action → that event, SUPPRESSING the local
620
+ * projection (the fact now lives in the observed log; counted exactly once). Closes
621
+ * the action → push → observed loop (R18). Idempotency: a confirmed action is no
622
+ * longer pending, so a re-push calls the executor for it again ZERO times.
623
+ */
624
+ export async function pushPendingGithubActions(
625
+ execute: GithubExecute,
626
+ opts: { root?: string; occurredAt: string },
627
+ ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
628
+ const confirmed: string[] = [];
629
+ const externalIds: Record<string, string> = {};
630
+ for (const action of pendingActions(SERVICE, opts.root)) {
631
+ const { externalId } = await pushGithubAction(execute, action);
632
+ confirmAction({
633
+ service: SERVICE,
634
+ actionId: action.id,
635
+ subject: action.subject,
636
+ fields: action.fields ?? {},
637
+ occurredAt: opts.occurredAt,
638
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
639
+ });
640
+ confirmed.push(action.id);
641
+ externalIds[action.id] = externalId;
642
+ }
643
+ return { pushed: confirmed.length, confirmed, externalIds };
644
+ }