@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/README.md +63 -2
- package/bin/patchwork.js +148 -0
- package/lib/board.js +630 -0
- package/lib/config.js +139 -0
- package/lib/connect.js +502 -0
- package/lib/connections.js +55 -0
- package/lib/github.js +391 -0
- package/lib/instructions.js +3 -0
- package/lib/mcp.js +104 -0
- package/lib/runtime.js +510 -0
- package/lib/store.js +185 -0
- package/lib/sync.js +364 -0
- package/package.json +29 -4
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|