@volter/twin-github 0.1.2 → 2.0.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.
Files changed (215) hide show
  1. package/README.md +88 -36
  2. package/client/github-mirror.css +277 -319
  3. package/client/github-mirror.d.ts +418 -0
  4. package/client/github-mirror.js +485 -0
  5. package/client/github-mirror.tsx +153 -357
  6. package/client/pulls-rest.ts +159 -0
  7. package/client/pulls-workspace.tsx +841 -0
  8. package/dist/client/github-mirror.bundle.js +239 -0
  9. package/dist/client/github-mirror.css +916 -0
  10. package/dist/client/github-mirror.d.ts +418 -0
  11. package/dist/client/github-mirror.js +485 -0
  12. package/dist/client/github-mirror.tsx +1315 -0
  13. package/dist/client/pulls-rest.d.ts +42 -0
  14. package/dist/client/pulls-rest.js +140 -0
  15. package/dist/client/pulls-rest.ts +159 -0
  16. package/dist/client/pulls-workspace.bundle.js +22 -0
  17. package/dist/client/pulls-workspace.d.ts +114 -0
  18. package/dist/client/pulls-workspace.js +418 -0
  19. package/dist/client/pulls-workspace.tsx +841 -0
  20. package/dist/src/cli.d.ts +2 -0
  21. package/dist/src/cli.js +40 -0
  22. package/dist/src/generated/graphql-sdl.gen.json +1 -0
  23. package/dist/src/generated/graphql.gen.json +1 -0
  24. package/dist/src/generated/surface.gen.json +1 -0
  25. package/dist/src/generated/ui.gen.json +1 -0
  26. package/dist/src/github-budget.d.ts +69 -0
  27. package/dist/src/github-budget.js +172 -0
  28. package/dist/src/github-capabilities.d.ts +5 -0
  29. package/dist/src/github-capabilities.js +4468 -0
  30. package/dist/src/github-conformance.d.ts +43 -0
  31. package/dist/src/github-conformance.js +76 -0
  32. package/dist/src/github-connector.d.ts +307 -0
  33. package/dist/src/github-connector.js +1398 -0
  34. package/dist/src/github-events.d.ts +41 -0
  35. package/dist/src/github-events.js +230 -0
  36. package/dist/src/github-git-http.d.ts +49 -0
  37. package/dist/src/github-git-http.js +185 -0
  38. package/dist/src/github-git-plane.d.ts +114 -0
  39. package/dist/src/github-git-plane.js +407 -0
  40. package/dist/src/github-mirror-state.d.ts +2 -0
  41. package/dist/src/github-mirror-state.js +335 -0
  42. package/dist/src/github-mirror-ui.d.ts +20 -0
  43. package/dist/src/github-mirror-ui.js +101 -0
  44. package/dist/src/github-server.d.ts +14 -0
  45. package/dist/src/github-server.js +240 -0
  46. package/dist/src/github-shared.d.ts +12 -0
  47. package/dist/src/github-shared.js +21 -0
  48. package/dist/src/github-twin.d.ts +1411 -0
  49. package/dist/src/github-twin.js +4084 -0
  50. package/dist/src/github-ui-conformance.d.ts +4 -0
  51. package/dist/src/github-ui-conformance.js +105 -0
  52. package/dist/src/github-ui-structure.d.ts +18 -0
  53. package/dist/src/github-ui-structure.js +251 -0
  54. package/dist/src/graphql-wire.d.ts +22 -0
  55. package/dist/src/graphql-wire.js +89 -0
  56. package/dist/src/index.d.ts +17 -0
  57. package/dist/src/index.js +101 -0
  58. package/dist/src/manifest.d.ts +8 -0
  59. package/dist/src/manifest.js +597 -0
  60. package/dist/src/npm-registry.d.ts +7 -0
  61. package/dist/src/npm-registry.js +47 -0
  62. package/dist/src/screens/app-installation.d.ts +3 -0
  63. package/dist/src/screens/app-installation.js +166 -0
  64. package/dist/src/screens/app-manifest.d.ts +3 -0
  65. package/dist/src/screens/app-manifest.js +81 -0
  66. package/dist/src/screens/oauth.d.ts +15 -0
  67. package/dist/src/screens/oauth.js +257 -0
  68. package/dist/src/screens/session.d.ts +8 -0
  69. package/dist/src/screens/session.js +159 -0
  70. package/dist/src/semantics/actions.d.ts +2 -0
  71. package/dist/src/semantics/actions.js +413 -0
  72. package/dist/src/semantics/activity.d.ts +4 -0
  73. package/dist/src/semantics/activity.js +161 -0
  74. package/dist/src/semantics/apps.d.ts +2 -0
  75. package/dist/src/semantics/apps.js +144 -0
  76. package/dist/src/semantics/branches.d.ts +2 -0
  77. package/dist/src/semantics/branches.js +136 -0
  78. package/dist/src/semantics/checks.d.ts +2 -0
  79. package/dist/src/semantics/checks.js +176 -0
  80. package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
  81. package/dist/src/semantics/code-scanning-upload.js +97 -0
  82. package/dist/src/semantics/codespaces.d.ts +2 -0
  83. package/dist/src/semantics/codespaces.js +58 -0
  84. package/dist/src/semantics/commits.d.ts +2 -0
  85. package/dist/src/semantics/commits.js +109 -0
  86. package/dist/src/semantics/contents.d.ts +2 -0
  87. package/dist/src/semantics/contents.js +131 -0
  88. package/dist/src/semantics/deployments.d.ts +2 -0
  89. package/dist/src/semantics/deployments.js +127 -0
  90. package/dist/src/semantics/gists.d.ts +2 -0
  91. package/dist/src/semantics/gists.js +86 -0
  92. package/dist/src/semantics/git.d.ts +2 -0
  93. package/dist/src/semantics/git.js +235 -0
  94. package/dist/src/semantics/graphql.d.ts +7 -0
  95. package/dist/src/semantics/graphql.js +512 -0
  96. package/dist/src/semantics/index.d.ts +5 -0
  97. package/dist/src/semantics/index.js +62 -0
  98. package/dist/src/semantics/issues.d.ts +2 -0
  99. package/dist/src/semantics/issues.js +456 -0
  100. package/dist/src/semantics/keys.d.ts +2 -0
  101. package/dist/src/semantics/keys.js +66 -0
  102. package/dist/src/semantics/labels.d.ts +2 -0
  103. package/dist/src/semantics/labels.js +100 -0
  104. package/dist/src/semantics/meta.d.ts +12 -0
  105. package/dist/src/semantics/meta.js +144 -0
  106. package/dist/src/semantics/notifications.d.ts +4 -0
  107. package/dist/src/semantics/notifications.js +62 -0
  108. package/dist/src/semantics/orgs.d.ts +2 -0
  109. package/dist/src/semantics/orgs.js +420 -0
  110. package/dist/src/semantics/packages.d.ts +2 -0
  111. package/dist/src/semantics/packages.js +55 -0
  112. package/dist/src/semantics/pages.d.ts +2 -0
  113. package/dist/src/semantics/pages.js +176 -0
  114. package/dist/src/semantics/projects.d.ts +2 -0
  115. package/dist/src/semantics/projects.js +239 -0
  116. package/dist/src/semantics/pulls.d.ts +2 -0
  117. package/dist/src/semantics/pulls.js +462 -0
  118. package/dist/src/semantics/push-reactions.d.ts +101 -0
  119. package/dist/src/semantics/push-reactions.js +513 -0
  120. package/dist/src/semantics/releases.d.ts +19 -0
  121. package/dist/src/semantics/releases.js +231 -0
  122. package/dist/src/semantics/repo-invitations.d.ts +2 -0
  123. package/dist/src/semantics/repo-invitations.js +98 -0
  124. package/dist/src/semantics/repos.d.ts +17 -0
  125. package/dist/src/semantics/repos.js +390 -0
  126. package/dist/src/semantics/rulesets.d.ts +2 -0
  127. package/dist/src/semantics/rulesets.js +111 -0
  128. package/dist/src/semantics/search.d.ts +2 -0
  129. package/dist/src/semantics/search.js +84 -0
  130. package/dist/src/semantics/security.d.ts +2 -0
  131. package/dist/src/semantics/security.js +177 -0
  132. package/dist/src/semantics/shared.d.ts +61 -0
  133. package/dist/src/semantics/shared.js +151 -0
  134. package/dist/src/semantics/users.d.ts +2 -0
  135. package/dist/src/semantics/users.js +255 -0
  136. package/dist/src/semantics/webhooks.d.ts +2 -0
  137. package/dist/src/semantics/webhooks.js +107 -0
  138. package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
  139. package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
  140. package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
  141. package/dist/test-fixtures/github-comment-schema.json +702 -0
  142. package/dist/test-fixtures/github-known-deviations.json +94 -0
  143. package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
  144. package/dist/test-fixtures/github-openapi-operations.json +362 -0
  145. package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
  146. package/dist/test-fixtures/github-pull-schema.json +3601 -0
  147. package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
  148. package/dist/test-fixtures/github-review-schema.json +236 -0
  149. package/package.json +20 -11
  150. package/src/cli.ts +7 -6
  151. package/src/generated/graphql-sdl.gen.json +1 -0
  152. package/src/generated/graphql.gen.json +1 -0
  153. package/src/generated/surface.gen.json +1 -0
  154. package/src/generated/ui.gen.json +1 -0
  155. package/src/github-a11y-snapshot.uitest.ts +5 -5
  156. package/src/github-budget.ts +4 -4
  157. package/src/github-capabilities.ts +1993 -556
  158. package/src/github-conformance.ts +12 -7
  159. package/src/github-connector.ts +108 -96
  160. package/src/github-events.ts +223 -95
  161. package/src/github-git-http.ts +47 -83
  162. package/src/github-git-plane.ts +247 -385
  163. package/src/github-journey.uitest.ts +28 -44
  164. package/src/github-mirror-state.ts +23 -61
  165. package/src/github-mirror-ui.ts +16 -10
  166. package/src/github-server.ts +158 -60
  167. package/src/github-shared.ts +1 -1
  168. package/src/github-twin.ts +1525 -4494
  169. package/src/github-ui-conformance.ts +7 -8
  170. package/src/github-ui-structure.ts +19 -18
  171. package/src/graphql-wire.ts +115 -0
  172. package/src/index.ts +47 -7
  173. package/src/manifest.ts +605 -0
  174. package/src/npm-registry.ts +43 -0
  175. package/src/screens/app-installation.tsx +217 -0
  176. package/src/screens/app-manifest.tsx +97 -0
  177. package/src/screens/oauth.tsx +258 -0
  178. package/src/screens/session.tsx +186 -0
  179. package/src/semantics/actions.ts +395 -0
  180. package/src/semantics/activity.ts +171 -0
  181. package/src/semantics/apps.ts +133 -0
  182. package/src/semantics/branches.ts +133 -0
  183. package/src/semantics/checks.ts +163 -0
  184. package/src/semantics/code-scanning-upload.ts +92 -0
  185. package/src/semantics/codespaces.ts +58 -0
  186. package/src/semantics/commits.ts +109 -0
  187. package/src/semantics/contents.ts +112 -0
  188. package/src/semantics/deployments.ts +116 -0
  189. package/src/semantics/gists.ts +85 -0
  190. package/src/semantics/git.ts +226 -0
  191. package/src/semantics/graphql.ts +505 -0
  192. package/src/semantics/index.ts +68 -0
  193. package/src/semantics/issues.ts +434 -0
  194. package/src/semantics/keys.ts +66 -0
  195. package/src/semantics/labels.ts +91 -0
  196. package/src/semantics/meta.ts +139 -0
  197. package/src/semantics/notifications.ts +58 -0
  198. package/src/semantics/orgs.ts +383 -0
  199. package/src/semantics/packages.ts +59 -0
  200. package/src/semantics/pages.ts +154 -0
  201. package/src/semantics/projects.ts +246 -0
  202. package/src/semantics/pulls.ts +421 -0
  203. package/src/semantics/push-reactions.ts +471 -0
  204. package/src/semantics/releases.ts +213 -0
  205. package/src/semantics/repo-invitations.ts +112 -0
  206. package/src/semantics/repos.ts +373 -0
  207. package/src/semantics/rulesets.ts +107 -0
  208. package/src/semantics/search.ts +83 -0
  209. package/src/semantics/security.ts +153 -0
  210. package/src/semantics/shared.ts +181 -0
  211. package/src/semantics/users.ts +252 -0
  212. package/src/semantics/webhooks.ts +101 -0
  213. package/test-fixtures/github-known-deviations.json +7 -8
  214. package/test-fixtures/github-openapi-operations.json +46 -227
  215. package/src/github-graphql.ts +0 -398
@@ -1,105 +1,233 @@
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 }; ref?: string; before?: string; after?: string; created?: boolean; deleted?: boolean };
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 */ }
1
+ // Native repository hooks and delivery history live in the World's existing state.
2
+ // Callback HTTP is the caller's webhook sink, never a credentialed GitHub API call.
3
+ import { createHash, createHmac } from 'node:crypto';
4
+ import { applyTwinWrite, applyTwinWriteAtomic, ownFields, twinResources, worldNow } from '@volter/world-core';
5
+ import { GITHUB_WORLD_USER, githubApps, githubState, handleGithubRequest, toGithubWebhook } from './github-twin.ts';
6
+ import type { GithubWebhook as Hook, GithubWriteEvent } from './github-twin.ts';
7
+ import { worldEgressRefusal } from '@volter/world-core/network-policy';
8
+
9
+ export type GithubWebhook = Record<string, unknown>;
10
+ export type GithubWebhookResponse = { status: number; headers?: Record<string, string>; body?: string };
11
+ export type GithubWebhookDelivery = (url: string, event: string, payload: GithubWebhook, headers: Record<string, string>, body: string) => Promise<GithubWebhookResponse> | GithubWebhookResponse;
12
+ type Options = { root?: string; occurredAt?: string; deliver?: GithubWebhookDelivery };
13
+ type Delivery = {
14
+ id: number; guid: string; repository: string; hookId: number; event: string; action?: string;
15
+ url: string; payloadJson: string; requestBody: string; requestHeaders: Record<string, string>;
16
+ deliveredAt: string; redelivery: boolean; statusCode: number | null; error?: string;
17
+ responseHeaders?: Record<string, string>; responseBody?: string;
44
18
  };
19
+ const TYPE = 'webhook_delivery';
45
20
 
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
- // 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
- : {}),
66
- };
21
+ export function signGithubPayload(secret: string, body: string): string {
22
+ return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
23
+ }
24
+
25
+ const httpDelivery: GithubWebhookDelivery = async (url, _event, _payload, headers, body) => {
26
+ // the World's egress rule: a refused hook URL fails as a transport failure, recorded on the delivery
27
+ const refusal = worldEgressRefusal(url);
28
+ if (refusal !== null) throw new Error(refusal);
29
+ const response = await fetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
30
+ return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
31
+ };
32
+
33
+ async function nativeObject(path: string, root?: string): Promise<Record<string, unknown> | undefined> {
34
+ const response = await handleGithubRequest({ method: 'GET', path, root });
35
+ return response.status === 200 && response.body && !Array.isArray(response.body)
36
+ ? response.body as Record<string, unknown> : undefined;
37
+ }
38
+
39
+ async function buildPayload(write: GithubWriteEvent, root?: string): Promise<GithubWebhook> {
40
+ const payload: GithubWebhook = { action: write.action, repository: { full_name: write.repository }, sender: { login: GITHUB_WORLD_USER, type: 'User' } };
41
+ const path = `/repos/${write.repository}`;
42
+ if (['pull_request', 'pull_request_review', 'pull_request_review_comment'].includes(write.event)) {
43
+ payload.pull_request = write.pr ?? await nativeObject(`${path}/pulls/${write.number}`, root) ?? { number: write.number };
44
+ }
45
+ if (write.event === 'pull_request') payload.number = write.number;
46
+ if (write.event === 'issues' || write.event === 'issue_comment') payload.issue = await issuePayload(write, path, root);
47
+ if (write.event === 'pull_request_review') payload.review = reviewPayload(write);
48
+ if (write.event === 'issue_comment' || write.event === 'pull_request_review_comment') payload.comment = write.comment ?? { body: write.body ?? '' };
49
+ if (write.event === 'discussion' || write.event === 'discussion_comment') payload.number = write.number;
50
+ if (write.event === 'push' && write.ref !== undefined) pushPayload(payload, write);
51
+ return payload;
52
+ }
53
+ /** An issue event's issue: the one the write carried, or as the API reads it. */
54
+ async function issuePayload(write: GithubWriteEvent, path: string, root?: string): Promise<unknown> {
55
+ return write.issue ?? await nativeObject(`${path}/issues/${write.number}`, root) ?? { number: write.number };
56
+ }
57
+ /** A review event's review, its state lower-cased as webhooks send it. */
58
+ function reviewPayload(write: GithubWriteEvent): Record<string, unknown> {
59
+ const review = write.review ?? { body: write.body ?? '' };
60
+ return { ...review, ...(typeof review.state === 'string' ? { state: review.state.toLowerCase() } : {}) };
61
+ }
62
+ /** A push event names its ref and the commits before and after it, and carries no action. */
63
+ function pushPayload(payload: GithubWebhook, write: GithubWriteEvent): void {
64
+ delete payload.action;
65
+ Object.assign(payload, { ref: write.ref, before: write.before ?? '', after: write.after ?? '', created: write.before === '0'.repeat(40), deleted: write.after === '0'.repeat(40) });
66
+ }
67
+
68
+ function records(root?: string): Delivery[] {
69
+ return twinResources('github', root).filter(r => r.type === TYPE).map(r => ownFields(r) as unknown as Delivery);
70
+ }
71
+
72
+ function hookFor(repository: string, id: number, root?: string): Hook | undefined {
73
+ return githubState(root).webhooks.find(h => h.repository === repository && h.id === id);
67
74
  }
68
75
 
69
- function headersFor(event: string, deliveryId: string, payloadJson: string, secret?: string): Record<string, string> {
70
- const headers: Record<string, string> = { 'x-github-event': event, 'x-github-delivery': deliveryId };
71
- if (secret) headers['x-hub-signature-256'] = signGithubPayload(secret, payloadJson);
76
+ function headersFor(hook: Hook, event: string, guid: string, body: string): Record<string, string> {
77
+ const headers: Record<string, string> = {
78
+ 'content-type': hook.content_type !== 'json' ? 'application/x-www-form-urlencoded' : 'application/json',
79
+ 'x-github-event': event, 'x-github-delivery': guid, 'x-github-hook-id': String(hook.id),
80
+ };
81
+ if (hook.secret) headers['x-hub-signature-256'] = signGithubPayload(hook.secret, body);
72
82
  return headers;
73
83
  }
74
84
 
75
- /** Emit a GitHub webhook for a twin write to registered endpoints. No-op when none registered. */
76
- export async function emitGithubEvent(
77
- write: GithubWriteEvent,
78
- opts: { occurredAt?: string; deliver?: GithubWebhookDelivery } = {},
79
- ): Promise<GithubWebhook[]> {
80
- if (registry.length === 0) return [];
81
- const deliver = opts.deliver ?? httpDelivery;
82
- const payload = buildPayload(write);
83
- const payloadJson = JSON.stringify(payload);
84
- const out: GithubWebhook[] = [];
85
- for (const ep of registry) {
86
- const id = `${++deliverySeq}`;
87
- deliveries.push({ id, url: ep.url, event: write.event, action: write.action, payloadJson, deliveredAt: opts.occurredAt, secret: ep.secret });
88
- const headers = headersFor(write.event, id, payloadJson, ep.secret);
89
- await deliver(ep.url, write.event, payload, headers);
90
- out.push(payload);
85
+ async function dispatch(hook: Hook, event: string, payload: GithubWebhook, opts: Options, original?: Delivery): Promise<Delivery> {
86
+ const payloadJson = original?.payloadJson ?? JSON.stringify(payload);
87
+ const requestBody = hook.content_type !== 'json' ? new URLSearchParams({ payload: payloadJson }).toString() : payloadJson;
88
+ const occurredAt = opts.occurredAt ?? worldNow();
89
+ // Allocation and append share the kernel lock; two callbacks cannot claim the same id.
90
+ const { value: pending } = await applyTwinWriteAtomic<Delivery>('github', resources => {
91
+ const id = resources.filter(r => r.type === TYPE).reduce((max, r) => Math.max(max, Number(ownFields(r).id) || 0), 0) + 1;
92
+ // from the World's history alone (never its directory): a replay of the same history delivers the same ids
93
+ const hex = createHash('sha256').update(`${occurredAt}\0${hook.repository}\0${hook.id}\0${id}\0${payloadJson}`).digest('hex');
94
+ const guid = original?.guid ?? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
95
+ const value: Delivery = {
96
+ id, guid, repository: hook.repository, hookId: hook.id, event,
97
+ ...(typeof payload.action === 'string' ? { action: payload.action } : {}),
98
+ url: hook.url!, payloadJson, requestBody, requestHeaders: headersFor(hook, event, guid, requestBody),
99
+ deliveredAt: occurredAt, redelivery: original !== undefined, statusCode: null,
100
+ };
101
+ return { kind: 'write', value, write: { operation: 'webhook_delivery.create', subjectType: TYPE, subjectId: `webhook-delivery:${id}`, fields: value, occurredAt, actor: { kind: 'system' } } };
102
+ }, opts.root);
103
+ let result: Partial<Delivery>;
104
+ try {
105
+ const response = await (opts.deliver ?? httpDelivery)(pending.url, event, JSON.parse(payloadJson), pending.requestHeaders, requestBody);
106
+ result = { statusCode: response.status, responseHeaders: response.headers ?? {}, responseBody: response.body ?? '' };
107
+ } catch {
108
+ // Persist failure without exposing signing keys, URL credentials or a thrown transport stack.
109
+ result = { statusCode: 0, error: 'Webhook transport failed' };
110
+ }
111
+ await applyTwinWrite('github', { operation: 'webhook_delivery.update', subjectType: TYPE, subjectId: `webhook-delivery:${pending.id}`, fields: result, occurredAt, actor: { kind: 'system' } }, opts.root);
112
+ return { ...pending, ...result };
113
+ }
114
+
115
+ /** Current persisted hook settings are resolved per event and World, including after restart. */
116
+ export async function emitGithubEvent(write: GithubWriteEvent, opts: Options = {}): Promise<GithubWebhook[]> {
117
+ const hooks = githubState(opts.root).webhooks.filter(h => h.repository === write.repository && h.active && h.url && (h.events.includes('*') || h.events.includes(write.event)));
118
+ return hooks.length ? deliverToHooks(hooks, write, opts) : [];
119
+ }
120
+ /** Each subscribed repository hook gets the event's payload. */
121
+ async function deliverToHooks(hooks: Hook[], write: GithubWriteEvent, opts: Options): Promise<GithubWebhook[]> {
122
+ const payload = await buildPayload(write, opts.root);
123
+ for (const hook of hooks) await dispatch(hook, write.event, payload, opts);
124
+ return hooks.map(() => payload);
125
+ }
126
+
127
+ /** A repository's event, delivered as well to each GitHub App installed on the repository whose app subscribes to it,
128
+ * with the installation it is for, so the app knows which installation token to mint
129
+ * (docs.github.com/apps/creating-github-apps/registering-a-github-app/using-webhooks-with-github-apps,
130
+ * docs.github.com/webhooks/webhook-events-and-payloads: `installation` is present when the event is for an app's
131
+ * installation). Answers how many apps it was delivered to. */
132
+ export async function emitGithubInstallationEvents(write: GithubWriteEvent, opts: Options = {}): Promise<number> {
133
+ const st = githubState(opts.root);
134
+ const [owner] = write.repository.split('/');
135
+ const covering = st.appInstallations.filter((i) => i.account_login === owner && (i.repository_selection === 'all' || (i.repositories ?? []).includes(write.repository)));
136
+ let delivered = 0;
137
+ for (const installation of covering) {
138
+ const app = githubApps(opts.root).find((a) => a.slug === installation.app_slug);
139
+ if (!app?.hook_url || !app.events.includes(write.event)) continue;
140
+ const payload = { ...(await buildPayload(write, opts.root)), installation: { id: installation.id, node_id: nodeIdOf('Integration::Installation', installation.id) } };
141
+ if (await emitGithubAppEvent(app, write.event, payload, opts)) delivered += 1;
91
142
  }
92
- return out;
93
- }
94
-
95
- /** Redeliver a logged delivery by id — replays the EXACT payload to its endpoint. */
96
- export async function redeliverGithubDelivery(id: string, opts: { deliver?: GithubWebhookDelivery } = {}): Promise<boolean> {
97
- const d = deliveries.find((x) => x.id === id);
98
- if (!d) return false;
99
- const deliver = opts.deliver ?? httpDelivery;
100
- const newId = `${++deliverySeq}`;
101
- deliveries.push({ ...d, id: newId });
102
- const headers = headersFor(d.event, newId, d.payloadJson, d.secret);
103
- await deliver(d.url, d.event, JSON.parse(d.payloadJson) as GithubWebhook, headers);
143
+ return delivered;
144
+ }
145
+ const nodeIdOf = (type: string, id: number): string => Buffer.from(`${String(type.length).padStart(2, '0')}:${type}${id}`).toString('base64');
146
+
147
+ /** An event for a GitHub App's own webhook (installation, …), delivered to the URL its registration names
148
+ * and recorded as any delivery is; an app with no webhook URL gets none, as on GitHub. */
149
+ export async function emitGithubAppEvent(app: { id: number; hook_url?: string; webhook_secret?: string }, event: string, payload: GithubWebhook, opts: Options = {}): Promise<boolean> {
150
+ if (!app.hook_url) return false;
151
+ const hook = { id: app.id, repository: '', name: 'web', active: true, url: app.hook_url, content_type: 'json', secret: app.webhook_secret ?? '', events: [event] } as unknown as Hook;
152
+ await dispatch(hook, event, payload, opts);
104
153
  return true;
105
154
  }
155
+
156
+ export async function pingGithubHook(repository: string, hookId: number, opts: Options = {}): Promise<boolean> {
157
+ const hook = hookFor(repository, hookId, opts.root);
158
+ if (!hook?.url) return false;
159
+ await dispatch(hook, 'ping', { hook_id: hook.id, hook: toGithubWebhook(hook), repository: { full_name: repository } }, opts);
160
+ return true;
161
+ }
162
+
163
+ /** A hook's last delivery as GitHub reports it on the hook (`last_response`): unused until one is made. */
164
+ export function hookLastResponse(repository: string, hookId: number, root?: string): Record<string, unknown> {
165
+ const last = records(root).filter((d) => d.repository === repository && d.hookId === hookId).sort((a, b) => b.id - a.id)[0];
166
+ if (!last || last.statusCode === null) return { code: null, status: 'unused', message: null };
167
+ const ok = last.statusCode >= 200 && last.statusCode < 300;
168
+ return { code: last.statusCode || null, status: ok ? 'active' : 'failed', message: ok ? 'OK' : (last.error ?? `Invalid HTTP Response: ${last.statusCode}`) };
169
+ }
170
+
171
+ // a delivery takes no World time, so its duration is 0; a repository's hook belongs to no installation
172
+ function deliveryJson(d: Delivery, detail: boolean, repositoryId: number | null): Record<string, unknown> {
173
+ return {
174
+ id: d.id, guid: d.guid, delivered_at: d.deliveredAt, redelivery: d.redelivery, duration: 0, installation_id: null, repository_id: repositoryId,
175
+ status: d.statusCode === null ? 'Pending' : d.statusCode >= 200 && d.statusCode < 300 ? 'OK' : d.error ?? 'Failed',
176
+ status_code: d.statusCode, event: d.event, action: d.action ?? null,
177
+ ...(detail ? { url: d.url, request: { headers: d.requestHeaders, payload: JSON.parse(d.payloadJson) }, response: { headers: d.responseHeaders ?? {}, payload: d.responseBody ?? null } } : {}),
178
+ };
179
+ }
180
+
181
+ /** A GitHub App's own webhook deliveries, newest first (docs.github.com/rest/apps/webhooks#list-deliveries-for-an-app-webhook):
182
+ * each names the installation its payload was for; one delivery in detail carries its request and response. */
183
+ export function githubAppDeliveries(appId: number, root?: string): Record<string, unknown>[] {
184
+ return records(root).filter((d) => d.repository === '' && d.hookId === appId).sort((a, b) => b.id - a.id).map((d) => appDeliveryJson(d, false));
185
+ }
186
+ export function githubAppDelivery(appId: number, id: number, root?: string): Record<string, unknown> | undefined {
187
+ const d = records(root).find((x) => x.repository === '' && x.hookId === appId && x.id === id);
188
+ return d && appDeliveryJson(d, true);
189
+ }
190
+ function appDeliveryJson(d: Delivery, detail: boolean): Record<string, unknown> {
191
+ const payload = JSON.parse(d.payloadJson) as { installation?: { id?: number }; repository?: { id?: number } };
192
+ return { ...deliveryJson(d, detail, typeof payload.repository?.id === 'number' ? payload.repository.id : null), installation_id: payload.installation?.id ?? null };
193
+ }
194
+
195
+ /** Native delivery/ping routes, shared by CLI serve and in-process callers. */
196
+ export async function handleGithubWebhookRequest(request: Request, opts: Options & { readOnly?: boolean }): Promise<Response | undefined> {
197
+ const url = new URL(request.url);
198
+ const match = /^\/repos\/([^/]+)\/([^/]+)\/hooks\/(\d+)\/(deliveries(?:\/(\d+)(?:\/(attempts))?)?|pings)\/?$/.exec(url.pathname);
199
+ if (!match) return;
200
+ const repository = `${decodeURIComponent(match[1]!)}/${decodeURIComponent(match[2]!)}`;
201
+ const hook = hookFor(repository, Number(match[3]), opts.root);
202
+ const missing = () => Response.json({ message: 'Not Found' }, { status: 404 });
203
+ if (!hook) return missing();
204
+ if (request.method === 'POST' && opts.readOnly) return Response.json({ message: 'Read-only mirror' }, { status: 403 });
205
+ if (match[4] === 'pings') {
206
+ if (request.method !== 'POST') return missing();
207
+ await pingGithubHook(repository, hook.id, opts);
208
+ return new Response(null, { status: 204 });
209
+ }
210
+ const all = records(opts.root).filter(d => d.repository === repository && d.hookId === hook.id).sort((a, b) => b.id - a.id);
211
+ const repoId = githubState(opts.root).repos.find((r) => r.full_name === repository)?.id ?? null;
212
+ if (!match[5]) {
213
+ if (request.method !== 'GET') return missing();
214
+ const perPage = Number(url.searchParams.get('per_page') ?? 30);
215
+ const cursor = url.searchParams.get('cursor');
216
+ const status = url.searchParams.get('status');
217
+ if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100 || cursor !== null && !/^\d+$/.test(cursor) || status !== null && !['success', 'failure'].includes(status)) return Response.json({ message: 'Validation Failed' }, { status: 422 });
218
+ const eligible = all.filter(d => (cursor === null || d.id < Number(cursor)) && (status === null || (status === 'success'
219
+ ? d.statusCode !== null && d.statusCode >= 200 && d.statusCode <= 399
220
+ : d.statusCode !== null && d.statusCode >= 400 && d.statusCode <= 599)));
221
+ const page = eligible.slice(0, perPage);
222
+ const headers: Record<string, string> = {};
223
+ if (eligible.length > page.length) { url.searchParams.set('cursor', String(page.at(-1)!.id)); headers.link = `<${url.href}>; rel="next"`; }
224
+ return Response.json(page.map(d => deliveryJson(d, false, repoId)), { headers });
225
+ }
226
+ const delivery = all.find(d => d.id === Number(match[5]));
227
+ if (!delivery) return missing();
228
+ if (match[6] === 'attempts' && request.method === 'POST') {
229
+ await dispatch(hook, delivery.event, JSON.parse(delivery.payloadJson), opts, delivery);
230
+ return new Response(null, { status: 202 });
231
+ }
232
+ return !match[6] && request.method === 'GET' ? Response.json(deliveryJson(delivery, true, repoId)) : missing();
233
+ }
@@ -26,17 +26,15 @@
26
26
  // Data REST reads fall back to it (github-twin.ts).
27
27
  //
28
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.
29
+ // server announces as a REST contents write's is — GitHub's push reactions and the webhook.
31
30
  //
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';
31
+ // Offline + deterministic: everything here is the kernel's git library over the World's
32
+ // blob store (no network, no rate budget, no `git` binary).
33
+ import { applyTwinWrite, git } from '@volter/world-core';
34
+ const { serveSmartHttp, serveSmartHttpPost } = git;
35
+ import { ensureBareRepo, hasBareRepo, listGitPlaneRefs, planeRepo, type PlaneRef } from './github-git-plane.ts';
36
+ import { closePrsForDeletedBranch, githubState, kernelAtomic, syncPrHeadsToBranchTip, type GithubWriteEvent } from './github-twin.ts';
37
+ import { directPushRules } from './semantics/push-reactions.ts';
40
38
 
41
39
  const SERVICE = 'github';
42
40
 
@@ -65,17 +63,10 @@ export type GitSmartHttpResponse = {
65
63
  status: number;
66
64
  contentType: string;
67
65
  body: Uint8Array;
68
- /** push webhook events (receive-pack only) — the caller emits them via emitGithubEvent. */
66
+ /** push webhook events (receive-pack only) — the server announces them. */
69
67
  webhooks: GithubWriteEvent[];
70
68
  };
71
69
 
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
70
  function plain(status: number, message: string): GitSmartHttpResponse {
80
71
  return { status, contentType: 'text/plain; charset=utf-8', body: Buffer.from(`${message}\n`, 'utf8'), webhooks: [] };
81
72
  }
@@ -88,22 +79,19 @@ function repoNotFound(): GitSmartHttpResponse {
88
79
  // The bare repo must exist to be served. A repo the CONTROL plane knows (created via REST
89
80
  // before the git plane existed, or pulled from real GitHub) is materialized on first
90
81
  // 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);
82
+ /** The repository's refs exist, or are created for a repository the twin knows (its default branch). */
83
+ function resolvePlaneRepo(repo: string, root?: string): boolean {
84
+ if (hasBareRepo(repo, root)) return true;
93
85
  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;
86
+ if (!known) return false;
87
+ ensureBareRepo(repo, root, known.default_branch ?? 'main');
88
+ return true;
101
89
  }
102
90
 
103
91
  /**
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).
92
+ * Smart HTTP over the kernel library (`serveSmartHttp`): the advertisement, upload-pack and
93
+ * receive-pack — no `git` binary. A push's applied ref commands reach the twin through the
94
+ * reconciliation below (git_ref / branch rows, PR heads, the push webhooks).
107
95
  */
108
96
  export async function handleGithubGitSmartHttp(req: {
109
97
  method: string;
@@ -114,72 +102,52 @@ export async function handleGithubGitSmartHttp(req: {
114
102
  readOnly?: boolean;
115
103
  /** request Content-Encoding (git clients gzip large POST bodies) */
116
104
  contentEncoding?: string;
117
- /** request Git-Protocol header (protocol v2 negotiation) */
105
+ /** request Git-Protocol header (protocol v2 negotiation — the library answers v0, as git falls back to) */
118
106
  gitProtocol?: string;
119
107
  occurredAt?: string;
120
108
  }): Promise<GitSmartHttpResponse | null> {
121
109
  const route = parseGitSmartHttpPath(req.method, req.pathname);
122
110
  if (!route) return null;
123
-
124
- // ── GET info/refs — the smart service advertisement ─────────────────────────────────────
111
+ const tail = route.kind === 'info-refs' ? 'info/refs' : route.kind === 'upload-pack' ? 'git-upload-pack' : 'git-receive-pack';
125
112
  if (route.kind === 'info-refs') {
126
113
  const service = req.query?.get('service') ?? '';
127
114
  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
115
  return plain(403, 'Dumb HTTP protocol is not supported: pass ?service=git-upload-pack or ?service=git-receive-pack.');
132
116
  }
133
117
  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
118
  }
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
119
  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'
120
+ if (!resolvePlaneRepo(route.repo, req.root)) return repoNotFound();
121
+ const before = route.kind === 'receive-pack' ? await listGitPlaneRefs(route.repo, req.root) : [];
122
+ // POST hands the kernel the body already in hand; a Request round-trip would copy the whole pack.
123
+ const answer = route.kind === 'info-refs'
124
+ ? await serveSmartHttp(new Request(`http://twin/${route.repo}.git/info/refs?service=${req.query?.get('service') ?? ''}`), planeRepo(route.repo, req.root), tail)
125
+ : await serveSmartHttpPost(req.body ?? new Uint8Array(), req.contentEncoding ?? '', { ...planeRepo(route.repo, req.root), policy: rulesPolicy(route.repo, req.root) }, tail as 'git-upload-pack' | 'git-receive-pack');
126
+ if (!answer) return null;
127
+ const body = new Uint8Array(await answer.arrayBuffer());
128
+ const webhooks = route.kind === 'receive-pack' && answer.status === 200
170
129
  ? await reconcileGitPlaneRefs(route.repo, before, { ...(req.root !== undefined ? { root: req.root } : {}), ...(req.occurredAt !== undefined ? { occurredAt: req.occurredAt } : {}) })
171
130
  : [];
172
- return { status: 200, contentType: `application/x-git-${cmd}-result`, body: res.stdout, webhooks };
131
+ return { status: answer.status, contentType: answer.headers.get('content-type') ?? 'application/octet-stream', body, webhooks };
173
132
  }
174
133
 
175
134
  const ZERO_SHA = '0'.repeat(40);
176
135
 
136
+ /** An active ruleset requiring pull requests refuses a direct push to its branch, as it refuses an API commit
137
+ * (semantics/push-reactions.ts directPushRules); git reports the ref `ng` with GitHub's reason. */
138
+ function rulesPolicy(repo: string, root?: string): (cmd: { name: string; new: string }) => string | null {
139
+ return (cmd) => {
140
+ if (!cmd.name.startsWith('refs/heads/') || cmd.new === ZERO_SHA) return null;
141
+ return directPushRules(githubState(root), repo, cmd.name.slice('refs/heads/'.length)).refused ? 'push declined due to repository rule violations' : null;
142
+ };
143
+ }
144
+
177
145
  // Per-write ordinal folded into every reconcile write's fields: the kernel dedupes actions
178
146
  // by content + occurredAt millisecond, so a ref REVISITING a value under a pinned clock
179
147
  // (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;
148
+ // and leave the projection at B while the bare repo says A (docs/contributing/adding-a-twin.md §5 — the
149
+ // upstash `rev` pattern). Strictly increasing within the process; harmless extra field.
150
+ let reconcileSeq = 0; // counter: strictly increasing within the process; never state
183
151
 
184
152
  /**
185
153
  * Fold the bare repo's ref changes since `before` into the kernel event log — the SAME
@@ -194,7 +162,7 @@ export async function reconcileGitPlaneRefs(
194
162
  ): Promise<GithubWriteEvent[]> {
195
163
  const root = opts.root;
196
164
  const occurredAt = opts.occurredAt ?? new Date().toISOString();
197
- const after = listGitPlaneRefs(repo, root);
165
+ const after = await listGitPlaneRefs(repo, root);
198
166
  const beforeBy = new Map(before.map((r) => [r.ref, r]));
199
167
  const afterBy = new Map(after.map((r) => [r.ref, r]));
200
168
  const events: GithubWriteEvent[] = [];
@@ -218,9 +186,7 @@ export async function reconcileGitPlaneRefs(
218
186
  }, root);
219
187
  // GitHub's `synchronize`: a push to a PR's head branch moves the PR head — the old
220
188
  // 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
- }
189
+ await syncPrHeadsToBranchTip(repo, name, r.object_sha, root, occurredAt, (pr, base) => events.push({ event: 'pull_request', action: 'synchronize', repository: base, number: Number(pr.number), pr }));
224
190
  }
225
191
  events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: prev?.object_sha ?? ZERO_SHA, after: r.object_sha });
226
192
  }
@@ -228,19 +194,17 @@ export async function reconcileGitPlaneRefs(
228
194
  if (afterBy.has(r.ref)) continue;
229
195
  await applyTwinWrite(SERVICE, {
230
196
  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' },
197
+ fields: { deleted: true, repository: repo, ref: r.ref, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
232
198
  }, root);
233
199
  const name = branchName(r.ref);
234
200
  if (name) {
235
201
  await applyTwinWrite(SERVICE, {
236
202
  operation: 'branch.delete', subjectType: 'branch', subjectId: `branch:${repo}#${name}`,
237
- fields: { repository: repo, name, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
203
+ fields: { deleted: true, repository: repo, name, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
238
204
  }, root);
239
205
  // Real GitHub CLOSES an open PR whose head branch is deleted (shared with the REST
240
206
  // 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
- }
207
+ await closePrsForDeletedBranch(repo, name, root, occurredAt, (pr, base) => events.push({ event: 'pull_request', action: 'closed', repository: base, number: Number(pr.number), pr }), kernelAtomic(occurredAt, root), { operation: 'git-receive-pack', actor: 'external' });
244
208
  }
245
209
  events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: r.object_sha, after: ZERO_SHA });
246
210
  }