@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.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/lib/sync.js ADDED
@@ -0,0 +1,364 @@
1
+ import path from "node:path";
2
+ import { home, digest, locked, readJSON, writeJSON } from "./store.js";
3
+ import { GitHubError } from "./github.js";
4
+ import { assertBoard, fullRecords, isRequest, project } from "./board.js";
5
+
6
+ export const POLL_INTERVAL = 30_000,
7
+ PROMPT_INTERVAL = 15_000;
8
+ export const RECONCILE_INTERVAL = 15 * 60_000,
9
+ OVERLAP = 120_000;
10
+ export const accountFile = (c) =>
11
+ path.join(home(), "github", `account-${c.ownerId}.json`);
12
+ export const boardFile = (c) =>
13
+ path.join(
14
+ home(),
15
+ "github",
16
+ `${digest(`${c.ownerId}:${c.repoId}:${c.repo.toLowerCase()}:${c.board}`)}.json`,
17
+ );
18
+ const merge = (old, updates) => {
19
+ const records = new Map(old.map((x) => [x.id, x]));
20
+ for (const value of updates) {
21
+ const prior = records.get(value.id);
22
+ records.set(
23
+ value.id,
24
+ prior?.body === value.body && prior?.updated_at === value.updated_at
25
+ ? { ...value, editProof: prior?.editProof || value.editProof }
26
+ : value,
27
+ );
28
+ }
29
+ return [...records.values()];
30
+ };
31
+ const thread = (comment) => Number(comment.issue_url?.split("/").at(-1));
32
+
33
+ // Keep only fields used by Patchwork, rather than duplicating GitHub's large
34
+ // user, reaction and repository objects in a second database.
35
+ const fields = (value, names) =>
36
+ Object.fromEntries(
37
+ names.filter((k) => value[k] !== undefined).map((k) => [k, value[k]]),
38
+ );
39
+ function compact(value, kind) {
40
+ const result = fields(
41
+ value,
42
+ kind === "issue"
43
+ ? [
44
+ "id",
45
+ "number",
46
+ "title",
47
+ "body",
48
+ "state",
49
+ "labels",
50
+ "created_at",
51
+ "updated_at",
52
+ "html_url",
53
+ "pull_request",
54
+ "comments",
55
+ ]
56
+ : kind === "comment"
57
+ ? [
58
+ "id",
59
+ "node_id",
60
+ "body",
61
+ "created_at",
62
+ "updated_at",
63
+ "issue_url",
64
+ "html_url",
65
+ "editProof",
66
+ ]
67
+ : ["id", "event", "created_at"],
68
+ );
69
+ for (const key of ["user", "actor"])
70
+ if (value[key]) result[key] = fields(value[key], ["id", "login"]);
71
+ if (result.labels)
72
+ result.labels = result.labels.map((l) =>
73
+ typeof l === "string" ? l : l.name,
74
+ );
75
+ return result;
76
+ }
77
+ function trimRecords(records) {
78
+ return {
79
+ board: compact(records.board, "issue"),
80
+ comments: merge([], records.comments).map((c) => compact(c, "comment")),
81
+ requests: Object.fromEntries(
82
+ Object.entries(records.requests).map(([id, r]) => [
83
+ id,
84
+ {
85
+ issue: compact(r.issue, "issue"),
86
+ comments: merge([], r.comments).map((c) => compact(c, "comment")),
87
+ events: merge([], r.events)
88
+ .filter((e) => ["closed", "reopened"].includes(e.event))
89
+ .map((e) => compact(e, "event")),
90
+ },
91
+ ]),
92
+ ),
93
+ };
94
+ }
95
+ function httpCache(gh, board) {
96
+ const entries = [];
97
+ let bytes = 0;
98
+ const priority = (key) =>
99
+ key === `/repos/${gh.repo}/issues/${board}`
100
+ ? 0
101
+ : key.includes("/issues?")
102
+ ? 1
103
+ : 2;
104
+ for (const entry of [...gh.etags]
105
+ .reverse()
106
+ .sort((a, b) => priority(a[0]) - priority(b[0]))) {
107
+ if (entry[0].includes("since=")) continue;
108
+ const size = Buffer.byteLength(JSON.stringify(entry));
109
+ if (bytes + size > 512_000 || entries.length >= 32) continue;
110
+ entries.unshift(entry);
111
+ bytes += size;
112
+ }
113
+ return entries;
114
+ }
115
+
116
+ class BoardSync {
117
+ constructor(c, gh, account, cache, clock) {
118
+ Object.assign(this, { c, gh, account, cache, clock });
119
+ }
120
+ async poll(maxAge = PROMPT_INTERVAL) {
121
+ const now = this.clock(),
122
+ { gh, c } = this;
123
+ if (
124
+ this.cache.records &&
125
+ now >= this.cache.checkedAt &&
126
+ now - this.cache.checkedAt < maxAge
127
+ )
128
+ return;
129
+ // Fetch the board first: its server Date anchors the window BEFORE any
130
+ // paginated reads. Local clock skew must never advance a remote cursor.
131
+ const board = await gh.api("GET", `/issues/${c.board}`);
132
+ const started = gh.serverTime;
133
+ assertBoard(board);
134
+ if (this.cache.records && this.cache.records.board.id !== board.id)
135
+ throw new Error("GitHub board identity changed. Reconnect explicitly.");
136
+ const full =
137
+ !this.cache.records ||
138
+ !this.cache.since ||
139
+ now < this.cache.checkedAt ||
140
+ now - this.cache.reconciledAt >= RECONCILE_INTERVAL;
141
+ let records;
142
+ if (full) {
143
+ records = await fullRecords(gh, c.board, board);
144
+ } else {
145
+ records = structuredClone(this.cache.records);
146
+ records.board = board;
147
+ const since = encodeURIComponent(
148
+ new Date(Math.max(0, this.cache.since - OVERLAP)).toISOString(),
149
+ );
150
+ // Include unlabelled changed issues so removing the Patchwork label is
151
+ // observed. Stable creation order avoids updated-order pagination churn.
152
+ const issues = await gh.list(
153
+ `/issues?state=all&sort=created&direction=asc&since=${since}`,
154
+ );
155
+ for (const issue of issues) {
156
+ if (!isRequest(issue, c.board)) {
157
+ delete records.requests[issue.number];
158
+ continue;
159
+ }
160
+ const old = records.requests[issue.number];
161
+ if (!old) {
162
+ records.requests[issue.number] = {
163
+ issue,
164
+ comments: await gh.list(`/issues/${issue.number}/comments`),
165
+ events: await gh.list(`/issues/${issue.number}/events`),
166
+ };
167
+ } else if (
168
+ JSON.stringify(compact(issue, "issue")) !== JSON.stringify(old.issue)
169
+ ) {
170
+ old.issue = issue;
171
+ // Events have no `since` API. Read them only for changed issues, to
172
+ // preserve a close/reopen pair even when the final state is unchanged.
173
+ old.events = await gh.list(`/issues/${issue.number}/events`);
174
+ }
175
+ }
176
+ // Repository-wide `since` includes EDITED comments, even on old closed
177
+ // requests and even if GitHub did not change the parent issue timestamp.
178
+ const comments = await gh.list(
179
+ `/issues/comments?sort=created&direction=asc&since=${since}`,
180
+ );
181
+ records.comments = merge(
182
+ records.comments,
183
+ comments.filter((x) => thread(x) === c.board),
184
+ );
185
+ for (const comment of comments) {
186
+ const request = records.requests[thread(comment)];
187
+ if (request) request.comments = merge(request.comments, [comment]);
188
+ }
189
+ }
190
+ // Commit only a COMPLETE traversal. A failed page or killed process cannot
191
+ // move the watermark; the overlap plus periodic full reconciliation also
192
+ // catches late visibility, deletions and changes during pagination.
193
+ this.cache = {
194
+ ...this.cache,
195
+ records: trimRecords(records),
196
+ checkedAt: now,
197
+ since: Number.isFinite(started) ? started : this.cache.since,
198
+ reconciledAt: full ? now : this.cache.reconciledAt,
199
+ };
200
+ }
201
+ async view() {
202
+ const key = this.c.repoId;
203
+ this.account.permissions[key] ??= {};
204
+ return project(
205
+ this.gh,
206
+ this.cache.records,
207
+ this.account.permissions[key],
208
+ this.clock(),
209
+ );
210
+ }
211
+ async authorize() {
212
+ try {
213
+ await this.gh.verify(this.c, {
214
+ cache: this.account.auth,
215
+ now: this.clock(),
216
+ write: true,
217
+ });
218
+ } catch (e) {
219
+ this.account.auth = {};
220
+ this.account.permissions = {};
221
+ this.gh.etags.clear();
222
+ throw e;
223
+ }
224
+ }
225
+ async accept(value) {
226
+ if (!value || value.unchanged) return;
227
+ const records = this.cache.records;
228
+ if (value.number) {
229
+ const old = records.requests[value.number];
230
+ records.requests[value.number] = {
231
+ issue: value,
232
+ comments:
233
+ old?.comments ||
234
+ (await this.gh.list(`/issues/${value.number}/comments`)),
235
+ events: await this.gh.list(`/issues/${value.number}/events`),
236
+ };
237
+ } else if (value.issue_url) {
238
+ const number = thread(value);
239
+ if (number === this.c.board)
240
+ records.comments = merge(records.comments, [value]);
241
+ else if (records.requests[number])
242
+ records.requests[number].comments = merge(
243
+ records.requests[number].comments,
244
+ [value],
245
+ );
246
+ }
247
+ this.cache.records = trimRecords(records);
248
+ }
249
+ labels(request) {
250
+ if (this.cache.records.requests[request.number])
251
+ this.cache.records.requests[request.number].issue.labels = request.labels;
252
+ }
253
+ async save() {
254
+ if (this.cache.records)
255
+ await writeJSON(boardFile(this.c), {
256
+ ...this.cache,
257
+ http: httpCache(this.gh, this.c.board),
258
+ });
259
+ }
260
+ }
261
+
262
+ // One account lock coalesces readers across projects, worktrees and clients and
263
+ // shares rate-limit backoff. No daemon; all work is driven by the existing hooks.
264
+ export async function withBoard(c, gh, fn, clock = Date.now) {
265
+ return locked(accountFile(c), async () => {
266
+ const account = (await readJSON(accountFile(c))) || {
267
+ version: 1,
268
+ auth: {},
269
+ permissions: {},
270
+ stats: {
271
+ since: new Date(clock()).toISOString(),
272
+ requests: 0,
273
+ reads: 0,
274
+ writes: 0,
275
+ notModified: 0,
276
+ failures: 0,
277
+ },
278
+ };
279
+ const failuresBefore = account.stats.failures;
280
+ let sync;
281
+ const invalidate = () => {
282
+ account.auth = {};
283
+ account.permissions = {};
284
+ gh.etags.clear();
285
+ };
286
+ let authenticated = false;
287
+ try {
288
+ const credential = await gh.credentials();
289
+ if (account.credential !== credential) {
290
+ invalidate();
291
+ account.credential = credential;
292
+ }
293
+ gh.beforeRequest = () => {
294
+ if (account.retryAt > clock())
295
+ throw new GitHubError(account.lastStatus, account.retryAt);
296
+ };
297
+ gh.onResponse = ({ method, status, error, count = true }) => {
298
+ if (count) {
299
+ account.stats.requests++;
300
+ account.stats[method === "GET" ? "reads" : "writes"]++;
301
+ }
302
+ if (status === 304) {
303
+ account.stats.notModified++;
304
+ return;
305
+ }
306
+ if (!error) return;
307
+ account.stats.failures++;
308
+ if ([401, 403].includes(status)) invalidate();
309
+ if ([0, 401, 403, 429].includes(status) || status >= 500) {
310
+ account.failures = Math.min((account.failures || 0) + 1, 5);
311
+ account.retryAt = Math.max(
312
+ error.retryAt || 0,
313
+ clock() + Math.min(60_000 * 2 ** (account.failures - 1), 900_000),
314
+ );
315
+ account.lastStatus = status;
316
+ error.retryAt = account.retryAt;
317
+ }
318
+ };
319
+ gh.beforeRequest();
320
+ try {
321
+ await gh.verify(c, { cache: account.auth, now: clock() });
322
+ authenticated = true;
323
+ } catch (e) {
324
+ invalidate();
325
+ throw e;
326
+ }
327
+ let cache;
328
+ try {
329
+ cache = await readJSON(boardFile(c));
330
+ } catch (e) {
331
+ if (!(e instanceof SyntaxError)) throw e;
332
+ } // Disposable; recover from damaged cache.
333
+ if (
334
+ !cache ||
335
+ cache.version !== 1 ||
336
+ cache.repoId !== c.repoId ||
337
+ cache.board !== c.board
338
+ )
339
+ cache = { version: 1, repoId: c.repoId, board: c.board };
340
+ gh.etags = new Map(
341
+ cache.credential === credential ? cache.http || [] : [],
342
+ );
343
+ cache.credential = credential;
344
+ sync = new BoardSync(c, gh, account, cache, clock);
345
+ const result = await fn(sync);
346
+ await sync.save();
347
+ if (account.stats.failures === failuresBefore) {
348
+ account.failures = 0;
349
+ account.retryAt = 0;
350
+ }
351
+ return result;
352
+ } catch (e) {
353
+ // A handled collaborator 404 means that person is not a writer; retain
354
+ // that negative result. An unhandled 404 may mean lost repository access.
355
+ if (e.status === 404) invalidate();
356
+ throw e;
357
+ } finally {
358
+ if (!authenticated) invalidate();
359
+ gh.release();
360
+ // Counts are numeric aggregates only: no credentials, paths, bodies or URLs.
361
+ await writeJSON(accountFile(c), account);
362
+ }
363
+ });
364
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@icebergsnow/patchwork-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A GitHub bulletin board with automatic hooks for Codex and Claude Code",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=22.13.0"
9
+ },
10
+ "bin": {
11
+ "patchwork": "bin/patchwork.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "README.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/IcebergSnow/Patchwork.git",
24
+ "directory": "packages/cli"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "1.32.1",
28
+ "smol-toml": "1.9.1",
29
+ "zod": "^4.0.0"
30
+ }
31
+ }