@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.
- package/README.md +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
package/src/github-connector.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
133
|
-
* repository file CONTENTS (per-file diffs/blob bytes are
|
|
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: {
|
|
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:
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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.
|
|
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
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
eventsAppended +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|