@volter/twin-github 0.1.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +232 -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 +104 -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 +170 -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 +225 -95
  161. package/src/github-git-http.ts +48 -84
  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 +50 -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 +193 -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
@@ -0,0 +1,1398 @@
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 { assertBudgetGuardIntact } from '@volter/world-core';
22
+ import { observeResource, toEntry } from '@volter/world-core';
23
+ import { twinResources, ownFields } from '@volter/world-core';
24
+ import { GithubBudget, GithubBudgetError, githubCallWeight } from "./github-budget.js";
25
+ import { githubState } from "./github-twin.js";
26
+ const SERVICE = 'github';
27
+ /**
28
+ * A live executor against the real GitHub REST API (token = the user's own PAT).
29
+ * Constructed with the real @octokit/rest in PROD by the CALLER and passed in; this
30
+ * helper shows the shape without importing the SDK. Kept tiny + dependency-free: it
31
+ * uses `fetch`, so the pack pulls in no network client. Live runs may instead pass a
32
+ * real `new Octokit({ auth }).request` bound into a `{ request }` object.
33
+ *
34
+ * THIS IS THE ONE PLACE this pack issues a live `api.github.com` request, and therefore the one
35
+ * place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the
36
+ * request goes out (`checkBudget`, which THROWS `GithubBudgetError` instead of returning when the
37
+ * ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `Retry-After` /
38
+ * 403-or-429 / `x-ratelimit-remaining: 0` signal becomes a persisted cooldown that makes every
39
+ * later call fail fast WITHOUT touching GitHub. The weights ARE GitHub's own published point costs
40
+ * (1 for a read, 5 for a write) — see `github-budget.ts`. There is deliberately NO option to
41
+ * disable the guard, and no value a caller can pass for `budget` that yields an unguarded client —
42
+ * but NOT immunity from a caller who WANTS one (a fresh `budgetOptions.path` or an injected clock
43
+ * restores the allowance; the kernel header states that limit and this does not upgrade it).
44
+ */
45
+ export function liveGithubExecute(token, baseUrl = 'https://api.github.com', opts = {}) {
46
+ // `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
47
+ // UNMODIFIED GithubBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
48
+ // Proxy that traps it are all refused, because all three are one-liners that would otherwise
49
+ // hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
50
+ // not a check). What this cannot stop is deliberate sabotage from inside the process (an
51
+ // injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
52
+ // otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
53
+ const doFetch = opts.fetchImpl ?? fetch;
54
+ // The default ledger is keyed by a hash of THIS PAT — GitHub's primary limit is per token, so a
55
+ // cwd-scoped ledger would hand it a fresh allowance per checkout/worktree/CI matrix leg.
56
+ // ONE expression decides which budget is used, so there is no second, weaker test that could
57
+ // disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
58
+ // must be an UNMODIFIED GithubBudget — a duck-typed stand-in, a SUBCLASS overriding
59
+ // `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
60
+ // would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
61
+ // alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
62
+ // sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
63
+ // header states that limit rather than pretending otherwise. This closes the accident and the
64
+ // one-liner, which are the shapes that actually happen.
65
+ const budget = opts.budget !== undefined && opts.budget !== null
66
+ ? assertBudgetGuardIntact(opts.budget, GithubBudget, 'liveGithubExecute')
67
+ : new GithubBudget({ token, ...(opts.budgetOptions ?? {}) });
68
+ return {
69
+ async request(route, params = {}) {
70
+ const sp = route.indexOf(' ');
71
+ const method = route.slice(0, sp);
72
+ let path = route.slice(sp + 1);
73
+ const rest = {};
74
+ // Substitute {param} path templates; remaining params become the QUERY STRING on
75
+ // GET/HEAD and the JSON body otherwise. (GET has no body, so leftover params were
76
+ // silently DROPPED before — every list read got GitHub's defaults, state=open
77
+ // per_page=30, which is how merged PRs froze out of observation: the poll's
78
+ // state:'closed' never reached the API. octokit does this same split.)
79
+ for (const [k, v] of Object.entries(params)) {
80
+ const token = `{${k}}`;
81
+ if (path.includes(token))
82
+ path = path.replace(token, encodeURIComponent(String(v)));
83
+ else
84
+ rest[k] = v;
85
+ }
86
+ const isRead = method === 'GET' || method === 'HEAD';
87
+ if (isRead && Object.keys(rest).length > 0) {
88
+ const qs = new URLSearchParams();
89
+ for (const [k, v] of Object.entries(rest))
90
+ qs.set(k, String(v));
91
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
92
+ }
93
+ // Priced by the ROUTE (`"GET /repos/{owner}/{repo}/pulls"`), which is what GitHub's own docs
94
+ // and point table name endpoints by — stable across owners and repos.
95
+ const weight = githubCallWeight(route);
96
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
97
+ const reservation = budget.checkBudget(weight);
98
+ const res = await doFetch(`${baseUrl}${path}`, {
99
+ method,
100
+ headers: {
101
+ Authorization: `Bearer ${token}`,
102
+ Accept: 'application/vnd.github+json',
103
+ 'Content-Type': 'application/json',
104
+ },
105
+ ...(isRead ? {} : { body: JSON.stringify(rest) }),
106
+ });
107
+ const resHeaders = {};
108
+ res.headers.forEach((v, k) => { resHeaders[k.toLowerCase()] = v; });
109
+ const data = res.status === 204 ? undefined : await res.json();
110
+ // Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
111
+ // `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
112
+ // first either way, so the refusal survives the throw. GitHub answers a tripped SECONDARY
113
+ // limit with 403 + `Retry-After` rather than 429, which is why the header is read on every
114
+ // response and not only on a 429.
115
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
116
+ // call that louder refusal wins; an answer GitHub ACCEPTED is kept, so a write that landed is
117
+ // never recorded as failed and performed again on retry.
118
+ try {
119
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
120
+ }
121
+ catch (error) {
122
+ if (!(error instanceof GithubBudgetError) || !res.ok)
123
+ throw error;
124
+ }
125
+ return { status: res.status, data };
126
+ },
127
+ };
128
+ }
129
+ // GitHub says a machine wrote something two ways: `user.type === 'Bot'` on the App-authored
130
+ // row, and the `[bot]` login suffix every GitHub App account carries. A consumer triaging a
131
+ // review feed sorts bot findings from human ones, so read both rather than trusting either.
132
+ //
133
+ // READ THE ASYMMETRY BEFORE YOU GATE ON THIS. `bot` ⇒ a machine wrote it is RELIABLE: only a
134
+ // GitHub App account is typed `Bot` or suffixed `[bot]`. The converse is NOT: `user` does not
135
+ // mean a human. A GitHub App acting through a USER token, a service/machine account, and an
136
+ // Organization all come back typed `User` (or `Organization`) with an ordinary login — and a
137
+ // review by a DELETED account answers `user: null`, so neither field exists and no author
138
+ // type is emitted at all. So `authorType === 'bot'` is safe to act on; `authorType === 'user'`
139
+ // is "not identified as a bot", never "a person did this".
140
+ function authorTypeOf(user) {
141
+ const type = user?.type === undefined || user?.type === null ? '' : String(user.type);
142
+ const login = user?.login === undefined || user?.login === null ? '' : String(user.login);
143
+ return type.toLowerCase() === 'bot' || login.endsWith('[bot]') ? 'bot' : 'user';
144
+ }
145
+ // The author fields as they ride a review/comment subject: the login when GitHub gave one
146
+ // (a deleted account answers `user: null`), and the human/bot split derived from it.
147
+ function authorFields(user) {
148
+ if (user === undefined || user === null)
149
+ return {};
150
+ const login = user.login === undefined || user.login === null ? undefined : String(user.login);
151
+ return { ...(login === undefined ? {} : { authorLogin: login }), authorType: authorTypeOf(user) };
152
+ }
153
+ /**
154
+ * Pull OBSERVED PRs for a repo via the injected executor. Folds the CONTENT the REST
155
+ * PR object returns (title/body/state) plus metadata (counts/refs) and the BODIES of
156
+ * each review + issue comment + INLINE (diff-anchored) review comment. The ONLY thing not
157
+ * pulled is the Non-goal — actual repository file CONTENTS (per-file diffs/blob bytes are
158
+ * not fetched here).
159
+ *
160
+ * Three reads per PR buy the conversation: the reviews, the issue comments, and
161
+ * `/pulls/{n}/comments` — the inline comments, where every bot finding lives and which
162
+ * this pull never fetched before. They are fetched ONCE per PR and joined to their review
163
+ * by `pull_request_review_id` rather than per review, because GitHub has no
164
+ * comments-of-one-review route worth N more calls.
165
+ *
166
+ * `lastUpdatedAt` is the budget: a PR whose `updated_at` has not moved since the last
167
+ * observation has no new conversation to buy, so those three reads are skipped entirely
168
+ * (`detailsFetched: false`) and N unchanged PRs cost only the list call.
169
+ */
170
+ /** The subject suffix for the PHANTOM review — the bucket an inline comment lands in when
171
+ * nothing names the review that wrapped it. A fixed word, not a mint: it is the SAME
172
+ * bucket every pull, so a consumer's feed sees one growing subject rather than a new one
173
+ * per poll. */
174
+ const UNATTACHED_REVIEW = 'unattached';
175
+ /**
176
+ * The reviews that COUNT as reviews: submitted, and not a PENDING draft. GitHub returns a
177
+ * reviewer's unsubmitted draft on the reviews page with `state: 'PENDING'` and no
178
+ * `submitted_at` — nobody has said anything yet — and this pull also carries a PHANTOM
179
+ * review holding orphaned inline comments, which is a bucket, not a verdict. Both belong in
180
+ * the `reviews` list (they are real rows) and NEITHER may be the PR's "latest review" or be
181
+ * counted as one, so exactly one predicate decides both and they can never disagree.
182
+ */
183
+ function submittedReviews(reviews) {
184
+ return reviews.filter((r) => r.submittedAt !== undefined && (r.state ?? '').toUpperCase() !== 'PENDING');
185
+ }
186
+ /** GitHub's page ceiling for these list reads. */
187
+ const GITHUB_MAX_PER_PAGE = 100;
188
+ /** How many pages one conversation read will walk before it stops asking. 100×10 = 1000
189
+ * reviews or inline comments on a single PR — past that the pull is buying a pathological
190
+ * outlier at real rate cost, and the bound is what keeps a poll's spend knowable. */
191
+ const MAX_CONVERSATION_PAGES = 10;
192
+ /**
193
+ * Read EVERY page of a list route, not just the first. GitHub's default page is 30 rows, so
194
+ * a busy PR's 31st review — or its 31st inline comment, where a scanner's findings pile up
195
+ * fastest — simply did not exist as far as the pull was concerned, and the missing rows read
196
+ * downstream as an absence rather than a page boundary. Asks for the vendor's maximum page
197
+ * (100, the same as `observeRepositoryAndBranches`) and stops on the first short page.
198
+ */
199
+ async function readAllPages(execute, route, params) {
200
+ const out = [];
201
+ for (let page = 1; page <= MAX_CONVERSATION_PAGES; page++) {
202
+ const res = await execute.request(route, { ...params, per_page: GITHUB_MAX_PER_PAGE, page });
203
+ const rows = Array.isArray(res.data) ? res.data : [];
204
+ out.push(...rows);
205
+ if (rows.length < GITHUB_MAX_PER_PAGE)
206
+ break;
207
+ }
208
+ return out;
209
+ }
210
+ export async function pullGithubPrs(execute, opts) {
211
+ const { owner, repo } = opts;
212
+ const repository = `${owner}/${repo}`;
213
+ const state = opts.state ?? 'open';
214
+ // Same ordering as the closed sweep below: newest-updated first. The two pages are read
215
+ // against ONE budget of rows, so an open list that came back in the vendor's default order
216
+ // (by number, descending) would spend that budget on whatever happened to be numbered
217
+ // highest rather than on what actually moved since the last poll.
218
+ const list = await execute.request('GET /repos/{owner}/{repo}/pulls', {
219
+ owner, repo, state, sort: 'updated', direction: 'desc', per_page: opts.perPage ?? 30,
220
+ });
221
+ if (list.status >= 400)
222
+ throw new Error(`github pull failed (list pulls): HTTP ${list.status}`);
223
+ const nodes = Array.isArray(list.data) ? list.data : [];
224
+ // A MERGE IS A CLOSED PR: an open-only list never observes the event that ends a job —
225
+ // the PR simply vanishes from the next pull. `state: 'all'` (what syncGithubFromRemote
226
+ // asks for) already covers it; an open-only caller gets a BOUNDED second page of the
227
+ // most recently updated closed PRs instead, so the merge folds without a pull walking
228
+ // the repo's whole closed history.
229
+ const closedPerPage = opts.closedPerPage ?? 20;
230
+ if (state === 'open' && closedPerPage > 0) {
231
+ const closed = await execute.request('GET /repos/{owner}/{repo}/pulls', {
232
+ owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: closedPerPage,
233
+ });
234
+ if (closed.status < 400 && Array.isArray(closed.data))
235
+ nodes.push(...closed.data);
236
+ }
237
+ const out = [];
238
+ // The two list pages can name the same PR — one that closed BETWEEN the two reads appears
239
+ // open on the first page and closed on the second. Keeping the page that named it first
240
+ // kept the STALE row and reported a merged PR as still open. Keep the row whose
241
+ // `updated_at` is LATER instead (a stampless row never displaces a stamped one), which is
242
+ // the vendor's own answer to "which of these two observations is the newer".
243
+ const byNumber = new Map();
244
+ for (const n of nodes) {
245
+ const number = Number(n.number);
246
+ // A row with no usable number is not a PR we can address: `Number(undefined)` is NaN and
247
+ // every such row collapsed onto ONE key, so the second one silently replaced the first.
248
+ if (!Number.isFinite(number))
249
+ continue;
250
+ const prev = byNumber.get(number);
251
+ if (prev === undefined) {
252
+ byNumber.set(number, n);
253
+ continue;
254
+ }
255
+ const a = prev.updated_at === undefined || prev.updated_at === null ? '' : String(prev.updated_at);
256
+ const b = n.updated_at === undefined || n.updated_at === null ? '' : String(n.updated_at);
257
+ if (b > a)
258
+ byNumber.set(number, n);
259
+ }
260
+ for (const n of byNumber.values()) {
261
+ const number = Number(n.number);
262
+ const updatedAt = n.updated_at === undefined || n.updated_at === null ? undefined : String(n.updated_at);
263
+ // BUDGET: GitHub moves a PR's `updated_at` on every review, comment, edit and push, so
264
+ // an unmoved stamp means there is no conversation to buy. A PR that reports no stamp at
265
+ // all is always fetched — silence is not evidence of stillness.
266
+ const detailsFetched = updatedAt === undefined || opts.lastUpdatedAt?.[`${repository}#${number}`] !== updatedAt;
267
+ const pr = { id: `${repository}#${number}`, number, repository, detailsFetched };
268
+ if (updatedAt !== undefined)
269
+ pr.updatedAt = updatedAt;
270
+ if (detailsFetched) {
271
+ // ALL THREE conversation reads are PAGED (see readAllPages): a review page boundary
272
+ // orphaned inline comments from their reviews, an inline page boundary simply lost
273
+ // findings, and the issue-comment read — which `commentCount` publishes — stopped at
274
+ // the vendor's default 30, so a busy conversation reported a truncated count as if it
275
+ // were the whole tab.
276
+ const reviewNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { owner, repo, pull_number: number });
277
+ const commentNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { owner, repo, issue_number: number });
278
+ const inlineNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments', { owner, repo, pull_number: number });
279
+ // The review is the unit: every inline comment names the review that wrapped it.
280
+ const inlineByReview = new Map();
281
+ for (const c of inlineNodes) {
282
+ if (c.id === undefined || c.id === null)
283
+ continue; // no id, no stable place in a thread
284
+ const reviewId = c.pull_request_review_id === undefined || c.pull_request_review_id === null ? '' : String(c.pull_request_review_id);
285
+ // The row's OWN author rides with it. The review is the join, never the byline.
286
+ const comment = { id: String(c.id), ...authorFields(c.user) };
287
+ if (c.path !== undefined && c.path !== null)
288
+ comment.path = String(c.path);
289
+ if (c.line !== undefined && c.line !== null)
290
+ comment.line = Number(c.line);
291
+ if (c.body !== undefined && c.body !== null)
292
+ comment.body = String(c.body);
293
+ if (c.in_reply_to_id !== undefined && c.in_reply_to_id !== null)
294
+ comment.inReplyTo = String(c.in_reply_to_id);
295
+ if (c.created_at !== undefined && c.created_at !== null)
296
+ comment.createdAt = String(c.created_at);
297
+ const bucket = inlineByReview.get(reviewId);
298
+ if (bucket)
299
+ bucket.push(comment);
300
+ else
301
+ inlineByReview.set(reviewId, [comment]);
302
+ }
303
+ const reviews = reviewNodes.map((r) => {
304
+ const id = r.id === undefined || r.id === null ? undefined : String(r.id);
305
+ const review = { ...(id === undefined ? {} : { id }), ...authorFields(r.user), comments: id === undefined ? [] : (inlineByReview.get(id) ?? []) };
306
+ if (r.state !== undefined && r.state !== null)
307
+ review.state = String(r.state);
308
+ if (r.body !== undefined && r.body !== null)
309
+ review.body = String(r.body);
310
+ if (r.submitted_at !== undefined && r.submitted_at !== null)
311
+ review.submittedAt = String(r.submitted_at);
312
+ if (r.commit_id !== undefined && r.commit_id !== null)
313
+ review.commitId = String(r.commit_id);
314
+ if (id !== undefined)
315
+ inlineByReview.delete(id);
316
+ return review;
317
+ });
318
+ // An inline comment whose review the reviews page did not name (a page boundary, a
319
+ // review deleted between the two reads) still EXISTS — it rides a review carrying its
320
+ // id, the `partial` flag and its comments, and nothing else, rather than being dropped.
321
+ // Inventing a state or an author for a review nobody showed us would be the worse lie.
322
+ const unattached = [];
323
+ for (const [reviewId, comments] of inlineByReview) {
324
+ // NO review id at all. This used to `continue` — the comment was OBSERVED and then
325
+ // silently discarded, which is the one thing a pull may never do with a fact it
326
+ // bought. It hangs off a fixed phantom subject per PR instead, flagged `partial` so
327
+ // nothing mistakes it for a review whose verdict simply has not arrived.
328
+ if (reviewId === '') {
329
+ unattached.push(...comments);
330
+ continue;
331
+ }
332
+ // A review whose id we know but whose ROW never arrived is just as unshown as the
333
+ // phantom: state, author and verdict are all absent because nobody sent them. It
334
+ // carries `partial` for the same reason the phantom does — without it a consumer
335
+ // reads a stateless orphan as a review whose fields merely have not landed yet.
336
+ reviews.push({ id: reviewId, partial: true, comments });
337
+ }
338
+ if (unattached.length > 0)
339
+ reviews.push({ id: UNATTACHED_REVIEW, partial: true, comments: unattached });
340
+ pr.reviews = reviews;
341
+ // Counted over the SAME set the newest verdict is drawn from — a raw row count included
342
+ // PENDING drafts and the phantom bucket, so `reviewCount` and `latestReview` described
343
+ // different populations of the same PR.
344
+ pr.reviewCount = submittedReviews(reviews).length;
345
+ pr.comments = commentNodes.map((c) => {
346
+ const comment = { ...(c.id === undefined || c.id === null ? {} : { id: String(c.id) }), ...authorFields(c.user) };
347
+ if (c.body !== undefined && c.body !== null)
348
+ comment.body = String(c.body);
349
+ if (c.created_at !== undefined && c.created_at !== null)
350
+ comment.createdAt = String(c.created_at);
351
+ if (c.updated_at !== undefined && c.updated_at !== null)
352
+ comment.updatedAt = String(c.updated_at);
353
+ return comment;
354
+ });
355
+ pr.commentCount = commentNodes.length;
356
+ }
357
+ if (n.user === null)
358
+ pr.authorLogin = null;
359
+ else if (n.user?.login !== undefined && n.user?.login !== null)
360
+ pr.authorLogin = String(n.user.login);
361
+ if (n.user?.type !== undefined && n.user?.type !== null)
362
+ pr.authorType = String(n.user.type);
363
+ // CONTENT: title/body/state are real text the REST PR object returns — fold them.
364
+ if (n.title !== undefined && n.title !== null)
365
+ pr.title = String(n.title);
366
+ if (n.body !== undefined && n.body !== null)
367
+ pr.body = String(n.body);
368
+ if (n.state !== undefined && n.state !== null)
369
+ pr.state = String(n.state);
370
+ // draft + merge state: the REST PR object's `draft` flag and merge status are
371
+ // first-class forge facts (a validator gates delivery on them), so fold them like
372
+ // state. GitHub marks a merged PR as state:closed with merged_at set, so derive
373
+ // `merged` from merged_at (or the explicit `merged` on the single-PR GET).
374
+ if (n.draft !== undefined && n.draft !== null)
375
+ pr.draft = Boolean(n.draft);
376
+ if (n.merged !== undefined || n.merged_at !== undefined)
377
+ pr.merged = Boolean(n.merged ?? n.merged_at);
378
+ if (pr.merged && n.merge_commit_sha !== undefined && n.merge_commit_sha !== null)
379
+ pr.mergeCommit = String(n.merge_commit_sha);
380
+ // base/head refs ride along when present.
381
+ if (n.base?.ref !== undefined)
382
+ pr.baseRef = String(n.base.ref);
383
+ if (n.head?.sha !== undefined)
384
+ pr.headSha = String(n.head.sha);
385
+ // changed-files / commits COUNTS are metadata; per-file diffs are NOT pulled (Non-goal).
386
+ if (n.changed_files !== undefined)
387
+ pr.changedFiles = Number(n.changed_files);
388
+ if (n.commits !== undefined)
389
+ pr.commitsCount = Number(n.commits);
390
+ out.push(pr);
391
+ }
392
+ return out;
393
+ }
394
+ /**
395
+ * Pull OBSERVED ISSUES for a repo via the injected executor (GET /repos/:o/:r/issues),
396
+ * EXCLUDING pull requests — the issues endpoint returns PRs too (each PR is an issue),
397
+ * distinguished by a `pull_request` field, which we filter out so PRs only flow through
398
+ * pullGithubPrs. Folds each issue's content (title/body/state). Numbers share the
399
+ * per-repo PR/issue space (real GitHub).
400
+ */
401
+ export async function pullGithubIssues(execute, opts) {
402
+ const { owner, repo } = opts;
403
+ const repository = `${owner}/${repo}`;
404
+ const list = await execute.request('GET /repos/{owner}/{repo}/issues', {
405
+ owner, repo, state: opts.state ?? 'open', per_page: opts.perPage ?? 30,
406
+ });
407
+ if (list.status >= 400)
408
+ throw new Error(`github pull failed (list issues): HTTP ${list.status}`);
409
+ const nodes = Array.isArray(list.data) ? list.data : [];
410
+ const out = [];
411
+ for (const n of nodes) {
412
+ // The issues endpoint includes PRs (they carry a `pull_request` object) — exclude them.
413
+ if (n.pull_request !== undefined && n.pull_request !== null)
414
+ continue;
415
+ const number = Number(n.number);
416
+ const iss = { id: `${repository}#issue:${number}`, number, repository };
417
+ if (n.title !== undefined && n.title !== null)
418
+ iss.title = String(n.title);
419
+ if (n.body !== undefined && n.body !== null)
420
+ iss.body = String(n.body);
421
+ if (n.state !== undefined && n.state !== null)
422
+ iss.state = String(n.state);
423
+ // the timestamps the write handler stores: shape parity between a written and an observed issue
424
+ if (typeof n.created_at === 'string')
425
+ iss.created_at = n.created_at;
426
+ if (typeof n.updated_at === 'string')
427
+ iss.updated_at = n.updated_at;
428
+ out.push(iss);
429
+ }
430
+ return out;
431
+ }
432
+ // Map observed PRs to the generic SyncResource[] shape (the Linear/Slack-shared pull
433
+ // contract). Carries CONTENT (title/body/state) now that pull is a content mirror, plus
434
+ // the refs. baseRef/headSha ride under their twin names so the github fold (which reads
435
+ // data.changed.baseRef.after / .headSha.after) picks them up.
436
+ //
437
+ // The PR is one subject and its CONVERSATION is many: each review (`…#<n>:review:<id>`,
438
+ // carrying the inline comments it wrapped) and each issue comment (`…#<n>:ic:<id>`) rides
439
+ // as its own resource, so two reviews between two polls are two deltas and a consumer can
440
+ // say who reviewed. Each dates its own delta — a review by `submitted_at`, a comment by
441
+ // `updated_at ?? created_at` — because a feed ordering a conversation wants the moment it
442
+ // was said, not the moment the poll happened to look.
443
+ export function observedPrsToResources(prs) {
444
+ return prs.flatMap((p) => {
445
+ const fields = { number: p.number, repository: p.repository };
446
+ if (p.title !== undefined)
447
+ fields.title = p.title;
448
+ if (p.body !== undefined)
449
+ fields.body = p.body;
450
+ if (p.state !== undefined)
451
+ fields.state = p.state;
452
+ // draft + merge state ride along so the generic delta connector (which diffs these
453
+ // SyncResources) captures a draft→ready or open→merged transition — and the github
454
+ // fold reads f.draft / f.merged / f.mergeCommit back onto the materialized PR.
455
+ if (p.draft !== undefined)
456
+ fields.draft = p.draft;
457
+ if (p.merged !== undefined)
458
+ fields.merged = p.merged;
459
+ if (p.mergeCommit !== undefined)
460
+ fields.mergeCommit = p.mergeCommit;
461
+ if (p.baseRef !== undefined)
462
+ fields.baseRef = p.baseRef;
463
+ if (p.headSha !== undefined)
464
+ fields.headSha = p.headSha;
465
+ // WHO opened it, and when GitHub last saw it change — the stamp the next pull's budget
466
+ // compares against, kept on the subject rather than in a cache beside it.
467
+ if (p.authorLogin !== undefined)
468
+ fields.authorLogin = p.authorLogin;
469
+ if (p.authorType !== undefined)
470
+ fields.user_type = p.authorType;
471
+ if (p.updatedAt !== undefined) {
472
+ fields.updatedAt = p.updatedAt;
473
+ fields.observedUpdatedAt = p.updatedAt;
474
+ }
475
+ // The reviewers' WORDS ride the observation: how many reviews, and the newest one's
476
+ // verdict and body, so a delta on the feed says what a reviewer said (a consumer decides
477
+ // from the words, never from a bare count moving). The reviews themselves are subjects
478
+ // below; this summary stays for a consumer that reads only pull_request. A pull whose
479
+ // budget skipped the conversation OMITS both — the shadow keeps what it already holds,
480
+ // because not buying a fact is not observing its absence.
481
+ if (p.reviewCount !== undefined)
482
+ fields.reviewCount = p.reviewCount;
483
+ if (p.commentCount !== undefined)
484
+ fields.commentCount = p.commentCount;
485
+ if (p.reviews !== undefined) {
486
+ // THE NEWEST VERDICT, by the vendor's clock — not `reviews.at(-1)`, which was list
487
+ // ORDER with the phantom bucket appended after it: the last element was routinely a
488
+ // review with no state and no body at all (`{}` on the feed), and a reviewer's unsent
489
+ // PENDING draft could take the slot from the approval that actually landed. The latest
490
+ // review is the one with the greatest `submitted_at` among the reviews that HAVE one
491
+ // and are not PENDING — the same set `reviewCount` counts.
492
+ const submitted = submittedReviews(p.reviews);
493
+ const latest = submitted.reduce((best, r) => (best === undefined || r.submittedAt >= best.submittedAt ? r : best), undefined);
494
+ fields.latestReview = latest === undefined ? null : { ...(latest.state === undefined ? {} : { state: latest.state }), ...(latest.body === undefined ? {} : { body: latest.body }), ...(latest.submittedAt === undefined ? {} : { submittedAt: latest.submittedAt }) };
495
+ }
496
+ const out = [{ type: 'pull_request', id: p.id, fields, ...(p.updatedAt === undefined ? {} : { occurredAt: p.updatedAt }) }];
497
+ for (const r of p.reviews ?? []) {
498
+ // No review id, no subject: an id minted from a position would move under the next
499
+ // review and thread a consumer's feed onto the wrong conversation.
500
+ if (r.id === undefined)
501
+ continue;
502
+ const reviewFields = { pr: p.number, repository: p.repository };
503
+ if (r.authorLogin !== undefined)
504
+ reviewFields.authorLogin = r.authorLogin;
505
+ if (r.authorType !== undefined)
506
+ reviewFields.authorType = r.authorType;
507
+ if (r.state !== undefined)
508
+ reviewFields.state = r.state;
509
+ if (r.body !== undefined)
510
+ reviewFields.body = r.body;
511
+ if (r.submittedAt !== undefined)
512
+ reviewFields.submittedAt = r.submittedAt;
513
+ if (r.commitId !== undefined)
514
+ reviewFields.commitId = r.commitId;
515
+ // The PHANTOM bucket says so on the wire. It holds inline comments nothing named a
516
+ // review for, so it has no state, no author and no verdict — and without this flag a
517
+ // consumer could not tell it from a real review whose fields simply had not arrived.
518
+ if (r.partial === true)
519
+ reviewFields.partial = true;
520
+ // Always present, even empty: a review that later grows an inline comment is then a
521
+ // delta on THIS field rather than a field appearing from nowhere. SORTED BY ID, because
522
+ // the vendor is free to hand the same comments back in a different order and a
523
+ // reordered array is a field change — a delta on a conversation nobody touched.
524
+ reviewFields.comments = [...r.comments].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((c) => {
525
+ // EACH FINDING'S OWN AUTHOR rides with it, not the wrapping review's. A consumer
526
+ // grading a finding reads the FINDING's author: a scanner App's comment inside a
527
+ // human's review is a bot finding, and serving the wrapper's login called it a
528
+ // human's. The wrapper is the fallback only for a row that named nobody at all.
529
+ const authorLogin = c.authorLogin ?? r.authorLogin;
530
+ const authorType = c.authorType ?? r.authorType;
531
+ return {
532
+ id: c.id,
533
+ ...(authorLogin === undefined ? {} : { authorLogin }),
534
+ ...(authorType === undefined ? {} : { authorType }),
535
+ ...(c.path === undefined ? {} : { path: c.path }),
536
+ ...(c.line === undefined ? {} : { line: c.line }),
537
+ ...(c.body === undefined ? {} : { body: c.body }),
538
+ ...(c.inReplyTo === undefined ? {} : { inReplyTo: c.inReplyTo }),
539
+ ...(c.createdAt === undefined ? {} : { createdAt: c.createdAt }),
540
+ };
541
+ });
542
+ // WHEN it was said. A PENDING draft and the phantom bucket have no `submitted_at`, and
543
+ // with no occurredAt the delta falls back to the POLL clock — which dates a finding by
544
+ // when the poller happened to look. The earliest comment it carries is the closest
545
+ // thing the vendor actually told us about when this subject started existing.
546
+ const reviewAt = r.submittedAt ?? r.comments.map((c) => c.createdAt).filter((d) => d !== undefined).sort()[0];
547
+ out.push({ type: 'review', id: `${p.id}:review:${r.id}`, fields: reviewFields, ...(reviewAt === undefined ? {} : { occurredAt: reviewAt }) });
548
+ }
549
+ for (const c of p.comments ?? []) {
550
+ if (c.id === undefined)
551
+ continue; // same rule as a review: no id, no subject
552
+ const commentFields = { pr: p.number, repository: p.repository };
553
+ if (c.authorLogin !== undefined)
554
+ commentFields.authorLogin = c.authorLogin;
555
+ if (c.authorType !== undefined)
556
+ commentFields.authorType = c.authorType;
557
+ if (c.body !== undefined)
558
+ commentFields.body = c.body;
559
+ if (c.createdAt !== undefined)
560
+ commentFields.createdAt = c.createdAt;
561
+ if (c.updatedAt !== undefined)
562
+ commentFields.updatedAt = c.updatedAt;
563
+ // An EDIT moves `updated_at` and nothing else, so it dates the delta ahead of the
564
+ // moment the comment was first written.
565
+ const at = c.updatedAt ?? c.createdAt;
566
+ out.push({ type: 'issue_comment', id: `${p.id}:ic:${c.id}`, fields: commentFields, ...(at === undefined ? {} : { occurredAt: at }) });
567
+ }
568
+ return out;
569
+ });
570
+ }
571
+ /**
572
+ * The `updated_at` this world last observed per PR — the budget's memory, read from the
573
+ * SHADOW (the fold of the world's own event log) rather than a cache beside it, so it
574
+ * survives a restart, is per-world like every other pulled fact, and cannot disagree with
575
+ * what was actually folded. A PR nobody has pulled yet is simply absent, and its
576
+ * conversation gets bought.
577
+ */
578
+ export function lastObservedPrUpdates(root) {
579
+ const out = {};
580
+ for (const resource of twinResources(SERVICE, root)) {
581
+ const subject = { subject: resource, fields: ownFields(resource) };
582
+ if (subject.subject.type !== 'pull_request')
583
+ continue;
584
+ const updatedAt = subject.fields.observedUpdatedAt ?? subject.fields.updatedAt;
585
+ if (typeof updatedAt === 'string' && updatedAt !== '')
586
+ out[subject.subject.id] = updatedAt;
587
+ }
588
+ return out;
589
+ }
590
+ // Map observed issues to SyncResource[] (content-bearing: title/body/state).
591
+ export function observedIssuesToResources(issues) {
592
+ return issues.map((i) => {
593
+ const fields = { number: i.number, repository: i.repository };
594
+ if (i.title !== undefined)
595
+ fields.title = i.title;
596
+ if (i.body !== undefined)
597
+ fields.body = i.body;
598
+ if (i.state !== undefined)
599
+ fields.state = i.state;
600
+ if (i.created_at !== undefined)
601
+ fields.created_at = i.created_at;
602
+ if (i.updated_at !== undefined)
603
+ fields.updated_at = i.updated_at;
604
+ return { type: 'issue', id: i.id, fields };
605
+ });
606
+ }
607
+ /**
608
+ * A push this connector DECLINES to make, as opposed to one the vendor rejected. It is
609
+ * raised when enacting the local write faithfully is impossible — the only case today is a
610
+ * threaded reply whose root has no id on the real repo — and the alternative (posting it
611
+ * somewhere else, or forwarding a twin-minted id) would write the wrong thing to a real
612
+ * account. A refusal is ledgered and the sweep continues; the action stays PENDING, so it
613
+ * pushes on a later sweep once the root is known.
614
+ */
615
+ export class GithubPushRefused extends Error {
616
+ constructor(message) {
617
+ super(`github push refused: ${message}`);
618
+ this.name = 'GithubPushRefused';
619
+ }
620
+ }
621
+ // Append ONE folded event, tolerating a per-row `Conflicting duplicate` the way the slack
622
+ // connector does (`slack-connector.ts` `fold`). `appendEventLocked` throws this when the
623
+ // store already holds a row with the SAME id/idempotencyKey but DIVERGENT content — e.g.
624
+ // a post-append line mutation raced the live writer (the peak-internal PH-216 incident).
625
+ // That divergence is a single row's integrity question and says nothing about the other
626
+ // resources in the same pull, so it must NOT abort the whole repo's fold: skip the row,
627
+ // surface its stable id, keep folding. Every OTHER error stays fatal (re-thrown).
628
+ function appendTolerant(event, root) {
629
+ // PROTOCOL 2: the pack OBSERVES a resource; the kernel diffs it against the tree and folds what
630
+ // changed onto the root's log (observe.ts). The v1 row shape is read for its subject and fields.
631
+ const entry = toEntry(event);
632
+ // Observations are field patches. Copying the pre-refresh tree here would reintroduce
633
+ // stale fields after earlier observations in the same collected batch update them.
634
+ const report = observeResource(event.service, { type: entry.subject.type, id: entry.subject.id, fields: entry.fields ?? {} }, { ...(root !== undefined ? { root } : {}), at: entry.occurredAt });
635
+ return { appended: report.appended > 0 || report.observed > 0 && report.unchanged === 0 && report.appended === 0 };
636
+ }
637
+ // A content-hashed, deterministic id for a folded evidence snapshot — never random,
638
+ // never Date.now. Re-folding the SAME observed evidence yields the same id, so
639
+ // appendEvent dedups it (idempotent pull). A changed observation yields a new id.
640
+ function evidenceHash(parts) {
641
+ // Reuse the kernel's hashing via a stable JSON of the parts; createHash isn't
642
+ // re-exported, so use a small FNV-1a over the canonical string (deterministic).
643
+ const s = JSON.stringify(parts);
644
+ let h = 0x811c9dc5;
645
+ for (let i = 0; i < s.length; i++) {
646
+ h ^= s.charCodeAt(i);
647
+ h = Math.imul(h, 0x01000193);
648
+ }
649
+ return (h >>> 0).toString(16).padStart(8, '0');
650
+ }
651
+ // Fold ONE observed PR as a `github.pull_request` event — the exact shape githubState()
652
+ // reads (content + metadata top-level + base/head under `changed`). This carries the
653
+ // COUNTS the generic delta path can't (githubState reads changedFiles/commitsCount
654
+ // top-level) and the CONTENT (title/body/state) the pull now mirrors. Idempotent via a
655
+ // content hash in the event id + idempotencyKey: a re-pull of identical observation
656
+ // appends nothing; a changed observation appends a new event.
657
+ function foldObservedPr(pr, occurredAt, root) {
658
+ const data = {
659
+ number: pr.number,
660
+ repository: pr.repository,
661
+ changed: {
662
+ ...(pr.baseRef !== undefined ? { baseRef: { after: pr.baseRef } } : {}),
663
+ ...(pr.headSha !== undefined ? { headSha: { after: pr.headSha } } : {}),
664
+ },
665
+ };
666
+ if (pr.title !== undefined)
667
+ data.title = pr.title;
668
+ if (pr.authorLogin !== undefined)
669
+ data.user_login = pr.authorLogin;
670
+ if (pr.authorType !== undefined)
671
+ data.user_type = pr.authorType;
672
+ if (pr.body !== undefined)
673
+ data.body = pr.body;
674
+ if (pr.state !== undefined)
675
+ data.state = pr.state;
676
+ if (pr.draft !== undefined)
677
+ data.draft = pr.draft;
678
+ if (pr.merged !== undefined)
679
+ data.merged = pr.merged;
680
+ if (pr.mergeCommit !== undefined)
681
+ data.mergeCommit = pr.mergeCommit;
682
+ if (pr.changedFiles !== undefined)
683
+ data.changedFiles = pr.changedFiles;
684
+ if (pr.commitsCount !== undefined)
685
+ data.commitsCount = pr.commitsCount;
686
+ // draft/merged/mergeCommit are in the hash so a draft→ready or open→merged transition
687
+ // re-folds (a new event) rather than being deduped away as an unchanged observation.
688
+ 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, pr.authorLogin, pr.authorType]);
689
+ const id = `github:pull_request:${pr.id}:${hash}`;
690
+ const event = {
691
+ id,
692
+ service: SERVICE,
693
+ type: 'github.pull_request',
694
+ schemaVersion: 1,
695
+ idempotencyKey: id,
696
+ occurredAt,
697
+ observedAt: occurredAt,
698
+ origin: 'connector',
699
+ subject: { type: 'pull_request', id: pr.id },
700
+ data,
701
+ };
702
+ return appendTolerant(event, root);
703
+ }
704
+ // Fold each review/comment as its own event, now CARRYING CONTENT (review state/body,
705
+ // comment body) — githubState() reads these to derive review_count/comment_count AND to
706
+ // surface the real text. The event id hashes the content so a changed body re-folds and
707
+ // an unchanged one is idempotent (a deterministic id per (pr, kind, index, content)).
708
+ function foldObservedExistence(pr, occurredAt, root) {
709
+ let appended = 0;
710
+ const conflictIds = [];
711
+ const emit = (type, key, extra) => {
712
+ const hash = evidenceHash([type, key, extra]);
713
+ const id = `github:${type}:${pr.id}:${key}:${hash}`;
714
+ const event = {
715
+ id,
716
+ service: SERVICE,
717
+ type: `github.${type}`,
718
+ schemaVersion: 1,
719
+ idempotencyKey: id,
720
+ occurredAt,
721
+ observedAt: occurredAt,
722
+ origin: 'connector',
723
+ subject: { type, id: `${pr.id}:${key}` },
724
+ data: { repository: pr.repository, number: pr.number, ...extra },
725
+ };
726
+ const outcome = appendTolerant(event, root);
727
+ if (outcome.appended)
728
+ appended += 1;
729
+ else if (outcome.conflictId !== undefined)
730
+ conflictIds.push(outcome.conflictId);
731
+ };
732
+ // A PR whose conversation the budget did not buy emits nothing here: the events already
733
+ // in the log stand, and githubState keeps counting them. `reviews`/`comments` are absent
734
+ // in that case, NOT empty — the difference is exactly "unknown" versus "none".
735
+ if (!pr.detailsFetched)
736
+ return { appended, conflictIds };
737
+ // The review's key is the VENDOR's review id, not its index on the page: an index moves
738
+ // when a review is deleted, which re-keys every later review onto a subject that already
739
+ // means something else. The inline comments it wrapped name that same key, so the join
740
+ // the pull made survives into the twin's own state.
741
+ (pr.reviews ?? []).forEach((r, i) => {
742
+ const key = r.id === undefined ? `rev${i}` : `rev:${r.id}`;
743
+ emit('pull_request_review', key, {
744
+ ...(r.state !== undefined ? { state: r.state } : {}),
745
+ ...(r.body !== undefined ? { body: r.body } : {}),
746
+ ...(r.submittedAt !== undefined ? { submitted_at: r.submittedAt } : {}),
747
+ ...(r.authorLogin !== undefined ? { user_login: r.authorLogin } : {}),
748
+ ...(r.authorType !== undefined ? { user_type: r.authorType === 'bot' ? 'Bot' : 'User' } : {}),
749
+ });
750
+ // THE INLINE COMMENTS REACH THE TWIN'S OWN STATE. The pull bought them and the census
751
+ // claims `pull_request_review_comment` is pulled, but nothing was emitted for them — so
752
+ // `GET /pulls/:n/comments` on the twin answered [] for a PR whose findings the pull was
753
+ // holding. Each rides its own subject, keyed by the vendor's comment id, carrying its
754
+ // anchor, its thread position, and the review that wraps it.
755
+ for (const c of r.comments) {
756
+ // WHO WROTE THE FINDING is the comment's own `user`, not the review's. The review is
757
+ // the join (`pull_request_review_id`), never the byline: a scanner App's comment can
758
+ // ride a review a human submitted, and emitting the wrapper's login served that bot
759
+ // finding to the twin as the human's. The wrapper is the fallback ONLY when the
760
+ // comment row named nobody (`user: null`), and when neither names anyone the twin
761
+ // serves no author rather than guessing one.
762
+ const userLogin = c.authorLogin ?? r.authorLogin;
763
+ const userType = c.authorType ?? r.authorType;
764
+ emit('pull_request_review_comment', `rc:${c.id}`, {
765
+ external_id: c.id,
766
+ review_key: `${pr.id}:${key}`,
767
+ ...(c.body !== undefined ? { body: c.body } : {}),
768
+ ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
769
+ ...(c.path !== undefined ? { path: c.path } : {}),
770
+ ...(c.line !== undefined ? { line: c.line } : {}),
771
+ ...(c.inReplyTo !== undefined ? { in_reply_to: `${pr.id}:rc:${c.inReplyTo}` } : {}),
772
+ ...(userLogin !== undefined ? { user_login: userLogin } : {}),
773
+ ...(userType !== undefined ? { user_type: userType === 'bot' ? 'Bot' : 'User' } : {}),
774
+ });
775
+ }
776
+ });
777
+ (pr.comments ?? []).forEach((c, i) => emit('issue_comment', c.id === undefined ? `c${i}` : `ic:${c.id}`, {
778
+ ...(c.body !== undefined ? { body: c.body } : {}),
779
+ ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
780
+ }));
781
+ return { appended, conflictIds };
782
+ }
783
+ // Fold ONE observed issue as a `github.issue` event carrying its content (title/body/
784
+ // state). githubState()'s observed fold reads these into a first-class issue. Idempotent
785
+ // via a content hash in the id: a re-pull of identical content appends nothing.
786
+ function foldObservedIssue(iss, occurredAt, root) {
787
+ const data = { number: iss.number, repository: iss.repository };
788
+ if (iss.title !== undefined)
789
+ data.title = iss.title;
790
+ if (iss.body !== undefined)
791
+ data.body = iss.body;
792
+ if (iss.state !== undefined)
793
+ data.state = iss.state;
794
+ const hash = evidenceHash([iss.number, iss.repository, iss.title, iss.body, iss.state]);
795
+ const id = `github:issue:${iss.id}:${hash}`;
796
+ const event = {
797
+ id,
798
+ service: SERVICE,
799
+ type: 'github.issue',
800
+ schemaVersion: 1,
801
+ idempotencyKey: id,
802
+ occurredAt,
803
+ observedAt: occurredAt,
804
+ origin: 'connector',
805
+ subject: { type: 'issue', id: iss.id },
806
+ data,
807
+ };
808
+ return appendTolerant(event, root);
809
+ }
810
+ /**
811
+ * PULL + FOLD: pull a repo's OBSERVED PRs AND issues and fold them into the twin (mirror
812
+ * seeding). Folds CONTENT (PR/issue title/body/state, review state/body, comment body)
813
+ * plus metadata (counts/refs + review/comment existence) — only the Non-goal (repo file
814
+ * CONTENTS) is excluded. Also threads the same resources through `syncPull` so the generic
815
+ * shadow-diff dedup path is exercised (the Linear/Slack-shared contract); the github fold
816
+ * carries the counts/content the generic delta path drops. PRs and issues share ONE
817
+ * per-repo number space (real GitHub). Re-pulling identical observations appends nothing.
818
+ *
819
+ * Per-row conflict tolerance: if a stored event has DIVERGED from what a resource now
820
+ * folds to (a `Conflicting duplicate` — e.g. a post-append line mutation raced the live
821
+ * writer), that ONE row is skipped and its stable id recorded, rather than aborting the
822
+ * whole repo's fold — one row's integrity question must not become a total observation
823
+ * outage for the ~dozens of other resources in the same pull (peak-internal PH-216). The
824
+ * result exposes `conflictsSkipped` + `conflictingIds` so a poller can log the integrity
825
+ * problem loudly instead of it being swallowed. Every OTHER append error stays fatal.
826
+ */
827
+ export async function syncGithubFromReal(execute, opts) {
828
+ const pullOpts = {
829
+ owner: opts.owner,
830
+ repo: opts.repo,
831
+ ...(opts.state ? { state: opts.state } : {}),
832
+ ...(opts.perPage ? { perPage: opts.perPage } : {}),
833
+ };
834
+ // The budget's memory rides the world, not the process: the shadow already holds the
835
+ // `updated_at` of every PR this world has folded, so a poller restarted between two pulls
836
+ // still declines to re-buy a conversation nothing has touched.
837
+ const prs = await pullGithubPrs(execute, { ...pullOpts, lastUpdatedAt: lastObservedPrUpdates(opts.root) });
838
+ const issues = await pullGithubIssues(execute, pullOpts);
839
+ // Generic shadow-diff fold (content-bearing resources: title/body/state + refs).
840
+ // PROTOCOL 2: the pack OBSERVES each resource; the kernel diffs it against the tree and folds what
841
+ // changed onto the root's log (observe.ts) — inside a refresh, collected and folded as a batch.
842
+ const pull = { observed: 0, deltasAppended: 0, unchanged: 0 };
843
+ for (const r of [...observedPrsToResources(prs), ...observedIssuesToResources(issues), ...(await observeRepositoryAndBranches(execute, opts.owner, opts.repo))]) {
844
+ const report = observeResource(SERVICE, { type: r.type, id: r.id, fields: r.fields }, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: r.occurredAt ?? opts.occurredAt });
845
+ pull.observed += 1;
846
+ pull.deltasAppended += report.appended;
847
+ pull.unchanged += report.unchanged;
848
+ }
849
+ // GitHub fold (carries counts + review/comment content the delta drops, plus issues).
850
+ // A per-row `Conflicting duplicate` is tolerated (skipped + recorded), never fatal, so a
851
+ // single diverged row can't stop later resources in the same pull from folding.
852
+ let eventsAppended = 0;
853
+ let conflictsSkipped = 0;
854
+ const conflictingIds = [];
855
+ const recordConflict = (id) => {
856
+ if (id === undefined)
857
+ return;
858
+ conflictsSkipped += 1;
859
+ conflictingIds.push(id);
860
+ };
861
+ for (const pr of prs) {
862
+ const prOutcome = foldObservedPr(pr, opts.occurredAt, opts.root);
863
+ if (prOutcome.appended)
864
+ eventsAppended += 1;
865
+ else
866
+ recordConflict(prOutcome.conflictId);
867
+ const existence = foldObservedExistence(pr, opts.occurredAt, opts.root);
868
+ eventsAppended += existence.appended;
869
+ for (const id of existence.conflictIds)
870
+ recordConflict(id);
871
+ }
872
+ for (const iss of issues) {
873
+ const issOutcome = foldObservedIssue(iss, opts.occurredAt, opts.root);
874
+ if (issOutcome.appended)
875
+ eventsAppended += 1;
876
+ else
877
+ recordConflict(issOutcome.conflictId);
878
+ }
879
+ return { observed: pull.observed, deltasAppended: pull.deltasAppended, eventsAppended, issues: issues.length, conflictsSkipped, conflictingIds };
880
+ }
881
+ // ---------------------------------------------------------------------------
882
+ // PUSH (twin → real): enact pending LOCAL writes, then confirm.
883
+ // ---------------------------------------------------------------------------
884
+ /**
885
+ * Push ONE pending GitHub action to the real vendor via the injected executor. Maps
886
+ * the twin's local write (recorded by applyGithubWrite as an action with an
887
+ * `operation` + `fields`) to the matching REST call. The injected `execute.request`
888
+ * is the SOLE credentialed boundary. Returns the real external id when the API gives
889
+ * one (PR/issue/milestone number, review/comment/status/check id, merge sha). Throws
890
+ * on a non-2xx so a failure is never silent.
891
+ *
892
+ * Covers EVERY write operation applyGithubWrite emits, each faithfully mapped to its
893
+ * GitHub REST call (method/path/body):
894
+ * pull_request.create POST .../pulls
895
+ * pull_request.update PATCH .../pulls/:n (+ PATCH .../issues/:n
896
+ * for label/assignee/milestone fields)
897
+ * pull_request.merge PUT .../pulls/:n/merge
898
+ * pull_request.request_reviewers POST .../pulls/:n/requested_reviewers
899
+ * pull_request.remove_requested_reviewers DELETE .../pulls/:n/requested_reviewers
900
+ * pull_request_review.submit POST .../pulls/:n/reviews
901
+ * pull_request_review_comment.create POST .../pulls/:n/comments
902
+ * issue.create POST .../issues
903
+ * issue.update PATCH .../issues/:n
904
+ * issue_comment.create POST .../issues/:n/comments
905
+ * commit_status.create POST .../statuses/:sha
906
+ * check_run.create POST .../check-runs
907
+ * milestone.create POST .../milestones
908
+ * milestone.update PATCH .../milestones/:n
909
+ * Pushing sends the LOCAL content (titles/bodies/diffs the fork authored) — that is
910
+ * legitimate (only PULL must not fabricate content). Unknown ops FAIL LOUDLY (throw).
911
+ */
912
+ export async function pushGithubAction(execute, action, opts = {}) {
913
+ const f = action.fields ?? {};
914
+ const repository = String(f.repository ?? action.subject.id.split('#')[0]);
915
+ const [owner, repo] = repository.split('/');
916
+ const op = action.operation ?? '';
917
+ const ensureOk = (res, label) => {
918
+ if (res.status >= 400)
919
+ throw new Error(`github push failed (${label}): HTTP ${res.status}`);
920
+ };
921
+ if (op === 'pull_request.create') {
922
+ // A draft stays a draft on the remote: the flag rides the create.
923
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls', {
924
+ owner, repo, title: f.title, body: f.body, head: f.head_ref ?? f.head_sha, base: f.base_ref, ...(f.draft === undefined ? {} : { draft: Boolean(f.draft) }),
925
+ });
926
+ ensureOk(res, 'pulls.create');
927
+ return { externalId: String(res.data?.number ?? '') };
928
+ }
929
+ if (op === 'pull_request.update') {
930
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
931
+ const params = { owner, repo, pull_number: number };
932
+ if (f.title !== undefined)
933
+ params.title = f.title;
934
+ if (f.body !== undefined)
935
+ params.body = f.body;
936
+ if (f.base_ref !== undefined)
937
+ params.base = f.base_ref;
938
+ if (f.state !== undefined)
939
+ params.state = f.state;
940
+ // labels/assignees/milestone live on the issue resource, but PATCH .../pulls/:n is the
941
+ // endpoint applyGithubWrite routes a PR edit through; GitHub accepts these on the issue
942
+ // PATCH. The PR PATCH itself ignores labels/assignees/milestone, so route those on the
943
+ // matching issue PATCH (PRs ARE issues for that endpoint) to faithfully persist them.
944
+ let externalId = String(number);
945
+ if (Object.keys(params).length > 3 || f.draft === undefined) {
946
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', params);
947
+ ensureOk(res, 'pulls.update');
948
+ externalId = String(res.data?.number ?? number);
949
+ }
950
+ if (f.labels !== undefined || f.assignees !== undefined || f.milestone !== undefined) {
951
+ const issueParams = { owner, repo, issue_number: number };
952
+ if (f.labels !== undefined)
953
+ issueParams.labels = f.labels;
954
+ if (f.assignees !== undefined)
955
+ issueParams.assignees = f.assignees;
956
+ if (f.milestone !== undefined)
957
+ issueParams.milestone = f.milestone;
958
+ const issueRes = await execute.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', issueParams);
959
+ ensureOk(issueRes, 'pulls.update.issue_fields');
960
+ }
961
+ if (f.draft !== undefined) {
962
+ if (typeof f.draft !== 'boolean')
963
+ throw new Error('github push failed (pulls.draft): expected boolean');
964
+ // Resolve the vendor's node, never send the local twin's synthetic GraphQL ID.
965
+ const current = await execute.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { owner, repo, pull_number: number });
966
+ ensureOk(current, 'pulls.draft.read');
967
+ if (typeof current.data?.node_id !== 'string' || typeof current.data?.draft !== 'boolean')
968
+ throw new Error('github push failed (pulls.draft): vendor PR identity/state missing');
969
+ if (current.data.draft !== f.draft) {
970
+ const mutation = f.draft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview';
971
+ const res = await execute.request('POST /graphql', {
972
+ query: `mutation($id:ID!){${mutation}(input:{pullRequestId:$id}){pullRequest{id isDraft}}}`,
973
+ variables: { id: current.data.node_id },
974
+ });
975
+ ensureOk(res, 'pulls.draft');
976
+ const changed = res.data?.data?.[mutation]?.pullRequest;
977
+ if (res.data?.errors?.length || changed?.id !== current.data.node_id || changed?.isDraft !== f.draft)
978
+ throw new Error('github push failed (pulls.draft): GraphQL transition was not confirmed');
979
+ }
980
+ }
981
+ return { externalId };
982
+ }
983
+ if (op === 'pull_request.merge') {
984
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
985
+ const params = { owner, repo, pull_number: number };
986
+ // The twin merges with the default (merge) method; pass through any supplied detail.
987
+ if (f.commit_title !== undefined)
988
+ params.commit_title = f.commit_title;
989
+ if (f.commit_message !== undefined)
990
+ params.commit_message = f.commit_message;
991
+ if (f.merge_method !== undefined)
992
+ params.merge_method = f.merge_method;
993
+ if (f.head_sha !== undefined)
994
+ params.sha = f.head_sha;
995
+ const res = await execute.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', params);
996
+ ensureOk(res, 'pulls.merge');
997
+ // The merge endpoint returns the merge commit sha (not the PR number).
998
+ return { externalId: String(res.data?.sha ?? f.merge_commit_sha ?? '') };
999
+ }
1000
+ if (op === 'pull_request.request_reviewers') {
1001
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
1002
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers', {
1003
+ owner, repo, pull_number: number, reviewers: f.requested_reviewers ?? [],
1004
+ });
1005
+ ensureOk(res, 'pulls.request_reviewers');
1006
+ return { externalId: String(res.data?.number ?? number) };
1007
+ }
1008
+ if (op === 'pull_request.remove_requested_reviewers') {
1009
+ const number = Number(f.number ?? action.subject.id.split('#').pop());
1010
+ const res = await execute.request('DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers', {
1011
+ owner, repo, pull_number: number, reviewers: f.requested_reviewers ?? [],
1012
+ });
1013
+ ensureOk(res, 'pulls.remove_requested_reviewers');
1014
+ return { externalId: String(res.data?.number ?? number) };
1015
+ }
1016
+ if (op === 'pull_request_review.submit') {
1017
+ const number = Number(f.number);
1018
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
1019
+ owner, repo, pull_number: number, event: f.state, body: f.body,
1020
+ });
1021
+ ensureOk(res, 'reviews.submit');
1022
+ return { externalId: String(res.data?.id ?? '') };
1023
+ }
1024
+ if (op === 'pull_request_review_comment.create') {
1025
+ const number = Number(f.number);
1026
+ // A REPLY IS NOT A NEW ROOT COMMENT. `in_reply_to` says the local write answered an
1027
+ // existing thread; POSTing it to `.../pulls/{n}/comments` started a SECOND thread on the
1028
+ // real repo, unanchored to the finding it was answering. GitHub has a dedicated reply
1029
+ // route, and it addresses the root by the VENDOR's comment id — which is never the id the
1030
+ // twin minted for its own storage. Forwarding a minted id raw is how a push lands on a
1031
+ // stranger's row (the groq incident, §9), so the root is resolved through the known
1032
+ // external ids and the push REFUSES when there is no answer.
1033
+ if (f.in_reply_to !== undefined && f.in_reply_to !== null) {
1034
+ const rootKey = String(f.in_reply_to);
1035
+ const rootExternal = opts.externalIds?.[rootKey];
1036
+ if (rootExternal === undefined || rootExternal === '') {
1037
+ throw new GithubPushRefused(`reply to comment ${rootKey}: the root comment has no known GitHub id (it was never pushed or pulled), and the twin's own id is not addressable on the real repo`);
1038
+ }
1039
+ const replyParams = { owner, repo, pull_number: number, comment_id: rootExternal, body: f.body };
1040
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies', replyParams);
1041
+ ensureOk(res, 'pulls.review_comment.reply');
1042
+ return { externalId: String(res.data?.id ?? '') };
1043
+ }
1044
+ const params = {
1045
+ owner, repo, pull_number: number, body: f.body,
1046
+ };
1047
+ // Diff-anchoring fields ride along when the local write set them (the real REST API
1048
+ // needs path + line/side + commit_id to anchor a review comment to the diff).
1049
+ if (f.path !== undefined)
1050
+ params.path = f.path;
1051
+ if (f.line !== undefined)
1052
+ params.line = f.line;
1053
+ if (f.side !== undefined)
1054
+ params.side = f.side;
1055
+ if (f.start_line !== undefined)
1056
+ params.start_line = f.start_line;
1057
+ // GitHub REFUSES a diff-anchored comment without the commit it anchors to. The local
1058
+ // write carries the PR head it was authored against; fall back to the PR's head sha so an
1059
+ // anchored comment authored without one is not rejected at the vendor.
1060
+ const commitId = f.head_sha ?? f.commit_id;
1061
+ if (commitId !== undefined)
1062
+ params.commit_id = commitId;
1063
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/comments', params);
1064
+ ensureOk(res, 'pulls.review_comment.create');
1065
+ return { externalId: String(res.data?.id ?? '') };
1066
+ }
1067
+ if (op === 'issue.create') {
1068
+ const res = await execute.request('POST /repos/{owner}/{repo}/issues', {
1069
+ owner, repo, title: f.title, body: f.body,
1070
+ });
1071
+ ensureOk(res, 'issues.create');
1072
+ return { externalId: String(res.data?.number ?? '') };
1073
+ }
1074
+ if (op === 'issue.update') {
1075
+ const number = Number(f.number ?? action.subject.id.split('#issue:').pop());
1076
+ const params = { owner, repo, issue_number: number };
1077
+ if (f.title !== undefined)
1078
+ params.title = f.title;
1079
+ if (f.body !== undefined)
1080
+ params.body = f.body;
1081
+ if (f.state !== undefined)
1082
+ params.state = f.state;
1083
+ if (f.labels !== undefined)
1084
+ params.labels = f.labels;
1085
+ if (f.assignees !== undefined)
1086
+ params.assignees = f.assignees;
1087
+ if (f.milestone !== undefined)
1088
+ params.milestone = f.milestone;
1089
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', params);
1090
+ ensureOk(res, 'issues.update');
1091
+ return { externalId: String(res.data?.number ?? number) };
1092
+ }
1093
+ if (op === 'issue_comment.create') {
1094
+ const number = Number(f.number);
1095
+ const res = await execute.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
1096
+ owner, repo, issue_number: number, body: f.body,
1097
+ });
1098
+ ensureOk(res, 'issue_comment.create');
1099
+ return { externalId: String(res.data?.id ?? '') };
1100
+ }
1101
+ if (op === 'commit_status.create') {
1102
+ const sha = String(f.sha ?? '');
1103
+ const params = { owner, repo, sha, state: f.state };
1104
+ if (f.context !== undefined)
1105
+ params.context = f.context;
1106
+ if (f.description !== undefined)
1107
+ params.description = f.description;
1108
+ if (f.target_url !== undefined)
1109
+ params.target_url = f.target_url;
1110
+ const res = await execute.request('POST /repos/{owner}/{repo}/statuses/{sha}', params);
1111
+ ensureOk(res, 'statuses.create');
1112
+ return { externalId: String(res.data?.id ?? '') };
1113
+ }
1114
+ if (op === 'check_run.create') {
1115
+ const params = { owner, repo, name: f.name, head_sha: f.head_sha };
1116
+ if (f.status !== undefined)
1117
+ params.status = f.status;
1118
+ if (f.conclusion !== undefined)
1119
+ params.conclusion = f.conclusion;
1120
+ if (f.details_url !== undefined)
1121
+ params.details_url = f.details_url;
1122
+ if (f.started_at !== undefined)
1123
+ params.started_at = f.started_at;
1124
+ if (f.completed_at !== undefined)
1125
+ params.completed_at = f.completed_at;
1126
+ const res = await execute.request('POST /repos/{owner}/{repo}/check-runs', params);
1127
+ ensureOk(res, 'check_runs.create');
1128
+ return { externalId: String(res.data?.id ?? '') };
1129
+ }
1130
+ if (op === 'milestone.create') {
1131
+ const params = { owner, repo, title: f.title };
1132
+ if (f.description !== undefined)
1133
+ params.description = f.description;
1134
+ if (f.state !== undefined)
1135
+ params.state = f.state;
1136
+ if (f.due_on !== undefined)
1137
+ params.due_on = f.due_on;
1138
+ const res = await execute.request('POST /repos/{owner}/{repo}/milestones', params);
1139
+ ensureOk(res, 'milestones.create');
1140
+ return { externalId: String(res.data?.number ?? '') };
1141
+ }
1142
+ if (op === 'milestone.update') {
1143
+ const number = Number(f.number ?? action.subject.id.split('#milestone:').pop());
1144
+ const params = { owner, repo, milestone_number: number };
1145
+ if (f.title !== undefined)
1146
+ params.title = f.title;
1147
+ if (f.description !== undefined)
1148
+ params.description = f.description;
1149
+ if (f.state !== undefined)
1150
+ params.state = f.state;
1151
+ if (f.due_on !== undefined)
1152
+ params.due_on = f.due_on;
1153
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/milestones/{milestone_number}', params);
1154
+ ensureOk(res, 'milestones.update');
1155
+ return { externalId: String(res.data?.number ?? number) };
1156
+ }
1157
+ // A repository the working copy declared: reality already holds it (the link names it) —
1158
+ // it is confirmed as-is, and created only where it truly is absent.
1159
+ if (op === 'repository.create') {
1160
+ // The record names owner and name as fields (its subject is a sequence id, not a slug).
1161
+ const repoOwner = String(f.owner ?? owner);
1162
+ const repoName = String(f.name ?? repo);
1163
+ const probe = await execute.request('GET /repos/{owner}/{repo}', { owner: repoOwner, repo: repoName }).catch(() => undefined);
1164
+ if (probe !== undefined && probe.status < 300)
1165
+ return { externalId: `${repoOwner}/${repoName}` };
1166
+ const res = await execute.request('POST /orgs/{org}/repos', { org: repoOwner, name: repoName, ...(f.private === undefined ? {} : { private: f.private }), ...(f.default_branch === undefined ? {} : { default_branch: f.default_branch }) });
1167
+ ensureOk(res, 'repos.create');
1168
+ return { externalId: `${repoOwner}/${repoName}` };
1169
+ }
1170
+ // THE BRANCH AND ITS FILES cross too (an API-plane implementation: a seat that writes through
1171
+ // the Contents API, never git). A ref that already exists on the remote is the same ref.
1172
+ const alreadyThere = (e) => /already exists/iu.test(e instanceof Error ? e.message : String(e));
1173
+ if (op === 'git_ref.create' || op === 'branch.create') {
1174
+ const ref = op === 'git_ref.create' ? String(f.ref) : `refs/heads/${String(f.name)}`;
1175
+ const sha = String(op === 'git_ref.create' ? f.object_sha : f.commit_sha);
1176
+ try {
1177
+ const res = await execute.request('POST /repos/{owner}/{repo}/git/refs', { owner, repo, ref, sha });
1178
+ ensureOk(res, 'git.refs.create');
1179
+ }
1180
+ catch (e) {
1181
+ if (!alreadyThere(e))
1182
+ throw e;
1183
+ }
1184
+ return { externalId: ref };
1185
+ }
1186
+ if (op === 'content_file.create' || op === 'content_file.update' || op === 'content_file.delete') {
1187
+ const path = String(f.path);
1188
+ const branch = f.branch === undefined ? undefined : String(f.branch);
1189
+ // GitHub demands the current blob sha to update or delete; the remote is asked, not guessed.
1190
+ let currentSha;
1191
+ try {
1192
+ const current = await execute.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path, ...(branch === undefined ? {} : { ref: branch }) });
1193
+ const data = current.data;
1194
+ if (typeof data?.sha === 'string')
1195
+ currentSha = data.sha;
1196
+ }
1197
+ catch {
1198
+ currentSha = undefined;
1199
+ }
1200
+ if (op === 'content_file.delete') {
1201
+ const res = await execute.request('DELETE /repos/{owner}/{repo}/contents/{path}', { owner, repo, path, message: `Delete ${path}`, ...(currentSha === undefined ? {} : { sha: currentSha }), ...(branch === undefined ? {} : { branch }) });
1202
+ ensureOk(res, 'contents.delete');
1203
+ return { externalId: path };
1204
+ }
1205
+ const res = await execute.request('PUT /repos/{owner}/{repo}/contents/{path}', { owner, repo, path, message: `${op === 'content_file.create' ? 'Add' : 'Update'} ${path}`, content: String(f.content_b64 ?? ''), ...(currentSha === undefined ? {} : { sha: currentSha }), ...(branch === undefined ? {} : { branch }) });
1206
+ ensureOk(res, 'contents.put');
1207
+ const data = res.data;
1208
+ return { externalId: typeof data?.content?.sha === 'string' ? data.content.sha : path };
1209
+ }
1210
+ throw new Error(`github push: unsupported operation "${op}" for subject ${action.subject.type}`);
1211
+ }
1212
+ /**
1213
+ * The REAL-GitHub id of every review comment this world can address: the vendor id a PULL
1214
+ * observed for it. A locally-minted comment id means nothing on the real repo, so this is
1215
+ * what a threaded reply resolves its root through — and a root that is absent here is a
1216
+ * root the push must refuse rather than guess at.
1217
+ */
1218
+ function knownReviewCommentExternalIds(root) {
1219
+ const out = {};
1220
+ for (const c of githubState(root).comments) {
1221
+ if (c.kind === 'review' && c.external_id !== undefined && c.external_id !== '')
1222
+ out[String(c.id)] = c.external_id;
1223
+ }
1224
+ return out;
1225
+ }
1226
+ /**
1227
+ * THE R14 PUSH ADAPTER for github (jira's `pushJiraToRemote` is the reference; this
1228
+ * transcribes its METHOD): pending local actions cross to the remote through the kernel's
1229
+ * ONE RemoteExecute seam under a sealed credential the pack never sees, and each pushed
1230
+ * action is confirmed in the local log. Anchored naming: `createGithubTwinFetch` pairs
1231
+ * with `syncGithubFromRemote` and `pushGithubToRemote`. Push-plane only — a read refuses
1232
+ * loudly. The route grammar is the one `pushGithubAction` speaks (`METHOD /path/{param}`,
1233
+ * templated params in the path, the rest as the JSON body).
1234
+ */
1235
+ /** This pack's vendor executor over the kernel's ONE RemoteExecute (push plane). */
1236
+ export function githubPushExecutor(execute) {
1237
+ const executor = {
1238
+ async request(route, params = {}) {
1239
+ const sp = route.indexOf(' ');
1240
+ const method = route.slice(0, sp);
1241
+ // The push plane writes; the one read it makes is the current blob sha a Contents write
1242
+ // needs (GitHub refuses an update without it) — a read that serves the push, never a pull.
1243
+ let path = route.slice(sp + 1);
1244
+ const body = {};
1245
+ for (const [key, value] of Object.entries(params)) {
1246
+ const token = `{${key}}`;
1247
+ if (path.includes(token))
1248
+ path = path.replace(token, encodeURIComponent(String(value)));
1249
+ else if (value !== undefined)
1250
+ body[key] = value;
1251
+ }
1252
+ const read = method === 'GET' || method === 'HEAD';
1253
+ if (read && Object.keys(body).length > 0) {
1254
+ const qs = new URLSearchParams();
1255
+ for (const [key, value] of Object.entries(body))
1256
+ qs.set(key, String(value));
1257
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
1258
+ }
1259
+ const res = await execute({
1260
+ method,
1261
+ path,
1262
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'volter-twin-push', ...(read ? {} : { 'content-type': 'application/json' }) },
1263
+ ...(read ? {} : { body: JSON.stringify(body) }),
1264
+ });
1265
+ // The vendor's own words ride the error: push health on the link must say WHY reality
1266
+ // refused (a bare status is a guess), bounded so a page never lands in a ledger.
1267
+ if (res.status >= 300)
1268
+ throw new Error(`github remote push: ${route} answered ${res.status}${res.body === '' ? '' : ` — ${res.body.slice(0, 300)}`}`);
1269
+ return { status: res.status, data: res.body === '' ? undefined : JSON.parse(res.body) };
1270
+ },
1271
+ };
1272
+ return executor;
1273
+ }
1274
+ /** THE ANCHORED PUSH SEAM (contract "The push arm is the kernel's transaction; a pack performs one
1275
+ * action"): perform ONE pending action against GitHub over the kernel executor; the vendor's id comes back. */
1276
+ /** The vendor's id in the pack's own subject grammar: a number replaces the trailing number of the
1277
+ * local id (`acme/web#issue:1` → `acme/web#issue:57`, `acme/web#3` → `acme/web#9`, `comment:2` →
1278
+ * `comment:184`); anything else (a sha, a ref, a path, an owner/name) leaves the address as it is
1279
+ * and rides on the receipt as `vendorId`. The kernel rebinds the subject to what comes back
1280
+ * (contract "A pushed write adopts the vendor's id"). */
1281
+ export function adoptGithubId(localId, vendorId) {
1282
+ if (!vendorId)
1283
+ return localId;
1284
+ if (/^\d+$/.test(vendorId) && /\d+$/.test(localId))
1285
+ return localId.replace(/\d+$/, vendorId);
1286
+ if (vendorId === localId || vendorId.includes('#') || vendorId.startsWith('comment:') || vendorId.startsWith('review:'))
1287
+ return vendorId;
1288
+ return localId;
1289
+ }
1290
+ export async function performGithubAction(execute, action, ctx) {
1291
+ const externalIds = knownReviewCommentExternalIds(ctx.root);
1292
+ const replyTo = action.fields?.in_reply_to;
1293
+ if (replyTo !== undefined) {
1294
+ const local = `comment:${replyTo}`;
1295
+ const adopted = ctx.resolve('pull_request_review_comment', local);
1296
+ if (adopted !== local && /^comment:\d+$/.test(adopted))
1297
+ externalIds[String(replyTo)] = adopted.slice('comment:'.length);
1298
+ }
1299
+ const out = await pushGithubAction(githubPushExecutor(execute), action, { externalIds });
1300
+ const adopted = adoptGithubId(action.subject.id, out.externalId);
1301
+ // what the vendor minted rides on the receipt as fields: the number under which the row now lives
1302
+ const numeric = out.externalId !== undefined && /^\d+$/.test(out.externalId) && adopted !== action.subject.id;
1303
+ const data = { ...(numeric ? { number: Number(out.externalId) } : {}), ...(out.externalId && out.externalId !== adopted ? { vendorId: out.externalId } : {}) };
1304
+ return { externalId: adopted, ...(Object.keys(data).length ? { data } : {}) };
1305
+ }
1306
+ /**
1307
+ * THE REPOSITORY AND ITS BRANCHES are observed with the PRs and issues: a working copy's
1308
+ * confirmed writes (a branch it cut, the repo it declared) survive only as what reality
1309
+ * shows back, so the pull says what reality holds — the repo and every branch head.
1310
+ */
1311
+ export async function observeRepositoryAndBranches(execute, owner, repo) {
1312
+ const full = `${owner}/${repo}`;
1313
+ const out = [];
1314
+ const repoRes = await execute.request('GET /repos/{owner}/{repo}', { owner, repo }).catch(() => undefined);
1315
+ const r = repoRes?.data;
1316
+ if (repoRes !== undefined && repoRes.status < 300 && r !== undefined) {
1317
+ out.push({ type: 'repository', id: `repo:${full}`, fields: { owner, name: repo, full_name: full, default_branch: String(r.default_branch ?? 'main'), private: r.private === true, description: r.description ?? null } });
1318
+ }
1319
+ const branchesRes = await execute.request('GET /repos/{owner}/{repo}/branches', { owner, repo, per_page: 100 }).catch(() => undefined);
1320
+ const list = Array.isArray(branchesRes?.data) ? branchesRes.data : [];
1321
+ for (const b of list) {
1322
+ if (typeof b.name !== 'string')
1323
+ continue;
1324
+ out.push({ type: 'branch', id: `branch:${full}#${b.name}`, fields: { repository: full, name: b.name, commit_sha: String(b.commit?.sha ?? '') } });
1325
+ }
1326
+ return out;
1327
+ }
1328
+ /**
1329
+ * THE R14 SCHEDULED-PULL ADAPTER (jira is the reference; this transcribes its METHOD):
1330
+ * adapts this pack's executor onto the kernel's ONE RemoteExecute seam, so the twins
1331
+ * service can schedule pulls with a sealed credential the pack never sees. Anchored
1332
+ * naming: `createGithubTwinFetch` pairs with `syncGithubFromRemote`.
1333
+ *
1334
+ * The link's origin names the REPO, not just the API host — a link is a git remote:
1335
+ * https://api.github.com/repos/{owner}/{repo}
1336
+ * Egress still anchors at the origin's HOST (the service's RemoteExecute discards the
1337
+ * path when routing), so the path here is pure identity. Pull-plane only — every
1338
+ * non-read request refuses loudly.
1339
+ *
1340
+ * Rate note, counted over the routes this pull actually calls: 2 list reads (the repo and
1341
+ * its branches) + 1 PR list + 1 issue list, and then, per PR whose `updated_at` MOVED since
1342
+ * the last pull, three PAGED conversation reads — the reviews, the issue comments and the
1343
+ * inline comments — at 1 request each for a PR under 100 rows and up to 10 each beyond
1344
+ * that. So a poll costs 4 + 3·CHANGED at the floor and 4 + 30·CHANGED at the ceiling; an
1345
+ * unchanged PR costs nothing beyond the list. The link's sync interval still carries the
1346
+ * budget — 60s at the default page floods a PAT's 5000/hr the first time it walks a busy
1347
+ * repo; schedule github links at ≥120s.
1348
+ */
1349
+ export async function syncGithubFromRemote(execute, opts = {}) {
1350
+ // the repository is the end of the path: a served world's twin lives under /<org>/<world>/github
1351
+ const match = /\/repos\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(opts.origin === undefined ? '' : new URL(opts.origin).pathname);
1352
+ if (match === null) {
1353
+ throw new Error('github remote pull: the link origin must name the repo — https://api.github.com/repos/{owner}/{repo}');
1354
+ }
1355
+ const [, owner, repo] = match;
1356
+ const executor = {
1357
+ async request(route, params = {}) {
1358
+ const sp = route.indexOf(' ');
1359
+ const method = route.slice(0, sp);
1360
+ if (method !== 'GET' && method !== 'HEAD') {
1361
+ throw new Error(`syncGithubFromRemote is the PULL plane: ${route} is a push-side operation and never runs here`);
1362
+ }
1363
+ // Same route grammar liveGithubExecute speaks: substitute {param} templates,
1364
+ // remaining params become the query string (a read has no body).
1365
+ let path = route.slice(sp + 1);
1366
+ const rest = {};
1367
+ for (const [key, value] of Object.entries(params)) {
1368
+ const token = `{${key}}`;
1369
+ if (path.includes(token))
1370
+ path = path.replace(token, encodeURIComponent(String(value)));
1371
+ else
1372
+ rest[key] = value;
1373
+ }
1374
+ if (Object.keys(rest).length > 0) {
1375
+ const qs = new URLSearchParams();
1376
+ for (const [key, value] of Object.entries(rest))
1377
+ qs.set(key, String(value));
1378
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
1379
+ }
1380
+ // GitHub answers 403 to any request without a User-Agent (its documented rule);
1381
+ // workerd's fetch sends none, so the adapter names itself.
1382
+ const res = await execute({ method, path, headers: { accept: 'application/vnd.github+json', 'user-agent': 'volter-twin-pull' } });
1383
+ if (res.status >= 300)
1384
+ throw new Error(`github remote pull: ${route} answered ${res.status}`);
1385
+ return { status: res.status, data: res.body === '' ? undefined : JSON.parse(res.body) };
1386
+ },
1387
+ };
1388
+ // The pull plane's instant is observation time — fetch metadata, never served content
1389
+ // (R9 governs the serve path; this is the freshness half of the git model). `all`
1390
+ // because the merge IS the observation that ends a job — closed PRs must fold.
1391
+ return await syncGithubFromReal(executor, {
1392
+ owner, repo,
1393
+ occurredAt: new Date().toISOString(),
1394
+ state: opts.state ?? 'all',
1395
+ perPage: opts.perPage ?? 20,
1396
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
1397
+ });
1398
+ }