@wardby/cli 0.4.1 → 0.5.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/.env.example +8 -1
- package/README.md +3 -1
- package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
- package/dist/claude-coding-worker/driver.d.ts +2 -0
- package/dist/claude-coding-worker/driver.js +19 -3
- package/dist/claude-coding-worker/sdk.js +1 -1
- package/dist/cli.js +41 -22
- package/dist/coding/continuation-wording.d.ts +10 -0
- package/dist/coding/continuation-wording.js +13 -0
- package/dist/coding/docker-preflight.d.ts +12 -0
- package/dist/coding/docker-preflight.js +40 -0
- package/dist/coding/local-git.d.ts +22 -0
- package/dist/coding/local-git.js +102 -0
- package/dist/coding/local-repo-status.d.ts +7 -0
- package/dist/coding/local-repo-status.js +56 -0
- package/dist/coding/local-repo.d.ts +17 -0
- package/dist/coding/local-repo.js +65 -0
- package/dist/coding/observability.d.ts +1 -1
- package/dist/coding/observability.js +1 -0
- package/dist/coding/profile.d.ts +6 -0
- package/dist/coding/profile.js +13 -4
- package/dist/coding/protocol.d.ts +32 -7
- package/dist/coding/protocol.js +66 -4
- package/dist/coding-worker/artifact.d.ts +1 -0
- package/dist/coding-worker/errors.js +2 -0
- package/dist/config/providers.d.ts +13 -0
- package/dist/config/providers.js +21 -9
- package/dist/core/attribution.d.ts +13 -0
- package/dist/core/attribution.js +30 -0
- package/dist/core/budget-groups.d.ts +3 -1
- package/dist/core/budget-groups.js +53 -5
- package/dist/core/ci-context.d.ts +8 -0
- package/dist/core/ci-context.js +59 -0
- package/dist/core/delegation-siblings.d.ts +22 -0
- package/dist/core/delegation-siblings.js +40 -0
- package/dist/core/dispatch.d.ts +28 -4
- package/dist/core/dispatch.js +57 -15
- package/dist/core/engine-native.d.ts +16 -0
- package/dist/core/engine-native.js +49 -2
- package/dist/core/host-events.d.ts +25 -2
- package/dist/core/host-events.js +361 -19
- package/dist/core/host-status.d.ts +8 -3
- package/dist/core/host-status.js +38 -7
- package/dist/core/issue-events.d.ts +2 -6
- package/dist/core/issue-events.js +13 -19
- package/dist/core/pull-request-state-sync.d.ts +17 -0
- package/dist/core/pull-request-state-sync.js +53 -0
- package/dist/core/reconciler.d.ts +10 -1
- package/dist/core/reconciler.js +15 -2
- package/dist/core/related-pull-requests.d.ts +95 -0
- package/dist/core/related-pull-requests.js +338 -0
- package/dist/core/repo-access.d.ts +5 -1
- package/dist/core/repo-access.js +17 -1
- package/dist/core/review-fix-ledger.d.ts +20 -0
- package/dist/core/review-fix-ledger.js +41 -0
- package/dist/core/review-fix.d.ts +60 -0
- package/dist/core/review-fix.js +155 -0
- package/dist/core/review-host-tools.d.ts +13 -2
- package/dist/core/review-host-tools.js +72 -11
- package/dist/core/runner.d.ts +2 -2
- package/dist/core/runner.js +379 -184
- package/dist/core/serial-gate.d.ts +11 -0
- package/dist/core/serial-gate.js +19 -0
- package/dist/core/timing.d.ts +7 -0
- package/dist/core/timing.js +7 -0
- package/dist/generated/prisma/browser.d.ts +23 -0
- package/dist/generated/prisma/client.d.ts +23 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
- package/dist/generated/prisma/internal/class.d.ts +33 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
- package/dist/generated/prisma/models/Agent.d.ts +422 -1
- package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
- package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
- package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
- package/dist/generated/prisma/models/DeferredReview.js +1 -0
- package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
- package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
- package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
- package/dist/generated/prisma/models/LocalReview.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +223 -0
- package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
- package/dist/generated/prisma/models.d.ts +3 -0
- package/dist/help-index.json +486 -37
- package/dist/import/neutral-schema.d.ts +2 -2
- package/dist/mcp/auth/access.d.ts +3 -1
- package/dist/mcp/auth/host-account-cli.js +2 -2
- package/dist/mcp/auth/repo-authorization.d.ts +6 -7
- package/dist/mcp/auth/repo-authorization.js +21 -0
- package/dist/mcp/index.js +4 -1
- package/dist/mcp/tools/agents.js +46 -4
- package/dist/mcp/tools/host-accounts.js +2 -2
- package/dist/mcp/tools/repositories.js +71 -12
- package/dist/mcp/tools/runs.js +23 -0
- package/dist/mcp/tools/trigger.js +153 -24
- package/dist/providers/engine/types.d.ts +11 -0
- package/dist/providers/executor/composition.js +12 -7
- package/dist/providers/executor/container.d.ts +40 -3
- package/dist/providers/executor/container.js +140 -9
- package/dist/providers/executor/types.d.ts +1 -1
- package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
- package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
- package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
- package/dist/providers/jobs/kubernetes-client.js +3 -0
- package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
- package/dist/providers/jobs/kubernetes-isolation.js +8 -3
- package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
- package/dist/providers/jobs/kubernetes-preflight.js +7 -0
- package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
- package/dist/providers/jobs/kubernetes-quota.js +110 -0
- package/dist/providers/jobs/kubernetes.d.ts +9 -0
- package/dist/providers/jobs/kubernetes.js +50 -0
- package/dist/providers/jobs/types.d.ts +7 -0
- package/dist/providers/review-host/ci.d.ts +7 -0
- package/dist/providers/review-host/ci.js +20 -0
- package/dist/providers/review-host/github-events.d.ts +7 -0
- package/dist/providers/review-host/github-events.js +31 -7
- package/dist/providers/review-host/github.d.ts +19 -2
- package/dist/providers/review-host/github.js +145 -2
- package/dist/providers/review-host/index.d.ts +8 -2
- package/dist/providers/review-host/index.js +14 -3
- package/dist/providers/review-host/local.d.ts +72 -0
- package/dist/providers/review-host/local.js +434 -0
- package/dist/providers/review-host/types.d.ts +65 -3
- package/dist/providers/review-host/types.js +5 -3
- package/dist/providers/vcs/git.d.ts +10 -20
- package/dist/providers/vcs/git.js +71 -127
- package/dist/providers/vcs/github-remote.d.ts +67 -0
- package/dist/providers/vcs/github-remote.js +201 -0
- package/dist/providers/vcs/github.d.ts +81 -0
- package/dist/providers/vcs/github.js +203 -6
- package/dist/providers/vcs/index.d.ts +16 -1
- package/dist/providers/vcs/index.js +43 -15
- package/dist/providers/vcs/local-remote.d.ts +44 -0
- package/dist/providers/vcs/local-remote.js +108 -0
- package/dist/providers/vcs/remote.d.ts +58 -0
- package/dist/providers/vcs/remote.js +1 -0
- package/dist/providers/vcs/routing.d.ts +33 -0
- package/dist/providers/vcs/routing.js +52 -0
- package/dist/providers/vcs/types.d.ts +12 -1
- package/dist/quickstart/coding-db.d.ts +11 -0
- package/dist/quickstart/coding-db.js +75 -0
- package/dist/quickstart/coding-doctor.d.ts +13 -0
- package/dist/quickstart/coding-doctor.js +88 -0
- package/dist/quickstart/coding-images.d.ts +15 -0
- package/dist/quickstart/coding-images.js +79 -0
- package/dist/quickstart/coding-seed.d.ts +63 -0
- package/dist/quickstart/coding-seed.js +85 -0
- package/dist/quickstart/coding.d.ts +66 -0
- package/dist/quickstart/coding.js +430 -0
- package/dist/quickstart/images.d.ts +19 -0
- package/dist/quickstart/images.js +63 -0
- package/dist/quickstart/index.js +61 -12
- package/dist/quickstart/starter-services.d.ts +45 -0
- package/dist/quickstart/starter-services.js +186 -0
- package/dist/quickstart-images.json +1 -0
- package/dist/serve.js +14 -1
- package/dist/viewer/api-schema.d.ts +176 -0
- package/dist/viewer/api-schema.js +27 -2
- package/dist/viewer/graph.d.ts +7 -2
- package/dist/viewer/graph.js +25 -11
- package/dist/viewer/http.d.ts +7 -1
- package/dist/viewer/http.js +8 -3
- package/dist/viewer/infra.d.ts +2 -0
- package/dist/viewer/infra.js +23 -0
- package/dist/viewer/run-detail.d.ts +2 -1
- package/dist/viewer/run-detail.js +2 -2
- package/docs/agent-recipes.md +127 -5
- package/docs/code-review-agents.md +293 -38
- package/docs/coding-agent-setup.md +141 -2
- package/docs/coding-packages.md +21 -0
- package/docs/coding-services.md +3 -0
- package/docs/coding-worker-isolation.md +56 -20
- package/docs/getting-started-gke.md +24 -0
- package/docs/getting-started.md +196 -10
- package/docs/jira-agents.md +26 -5
- package/docs/security-deployment.md +9 -0
- package/docs/viewer-api.md +21 -2
- package/help/admin-viewer.md +11 -1
- package/help/agent-recipes.md +30 -4
- package/help/builder-agent.md +4 -0
- package/help/code-review-agents.md +82 -2
- package/help/coding-packages.md +12 -1
- package/help/coding-services.md +4 -0
- package/help/cost-attribution.md +3 -1
- package/help/creating-agents.md +3 -0
- package/help/deploy-gke.md +2 -1
- package/help/errors/coding-provider-not-configured.md +60 -0
- package/help/errors/coding-turn-limit.md +25 -0
- package/help/errors/continuation-closed.md +75 -0
- package/help/errors/local-branch-conflict.md +37 -0
- package/help/errors/local-path-invalid.md +30 -0
- package/help/errors/local-ref-invalid.md +36 -0
- package/help/errors/local-ref-not-found.md +41 -0
- package/help/errors/local-repo-not-allowed.md +46 -0
- package/help/errors/local-repo-not-found.md +39 -0
- package/help/errors/model-unavailable.md +7 -6
- package/help/errors/vcs-github-not-configured.md +34 -0
- package/help/getting-started.md +28 -0
- package/help/github.md +22 -5
- package/help/jira.md +9 -2
- package/help/local-repositories.md +182 -0
- package/help/related-pull-requests.md +89 -0
- package/help/review-fix-rounds.md +69 -0
- package/help/troubleshooting/budgets.md +11 -0
- package/help/troubleshooting/coding-workers.md +22 -0
- package/help/troubleshooting/repository-access.md +5 -0
- package/package.json +3 -2
- package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
- package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
- package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
- package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
- package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
- package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
- package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
- package/prisma/schema.prisma +98 -1
package/dist/core/host-events.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { linkedPullRequestAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET } from "./attribution.js";
|
|
1
|
+
import { linkedPullRequestAttribution, openingRunAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET } from "./attribution.js";
|
|
2
2
|
import { checkContinuation, dispatchRun } from "./dispatch.js";
|
|
3
3
|
import { mentionStatusRow, postMentionStatus } from "./host-status.js";
|
|
4
4
|
import { handlePullRequestClosed } from "./issue-bridge.js";
|
|
5
5
|
import { logger } from "./logger.js";
|
|
6
|
+
import { MAX_SIBLING_HINTS, openSiblings, SIBLING_GUIDANCE } from "./related-pull-requests.js";
|
|
6
7
|
import { requiredLevel } from "./repo-access.js";
|
|
7
8
|
import { composeTaskOverride } from "./untrusted-content.js";
|
|
8
9
|
import { DEFAULT_KNOWLEDGE_BUNDLE_PATH, parseConcept, RESERVED_BUNDLE_FILES, } from "../knowledge/concept.js";
|
|
@@ -11,7 +12,13 @@ import { spanHash } from "../knowledge/span-hash.js";
|
|
|
11
12
|
const log = logger.child({ module: "host-events" });
|
|
12
13
|
const MAX_TASK_BODY = 8000;
|
|
13
14
|
const MAX_TITLE = 256;
|
|
14
|
-
const HOST_NAMES = { github: "GitHub" };
|
|
15
|
+
const HOST_NAMES = { github: "GitHub", local: "local repository" };
|
|
16
|
+
/** A deferred review whose head's CI has not finished after this long is started anyway (by the reconciler sweep). */
|
|
17
|
+
export const DEFERRED_REVIEW_MAX_WAIT_MS = 15 * 60_000;
|
|
18
|
+
/** A deferred review row older than this is dropped without starting. */
|
|
19
|
+
export const DEFERRED_REVIEW_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
20
|
+
/** Upper bound on deferred reviews the sweep starts per pass, so a backlog drains over several passes. */
|
|
21
|
+
export const DEFERRED_REVIEW_BATCH = 50;
|
|
15
22
|
function escapeRegExp(value) {
|
|
16
23
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
24
|
}
|
|
@@ -233,6 +240,36 @@ async function verifyCitations(host, event, concepts, budget) {
|
|
|
233
240
|
}
|
|
234
241
|
return statuses;
|
|
235
242
|
}
|
|
243
|
+
/** The exact phrase every continuation hint uses (a prompt may match it). */
|
|
244
|
+
export function continuePriorRunPhrase(runId) {
|
|
245
|
+
return `pass continuePriorRun set to exactly "${runId}"`;
|
|
246
|
+
}
|
|
247
|
+
/** The continuation hint a router agent follows to continue a PR's branch instead of opening a new one. */
|
|
248
|
+
export function continuationHint(prNumber, runId) {
|
|
249
|
+
return (`[This request is a follow-up on PR #${prNumber}, originally opened by wardby run ${runId}. ` +
|
|
250
|
+
`If you delegate, ${continuePriorRunPhrase(runId)} so the same PR/branch is ` +
|
|
251
|
+
`continued instead of opening a new one.]`);
|
|
252
|
+
}
|
|
253
|
+
const HINT_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
254
|
+
const HINT_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
255
|
+
/** One hint per open sibling (stored rows only; links built, ids re-validated), then the shared guidance. */
|
|
256
|
+
export function siblingContinuationHints(siblings) {
|
|
257
|
+
const lines = siblings
|
|
258
|
+
.filter((s) => HINT_RUN_ID.test(s.openedByRunId) &&
|
|
259
|
+
HINT_REPOSITORY.test(s.repository) &&
|
|
260
|
+
Number.isSafeInteger(s.number) &&
|
|
261
|
+
s.number > 0)
|
|
262
|
+
.slice(0, MAX_SIBLING_HINTS)
|
|
263
|
+
.map((s) => `- ${s.repository}#${s.number} (https://github.com/${s.repository}/pull/${s.number}): to change it, ` +
|
|
264
|
+
`delegate to that repository's coding agent and ${continuePriorRunPhrase(s.openedByRunId)}.`);
|
|
265
|
+
if (lines.length === 0)
|
|
266
|
+
return undefined;
|
|
267
|
+
return [
|
|
268
|
+
"[This pull request is one of a set wardby opened for the same request. The set's other open pull requests:",
|
|
269
|
+
...lines,
|
|
270
|
+
`${SIBLING_GUIDANCE}]`,
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
236
273
|
/**
|
|
237
274
|
* The mention agent's task text. Deterministic so a prompt can parse it.
|
|
238
275
|
*
|
|
@@ -249,15 +286,16 @@ async function verifyCitations(host, event, concepts, budget) {
|
|
|
249
286
|
* never in the system prompt. composeTaskOverride stores both in the one
|
|
250
287
|
* taskOverride column.
|
|
251
288
|
*/
|
|
252
|
-
export function mentionTaskText(event) {
|
|
289
|
+
export function mentionTaskText(event, siblings = []) {
|
|
253
290
|
const kind = event.isPullRequest ? "PR" : "issue";
|
|
254
291
|
const Kind = event.isPullRequest ? "PR" : "Issue";
|
|
255
292
|
const inSubject = event.comment.kind === "subject";
|
|
256
293
|
const sections = [];
|
|
257
294
|
if (event.priorRunId) {
|
|
258
|
-
sections.push(
|
|
259
|
-
|
|
260
|
-
|
|
295
|
+
sections.push(continuationHint(event.number, event.priorRunId));
|
|
296
|
+
const hints = siblingContinuationHints(siblings);
|
|
297
|
+
if (hints)
|
|
298
|
+
sections.push(hints);
|
|
261
299
|
}
|
|
262
300
|
const title = event.subject?.title.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE);
|
|
263
301
|
const description = event.subject?.body.trim() ? event.subject.body.slice(0, MAX_TASK_BODY) : "";
|
|
@@ -329,7 +367,23 @@ skipReviewedCommits = false) {
|
|
|
329
367
|
// One lookup per PR, not per reviewer, and none at all when every reviewer
|
|
330
368
|
// skips this commit: taken at the first actual dispatch.
|
|
331
369
|
let attribution;
|
|
332
|
-
const linkedAttribution = () => (attribution ??=
|
|
370
|
+
const linkedAttribution = () => (attribution ??= (async () => {
|
|
371
|
+
const linked = await linkedPullRequestAttribution(deps.db, deps.issueTrackers, { codeProvider: host.provider, repository, number: prNumber }, RESPONSE_PATH_SNAPSHOT_BUDGET);
|
|
372
|
+
if (linked || !host.pullRequestOrigin)
|
|
373
|
+
return linked;
|
|
374
|
+
// Not linked to an issue yet (the lead run that opened it may still be
|
|
375
|
+
// running): attribute to the issue of the run whose marker the PR carries.
|
|
376
|
+
try {
|
|
377
|
+
const origin = await host.pullRequestOrigin(repository, prNumber);
|
|
378
|
+
return origin.markerRunId
|
|
379
|
+
? await openingRunAttribution(deps.db, deps.issueTrackers, origin.markerRunId, RESPONSE_PATH_SNAPSHOT_BUDGET)
|
|
380
|
+
: undefined;
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
log.warn({ err, repository, prNumber }, "could not read the pull request's opening run; the review is unattributed");
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
})());
|
|
333
387
|
for (const target of targets) {
|
|
334
388
|
if (skipReviewedCommits && (await alreadyReviewed(deps, repository, prNumber, headSha, target.agentId))) {
|
|
335
389
|
log.info({ repository, prNumber, agentId: target.agentId, headSha }, "review skipped: this commit was already reviewed");
|
|
@@ -382,6 +436,62 @@ skipReviewedCommits = false) {
|
|
|
382
436
|
}
|
|
383
437
|
return runIds;
|
|
384
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* CI finished on a pull request's head: re-runs, once per head, each review
|
|
441
|
+
* that published only a COMMENT while CI there was still running or had not
|
|
442
|
+
* reported (RunHostCheck.ciPendingAtReview). Waits for the last suite: while
|
|
443
|
+
* any CI on the head is still pending, a later completion decides. Never
|
|
444
|
+
* throws; a failure is logged and the delivery is not retried.
|
|
445
|
+
*/
|
|
446
|
+
async function rereviewAfterCi(deps, host, event, prNumber, reviewerLinks) {
|
|
447
|
+
try {
|
|
448
|
+
if (!host.readCi)
|
|
449
|
+
return [];
|
|
450
|
+
const onHead = await deps.db.runHostCheck.findMany({
|
|
451
|
+
where: { provider: event.provider, repository: event.repository, prNumber, headSha: event.headSha },
|
|
452
|
+
select: {
|
|
453
|
+
runId: true,
|
|
454
|
+
verdict: true,
|
|
455
|
+
ciPendingAtReview: true,
|
|
456
|
+
ciRereviewAt: true,
|
|
457
|
+
run: { select: { agentId: true } },
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
// Once per head and reviewer: an agent already re-run here is never re-run again on it.
|
|
461
|
+
const rerun = new Set(onHead.filter((r) => r.ciRereviewAt !== null).map((r) => r.run.agentId));
|
|
462
|
+
const waiting = onHead.filter((r) => r.verdict === "COMMENT" && r.ciPendingAtReview === true && !rerun.has(r.run.agentId));
|
|
463
|
+
if (waiting.length === 0)
|
|
464
|
+
return [];
|
|
465
|
+
const head = await host.pullRequestHead(event.repository, prNumber);
|
|
466
|
+
if (head.isFork || head.state !== "open" || head.headSha !== event.headSha)
|
|
467
|
+
return [];
|
|
468
|
+
const ci = await host.readCi(event.repository, event.headSha);
|
|
469
|
+
if (ci.state === "pending" || ci.state === "none" || ci.state === "unavailable")
|
|
470
|
+
return [];
|
|
471
|
+
// Only reviewers still authorized are claimed, so a denied one keeps its chance for later.
|
|
472
|
+
const allowed = await authorizedLinks(deps, event, reviewerLinks.filter((l) => waiting.some((r) => r.run.agentId === l.agentId)));
|
|
473
|
+
const allowedIds = new Set(allowed.map((l) => l.agentId));
|
|
474
|
+
// Claimed per row, so concurrent completions re-run each review only once.
|
|
475
|
+
const agentIds = new Set();
|
|
476
|
+
for (const row of waiting.filter((r) => allowedIds.has(r.run.agentId))) {
|
|
477
|
+
const claimed = await deps.db.runHostCheck.updateMany({
|
|
478
|
+
where: { runId: row.runId, ciRereviewAt: null },
|
|
479
|
+
data: { ciRereviewAt: new Date() },
|
|
480
|
+
});
|
|
481
|
+
if (claimed.count > 0)
|
|
482
|
+
agentIds.add(row.run.agentId);
|
|
483
|
+
}
|
|
484
|
+
if (agentIds.size === 0)
|
|
485
|
+
return [];
|
|
486
|
+
const targets = reviewTargets(allowed.filter((l) => agentIds.has(l.agentId)));
|
|
487
|
+
log.info({ repository: event.repository, prNumber, headSha: event.headSha, ci: ci.state }, "CI finished: re-running the review");
|
|
488
|
+
return await startReviews(deps, host, event.repository, prNumber, event.headSha, targets);
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
log.warn({ err, repository: event.repository, prNumber }, "could not re-run the review after CI finished");
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
385
495
|
/**
|
|
386
496
|
* Keeps only the links the gate still authorizes: the agent must have an
|
|
387
497
|
* owner, and the owner's current access (or a recorded admin/grandfathered
|
|
@@ -406,6 +516,222 @@ async function authorizedLinks(deps, event, links) {
|
|
|
406
516
|
return kept;
|
|
407
517
|
}
|
|
408
518
|
const reviewTargets = (links) => links.map((l) => ({ agentId: l.agentId, checkName: l.checkName }));
|
|
519
|
+
/** A link that reviews pull requests: write access, the pull_request trigger, and a check to report on. */
|
|
520
|
+
const isReviewerLink = (l) => l.access === "write" && l.triggers.includes("pull_request") && Boolean(l.checkName);
|
|
521
|
+
async function loadLinks(deps, provider, repository) {
|
|
522
|
+
return (await deps.db.agentRepository.findMany({
|
|
523
|
+
where: { provider, repository },
|
|
524
|
+
include: { agent: { select: { ownerId: true } } },
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
/** CI states that mean "not finished yet" when a head is pushed; `none` also covers checks not registered yet. */
|
|
528
|
+
const CI_NOT_FINISHED_ON_PUSH = new Set(["pending", "none"]);
|
|
529
|
+
/**
|
|
530
|
+
* pr_updated for reviewers linked with waitForCi: reads CI on the head once.
|
|
531
|
+
* While it is pending (or nothing has reported yet), records one
|
|
532
|
+
* DeferredReview row per reviewer and starts nothing; ci_completed, or the
|
|
533
|
+
* reconciler sweep after DEFERRED_REVIEW_MAX_WAIT_MS, starts it later; CI is
|
|
534
|
+
* read once more after recording, in case it finished in between. Returns the
|
|
535
|
+
* links to start now (all of them when CI has already finished, cannot be
|
|
536
|
+
* read, or the deferral cannot be recorded: as without waitForCi) and the runs
|
|
537
|
+
* the re-read started.
|
|
538
|
+
*/
|
|
539
|
+
async function deferUntilCi(deps, host, key, links) {
|
|
540
|
+
const now = { startNow: links, runIds: [] };
|
|
541
|
+
if (links.length === 0 || !host.readCi)
|
|
542
|
+
return now;
|
|
543
|
+
let state;
|
|
544
|
+
try {
|
|
545
|
+
state = (await host.readCi(key.repository, key.headSha)).state;
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not read CI; reviewing now");
|
|
549
|
+
return now;
|
|
550
|
+
}
|
|
551
|
+
if (!CI_NOT_FINISHED_ON_PUSH.has(state))
|
|
552
|
+
return now;
|
|
553
|
+
try {
|
|
554
|
+
// Idempotent per head and reviewer: a repeated delivery or a second push event for the same head adds nothing.
|
|
555
|
+
await deps.db.deferredReview.createMany({
|
|
556
|
+
data: links.map((l) => ({
|
|
557
|
+
provider: key.provider,
|
|
558
|
+
repository: key.repository,
|
|
559
|
+
prNumber: key.prNumber,
|
|
560
|
+
headSha: key.headSha,
|
|
561
|
+
agentId: l.agentId,
|
|
562
|
+
checkName: l.checkName,
|
|
563
|
+
})),
|
|
564
|
+
skipDuplicates: true,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not record the deferred review; reviewing now");
|
|
569
|
+
return now;
|
|
570
|
+
}
|
|
571
|
+
log.info({
|
|
572
|
+
repository: key.repository,
|
|
573
|
+
prNumber: key.prNumber,
|
|
574
|
+
headSha: key.headSha,
|
|
575
|
+
ci: state,
|
|
576
|
+
agentIds: links.map((l) => l.agentId),
|
|
577
|
+
}, "review deferred until CI finishes");
|
|
578
|
+
// CI may have finished between the read above and the insert, with its
|
|
579
|
+
// ci_completed delivery finding no row yet: read once more and, if it has,
|
|
580
|
+
// start now (claimed by delete, so a racing delivery starts it once). A
|
|
581
|
+
// failure leaves the rows for the sweep.
|
|
582
|
+
const runIds = [];
|
|
583
|
+
try {
|
|
584
|
+
const again = (await host.readCi(key.repository, key.headSha)).state;
|
|
585
|
+
if (!CI_NOT_FINISHED_ON_PUSH.has(again)) {
|
|
586
|
+
const rows = await deps.db.deferredReview.findMany({
|
|
587
|
+
where: {
|
|
588
|
+
provider: key.provider,
|
|
589
|
+
repository: key.repository,
|
|
590
|
+
prNumber: key.prNumber,
|
|
591
|
+
headSha: key.headSha,
|
|
592
|
+
agentId: { in: links.map((l) => l.agentId) },
|
|
593
|
+
},
|
|
594
|
+
select: { id: true, agentId: true, checkName: true },
|
|
595
|
+
});
|
|
596
|
+
if (rows.length > 0)
|
|
597
|
+
runIds.push(...(await startDeferred(deps, host, key, rows, links, "ci", "CI finished")));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not re-read CI after deferring; the sweep starts the review");
|
|
602
|
+
}
|
|
603
|
+
return { startNow: [], runIds };
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Starts deferred reviews for one PR head. The head must still be that sha,
|
|
607
|
+
* open, and not a fork; otherwise every row is dropped (a newer push records
|
|
608
|
+
* its own). Each row is claimed by deleting it, so a concurrent ci_completed
|
|
609
|
+
* delivery or sweep starts each review only once. On `ci`, only reviewers
|
|
610
|
+
* still authorized are claimed (the rest wait for the sweep); on `sweep` (the
|
|
611
|
+
* last attempt) every row is claimed and unauthorized or unlinked reviewers
|
|
612
|
+
* are dropped.
|
|
613
|
+
*/
|
|
614
|
+
async function startDeferred(deps, host, key, rows, reviewerLinks, mode, reason) {
|
|
615
|
+
const where = { repository: key.repository, prNumber: key.prNumber, headSha: key.headSha };
|
|
616
|
+
const head = await host.pullRequestHead(key.repository, key.prNumber);
|
|
617
|
+
if (head.isFork || head.state !== "open" || head.headSha !== key.headSha) {
|
|
618
|
+
await deps.db.deferredReview.deleteMany({ where: { id: { in: rows.map((r) => r.id) } } });
|
|
619
|
+
log.info({ ...where, state: head.state }, "deferred review dropped: the pull request moved on or closed");
|
|
620
|
+
return [];
|
|
621
|
+
}
|
|
622
|
+
const allowed = await authorizedLinks(deps, key, reviewerLinks.filter((l) => rows.some((r) => r.agentId === l.agentId)));
|
|
623
|
+
const allowedIds = new Set(allowed.map((l) => l.agentId));
|
|
624
|
+
const targets = [];
|
|
625
|
+
for (const row of rows) {
|
|
626
|
+
if (mode === "ci" && !allowedIds.has(row.agentId))
|
|
627
|
+
continue;
|
|
628
|
+
const claimed = await deps.db.deferredReview.deleteMany({ where: { id: row.id } });
|
|
629
|
+
if (claimed.count !== 1)
|
|
630
|
+
continue;
|
|
631
|
+
if (allowedIds.has(row.agentId))
|
|
632
|
+
targets.push({ agentId: row.agentId, checkName: row.checkName });
|
|
633
|
+
else
|
|
634
|
+
log.info({ ...where, agentId: row.agentId }, "deferred review dropped: the reviewer is no longer linked or authorized");
|
|
635
|
+
}
|
|
636
|
+
if (targets.length === 0)
|
|
637
|
+
return [];
|
|
638
|
+
log.info({ ...where, agentIds: targets.map((t) => t.agentId), reason }, "starting the deferred review");
|
|
639
|
+
try {
|
|
640
|
+
return await startReviews(deps, host, key.repository, key.prNumber, key.headSha, targets, true);
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
// The rows are already claimed (at most once): make the lost start visible.
|
|
644
|
+
log.warn({ err, ...where, agentIds: targets.map((t) => t.agentId) }, "claimed deferred reviews could not be started");
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* ci_completed: starts the reviews deferred on this head (see deferUntilCi),
|
|
650
|
+
* unless CI there is still pending (a later completion decides). Never
|
|
651
|
+
* throws; a failure is logged and the rows stay for the reconciler sweep.
|
|
652
|
+
*/
|
|
653
|
+
async function startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks) {
|
|
654
|
+
try {
|
|
655
|
+
const key = { provider: event.provider, repository: event.repository, prNumber, headSha: event.headSha };
|
|
656
|
+
const rows = await deps.db.deferredReview.findMany({
|
|
657
|
+
where: key,
|
|
658
|
+
select: { id: true, agentId: true, checkName: true },
|
|
659
|
+
});
|
|
660
|
+
if (rows.length === 0)
|
|
661
|
+
return [];
|
|
662
|
+
if (host.readCi) {
|
|
663
|
+
const ci = await host.readCi(event.repository, event.headSha);
|
|
664
|
+
if (ci.state === "pending")
|
|
665
|
+
return [];
|
|
666
|
+
}
|
|
667
|
+
return await startDeferred(deps, host, key, rows, reviewerLinks, "ci", "CI finished");
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
log.warn({ err, repository: event.repository, prNumber }, "could not start the deferred review after CI finished");
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The reconciler's fallback for deferred reviews: drops rows older than
|
|
676
|
+
* DEFERRED_REVIEW_MAX_AGE_MS, and starts (regardless of CI) those waiting
|
|
677
|
+
* longer than DEFERRED_REVIEW_MAX_WAIT_MS, at most DEFERRED_REVIEW_BATCH per
|
|
678
|
+
* pass, oldest first. Covers CI that never reported, ran very long, or whose
|
|
679
|
+
* completion webhook was missed. Never throws; returns the runs started.
|
|
680
|
+
*/
|
|
681
|
+
export async function startDeferredReviews(deps, now = new Date()) {
|
|
682
|
+
const runIds = [];
|
|
683
|
+
try {
|
|
684
|
+
const expired = await deps.db.deferredReview.deleteMany({
|
|
685
|
+
where: { createdAt: { lt: new Date(now.getTime() - DEFERRED_REVIEW_MAX_AGE_MS) } },
|
|
686
|
+
});
|
|
687
|
+
if (expired.count > 0)
|
|
688
|
+
log.info({ count: expired.count }, "dropped deferred reviews older than 24 h");
|
|
689
|
+
const providers = Object.keys(deps.hosts);
|
|
690
|
+
if (providers.length === 0)
|
|
691
|
+
return runIds;
|
|
692
|
+
const due = await deps.db.deferredReview.findMany({
|
|
693
|
+
where: {
|
|
694
|
+
provider: { in: providers },
|
|
695
|
+
createdAt: { lte: new Date(now.getTime() - DEFERRED_REVIEW_MAX_WAIT_MS) },
|
|
696
|
+
},
|
|
697
|
+
select: {
|
|
698
|
+
id: true,
|
|
699
|
+
provider: true,
|
|
700
|
+
repository: true,
|
|
701
|
+
prNumber: true,
|
|
702
|
+
headSha: true,
|
|
703
|
+
agentId: true,
|
|
704
|
+
checkName: true,
|
|
705
|
+
},
|
|
706
|
+
orderBy: { createdAt: "asc" },
|
|
707
|
+
take: DEFERRED_REVIEW_BATCH,
|
|
708
|
+
});
|
|
709
|
+
const groups = new Map();
|
|
710
|
+
for (const row of due) {
|
|
711
|
+
const id = JSON.stringify([row.provider, row.repository, row.prNumber, row.headSha]);
|
|
712
|
+
groups.set(id, [...(groups.get(id) ?? []), row]);
|
|
713
|
+
}
|
|
714
|
+
for (const rows of groups.values()) {
|
|
715
|
+
const first = rows[0];
|
|
716
|
+
const provider = first.provider;
|
|
717
|
+
const key = { provider, repository: first.repository, prNumber: first.prNumber, headSha: first.headSha };
|
|
718
|
+
const host = deps.hosts[provider];
|
|
719
|
+
if (!host)
|
|
720
|
+
continue;
|
|
721
|
+
try {
|
|
722
|
+
const reviewerLinks = (await loadLinks(deps, provider, key.repository)).filter(isReviewerLink);
|
|
723
|
+
runIds.push(...(await startDeferred(deps, host, key, rows, reviewerLinks, "sweep", "CI did not finish within 15 min")));
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not start a deferred review");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
log.warn({ err }, "deferred review sweep failed");
|
|
732
|
+
}
|
|
733
|
+
return runIds;
|
|
734
|
+
}
|
|
409
735
|
export async function routeHostEvent(event, deps) {
|
|
410
736
|
const none = { runIds: [], followUps: [] };
|
|
411
737
|
if (event.kind === "pr_closed") {
|
|
@@ -421,11 +747,8 @@ export async function routeHostEvent(event, deps) {
|
|
|
421
747
|
const host = deps.hosts[event.provider];
|
|
422
748
|
if (!host)
|
|
423
749
|
return none;
|
|
424
|
-
const links =
|
|
425
|
-
|
|
426
|
-
include: { agent: { select: { ownerId: true } } },
|
|
427
|
-
}));
|
|
428
|
-
const reviewerLinks = links.filter((l) => l.access === "write" && l.triggers.includes("pull_request") && l.checkName);
|
|
750
|
+
const links = await loadLinks(deps, event.provider, event.repository);
|
|
751
|
+
const reviewerLinks = links.filter(isReviewerLink);
|
|
429
752
|
switch (event.kind) {
|
|
430
753
|
case "push": {
|
|
431
754
|
const watchers = await authorizedLinks(deps, event, links.filter((l) => l.access === "write" && l.triggers.includes("push")));
|
|
@@ -495,13 +818,28 @@ export async function routeHostEvent(event, deps) {
|
|
|
495
818
|
case "pr_updated": {
|
|
496
819
|
if (event.isFork || reviewerLinks.length === 0)
|
|
497
820
|
return none;
|
|
498
|
-
const
|
|
499
|
-
if (
|
|
821
|
+
const allowed = await authorizedLinks(deps, event, reviewerLinks);
|
|
822
|
+
if (allowed.length === 0)
|
|
500
823
|
return none;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
824
|
+
// Reviewers linked with waitForCi review this head only once its CI finished.
|
|
825
|
+
const deferral = await deferUntilCi(deps, host, event, allowed.filter((l) => l.waitForCi));
|
|
826
|
+
const startNow = new Set(deferral.startNow);
|
|
827
|
+
const reviewers = reviewTargets(allowed.filter((l) => !l.waitForCi || startNow.has(l)));
|
|
828
|
+
const runIds = [...deferral.runIds];
|
|
829
|
+
if (reviewers.length > 0) {
|
|
830
|
+
runIds.push(...(await startReviews(deps, host, event.repository, event.prNumber, event.headSha, reviewers, true)));
|
|
831
|
+
}
|
|
832
|
+
return { runIds, followUps: [] };
|
|
833
|
+
}
|
|
834
|
+
case "ci_completed": {
|
|
835
|
+
if (reviewerLinks.length === 0)
|
|
836
|
+
return none;
|
|
837
|
+
const runIds = [];
|
|
838
|
+
for (const prNumber of event.prNumbers) {
|
|
839
|
+
runIds.push(...(await rereviewAfterCi(deps, host, event, prNumber, reviewerLinks)));
|
|
840
|
+
runIds.push(...(await startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks)));
|
|
841
|
+
}
|
|
842
|
+
return { runIds, followUps: [] };
|
|
505
843
|
}
|
|
506
844
|
case "check_rerun": {
|
|
507
845
|
const owners = reviewerLinks.filter((l) => l.checkName === event.checkName);
|
|
@@ -570,13 +908,17 @@ export async function routeHostEvent(event, deps) {
|
|
|
570
908
|
};
|
|
571
909
|
return { runIds: [], followUps: [react, reply] };
|
|
572
910
|
}
|
|
911
|
+
// Seeded from the PR's validated marker run: the request it belongs to. Stored state only.
|
|
912
|
+
const siblings = event.isPullRequest && event.priorRunId
|
|
913
|
+
? await openSiblings(deps.db, event.priorRunId, { repository: event.repository, number: event.number })
|
|
914
|
+
: [];
|
|
573
915
|
const dispatched = await dispatchRun({
|
|
574
916
|
db: deps.db,
|
|
575
917
|
executor: deps.executor,
|
|
576
918
|
selfDefects: { db: deps.db, issueTrackers: deps.issueTrackers },
|
|
577
919
|
agentId: allowed[0].agentId,
|
|
578
920
|
trigger: "host_event",
|
|
579
|
-
taskOverride: mentionTaskText(event),
|
|
921
|
+
taskOverride: mentionTaskText(event, siblings),
|
|
580
922
|
attribution: event.isPullRequest
|
|
581
923
|
? await linkedPullRequestAttribution(deps.db, deps.issueTrackers, { codeProvider: host.provider, repository: event.repository, number: event.number }, RESPONSE_PATH_SNAPSHOT_BUDGET)
|
|
582
924
|
: undefined,
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* died first, say) still gets one: the reconciler posts the outcome as a new
|
|
9
9
|
* comment. Best effort throughout: nothing here throws.
|
|
10
10
|
*/
|
|
11
|
-
import type { Prisma, PrismaClient, Run } from "#prisma";
|
|
11
|
+
import type { Prisma, PrismaClient, Run, RunStatus } from "#prisma";
|
|
12
12
|
import type { CodeReviewHost, ReviewHostProvider, ReviewHostRegistry } from "../providers/review-host/types.js";
|
|
13
|
+
/** The run statuses a run never leaves. */
|
|
14
|
+
export declare const TERMINAL_RUN_STATUSES: readonly RunStatus[];
|
|
13
15
|
export type HostStatusDb = Pick<PrismaClient, "runHostStatus" | "run">;
|
|
14
16
|
export type FinishedRun = Pick<Run, "id" | "status" | "finalText">;
|
|
15
17
|
/** The parts of a CodingRun result this comment uses. */
|
|
@@ -23,6 +25,8 @@ export interface PullRequestOutcome {
|
|
|
23
25
|
codeProvider: string;
|
|
24
26
|
/** The coding sub-run that produced it; set by collectRunOutcome. */
|
|
25
27
|
runId?: string;
|
|
28
|
+
/** How many of the run's own checks (result.tests) failed; the commands stay in the PR body. */
|
|
29
|
+
failedChecks?: number;
|
|
26
30
|
}
|
|
27
31
|
/** A sub-run that ended without succeeding. */
|
|
28
32
|
export interface FailedChild {
|
|
@@ -36,13 +40,14 @@ export interface FailedChild {
|
|
|
36
40
|
services?: string[];
|
|
37
41
|
}
|
|
38
42
|
export declare const runLine: (runId: string) => string;
|
|
39
|
-
export declare function workingBody(runId: string): string;
|
|
43
|
+
export declare function workingBody(runId: string, heading?: string): string;
|
|
40
44
|
/** The status row for a mention run, created in the same transaction as the run. */
|
|
41
45
|
export declare function mentionStatusRow(provider: ReviewHostProvider, event: {
|
|
42
46
|
repository: string;
|
|
43
47
|
number: number;
|
|
44
48
|
replyToReviewCommentId?: string;
|
|
45
49
|
}, runId: string): Prisma.RunHostStatusUncheckedCreateInput;
|
|
50
|
+
export declare function pullRequestOutcome(result: unknown): PullRequestOutcome | null;
|
|
46
51
|
export declare function outcomeBody(run: FinishedRun, repository: string, pullRequests: PullRequestOutcome[], failedChildren?: FailedChild[], opts?: {
|
|
47
52
|
budgetSentence?: string;
|
|
48
53
|
noPullRequestText?: string;
|
|
@@ -75,4 +80,4 @@ export declare function completeHostStatus(db: HostStatusDb, run: FinishedRun, h
|
|
|
75
80
|
* once. Does nothing when the row is gone, already has a comment, or is
|
|
76
81
|
* already complete. Never throws.
|
|
77
82
|
*/
|
|
78
|
-
export declare function postMentionStatus(db: HostStatusDb, host: CodeReviewHost, runId: string, hosts: ReviewHostRegistry | undefined): Promise<void>;
|
|
83
|
+
export declare function postMentionStatus(db: HostStatusDb, host: CodeReviewHost, runId: string, hosts: ReviewHostRegistry | undefined, heading?: string): Promise<void>;
|
package/dist/core/host-status.js
CHANGED
|
@@ -2,16 +2,26 @@ import { CODING_CODE_PROVIDER } from "../coding/protocol.js";
|
|
|
2
2
|
import { storedServiceNames } from "../coding/services/catalog.js";
|
|
3
3
|
import { SERVICE_UNREADY_CATEGORY, serviceRefusalSentence, serviceUnreadySentence, } from "../coding/services/wording.js";
|
|
4
4
|
import { PROTECTED_PATH_CATEGORY, PROTECTED_PATH_HOST_LINE } from "../coding/protected-path-wording.js";
|
|
5
|
+
import { CONTINUATION_CLOSED_CATEGORY, CONTINUATION_CLOSED_HOST_LINE } from "../coding/continuation-wording.js";
|
|
5
6
|
import { loadBudgetSentence } from "./budget-wording.js";
|
|
6
7
|
import { logger } from "./logger.js";
|
|
7
8
|
import { providerClassOfCategory, providerSentence } from "./provider-wording.js";
|
|
8
9
|
const log = logger.child({ module: "host-status" });
|
|
9
10
|
/** Keeps the edited comment readable; the full reply stays on the run. */
|
|
10
11
|
const MAX_REPLY_CHARS = 2000;
|
|
11
|
-
|
|
12
|
+
/** The run statuses a run never leaves. */
|
|
13
|
+
export const TERMINAL_RUN_STATUSES = [
|
|
14
|
+
"succeeded",
|
|
15
|
+
"failed",
|
|
16
|
+
"refused",
|
|
17
|
+
"lost",
|
|
18
|
+
"budget_exhausted",
|
|
19
|
+
"cancelled",
|
|
20
|
+
];
|
|
21
|
+
const TERMINAL = new Set(TERMINAL_RUN_STATUSES);
|
|
12
22
|
export const runLine = (runId) => `<sub>wardby run \`${runId}\`</sub>`;
|
|
13
|
-
export function workingBody(runId) {
|
|
14
|
-
return
|
|
23
|
+
export function workingBody(runId, heading = "👀 Working on it.") {
|
|
24
|
+
return `${heading}\n\n${runLine(runId)}`;
|
|
15
25
|
}
|
|
16
26
|
/** The status row for a mention run, created in the same transaction as the run. */
|
|
17
27
|
export function mentionStatusRow(provider, event, runId) {
|
|
@@ -24,7 +34,7 @@ export function mentionStatusRow(provider, event, runId) {
|
|
|
24
34
|
replyToReviewCommentId: event.replyToReviewCommentId ?? null,
|
|
25
35
|
};
|
|
26
36
|
}
|
|
27
|
-
function pullRequestOutcome(result) {
|
|
37
|
+
export function pullRequestOutcome(result) {
|
|
28
38
|
if (!result || typeof result !== "object")
|
|
29
39
|
return null;
|
|
30
40
|
const r = result;
|
|
@@ -40,8 +50,16 @@ function pullRequestOutcome(result) {
|
|
|
40
50
|
pullRequestNumber: r.pullRequestNumber,
|
|
41
51
|
codeProvider: CODING_CODE_PROVIDER,
|
|
42
52
|
...(typeof r.pullRequestUrl === "string" ? { pullRequestUrl: r.pullRequestUrl } : {}),
|
|
53
|
+
...failedCheckCount(r.tests),
|
|
43
54
|
};
|
|
44
55
|
}
|
|
56
|
+
/** Only a count reaches the comment: test commands are agent-authored and belong in the PR body. */
|
|
57
|
+
function failedCheckCount(tests) {
|
|
58
|
+
if (!Array.isArray(tests))
|
|
59
|
+
return {};
|
|
60
|
+
const failed = tests.filter((t) => t && typeof t === "object" && t.outcome === "failed").length;
|
|
61
|
+
return failed > 0 ? { failedChecks: failed } : {};
|
|
62
|
+
}
|
|
45
63
|
/** The agent's reply as a quote, cut to MAX_REPLY_CHARS, with @-mentions defused so nobody is pinged. */
|
|
46
64
|
const cutReply = (text) => text.length > MAX_REPLY_CHARS ? `${text.slice(0, MAX_REPLY_CHARS)}…` : text;
|
|
47
65
|
function quoteReply(text) {
|
|
@@ -85,13 +103,19 @@ export function outcomeBody(run, repository, pullRequests, failedChildren = [],
|
|
|
85
103
|
: null;
|
|
86
104
|
// No path reaches this comment (see coding/protected-path-wording.ts), only the category.
|
|
87
105
|
const protectedPathOf = (c) => c.status === "failed" && c.failureCategory === PROTECTED_PATH_CATEGORY;
|
|
106
|
+
// A continuation whose PR closed before the sub-run ever spent anything
|
|
107
|
+
// is stored as "refused" (a preflight refusal), not "failed" -- the
|
|
108
|
+
// category is the same either way, so both statuses are recognised here.
|
|
109
|
+
const continuationClosedOf = (c) => (c.status === "failed" || c.status === "refused") && c.failureCategory === CONTINUATION_CLOSED_CATEGORY;
|
|
88
110
|
const providerClasses = new Set(failedChildren.map(providerClassOf).filter((c) => c !== null));
|
|
89
111
|
const serviceSentences = new Set(failedChildren.map(serviceSentenceOf).filter((s) => s !== null));
|
|
90
112
|
const protectedPathFailed = failedChildren.some(protectedPathOf);
|
|
113
|
+
const continuationClosedFailed = failedChildren.some(continuationClosedOf);
|
|
91
114
|
const other = failedChildren.filter((c) => c.status !== "budget_exhausted" &&
|
|
92
115
|
providerClassOf(c) === null &&
|
|
93
116
|
serviceSentenceOf(c) === null &&
|
|
94
|
-
!protectedPathOf(c)
|
|
117
|
+
!protectedPathOf(c) &&
|
|
118
|
+
!continuationClosedOf(c));
|
|
95
119
|
const lines = [];
|
|
96
120
|
if (outOfBudget.length > 0) {
|
|
97
121
|
lines.push(`A sub-run ran out of budget: ${outOfBudget.map((c) => `\`${c.id}\``).join(", ")}.`);
|
|
@@ -106,11 +130,18 @@ export function outcomeBody(run, repository, pullRequests, failedChildren = [],
|
|
|
106
130
|
}
|
|
107
131
|
if (protectedPathFailed)
|
|
108
132
|
lines.push(PROTECTED_PATH_HOST_LINE);
|
|
133
|
+
if (continuationClosedFailed)
|
|
134
|
+
lines.push(CONTINUATION_CLOSED_HOST_LINE);
|
|
109
135
|
if (other.length > 0) {
|
|
110
136
|
lines.push(`A sub-run did not succeed: ${other.map((c) => `\`${c.id}\` (\`${c.status}\`)`).join(", ")}.`);
|
|
111
137
|
}
|
|
112
138
|
return `❌ ${lines.join(" ")}${partial}${quoted}\n\n${footer}`;
|
|
113
139
|
}
|
|
140
|
+
const failedChecks = pullRequests.reduce((sum, pr) => sum + (pr.failedChecks ?? 0), 0);
|
|
141
|
+
if (links.length > 0 && failedChecks > 0) {
|
|
142
|
+
const checks = failedChecks === 1 ? "1 check" : `${failedChecks} checks`;
|
|
143
|
+
return `⚠️ ${links.join(", ")}, but ${checks} failed in the run: see the pull request's description before merging.\n\n${footer}`;
|
|
144
|
+
}
|
|
114
145
|
if (links.length > 0)
|
|
115
146
|
return `✅ ${links.join(", ")}.\n\n${footer}`;
|
|
116
147
|
if (opts.replyAsAnswer && reply)
|
|
@@ -199,14 +230,14 @@ export async function completeHostStatus(db, run, hosts, opts = {}) {
|
|
|
199
230
|
* once. Does nothing when the row is gone, already has a comment, or is
|
|
200
231
|
* already complete. Never throws.
|
|
201
232
|
*/
|
|
202
|
-
export async function postMentionStatus(db, host, runId, hosts) {
|
|
233
|
+
export async function postMentionStatus(db, host, runId, hosts, heading) {
|
|
203
234
|
try {
|
|
204
235
|
const status = await db.runHostStatus.findUnique({ where: { runId } });
|
|
205
236
|
if (!status || status.commentId || status.completedAt)
|
|
206
237
|
return;
|
|
207
238
|
const posted = await host.comment(status.repository, {
|
|
208
239
|
number: status.number,
|
|
209
|
-
body: workingBody(runId),
|
|
240
|
+
body: workingBody(runId, heading),
|
|
210
241
|
...(status.replyToReviewCommentId ? { replyToReviewCommentId: status.replyToReviewCommentId } : {}),
|
|
211
242
|
});
|
|
212
243
|
const claimed = await db.runHostStatus.updateMany({
|
|
@@ -11,6 +11,7 @@ import type { PrismaClient } from "#prisma";
|
|
|
11
11
|
import type { Executor } from "../providers/executor/types.js";
|
|
12
12
|
import type { IssueEvent, IssueEventKind, IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
|
|
13
13
|
import { type DispatchDb } from "./dispatch.js";
|
|
14
|
+
import { type OpenSibling } from "./related-pull-requests.js";
|
|
14
15
|
export type IssueEventDb = DispatchDb & Pick<PrismaClient, "agentIssueProject" | "runIssueStatus" | "issuePullRequest" | "workItem">;
|
|
15
16
|
export interface RouteIssueEventDeps {
|
|
16
17
|
db: IssueEventDb;
|
|
@@ -21,12 +22,7 @@ export interface RouteResult {
|
|
|
21
22
|
runIds: string[];
|
|
22
23
|
followUps: Array<() => Promise<void>>;
|
|
23
24
|
}
|
|
24
|
-
export
|
|
25
|
-
repository: string;
|
|
26
|
-
number: number;
|
|
27
|
-
url: string;
|
|
28
|
-
openedByRunId: string;
|
|
29
|
-
}
|
|
25
|
+
export type OpenIssuePr = OpenSibling;
|
|
30
26
|
type TaskLink = {
|
|
31
27
|
triggerLabels: string[];
|
|
32
28
|
trustedAccountIds: string[];
|