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