@wardby/cli 0.4.1 → 0.5.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/.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 +476 -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 +123 -2
- 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 +82 -8
- 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 +25 -1
- 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 +4 -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
|
@@ -2,6 +2,7 @@ import { RESPONSE_PATH_SNAPSHOT_BUDGET, resolveWorkItem } from "./attribution.js
|
|
|
2
2
|
import { dispatchRun } from "./dispatch.js";
|
|
3
3
|
import { issueStatusRow, postIssueWorkingStatus } from "./issue-status.js";
|
|
4
4
|
import { logger } from "./logger.js";
|
|
5
|
+
import { openSiblingsForIssue, SIBLING_GUIDANCE } from "./related-pull-requests.js";
|
|
5
6
|
import { composeTaskOverride } from "./untrusted-content.js";
|
|
6
7
|
const log = logger.child({ module: "issue-events" });
|
|
7
8
|
const MAX_TASK_BODY = 8000;
|
|
@@ -42,11 +43,15 @@ export function issueTaskText(event, matched, issueUrl, link, openPrs = []) {
|
|
|
42
43
|
"Use jira_get_issue to read the issue. Your final answer is posted on this issue for you when you finish, so " +
|
|
43
44
|
"do not also post it with jira_comment; use jira_comment only for other issues or progress notes on long work.",
|
|
44
45
|
];
|
|
45
|
-
// Control-plane data from stored rows (validated
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Control-plane data from stored rows (validated here), never issue text.
|
|
47
|
+
const hinted = openPrs.filter((p) => RUN_ID_RE.test(p.openedByRunId));
|
|
48
|
+
for (const pr of hinted) {
|
|
49
|
+
sections.push(`This issue already has an open pull request wardby opened: ${pr.repository}#${pr.number} ` +
|
|
50
|
+
`(https://github.com/${pr.repository}/pull/${pr.number}). ` +
|
|
48
51
|
`To revise it, delegate with continuePriorRun set to exactly "${pr.openedByRunId}".`);
|
|
49
52
|
}
|
|
53
|
+
if (hinted.length > 0)
|
|
54
|
+
sections.push(SIBLING_GUIDANCE);
|
|
50
55
|
if (matched.includes("mention") && event.comment) {
|
|
51
56
|
sections.push(`Request comment:\n${event.comment.body.slice(0, MAX_TASK_BODY)}`);
|
|
52
57
|
}
|
|
@@ -80,20 +85,6 @@ function matchedKinds(event, link, bot) {
|
|
|
80
85
|
}
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
|
-
async function openPullRequests(db, issueKey, agentId) {
|
|
84
|
-
try {
|
|
85
|
-
return await db.issuePullRequest.findMany({
|
|
86
|
-
where: { issueProvider: "jira", issueKey, agentId, state: "open" },
|
|
87
|
-
orderBy: { createdAt: "desc" },
|
|
88
|
-
take: 3,
|
|
89
|
-
select: { repository: true, number: true, url: true, openedByRunId: true },
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
log.warn({ err, issueKey, agentId }, "open pull requests could not be looked up; continuing without a hint");
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
88
|
const JQL_FILTER_BUDGET = { timeoutMs: 5000, retryOn429: false };
|
|
98
89
|
export async function routeIssueEvent(event, deps) {
|
|
99
90
|
const result = { runIds: [], followUps: [] };
|
|
@@ -108,6 +99,10 @@ export async function routeIssueEvent(event, deps) {
|
|
|
108
99
|
// One snapshot per event, taken lazily on the first dispatch so an event no link matches costs nothing.
|
|
109
100
|
let item;
|
|
110
101
|
const workItem = () => (item ??= resolveWorkItem(deps.db, deps.trackers, event.provider, event.issueKey, RESPONSE_PATH_SNAPSHOT_BUDGET));
|
|
102
|
+
// Likewise the card's open siblings: one lookup per event (every matched
|
|
103
|
+
// link gets the same whole-card hints), not one per link.
|
|
104
|
+
let siblings;
|
|
105
|
+
const openPrs = () => (siblings ??= openSiblingsForIssue(deps.db, { provider: event.provider, key: event.issueKey }));
|
|
111
106
|
for (const link of links) {
|
|
112
107
|
if (link.access !== "write" || !link.agent.ownerId || link.agent.kind !== "native")
|
|
113
108
|
continue;
|
|
@@ -126,7 +121,6 @@ export async function routeIssueEvent(event, deps) {
|
|
|
126
121
|
if (!ok)
|
|
127
122
|
continue;
|
|
128
123
|
}
|
|
129
|
-
const openPrs = await openPullRequests(deps.db, event.issueKey, link.agentId);
|
|
130
124
|
try {
|
|
131
125
|
const dispatched = await dispatchRun({
|
|
132
126
|
db: deps.db,
|
|
@@ -135,7 +129,7 @@ export async function routeIssueEvent(event, deps) {
|
|
|
135
129
|
agentId: link.agentId,
|
|
136
130
|
trigger: "host_event",
|
|
137
131
|
attribution: { source: "issue_event", item: await workItem() },
|
|
138
|
-
taskOverride: issueTaskText(event, matched, tracker.issueUrl(event.issueKey), link, openPrs),
|
|
132
|
+
taskOverride: issueTaskText(event, matched, tracker.issueUrl(event.issueKey), link, await openPrs()),
|
|
139
133
|
afterPersist: async (tx, run) => {
|
|
140
134
|
await tx.runIssueStatus.create({ data: issueStatusRow(event, run.id, link.commentVisibilityRole) });
|
|
141
135
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps IssuePullRequest.state current when a pr_closed webhook was missed
|
|
3
|
+
* (an outage, a redelivery that never came). Each reconciler pass reads a
|
|
4
|
+
* small batch of rows still marked open — least recently checked first —
|
|
5
|
+
* from the code host and settles merged or closed ones through
|
|
6
|
+
* handlePullRequestClosed, the webhook's own path. Sibling continuation hints
|
|
7
|
+
* and the Related pull requests section read this state. Best effort: never throws.
|
|
8
|
+
*/
|
|
9
|
+
import type { PrismaClient } from "#prisma";
|
|
10
|
+
import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
|
|
11
|
+
import type { ReviewHostRegistry } from "../providers/review-host/types.js";
|
|
12
|
+
/** Rows checked per pass (each is one token mint + one GET + one revoke). */
|
|
13
|
+
export declare const PR_STATE_SYNC_BATCH = 10;
|
|
14
|
+
/** A row is re-checked at most this often. */
|
|
15
|
+
export declare const PR_STATE_SYNC_MIN_AGE_MS: number;
|
|
16
|
+
export type PullRequestStateSyncDb = Pick<PrismaClient, "issuePullRequest" | "agentIssueProject">;
|
|
17
|
+
export declare function syncOpenPullRequestStates(db: PullRequestStateSyncDb, hosts: ReviewHostRegistry | undefined, trackers: IssueTrackerRegistry | undefined, now?: Date): Promise<number>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { handlePullRequestClosed } from "./issue-bridge.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const log = logger.child({ module: "pull-request-state-sync" });
|
|
4
|
+
/** Rows checked per pass (each is one token mint + one GET + one revoke). */
|
|
5
|
+
export const PR_STATE_SYNC_BATCH = 10;
|
|
6
|
+
/** A row is re-checked at most this often. */
|
|
7
|
+
export const PR_STATE_SYNC_MIN_AGE_MS = 10 * 60_000;
|
|
8
|
+
export async function syncOpenPullRequestStates(db, hosts, trackers, now = new Date()) {
|
|
9
|
+
// handlePullRequestClosed needs trackers; without them no IssuePullRequest rows are written anyway.
|
|
10
|
+
if (!hosts || !trackers)
|
|
11
|
+
return 0;
|
|
12
|
+
let settled = 0;
|
|
13
|
+
try {
|
|
14
|
+
const rows = await db.issuePullRequest.findMany({
|
|
15
|
+
where: { state: "open", updatedAt: { lt: new Date(now.getTime() - PR_STATE_SYNC_MIN_AGE_MS) } },
|
|
16
|
+
orderBy: { updatedAt: "asc" },
|
|
17
|
+
take: PR_STATE_SYNC_BATCH,
|
|
18
|
+
select: { id: true, codeProvider: true, repository: true, number: true },
|
|
19
|
+
});
|
|
20
|
+
const touch = (id) => db.issuePullRequest
|
|
21
|
+
.updateMany({ where: { id, state: "open" }, data: { updatedAt: now } })
|
|
22
|
+
.catch((err) => log.warn({ err, id }, "could not mark a pull request as checked"));
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
const host = hosts[row.codeProvider];
|
|
25
|
+
if (!host?.pullRequestOrigin)
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
const origin = await host.pullRequestOrigin(row.repository, row.number);
|
|
29
|
+
if (origin.state === "open" && origin.merged !== true) {
|
|
30
|
+
await touch(row.id);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const merged = origin.merged === true;
|
|
34
|
+
await handlePullRequestClosed(db, trackers, {
|
|
35
|
+
codeProvider: row.codeProvider,
|
|
36
|
+
repository: row.repository,
|
|
37
|
+
number: row.number,
|
|
38
|
+
merged,
|
|
39
|
+
});
|
|
40
|
+
settled += 1;
|
|
41
|
+
log.info({ repository: row.repository, number: row.number, merged }, "settled a pull request whose close was missed");
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
log.warn({ err, repository: row.repository, number: row.number }, "could not sync a pull request's state");
|
|
45
|
+
await touch(row.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
log.warn({ err }, "pull request state sync failed");
|
|
51
|
+
}
|
|
52
|
+
return settled;
|
|
53
|
+
}
|
|
@@ -34,11 +34,16 @@
|
|
|
34
34
|
* of those paths instead of patching each. Mention status comments, and the
|
|
35
35
|
* status comments of runs started by Jira issue events, get the same sweep:
|
|
36
36
|
* it also catches a run that ended before its comment was posted.
|
|
37
|
+
*
|
|
38
|
+
* Given host-event deps, each pass also starts reviews deferred until CI
|
|
39
|
+
* finished (waitForCi links) whose CI has not finished in time, and drops
|
|
40
|
+
* deferred reviews that are too old (host-events.ts startDeferredReviews).
|
|
37
41
|
*/
|
|
38
42
|
import type { PrismaClient } from "#prisma";
|
|
39
43
|
import type { Executor } from "../providers/executor/types.js";
|
|
40
44
|
import type { ReviewHostRegistry } from "../providers/review-host/types.js";
|
|
41
45
|
import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
|
|
46
|
+
import { type ReviewStartDeps } from "./host-events.js";
|
|
42
47
|
export type ReconcilerDb = Pick<PrismaClient, "run" | "runHostCheck" | "runHostStatus" | "runIssueStatus" | "agentIssueProject" | "issuePullRequest" | "codingRun" | "agent" | "$transaction" | "$queryRaw">;
|
|
43
48
|
/**
|
|
44
49
|
* How long after a run finishes before its still-open check counts as
|
|
@@ -68,7 +73,9 @@ export declare function closeOrphanedHostChecks(db: Pick<PrismaClient, "runHostC
|
|
|
68
73
|
*/
|
|
69
74
|
export declare function closeOrphanedHostStatuses(db: Pick<PrismaClient, "runHostStatus" | "run">, hosts: ReviewHostRegistry | undefined, now?: Date): Promise<void>;
|
|
70
75
|
/** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
|
|
71
|
-
export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry, issueTrackers?: IssueTrackerRegistry
|
|
76
|
+
export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry, issueTrackers?: IssueTrackerRegistry,
|
|
77
|
+
/** What starting a deferred review needs; absent = no deferred-review sweep. */
|
|
78
|
+
deferredReviews?: ReviewStartDeps): Promise<number>;
|
|
72
79
|
export interface ReconcilerOptions {
|
|
73
80
|
db?: ReconcilerDb;
|
|
74
81
|
intervalMs?: number;
|
|
@@ -78,6 +85,8 @@ export interface ReconcilerOptions {
|
|
|
78
85
|
reviewHosts?: ReviewHostRegistry;
|
|
79
86
|
/** Trackers used to complete issue status comments orphaned the same way; none configured = no sweep. */
|
|
80
87
|
issueTrackers?: IssueTrackerRegistry;
|
|
88
|
+
/** Host-event deps for starting deferred reviews (waitForCi) whose CI never finished; absent = no sweep. */
|
|
89
|
+
deferredReviews?: ReviewStartDeps;
|
|
81
90
|
}
|
|
82
91
|
export interface ReconcilerHandle {
|
|
83
92
|
stop(): void;
|
package/dist/core/reconciler.js
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
* of those paths instead of patching each. Mention status comments, and the
|
|
35
35
|
* status comments of runs started by Jira issue events, get the same sweep:
|
|
36
36
|
* it also catches a run that ended before its comment was posted.
|
|
37
|
+
*
|
|
38
|
+
* Given host-event deps, each pass also starts reviews deferred until CI
|
|
39
|
+
* finished (waitForCi links) whose CI has not finished in time, and drops
|
|
40
|
+
* deferred reviews that are too old (host-events.ts startDeferredReviews).
|
|
37
41
|
*/
|
|
38
42
|
import { HEARTBEAT_TIMEOUT_MS, RECONCILE_INTERVAL_MS } from "./timing.js";
|
|
39
43
|
import { prisma as defaultDb } from "./db.js";
|
|
@@ -42,6 +46,8 @@ import { closeOpenHostCheck } from "./review-host-checks.js";
|
|
|
42
46
|
import { completeHostStatus } from "./host-status.js";
|
|
43
47
|
import { closeOrphanedIssueStatuses } from "./issue-status.js";
|
|
44
48
|
import { fileSelfDefect } from "./self-defects.js";
|
|
49
|
+
import { syncOpenPullRequestStates } from "./pull-request-state-sync.js";
|
|
50
|
+
import { startDeferredReviews } from "./host-events.js";
|
|
45
51
|
const reconcilerLog = logger.child({ module: "reconciler" });
|
|
46
52
|
/** A pass waits this long per self-defect, then moves on; the filing finishes (or logs) in the background. */
|
|
47
53
|
const SELF_DEFECT_WAIT = { waitMs: 2_000 };
|
|
@@ -120,7 +126,9 @@ export async function closeOrphanedHostStatuses(db, hosts, now = new Date()) {
|
|
|
120
126
|
await completeHostStatus(db, run, hosts, { postIfMissing: true });
|
|
121
127
|
}
|
|
122
128
|
/** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
|
|
123
|
-
export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts, issueTrackers
|
|
129
|
+
export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts, issueTrackers,
|
|
130
|
+
/** What starting a deferred review needs; absent = no deferred-review sweep. */
|
|
131
|
+
deferredReviews) {
|
|
124
132
|
const cutoff = new Date(now.getTime() - heartbeatTimeoutMs);
|
|
125
133
|
const stale = {
|
|
126
134
|
executionManaged: true,
|
|
@@ -228,6 +236,11 @@ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = H
|
|
|
228
236
|
await closeOrphanedHostChecks(db, reviewHosts, now);
|
|
229
237
|
await closeOrphanedHostStatuses(db, reviewHosts, now);
|
|
230
238
|
await closeOrphanedIssueStatuses(db, issueTrackers, now);
|
|
239
|
+
// Settle stored pull requests whose merge/close webhook was missed; bounded per pass, never throws.
|
|
240
|
+
await syncOpenPullRequestStates(db, reviewHosts, issueTrackers, now);
|
|
241
|
+
// Start reviews whose head's CI never finished (waitForCi links); bounded per pass, never throws.
|
|
242
|
+
if (deferredReviews)
|
|
243
|
+
await startDeferredReviews(deferredReviews, now);
|
|
231
244
|
return lost;
|
|
232
245
|
}
|
|
233
246
|
export function startReconciler(options = {}) {
|
|
@@ -235,7 +248,7 @@ export function startReconciler(options = {}) {
|
|
|
235
248
|
const intervalMs = options.intervalMs ?? RECONCILE_INTERVAL_MS;
|
|
236
249
|
const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
|
|
237
250
|
const timer = setInterval(() => {
|
|
238
|
-
reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts, options.issueTrackers).catch((err) => {
|
|
251
|
+
reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts, options.issueTrackers, options.deferredReviews).catch((err) => {
|
|
239
252
|
reconcilerLog.error({ err }, "reconcile pass failed");
|
|
240
253
|
});
|
|
241
254
|
}, intervalMs);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull requests that came from the same request as a run: every pull
|
|
3
|
+
* request opened by a coding run in the same top-level run tree (a lead that
|
|
4
|
+
* fanned out to several repositories), plus any recorded for the same tracker
|
|
5
|
+
* issue (IssuePullRequest). Control-plane rows only.
|
|
6
|
+
* Ordered by when each pull request's coding run was dispatched: the lead's
|
|
7
|
+
* delegation order (for a lead with parallelDelegations, the order its
|
|
8
|
+
* concurrent delegations were admitted, ties broken by repository#number).
|
|
9
|
+
* A PR opened while siblings were still running lists only the PRs opened
|
|
10
|
+
* before it; updateRelatedPullRequests rewrites every open PR of the set
|
|
11
|
+
* with the full list when the lead run ends. Continuations join
|
|
12
|
+
* the set in both directions: a follow-up that continued one of the set's
|
|
13
|
+
* pull requests brings the original request's tree, and the original request
|
|
14
|
+
* brings every later follow-up tree that continued one of its pull requests.
|
|
15
|
+
*/
|
|
16
|
+
import { type PrismaClient, type Run } from "#prisma";
|
|
17
|
+
import { type IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
|
|
18
|
+
import type { ReviewHostRegistry } from "../providers/review-host/types.js";
|
|
19
|
+
/** Most pull requests a group holds; the description shows fewer (MAX_RELATED_PULL_REQUESTS). */
|
|
20
|
+
export declare const MAX_RELATED_GROUP = 50;
|
|
21
|
+
export type RelatedPullRequestsDb = Pick<PrismaClient, "run" | "codingRun" | "issuePullRequest" | "$queryRaw">;
|
|
22
|
+
export type StoredPullRequestState = "open" | "merged" | "closed";
|
|
23
|
+
export interface RelatedPullRequest {
|
|
24
|
+
repository: string;
|
|
25
|
+
number: number;
|
|
26
|
+
openedAt: Date;
|
|
27
|
+
/** The coding run that opened it: the continuePriorRun target for a follow-up. */
|
|
28
|
+
openedByRunId: string;
|
|
29
|
+
/** Only when stored (IssuePullRequest.state, kept current by pr_closed); otherwise unknown. */
|
|
30
|
+
state?: StoredPullRequestState;
|
|
31
|
+
}
|
|
32
|
+
export interface RelatedPullRequestGroup {
|
|
33
|
+
pullRequests: RelatedPullRequest[];
|
|
34
|
+
/** The first well-formed issue seen on the group's coding runs. */
|
|
35
|
+
issue?: {
|
|
36
|
+
provider: string;
|
|
37
|
+
key: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export declare function collectRelatedPullRequests(db: RelatedPullRequestsDb, runId: string,
|
|
41
|
+
/**
|
|
42
|
+
* Scopes the issue join (and any tree row explicitly tagged for a
|
|
43
|
+
* different issue) to exactly this issue, instead of every issue key the
|
|
44
|
+
* tree's coding runs happen to carry. Used by openSiblingsForIssue so a
|
|
45
|
+
* tree that touches two cards never leaks the other card's pull requests
|
|
46
|
+
* into this one's continuation hints; omitted by every other caller, which
|
|
47
|
+
* keeps today's "every issue the tree touched" behavior (Decision 2).
|
|
48
|
+
*/
|
|
49
|
+
onlyIssueKey?: {
|
|
50
|
+
provider: string;
|
|
51
|
+
key: string;
|
|
52
|
+
}): Promise<RelatedPullRequestGroup>;
|
|
53
|
+
/**
|
|
54
|
+
* After a native run ends, rewrites the Related pull requests section on
|
|
55
|
+
* every open pull request of its request (control-plane rows only). Best
|
|
56
|
+
* effort: never throws, and never removes a section (a set of one writes
|
|
57
|
+
* nothing). Only PRs the App authored, whose marker names a coding run this
|
|
58
|
+
* deployment recorded in that repository, are edited.
|
|
59
|
+
*/
|
|
60
|
+
export declare function updateRelatedPullRequests(db: RelatedPullRequestsDb, run: Pick<Run, "id">, hosts: ReviewHostRegistry | undefined, trackers: IssueTrackerRegistry | undefined): Promise<void>;
|
|
61
|
+
/** Most open siblings hinted in one task. */
|
|
62
|
+
export declare const MAX_SIBLING_HINTS = 10;
|
|
63
|
+
/** Closes every sibling-hint block (mention and Jira alike). */
|
|
64
|
+
export declare const SIBLING_GUIDANCE: string;
|
|
65
|
+
export interface OpenSibling {
|
|
66
|
+
repository: string;
|
|
67
|
+
number: number;
|
|
68
|
+
openedByRunId: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The group's pull requests a follow-up may continue: stored state "open", or
|
|
72
|
+
* no stored state yet (a same-tree sibling not recorded for an issue), minus
|
|
73
|
+
* `exclude` and malformed run ids. Stored state only: no host call. A hint for
|
|
74
|
+
* a PR that has closed since is refused by the continuation itself (git.ts).
|
|
75
|
+
*/
|
|
76
|
+
export declare function openSiblingsOf(group: RelatedPullRequestGroup, exclude?: {
|
|
77
|
+
repository: string;
|
|
78
|
+
number: number;
|
|
79
|
+
}): OpenSibling[];
|
|
80
|
+
/** Open siblings of the request `seedRunId` belongs to. Never throws. */
|
|
81
|
+
export declare function openSiblings(db: RelatedPullRequestsDb, seedRunId: string, exclude: {
|
|
82
|
+
repository: string;
|
|
83
|
+
number: number;
|
|
84
|
+
}): Promise<OpenSibling[]>;
|
|
85
|
+
/**
|
|
86
|
+
* Open pull requests of a whole card: seeded from the newest pull request
|
|
87
|
+
* recorded for the issue (any agent), then the collector is scoped to this
|
|
88
|
+
* issue alone (never "every issue the tree touched") so a tree that happens
|
|
89
|
+
* to also carry another card's coding runs can't leak that card's pull
|
|
90
|
+
* requests into this one's hints. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export declare function openSiblingsForIssue(db: RelatedPullRequestsDb, issue: {
|
|
93
|
+
provider: string;
|
|
94
|
+
key: string;
|
|
95
|
+
}): Promise<OpenSibling[]>;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull requests that came from the same request as a run: every pull
|
|
3
|
+
* request opened by a coding run in the same top-level run tree (a lead that
|
|
4
|
+
* fanned out to several repositories), plus any recorded for the same tracker
|
|
5
|
+
* issue (IssuePullRequest). Control-plane rows only.
|
|
6
|
+
* Ordered by when each pull request's coding run was dispatched: the lead's
|
|
7
|
+
* delegation order (for a lead with parallelDelegations, the order its
|
|
8
|
+
* concurrent delegations were admitted, ties broken by repository#number).
|
|
9
|
+
* A PR opened while siblings were still running lists only the PRs opened
|
|
10
|
+
* before it; updateRelatedPullRequests rewrites every open PR of the set
|
|
11
|
+
* with the full list when the lead run ends. Continuations join
|
|
12
|
+
* the set in both directions: a follow-up that continued one of the set's
|
|
13
|
+
* pull requests brings the original request's tree, and the original request
|
|
14
|
+
* brings every later follow-up tree that continued one of its pull requests.
|
|
15
|
+
*/
|
|
16
|
+
import { Prisma } from "#prisma";
|
|
17
|
+
import { CODING_CODE_PROVIDER, normalizeGitHubRepository } from "../coding/protocol.js";
|
|
18
|
+
import { ISSUE_KEY, ISSUE_TRACKER_NAMES, } from "../providers/issue-tracker/types.js";
|
|
19
|
+
import { renderRelatedSection } from "../providers/vcs/github.js";
|
|
20
|
+
import { pullRequestOutcome } from "./host-status.js";
|
|
21
|
+
import { logger } from "./logger.js";
|
|
22
|
+
const log = logger.child({ module: "related-pull-requests" });
|
|
23
|
+
/** Most pull requests a group holds; the description shows fewer (MAX_RELATED_PULL_REQUESTS). */
|
|
24
|
+
export const MAX_RELATED_GROUP = 50;
|
|
25
|
+
/** Most coding runs read per tree walk (and per continuation lookup). */
|
|
26
|
+
const MAX_TREE_CODING_RUNS = 200;
|
|
27
|
+
/** Most forward/reverse expansion passes after the seed's own tree walk. */
|
|
28
|
+
const MAX_EXPANSION_PASSES = 4;
|
|
29
|
+
const STORED_STATES = new Set(["open", "merged", "closed"]);
|
|
30
|
+
/** Every coding run in the top-level trees containing `seeds` (walks up parentRunId, then down). */
|
|
31
|
+
async function treeCodingRuns(db, seeds) {
|
|
32
|
+
return db.$queryRaw `
|
|
33
|
+
WITH RECURSIVE up AS (
|
|
34
|
+
SELECT r."id", r."parentRunId" FROM "Run" r WHERE r."id" IN (${Prisma.join(seeds)})
|
|
35
|
+
-- UNION, not UNION ALL: a parentRunId cycle (never written, but not a constraint) can't recurse forever.
|
|
36
|
+
UNION
|
|
37
|
+
SELECT p."id", p."parentRunId" FROM "Run" p JOIN up u ON p."id" = u."parentRunId"
|
|
38
|
+
), down AS (
|
|
39
|
+
SELECT u."id" FROM up u WHERE u."parentRunId" IS NULL
|
|
40
|
+
UNION
|
|
41
|
+
SELECT c."id" FROM "Run" c JOIN down d ON c."parentRunId" = d."id"
|
|
42
|
+
)
|
|
43
|
+
SELECT cr."runId", cr."result", cr."rootCodingRunId", cr."issueProvider", cr."issueKey", r."startedAt"
|
|
44
|
+
FROM "CodingRun" cr
|
|
45
|
+
JOIN down d ON cr."runId" = d."id"
|
|
46
|
+
JOIN "Run" r ON r."id" = cr."runId"
|
|
47
|
+
ORDER BY r."startedAt" ASC, cr."runId" ASC
|
|
48
|
+
LIMIT ${MAX_TREE_CODING_RUNS}`;
|
|
49
|
+
}
|
|
50
|
+
function safeRepository(value) {
|
|
51
|
+
try {
|
|
52
|
+
return normalizeGitHubRepository(value);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function collectRelatedPullRequests(db, runId,
|
|
59
|
+
/**
|
|
60
|
+
* Scopes the issue join (and any tree row explicitly tagged for a
|
|
61
|
+
* different issue) to exactly this issue, instead of every issue key the
|
|
62
|
+
* tree's coding runs happen to carry. Used by openSiblingsForIssue so a
|
|
63
|
+
* tree that touches two cards never leaks the other card's pull requests
|
|
64
|
+
* into this one's continuation hints; omitted by every other caller, which
|
|
65
|
+
* keeps today's "every issue the tree touched" behavior (Decision 2).
|
|
66
|
+
*/
|
|
67
|
+
onlyIssueKey) {
|
|
68
|
+
const rows = await treeCodingRuns(db, [runId]);
|
|
69
|
+
const seen = new Set(rows.map((r) => r.runId));
|
|
70
|
+
// A continuation's pull request belongs to the request that opened it, so
|
|
71
|
+
// the set is closed in both directions: forward (a continuation row walks
|
|
72
|
+
// to its root's tree) and reverse (an opener's later continuations, found
|
|
73
|
+
// through the indexed rootCodingRunId, walk their own trees, which may
|
|
74
|
+
// have opened new pull requests). Repeated until nothing new turns up, at
|
|
75
|
+
// most MAX_EXPANSION_PASSES times, and never past a full group.
|
|
76
|
+
const expandedOpeners = new Set();
|
|
77
|
+
for (let pass = 0; pass < MAX_EXPANSION_PASSES; pass++) {
|
|
78
|
+
const roots = [];
|
|
79
|
+
const openers = [];
|
|
80
|
+
let opened = 0;
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
const outcome = pullRequestOutcome(r.result)?.outcome;
|
|
83
|
+
if (outcome === "pull_request_updated" && r.rootCodingRunId && !seen.has(r.rootCodingRunId)) {
|
|
84
|
+
roots.push(r.rootCodingRunId);
|
|
85
|
+
}
|
|
86
|
+
else if (outcome === "pull_request_opened") {
|
|
87
|
+
opened++;
|
|
88
|
+
if (!expandedOpeners.has(r.runId))
|
|
89
|
+
openers.push(r.runId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (opened >= MAX_RELATED_GROUP)
|
|
93
|
+
break;
|
|
94
|
+
for (const opener of openers)
|
|
95
|
+
expandedOpeners.add(opener);
|
|
96
|
+
const continuations = openers.length > 0
|
|
97
|
+
? await db.codingRun.findMany({
|
|
98
|
+
where: { rootCodingRunId: { in: openers } },
|
|
99
|
+
select: { runId: true },
|
|
100
|
+
orderBy: { runId: "asc" },
|
|
101
|
+
take: MAX_TREE_CODING_RUNS,
|
|
102
|
+
})
|
|
103
|
+
: [];
|
|
104
|
+
const seeds = [...new Set([...roots, ...continuations.map((c) => c.runId)])].filter((id) => !seen.has(id));
|
|
105
|
+
if (seeds.length === 0)
|
|
106
|
+
break;
|
|
107
|
+
let added = 0;
|
|
108
|
+
for (const extra of await treeCodingRuns(db, seeds)) {
|
|
109
|
+
if (!seen.has(extra.runId)) {
|
|
110
|
+
seen.add(extra.runId);
|
|
111
|
+
rows.push(extra);
|
|
112
|
+
added++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (added === 0)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const byKey = new Map();
|
|
119
|
+
const keyOf = (repository, number) => `${repository}#${number}`;
|
|
120
|
+
let issue;
|
|
121
|
+
const issues = new Map();
|
|
122
|
+
for (const r of rows) {
|
|
123
|
+
if (r.issueProvider && r.issueKey && ISSUE_KEY.test(r.issueKey)) {
|
|
124
|
+
const found = { provider: r.issueProvider, key: r.issueKey };
|
|
125
|
+
issue ??= found;
|
|
126
|
+
issues.set(`${found.provider}\0${found.key}`, found);
|
|
127
|
+
}
|
|
128
|
+
const pr = pullRequestOutcome(r.result);
|
|
129
|
+
if (pr?.outcome !== "pull_request_opened")
|
|
130
|
+
continue;
|
|
131
|
+
// A tree row explicitly tagged for a different card never joins this one's set.
|
|
132
|
+
if (onlyIssueKey && r.issueKey && (r.issueProvider !== onlyIssueKey.provider || r.issueKey !== onlyIssueKey.key)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const repository = safeRepository(pr.repository);
|
|
136
|
+
if (repository && !byKey.has(keyOf(repository, pr.pullRequestNumber))) {
|
|
137
|
+
byKey.set(keyOf(repository, pr.pullRequestNumber), {
|
|
138
|
+
repository,
|
|
139
|
+
number: pr.pullRequestNumber,
|
|
140
|
+
openedAt: new Date(r.startedAt),
|
|
141
|
+
openedByRunId: r.runId,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const issueFilter = onlyIssueKey
|
|
146
|
+
? new Map([[`${onlyIssueKey.provider}\0${onlyIssueKey.key}`, onlyIssueKey]])
|
|
147
|
+
: issues;
|
|
148
|
+
if (issueFilter.size > 0) {
|
|
149
|
+
const recorded = await db.issuePullRequest.findMany({
|
|
150
|
+
where: {
|
|
151
|
+
codeProvider: CODING_CODE_PROVIDER,
|
|
152
|
+
OR: [...issueFilter.values()].map((i) => ({ issueProvider: i.provider, issueKey: i.key })),
|
|
153
|
+
},
|
|
154
|
+
select: { repository: true, number: true, createdAt: true, openedByRunId: true, state: true },
|
|
155
|
+
orderBy: { createdAt: "asc" },
|
|
156
|
+
take: MAX_RELATED_GROUP,
|
|
157
|
+
});
|
|
158
|
+
// Every pull request recorded for the issue, merged and closed included (Decision 2).
|
|
159
|
+
for (const r of recorded) {
|
|
160
|
+
const repository = safeRepository(r.repository);
|
|
161
|
+
if (!repository || !Number.isSafeInteger(r.number) || r.number <= 0)
|
|
162
|
+
continue;
|
|
163
|
+
const state = STORED_STATES.has(r.state) ? r.state : undefined;
|
|
164
|
+
const existing = byKey.get(keyOf(repository, r.number));
|
|
165
|
+
if (existing) {
|
|
166
|
+
// The tree's own opener and dispatch time stay; the stored state is added.
|
|
167
|
+
if (state)
|
|
168
|
+
existing.state = state;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
byKey.set(keyOf(repository, r.number), {
|
|
172
|
+
repository,
|
|
173
|
+
number: r.number,
|
|
174
|
+
openedAt: r.createdAt,
|
|
175
|
+
openedByRunId: r.openedByRunId,
|
|
176
|
+
...(state ? { state } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const pullRequests = [...byKey.values()]
|
|
181
|
+
.sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime() ||
|
|
182
|
+
`${a.repository}#${a.number}`.localeCompare(`${b.repository}#${b.number}`))
|
|
183
|
+
.slice(0, MAX_RELATED_GROUP);
|
|
184
|
+
return { pullRequests, ...(issue ? { issue } : {}) };
|
|
185
|
+
}
|
|
186
|
+
function issueFor(group, trackers) {
|
|
187
|
+
if (!group.issue)
|
|
188
|
+
return undefined;
|
|
189
|
+
const provider = group.issue.provider;
|
|
190
|
+
let url;
|
|
191
|
+
try {
|
|
192
|
+
url = trackers?.[provider]?.issueUrl(group.issue.key);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
url = undefined;
|
|
196
|
+
}
|
|
197
|
+
const trackerName = Object.hasOwn(ISSUE_TRACKER_NAMES, provider) ? ISSUE_TRACKER_NAMES[provider] : undefined;
|
|
198
|
+
return { key: group.issue.key, ...(url ? { url } : {}), ...(trackerName ? { trackerName } : {}) };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* After a native run ends, rewrites the Related pull requests section on
|
|
202
|
+
* every open pull request of its request (control-plane rows only). Best
|
|
203
|
+
* effort: never throws, and never removes a section (a set of one writes
|
|
204
|
+
* nothing). Only PRs the App authored, whose marker names a coding run this
|
|
205
|
+
* deployment recorded in that repository, are edited.
|
|
206
|
+
*/
|
|
207
|
+
export async function updateRelatedPullRequests(db, run, hosts, trackers) {
|
|
208
|
+
const host = hosts?.[CODING_CODE_PROVIDER];
|
|
209
|
+
if (!host?.replaceRelatedSection || !host.pullRequestOrigin)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
// A run that delegated nothing has no request set of its own.
|
|
213
|
+
if (!(await db.run.findFirst({ where: { parentRunId: run.id }, select: { id: true } })))
|
|
214
|
+
return;
|
|
215
|
+
const group = await collectRelatedPullRequests(db, run.id);
|
|
216
|
+
if (group.pullRequests.length < 2)
|
|
217
|
+
return;
|
|
218
|
+
const live = [];
|
|
219
|
+
for (const pr of group.pullRequests) {
|
|
220
|
+
// A stored merged/closed state is final and such a PR is never edited: no host read.
|
|
221
|
+
if (pr.state === "merged" || pr.state === "closed") {
|
|
222
|
+
live.push({ repository: pr.repository, number: pr.number, state: pr.state });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const origin = await host.pullRequestOrigin(pr.repository, pr.number);
|
|
227
|
+
const state = origin.merged
|
|
228
|
+
? "merged"
|
|
229
|
+
: origin.state !== "open"
|
|
230
|
+
? "closed"
|
|
231
|
+
: origin.draft
|
|
232
|
+
? "draft"
|
|
233
|
+
: "open";
|
|
234
|
+
let markerRunId;
|
|
235
|
+
if (origin.markerRunId) {
|
|
236
|
+
const opener = await db.codingRun.findUnique({
|
|
237
|
+
where: { runId: origin.markerRunId },
|
|
238
|
+
select: { repository: true },
|
|
239
|
+
});
|
|
240
|
+
if (opener && opener.repository.toLowerCase() === pr.repository)
|
|
241
|
+
markerRunId = origin.markerRunId;
|
|
242
|
+
}
|
|
243
|
+
live.push({ repository: pr.repository, number: pr.number, state, ...(markerRunId ? { markerRunId } : {}) });
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
log.warn({ err, runId: run.id, repository: pr.repository, number: pr.number }, "could not read a related pull request");
|
|
247
|
+
// The stored state (issue-recorded PRs) is better than none; never edited without a live read.
|
|
248
|
+
live.push({ repository: pr.repository, number: pr.number, ...(pr.state ? { state: pr.state } : {}) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const issue = issueFor(group, trackers);
|
|
252
|
+
for (const target of live) {
|
|
253
|
+
if (!target.markerRunId || (target.state !== "open" && target.state !== "draft"))
|
|
254
|
+
continue;
|
|
255
|
+
const block = renderRelatedSection({
|
|
256
|
+
entries: live.map((pr) => ({
|
|
257
|
+
repository: pr.repository,
|
|
258
|
+
number: pr.number,
|
|
259
|
+
...(pr.state ? { state: pr.state } : {}),
|
|
260
|
+
...(pr === target ? { self: true } : {}),
|
|
261
|
+
})),
|
|
262
|
+
...(issue ? { issue } : {}),
|
|
263
|
+
});
|
|
264
|
+
if (!block)
|
|
265
|
+
continue;
|
|
266
|
+
try {
|
|
267
|
+
const outcome = await host.replaceRelatedSection(target.repository, target.number, {
|
|
268
|
+
expectedMarkerRunId: target.markerRunId,
|
|
269
|
+
block,
|
|
270
|
+
});
|
|
271
|
+
log.info({ runId: run.id, repository: target.repository, number: target.number, outcome }, "related pull requests section");
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
log.warn({ err, runId: run.id, repository: target.repository, number: target.number }, "could not update a related pull requests section");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
log.warn({ err, runId: run.id }, "could not update related pull requests");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Most open siblings hinted in one task. */
|
|
283
|
+
export const MAX_SIBLING_HINTS = 10;
|
|
284
|
+
const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
285
|
+
/** Closes every sibling-hint block (mention and Jira alike). */
|
|
286
|
+
export const SIBLING_GUIDANCE = "Continue the pull request you were asked about, and a listed open one only when the change requires it. " +
|
|
287
|
+
"Never open a new pull request in a repository that already has an open pull request listed here. Merged or " +
|
|
288
|
+
"closed pull requests are not listed: a change there needs a new pull request.";
|
|
289
|
+
/**
|
|
290
|
+
* The group's pull requests a follow-up may continue: stored state "open", or
|
|
291
|
+
* no stored state yet (a same-tree sibling not recorded for an issue), minus
|
|
292
|
+
* `exclude` and malformed run ids. Stored state only: no host call. A hint for
|
|
293
|
+
* a PR that has closed since is refused by the continuation itself (git.ts).
|
|
294
|
+
*/
|
|
295
|
+
export function openSiblingsOf(group, exclude) {
|
|
296
|
+
const excluded = exclude ? `${safeRepository(exclude.repository) ?? ""}#${exclude.number}` : "";
|
|
297
|
+
return group.pullRequests
|
|
298
|
+
.filter((pr) => (pr.state === undefined || pr.state === "open") &&
|
|
299
|
+
`${pr.repository}#${pr.number}` !== excluded &&
|
|
300
|
+
SAFE_RUN_ID.test(pr.openedByRunId))
|
|
301
|
+
.slice(0, MAX_SIBLING_HINTS)
|
|
302
|
+
.map((pr) => ({ repository: pr.repository, number: pr.number, openedByRunId: pr.openedByRunId }));
|
|
303
|
+
}
|
|
304
|
+
/** Open siblings of the request `seedRunId` belongs to. Never throws. */
|
|
305
|
+
export async function openSiblings(db, seedRunId, exclude) {
|
|
306
|
+
try {
|
|
307
|
+
return openSiblingsOf(await collectRelatedPullRequests(db, seedRunId), exclude);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
log.warn({ err, seedRunId }, "could not list open sibling pull requests; continuing without hints");
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Open pull requests of a whole card: seeded from the newest pull request
|
|
316
|
+
* recorded for the issue (any agent), then the collector is scoped to this
|
|
317
|
+
* issue alone (never "every issue the tree touched") so a tree that happens
|
|
318
|
+
* to also carry another card's coding runs can't leak that card's pull
|
|
319
|
+
* requests into this one's hints. Never throws.
|
|
320
|
+
*/
|
|
321
|
+
export async function openSiblingsForIssue(db, issue) {
|
|
322
|
+
try {
|
|
323
|
+
const newest = await db.issuePullRequest.findMany({
|
|
324
|
+
where: { issueProvider: issue.provider, issueKey: issue.key, codeProvider: CODING_CODE_PROVIDER },
|
|
325
|
+
orderBy: { createdAt: "desc" },
|
|
326
|
+
take: 1,
|
|
327
|
+
select: { openedByRunId: true },
|
|
328
|
+
});
|
|
329
|
+
const seed = newest[0]?.openedByRunId;
|
|
330
|
+
if (!seed || !SAFE_RUN_ID.test(seed))
|
|
331
|
+
return [];
|
|
332
|
+
return openSiblingsOf(await collectRelatedPullRequests(db, seed, issue));
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
log.warn({ err, issueKey: issue.key }, "could not list the issue's open pull requests; continuing without hints");
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
}
|