parawatch 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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/adapters/claudeCode.js +331 -0
- package/dist/adapters/codex.js +185 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/jsonl.js +144 -0
- package/dist/bin/cli.js +491 -0
- package/dist/core/desktop.js +102 -0
- package/dist/core/digest.js +146 -0
- package/dist/core/follow.js +70 -0
- package/dist/core/format.js +351 -0
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +121 -0
- package/dist/core/ops.js +415 -0
- package/dist/core/runs.js +284 -0
- package/dist/core/skill.js +67 -0
- package/dist/core/snapshot.js +71 -0
- package/dist/core/state.js +107 -0
- package/dist/core/store.js +127 -0
- package/dist/core/types.js +6 -0
- package/package.json +57 -4
- package/skills/parawatch/SKILL.md +135 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull requests through the GitHub CLI (`gh`). Parawatch never holds a token: it runs `gh`, which
|
|
3
|
+
* the human has already signed in, and reads what it prints. Without `gh`, or signed out, all of
|
|
4
|
+
* this stays off and nothing else changes.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { UserError } from "./ops.js";
|
|
10
|
+
import { appendLocked, homeDir, withLock } from "./store.js";
|
|
11
|
+
export const DEFAULT_STALL_HOURS = {
|
|
12
|
+
review: 24,
|
|
13
|
+
"no reviewer": 4,
|
|
14
|
+
"changes requested": 24,
|
|
15
|
+
CI: 2,
|
|
16
|
+
"CI failed": 4,
|
|
17
|
+
conflict: 4,
|
|
18
|
+
"merge queue": 6,
|
|
19
|
+
"auto-merge": 6,
|
|
20
|
+
merge: 24,
|
|
21
|
+
};
|
|
22
|
+
/** `config.json` in the parawatch home, all of it optional. */
|
|
23
|
+
export function githubConfig() {
|
|
24
|
+
let raw = {};
|
|
25
|
+
try {
|
|
26
|
+
raw = JSON.parse(fs.readFileSync(path.join(homeDir(), "config.json"), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// no config file: defaults
|
|
30
|
+
}
|
|
31
|
+
const g = raw.github ?? {};
|
|
32
|
+
return {
|
|
33
|
+
enabled: process.env.PARAWATCH_GITHUB === "0" ? false : g.enabled !== false,
|
|
34
|
+
intervalMinutes: typeof g.intervalMinutes === "number" && g.intervalMinutes > 0 ? g.intervalMinutes : 5,
|
|
35
|
+
stallHours: { ...DEFAULT_STALL_HOURS, ...(g.stallHours ?? {}) },
|
|
36
|
+
maxAgeDays: typeof g.maxAgeDays === "number" && g.maxAgeDays > 0 ? g.maxAgeDays : 60,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ------------------------------------------------------------------ refs
|
|
40
|
+
const URL_RE = /^https?:\/\/([^/\s]+)\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/;
|
|
41
|
+
const SHORT_RE = /^(?:([^/\s]+)\/)?([^/\s#]+\/[^/\s#]+)#(\d+)$/;
|
|
42
|
+
export function prRef(host, repo, number, url) {
|
|
43
|
+
return { key: `${host}/${repo}#${number}`, host, repo, number, url: url ?? `https://${host}/${repo}/pull/${number}` };
|
|
44
|
+
}
|
|
45
|
+
/** A PR from a URL, `owner/repo#12` or `host/owner/repo#12`, without asking GitHub. */
|
|
46
|
+
export function parsePrRef(input) {
|
|
47
|
+
const t = input.trim();
|
|
48
|
+
const u = URL_RE.exec(t);
|
|
49
|
+
if (u)
|
|
50
|
+
return prRef(u[1], u[2], Number(u[3]), `https://${u[1]}/${u[2]}/pull/${u[3]}`);
|
|
51
|
+
const s = SHORT_RE.exec(t);
|
|
52
|
+
if (s)
|
|
53
|
+
return prRef(s[1] ?? "github.com", s[2], Number(s[3]));
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Like `parsePrRef`, but `#12` or `12` asks `gh` which repository the folder is. */
|
|
57
|
+
export function resolvePrRef(input, cwd = process.cwd()) {
|
|
58
|
+
const direct = parsePrRef(input);
|
|
59
|
+
if (direct)
|
|
60
|
+
return direct;
|
|
61
|
+
const n = /^#?(\d+)$/.exec(input.trim());
|
|
62
|
+
if (!n)
|
|
63
|
+
throw new UserError(`"${input}" is not a pull request: give a URL, owner/repo#N, or #N inside the repository`);
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = execFileSync("gh", ["pr", "view", n[1], "--json", "url", "-q", ".url"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 }).trim();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new UserError(`could not look up #${n[1]} with gh here: ${String(err.stderr ?? err).trim().split("\n")[0]}`);
|
|
70
|
+
}
|
|
71
|
+
const ref = parsePrRef(url);
|
|
72
|
+
if (!ref)
|
|
73
|
+
throw new UserError(`gh returned "${url}", which is not a pull request URL`);
|
|
74
|
+
return ref;
|
|
75
|
+
}
|
|
76
|
+
/** The PR a link in a run's log or a key points at. */
|
|
77
|
+
export function keyOf(input) {
|
|
78
|
+
return parsePrRef(input)?.key;
|
|
79
|
+
}
|
|
80
|
+
export function cachePath() {
|
|
81
|
+
return path.join(homeDir(), "github.json");
|
|
82
|
+
}
|
|
83
|
+
export function readCache() {
|
|
84
|
+
try {
|
|
85
|
+
const c = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
|
|
86
|
+
return { ...c, prs: c.prs ?? [], since: c.since ?? {} };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { prs: [], since: {} };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function writeCache(c) {
|
|
93
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
94
|
+
const tmp = `${cachePath()}.${process.pid}.tmp`;
|
|
95
|
+
fs.writeFileSync(tmp, JSON.stringify(c) + "\n");
|
|
96
|
+
fs.renameSync(tmp, cachePath());
|
|
97
|
+
}
|
|
98
|
+
export const runGh = (args) => new Promise((resolve, reject) => {
|
|
99
|
+
execFile("gh", args, { encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
100
|
+
if (err)
|
|
101
|
+
reject(new Error(String(stderr || err.message).trim().split("\n")[0]));
|
|
102
|
+
else
|
|
103
|
+
resolve(stdout);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
/** Hosts `gh` is signed in to. */
|
|
107
|
+
export async function ghHosts(gh = runGh) {
|
|
108
|
+
try {
|
|
109
|
+
const out = JSON.parse(await gh(["auth", "status", "--json", "hosts"]));
|
|
110
|
+
return Object.entries(out.hosts ?? {})
|
|
111
|
+
.filter(([, accounts]) => accounts.some((a) => a.state === "success"))
|
|
112
|
+
.map(([h]) => h);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const FIELDS = (withQueue) => `
|
|
119
|
+
number title url state isDraft mergeable reviewDecision updatedAt
|
|
120
|
+
author { login }
|
|
121
|
+
repository { nameWithOwner }
|
|
122
|
+
reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { name } ... on Bot { login } ... on Mannequin { login } } } }
|
|
123
|
+
latestReviews(first: 30) { nodes { author { login } state submittedAt } }
|
|
124
|
+
comments { totalCount }
|
|
125
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
|
126
|
+
${withQueue ? "mergeQueueEntry { state position }\n autoMergeRequest { enabledAt }" : ""}`;
|
|
127
|
+
function gqlString(s) {
|
|
128
|
+
return JSON.stringify(s);
|
|
129
|
+
}
|
|
130
|
+
function buildQuery(extra, withQueue, updatedSince) {
|
|
131
|
+
const aliases = extra
|
|
132
|
+
.map((r, i) => {
|
|
133
|
+
const [owner, name] = r.repo.split("/");
|
|
134
|
+
return ` p${i}: repository(owner: ${gqlString(owner)}, name: ${gqlString(name)}) { pullRequest(number: ${r.number}) { ...P } }`;
|
|
135
|
+
})
|
|
136
|
+
.join("\n");
|
|
137
|
+
return `query {
|
|
138
|
+
viewer { login }
|
|
139
|
+
mine: search(query: "is:pr is:open author:@me archived:false updated:>=${updatedSince}", type: ISSUE, first: 50) { nodes { ...P } }
|
|
140
|
+
asked: search(query: "is:pr is:open review-requested:@me archived:false updated:>=${updatedSince}", type: ISSUE, first: 30) { nodes { ...P } }
|
|
141
|
+
${aliases}
|
|
142
|
+
}
|
|
143
|
+
fragment P on PullRequest {${FIELDS(withQueue)}
|
|
144
|
+
}`;
|
|
145
|
+
}
|
|
146
|
+
const o = (x) => (typeof x === "object" && x !== null ? x : {});
|
|
147
|
+
const s = (x) => (typeof x === "string" ? x : undefined);
|
|
148
|
+
function toStatus(host, node, viewer, asked) {
|
|
149
|
+
const repo = s(o(node.repository).nameWithOwner);
|
|
150
|
+
const number = typeof node.number === "number" ? node.number : undefined;
|
|
151
|
+
if (!repo || number === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
const ref = prRef(host, repo, number, s(node.url));
|
|
154
|
+
const author = s(o(node.author).login);
|
|
155
|
+
const pending = (o(node.reviewRequests).nodes ?? [])
|
|
156
|
+
.map((n) => {
|
|
157
|
+
const r = o(o(n).requestedReviewer);
|
|
158
|
+
return s(r.login) ?? s(r.name);
|
|
159
|
+
})
|
|
160
|
+
.filter((x) => Boolean(x));
|
|
161
|
+
const reviews = (o(node.latestReviews).nodes ?? []).map((n) => ({
|
|
162
|
+
by: s(o(o(n).author).login) ?? "someone",
|
|
163
|
+
state: s(o(n).state) ?? "COMMENTED",
|
|
164
|
+
at: s(o(n).submittedAt) ?? "",
|
|
165
|
+
}));
|
|
166
|
+
const commit = o(o((o(node.commits).nodes ?? [])[0]).commit);
|
|
167
|
+
const queue = o(node.mergeQueueEntry);
|
|
168
|
+
const state = s(node.state);
|
|
169
|
+
return {
|
|
170
|
+
key: ref.key,
|
|
171
|
+
url: ref.url,
|
|
172
|
+
host,
|
|
173
|
+
repo,
|
|
174
|
+
number,
|
|
175
|
+
title: s(node.title) ?? "",
|
|
176
|
+
author,
|
|
177
|
+
state: state === "MERGED" ? "merged" : state === "CLOSED" ? "closed" : "open",
|
|
178
|
+
draft: node.isDraft === true,
|
|
179
|
+
mergeable: s(node.mergeable),
|
|
180
|
+
reviewDecision: s(node.reviewDecision) ?? undefined,
|
|
181
|
+
pending,
|
|
182
|
+
reviews,
|
|
183
|
+
comments: typeof o(node.comments).totalCount === "number" ? o(node.comments).totalCount : 0,
|
|
184
|
+
ci: s(o(commit.statusCheckRollup).state),
|
|
185
|
+
queue: s(queue.state) ? { state: s(queue.state), position: typeof queue.position === "number" ? queue.position : undefined } : undefined,
|
|
186
|
+
autoMerge: Boolean(node.autoMergeRequest),
|
|
187
|
+
updatedAt: s(node.updatedAt) ?? new Date().toISOString(),
|
|
188
|
+
mine: Boolean(viewer) && author === viewer,
|
|
189
|
+
reviewRequested: asked,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Every open PR the human wrote or is asked to review on one host, plus the extra ones named. */
|
|
193
|
+
export async function fetchHost(host, extra, gh = runGh, maxAgeDays = 60) {
|
|
194
|
+
const updatedSince = new Date(Date.now() - maxAgeDays * 86_400_000).toISOString().slice(0, 10);
|
|
195
|
+
const ask = async (withQueue) => JSON.parse(await gh(["api", "graphql", "--hostname", host, "-f", `query=${buildQuery(extra, withQueue, updatedSince)}`]));
|
|
196
|
+
let out;
|
|
197
|
+
try {
|
|
198
|
+
out = await ask(true);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
// older GitHub Enterprise servers have no merge queue fields
|
|
202
|
+
if (!/mergeQueueEntry|autoMergeRequest/.test(String(err.message)))
|
|
203
|
+
throw err;
|
|
204
|
+
out = await ask(false);
|
|
205
|
+
}
|
|
206
|
+
const data = o(out.data);
|
|
207
|
+
const viewer = s(o(data.viewer).login) ?? "";
|
|
208
|
+
const byKey = new Map();
|
|
209
|
+
const put = (node, asked) => {
|
|
210
|
+
const st = toStatus(host, o(node), viewer, asked);
|
|
211
|
+
if (!st)
|
|
212
|
+
return;
|
|
213
|
+
const had = byKey.get(st.key);
|
|
214
|
+
byKey.set(st.key, had ? { ...st, reviewRequested: had.reviewRequested || asked } : st);
|
|
215
|
+
};
|
|
216
|
+
for (const n of o(data.mine).nodes ?? [])
|
|
217
|
+
put(n, false);
|
|
218
|
+
for (const n of o(data.asked).nodes ?? [])
|
|
219
|
+
put(n, true);
|
|
220
|
+
extra.forEach((_, i) => {
|
|
221
|
+
const pr = o(o(data[`p${i}`]).pullRequest);
|
|
222
|
+
if (Object.keys(pr).length)
|
|
223
|
+
put(pr, false);
|
|
224
|
+
});
|
|
225
|
+
return [...byKey.values()];
|
|
226
|
+
}
|
|
227
|
+
// ------------------------------------------------------------------ what changed
|
|
228
|
+
/** What a PR waits on now, and on whom. */
|
|
229
|
+
export function waitingOn(p) {
|
|
230
|
+
const author = p.author ? [p.author] : undefined;
|
|
231
|
+
if (p.state === "merged")
|
|
232
|
+
return { what: "merged" };
|
|
233
|
+
if (p.state === "closed")
|
|
234
|
+
return { what: "closed" };
|
|
235
|
+
if (p.draft)
|
|
236
|
+
return { what: "draft", who: author };
|
|
237
|
+
if (p.mergeable === "CONFLICTING")
|
|
238
|
+
return { what: "conflict", who: author };
|
|
239
|
+
if (p.ci === "FAILURE" || p.ci === "ERROR")
|
|
240
|
+
return { what: "CI failed", who: author };
|
|
241
|
+
if (p.reviewDecision === "CHANGES_REQUESTED") {
|
|
242
|
+
const by = p.reviews.filter((r) => r.state === "CHANGES_REQUESTED").map((r) => r.by);
|
|
243
|
+
return { what: "changes requested", who: author ?? by };
|
|
244
|
+
}
|
|
245
|
+
if (p.queue)
|
|
246
|
+
return { what: "merge queue" };
|
|
247
|
+
if (p.ci === "PENDING" || p.ci === "EXPECTED")
|
|
248
|
+
return { what: "CI" };
|
|
249
|
+
if (p.reviewDecision === "APPROVED")
|
|
250
|
+
return p.autoMerge ? { what: "auto-merge" } : { what: "merge", who: author };
|
|
251
|
+
if (p.pending.length)
|
|
252
|
+
return { what: "review", who: p.pending };
|
|
253
|
+
if (p.reviewDecision === "REVIEW_REQUIRED")
|
|
254
|
+
return { what: "no reviewer", who: author };
|
|
255
|
+
return { what: "merge", who: author };
|
|
256
|
+
}
|
|
257
|
+
const REVIEW_CHANGE = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", COMMENTED: "review", DISMISSED: "review" };
|
|
258
|
+
/** The events between two readings of the same PR. */
|
|
259
|
+
export function changesBetween(old, now) {
|
|
260
|
+
const out = [];
|
|
261
|
+
const seen = new Set(old.reviews.map((r) => `${r.by}|${r.state}|${r.at}`));
|
|
262
|
+
for (const r of now.reviews) {
|
|
263
|
+
if (!seen.has(`${r.by}|${r.state}|${r.at}`))
|
|
264
|
+
out.push({ change: REVIEW_CHANGE[r.state] ?? "review", by: r.by });
|
|
265
|
+
}
|
|
266
|
+
if (now.comments > old.comments)
|
|
267
|
+
out.push({ change: "commented" });
|
|
268
|
+
const failed = (x) => x === "FAILURE" || x === "ERROR";
|
|
269
|
+
if (failed(now.ci) && !failed(old.ci))
|
|
270
|
+
out.push({ change: "ci_failed" });
|
|
271
|
+
if (now.ci === "SUCCESS" && old.ci && old.ci !== "SUCCESS")
|
|
272
|
+
out.push({ change: "ci_passed" });
|
|
273
|
+
if (now.queue && !old.queue)
|
|
274
|
+
out.push({ change: "queued" });
|
|
275
|
+
if (!now.queue && old.queue && now.state === "open")
|
|
276
|
+
out.push({ change: "queue_removed" });
|
|
277
|
+
if (now.mergeable === "CONFLICTING" && old.mergeable !== "CONFLICTING")
|
|
278
|
+
out.push({ change: "conflict" });
|
|
279
|
+
if (now.state === "merged" && old.state !== "merged")
|
|
280
|
+
out.push({ change: "merged" });
|
|
281
|
+
if (now.state === "closed" && old.state === "open")
|
|
282
|
+
out.push({ change: "closed" });
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
/** Next cache and the events it implies, given the previous cache and a fresh reading. */
|
|
286
|
+
export function nextCache(prev, fresh, hosts, at) {
|
|
287
|
+
const old = new Map(prev.prs.map((p) => [p.key, p]));
|
|
288
|
+
const events = [];
|
|
289
|
+
const since = {};
|
|
290
|
+
for (const p of fresh) {
|
|
291
|
+
const was = old.get(p.key);
|
|
292
|
+
if (was) {
|
|
293
|
+
for (const c of changesBetween(was, p)) {
|
|
294
|
+
events.push({ type: "pr.change", pr: prRef(p.host, p.repo, p.number, p.url), change: c.change, title: p.title, by: c.by, source: "github" });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const w = waitingOn(p).what;
|
|
298
|
+
const before = prev.since[p.key];
|
|
299
|
+
// first sighting: the last update is the best guess of when it entered this state
|
|
300
|
+
since[p.key] = before && before.waitingOn === w ? before : { waitingOn: w, since: was ? at : p.updatedAt };
|
|
301
|
+
}
|
|
302
|
+
// keep closed and merged ones for a day, so the change shows, then forget them
|
|
303
|
+
const dayAgo = new Date(Date.parse(at) - 86_400_000).toISOString();
|
|
304
|
+
const prs = fresh.filter((p) => p.state === "open" || (since[p.key]?.since ?? at) > dayAgo);
|
|
305
|
+
for (const k of Object.keys(since))
|
|
306
|
+
if (!prs.some((p) => p.key === k))
|
|
307
|
+
delete since[k];
|
|
308
|
+
return { cache: { fetchedAt: at, hosts, prs, since }, events };
|
|
309
|
+
}
|
|
310
|
+
// ------------------------------------------------------------------ sync
|
|
311
|
+
function fetchingPath() {
|
|
312
|
+
return path.join(homeDir(), "github.fetching");
|
|
313
|
+
}
|
|
314
|
+
/** Only one process reads GitHub at a time; a crashed reader's mark goes stale after a minute. */
|
|
315
|
+
function takeFetch() {
|
|
316
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
317
|
+
try {
|
|
318
|
+
fs.writeFileSync(fetchingPath(), String(process.pid), { flag: "wx" });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
try {
|
|
323
|
+
if (Date.now() - fs.statSync(fetchingPath()).mtimeMs > 60_000) {
|
|
324
|
+
fs.rmSync(fetchingPath(), { force: true });
|
|
325
|
+
return takeFetch();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// gone in between; someone just finished
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Read GitHub if the last reading is older than the configured interval, record what changed as
|
|
336
|
+
* `pr.change` events (they wake the agents that own the PRs), and return the cache.
|
|
337
|
+
*/
|
|
338
|
+
export async function syncGithub(opts) {
|
|
339
|
+
const cfg = githubConfig();
|
|
340
|
+
const prev = readCache();
|
|
341
|
+
if (!cfg.enabled)
|
|
342
|
+
return prev;
|
|
343
|
+
const age = prev.fetchedAt ? Date.now() - Date.parse(prev.fetchedAt) : Infinity;
|
|
344
|
+
if (!opts.force && age < cfg.intervalMinutes * 60_000)
|
|
345
|
+
return prev;
|
|
346
|
+
if (!takeFetch())
|
|
347
|
+
return prev;
|
|
348
|
+
const gh = opts.gh ?? runGh;
|
|
349
|
+
const started = new Date().toISOString();
|
|
350
|
+
try {
|
|
351
|
+
const hosts = await ghHosts(gh);
|
|
352
|
+
if (!hosts.length) {
|
|
353
|
+
const c = { ...prev, fetchedAt: started, hosts: [], error: "gh is not installed or not signed in" };
|
|
354
|
+
writeCache(c);
|
|
355
|
+
return c;
|
|
356
|
+
}
|
|
357
|
+
// PRs linked to sessions, and ones seen before, are read by number so merges and closes show
|
|
358
|
+
const extra = new Map();
|
|
359
|
+
for (const l of opts.state.prLinks.values())
|
|
360
|
+
extra.set(l.pr.key, l.pr);
|
|
361
|
+
const cutoff = new Date(Date.now() - cfg.maxAgeDays * 86_400_000).toISOString();
|
|
362
|
+
for (const p of prev.prs)
|
|
363
|
+
if (p.state === "open" && p.updatedAt >= cutoff)
|
|
364
|
+
extra.set(p.key, prRef(p.host, p.repo, p.number, p.url));
|
|
365
|
+
const fresh = [];
|
|
366
|
+
const errors = [];
|
|
367
|
+
const allHosts = new Set([...hosts, ...[...extra.values()].map((r) => r.host)]);
|
|
368
|
+
for (const host of allHosts) {
|
|
369
|
+
if (!hosts.includes(host)) {
|
|
370
|
+
errors.push(`gh is not signed in to ${host}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
fresh.push(...(await fetchHost(host, [...extra.values()].filter((r) => r.host === host).slice(0, 80), gh, cfg.maxAgeDays)));
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
errors.push(`${host}: ${err.message}`);
|
|
378
|
+
// keep what we knew about that host
|
|
379
|
+
fresh.push(...prev.prs.filter((p) => p.host === host));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return withLock(() => {
|
|
383
|
+
const latest = readCache();
|
|
384
|
+
if (latest.fetchedAt && latest.fetchedAt > started)
|
|
385
|
+
return latest; // someone else was faster
|
|
386
|
+
const { cache, events } = nextCache(latest, fresh, hosts, started);
|
|
387
|
+
if (errors.length)
|
|
388
|
+
cache.error = errors.join("; ");
|
|
389
|
+
appendLocked(() => events);
|
|
390
|
+
writeCache(cache);
|
|
391
|
+
return cache;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
fs.rmSync(fetchingPath(), { force: true });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// ------------------------------------------------------------------ views
|
|
399
|
+
/** Open PRs as `status` shows them, each with the session that owns it when one does. */
|
|
400
|
+
export function prViews(cache, state, runs, now = Date.now()) {
|
|
401
|
+
const cfg = githubConfig();
|
|
402
|
+
const created = new Map();
|
|
403
|
+
for (const r of runs)
|
|
404
|
+
for (const u of r.createdPrs ?? []) {
|
|
405
|
+
const k = keyOf(u);
|
|
406
|
+
if (k)
|
|
407
|
+
created.set(k, r);
|
|
408
|
+
}
|
|
409
|
+
return cache.prs
|
|
410
|
+
.filter((p) => p.state === "open")
|
|
411
|
+
.map((p) => {
|
|
412
|
+
const w = waitingOn(p);
|
|
413
|
+
const since = cache.since[p.key]?.since ?? p.updatedAt;
|
|
414
|
+
const limit = cfg.stallHours[w.what];
|
|
415
|
+
const link = state.prLinks.get(p.key);
|
|
416
|
+
const run = link?.session ? runs.find((r) => r.id.startsWith(link.session)) : created.get(p.key);
|
|
417
|
+
const session = link?.session ?? run?.id;
|
|
418
|
+
const claim = session ? state.claims.find((c) => session.startsWith(c.session)) : undefined;
|
|
419
|
+
return {
|
|
420
|
+
key: p.key,
|
|
421
|
+
url: p.url,
|
|
422
|
+
repo: p.repo,
|
|
423
|
+
number: p.number,
|
|
424
|
+
title: p.title,
|
|
425
|
+
author: p.author,
|
|
426
|
+
state: p.state,
|
|
427
|
+
draft: p.draft,
|
|
428
|
+
waitingOn: w.what,
|
|
429
|
+
who: w.who,
|
|
430
|
+
since,
|
|
431
|
+
stalled: limit !== undefined && now - Date.parse(since) > limit * 3_600_000,
|
|
432
|
+
reviewRequested: p.reviewRequested,
|
|
433
|
+
mine: p.mine,
|
|
434
|
+
session,
|
|
435
|
+
sessionTitle: claim?.title ?? run?.title,
|
|
436
|
+
project: link?.project ?? claim?.project ?? run?.project,
|
|
437
|
+
task: link?.task ?? claim?.task ?? run?.task,
|
|
438
|
+
};
|
|
439
|
+
})
|
|
440
|
+
.sort((a, b) => Number(b.stalled) - Number(a.stalled) || a.since.localeCompare(b.since));
|
|
441
|
+
}
|
|
442
|
+
//# sourceMappingURL=github.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readInbox, inboxFrom } from "./ops.js";
|
|
5
|
+
import { homeDir, readCursor, readEvents } from "./store.js";
|
|
6
|
+
import { fold } from "./state.js";
|
|
7
|
+
import { inboxLine } from "./format.js";
|
|
8
|
+
export const BEGIN = "<!-- parawatch:begin -->";
|
|
9
|
+
export const END = "<!-- parawatch:end -->";
|
|
10
|
+
export function instructions(project) {
|
|
11
|
+
const p = project ?? "<project>";
|
|
12
|
+
return `${BEGIN}
|
|
13
|
+
## Parawatch
|
|
14
|
+
|
|
15
|
+
The human follows this work through \`parawatch\`. Use it instead of asking in chat. If a \`parawatch\` skill is available, follow it.
|
|
16
|
+
|
|
17
|
+
- As soon as you know what you are working on, say so, even when this folder is not where the code lives: \`parawatch claim --project ${p} --task <ticket, PR or short name> --title "<what you do>"\`. Claim again when you move to other work.
|
|
18
|
+
- When you open a pull request: \`parawatch pr link <url>\`. Reviews, CI results and merges on it then wake your wait.
|
|
19
|
+
- When the work is finished: \`parawatch claim --done "<one line on how it ended>"\`.
|
|
20
|
+
|
|
21
|
+
- Need a decision: \`parawatch ask --project ${p} "question" --option "..." --option "..." --recommend N --body "two or three lines"\`
|
|
22
|
+
- Need approval for a step you have prepared (a merge, a message, a status change): \`parawatch ask --project ${p} "title" --action "<exact command>" [--preview <file with the text>]\`
|
|
23
|
+
- Need the human to try something (a build, a video, a screen): \`parawatch try --project ${p} "title" --open <app or file> --attach <video or image> --look "what to look at"\`
|
|
24
|
+
- Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
|
|
25
|
+
- Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
|
|
26
|
+
- Put every question for the human on a card, not only in your reply: a question left in chat is easy to miss.
|
|
27
|
+
- After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait --project ${p}\`. It prints the human's answers and pull request news and exits; act on them, \`parawatch close <card> --note "what you did"\`, then arm it again.
|
|
28
|
+
- A long job with no agent log (a bake, a benchmark): \`parawatch run start --project ${p} "title" --heartbeat <seconds> [--log <file>]\`, \`parawatch run note <id> "..."\`, \`parawatch run end <id>\`.
|
|
29
|
+
- Never answer a card yourself. \`parawatch status\` shows everything that is open.
|
|
30
|
+
${END}`;
|
|
31
|
+
}
|
|
32
|
+
/** Insert or replace the parawatch block in an instruction file. Returns true when the file changed. */
|
|
33
|
+
export function writeInstructions(file, project) {
|
|
34
|
+
let text = "";
|
|
35
|
+
try {
|
|
36
|
+
text = fs.readFileSync(file, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err.code !== "ENOENT")
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const block = instructions(project);
|
|
43
|
+
let next;
|
|
44
|
+
const a = text.indexOf(BEGIN);
|
|
45
|
+
const b = text.indexOf(END);
|
|
46
|
+
if (a >= 0 && b > a)
|
|
47
|
+
next = text.slice(0, a) + block + text.slice(b + END.length);
|
|
48
|
+
else
|
|
49
|
+
next = text + (text && !text.endsWith("\n") ? "\n" : "") + (text ? "\n" : "") + block + "\n";
|
|
50
|
+
if (next === text)
|
|
51
|
+
return false;
|
|
52
|
+
fs.writeFileSync(file, next);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Claude Code hook: given the hook's JSON on stdin, return text to add to the turn
|
|
57
|
+
* when the human has answered something for this session's project. Peeks only;
|
|
58
|
+
* the agent's own `wait` still advances its cursor.
|
|
59
|
+
*/
|
|
60
|
+
export function hookContext(input, project) {
|
|
61
|
+
const events = readEvents();
|
|
62
|
+
const state = fold(events);
|
|
63
|
+
let name = project;
|
|
64
|
+
if (!name && input.session_id)
|
|
65
|
+
name = state.claims.find((c) => input.session_id.startsWith(c.session))?.project;
|
|
66
|
+
if (!name && input.cwd) {
|
|
67
|
+
for (const p of state.projects.values())
|
|
68
|
+
if (p.paths.some((x) => input.cwd === x || input.cwd.startsWith(x + "/")))
|
|
69
|
+
name = p.name;
|
|
70
|
+
}
|
|
71
|
+
if (!name)
|
|
72
|
+
return undefined;
|
|
73
|
+
const items = inboxFrom(events, readCursor(name), { project: name });
|
|
74
|
+
if (!items.length)
|
|
75
|
+
return undefined;
|
|
76
|
+
const cards = items.filter((i) => i.kind !== "pr").length;
|
|
77
|
+
const prs = items.length - cards;
|
|
78
|
+
const what = [cards ? `${cards} answer(s) on cards` : "", prs ? `${prs} pull request update(s)` : ""].filter(Boolean).join(" and ");
|
|
79
|
+
return [`Parawatch: ${what} for ${name} that you have not handled yet.`, ...items.map(inboxLine), cards ? `Act on them, then \`parawatch close <card>\`.` : `Act on them.`].join("\n");
|
|
80
|
+
}
|
|
81
|
+
/** Best-effort desktop notification. Never throws, never blocks. */
|
|
82
|
+
/** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */
|
|
83
|
+
export function desktopPidPath() {
|
|
84
|
+
return path.join(homeDir(), "desktop.pid");
|
|
85
|
+
}
|
|
86
|
+
function desktopAppRunning() {
|
|
87
|
+
try {
|
|
88
|
+
const pid = Number(fs.readFileSync(desktopPidPath(), "utf8").trim());
|
|
89
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
90
|
+
return false;
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function notify(card) {
|
|
99
|
+
if (process.env.PARAWATCH_NOTIFY === "0")
|
|
100
|
+
return;
|
|
101
|
+
if (card.kind === "todo")
|
|
102
|
+
return; // a to-do waits in the panel
|
|
103
|
+
if (desktopAppRunning())
|
|
104
|
+
return; // the app notifies, with its own badge
|
|
105
|
+
const title = `parawatch · ${card.project}`;
|
|
106
|
+
const body = `${card.kind === "decide" ? "Decide" : card.kind === "try" ? "Try" : "Blocked"}: ${card.title}`;
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "darwin") {
|
|
109
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
110
|
+
spawn("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
|
|
111
|
+
}
|
|
112
|
+
else if (process.platform === "linux") {
|
|
113
|
+
spawn("notify-send", [title, body], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// no notifier available
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export { readInbox };
|
|
121
|
+
//# sourceMappingURL=integrate.js.map
|