@volter/twin-github 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,8 +18,17 @@
18
18
  // text it didn't receive; the one excluded thing is the Non-goal — actual repository
19
19
  // file CONTENTS (git blob bytes) — which the pull does not fetch. Content-bearing twin
20
20
  // state thus comes from BOTH the observed fold and LOCAL writes, which push reconciles.
21
- import { appendEvent, confirmAction, pendingActions, syncPull } from '@volter/twin';
22
- import type { SyncResource, WorldServiceEvent } from '@volter/twin';
21
+ //
22
+ // THE CONVERSATION IS A SET OF SUBJECTS (not a fold on the PR): every review and every issue
23
+ // comment pulls as its OWN SyncResource, because the shadow diff emits one delta per SUBJECT.
24
+ // Folded onto the PR as `reviewCount`/`latestReview`, two reviews between two polls collapsed
25
+ // into one delta (the second overwrote the first), and nothing said WHO reviewed. A review
26
+ // carries its inline (diff-anchored) comments, since GitHub wraps every inline comment in a
27
+ // review — `pull_request_review_id` on each `/pulls/{n}/comments` row is the join.
28
+ import { appendEvent, assertBudgetGuardIntact, buildShadowState, confirmAction, pendingActions, syncPull } from '@volter/twin';
29
+ import type { RemoteExecute, SyncResource, WorldServiceEvent } from '@volter/twin';
30
+ import { GithubBudget, githubCallWeight, type GithubBudgetOptions } from './github-budget.ts';
31
+ import { githubState } from './github-twin.ts';
23
32
 
24
33
  const SERVICE = 'github';
25
34
 
@@ -41,14 +50,61 @@ export interface GithubExecute {
41
50
  ): Promise<{ status: number; data: any }>;
42
51
  }
43
52
 
53
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
54
+ export type LiveGithubOptions = {
55
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
56
+ fetchImpl?: typeof fetch;
57
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
58
+ budget?: GithubBudget;
59
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
60
+ budgetOptions?: GithubBudgetOptions;
61
+ };
62
+
44
63
  /**
45
64
  * A live executor against the real GitHub REST API (token = the user's own PAT).
46
65
  * Constructed with the real @octokit/rest in PROD by the CALLER and passed in; this
47
66
  * helper shows the shape without importing the SDK. Kept tiny + dependency-free: it
48
67
  * uses `fetch`, so the pack pulls in no network client. Live runs may instead pass a
49
68
  * real `new Octokit({ auth }).request` bound into a `{ request }` object.
69
+ *
70
+ * THIS IS THE ONE PLACE this pack issues a live `api.github.com` request, and therefore the one
71
+ * place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the
72
+ * request goes out (`checkBudget`, which THROWS `GithubBudgetError` instead of returning when the
73
+ * ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `Retry-After` /
74
+ * 403-or-429 / `x-ratelimit-remaining: 0` signal becomes a persisted cooldown that makes every
75
+ * later call fail fast WITHOUT touching GitHub. The weights ARE GitHub's own published point costs
76
+ * (1 for a read, 5 for a write) — see `github-budget.ts`. There is deliberately NO option to
77
+ * disable the guard, and no value a caller can pass for `budget` that yields an unguarded client —
78
+ * but NOT immunity from a caller who WANTS one (a fresh `budgetOptions.path` or an injected clock
79
+ * restores the allowance; the kernel header states that limit and this does not upgrade it).
50
80
  */
51
- export function liveGithubExecute(token: string, baseUrl = 'https://api.github.com'): GithubExecute {
81
+ export function liveGithubExecute(
82
+ token: string,
83
+ baseUrl = 'https://api.github.com',
84
+ opts: LiveGithubOptions = {},
85
+ ): GithubExecute {
86
+ // `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
87
+ // UNMODIFIED GithubBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
88
+ // Proxy that traps it are all refused, because all three are one-liners that would otherwise
89
+ // hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
90
+ // not a check). What this cannot stop is deliberate sabotage from inside the process (an
91
+ // injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
92
+ // otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
93
+ const doFetch = opts.fetchImpl ?? fetch;
94
+ // The default ledger is keyed by a hash of THIS PAT — GitHub's primary limit is per token, so a
95
+ // cwd-scoped ledger would hand it a fresh allowance per checkout/worktree/CI matrix leg.
96
+ // ONE expression decides which budget is used, so there is no second, weaker test that could
97
+ // disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
98
+ // must be an UNMODIFIED GithubBudget — a duck-typed stand-in, a SUBCLASS overriding
99
+ // `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
100
+ // would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
101
+ // alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
102
+ // sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
103
+ // header states that limit rather than pretending otherwise. This closes the accident and the
104
+ // one-liner, which are the shapes that actually happen.
105
+ const budget = opts.budget !== undefined && opts.budget !== null
106
+ ? assertBudgetGuardIntact(opts.budget, GithubBudget, 'liveGithubExecute')
107
+ : new GithubBudget({ token, ...(opts.budgetOptions ?? {}) });
52
108
  return {
53
109
  async request(route, params = {}) {
54
110
  const sp = route.indexOf(' ');
@@ -71,7 +127,12 @@ export function liveGithubExecute(token: string, baseUrl = 'https://api.github.c
71
127
  for (const [k, v] of Object.entries(rest)) qs.set(k, String(v));
72
128
  path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
73
129
  }
74
- const res = await fetch(`${baseUrl}${path}`, {
130
+ // Priced by the ROUTE (`"GET /repos/{owner}/{repo}/pulls"`), which is what GitHub's own docs
131
+ // and point table name endpoints by — stable across owners and repos.
132
+ const weight = githubCallWeight(route);
133
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
134
+ const reservation = budget.checkBudget(weight);
135
+ const res = await doFetch(`${baseUrl}${path}`, {
75
136
  method,
76
137
  headers: {
77
138
  Authorization: `Bearer ${token}`,
@@ -80,7 +141,16 @@ export function liveGithubExecute(token: string, baseUrl = 'https://api.github.c
80
141
  },
81
142
  ...(isRead ? {} : { body: JSON.stringify(rest) }),
82
143
  });
83
- return { status: res.status, data: res.status === 204 ? undefined : await res.json() };
144
+ const resHeaders: Record<string, string> = {};
145
+ res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
146
+ const data = res.status === 204 ? undefined : await res.json();
147
+ // Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
148
+ // `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
149
+ // first either way, so the refusal survives the throw. GitHub answers a tripped SECONDARY
150
+ // limit with 403 + `Retry-After` rather than 429, which is why the header is read on every
151
+ // response and not only on a 429.
152
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
153
+ return { status: res.status, data };
84
154
  },
85
155
  };
86
156
  }
@@ -92,8 +162,56 @@ export function liveGithubExecute(token: string, baseUrl = 'https://api.github.c
92
162
  // The shape of an observed PR. Carries the CONTENT the REST PR object returns (title/
93
163
  // body/state) plus metadata (counts + refs) and the BODIES of reviews/comments. The
94
164
  // only excluded thing is the Non-goal: actual repository file CONTENTS are never pulled.
95
- type ObservedReview = { state?: string; body?: string; submittedAt?: string };
96
- type ObservedComment = { body?: string; createdAt?: string };
165
+
166
+ // One INLINE (diff-anchored) review comment, as it rides its review. `position` and the
167
+ // outdated flag are deliberately NOT carried: GitHub recomputes both on every push, so
168
+ // folding them would make each push a delta on every review that ever touched the file.
169
+ type ObservedReviewComment = {
170
+ id: string;
171
+ /** WHO wrote THIS finding, from the comment's own `user` — the row the vendor sent, not
172
+ * the review that wraps it. A review is a join key, not an authorship claim: a scanner
173
+ * App's finding rides a review a human submitted, a threaded reply rides the root's
174
+ * review, and the phantom bucket has no author at all. The pull BOUGHT this field and
175
+ * used to throw it away, so a bot finding was served under the human reviewer's login —
176
+ * the exact fact a consumer triaging findings reads. Absent when the vendor named nobody
177
+ * (`user: null`, a deleted account); the wrapper's author is then the only evidence
178
+ * there is, and the fold falls back to it rather than inventing one. */
179
+ authorLogin?: string;
180
+ authorType?: 'bot' | 'user';
181
+ path?: string;
182
+ line?: number;
183
+ body?: string;
184
+ inReplyTo?: string;
185
+ createdAt?: string;
186
+ };
187
+ // One review: a verdict, its words, WHO said them, and the inline comments it wrapped.
188
+ type ObservedReview = {
189
+ id?: string;
190
+ /** True for a review NOBODY SHOWED US — one the reviews page never returned. Two shapes
191
+ * wear it: the PHANTOM bucket (`unattached`), holding inline comments whose review id was
192
+ * missing, and a NAMED orphan, whose id an inline comment gave us but whose row never
193
+ * arrived (a review deleted between the two reads, a page boundary). Both carry comments
194
+ * and NOTHING ELSE — no state, no author, no verdict — and a consumer must be able to tell
195
+ * either from a real review it merely has no state for yet, so both say so. */
196
+ partial?: boolean;
197
+ authorLogin?: string;
198
+ authorType?: 'bot' | 'user';
199
+ state?: string;
200
+ body?: string;
201
+ submittedAt?: string;
202
+ commitId?: string;
203
+ comments: ObservedReviewComment[];
204
+ };
205
+ // One issue comment (the PR's conversation tab). `updatedAt` distinguishes an EDIT — same
206
+ // author, same position, new words — from the comment that was already there.
207
+ type ObservedComment = {
208
+ id?: string;
209
+ authorLogin?: string;
210
+ authorType?: 'bot' | 'user';
211
+ body?: string;
212
+ createdAt?: string;
213
+ updatedAt?: string;
214
+ };
97
215
  type ObservedPr = {
98
216
  id: string; // owner/repo#number
99
217
  number: number;
@@ -108,12 +226,49 @@ type ObservedPr = {
108
226
  headSha?: string;
109
227
  changedFiles?: number;
110
228
  commitsCount?: number;
111
- reviewCount: number;
112
- commentCount: number;
113
- reviews: ObservedReview[];
114
- comments: ObservedComment[];
229
+ authorLogin?: string;
230
+ /** The PR's own `updated_at` — the provider instant that dates its delta AND the stamp
231
+ * the next pull compares against to decide whether its conversation is worth buying. */
232
+ updatedAt?: string;
233
+ /** False when the budget skipped this PR's reviews/comments (its `updated_at` had not
234
+ * moved). The counts and the conversation subjects are then OMITTED rather than
235
+ * reported as zero — an unbought fact is not an observation of absence. */
236
+ detailsFetched: boolean;
237
+ /** How many reviews this PR has — counted over exactly the set `latestReview` is drawn
238
+ * from (submitted, non-PENDING), so the count and the newest verdict can never disagree. */
239
+ reviewCount?: number;
240
+ /** How many issue comments the conversation read bought. Emitted onto the subject beside
241
+ * `reviewCount` (it used to be computed here and dropped on the floor). */
242
+ commentCount?: number;
243
+ reviews?: ObservedReview[];
244
+ comments?: ObservedComment[];
115
245
  };
116
246
 
247
+ // GitHub says a machine wrote something two ways: `user.type === 'Bot'` on the App-authored
248
+ // row, and the `[bot]` login suffix every GitHub App account carries. A consumer triaging a
249
+ // review feed sorts bot findings from human ones, so read both rather than trusting either.
250
+ //
251
+ // READ THE ASYMMETRY BEFORE YOU GATE ON THIS. `bot` ⇒ a machine wrote it is RELIABLE: only a
252
+ // GitHub App account is typed `Bot` or suffixed `[bot]`. The converse is NOT: `user` does not
253
+ // mean a human. A GitHub App acting through a USER token, a service/machine account, and an
254
+ // Organization all come back typed `User` (or `Organization`) with an ordinary login — and a
255
+ // review by a DELETED account answers `user: null`, so neither field exists and no author
256
+ // type is emitted at all. So `authorType === 'bot'` is safe to act on; `authorType === 'user'`
257
+ // is "not identified as a bot", never "a person did this".
258
+ function authorTypeOf(user: any): 'bot' | 'user' {
259
+ const type = user?.type === undefined || user?.type === null ? '' : String(user.type);
260
+ const login = user?.login === undefined || user?.login === null ? '' : String(user.login);
261
+ return type.toLowerCase() === 'bot' || login.endsWith('[bot]') ? 'bot' : 'user';
262
+ }
263
+
264
+ // The author fields as they ride a review/comment subject: the login when GitHub gave one
265
+ // (a deleted account answers `user: null`), and the human/bot split derived from it.
266
+ function authorFields(user: any): { authorLogin?: string; authorType?: 'bot' | 'user' } {
267
+ if (user === undefined || user === null) return {};
268
+ const login = user.login === undefined || user.login === null ? undefined : String(user.login);
269
+ return { ...(login === undefined ? {} : { authorLogin: login }), authorType: authorTypeOf(user) };
270
+ }
271
+
117
272
  // The shape of an observed ISSUE (GET .../issues with PRs excluded). Carries content
118
273
  // (title/body/state) — issues exist on pull as first-class objects, not just via local
119
274
  // writes. Numbers share the per-repo PR/issue space (real GitHub).
@@ -129,49 +284,199 @@ type ObservedIssue = {
129
284
  /**
130
285
  * Pull OBSERVED PRs for a repo via the injected executor. Folds the CONTENT the REST
131
286
  * PR object returns (title/body/state) plus metadata (counts/refs) and the BODIES of
132
- * each review + issue comment. The ONLY thing not pulled is the Non-goal — actual
133
- * repository file CONTENTS (per-file diffs/blob bytes are not fetched here).
287
+ * each review + issue comment + INLINE (diff-anchored) review comment. The ONLY thing not
288
+ * pulled is the Non-goal — actual repository file CONTENTS (per-file diffs/blob bytes are
289
+ * not fetched here).
290
+ *
291
+ * Three reads per PR buy the conversation: the reviews, the issue comments, and
292
+ * `/pulls/{n}/comments` — the inline comments, where every bot finding lives and which
293
+ * this pull never fetched before. They are fetched ONCE per PR and joined to their review
294
+ * by `pull_request_review_id` rather than per review, because GitHub has no
295
+ * comments-of-one-review route worth N more calls.
296
+ *
297
+ * `lastUpdatedAt` is the budget: a PR whose `updated_at` has not moved since the last
298
+ * observation has no new conversation to buy, so those three reads are skipped entirely
299
+ * (`detailsFetched: false`) and N unchanged PRs cost only the list call.
300
+ */
301
+ /** The subject suffix for the PHANTOM review — the bucket an inline comment lands in when
302
+ * nothing names the review that wrapped it. A fixed word, not a mint: it is the SAME
303
+ * bucket every pull, so a consumer's feed sees one growing subject rather than a new one
304
+ * per poll. */
305
+ const UNATTACHED_REVIEW = 'unattached';
306
+
307
+ /**
308
+ * The reviews that COUNT as reviews: submitted, and not a PENDING draft. GitHub returns a
309
+ * reviewer's unsubmitted draft on the reviews page with `state: 'PENDING'` and no
310
+ * `submitted_at` — nobody has said anything yet — and this pull also carries a PHANTOM
311
+ * review holding orphaned inline comments, which is a bucket, not a verdict. Both belong in
312
+ * the `reviews` list (they are real rows) and NEITHER may be the PR's "latest review" or be
313
+ * counted as one, so exactly one predicate decides both and they can never disagree.
134
314
  */
315
+ function submittedReviews(reviews: ObservedReview[]): ObservedReview[] {
316
+ return reviews.filter((r) => r.submittedAt !== undefined && (r.state ?? '').toUpperCase() !== 'PENDING');
317
+ }
318
+
319
+ /** GitHub's page ceiling for these list reads. */
320
+ const GITHUB_MAX_PER_PAGE = 100;
321
+ /** How many pages one conversation read will walk before it stops asking. 100×10 = 1000
322
+ * reviews or inline comments on a single PR — past that the pull is buying a pathological
323
+ * outlier at real rate cost, and the bound is what keeps a poll's spend knowable. */
324
+ const MAX_CONVERSATION_PAGES = 10;
325
+
326
+ /**
327
+ * Read EVERY page of a list route, not just the first. GitHub's default page is 30 rows, so
328
+ * a busy PR's 31st review — or its 31st inline comment, where a scanner's findings pile up
329
+ * fastest — simply did not exist as far as the pull was concerned, and the missing rows read
330
+ * downstream as an absence rather than a page boundary. Asks for the vendor's maximum page
331
+ * (100, the same as `observeRepositoryAndBranches`) and stops on the first short page.
332
+ */
333
+ async function readAllPages(
334
+ execute: GithubExecute,
335
+ route: string,
336
+ params: Record<string, unknown>,
337
+ ): Promise<any[]> {
338
+ const out: any[] = [];
339
+ for (let page = 1; page <= MAX_CONVERSATION_PAGES; page++) {
340
+ const res = await execute.request(route, { ...params, per_page: GITHUB_MAX_PER_PAGE, page });
341
+ const rows: any[] = Array.isArray(res.data) ? res.data : [];
342
+ out.push(...rows);
343
+ if (rows.length < GITHUB_MAX_PER_PAGE) break;
344
+ }
345
+ return out;
346
+ }
347
+
135
348
  export async function pullGithubPrs(
136
349
  execute: GithubExecute,
137
- opts: { owner: string; repo: string; state?: 'open' | 'closed' | 'all'; perPage?: number },
350
+ opts: {
351
+ owner: string;
352
+ repo: string;
353
+ state?: 'open' | 'closed' | 'all';
354
+ perPage?: number;
355
+ /** How many recently-updated CLOSED PRs an open-only pull also sweeps (default 20). */
356
+ closedPerPage?: number;
357
+ /** `owner/repo#n` → the `updated_at` last observed for it. */
358
+ lastUpdatedAt?: Record<string, string>;
359
+ },
138
360
  ): Promise<ObservedPr[]> {
139
361
  const { owner, repo } = opts;
140
362
  const repository = `${owner}/${repo}`;
363
+ const state = opts.state ?? 'open';
364
+ // Same ordering as the closed sweep below: newest-updated first. The two pages are read
365
+ // against ONE budget of rows, so an open list that came back in the vendor's default order
366
+ // (by number, descending) would spend that budget on whatever happened to be numbered
367
+ // highest rather than on what actually moved since the last poll.
141
368
  const list = await execute.request('GET /repos/{owner}/{repo}/pulls', {
142
- owner, repo, state: opts.state ?? 'open', per_page: opts.perPage ?? 30,
369
+ owner, repo, state, sort: 'updated', direction: 'desc', per_page: opts.perPage ?? 30,
143
370
  });
144
371
  if (list.status >= 400) throw new Error(`github pull failed (list pulls): HTTP ${list.status}`);
145
372
  const nodes: any[] = Array.isArray(list.data) ? list.data : [];
373
+ // A MERGE IS A CLOSED PR: an open-only list never observes the event that ends a job —
374
+ // the PR simply vanishes from the next pull. `state: 'all'` (what syncGithubFromRemote
375
+ // asks for) already covers it; an open-only caller gets a BOUNDED second page of the
376
+ // most recently updated closed PRs instead, so the merge folds without a pull walking
377
+ // the repo's whole closed history.
378
+ const closedPerPage = opts.closedPerPage ?? 20;
379
+ if (state === 'open' && closedPerPage > 0) {
380
+ const closed = await execute.request('GET /repos/{owner}/{repo}/pulls', {
381
+ owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: closedPerPage,
382
+ });
383
+ if (closed.status < 400 && Array.isArray(closed.data)) nodes.push(...closed.data);
384
+ }
146
385
  const out: ObservedPr[] = [];
386
+ // The two list pages can name the same PR — one that closed BETWEEN the two reads appears
387
+ // open on the first page and closed on the second. Keeping the page that named it first
388
+ // kept the STALE row and reported a merged PR as still open. Keep the row whose
389
+ // `updated_at` is LATER instead (a stampless row never displaces a stamped one), which is
390
+ // the vendor's own answer to "which of these two observations is the newer".
391
+ const byNumber = new Map<number, any>();
147
392
  for (const n of nodes) {
148
393
  const number = Number(n.number);
149
- // Reviews + comments: fold the BODIES (content) and count. A real connector would
150
- // page these; the fake returns the list, which we map to body-bearing entries.
151
- const reviewsRes = await execute.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { owner, repo, pull_number: number });
152
- const commentsRes = await execute.request('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { owner, repo, issue_number: number });
153
- const reviewNodes: any[] = Array.isArray(reviewsRes.data) ? reviewsRes.data : [];
154
- const commentNodes: any[] = Array.isArray(commentsRes.data) ? commentsRes.data : [];
155
- const pr: ObservedPr = {
156
- id: `${repository}#${number}`,
157
- number,
158
- repository,
159
- reviewCount: reviewNodes.length,
160
- commentCount: commentNodes.length,
161
- reviews: reviewNodes.map((r) => {
162
- const review: ObservedReview = {};
394
+ // A row with no usable number is not a PR we can address: `Number(undefined)` is NaN and
395
+ // every such row collapsed onto ONE key, so the second one silently replaced the first.
396
+ if (!Number.isFinite(number)) continue;
397
+ const prev = byNumber.get(number);
398
+ if (prev === undefined) { byNumber.set(number, n); continue; }
399
+ const a = prev.updated_at === undefined || prev.updated_at === null ? '' : String(prev.updated_at);
400
+ const b = n.updated_at === undefined || n.updated_at === null ? '' : String(n.updated_at);
401
+ if (b > a) byNumber.set(number, n);
402
+ }
403
+ for (const n of byNumber.values()) {
404
+ const number = Number(n.number);
405
+ const updatedAt = n.updated_at === undefined || n.updated_at === null ? undefined : String(n.updated_at);
406
+ // BUDGET: GitHub moves a PR's `updated_at` on every review, comment, edit and push, so
407
+ // an unmoved stamp means there is no conversation to buy. A PR that reports no stamp at
408
+ // all is always fetched — silence is not evidence of stillness.
409
+ const detailsFetched = updatedAt === undefined || opts.lastUpdatedAt?.[`${repository}#${number}`] !== updatedAt;
410
+ const pr: ObservedPr = { id: `${repository}#${number}`, number, repository, detailsFetched };
411
+ if (updatedAt !== undefined) pr.updatedAt = updatedAt;
412
+ if (detailsFetched) {
413
+ // ALL THREE conversation reads are PAGED (see readAllPages): a review page boundary
414
+ // orphaned inline comments from their reviews, an inline page boundary simply lost
415
+ // findings, and the issue-comment read — which `commentCount` publishes — stopped at
416
+ // the vendor's default 30, so a busy conversation reported a truncated count as if it
417
+ // were the whole tab.
418
+ const reviewNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { owner, repo, pull_number: number });
419
+ const commentNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { owner, repo, issue_number: number });
420
+ const inlineNodes = await readAllPages(execute, 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments', { owner, repo, pull_number: number });
421
+ // The review is the unit: every inline comment names the review that wrapped it.
422
+ const inlineByReview = new Map<string, ObservedReviewComment[]>();
423
+ for (const c of inlineNodes) {
424
+ if (c.id === undefined || c.id === null) continue; // no id, no stable place in a thread
425
+ const reviewId = c.pull_request_review_id === undefined || c.pull_request_review_id === null ? '' : String(c.pull_request_review_id);
426
+ // The row's OWN author rides with it. The review is the join, never the byline.
427
+ const comment: ObservedReviewComment = { id: String(c.id), ...authorFields(c.user) };
428
+ if (c.path !== undefined && c.path !== null) comment.path = String(c.path);
429
+ if (c.line !== undefined && c.line !== null) comment.line = Number(c.line);
430
+ if (c.body !== undefined && c.body !== null) comment.body = String(c.body);
431
+ if (c.in_reply_to_id !== undefined && c.in_reply_to_id !== null) comment.inReplyTo = String(c.in_reply_to_id);
432
+ if (c.created_at !== undefined && c.created_at !== null) comment.createdAt = String(c.created_at);
433
+ const bucket = inlineByReview.get(reviewId);
434
+ if (bucket) bucket.push(comment);
435
+ else inlineByReview.set(reviewId, [comment]);
436
+ }
437
+ const reviews: ObservedReview[] = reviewNodes.map((r) => {
438
+ const id = r.id === undefined || r.id === null ? undefined : String(r.id);
439
+ const review: ObservedReview = { ...(id === undefined ? {} : { id }), ...authorFields(r.user), comments: id === undefined ? [] : (inlineByReview.get(id) ?? []) };
163
440
  if (r.state !== undefined && r.state !== null) review.state = String(r.state);
164
441
  if (r.body !== undefined && r.body !== null) review.body = String(r.body);
165
442
  if (r.submitted_at !== undefined && r.submitted_at !== null) review.submittedAt = String(r.submitted_at);
443
+ if (r.commit_id !== undefined && r.commit_id !== null) review.commitId = String(r.commit_id);
444
+ if (id !== undefined) inlineByReview.delete(id);
166
445
  return review;
167
- }),
168
- comments: commentNodes.map((c) => {
169
- const comment: ObservedComment = {};
446
+ });
447
+ // An inline comment whose review the reviews page did not name (a page boundary, a
448
+ // review deleted between the two reads) still EXISTS — it rides a review carrying its
449
+ // id, the `partial` flag and its comments, and nothing else, rather than being dropped.
450
+ // Inventing a state or an author for a review nobody showed us would be the worse lie.
451
+ const unattached: ObservedReviewComment[] = [];
452
+ for (const [reviewId, comments] of inlineByReview) {
453
+ // NO review id at all. This used to `continue` — the comment was OBSERVED and then
454
+ // silently discarded, which is the one thing a pull may never do with a fact it
455
+ // bought. It hangs off a fixed phantom subject per PR instead, flagged `partial` so
456
+ // nothing mistakes it for a review whose verdict simply has not arrived.
457
+ if (reviewId === '') { unattached.push(...comments); continue; }
458
+ // A review whose id we know but whose ROW never arrived is just as unshown as the
459
+ // phantom: state, author and verdict are all absent because nobody sent them. It
460
+ // carries `partial` for the same reason the phantom does — without it a consumer
461
+ // reads a stateless orphan as a review whose fields merely have not landed yet.
462
+ reviews.push({ id: reviewId, partial: true, comments });
463
+ }
464
+ if (unattached.length > 0) reviews.push({ id: UNATTACHED_REVIEW, partial: true, comments: unattached });
465
+ pr.reviews = reviews;
466
+ // Counted over the SAME set the newest verdict is drawn from — a raw row count included
467
+ // PENDING drafts and the phantom bucket, so `reviewCount` and `latestReview` described
468
+ // different populations of the same PR.
469
+ pr.reviewCount = submittedReviews(reviews).length;
470
+ pr.comments = commentNodes.map((c) => {
471
+ const comment: ObservedComment = { ...(c.id === undefined || c.id === null ? {} : { id: String(c.id) }), ...authorFields(c.user) };
170
472
  if (c.body !== undefined && c.body !== null) comment.body = String(c.body);
171
473
  if (c.created_at !== undefined && c.created_at !== null) comment.createdAt = String(c.created_at);
474
+ if (c.updated_at !== undefined && c.updated_at !== null) comment.updatedAt = String(c.updated_at);
172
475
  return comment;
173
- }),
174
- };
476
+ });
477
+ pr.commentCount = commentNodes.length;
478
+ }
479
+ if (n.user?.login !== undefined && n.user?.login !== null) pr.authorLogin = String(n.user.login);
175
480
  // CONTENT: title/body/state are real text the REST PR object returns — fold them.
176
481
  if (n.title !== undefined && n.title !== null) pr.title = String(n.title);
177
482
  if (n.body !== undefined && n.body !== null) pr.body = String(n.body);
@@ -230,8 +535,15 @@ export async function pullGithubIssues(
230
535
  // contract). Carries CONTENT (title/body/state) now that pull is a content mirror, plus
231
536
  // the refs. baseRef/headSha ride under their twin names so the github fold (which reads
232
537
  // data.changed.baseRef.after / .headSha.after) picks them up.
538
+ //
539
+ // The PR is one subject and its CONVERSATION is many: each review (`…#<n>:review:<id>`,
540
+ // carrying the inline comments it wrapped) and each issue comment (`…#<n>:ic:<id>`) rides
541
+ // as its own resource, so two reviews between two polls are two deltas and a consumer can
542
+ // say who reviewed. Each dates its own delta — a review by `submitted_at`, a comment by
543
+ // `updated_at ?? created_at` — because a feed ordering a conversation wants the moment it
544
+ // was said, not the moment the poll happened to look.
233
545
  export function observedPrsToResources(prs: ObservedPr[]): SyncResource[] {
234
- return prs.map((p) => {
546
+ return prs.flatMap((p) => {
235
547
  const fields: SyncResource['fields'] = { number: p.number, repository: p.repository };
236
548
  if (p.title !== undefined) fields.title = p.title;
237
549
  if (p.body !== undefined) fields.body = p.body;
@@ -244,10 +556,109 @@ export function observedPrsToResources(prs: ObservedPr[]): SyncResource[] {
244
556
  if (p.mergeCommit !== undefined) fields.mergeCommit = p.mergeCommit;
245
557
  if (p.baseRef !== undefined) fields.baseRef = p.baseRef;
246
558
  if (p.headSha !== undefined) fields.headSha = p.headSha;
247
- return { type: 'pull_request', id: p.id, fields };
559
+ // WHO opened it, and when GitHub last saw it change — the stamp the next pull's budget
560
+ // compares against, kept on the subject rather than in a cache beside it.
561
+ if (p.authorLogin !== undefined) fields.authorLogin = p.authorLogin;
562
+ if (p.updatedAt !== undefined) fields.updatedAt = p.updatedAt;
563
+ // The reviewers' WORDS ride the observation: how many reviews, and the newest one's
564
+ // verdict and body, so a delta on the feed says what a reviewer said (a consumer decides
565
+ // from the words, never from a bare count moving). The reviews themselves are subjects
566
+ // below; this summary stays for a consumer that reads only pull_request. A pull whose
567
+ // budget skipped the conversation OMITS both — the shadow keeps what it already holds,
568
+ // because not buying a fact is not observing its absence.
569
+ if (p.reviewCount !== undefined) fields.reviewCount = p.reviewCount;
570
+ if (p.commentCount !== undefined) fields.commentCount = p.commentCount;
571
+ if (p.reviews !== undefined) {
572
+ // THE NEWEST VERDICT, by the vendor's clock — not `reviews.at(-1)`, which was list
573
+ // ORDER with the phantom bucket appended after it: the last element was routinely a
574
+ // review with no state and no body at all (`{}` on the feed), and a reviewer's unsent
575
+ // PENDING draft could take the slot from the approval that actually landed. The latest
576
+ // review is the one with the greatest `submitted_at` among the reviews that HAVE one
577
+ // and are not PENDING — the same set `reviewCount` counts.
578
+ const submitted = submittedReviews(p.reviews);
579
+ const latest = submitted.reduce<ObservedReview | undefined>((best, r) => (best === undefined || r.submittedAt! >= best.submittedAt! ? r : best), undefined);
580
+ fields.latestReview = latest === undefined ? null : { ...(latest.state === undefined ? {} : { state: latest.state }), ...(latest.body === undefined ? {} : { body: latest.body }), ...(latest.submittedAt === undefined ? {} : { submittedAt: latest.submittedAt }) };
581
+ }
582
+ const out: SyncResource[] = [{ type: 'pull_request', id: p.id, fields, ...(p.updatedAt === undefined ? {} : { occurredAt: p.updatedAt }) }];
583
+ for (const r of p.reviews ?? []) {
584
+ // No review id, no subject: an id minted from a position would move under the next
585
+ // review and thread a consumer's feed onto the wrong conversation.
586
+ if (r.id === undefined) continue;
587
+ const reviewFields: SyncResource['fields'] = { pr: p.number, repository: p.repository };
588
+ if (r.authorLogin !== undefined) reviewFields.authorLogin = r.authorLogin;
589
+ if (r.authorType !== undefined) reviewFields.authorType = r.authorType;
590
+ if (r.state !== undefined) reviewFields.state = r.state;
591
+ if (r.body !== undefined) reviewFields.body = r.body;
592
+ if (r.submittedAt !== undefined) reviewFields.submittedAt = r.submittedAt;
593
+ if (r.commitId !== undefined) reviewFields.commitId = r.commitId;
594
+ // The PHANTOM bucket says so on the wire. It holds inline comments nothing named a
595
+ // review for, so it has no state, no author and no verdict — and without this flag a
596
+ // consumer could not tell it from a real review whose fields simply had not arrived.
597
+ if (r.partial === true) reviewFields.partial = true;
598
+ // Always present, even empty: a review that later grows an inline comment is then a
599
+ // delta on THIS field rather than a field appearing from nowhere. SORTED BY ID, because
600
+ // the vendor is free to hand the same comments back in a different order and a
601
+ // reordered array is a field change — a delta on a conversation nobody touched.
602
+ reviewFields.comments = [...r.comments].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((c) => {
603
+ // EACH FINDING'S OWN AUTHOR rides with it, not the wrapping review's. A consumer
604
+ // grading a finding reads the FINDING's author: a scanner App's comment inside a
605
+ // human's review is a bot finding, and serving the wrapper's login called it a
606
+ // human's. The wrapper is the fallback only for a row that named nobody at all.
607
+ const authorLogin = c.authorLogin ?? r.authorLogin;
608
+ const authorType = c.authorType ?? r.authorType;
609
+ return {
610
+ id: c.id,
611
+ ...(authorLogin === undefined ? {} : { authorLogin }),
612
+ ...(authorType === undefined ? {} : { authorType }),
613
+ ...(c.path === undefined ? {} : { path: c.path }),
614
+ ...(c.line === undefined ? {} : { line: c.line }),
615
+ ...(c.body === undefined ? {} : { body: c.body }),
616
+ ...(c.inReplyTo === undefined ? {} : { inReplyTo: c.inReplyTo }),
617
+ ...(c.createdAt === undefined ? {} : { createdAt: c.createdAt }),
618
+ };
619
+ });
620
+ // WHEN it was said. A PENDING draft and the phantom bucket have no `submitted_at`, and
621
+ // with no occurredAt the delta falls back to the POLL clock — which dates a finding by
622
+ // when the poller happened to look. The earliest comment it carries is the closest
623
+ // thing the vendor actually told us about when this subject started existing.
624
+ const reviewAt = r.submittedAt ?? r.comments.map((c) => c.createdAt).filter((d): d is string => d !== undefined).sort()[0];
625
+ out.push({ type: 'review', id: `${p.id}:review:${r.id}`, fields: reviewFields, ...(reviewAt === undefined ? {} : { occurredAt: reviewAt }) });
626
+ }
627
+ for (const c of p.comments ?? []) {
628
+ if (c.id === undefined) continue; // same rule as a review: no id, no subject
629
+ const commentFields: SyncResource['fields'] = { pr: p.number, repository: p.repository };
630
+ if (c.authorLogin !== undefined) commentFields.authorLogin = c.authorLogin;
631
+ if (c.authorType !== undefined) commentFields.authorType = c.authorType;
632
+ if (c.body !== undefined) commentFields.body = c.body;
633
+ if (c.createdAt !== undefined) commentFields.createdAt = c.createdAt;
634
+ if (c.updatedAt !== undefined) commentFields.updatedAt = c.updatedAt;
635
+ // An EDIT moves `updated_at` and nothing else, so it dates the delta ahead of the
636
+ // moment the comment was first written.
637
+ const at = c.updatedAt ?? c.createdAt;
638
+ out.push({ type: 'issue_comment', id: `${p.id}:ic:${c.id}`, fields: commentFields, ...(at === undefined ? {} : { occurredAt: at }) });
639
+ }
640
+ return out;
248
641
  });
249
642
  }
250
643
 
644
+ /**
645
+ * The `updated_at` this world last observed per PR — the budget's memory, read from the
646
+ * SHADOW (the fold of the world's own event log) rather than a cache beside it, so it
647
+ * survives a restart, is per-world like every other pulled fact, and cannot disagree with
648
+ * what was actually folded. A PR nobody has pulled yet is simply absent, and its
649
+ * conversation gets bought.
650
+ */
651
+ export function lastObservedPrUpdates(root?: string): Record<string, string> {
652
+ const shadow = buildShadowState(SERVICE, () => null, root);
653
+ const out: Record<string, string> = {};
654
+ for (const subject of Object.values(shadow.subjects)) {
655
+ if (subject.subject.type !== 'pull_request') continue;
656
+ const updatedAt = subject.fields.updatedAt;
657
+ if (typeof updatedAt === 'string' && updatedAt !== '') out[subject.subject.id] = updatedAt;
658
+ }
659
+ return out;
660
+ }
661
+
251
662
  // Map observed issues to SyncResource[] (content-bearing: title/body/state).
252
663
  export function observedIssuesToResources(issues: ObservedIssue[]): SyncResource[] {
253
664
  return issues.map((i) => {
@@ -259,6 +670,44 @@ export function observedIssuesToResources(issues: ObservedIssue[]): SyncResource
259
670
  });
260
671
  }
261
672
 
673
+ /**
674
+ * A push this connector DECLINES to make, as opposed to one the vendor rejected. It is
675
+ * raised when enacting the local write faithfully is impossible — the only case today is a
676
+ * threaded reply whose root has no id on the real repo — and the alternative (posting it
677
+ * somewhere else, or forwarding a twin-minted id) would write the wrong thing to a real
678
+ * account. A refusal is ledgered and the sweep continues; the action stays PENDING, so it
679
+ * pushes on a later sweep once the root is known.
680
+ */
681
+ export class GithubPushRefused extends Error {
682
+ constructor(message: string) {
683
+ super(`github push refused: ${message}`);
684
+ this.name = 'GithubPushRefused';
685
+ }
686
+ }
687
+
688
+ // The outcome of a tolerant append: whether a new event was written, and — when the
689
+ // store already holds a DIVERGENT row under the same id — the stable id of the row that
690
+ // conflicted. See `appendTolerant`.
691
+ type AppendOutcome = { appended: boolean; conflictId?: string };
692
+
693
+ // Append ONE folded event, tolerating a per-row `Conflicting duplicate` the way the slack
694
+ // connector does (`slack-connector.ts` `fold`). `appendEventLocked` throws this when the
695
+ // store already holds a row with the SAME id/idempotencyKey but DIVERGENT content — e.g.
696
+ // a post-append line mutation raced the live writer (the peak-internal PH-216 incident).
697
+ // That divergence is a single row's integrity question and says nothing about the other
698
+ // resources in the same pull, so it must NOT abort the whole repo's fold: skip the row,
699
+ // surface its stable id, keep folding. Every OTHER error stays fatal (re-thrown).
700
+ function appendTolerant(event: WorldServiceEvent, root?: string): AppendOutcome {
701
+ try {
702
+ return { appended: appendEvent(event, root).appended };
703
+ } catch (error) {
704
+ if (error instanceof Error && error.message.includes('Conflicting duplicate')) {
705
+ return { appended: false, conflictId: (event as unknown as { id: string }).id };
706
+ }
707
+ throw error;
708
+ }
709
+ }
710
+
262
711
  // A content-hashed, deterministic id for a folded evidence snapshot — never random,
263
712
  // never Date.now. Re-folding the SAME observed evidence yields the same id, so
264
713
  // appendEvent dedups it (idempotent pull). A changed observation yields a new id.
@@ -277,7 +726,7 @@ function evidenceHash(parts: unknown[]): string {
277
726
  // top-level) and the CONTENT (title/body/state) the pull now mirrors. Idempotent via a
278
727
  // content hash in the event id + idempotencyKey: a re-pull of identical observation
279
728
  // appends nothing; a changed observation appends a new event.
280
- function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): boolean {
729
+ function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): AppendOutcome {
281
730
  const data: Record<string, unknown> = {
282
731
  number: pr.number,
283
732
  repository: pr.repository,
@@ -310,15 +759,16 @@ function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): bool
310
759
  subject: { type: 'pull_request', id: pr.id },
311
760
  data,
312
761
  } as unknown as WorldServiceEvent;
313
- return appendEvent(event, root).appended;
762
+ return appendTolerant(event, root);
314
763
  }
315
764
 
316
765
  // Fold each review/comment as its own event, now CARRYING CONTENT (review state/body,
317
766
  // comment body) — githubState() reads these to derive review_count/comment_count AND to
318
767
  // surface the real text. The event id hashes the content so a changed body re-folds and
319
768
  // an unchanged one is idempotent (a deterministic id per (pr, kind, index, content)).
320
- function foldObservedExistence(pr: ObservedPr, occurredAt: string, root?: string): number {
769
+ function foldObservedExistence(pr: ObservedPr, occurredAt: string, root?: string): { appended: number; conflictIds: string[] } {
321
770
  let appended = 0;
771
+ const conflictIds: string[] = [];
322
772
  const emit = (type: string, key: string, extra: Record<string, unknown>): void => {
323
773
  const hash = evidenceHash([type, key, extra]);
324
774
  const id = `github:${type}:${pr.id}:${key}:${hash}`;
@@ -334,24 +784,65 @@ function foldObservedExistence(pr: ObservedPr, occurredAt: string, root?: string
334
784
  subject: { type, id: `${pr.id}:${key}` },
335
785
  data: { repository: pr.repository, number: pr.number, ...extra },
336
786
  } as unknown as WorldServiceEvent;
337
- if (appendEvent(event, root).appended) appended += 1;
787
+ const outcome = appendTolerant(event, root);
788
+ if (outcome.appended) appended += 1;
789
+ else if (outcome.conflictId !== undefined) conflictIds.push(outcome.conflictId);
338
790
  };
339
- pr.reviews.forEach((r, i) => emit('pull_request_review', `rev${i}`, {
340
- ...(r.state !== undefined ? { state: r.state } : {}),
341
- ...(r.body !== undefined ? { body: r.body } : {}),
342
- ...(r.submittedAt !== undefined ? { submitted_at: r.submittedAt } : {}),
343
- }));
344
- pr.comments.forEach((c, i) => emit('issue_comment', `c${i}`, {
791
+ // A PR whose conversation the budget did not buy emits nothing here: the events already
792
+ // in the log stand, and githubState keeps counting them. `reviews`/`comments` are absent
793
+ // in that case, NOT empty the difference is exactly "unknown" versus "none".
794
+ if (!pr.detailsFetched) return { appended, conflictIds };
795
+ // The review's key is the VENDOR's review id, not its index on the page: an index moves
796
+ // when a review is deleted, which re-keys every later review onto a subject that already
797
+ // means something else. The inline comments it wrapped name that same key, so the join
798
+ // the pull made survives into the twin's own state.
799
+ (pr.reviews ?? []).forEach((r, i) => {
800
+ const key = r.id === undefined ? `rev${i}` : `rev:${r.id}`;
801
+ emit('pull_request_review', key, {
802
+ ...(r.state !== undefined ? { state: r.state } : {}),
803
+ ...(r.body !== undefined ? { body: r.body } : {}),
804
+ ...(r.submittedAt !== undefined ? { submitted_at: r.submittedAt } : {}),
805
+ ...(r.authorLogin !== undefined ? { user_login: r.authorLogin } : {}),
806
+ ...(r.authorType !== undefined ? { user_type: r.authorType === 'bot' ? 'Bot' : 'User' } : {}),
807
+ });
808
+ // THE INLINE COMMENTS REACH THE TWIN'S OWN STATE. The pull bought them and the census
809
+ // claims `pull_request_review_comment` is pulled, but nothing was emitted for them — so
810
+ // `GET /pulls/:n/comments` on the twin answered [] for a PR whose findings the pull was
811
+ // holding. Each rides its own subject, keyed by the vendor's comment id, carrying its
812
+ // anchor, its thread position, and the review that wraps it.
813
+ for (const c of r.comments) {
814
+ // WHO WROTE THE FINDING is the comment's own `user`, not the review's. The review is
815
+ // the join (`pull_request_review_id`), never the byline: a scanner App's comment can
816
+ // ride a review a human submitted, and emitting the wrapper's login served that bot
817
+ // finding to the twin as the human's. The wrapper is the fallback ONLY when the
818
+ // comment row named nobody (`user: null`), and when neither names anyone the twin
819
+ // serves no author rather than guessing one.
820
+ const userLogin = c.authorLogin ?? r.authorLogin;
821
+ const userType = c.authorType ?? r.authorType;
822
+ emit('pull_request_review_comment', `rc:${c.id}`, {
823
+ external_id: c.id,
824
+ review_key: `${pr.id}:${key}`,
825
+ ...(c.body !== undefined ? { body: c.body } : {}),
826
+ ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
827
+ ...(c.path !== undefined ? { path: c.path } : {}),
828
+ ...(c.line !== undefined ? { line: c.line } : {}),
829
+ ...(c.inReplyTo !== undefined ? { in_reply_to: `${pr.id}:rc:${c.inReplyTo}` } : {}),
830
+ ...(userLogin !== undefined ? { user_login: userLogin } : {}),
831
+ ...(userType !== undefined ? { user_type: userType === 'bot' ? 'Bot' : 'User' } : {}),
832
+ });
833
+ }
834
+ });
835
+ (pr.comments ?? []).forEach((c, i) => emit('issue_comment', `c${i}`, {
345
836
  ...(c.body !== undefined ? { body: c.body } : {}),
346
837
  ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
347
838
  }));
348
- return appended;
839
+ return { appended, conflictIds };
349
840
  }
350
841
 
351
842
  // Fold ONE observed issue as a `github.issue` event carrying its content (title/body/
352
843
  // state). githubState()'s observed fold reads these into a first-class issue. Idempotent
353
844
  // via a content hash in the id: a re-pull of identical content appends nothing.
354
- function foldObservedIssue(iss: ObservedIssue, occurredAt: string, root?: string): boolean {
845
+ function foldObservedIssue(iss: ObservedIssue, occurredAt: string, root?: string): AppendOutcome {
355
846
  const data: Record<string, unknown> = { number: iss.number, repository: iss.repository };
356
847
  if (iss.title !== undefined) data.title = iss.title;
357
848
  if (iss.body !== undefined) data.body = iss.body;
@@ -370,7 +861,7 @@ function foldObservedIssue(iss: ObservedIssue, occurredAt: string, root?: string
370
861
  subject: { type: 'issue', id: iss.id },
371
862
  data,
372
863
  } as unknown as WorldServiceEvent;
373
- return appendEvent(event, root).appended;
864
+ return appendTolerant(event, root);
374
865
  }
375
866
 
376
867
  /**
@@ -381,36 +872,62 @@ function foldObservedIssue(iss: ObservedIssue, occurredAt: string, root?: string
381
872
  * shadow-diff dedup path is exercised (the Linear/Slack-shared contract); the github fold
382
873
  * carries the counts/content the generic delta path drops. PRs and issues share ONE
383
874
  * per-repo number space (real GitHub). Re-pulling identical observations appends nothing.
875
+ *
876
+ * Per-row conflict tolerance: if a stored event has DIVERGED from what a resource now
877
+ * folds to (a `Conflicting duplicate` — e.g. a post-append line mutation raced the live
878
+ * writer), that ONE row is skipped and its stable id recorded, rather than aborting the
879
+ * whole repo's fold — one row's integrity question must not become a total observation
880
+ * outage for the ~dozens of other resources in the same pull (peak-internal PH-216). The
881
+ * result exposes `conflictsSkipped` + `conflictingIds` so a poller can log the integrity
882
+ * problem loudly instead of it being swallowed. Every OTHER append error stays fatal.
384
883
  */
385
884
  export async function syncGithubFromReal(
386
885
  execute: GithubExecute,
387
886
  opts: { owner: string; repo: string; root?: string; occurredAt: string; state?: 'open' | 'closed' | 'all'; perPage?: number },
388
- ): Promise<{ observed: number; deltasAppended: number; eventsAppended: number; issues: number }> {
887
+ ): Promise<{ observed: number; deltasAppended: number; eventsAppended: number; issues: number; conflictsSkipped: number; conflictingIds: string[] }> {
389
888
  const pullOpts = {
390
889
  owner: opts.owner,
391
890
  repo: opts.repo,
392
891
  ...(opts.state ? { state: opts.state } : {}),
393
892
  ...(opts.perPage ? { perPage: opts.perPage } : {}),
394
893
  };
395
- const prs = await pullGithubPrs(execute, pullOpts);
894
+ // The budget's memory rides the world, not the process: the shadow already holds the
895
+ // `updated_at` of every PR this world has folded, so a poller restarted between two pulls
896
+ // still declines to re-buy a conversation nothing has touched.
897
+ const prs = await pullGithubPrs(execute, { ...pullOpts, lastUpdatedAt: lastObservedPrUpdates(opts.root) });
396
898
  const issues = await pullGithubIssues(execute, pullOpts);
397
899
  // Generic shadow-diff fold (content-bearing resources: title/body/state + refs).
398
900
  const pull = syncPull({
399
901
  service: SERVICE,
400
- resources: [...observedPrsToResources(prs), ...observedIssuesToResources(issues)],
902
+ resources: [...observedPrsToResources(prs), ...observedIssuesToResources(issues), ...(await observeRepositoryAndBranches(execute, opts.owner, opts.repo))],
401
903
  occurredAt: opts.occurredAt,
402
904
  ...(opts.root !== undefined ? { root: opts.root } : {}),
403
905
  });
404
906
  // GitHub fold (carries counts + review/comment content the delta drops, plus issues).
907
+ // A per-row `Conflicting duplicate` is tolerated (skipped + recorded), never fatal, so a
908
+ // single diverged row can't stop later resources in the same pull from folding.
405
909
  let eventsAppended = 0;
910
+ let conflictsSkipped = 0;
911
+ const conflictingIds: string[] = [];
912
+ const recordConflict = (id: string | undefined): void => {
913
+ if (id === undefined) return;
914
+ conflictsSkipped += 1;
915
+ conflictingIds.push(id);
916
+ };
406
917
  for (const pr of prs) {
407
- if (foldObservedPr(pr, opts.occurredAt, opts.root)) eventsAppended += 1;
408
- eventsAppended += foldObservedExistence(pr, opts.occurredAt, opts.root);
918
+ const prOutcome = foldObservedPr(pr, opts.occurredAt, opts.root);
919
+ if (prOutcome.appended) eventsAppended += 1;
920
+ else recordConflict(prOutcome.conflictId);
921
+ const existence = foldObservedExistence(pr, opts.occurredAt, opts.root);
922
+ eventsAppended += existence.appended;
923
+ for (const id of existence.conflictIds) recordConflict(id);
409
924
  }
410
925
  for (const iss of issues) {
411
- if (foldObservedIssue(iss, opts.occurredAt, opts.root)) eventsAppended += 1;
926
+ const issOutcome = foldObservedIssue(iss, opts.occurredAt, opts.root);
927
+ if (issOutcome.appended) eventsAppended += 1;
928
+ else recordConflict(issOutcome.conflictId);
412
929
  }
413
- return { observed: pull.observed, deltasAppended: pull.deltasAppended, eventsAppended, issues: issues.length };
930
+ return { observed: pull.observed, deltasAppended: pull.deltasAppended, eventsAppended, issues: issues.length, conflictsSkipped, conflictingIds };
414
931
  }
415
932
 
416
933
  // ---------------------------------------------------------------------------
@@ -448,6 +965,11 @@ export async function syncGithubFromReal(
448
965
  export async function pushGithubAction(
449
966
  execute: GithubExecute,
450
967
  action: { operation?: string; subject: { type: string; id: string }; fields?: Record<string, unknown> },
968
+ opts: {
969
+ /** Twin comment id → the id that row has on the REAL repo (pulled from GitHub, or
970
+ * returned by an earlier push in this sweep). A reply resolves its root here. */
971
+ externalIds?: Record<string, string>;
972
+ } = {},
451
973
  ): Promise<{ externalId: string }> {
452
974
  const f = action.fields ?? {};
453
975
  const repository = String(f.repository ?? action.subject.id.split('#')[0]);
@@ -459,8 +981,9 @@ export async function pushGithubAction(
459
981
  };
460
982
 
461
983
  if (op === 'pull_request.create') {
984
+ // A draft stays a draft on the remote: the flag rides the create.
462
985
  const res = await execute.request('POST /repos/{owner}/{repo}/pulls', {
463
- owner, repo, title: f.title, body: f.body, head: f.head_sha, base: f.base_ref,
986
+ 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) }),
464
987
  });
465
988
  ensureOk(res, 'pulls.create');
466
989
  return { externalId: String(res.data?.number ?? '') };
@@ -527,6 +1050,24 @@ export async function pushGithubAction(
527
1050
  }
528
1051
  if (op === 'pull_request_review_comment.create') {
529
1052
  const number = Number(f.number);
1053
+ // A REPLY IS NOT A NEW ROOT COMMENT. `in_reply_to` says the local write answered an
1054
+ // existing thread; POSTing it to `.../pulls/{n}/comments` started a SECOND thread on the
1055
+ // real repo, unanchored to the finding it was answering. GitHub has a dedicated reply
1056
+ // route, and it addresses the root by the VENDOR's comment id — which is never the id the
1057
+ // twin minted for its own storage. Forwarding a minted id raw is how a push lands on a
1058
+ // stranger's row (the groq incident, §9), so the root is resolved through the known
1059
+ // external ids and the push REFUSES when there is no answer.
1060
+ if (f.in_reply_to !== undefined && f.in_reply_to !== null) {
1061
+ const rootKey = String(f.in_reply_to);
1062
+ const rootExternal = opts.externalIds?.[rootKey];
1063
+ if (rootExternal === undefined || rootExternal === '') {
1064
+ 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`);
1065
+ }
1066
+ const replyParams: Record<string, unknown> = { owner, repo, pull_number: number, comment_id: rootExternal, body: f.body };
1067
+ const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies', replyParams);
1068
+ ensureOk(res, 'pulls.review_comment.reply');
1069
+ return { externalId: String(res.data?.id ?? '') };
1070
+ }
530
1071
  const params: Record<string, unknown> = {
531
1072
  owner, repo, pull_number: number, body: f.body,
532
1073
  };
@@ -536,7 +1077,11 @@ export async function pushGithubAction(
536
1077
  if (f.line !== undefined) params.line = f.line;
537
1078
  if (f.side !== undefined) params.side = f.side;
538
1079
  if (f.start_line !== undefined) params.start_line = f.start_line;
539
- if (f.head_sha !== undefined) params.commit_id = f.head_sha;
1080
+ // GitHub REFUSES a diff-anchored comment without the commit it anchors to. The local
1081
+ // write carries the PR head it was authored against; fall back to the PR's head sha so an
1082
+ // anchored comment authored without one is not rejected at the vendor.
1083
+ const commitId = f.head_sha ?? f.commit_id;
1084
+ if (commitId !== undefined) params.commit_id = commitId;
540
1085
  const res = await execute.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/comments', params);
541
1086
  ensureOk(res, 'pulls.review_comment.create');
542
1087
  return { externalId: String(res.data?.id ?? '') };
@@ -610,9 +1155,71 @@ export async function pushGithubAction(
610
1155
  ensureOk(res, 'milestones.update');
611
1156
  return { externalId: String(res.data?.number ?? number) };
612
1157
  }
1158
+ // A repository the working copy declared: reality already holds it (the link names it) —
1159
+ // it is confirmed as-is, and created only where it truly is absent.
1160
+ if (op === 'repository.create') {
1161
+ // The record names owner and name as fields (its subject is a sequence id, not a slug).
1162
+ const repoOwner = String(f.owner ?? owner);
1163
+ const repoName = String(f.name ?? repo);
1164
+ const probe = await execute.request('GET /repos/{owner}/{repo}', { owner: repoOwner, repo: repoName }).catch(() => undefined);
1165
+ if (probe !== undefined && probe.status < 300) 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: unknown): boolean => /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
+ } catch (e) {
1180
+ if (!alreadyThere(e)) throw e;
1181
+ }
1182
+ return { externalId: ref };
1183
+ }
1184
+ if (op === 'content_file.create' || op === 'content_file.update' || op === 'content_file.delete') {
1185
+ const path = String(f.path);
1186
+ const branch = f.branch === undefined ? undefined : String(f.branch);
1187
+ // GitHub demands the current blob sha to update or delete; the remote is asked, not guessed.
1188
+ let currentSha: string | undefined;
1189
+ try {
1190
+ const current = await execute.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path, ...(branch === undefined ? {} : { ref: branch }) });
1191
+ const data = current.data as { sha?: unknown } | undefined;
1192
+ if (typeof data?.sha === 'string') currentSha = data.sha;
1193
+ } catch {
1194
+ currentSha = undefined;
1195
+ }
1196
+ if (op === 'content_file.delete') {
1197
+ const res = await execute.request('DELETE /repos/{owner}/{repo}/contents/{path}', { owner, repo, path, message: `Delete ${path}`, ...(currentSha === undefined ? {} : { sha: currentSha }), ...(branch === undefined ? {} : { branch }) });
1198
+ ensureOk(res, 'contents.delete');
1199
+ return { externalId: path };
1200
+ }
1201
+ 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 }) });
1202
+ ensureOk(res, 'contents.put');
1203
+ const data = res.data as { content?: { sha?: unknown } } | undefined;
1204
+ return { externalId: typeof data?.content?.sha === 'string' ? data.content.sha : path };
1205
+ }
613
1206
  throw new Error(`github push: unsupported operation "${op}" for subject ${action.subject.type}`);
614
1207
  }
615
1208
 
1209
+ /**
1210
+ * The REAL-GitHub id of every review comment this world can address: the vendor id a PULL
1211
+ * observed for it. A locally-minted comment id means nothing on the real repo, so this is
1212
+ * what a threaded reply resolves its root through — and a root that is absent here is a
1213
+ * root the push must refuse rather than guess at.
1214
+ */
1215
+ function knownReviewCommentExternalIds(root?: string): Record<string, string> {
1216
+ const out: Record<string, string> = {};
1217
+ for (const c of githubState(root).comments) {
1218
+ if (c.kind === 'review' && c.external_id !== undefined && c.external_id !== '') out[String(c.id)] = c.external_id;
1219
+ }
1220
+ return out;
1221
+ }
1222
+
616
1223
  /**
617
1224
  * PUSH (all): push every pending LOCAL GitHub action to real GitHub via the injected
618
1225
  * executor and CONFIRM each on success — `confirmAction` records the confirmed fields
@@ -620,15 +1227,32 @@ export async function pushGithubAction(
620
1227
  * projection (the fact now lives in the observed log; counted exactly once). Closes
621
1228
  * the action → push → observed loop (R18). Idempotency: a confirmed action is no
622
1229
  * longer pending, so a re-push calls the executor for it again ZERO times.
1230
+ *
1231
+ * PER-ACTION TOLERANCE, the same doctrine the pull's per-row conflict skip follows: one
1232
+ * action the connector cannot enact (a reply whose root has no id on the real repo) or that
1233
+ * the vendor rejected must NOT abort the other pending writes in the sweep — it is skipped
1234
+ * with its reason, left PENDING so a later sweep retries it, and REPORTED in `skipped` so a
1235
+ * poller can log it loudly instead of it being swallowed. Nothing is confirmed on a failure,
1236
+ * so a skipped action is never mistaken for a written one.
623
1237
  */
624
1238
  export async function pushPendingGithubActions(
625
1239
  execute: GithubExecute,
626
1240
  opts: { root?: string; occurredAt: string },
627
- ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
1241
+ ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; skipped: Array<{ actionId: string; operation: string; reason: string }> }> {
628
1242
  const confirmed: string[] = [];
629
1243
  const externalIds: Record<string, string> = {};
1244
+ const skipped: Array<{ actionId: string; operation: string; reason: string }> = [];
1245
+ // Seeded from what the world already knows, then GROWN as the sweep confirms writes — a
1246
+ // root comment created and replied to between two pushes resolves within one sweep.
1247
+ const commentExternalIds = knownReviewCommentExternalIds(opts.root);
630
1248
  for (const action of pendingActions(SERVICE, opts.root)) {
631
- const { externalId } = await pushGithubAction(execute, action);
1249
+ let externalId: string;
1250
+ try {
1251
+ ({ externalId } = await pushGithubAction(execute, action, { externalIds: commentExternalIds }));
1252
+ } catch (error) {
1253
+ skipped.push({ actionId: action.id, operation: action.operation ?? '', reason: error instanceof Error ? error.message : String(error) });
1254
+ continue;
1255
+ }
632
1256
  confirmAction({
633
1257
  service: SERVICE,
634
1258
  actionId: action.id,
@@ -639,6 +1263,153 @@ export async function pushPendingGithubActions(
639
1263
  });
640
1264
  confirmed.push(action.id);
641
1265
  externalIds[action.id] = externalId;
1266
+ // A review comment the vendor just accepted is now addressable, so a reply LATER in this
1267
+ // same sweep can name it. Keyed by the twin's own comment id, which is what the reply's
1268
+ // `in_reply_to` carries.
1269
+ const local = /^comment:(\d+)$/.exec(action.subject.id);
1270
+ if (local !== null && action.operation === 'pull_request_review_comment.create' && externalId !== '') commentExternalIds[local[1]!] = externalId;
642
1271
  }
643
- return { pushed: confirmed.length, confirmed, externalIds };
1272
+ return { pushed: confirmed.length, confirmed, externalIds, skipped };
1273
+ }
1274
+
1275
+ /**
1276
+ * THE R14 PUSH ADAPTER for github (jira's `pushJiraToRemote` is the reference; this
1277
+ * transcribes its METHOD): pending local actions cross to the remote through the kernel's
1278
+ * ONE RemoteExecute seam under a sealed credential the pack never sees, and each pushed
1279
+ * action is confirmed in the local log. Anchored naming: `createGithubTwinFetch` pairs
1280
+ * with `syncGithubFromRemote` and `pushGithubToRemote`. Push-plane only — a read refuses
1281
+ * loudly. The route grammar is the one `pushGithubAction` speaks (`METHOD /path/{param}`,
1282
+ * templated params in the path, the rest as the JSON body).
1283
+ */
1284
+ export async function pushGithubToRemote(
1285
+ execute: RemoteExecute,
1286
+ opts: { root?: string; origin?: string } = {},
1287
+ ): Promise<{ pushed: number; confirmed: string[]; skipped: Array<{ actionId: string; operation: string; reason: string }> }> {
1288
+ const executor: GithubExecute = {
1289
+ async request(route, params = {}) {
1290
+ const sp = route.indexOf(' ');
1291
+ const method = route.slice(0, sp);
1292
+ // The push plane writes; the one read it makes is the current blob sha a Contents write
1293
+ // needs (GitHub refuses an update without it) — a read that serves the push, never a pull.
1294
+ let path = route.slice(sp + 1);
1295
+ const body: Record<string, unknown> = {};
1296
+ for (const [key, value] of Object.entries(params)) {
1297
+ const token = `{${key}}`;
1298
+ if (path.includes(token)) path = path.replace(token, encodeURIComponent(String(value)));
1299
+ else if (value !== undefined) body[key] = value;
1300
+ }
1301
+ const read = method === 'GET' || method === 'HEAD';
1302
+ if (read && Object.keys(body).length > 0) {
1303
+ const qs = new URLSearchParams();
1304
+ for (const [key, value] of Object.entries(body)) qs.set(key, String(value));
1305
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
1306
+ }
1307
+ const res = await execute({
1308
+ method,
1309
+ path,
1310
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'volter-twin-push', ...(read ? {} : { 'content-type': 'application/json' }) },
1311
+ ...(read ? {} : { body: JSON.stringify(body) }),
1312
+ });
1313
+ // The vendor's own words ride the error: push health on the link must say WHY reality
1314
+ // refused (a bare status is a guess), bounded so a page never lands in a ledger.
1315
+ if (res.status >= 300) throw new Error(`github remote push: ${route} answered ${res.status}${res.body === '' ? '' : ` — ${res.body.slice(0, 300)}`}`);
1316
+ return { status: res.status, data: res.body === '' ? undefined : JSON.parse(res.body) };
1317
+ },
1318
+ };
1319
+ const pushed = await pushPendingGithubActions(executor, { ...(opts.root === undefined ? {} : { root: opts.root }), occurredAt: new Date().toISOString() });
1320
+ // The refusals ride out with the successes: a link that pushed nothing because every action
1321
+ // was refused must not read as a clean sweep.
1322
+ return { pushed: pushed.pushed, confirmed: pushed.confirmed, skipped: pushed.skipped };
1323
+ }
1324
+
1325
+ /**
1326
+ * THE REPOSITORY AND ITS BRANCHES are observed with the PRs and issues: a working copy's
1327
+ * confirmed writes (a branch it cut, the repo it declared) survive only as what reality
1328
+ * shows back, so the pull says what reality holds — the repo and every branch head.
1329
+ */
1330
+ export async function observeRepositoryAndBranches(execute: GithubExecute, owner: string, repo: string): Promise<SyncResource[]> {
1331
+ const full = `${owner}/${repo}`;
1332
+ const out: SyncResource[] = [];
1333
+ const repoRes = await execute.request('GET /repos/{owner}/{repo}', { owner, repo }).catch(() => undefined);
1334
+ const r = repoRes?.data as { default_branch?: unknown; private?: unknown; description?: unknown } | undefined;
1335
+ if (repoRes !== undefined && repoRes.status < 300 && r !== undefined) {
1336
+ 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 } });
1337
+ }
1338
+ const branchesRes = await execute.request('GET /repos/{owner}/{repo}/branches', { owner, repo, per_page: 100 }).catch(() => undefined);
1339
+ const list = Array.isArray(branchesRes?.data) ? (branchesRes!.data as Array<{ name?: unknown; commit?: { sha?: unknown } }>) : [];
1340
+ for (const b of list) {
1341
+ if (typeof b.name !== 'string') continue;
1342
+ out.push({ type: 'branch', id: `branch:${full}#${b.name}`, fields: { repository: full, name: b.name, commit_sha: String(b.commit?.sha ?? '') } });
1343
+ }
1344
+ return out;
1345
+ }
1346
+
1347
+ /**
1348
+ * THE R14 SCHEDULED-PULL ADAPTER (jira is the reference; this transcribes its METHOD):
1349
+ * adapts this pack's executor onto the kernel's ONE RemoteExecute seam, so the twins
1350
+ * service can schedule pulls with a sealed credential the pack never sees. Anchored
1351
+ * naming: `createGithubTwinFetch` pairs with `syncGithubFromRemote`.
1352
+ *
1353
+ * The link's origin names the REPO, not just the API host — a link is a git remote:
1354
+ * https://api.github.com/repos/{owner}/{repo}
1355
+ * Egress still anchors at the origin's HOST (the service's RemoteExecute discards the
1356
+ * path when routing), so the path here is pure identity. Pull-plane only — every
1357
+ * non-read request refuses loudly.
1358
+ *
1359
+ * Rate note, counted over the routes this pull actually calls: 2 list reads (the repo and
1360
+ * its branches) + 1 PR list + 1 issue list, and then, per PR whose `updated_at` MOVED since
1361
+ * the last pull, three PAGED conversation reads — the reviews, the issue comments and the
1362
+ * inline comments — at 1 request each for a PR under 100 rows and up to 10 each beyond
1363
+ * that. So a poll costs 4 + 3·CHANGED at the floor and 4 + 30·CHANGED at the ceiling; an
1364
+ * unchanged PR costs nothing beyond the list. The link's sync interval still carries the
1365
+ * budget — 60s at the default page floods a PAT's 5000/hr the first time it walks a busy
1366
+ * repo; schedule github links at ≥120s.
1367
+ */
1368
+ export async function syncGithubFromRemote(
1369
+ execute: RemoteExecute,
1370
+ opts: { root?: string; origin?: string; state?: 'open' | 'closed' | 'all'; perPage?: number } = {},
1371
+ ): Promise<{ observed: number; deltasAppended: number; eventsAppended: number; issues: number; conflictsSkipped: number; conflictingIds: string[] }> {
1372
+ const match = /^\/repos\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(opts.origin === undefined ? '' : new URL(opts.origin).pathname);
1373
+ if (match === null) {
1374
+ throw new Error('github remote pull: the link origin must name the repo — https://api.github.com/repos/{owner}/{repo}');
1375
+ }
1376
+ const [, owner, repo] = match as unknown as [string, string, string];
1377
+ const executor: GithubExecute = {
1378
+ async request(route, params = {}) {
1379
+ const sp = route.indexOf(' ');
1380
+ const method = route.slice(0, sp);
1381
+ if (method !== 'GET' && method !== 'HEAD') {
1382
+ throw new Error(`syncGithubFromRemote is the PULL plane: ${route} is a push-side operation and never runs here`);
1383
+ }
1384
+ // Same route grammar liveGithubExecute speaks: substitute {param} templates,
1385
+ // remaining params become the query string (a read has no body).
1386
+ let path = route.slice(sp + 1);
1387
+ const rest: Record<string, unknown> = {};
1388
+ for (const [key, value] of Object.entries(params)) {
1389
+ const token = `{${key}}`;
1390
+ if (path.includes(token)) path = path.replace(token, encodeURIComponent(String(value)));
1391
+ else rest[key] = value;
1392
+ }
1393
+ if (Object.keys(rest).length > 0) {
1394
+ const qs = new URLSearchParams();
1395
+ for (const [key, value] of Object.entries(rest)) qs.set(key, String(value));
1396
+ path += `${path.includes('?') ? '&' : '?'}${qs.toString()}`;
1397
+ }
1398
+ // GitHub answers 403 to any request without a User-Agent (its documented rule);
1399
+ // workerd's fetch sends none, so the adapter names itself.
1400
+ const res = await execute({ method, path, headers: { accept: 'application/vnd.github+json', 'user-agent': 'volter-twin-pull' } });
1401
+ if (res.status >= 300) throw new Error(`github remote pull: ${route} answered ${res.status}`);
1402
+ return { status: res.status, data: res.body === '' ? undefined : JSON.parse(res.body) };
1403
+ },
1404
+ };
1405
+ // The pull plane's instant is observation time — fetch metadata, never served content
1406
+ // (R9 governs the serve path; this is the freshness half of the git model). `all`
1407
+ // because the merge IS the observation that ends a job — closed PRs must fold.
1408
+ return await syncGithubFromReal(executor, {
1409
+ owner, repo,
1410
+ occurredAt: new Date().toISOString(),
1411
+ state: opts.state ?? 'all',
1412
+ perPage: opts.perPage ?? 20,
1413
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
1414
+ });
644
1415
  }