@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.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 +91 -2
- package/bin/patchwork.js +150 -0
- package/lib/board.js +630 -0
- package/lib/config.js +139 -0
- package/lib/connect.js +508 -0
- package/lib/connections.js +56 -0
- package/lib/github.js +391 -0
- package/lib/instructions.js +3 -0
- package/lib/mcp.js +108 -0
- package/lib/runtime.js +528 -0
- package/lib/store.js +185 -0
- package/lib/sync.js +364 -0
- package/package.json +29 -4
package/lib/store.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
unlink,
|
|
9
|
+
lstat,
|
|
10
|
+
realpath,
|
|
11
|
+
readdir,
|
|
12
|
+
rmdir,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
|
|
17
|
+
export const digest = (value) =>
|
|
18
|
+
createHash("sha256").update(value).digest("hex");
|
|
19
|
+
export const home = () =>
|
|
20
|
+
path.resolve(
|
|
21
|
+
process.env.PATCHWORK_HOME || path.join(os.homedir(), ".patchwork"),
|
|
22
|
+
);
|
|
23
|
+
export async function regular(file) {
|
|
24
|
+
try {
|
|
25
|
+
const s = await lstat(file);
|
|
26
|
+
if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1)
|
|
27
|
+
throw new Error(`Refusing linked or non-regular file: ${file}`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e.code !== "ENOENT") throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function directory(dir) {
|
|
33
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
if (
|
|
35
|
+
(await lstat(dir)).isSymbolicLink() ||
|
|
36
|
+
(await realpath(dir)) !== path.resolve(dir)
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Refusing a linked configuration directory.");
|
|
39
|
+
}
|
|
40
|
+
export async function read(file) {
|
|
41
|
+
await regular(file);
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(file, "utf8");
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e.code === "ENOENT") return "";
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function write(file, value) {
|
|
50
|
+
await directory(path.dirname(file));
|
|
51
|
+
await regular(file);
|
|
52
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
53
|
+
const handle = await open(temp, "wx", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
await handle.writeFile(value);
|
|
56
|
+
await handle.sync();
|
|
57
|
+
await handle.close();
|
|
58
|
+
await rename(temp, file);
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
await unlink(temp).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export const readJSON = async (file) => {
|
|
65
|
+
const text = await read(file);
|
|
66
|
+
return text ? JSON.parse(text) : null;
|
|
67
|
+
};
|
|
68
|
+
export const writeJSON = (file, value) =>
|
|
69
|
+
write(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
70
|
+
export const connectionFile = (root, client) =>
|
|
71
|
+
path.join(home(), "connections", `${digest(`${root}\0${client}`)}.json`);
|
|
72
|
+
|
|
73
|
+
const heldLocks = new AsyncLocalStorage();
|
|
74
|
+
function dead(pid) {
|
|
75
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return e.code === "ESRCH";
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// Lock order: connection -> sessions -> GitHub account. Never steal a live
|
|
84
|
+
// process's lock. Publish a nonempty directory atomically, so a crash cannot
|
|
85
|
+
// leave an ownerless acquisition. Unique owner names make stale cleanup safe
|
|
86
|
+
// even when another waiter has already replaced the abandoned directory.
|
|
87
|
+
export async function locked(file, fn) {
|
|
88
|
+
file = path.resolve(file);
|
|
89
|
+
const held = heldLocks.getStore() || new Set();
|
|
90
|
+
if (held.has(file)) throw new Error("Recursive Patchwork lock acquisition.");
|
|
91
|
+
await directory(path.dirname(file));
|
|
92
|
+
const lock = `${file}.lock`;
|
|
93
|
+
const owner = `${process.pid}-${crypto.randomUUID()}`;
|
|
94
|
+
const candidate = `${lock}.${owner}`;
|
|
95
|
+
await mkdir(candidate, { mode: 0o700 });
|
|
96
|
+
const handle = await open(path.join(candidate, owner), "wx", 0o600);
|
|
97
|
+
await handle.close();
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
for (;;) {
|
|
101
|
+
try {
|
|
102
|
+
await rename(candidate, lock);
|
|
103
|
+
break;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (
|
|
106
|
+
!["EEXIST", "ENOTEMPTY", "ENOTDIR", "EPERM", "EACCES"].includes(
|
|
107
|
+
e.code,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
throw e;
|
|
111
|
+
try {
|
|
112
|
+
const stat = await lstat(lock);
|
|
113
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing a linked lock.");
|
|
114
|
+
if (stat.isDirectory()) {
|
|
115
|
+
const owners = await readdir(lock);
|
|
116
|
+
for (const name of owners) {
|
|
117
|
+
if (
|
|
118
|
+
/^\d+-[a-f0-9-]{36}$/.test(name) &&
|
|
119
|
+
dead(Number(name.split("-")[0]))
|
|
120
|
+
)
|
|
121
|
+
await unlink(path.join(lock, name)).catch((e) => {
|
|
122
|
+
if (e.code !== "ENOENT") throw e;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// rmdir cannot remove a newly published, nonempty lock.
|
|
126
|
+
await rmdir(lock).catch((e) => {
|
|
127
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(e.code)) throw e;
|
|
128
|
+
});
|
|
129
|
+
} else if (stat.isFile()) {
|
|
130
|
+
// Compatibility with the previous PID-file lock. unlink cannot
|
|
131
|
+
// remove a new directory lock if another cleaner won the race.
|
|
132
|
+
if (dead(Number(await read(lock))))
|
|
133
|
+
await unlink(lock).catch(() => {});
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error("Invalid Patchwork lock.");
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Windows can report a sharing violation while another waiter is
|
|
139
|
+
// removing the stale directory. Retry the entire acquisition under
|
|
140
|
+
// the existing deadline; never treat a failed cleanup as ownership.
|
|
141
|
+
if (
|
|
142
|
+
e.code !== "ENOENT" &&
|
|
143
|
+
!(
|
|
144
|
+
process.platform === "win32" &&
|
|
145
|
+
["EPERM", "EACCES", "EBUSY"].includes(e.code)
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
if (Date.now() - started >= 60_000) {
|
|
151
|
+
const error = new Error(
|
|
152
|
+
"Another Patchwork operation is still running. Retry shortly; no delivery cursor was advanced.",
|
|
153
|
+
);
|
|
154
|
+
error.code = "LOCK_BUSY";
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await new Promise((resolve) =>
|
|
158
|
+
setTimeout(resolve, 75 + Math.random() * 50),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return await heldLocks.run(new Set([...held, file]), fn);
|
|
164
|
+
} finally {
|
|
165
|
+
await unlink(path.join(lock, owner));
|
|
166
|
+
await rmdir(lock).catch((e) => {
|
|
167
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(e.code)) throw e;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
await unlink(path.join(candidate, owner)).catch(() => {});
|
|
172
|
+
await rmdir(candidate).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function loadConnection(file, enabled = true) {
|
|
177
|
+
const c = await readJSON(file);
|
|
178
|
+
if (!c || c.version !== 1)
|
|
179
|
+
throw new Error("Run Patchwork connect in this project first.");
|
|
180
|
+
if (enabled && !c.enabled)
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Patchwork is disconnected in this project. Run connect to enable it.",
|
|
183
|
+
);
|
|
184
|
+
return c;
|
|
185
|
+
}
|
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.1",
|
|
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
|
+
}
|