@valescoagency/runway 0.14.2 → 0.15.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 +8 -1
- package/dist/config.js +22 -0
- package/dist/dashboard/projector.js +90 -0
- package/dist/dashboard/server.js +51 -2
- package/dist/dashboard/storage.js +129 -0
- package/dist/dashboard/views.js +77 -1
- package/dist/github.js +350 -0
- package/dist/orchestrator.js +44 -0
- package/dist/prompts.js +99 -0
- package/dist/shepherd.js +707 -0
- package/package.json +1 -1
- package/prompts/shepherd-ci-fix.md +47 -0
- package/prompts/shepherd-rebase.md +40 -0
- package/prompts/shepherd-review-fix.md +40 -0
- package/prompts/shepherd-review-respond.md +31 -0
package/dist/shepherd.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { claudeCode } from "@ai-hero/sandcastle";
|
|
2
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
3
|
+
import { Duration, Effect } from "effect";
|
|
4
|
+
import { renderShepherdCiFixPrompt, renderShepherdRebasePrompt, renderShepherdReviewFixPrompt, renderShepherdReviewRespondPrompt, } from "./prompts.js";
|
|
5
|
+
import { dockerEnv, runSandcastle, stringifyResult, } from "./sandcastle.js";
|
|
6
|
+
/**
|
|
7
|
+
* VA-460: "PR has been calm long enough — presume ready for human
|
|
8
|
+
* merge" default. Not a budget; just the natural-exit threshold for
|
|
9
|
+
* the loop when nothing is happening. Override via
|
|
10
|
+
* `ShepherdDeps.maxQuietPolls` for tests.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_MAX_QUIET_POLLS = 5;
|
|
13
|
+
const DEFAULT_NO_OP_MERGE_CHECK = () => Effect.succeed(false);
|
|
14
|
+
/**
|
|
15
|
+
* VA-460: named no-op subscription stubs. VA-461..463 each replace
|
|
16
|
+
* one of these with a real handler factory.
|
|
17
|
+
*
|
|
18
|
+
* A. mergeabilityStub — replaced by `makeMergeabilityWatcher` (VA-461)
|
|
19
|
+
* B. ciStub — replaced by VA-462 (check-runs watcher)
|
|
20
|
+
* C. reviewerStub — replaced by VA-463 (PR comment watcher)
|
|
21
|
+
*/
|
|
22
|
+
export const mergeabilityStub = () => Effect.succeed({ kind: "quiet" });
|
|
23
|
+
export const ciStub = () => Effect.succeed({ kind: "quiet" });
|
|
24
|
+
export const reviewerStub = () => Effect.succeed({ kind: "quiet" });
|
|
25
|
+
export const DEFAULT_SUBSCRIPTIONS = [
|
|
26
|
+
mergeabilityStub,
|
|
27
|
+
ciStub,
|
|
28
|
+
reviewerStub,
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* VA-461: real merge check, wired to the github gateway's
|
|
32
|
+
* `getPullRequest`. Drop-in replacement for the scaffolding's
|
|
33
|
+
* `DEFAULT_NO_OP_MERGE_CHECK`.
|
|
34
|
+
*/
|
|
35
|
+
export function makeIsMerged(github) {
|
|
36
|
+
return (ctx) => Effect.map(github.getPullRequest({ repoPath: ctx.cwd, prNumber: ctx.prNumber }), (pr) => pr.merged);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Production rebase-agent invocation: render the prompt, run one
|
|
40
|
+
* sandcastle iteration, parse the agent's `IMPL:` verdict. The
|
|
41
|
+
* shepherd's `fix` action wraps this so tests can stub the whole
|
|
42
|
+
* function via `MergeabilityWatcherDeps.invokeRebaseAgent`.
|
|
43
|
+
*/
|
|
44
|
+
export const defaultInvokeRebaseAgent = (args) => Effect.gen(function* () {
|
|
45
|
+
const prompt = yield* Effect.promise(() => renderShepherdRebasePrompt({
|
|
46
|
+
issue: args.issue,
|
|
47
|
+
branch: args.branch,
|
|
48
|
+
baseBranch: args.baseBranch,
|
|
49
|
+
mergeableState: args.mergeableState,
|
|
50
|
+
conflictFiles: args.conflictFiles,
|
|
51
|
+
}));
|
|
52
|
+
const result = yield* runSandcastle({
|
|
53
|
+
agent: claudeCode("claude-opus-4-7"),
|
|
54
|
+
sandbox: docker({ env: dockerEnv(args.config) }),
|
|
55
|
+
cwd: args.cwd,
|
|
56
|
+
prompt,
|
|
57
|
+
// VA-456: rebase agent operates ON the impl branch.
|
|
58
|
+
branchStrategy: { type: "branch", branch: args.branch },
|
|
59
|
+
maxIterations: 1,
|
|
60
|
+
name: `shepherd-rebase-${args.issue.identifier}`,
|
|
61
|
+
});
|
|
62
|
+
return parseRebaseVerdict(stringifyResult(result));
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* VA-461: parse the rebase agent's `IMPL: DONE` / `IMPL: BLOCKED`
|
|
66
|
+
* verdict. Mirrors the shape `parseImplVerdict` (src/implement.ts)
|
|
67
|
+
* uses for the main impl loop.
|
|
68
|
+
*/
|
|
69
|
+
export function parseRebaseVerdict(output) {
|
|
70
|
+
const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
|
|
71
|
+
if (blocked) {
|
|
72
|
+
const reason = (blocked[1] ?? "").trim();
|
|
73
|
+
const files = extractConflictFilesFromReason(reason);
|
|
74
|
+
return { kind: "blocked", reason, conflictFiles: files };
|
|
75
|
+
}
|
|
76
|
+
if (/^IMPL:\s*DONE\b/im.test(output)) {
|
|
77
|
+
return { kind: "done" };
|
|
78
|
+
}
|
|
79
|
+
// No marker at all is treated as BLOCKED with a synthetic reason —
|
|
80
|
+
// the loop should escalate rather than silently believe the rebase
|
|
81
|
+
// succeeded.
|
|
82
|
+
return {
|
|
83
|
+
kind: "blocked",
|
|
84
|
+
reason: "rebase agent produced no IMPL: marker",
|
|
85
|
+
conflictFiles: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Production CI-fix invocation: render the prompt, run one sandcastle
|
|
90
|
+
* iteration, parse the verdict. Tests stub via the watcher deps.
|
|
91
|
+
*/
|
|
92
|
+
export const defaultInvokeCiFixAgent = (args) => Effect.gen(function* () {
|
|
93
|
+
const prompt = yield* Effect.promise(() => renderShepherdCiFixPrompt({
|
|
94
|
+
issue: args.issue,
|
|
95
|
+
branch: args.branch,
|
|
96
|
+
checkName: args.checkName,
|
|
97
|
+
logTail: args.logTail,
|
|
98
|
+
}));
|
|
99
|
+
const result = yield* runSandcastle({
|
|
100
|
+
agent: claudeCode("claude-opus-4-7"),
|
|
101
|
+
sandbox: docker({ env: dockerEnv(args.config) }),
|
|
102
|
+
cwd: args.cwd,
|
|
103
|
+
prompt,
|
|
104
|
+
branchStrategy: { type: "branch", branch: args.branch },
|
|
105
|
+
maxIterations: 1,
|
|
106
|
+
name: `shepherd-ci-fix-${args.issue.identifier}-${sanitiseCheckName(args.checkName)}`,
|
|
107
|
+
});
|
|
108
|
+
return parseCiFixVerdict(stringifyResult(result));
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* Parse the CI-fix agent's verdict. Mirrors `parseRebaseVerdict` — same
|
|
112
|
+
* IMPL: DONE / IMPL: BLOCKED markers, same fallback to BLOCKED when
|
|
113
|
+
* no marker fires.
|
|
114
|
+
*/
|
|
115
|
+
export function parseCiFixVerdict(output) {
|
|
116
|
+
const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
|
|
117
|
+
if (blocked) {
|
|
118
|
+
return { kind: "blocked", reason: (blocked[1] ?? "").trim() };
|
|
119
|
+
}
|
|
120
|
+
if (/^IMPL:\s*DONE\b/im.test(output)) {
|
|
121
|
+
return { kind: "done" };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
kind: "blocked",
|
|
125
|
+
reason: "ci-fix agent produced no IMPL: marker",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* VA-462: stable hash for a log tail used as the de-dup key. Just a
|
|
130
|
+
* cheap DJB2 — collisions don't cause correctness issues here (worst
|
|
131
|
+
* case is one extra re-impl), and we want determinism without a crypto
|
|
132
|
+
* import.
|
|
133
|
+
*/
|
|
134
|
+
function hashLogTail(s) {
|
|
135
|
+
let hash = 5381;
|
|
136
|
+
for (let i = 0; i < s.length; i++) {
|
|
137
|
+
hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0;
|
|
138
|
+
}
|
|
139
|
+
return String(hash);
|
|
140
|
+
}
|
|
141
|
+
function sanitiseCheckName(name) {
|
|
142
|
+
return name.replace(/[^a-z0-9_-]+/gi, "-").slice(0, 40);
|
|
143
|
+
}
|
|
144
|
+
const DEFAULT_MAX_CONSECUTIVE_SAME_FAILURE = 3;
|
|
145
|
+
export function makeCiWatcher(deps) {
|
|
146
|
+
const invoke = deps.invokeCiFixAgent ?? defaultInvokeCiFixAgent;
|
|
147
|
+
const maxConsecutive = deps.maxConsecutiveSameFailure ?? DEFAULT_MAX_CONSECUTIVE_SAME_FAILURE;
|
|
148
|
+
// Per-PR-shepherd state. The closure lives for the duration of one
|
|
149
|
+
// runShepherdPass invocation; a new PR opens fresh history.
|
|
150
|
+
const failureHistory = new Map();
|
|
151
|
+
return (ctx) => Effect.gen(function* () {
|
|
152
|
+
const pr = yield* deps.github.getPullRequest({
|
|
153
|
+
repoPath: ctx.cwd,
|
|
154
|
+
prNumber: ctx.prNumber,
|
|
155
|
+
});
|
|
156
|
+
// Merged PRs short-circuit before we burn an API call on
|
|
157
|
+
// check-runs. The merge-check upstream is the canonical exit;
|
|
158
|
+
// this is defense-in-depth.
|
|
159
|
+
if (pr.merged)
|
|
160
|
+
return { kind: "quiet" };
|
|
161
|
+
const checkRuns = yield* deps.github.getCheckRuns({
|
|
162
|
+
repoPath: ctx.cwd,
|
|
163
|
+
sha: pr.headSha,
|
|
164
|
+
});
|
|
165
|
+
const failing = checkRuns.filter((c) => c.status === "completed" && c.conclusion === "failure");
|
|
166
|
+
if (failing.length === 0)
|
|
167
|
+
return { kind: "quiet" };
|
|
168
|
+
// Branch-protection lookup can return null (no admin / no
|
|
169
|
+
// protection rule); the watcher then treats every check as
|
|
170
|
+
// required to err on the side of acting on failures rather
|
|
171
|
+
// than swallowing them silently.
|
|
172
|
+
const required = yield* deps.github.getRequiredChecks({
|
|
173
|
+
repoPath: ctx.cwd,
|
|
174
|
+
baseBranch: ctx.baseBranch,
|
|
175
|
+
});
|
|
176
|
+
const requiredFailing = required === null
|
|
177
|
+
? failing
|
|
178
|
+
: failing.filter((c) => required.includes(c.name));
|
|
179
|
+
if (requiredFailing.length === 0) {
|
|
180
|
+
// Optional failures: surface in logs so the operator sees
|
|
181
|
+
// them but don't burn iteration budget. The AC pinpoints
|
|
182
|
+
// exactly this behavior.
|
|
183
|
+
const names = failing.map((c) => c.name).join(", ");
|
|
184
|
+
yield* Effect.logInfo(`${ctx.issue.identifier}: shepherd.ci.optional_failure pr=${pr.number} checks=${names} (no action)`);
|
|
185
|
+
return { kind: "quiet" };
|
|
186
|
+
}
|
|
187
|
+
// First required failure wins for this tick. The next iteration
|
|
188
|
+
// (after the fix lands) re-polls and picks up whatever's still
|
|
189
|
+
// broken.
|
|
190
|
+
const first = requiredFailing[0];
|
|
191
|
+
const logTail = first.runId !== null
|
|
192
|
+
? yield* deps.github.getCheckRunLogTail({
|
|
193
|
+
repoPath: ctx.cwd,
|
|
194
|
+
runId: first.runId,
|
|
195
|
+
lines: 50,
|
|
196
|
+
})
|
|
197
|
+
: "";
|
|
198
|
+
const errorHash = hashLogTail(logTail);
|
|
199
|
+
const prior = failureHistory.get(first.name);
|
|
200
|
+
// Rule 1: identical failure on the same SHA → quiet (already acted).
|
|
201
|
+
if (prior &&
|
|
202
|
+
prior.errorHash === errorHash &&
|
|
203
|
+
prior.sha === pr.headSha) {
|
|
204
|
+
return { kind: "quiet" };
|
|
205
|
+
}
|
|
206
|
+
// Rule 2: identical failure on a NEW SHA → escalate after the
|
|
207
|
+
// configured threshold.
|
|
208
|
+
if (prior && prior.errorHash === errorHash) {
|
|
209
|
+
const newCount = prior.count + 1;
|
|
210
|
+
failureHistory.set(first.name, {
|
|
211
|
+
errorHash,
|
|
212
|
+
count: newCount,
|
|
213
|
+
sha: pr.headSha,
|
|
214
|
+
});
|
|
215
|
+
if (newCount >= maxConsecutive) {
|
|
216
|
+
const lastLine = lastNonEmptyLine(logTail);
|
|
217
|
+
return {
|
|
218
|
+
kind: "hitl",
|
|
219
|
+
reason: `Repeated CI failure in \`${first.name}\`: ${lastLine}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// else: fall through to fix
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Rule 3: fresh failure window.
|
|
226
|
+
failureHistory.set(first.name, {
|
|
227
|
+
errorHash,
|
|
228
|
+
count: 1,
|
|
229
|
+
sha: pr.headSha,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const checkName = first.name;
|
|
233
|
+
return {
|
|
234
|
+
kind: "fix",
|
|
235
|
+
description: `Re-impl after CI failure in \`${checkName}\``,
|
|
236
|
+
action: Effect.gen(function* () {
|
|
237
|
+
const result = yield* invoke({
|
|
238
|
+
issue: ctx.issue,
|
|
239
|
+
branch: ctx.branch,
|
|
240
|
+
checkName,
|
|
241
|
+
logTail,
|
|
242
|
+
cwd: ctx.cwd,
|
|
243
|
+
config: deps.config,
|
|
244
|
+
});
|
|
245
|
+
if (result.kind === "blocked") {
|
|
246
|
+
return {
|
|
247
|
+
kind: "hitl",
|
|
248
|
+
reason: `CI fix BLOCKED in \`${checkName}\`: ${result.reason}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
kind: "fixed",
|
|
253
|
+
detail: `Re-impl after \`${checkName}\` failure`,
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function lastNonEmptyLine(s) {
|
|
260
|
+
const lines = s.split("\n");
|
|
261
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
262
|
+
const line = lines[i].trim();
|
|
263
|
+
if (line.length > 0)
|
|
264
|
+
return line;
|
|
265
|
+
}
|
|
266
|
+
return "(empty log)";
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* VA-463: parse a PR-reviewer-bot comment for its `REVIEW: ...` line.
|
|
270
|
+
* Returns `{ kind: "none" }` when the body has no marker (e.g., a
|
|
271
|
+
* status update comment from the bot before the verdict). The regex
|
|
272
|
+
* matches the LAST occurrence — the prompt structure has the verdict
|
|
273
|
+
* line at the end of the comment body.
|
|
274
|
+
*/
|
|
275
|
+
export function parsePrReviewVerdict(body) {
|
|
276
|
+
const matches = Array.from(body.matchAll(/^REVIEW:\s*(APPROVED|CHANGES-REQUESTED|NEEDS-DISCUSSION)(?:\s*[—-]\s*(.*))?$/gim));
|
|
277
|
+
if (matches.length === 0)
|
|
278
|
+
return { kind: "none" };
|
|
279
|
+
const last = matches[matches.length - 1];
|
|
280
|
+
const kind = last[1].toUpperCase();
|
|
281
|
+
const detail = (last[2] ?? "").trim();
|
|
282
|
+
if (kind === "APPROVED")
|
|
283
|
+
return { kind: "approved" };
|
|
284
|
+
if (kind === "CHANGES-REQUESTED") {
|
|
285
|
+
return { kind: "changes-requested", fix: detail };
|
|
286
|
+
}
|
|
287
|
+
return { kind: "needs-discussion", decision: detail };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Production reviewer-response invocation: pick the right template
|
|
291
|
+
* (parsed CHANGES-REQUESTED fix vs. verbatim human comment), run one
|
|
292
|
+
* sandcastle iteration, parse the IMPL: verdict.
|
|
293
|
+
*/
|
|
294
|
+
export const defaultInvokeReviewerResponseAgent = (args) => Effect.gen(function* () {
|
|
295
|
+
const prompt = yield* Effect.promise(() => args.mode.kind === "fix-request"
|
|
296
|
+
? renderShepherdReviewFixPrompt({
|
|
297
|
+
issue: args.issue,
|
|
298
|
+
branch: args.branch,
|
|
299
|
+
fixRequest: args.mode.fix,
|
|
300
|
+
})
|
|
301
|
+
: renderShepherdReviewRespondPrompt({
|
|
302
|
+
issue: args.issue,
|
|
303
|
+
branch: args.branch,
|
|
304
|
+
commentAuthor: args.mode.commentAuthor,
|
|
305
|
+
commentSource: args.mode.commentSource,
|
|
306
|
+
commentBody: args.mode.commentBody,
|
|
307
|
+
}));
|
|
308
|
+
const result = yield* runSandcastle({
|
|
309
|
+
agent: claudeCode("claude-opus-4-7"),
|
|
310
|
+
sandbox: docker({ env: dockerEnv(args.config) }),
|
|
311
|
+
cwd: args.cwd,
|
|
312
|
+
prompt,
|
|
313
|
+
branchStrategy: { type: "branch", branch: args.branch },
|
|
314
|
+
maxIterations: 1,
|
|
315
|
+
name: `shepherd-review-${args.issue.identifier}-${args.mode.kind}`,
|
|
316
|
+
});
|
|
317
|
+
return parseReviewerResponseVerdict(stringifyResult(result));
|
|
318
|
+
});
|
|
319
|
+
export function parseReviewerResponseVerdict(output) {
|
|
320
|
+
const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
|
|
321
|
+
if (blocked) {
|
|
322
|
+
return { kind: "blocked", reason: (blocked[1] ?? "").trim() };
|
|
323
|
+
}
|
|
324
|
+
if (/^IMPL:\s*DONE\b/im.test(output)) {
|
|
325
|
+
return { kind: "done" };
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
kind: "blocked",
|
|
329
|
+
reason: "reviewer-response agent produced no IMPL: marker",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* VA-463: subscription C — reviewer-comment responder.
|
|
334
|
+
*
|
|
335
|
+
* Polls PR comments (top-level + review thread + review summaries) on
|
|
336
|
+
* every shepherd tick. Partitions by author:
|
|
337
|
+
*
|
|
338
|
+
* * Adversarial reviewer bot (`config.prReviewerBotLogin`): parse
|
|
339
|
+
* for `REVIEW:` marker. APPROVED → quiet. CHANGES-REQUESTED →
|
|
340
|
+
* fix-request prompt with the parsed fix line. NEEDS-DISCUSSION
|
|
341
|
+
* → immediate hitl with the canonical reason verbatim.
|
|
342
|
+
* * Anyone else (human): pass the comment body verbatim to the
|
|
343
|
+
* respond prompt; agent decides actionable vs. not. BLOCKED →
|
|
344
|
+
* hitl with the agent's reason.
|
|
345
|
+
*
|
|
346
|
+
* Dedup: per-shepherd-pass Set of responded comment IDs. The first
|
|
347
|
+
* unhandled comment each tick wins; subsequent comments wait for the
|
|
348
|
+
* next iteration. The next pass picks up whatever's still unhandled.
|
|
349
|
+
*
|
|
350
|
+
* Initial snapshot: the first poll records every existing comment ID
|
|
351
|
+
* as already-seen WITHOUT acting on them — only comments posted AFTER
|
|
352
|
+
* the shepherd loop started should trigger work. The PR was just
|
|
353
|
+
* opened; pre-existing comments are noise from prior runs.
|
|
354
|
+
*/
|
|
355
|
+
export function makeReviewerWatcher(deps) {
|
|
356
|
+
const invoke = deps.invokeReviewerResponseAgent ?? defaultInvokeReviewerResponseAgent;
|
|
357
|
+
const respondedTo = new Set();
|
|
358
|
+
let primed = false;
|
|
359
|
+
return (ctx) => Effect.gen(function* () {
|
|
360
|
+
const pr = yield* deps.github.getPullRequest({
|
|
361
|
+
repoPath: ctx.cwd,
|
|
362
|
+
prNumber: ctx.prNumber,
|
|
363
|
+
});
|
|
364
|
+
if (pr.merged)
|
|
365
|
+
return { kind: "quiet" };
|
|
366
|
+
const comments = yield* deps.github.getPullRequestComments({
|
|
367
|
+
repoPath: ctx.cwd,
|
|
368
|
+
prNumber: ctx.prNumber,
|
|
369
|
+
});
|
|
370
|
+
// Initial-snapshot priming: on the first poll, snapshot every
|
|
371
|
+
// existing comment ID into the responded set so we only act on
|
|
372
|
+
// NEW comments posted during this shepherd loop's lifetime.
|
|
373
|
+
// Pre-existing comments (from prior runway runs, manual reviews,
|
|
374
|
+
// etc.) are noise.
|
|
375
|
+
if (!primed) {
|
|
376
|
+
for (const c of comments)
|
|
377
|
+
respondedTo.add(c.id);
|
|
378
|
+
primed = true;
|
|
379
|
+
return { kind: "quiet" };
|
|
380
|
+
}
|
|
381
|
+
// First unhandled comment wins. Sorted ascending by createdAt
|
|
382
|
+
// already (gateway contract), so this picks the oldest unseen
|
|
383
|
+
// one — gives human reviewers a chance to be answered in order.
|
|
384
|
+
const next = comments.find((c) => !respondedTo.has(c.id));
|
|
385
|
+
if (!next)
|
|
386
|
+
return { kind: "quiet" };
|
|
387
|
+
// Mark before processing so a poll mid-action doesn't double-fire.
|
|
388
|
+
respondedTo.add(next.id);
|
|
389
|
+
const botLogin = deps.config.prReviewerBotLogin?.trim();
|
|
390
|
+
const isBot = Boolean(botLogin) && next.authorLogin === botLogin;
|
|
391
|
+
if (isBot) {
|
|
392
|
+
const verdict = parsePrReviewVerdict(next.body);
|
|
393
|
+
if (verdict.kind === "approved" || verdict.kind === "none") {
|
|
394
|
+
// APPROVED is a positive signal — the shepherd keeps
|
|
395
|
+
// watching CI/mergeability but doesn't fire. NONE is the
|
|
396
|
+
// bot posting a status update without a verdict; same
|
|
397
|
+
// treatment.
|
|
398
|
+
return { kind: "quiet" };
|
|
399
|
+
}
|
|
400
|
+
if (verdict.kind === "needs-discussion") {
|
|
401
|
+
return {
|
|
402
|
+
kind: "hitl",
|
|
403
|
+
reason: `Reviewer flagged needs-discussion: ${verdict.decision || "(no detail provided)"}`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// CHANGES-REQUESTED → fix-request prompt path.
|
|
407
|
+
return buildFixSignal(ctx, deps, invoke, {
|
|
408
|
+
kind: "fix-request",
|
|
409
|
+
fix: verdict.fix,
|
|
410
|
+
}, next);
|
|
411
|
+
}
|
|
412
|
+
// Human (non-bot) comment path. Pass the body verbatim — the
|
|
413
|
+
// agent decides whether it's actionable.
|
|
414
|
+
return buildFixSignal(ctx, deps, invoke, {
|
|
415
|
+
kind: "verbatim",
|
|
416
|
+
commentAuthor: next.authorLogin,
|
|
417
|
+
commentSource: next.source,
|
|
418
|
+
commentBody: next.body,
|
|
419
|
+
}, next);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function buildFixSignal(ctx, deps, invoke, mode, comment) {
|
|
423
|
+
const summary = mode.kind === "fix-request"
|
|
424
|
+
? `CHANGES-REQUESTED — ${truncate(mode.fix, 60)}`
|
|
425
|
+
: `${comment.authorLogin || "unknown"} (${comment.source})`;
|
|
426
|
+
return {
|
|
427
|
+
kind: "fix",
|
|
428
|
+
description: `Respond to PR comment: ${summary}`,
|
|
429
|
+
action: Effect.gen(function* () {
|
|
430
|
+
const result = yield* invoke({
|
|
431
|
+
issue: ctx.issue,
|
|
432
|
+
branch: ctx.branch,
|
|
433
|
+
cwd: ctx.cwd,
|
|
434
|
+
config: deps.config,
|
|
435
|
+
mode,
|
|
436
|
+
});
|
|
437
|
+
if (result.kind === "blocked") {
|
|
438
|
+
return {
|
|
439
|
+
kind: "hitl",
|
|
440
|
+
reason: mode.kind === "fix-request"
|
|
441
|
+
? `Reviewer CHANGES-REQUESTED BLOCKED: ${result.reason}`
|
|
442
|
+
: `Reviewer comment BLOCKED (${comment.authorLogin || "unknown"}): ${result.reason}`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
kind: "fixed",
|
|
447
|
+
detail: mode.kind === "fix-request"
|
|
448
|
+
? `Addressed reviewer fix-request`
|
|
449
|
+
: `Responded to ${comment.authorLogin || "reviewer"} comment`,
|
|
450
|
+
};
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function truncate(s, n) {
|
|
455
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
456
|
+
if (flat.length <= n)
|
|
457
|
+
return flat;
|
|
458
|
+
return `${flat.slice(0, n - 1)}…`;
|
|
459
|
+
}
|
|
460
|
+
function extractConflictFilesFromReason(reason) {
|
|
461
|
+
// Format we instruct the agent to produce: "Mergeability: conflict
|
|
462
|
+
// in <file1>, <file2> required human resolution". Anything else is
|
|
463
|
+
// parsed loosely — we accept comma-separated lists after "in" /
|
|
464
|
+
// "conflicts in" / "files".
|
|
465
|
+
// File paths legitimately contain `.` (extensions), so the inner
|
|
466
|
+
// character class only excludes `;` and newlines. The terminator is
|
|
467
|
+
// either " required <word>" or end-of-string.
|
|
468
|
+
const m = reason.match(/(?:conflict|conflicts|files)\s+(?:in\s+)?([^;\n]+?)(?:\s+required\s|\s*$)/i);
|
|
469
|
+
if (!m)
|
|
470
|
+
return [];
|
|
471
|
+
return m[1]
|
|
472
|
+
.split(",")
|
|
473
|
+
.map((s) => s.trim())
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
}
|
|
476
|
+
export function makeMergeabilityWatcher(deps) {
|
|
477
|
+
const invoke = deps.invokeRebaseAgent ?? defaultInvokeRebaseAgent;
|
|
478
|
+
return (ctx) => Effect.gen(function* () {
|
|
479
|
+
const pr = yield* deps.github.getPullRequest({
|
|
480
|
+
repoPath: ctx.cwd,
|
|
481
|
+
prNumber: ctx.prNumber,
|
|
482
|
+
});
|
|
483
|
+
// `merged` is handled by the merge-check upstream of subscription
|
|
484
|
+
// polls; defensive return here keeps the watcher idempotent if
|
|
485
|
+
// its merge-check sibling races slow.
|
|
486
|
+
if (pr.merged)
|
|
487
|
+
return { kind: "quiet" };
|
|
488
|
+
// Only `dirty` and `behind` warrant action. `unknown` happens
|
|
489
|
+
// briefly after a push while GH computes — treat as quiet so
|
|
490
|
+
// the next tick re-evaluates against settled state.
|
|
491
|
+
if (pr.mergeableState !== "dirty" && pr.mergeableState !== "behind") {
|
|
492
|
+
return { kind: "quiet" };
|
|
493
|
+
}
|
|
494
|
+
// Pre-detect conflict files locally. `behind` typically returns
|
|
495
|
+
// an empty list (clean fast-forward); `dirty` returns the paths
|
|
496
|
+
// the agent will need to resolve.
|
|
497
|
+
const conflictFiles = yield* deps.github.getConflictFiles({
|
|
498
|
+
repoPath: ctx.cwd,
|
|
499
|
+
baseBranch: ctx.baseBranch,
|
|
500
|
+
branch: ctx.branch,
|
|
501
|
+
});
|
|
502
|
+
const mergeableState = pr.mergeableState;
|
|
503
|
+
return {
|
|
504
|
+
kind: "fix",
|
|
505
|
+
description: `Rebase ${ctx.branch} onto origin/${ctx.baseBranch} (mergeable_state=${mergeableState})`,
|
|
506
|
+
action: Effect.gen(function* () {
|
|
507
|
+
const result = yield* invoke({
|
|
508
|
+
issue: ctx.issue,
|
|
509
|
+
branch: ctx.branch,
|
|
510
|
+
baseBranch: ctx.baseBranch,
|
|
511
|
+
mergeableState,
|
|
512
|
+
conflictFiles,
|
|
513
|
+
cwd: ctx.cwd,
|
|
514
|
+
config: deps.config,
|
|
515
|
+
});
|
|
516
|
+
if (result.kind === "blocked") {
|
|
517
|
+
const files = result.conflictFiles.length > 0
|
|
518
|
+
? result.conflictFiles
|
|
519
|
+
: conflictFiles;
|
|
520
|
+
const fileList = files.length > 0 ? files.join(", ") : "(unknown)";
|
|
521
|
+
return {
|
|
522
|
+
kind: "hitl",
|
|
523
|
+
reason: `Mergeability: conflict in ${fileList} required human resolution`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
kind: "fixed",
|
|
528
|
+
detail: `Rebased ${ctx.branch} onto origin/${ctx.baseBranch}`,
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Parse the PR number from a GitHub PR URL
|
|
536
|
+
* (`https://github.com/<owner>/<repo>/pull/<n>`). Throws when the URL
|
|
537
|
+
* doesn't match — finalize only hands us GitHub PR URLs, so a parse
|
|
538
|
+
* failure is a precondition violation, not a runtime branch worth
|
|
539
|
+
* handling gracefully.
|
|
540
|
+
*/
|
|
541
|
+
export function parsePrNumber(prUrl) {
|
|
542
|
+
const m = prUrl.match(/\/pull\/(\d+)(?:[/?#]|$)/);
|
|
543
|
+
if (!m) {
|
|
544
|
+
throw new Error(`shepherd: cannot parse PR number from ${prUrl}`);
|
|
545
|
+
}
|
|
546
|
+
return Number.parseInt(m[1], 10);
|
|
547
|
+
}
|
|
548
|
+
export const runShepherdPass = (issue, deps, branch, prUrl) => Effect.gen(function* () {
|
|
549
|
+
const prNumber = parsePrNumber(prUrl);
|
|
550
|
+
const isMerged = deps.isMerged ?? DEFAULT_NO_OP_MERGE_CHECK;
|
|
551
|
+
const subscriptions = deps.subscriptions ?? DEFAULT_SUBSCRIPTIONS;
|
|
552
|
+
const maxQuietPolls = deps.maxQuietPolls ?? DEFAULT_MAX_QUIET_POLLS;
|
|
553
|
+
const maxFixAttempts = deps.maxFixAttempts ?? deps.config.shepherdMaxIterations;
|
|
554
|
+
const maxWallSeconds = deps.maxWallSeconds ?? deps.config.shepherdMaxWallSeconds;
|
|
555
|
+
const pollSeconds = deps.pollIntervalSeconds ?? deps.config.shepherdPollIntervalSeconds;
|
|
556
|
+
const startMs = Date.now();
|
|
557
|
+
let fixCount = 0;
|
|
558
|
+
let quietStreak = 0;
|
|
559
|
+
let lastAction = "(none)";
|
|
560
|
+
let pollIndex = 0;
|
|
561
|
+
// VA-465: emit the start marker with structured attributes the
|
|
562
|
+
// dashboard's `extractShepherdMarkers` reads. The body is the
|
|
563
|
+
// routing key; attributes carry context.
|
|
564
|
+
yield* Effect.logInfo("shepherd.start").pipe(Effect.annotateLogs({
|
|
565
|
+
pr: String(prNumber),
|
|
566
|
+
branch,
|
|
567
|
+
issue: issue.identifier,
|
|
568
|
+
maxFixAttempts: String(maxFixAttempts),
|
|
569
|
+
maxWallSeconds: String(maxWallSeconds),
|
|
570
|
+
}));
|
|
571
|
+
while (true) {
|
|
572
|
+
pollIndex++;
|
|
573
|
+
// VA-464: wall-clock budget — checked at the top of every poll
|
|
574
|
+
// so a stuck subscription action that takes minutes can still
|
|
575
|
+
// be capped by it. Fires before merge / subscription work to
|
|
576
|
+
// avoid spending one more API call on an already-doomed loop.
|
|
577
|
+
const elapsedMs = Date.now() - startMs;
|
|
578
|
+
if (elapsedMs >= maxWallSeconds * 1000) {
|
|
579
|
+
const reason = `Shepherd wall-clock budget exhausted (${maxWallSeconds}s) — last action: ${lastAction}`;
|
|
580
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
581
|
+
pr: String(prNumber),
|
|
582
|
+
outcome: "hitl",
|
|
583
|
+
reason,
|
|
584
|
+
}));
|
|
585
|
+
return { kind: "hitl", reason };
|
|
586
|
+
}
|
|
587
|
+
const ctx = {
|
|
588
|
+
issue,
|
|
589
|
+
branch,
|
|
590
|
+
prUrl,
|
|
591
|
+
prNumber,
|
|
592
|
+
cwd: deps.cwd,
|
|
593
|
+
baseBranch: deps.baseBranch,
|
|
594
|
+
config: deps.config,
|
|
595
|
+
github: deps.github,
|
|
596
|
+
iteration: pollIndex,
|
|
597
|
+
};
|
|
598
|
+
const merged = yield* isMerged(ctx);
|
|
599
|
+
if (merged) {
|
|
600
|
+
// VA-465: merged is the success terminal. Use the canonical
|
|
601
|
+
// `shepherd.ended` body so the projector dispatches via
|
|
602
|
+
// `markShepherdEnded` (outcome=merged).
|
|
603
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
604
|
+
pr: String(prNumber),
|
|
605
|
+
outcome: "merged",
|
|
606
|
+
}));
|
|
607
|
+
return { kind: "merged" };
|
|
608
|
+
}
|
|
609
|
+
// First non-quiet signal wins — A/B/C are checked in declaration
|
|
610
|
+
// order, so callers control precedence by ordering the array.
|
|
611
|
+
let firedFix = false;
|
|
612
|
+
let subscriptionName = "(unknown)";
|
|
613
|
+
for (let i = 0; i < subscriptions.length; i++) {
|
|
614
|
+
const sub = subscriptions[i];
|
|
615
|
+
const signal = yield* sub(ctx);
|
|
616
|
+
if (signal.kind === "hitl") {
|
|
617
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
618
|
+
pr: String(prNumber),
|
|
619
|
+
outcome: "hitl",
|
|
620
|
+
reason: signal.reason,
|
|
621
|
+
subscription: `subscription[${i}]`,
|
|
622
|
+
}));
|
|
623
|
+
return { kind: "hitl", reason: signal.reason };
|
|
624
|
+
}
|
|
625
|
+
if (signal.kind === "fix") {
|
|
626
|
+
// VA-464: each fix counts against `maxFixAttempts`. Check
|
|
627
|
+
// BEFORE running the action so the count and the exhaustion
|
|
628
|
+
// exit are deterministic. The Nth fix is the LAST one we
|
|
629
|
+
// run — the (N+1)th attempt becomes the budget-exhausted
|
|
630
|
+
// hitl.
|
|
631
|
+
subscriptionName = `subscription[${i}]`;
|
|
632
|
+
if (fixCount >= maxFixAttempts) {
|
|
633
|
+
const reason = `Shepherd iteration budget exhausted (${maxFixAttempts}) — last action: ${lastAction}`;
|
|
634
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
635
|
+
pr: String(prNumber),
|
|
636
|
+
outcome: "hitl",
|
|
637
|
+
reason,
|
|
638
|
+
}));
|
|
639
|
+
return { kind: "hitl", reason };
|
|
640
|
+
}
|
|
641
|
+
fixCount++;
|
|
642
|
+
lastAction = `${subscriptionName} on "${signal.description}"`;
|
|
643
|
+
// VA-465: iteration.start marker — drives the sub-card's
|
|
644
|
+
// "acting" state on the dashboard.
|
|
645
|
+
yield* Effect.logInfo("shepherd.iteration.start").pipe(Effect.annotateLogs({
|
|
646
|
+
pr: String(prNumber),
|
|
647
|
+
subscription: subscriptionName,
|
|
648
|
+
iteration: String(fixCount),
|
|
649
|
+
maxIterations: String(maxFixAttempts),
|
|
650
|
+
description: signal.description,
|
|
651
|
+
}));
|
|
652
|
+
const out = yield* signal.action;
|
|
653
|
+
if (out.kind === "hitl") {
|
|
654
|
+
// Iteration-end (hitl) AND ended (hitl) both fire — the
|
|
655
|
+
// first refreshes the row's last_seen + description;
|
|
656
|
+
// the second terminates it.
|
|
657
|
+
yield* Effect.logInfo("shepherd.iteration.end").pipe(Effect.annotateLogs({
|
|
658
|
+
pr: String(prNumber),
|
|
659
|
+
outcome: "hitl",
|
|
660
|
+
detail: out.reason,
|
|
661
|
+
}));
|
|
662
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
663
|
+
pr: String(prNumber),
|
|
664
|
+
outcome: "hitl",
|
|
665
|
+
reason: out.reason,
|
|
666
|
+
}));
|
|
667
|
+
return { kind: "hitl", reason: out.reason };
|
|
668
|
+
}
|
|
669
|
+
yield* Effect.logInfo("shepherd.iteration.end").pipe(Effect.annotateLogs({
|
|
670
|
+
pr: String(prNumber),
|
|
671
|
+
outcome: "fixed",
|
|
672
|
+
detail: out.detail,
|
|
673
|
+
}));
|
|
674
|
+
firedFix = true;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (firedFix) {
|
|
679
|
+
// A fix just landed — reset the quiet streak; the new commit
|
|
680
|
+
// may produce CI activity / mergeability changes shortly.
|
|
681
|
+
quietStreak = 0;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
quietStreak++;
|
|
685
|
+
if (quietStreak >= maxQuietPolls) {
|
|
686
|
+
const reason = `shepherd quiet for ${maxQuietPolls} consecutive poll${maxQuietPolls === 1 ? "" : "s"} ` +
|
|
687
|
+
`with no merge and no actionable signals — PR ready for human merge`;
|
|
688
|
+
yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
|
|
689
|
+
pr: String(prNumber),
|
|
690
|
+
outcome: "ready",
|
|
691
|
+
reason,
|
|
692
|
+
}));
|
|
693
|
+
return { kind: "ready", reason };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Sleep between polls. Cap the sleep against the wall-clock
|
|
697
|
+
// budget so the next poll runs at the right moment even when
|
|
698
|
+
// the budget is about to fire.
|
|
699
|
+
const remainingMs = maxWallSeconds * 1000 - (Date.now() - startMs);
|
|
700
|
+
if (remainingMs <= 0)
|
|
701
|
+
continue; // Wall-clock check at top will fire.
|
|
702
|
+
const sleepMs = Math.min(pollSeconds * 1000, remainingMs);
|
|
703
|
+
if (sleepMs > 0) {
|
|
704
|
+
yield* Effect.sleep(Duration.millis(sleepMs));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}).pipe(Effect.withSpan("shepherd"));
|