@cueloop/daemon 0.1.0-alpha.85 → 0.1.0-alpha.86
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/package.json +2 -2
- package/src/api.ts +63 -10
- package/src/client.ts +2 -0
- package/src/diff-hot-reload.test.ts +22 -3
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +50 -13
- package/src/pr-poller.test.ts +43 -17
- package/src/pr-poller.ts +21 -22
- package/src/thread-review.ts +8 -0
- package/src/validate.test.ts +17 -0
- package/src/validate.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.86",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
41
|
+
"@cueloop/schema": "0.1.0-alpha.86",
|
|
42
42
|
"@pierre/diffs": "^1.4.1",
|
|
43
43
|
"effect": "^4.0.0-rc.115",
|
|
44
44
|
"smol-toml": "^1.8.0",
|
package/src/api.ts
CHANGED
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
import { HarnessStateStore } from "./harness-state-store";
|
|
66
66
|
import { DiffWatcher } from "./diff-watcher";
|
|
67
67
|
import { PrReviewPoller } from "./pr-poller";
|
|
68
|
-
import {
|
|
68
|
+
import { prSnapshot } from "./gh";
|
|
69
69
|
import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
|
|
70
70
|
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
71
71
|
import { resolveWorkspace } from "./thread-review";
|
|
@@ -90,6 +90,7 @@ function annotationDeliveryFingerprint(annotation: Annotation | undefined): stri
|
|
|
90
90
|
target: annotation.target,
|
|
91
91
|
body: annotation.body,
|
|
92
92
|
author: annotation.author,
|
|
93
|
+
reviewComment: annotation.reviewComment,
|
|
93
94
|
replyTo: annotation.replyTo,
|
|
94
95
|
});
|
|
95
96
|
}
|
|
@@ -158,7 +159,9 @@ export class DaemonCore {
|
|
|
158
159
|
this.ghosttyThreadSurfaces.delete(threadId);
|
|
159
160
|
}
|
|
160
161
|
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
161
|
-
this.prPoller = new PrReviewPoller((sessionId) =>
|
|
162
|
+
this.prPoller = new PrReviewPoller((sessionId, refs) =>
|
|
163
|
+
this.markPrRefreshAvailable(sessionId, refs),
|
|
164
|
+
);
|
|
162
165
|
// resume hot-reload for diff sessions that survived a daemon restart
|
|
163
166
|
for (const session of this.store.list()) {
|
|
164
167
|
this.trackLiveDiffSession(session);
|
|
@@ -1073,31 +1076,72 @@ export class DaemonCore {
|
|
|
1073
1076
|
|
|
1074
1077
|
if (!session || session.status !== "pending" || session.artifact.type !== "diff")
|
|
1075
1078
|
return { changed: false };
|
|
1076
|
-
const pr = session.artifact.meta.pr;
|
|
1079
|
+
const pr = session.artifact.meta.prUrl ?? session.artifact.meta.pr;
|
|
1077
1080
|
|
|
1078
1081
|
if (pr === undefined) return { changed: false };
|
|
1079
1082
|
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
1080
1083
|
|
|
1081
1084
|
this.diffRefreshGenerations.set(id, generation);
|
|
1082
|
-
const
|
|
1085
|
+
const snapshot = await prSnapshot(pr);
|
|
1083
1086
|
|
|
1084
1087
|
// gh failed (offline, unauthenticated): keep the diff we have, try again next poll
|
|
1085
|
-
if (
|
|
1088
|
+
if (snapshot === null) return { changed: false };
|
|
1086
1089
|
// the pull yields the event loop: discard a stale capture or a closed session
|
|
1087
1090
|
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
1088
1091
|
const current = this.store.get(id);
|
|
1089
1092
|
|
|
1090
1093
|
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
1091
1094
|
return { changed: false };
|
|
1092
|
-
|
|
1093
|
-
|
|
1095
|
+
const contentChanged = snapshot.patch !== current.artifact.content;
|
|
1096
|
+
|
|
1097
|
+
if (
|
|
1098
|
+
!contentChanged &&
|
|
1099
|
+
snapshot.baseSha === current.artifact.meta.prBaseSha &&
|
|
1100
|
+
snapshot.headSha === current.artifact.meta.prHeadSha &&
|
|
1101
|
+
current.artifact.meta.prRefreshBaseSha === undefined &&
|
|
1102
|
+
current.artifact.meta.prRefreshHeadSha === undefined
|
|
1103
|
+
)
|
|
1104
|
+
return { changed: false };
|
|
1105
|
+
current.artifact = {
|
|
1106
|
+
...current.artifact,
|
|
1107
|
+
content: snapshot.patch,
|
|
1108
|
+
meta: {
|
|
1109
|
+
...current.artifact.meta,
|
|
1110
|
+
prBaseSha: snapshot.baseSha,
|
|
1111
|
+
prHeadSha: snapshot.headSha,
|
|
1112
|
+
prRefreshBaseSha: undefined,
|
|
1113
|
+
prRefreshHeadSha: undefined,
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1094
1116
|
const history = withHistory(current).history;
|
|
1095
1117
|
|
|
1096
|
-
if (history) current.history = recaptureMainHead(history, patch);
|
|
1118
|
+
if (history && contentChanged) current.history = recaptureMainHead(history, snapshot.patch);
|
|
1097
1119
|
this.store.upsert(current);
|
|
1098
1120
|
this.emit("session.updated", id);
|
|
1099
1121
|
|
|
1100
|
-
return { changed:
|
|
1122
|
+
return { changed: contentChanged };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Record moved PR refs without replacing the review until the reviewer refreshes it. */
|
|
1126
|
+
private markPrRefreshAvailable(id: string, refs: { baseSha: string; headSha: string }): void {
|
|
1127
|
+
const session = this.store.get(id);
|
|
1128
|
+
|
|
1129
|
+
if (!session || session.status !== "pending" || session.artifact.meta.pr === undefined) return;
|
|
1130
|
+
if (
|
|
1131
|
+
session.artifact.meta.prBaseSha === refs.baseSha &&
|
|
1132
|
+
session.artifact.meta.prHeadSha === refs.headSha
|
|
1133
|
+
)
|
|
1134
|
+
return;
|
|
1135
|
+
session.artifact = {
|
|
1136
|
+
...session.artifact,
|
|
1137
|
+
meta: {
|
|
1138
|
+
...session.artifact.meta,
|
|
1139
|
+
prRefreshBaseSha: refs.baseSha,
|
|
1140
|
+
prRefreshHeadSha: refs.headSha,
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
this.store.upsert(session);
|
|
1144
|
+
this.emit("session.updated", id);
|
|
1101
1145
|
}
|
|
1102
1146
|
|
|
1103
1147
|
/** Re-capture every working-tree diff session sharing a repo root (one debounced fs change). */
|
|
@@ -1122,7 +1166,16 @@ export class DaemonCore {
|
|
|
1122
1166
|
if (isWorkingTreeDiffSession(session))
|
|
1123
1167
|
this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
|
|
1124
1168
|
else if (isPrReviewSession(session))
|
|
1125
|
-
this.prPoller.trackPr(
|
|
1169
|
+
this.prPoller.trackPr(
|
|
1170
|
+
session.id,
|
|
1171
|
+
session.artifact.meta.prUrl ?? session.artifact.meta.pr!,
|
|
1172
|
+
session.artifact.meta.prBaseSha && session.artifact.meta.prHeadSha
|
|
1173
|
+
? {
|
|
1174
|
+
baseSha: session.artifact.meta.prBaseSha,
|
|
1175
|
+
headSha: session.artifact.meta.prHeadSha,
|
|
1176
|
+
}
|
|
1177
|
+
: null,
|
|
1178
|
+
);
|
|
1126
1179
|
}
|
|
1127
1180
|
|
|
1128
1181
|
private untrackLiveDiffSession(session: Thread): void {
|
package/src/client.ts
CHANGED
|
@@ -142,6 +142,8 @@ export interface ThreadClient {
|
|
|
142
142
|
repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
143
143
|
/** The live working-tree diff (patch plus per-file contents) at `cwd`. */
|
|
144
144
|
repoDiff?(cwd: string): Promise<WorkingTreeDiff>;
|
|
145
|
+
/** Refresh a local diff or explicitly pull a moved PR head. */
|
|
146
|
+
sessionRefreshDiff?(id: string): Promise<{ changed: boolean }>;
|
|
145
147
|
/** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
|
|
146
148
|
sessionWorkbench?(cwd: string): Promise<Thread>;
|
|
147
149
|
/** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { DaemonCore } from "./api";
|
|
@@ -168,10 +168,17 @@ describe("PR review sessions", () => {
|
|
|
168
168
|
test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
|
|
169
169
|
// Given a stub gh that reports a PR diff distinct from the local working tree
|
|
170
170
|
const ghStub = join(repo, "gh-stub.sh");
|
|
171
|
+
const ghLog = join(repo, "gh-stub.log");
|
|
171
172
|
|
|
172
173
|
writeFileSync(
|
|
173
174
|
ghStub,
|
|
174
|
-
|
|
175
|
+
`#!/bin/sh
|
|
176
|
+
printf '%s\n' "$*" >> '${ghLog}'
|
|
177
|
+
case "$2" in
|
|
178
|
+
diff) printf "STUB PR DIFF\\n" ;;
|
|
179
|
+
view) printf '{"baseRefOid":"base-2","headRefOid":"sha-2"}\\n' ;;
|
|
180
|
+
esac
|
|
181
|
+
`,
|
|
175
182
|
);
|
|
176
183
|
chmodSync(ghStub, 0o755);
|
|
177
184
|
const previousGh = process.env.CUELOOP_GH;
|
|
@@ -185,7 +192,14 @@ describe("PR review sessions", () => {
|
|
|
185
192
|
type: "diff",
|
|
186
193
|
content: "OLD PR DIFF\n",
|
|
187
194
|
files: [],
|
|
188
|
-
meta: {
|
|
195
|
+
meta: {
|
|
196
|
+
pr: "1",
|
|
197
|
+
prUrl: "https://github.com/org/repo/pull/1",
|
|
198
|
+
prBaseSha: "base-1",
|
|
199
|
+
prHeadSha: "sha-1",
|
|
200
|
+
prRefreshBaseSha: "base-2",
|
|
201
|
+
prRefreshHeadSha: "sha-2",
|
|
202
|
+
},
|
|
189
203
|
};
|
|
190
204
|
const session = core.sessionCreate({ workspace, artifact });
|
|
191
205
|
|
|
@@ -200,6 +214,11 @@ describe("PR review sessions", () => {
|
|
|
200
214
|
|
|
201
215
|
expect(refreshed.artifact.content).toContain("STUB PR DIFF");
|
|
202
216
|
expect(refreshed.artifact.content).not.toContain("export const a = 123;");
|
|
217
|
+
expect(refreshed.artifact.meta.prBaseSha).toBe("base-2");
|
|
218
|
+
expect(refreshed.artifact.meta.prHeadSha).toBe("sha-2");
|
|
219
|
+
expect(refreshed.artifact.meta.prRefreshBaseSha).toBeUndefined();
|
|
220
|
+
expect(refreshed.artifact.meta.prRefreshHeadSha).toBeUndefined();
|
|
221
|
+
expect(readFileSync(ghLog, "utf8")).toContain("https://github.com/org/repo/pull/1");
|
|
203
222
|
} finally {
|
|
204
223
|
if (previousGh === undefined) delete process.env.CUELOOP_GH;
|
|
205
224
|
else process.env.CUELOOP_GH = previousGh;
|
package/src/gh.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { prSnapshot } from "./gh";
|
|
3
|
+
|
|
4
|
+
describe("prSnapshot", () => {
|
|
5
|
+
test("retries until the diff and head are one stable snapshot", async () => {
|
|
6
|
+
const refs = [
|
|
7
|
+
{ baseSha: "base-1", headSha: "head-1" },
|
|
8
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
9
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
10
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
11
|
+
];
|
|
12
|
+
const patches = ["patch-1", "patch-2"];
|
|
13
|
+
|
|
14
|
+
const snapshot = await prSnapshot(
|
|
15
|
+
"https://github.com/org/repo/pull/1",
|
|
16
|
+
3,
|
|
17
|
+
async () => refs.shift() ?? null,
|
|
18
|
+
async () => patches.shift() ?? null,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(snapshot).toEqual({ patch: "patch-2", baseSha: "base-1", headSha: "head-2" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns no snapshot when the head keeps moving", async () => {
|
|
25
|
+
let head = 0;
|
|
26
|
+
|
|
27
|
+
const snapshot = await prSnapshot(
|
|
28
|
+
"https://github.com/org/repo/pull/1",
|
|
29
|
+
2,
|
|
30
|
+
async () => ({ baseSha: "base", headSha: `head-${head++}` }),
|
|
31
|
+
async () => "patch",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(snapshot).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("retries when only the base moves", async () => {
|
|
38
|
+
const refs = [
|
|
39
|
+
{ baseSha: "base-1", headSha: "head" },
|
|
40
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
41
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
42
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
43
|
+
];
|
|
44
|
+
const patches = ["old-base-patch", "new-base-patch"];
|
|
45
|
+
|
|
46
|
+
const snapshot = await prSnapshot(
|
|
47
|
+
"https://github.com/org/repo/pull/1",
|
|
48
|
+
3,
|
|
49
|
+
async () => refs.shift() ?? null,
|
|
50
|
+
async () => patches.shift() ?? null,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(snapshot).toEqual({
|
|
54
|
+
patch: "new-base-patch",
|
|
55
|
+
baseSha: "base-2",
|
|
56
|
+
headSha: "head",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/gh.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
// gh CLI reads for PR reviews; auth is delegated to the user's gh. Reads only.
|
|
2
2
|
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
export interface PullRequestRefs {
|
|
6
|
+
baseSha: string;
|
|
7
|
+
headSha: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PullRequestRefsSchema = v.object({
|
|
11
|
+
baseRefOid: v.pipe(v.string(), v.nonEmpty()),
|
|
12
|
+
headRefOid: v.pipe(v.string(), v.nonEmpty()),
|
|
13
|
+
});
|
|
14
|
+
|
|
3
15
|
const ghBinary = (): string => process.env.CUELOOP_GH || "gh";
|
|
4
16
|
|
|
5
17
|
async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
|
|
@@ -9,22 +21,23 @@ async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
|
|
|
9
21
|
return { code: await process.exited, stdout };
|
|
10
22
|
}
|
|
11
23
|
|
|
12
|
-
/** The PR head
|
|
13
|
-
export async function
|
|
14
|
-
const { code, stdout } = await gh([
|
|
15
|
-
"pr",
|
|
16
|
-
"view",
|
|
17
|
-
pr,
|
|
18
|
-
"--json",
|
|
19
|
-
"headRefOid",
|
|
20
|
-
"-q",
|
|
21
|
-
".headRefOid",
|
|
22
|
-
]);
|
|
24
|
+
/** The PR base and head commits, or null when gh is absent, unauthenticated, or the call fails. */
|
|
25
|
+
export async function prRefs(pr: string): Promise<PullRequestRefs | null> {
|
|
26
|
+
const { code, stdout } = await gh(["pr", "view", pr, "--json", "baseRefOid,headRefOid"]);
|
|
23
27
|
|
|
24
28
|
if (code !== 0) return null;
|
|
25
|
-
|
|
29
|
+
let input: unknown;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
input = JSON.parse(stdout);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const parsed = v.safeParse(PullRequestRefsSchema, input);
|
|
37
|
+
|
|
38
|
+
if (!parsed.success) return null;
|
|
26
39
|
|
|
27
|
-
return
|
|
40
|
+
return { baseSha: parsed.output.baseRefOid, headSha: parsed.output.headRefOid };
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
/** The PR unified diff as gh prints it, or null when the call fails. */
|
|
@@ -35,3 +48,27 @@ export async function prDiff(pr: string): Promise<string | null> {
|
|
|
35
48
|
|
|
36
49
|
return stdout;
|
|
37
50
|
}
|
|
51
|
+
|
|
52
|
+
/** A stable PR diff and the exact base/head pair it represents. */
|
|
53
|
+
export async function prSnapshot(
|
|
54
|
+
pr: string,
|
|
55
|
+
attempts = 3,
|
|
56
|
+
readRefs: (reference: string) => Promise<PullRequestRefs | null> = prRefs,
|
|
57
|
+
readDiff: (reference: string) => Promise<string | null> = prDiff,
|
|
58
|
+
): Promise<{ patch: string; baseSha: string; headSha: string } | null> {
|
|
59
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
60
|
+
const before = await readRefs(pr);
|
|
61
|
+
|
|
62
|
+
if (before === null) return null;
|
|
63
|
+
const patch = await readDiff(pr);
|
|
64
|
+
|
|
65
|
+
if (patch === null) return null;
|
|
66
|
+
const after = await readRefs(pr);
|
|
67
|
+
|
|
68
|
+
if (after === null) return null;
|
|
69
|
+
if (before.baseSha === after.baseSha && before.headSha === after.headSha)
|
|
70
|
+
return { patch, ...after };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
package/src/pr-poller.test.ts
CHANGED
|
@@ -2,27 +2,31 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { PrReviewPoller } from "./pr-poller";
|
|
3
3
|
|
|
4
4
|
/** Build a poller whose head sha is controllable, with the timer effectively disabled. */
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
function refs(baseSha: string, headSha: string) {
|
|
6
|
+
return { baseSha, headSha };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makePoller(initialRefs: ReturnType<typeof refs> | null) {
|
|
10
|
+
let currentRefs = initialRefs;
|
|
11
|
+
const advanced: Array<{ sessionId: string; refs: ReturnType<typeof refs> }> = [];
|
|
8
12
|
const poller = new PrReviewPoller(
|
|
9
|
-
(sessionId) => advanced.push(sessionId),
|
|
10
|
-
async () =>
|
|
13
|
+
(sessionId, nextRefs) => advanced.push({ sessionId, refs: nextRefs }),
|
|
14
|
+
async () => currentRefs,
|
|
11
15
|
1_000_000,
|
|
12
16
|
);
|
|
13
17
|
|
|
14
18
|
return {
|
|
15
19
|
poller,
|
|
16
20
|
advanced,
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
setRefs: (next: ReturnType<typeof refs> | null) => {
|
|
22
|
+
currentRefs = next;
|
|
19
23
|
},
|
|
20
24
|
};
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
describe("PrReviewPoller", () => {
|
|
24
28
|
test("the first observation is a silent baseline", async () => {
|
|
25
|
-
const { poller, advanced } = makePoller("sha1");
|
|
29
|
+
const { poller, advanced } = makePoller(refs("base1", "sha1"));
|
|
26
30
|
|
|
27
31
|
poller.trackPr("s1", "org/repo#1");
|
|
28
32
|
await poller.refreshHead("s1");
|
|
@@ -32,45 +36,67 @@ describe("PrReviewPoller", () => {
|
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
test("a moved head fires once; an unchanged head stays quiet", async () => {
|
|
35
|
-
const { poller, advanced,
|
|
39
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
36
40
|
|
|
37
41
|
poller.trackPr("s1", "org/repo#1");
|
|
38
42
|
await poller.refreshHead("s1");
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
setRefs(refs("base1", "sha2"));
|
|
41
45
|
await poller.refreshHead("s1");
|
|
42
46
|
await poller.refreshHead("s1");
|
|
43
47
|
|
|
44
|
-
expect(advanced).toEqual(["s1"]);
|
|
48
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
49
|
+
poller.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a persisted reviewed head exposes a move on the first check after restart", async () => {
|
|
53
|
+
const { poller, advanced } = makePoller(refs("base1", "sha2"));
|
|
54
|
+
|
|
55
|
+
poller.trackPr("s1", "https://github.com/org/repo/pull/1", refs("base1", "sha1"));
|
|
56
|
+
await poller.refreshHead("s1");
|
|
57
|
+
|
|
58
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
45
59
|
poller.close();
|
|
46
60
|
});
|
|
47
61
|
|
|
48
62
|
test("a failed head read never fires and keeps the last baseline", async () => {
|
|
49
|
-
const { poller, advanced,
|
|
63
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
50
64
|
|
|
51
65
|
poller.trackPr("s1", "org/repo#1");
|
|
52
66
|
await poller.refreshHead("s1");
|
|
53
67
|
|
|
54
|
-
|
|
68
|
+
setRefs(null);
|
|
55
69
|
await poller.refreshHead("s1");
|
|
56
|
-
|
|
70
|
+
setRefs(refs("base1", "sha2"));
|
|
57
71
|
await poller.refreshHead("s1");
|
|
58
72
|
|
|
59
|
-
expect(advanced).toEqual(["s1"]);
|
|
73
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
|
|
60
74
|
poller.close();
|
|
61
75
|
});
|
|
62
76
|
|
|
63
77
|
test("untrack stops further checks", async () => {
|
|
64
|
-
const { poller, advanced,
|
|
78
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
65
79
|
|
|
66
80
|
poller.trackPr("s1", "org/repo#1");
|
|
67
81
|
await poller.refreshHead("s1");
|
|
68
82
|
poller.untrackPr("s1");
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
setRefs(refs("base1", "sha2"));
|
|
71
85
|
await poller.refreshHead("s1");
|
|
72
86
|
|
|
73
87
|
expect(advanced).toEqual([]);
|
|
74
88
|
poller.close();
|
|
75
89
|
});
|
|
90
|
+
|
|
91
|
+
test("a moved base fires while the head stays unchanged", async () => {
|
|
92
|
+
const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
|
|
93
|
+
|
|
94
|
+
poller.trackPr("s1", "org/repo#1");
|
|
95
|
+
await poller.refreshHead("s1");
|
|
96
|
+
setRefs(refs("base2", "sha1"));
|
|
97
|
+
await poller.refreshHead("s1");
|
|
98
|
+
|
|
99
|
+
expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base2", "sha1") }]);
|
|
100
|
+
poller.close();
|
|
101
|
+
});
|
|
76
102
|
});
|
package/src/pr-poller.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Polls each open PR review for new commits
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* full diff when the head actually moved. Best-effort: a missing or failing gh
|
|
6
|
-
* leaves the last known head in place and never throws.
|
|
2
|
+
* Polls each open PR review for new commits. A remote PR has no push signal, so
|
|
3
|
+
* this checks its base/head pair on an interval and only exposes refresh when
|
|
4
|
+
* either side moved. Best-effort: a missing or failing gh keeps the last refs.
|
|
7
5
|
*/
|
|
8
6
|
|
|
9
|
-
import {
|
|
7
|
+
import { prRefs, type PullRequestRefs } from "./gh";
|
|
10
8
|
|
|
11
|
-
/** One cheap
|
|
9
|
+
/** One cheap refs check per interval; the expensive full-diff pull happens only on a move. */
|
|
12
10
|
const PR_POLL_INTERVAL_MS = 30_000;
|
|
13
11
|
|
|
14
12
|
interface PrPoll {
|
|
15
13
|
pr: string;
|
|
16
|
-
/** Last
|
|
17
|
-
|
|
14
|
+
/** Last refs seen; null until the baseline check lands, so the first observation never fires. */
|
|
15
|
+
lastRefs: PullRequestRefs | null;
|
|
18
16
|
timer: ReturnType<typeof setInterval>;
|
|
19
17
|
}
|
|
20
18
|
|
|
@@ -22,41 +20,42 @@ export class PrReviewPoller {
|
|
|
22
20
|
private readonly polls = new Map<string, PrPoll>();
|
|
23
21
|
|
|
24
22
|
constructor(
|
|
25
|
-
private readonly onPrAdvance: (sessionId: string) => void,
|
|
26
|
-
private readonly
|
|
23
|
+
private readonly onPrAdvance: (sessionId: string, refs: PullRequestRefs) => void,
|
|
24
|
+
private readonly refs: (pr: string) => Promise<PullRequestRefs | null> = prRefs,
|
|
27
25
|
private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
|
|
28
26
|
) {}
|
|
29
27
|
|
|
30
|
-
/** Start polling a PR review. Idempotent per session; the
|
|
31
|
-
trackPr(sessionId: string, pr: string): void {
|
|
28
|
+
/** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
|
|
29
|
+
trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
|
|
32
30
|
if (this.polls.has(sessionId)) return;
|
|
33
31
|
const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
|
|
34
32
|
// the poll timer must not by itself keep the daemon alive against idle-exit
|
|
35
33
|
timer.unref?.();
|
|
36
|
-
this.polls.set(sessionId, { pr,
|
|
34
|
+
this.polls.set(sessionId, { pr, lastRefs: reviewedRefs, timer });
|
|
37
35
|
void this.refreshHead(sessionId);
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
/** Check the PR
|
|
38
|
+
/** Check the PR refs once; fire onPrAdvance only when they moved off a known baseline. */
|
|
41
39
|
async refreshHead(sessionId: string): Promise<void> {
|
|
42
40
|
const poll = this.polls.get(sessionId);
|
|
43
41
|
|
|
44
42
|
if (!poll) return;
|
|
45
|
-
const
|
|
43
|
+
const refs = await this.refs(poll.pr).catch(() => null);
|
|
46
44
|
|
|
47
|
-
if (
|
|
45
|
+
if (refs === null) return;
|
|
48
46
|
// the await yields; re-read so an untrack during the call is honoured
|
|
49
47
|
const current = this.polls.get(sessionId);
|
|
50
48
|
|
|
51
49
|
if (!current) return;
|
|
52
|
-
if (current.
|
|
53
|
-
current.
|
|
50
|
+
if (current.lastRefs === null) {
|
|
51
|
+
current.lastRefs = refs;
|
|
54
52
|
|
|
55
53
|
return;
|
|
56
54
|
}
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
|
|
56
|
+
return;
|
|
57
|
+
current.lastRefs = refs;
|
|
58
|
+
this.onPrAdvance(sessionId, refs);
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
untrackPr(sessionId: string): void {
|
package/src/thread-review.ts
CHANGED
|
@@ -78,6 +78,10 @@ export interface OpenReviewOptions {
|
|
|
78
78
|
planPath?: string;
|
|
79
79
|
prototypePath?: string;
|
|
80
80
|
pr?: string;
|
|
81
|
+
prBrief?: string;
|
|
82
|
+
prBaseSha?: string;
|
|
83
|
+
prHeadSha?: string;
|
|
84
|
+
prUrl?: string;
|
|
81
85
|
herdrPane?: string;
|
|
82
86
|
/**
|
|
83
87
|
* Full file contents per changed file for a working-tree diff, carried onto
|
|
@@ -297,6 +301,10 @@ export async function openReview(
|
|
|
297
301
|
planPath: options.planPath,
|
|
298
302
|
prototypePath: options.prototypePath,
|
|
299
303
|
pr: options.pr,
|
|
304
|
+
prBrief: options.prBrief,
|
|
305
|
+
prBaseSha: options.prBaseSha,
|
|
306
|
+
prHeadSha: options.prHeadSha,
|
|
307
|
+
prUrl: options.prUrl,
|
|
300
308
|
herdrPane: options.herdrPane,
|
|
301
309
|
title:
|
|
302
310
|
options.title ??
|
package/src/validate.test.ts
CHANGED
|
@@ -210,6 +210,12 @@ describe("wire pins", () => {
|
|
|
210
210
|
planPath: "/repo/plan.md",
|
|
211
211
|
prototypePath: "/repo/proto.html",
|
|
212
212
|
pr: "org/repo#1",
|
|
213
|
+
prBrief: "# PR",
|
|
214
|
+
prBaseSha: "base",
|
|
215
|
+
prHeadSha: "head",
|
|
216
|
+
prRefreshBaseSha: "next-base",
|
|
217
|
+
prRefreshHeadSha: "next",
|
|
218
|
+
prUrl: "https://github.com/org/repo/pull/1",
|
|
213
219
|
herdrPane: "%7",
|
|
214
220
|
title: "Plan",
|
|
215
221
|
workbench: false,
|
|
@@ -233,6 +239,17 @@ describe("wire pins", () => {
|
|
|
233
239
|
body: "b",
|
|
234
240
|
orphan: false,
|
|
235
241
|
author: "SHA256:abc",
|
|
242
|
+
reviewComment: {
|
|
243
|
+
severity: "p1",
|
|
244
|
+
title: "Finding",
|
|
245
|
+
path: "src/x.ts",
|
|
246
|
+
line: 1,
|
|
247
|
+
startLine: 1,
|
|
248
|
+
startAnchor: fullAnchor,
|
|
249
|
+
side: "RIGHT",
|
|
250
|
+
suggestion: "replacement",
|
|
251
|
+
prompt: "fix it",
|
|
252
|
+
},
|
|
236
253
|
replyTo: "a0",
|
|
237
254
|
resolution: { revision: 2, source: "agent" },
|
|
238
255
|
createdAt: "now",
|
package/src/validate.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
type Delivery,
|
|
30
30
|
type PendingDelivery,
|
|
31
31
|
type Revision,
|
|
32
|
+
REVIEW_SEVERITIES,
|
|
33
|
+
type ReviewComment,
|
|
32
34
|
type SessionHistory,
|
|
33
35
|
type ShareAccess,
|
|
34
36
|
type ShareLink,
|
|
@@ -85,6 +87,12 @@ export const ArtifactMetaSchema = v.object({
|
|
|
85
87
|
planPath: v.optional(v.string()),
|
|
86
88
|
prototypePath: v.optional(v.string()),
|
|
87
89
|
pr: v.optional(v.string()),
|
|
90
|
+
prBrief: v.optional(v.string()),
|
|
91
|
+
prBaseSha: v.optional(NonEmpty),
|
|
92
|
+
prHeadSha: v.optional(NonEmpty),
|
|
93
|
+
prRefreshBaseSha: v.optional(NonEmpty),
|
|
94
|
+
prRefreshHeadSha: v.optional(NonEmpty),
|
|
95
|
+
prUrl: v.optional(NonEmpty),
|
|
88
96
|
herdrPane: v.optional(v.string()),
|
|
89
97
|
title: v.optional(v.string()),
|
|
90
98
|
workbench: v.optional(v.boolean()),
|
|
@@ -135,6 +143,19 @@ export const AnnotationSchema = v.object({
|
|
|
135
143
|
body: v.string(),
|
|
136
144
|
orphan: v.optional(v.boolean()),
|
|
137
145
|
author: v.optional(v.string()),
|
|
146
|
+
reviewComment: v.optional(
|
|
147
|
+
v.object({
|
|
148
|
+
severity: v.picklist(REVIEW_SEVERITIES),
|
|
149
|
+
title: NonEmpty,
|
|
150
|
+
path: NonEmpty,
|
|
151
|
+
line: v.pipe(v.number(), v.integer(), v.minValue(1)),
|
|
152
|
+
startLine: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
|
|
153
|
+
startAnchor: v.optional(AnchorSchema),
|
|
154
|
+
side: v.picklist(["LEFT", "RIGHT"]),
|
|
155
|
+
suggestion: v.optional(v.string()),
|
|
156
|
+
prompt: v.optional(v.string()),
|
|
157
|
+
} satisfies EntriesOf<ReviewComment>),
|
|
158
|
+
),
|
|
138
159
|
replyTo: v.optional(NonEmpty),
|
|
139
160
|
resolution: v.optional(
|
|
140
161
|
v.object({
|