agent-relay-server 0.38.0 → 0.40.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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{activity-ClpDglG8.js → activity-DAz3DcDA.js} +2 -2
- package/public/assets/{activity-ClpDglG8.js.map → activity-DAz3DcDA.js.map} +1 -1
- package/public/assets/{agents-CHmEJvqV.js → agents-BJ7qRWxt.js} +2 -2
- package/public/assets/{agents-CHmEJvqV.js.map → agents-BJ7qRWxt.js.map} +1 -1
- package/public/assets/{analytics-2kTjXIj1.js → analytics-MNCS2qnT.js} +2 -2
- package/public/assets/{analytics-2kTjXIj1.js.map → analytics-MNCS2qnT.js.map} +1 -1
- package/public/assets/{automation-B5U_g-1P.js → automation-COp2Kb3h.js} +2 -2
- package/public/assets/{automation-B5U_g-1P.js.map → automation-COp2Kb3h.js.map} +1 -1
- package/public/assets/chat-D_O8VqMR.js +2 -0
- package/public/assets/chat-D_O8VqMR.js.map +1 -0
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-tmf8IBfr.js → formatted-body-impl-BbMHqkCy.js} +2 -2
- package/public/assets/{formatted-body-impl-tmf8IBfr.js.map → formatted-body-impl-BbMHqkCy.js.map} +1 -1
- package/public/assets/{index-B1QUkb_O.js → index-DgJOEApM.js} +5 -5
- package/public/assets/{index-B1QUkb_O.js.map → index-DgJOEApM.js.map} +1 -1
- package/public/assets/{index-Bins8N_5.css → index-Dop-uXiy.css} +1 -1
- package/public/assets/{maintenance-Tn23oWBF.js → maintenance-BHv90kXZ.js} +2 -2
- package/public/assets/{maintenance-Tn23oWBF.js.map → maintenance-BHv90kXZ.js.map} +1 -1
- package/public/assets/managed-agents-BAlvkxfo.js +2 -0
- package/public/assets/managed-agents-BAlvkxfo.js.map +1 -0
- package/public/assets/{markdown-preview-impl-D4UIjB3I.js → markdown-preview-impl-6k_FWjmd.js} +2 -2
- package/public/assets/{markdown-preview-impl-D4UIjB3I.js.map → markdown-preview-impl-6k_FWjmd.js.map} +1 -1
- package/public/assets/{memory-SVCob0fo.js → memory-DBjqdVpg.js} +2 -2
- package/public/assets/{memory-SVCob0fo.js.map → memory-DBjqdVpg.js.map} +1 -1
- package/public/assets/{messages-CHK24Uxx.js → messages-axoaJY2N.js} +2 -2
- package/public/assets/{messages-CHK24Uxx.js.map → messages-axoaJY2N.js.map} +1 -1
- package/public/assets/{orchestrators-CQcJb6VE.js → orchestrators-DMfHZ44H.js} +2 -2
- package/public/assets/{orchestrators-CQcJb6VE.js.map → orchestrators-DMfHZ44H.js.map} +1 -1
- package/public/assets/{overview-DbyX7k-7.js → overview-DqxYWpUT.js} +2 -2
- package/public/assets/{overview-DbyX7k-7.js.map → overview-DqxYWpUT.js.map} +1 -1
- package/public/assets/{pairs-CaL0_ZfW.js → pairs-D3wnzC1V.js} +2 -2
- package/public/assets/{pairs-CaL0_ZfW.js.map → pairs-D3wnzC1V.js.map} +1 -1
- package/public/assets/{security-BogsfkbT.js → security-KWrb7PKj.js} +2 -2
- package/public/assets/{security-BogsfkbT.js.map → security-KWrb7PKj.js.map} +1 -1
- package/public/assets/{settings-BOsnUh5f.js → settings-PXMEzNAm.js} +2 -2
- package/public/assets/{settings-BOsnUh5f.js.map → settings-PXMEzNAm.js.map} +1 -1
- package/public/assets/{tasks-CCxQovOv.js → tasks-CFYShBCz.js} +2 -2
- package/public/assets/{tasks-CCxQovOv.js.map → tasks-CFYShBCz.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BDikdsxs.js → terminal-viewer-impl-BwPYZlWI.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BDikdsxs.js.map → terminal-viewer-impl-BwPYZlWI.js.map} +1 -1
- package/public/assets/{work-queue-fM-tu0iP.js → work-queue-VQ_6QDJc.js} +2 -2
- package/public/assets/{work-queue-fM-tu0iP.js.map → work-queue-VQ_6QDJc.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/src/adapter.ts +5 -1
- package/src/config-store.ts +1 -1
- package/src/db/messages.ts +36 -2
- package/src/db/migrations.ts +5 -0
- package/src/lifecycle-manager.ts +74 -6
- package/src/maintenance.ts +20 -27
- package/src/reviewer-pipeline.ts +197 -0
- package/src/routes/commands.ts +28 -0
- package/src/services/managed-running.ts +1 -0
- package/src/services/send-message.ts +4 -0
- package/src/workspace-pr-completion.ts +121 -0
- package/public/assets/chat-zPXWB-03.js +0 -2
- package/public/assets/chat-zPXWB-03.js.map +0 -1
- package/public/assets/managed-agents-CasacvJX.js +0 -2
- package/public/assets/managed-agents-CasacvJX.js.map +0 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { planSend } from "./agent-ref";
|
|
3
|
+
import { createCommand } from "./commands-db";
|
|
4
|
+
import { getSpawnPolicy } from "./config-store";
|
|
5
|
+
import { getLifecycleManager } from "./lifecycle-manager";
|
|
6
|
+
import { notifySystemMessage } from "./notify";
|
|
7
|
+
import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
|
|
8
|
+
import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
9
|
+
|
|
10
|
+
type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
|
|
11
|
+
|
|
12
|
+
export function parsePrNumber(prUrl: string | undefined): number | undefined {
|
|
13
|
+
if (!prUrl) return undefined;
|
|
14
|
+
const match = prUrl.match(/\/pull\/(\d+)(?:\D|$)/);
|
|
15
|
+
if (!match) return undefined;
|
|
16
|
+
const num = Number(match[1]);
|
|
17
|
+
return Number.isSafeInteger(num) && num > 0 ? num : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function text(value: unknown): string | undefined {
|
|
21
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function reviewerTarget(reviewer: string): { target: string; policyName?: string } {
|
|
25
|
+
if (reviewer.startsWith("policy:")) {
|
|
26
|
+
const policyName = reviewer.slice("policy:".length).trim();
|
|
27
|
+
return policyName ? { target: reviewer, policyName } : { target: reviewer };
|
|
28
|
+
}
|
|
29
|
+
const policy = getSpawnPolicy(reviewer);
|
|
30
|
+
if (policy) return { target: `policy:${policy.value.name}`, policyName: policy.value.name };
|
|
31
|
+
const plan = planSend(reviewer, listAgents(), { excludeId: "system" });
|
|
32
|
+
if (plan.kind === "direct" || plan.kind === "fanout" || plan.kind === "passthrough") {
|
|
33
|
+
return { target: plan.to };
|
|
34
|
+
}
|
|
35
|
+
return { target: reviewer };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function handleReviewerMergeResult(command: Command, result: Record<string, unknown>): boolean {
|
|
39
|
+
if (result.awaitingApproval !== true || !isRecord(command.params)) return false;
|
|
40
|
+
const reviewer = text(command.params.reviewer);
|
|
41
|
+
if (!reviewer) return false;
|
|
42
|
+
const workspaceId = text(result.workspaceId) ?? command.correlationId;
|
|
43
|
+
if (!workspaceId) return false;
|
|
44
|
+
const workspace = getWorkspace(workspaceId);
|
|
45
|
+
if (!workspace) return false;
|
|
46
|
+
|
|
47
|
+
const prUrl = text(result.prUrl);
|
|
48
|
+
const prNumber = parsePrNumber(prUrl);
|
|
49
|
+
const { target, policyName } = reviewerTarget(reviewer);
|
|
50
|
+
const compareRef = workspace.baseRef && workspace.branch ? `${workspace.baseRef}...${workspace.branch}` : workspace.branch;
|
|
51
|
+
const updated = updateWorkspaceStatus(workspace.id, "review_requested", {
|
|
52
|
+
mergeResult: result,
|
|
53
|
+
mergeCommandId: command.id,
|
|
54
|
+
prUrl,
|
|
55
|
+
prNumber,
|
|
56
|
+
awaitingApproval: true,
|
|
57
|
+
reviewer: target,
|
|
58
|
+
reviewRequestedAt: Date.now(),
|
|
59
|
+
compareRef,
|
|
60
|
+
});
|
|
61
|
+
if (!updated) return false;
|
|
62
|
+
|
|
63
|
+
notifySystemMessage(target, {
|
|
64
|
+
subject: `Review requested: ${workspace.branch ?? workspace.id}`,
|
|
65
|
+
body: [
|
|
66
|
+
`Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
|
|
67
|
+
`Repo: ${workspace.repoRoot}`,
|
|
68
|
+
workspace.branch ? `Branch: ${workspace.branch}` : undefined,
|
|
69
|
+
workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
|
|
70
|
+
compareRef ? `Compare: ${compareRef}` : undefined,
|
|
71
|
+
"Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
|
|
72
|
+
].filter(Boolean).join("\n"),
|
|
73
|
+
payload: {
|
|
74
|
+
kind: "workspace.review-request",
|
|
75
|
+
workspaceId: workspace.id,
|
|
76
|
+
repoRoot: workspace.repoRoot,
|
|
77
|
+
worktreePath: workspace.worktreePath,
|
|
78
|
+
branch: workspace.branch,
|
|
79
|
+
baseRef: workspace.baseRef,
|
|
80
|
+
prUrl,
|
|
81
|
+
prNumber,
|
|
82
|
+
compareRef,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (policyName) getLifecycleManager().onMessageForPolicy(policyName);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isAwaitingReviewerApproval(workspace: WorkspaceRecord): boolean {
|
|
90
|
+
return workspace.status === "review_requested" && workspace.metadata.awaitingApproval === true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function previewReviewDecision(preview: WorkspaceMergePreview): ReviewDecision | undefined {
|
|
94
|
+
const decision = preview.reviewDecision;
|
|
95
|
+
return decision === "APPROVED" || decision === "CHANGES_REQUESTED" || decision === "REVIEW_REQUIRED" ? decision : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function handleReviewerPipelineScan(
|
|
99
|
+
workspace: WorkspaceRecord,
|
|
100
|
+
preview: WorkspaceMergePreview,
|
|
101
|
+
orchestratorAgentId: string,
|
|
102
|
+
): { armCommand?: Command; notifiedOwner?: string; skipped?: string } {
|
|
103
|
+
if (!isAwaitingReviewerApproval(workspace)) return { skipped: "not-awaiting-approval" };
|
|
104
|
+
if (workspaceActiveArm(workspace)) return { skipped: "auto-merge-already-armed" };
|
|
105
|
+
|
|
106
|
+
const decision = previewReviewDecision(preview);
|
|
107
|
+
const prNumber = preview.prNumber ?? (typeof workspace.metadata.prNumber === "number" ? workspace.metadata.prNumber : undefined);
|
|
108
|
+
const prUrl = preview.prUrl ?? text(workspace.metadata.prUrl);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
|
|
111
|
+
if (decision === "APPROVED") {
|
|
112
|
+
if (!prNumber && !prUrl) return { skipped: "missing-pr" };
|
|
113
|
+
const command = createCommand({
|
|
114
|
+
type: "workspace.pr-arm-auto-merge",
|
|
115
|
+
source: "system",
|
|
116
|
+
target: orchestratorAgentId,
|
|
117
|
+
correlationId: workspace.id,
|
|
118
|
+
params: {
|
|
119
|
+
action: "pr-arm-auto-merge",
|
|
120
|
+
workspaceId: workspace.id,
|
|
121
|
+
repoRoot: workspace.repoRoot,
|
|
122
|
+
worktreePath: workspace.worktreePath,
|
|
123
|
+
branch: workspace.branch,
|
|
124
|
+
baseRef: workspace.baseRef,
|
|
125
|
+
prNumber,
|
|
126
|
+
prUrl,
|
|
127
|
+
requestedBy: "reviewer-pipeline",
|
|
128
|
+
requestedAt: now,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
patchWorkspaceMetadata(workspace.id, {
|
|
132
|
+
autoMergeArmed: true,
|
|
133
|
+
autoMergeArmCommandId: command.id,
|
|
134
|
+
autoMergeArmedAt: now,
|
|
135
|
+
lastReviewDecision: decision,
|
|
136
|
+
reviewDecisionAt: now,
|
|
137
|
+
});
|
|
138
|
+
return { armCommand: command };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (decision === "CHANGES_REQUESTED") {
|
|
142
|
+
const last = text(workspace.metadata.lastReviewDecision);
|
|
143
|
+
if (last === "CHANGES_REQUESTED") return { skipped: "changes-requested-already-notified" };
|
|
144
|
+
const notifiedOwner = workspace.ownerAgentId
|
|
145
|
+
? notifyReviewChangesRequested(workspace, preview, prUrl, prNumber)
|
|
146
|
+
: undefined;
|
|
147
|
+
patchWorkspaceMetadata(workspace.id, {
|
|
148
|
+
lastReviewDecision: decision,
|
|
149
|
+
reviewDecisionAt: now,
|
|
150
|
+
changesRequestedNotifiedAt: now,
|
|
151
|
+
changesRequestedPrUrl: prUrl,
|
|
152
|
+
changesRequestedPrNumber: prNumber,
|
|
153
|
+
});
|
|
154
|
+
return { notifiedOwner };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (decision && workspace.metadata.lastReviewDecision !== decision) {
|
|
158
|
+
patchWorkspaceMetadata(workspace.id, { lastReviewDecision: decision, reviewDecisionAt: now });
|
|
159
|
+
}
|
|
160
|
+
return { skipped: decision ?? "review-not-ready" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function workspaceActiveArm(workspace: WorkspaceRecord): boolean {
|
|
164
|
+
return workspace.metadata.autoMergeArmed === true || typeof workspace.metadata.autoMergeArmCommandId === "string";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function notifyReviewChangesRequested(
|
|
168
|
+
workspace: WorkspaceRecord,
|
|
169
|
+
preview: WorkspaceMergePreview,
|
|
170
|
+
prUrl: string | undefined,
|
|
171
|
+
prNumber: number | undefined,
|
|
172
|
+
): string | undefined {
|
|
173
|
+
const target = workspace.ownerAgentId;
|
|
174
|
+
if (!target) return undefined;
|
|
175
|
+
notifySystemMessage(target, {
|
|
176
|
+
subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
|
|
177
|
+
body: [
|
|
178
|
+
`The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
|
|
179
|
+
`Workspace \`${workspace.id}\` remains in review_requested.`,
|
|
180
|
+
preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
|
|
181
|
+
].filter(Boolean).join("\n"),
|
|
182
|
+
payload: {
|
|
183
|
+
kind: "workspace.review-changes-requested",
|
|
184
|
+
workspaceId: workspace.id,
|
|
185
|
+
repoRoot: workspace.repoRoot,
|
|
186
|
+
worktreePath: workspace.worktreePath,
|
|
187
|
+
branch: workspace.branch,
|
|
188
|
+
baseRef: workspace.baseRef,
|
|
189
|
+
prUrl,
|
|
190
|
+
prNumber,
|
|
191
|
+
reviewDecision: preview.reviewDecision,
|
|
192
|
+
statusCheckRollup: preview.statusCheckRollup,
|
|
193
|
+
},
|
|
194
|
+
replyExpected: false,
|
|
195
|
+
});
|
|
196
|
+
return target;
|
|
197
|
+
}
|
package/src/routes/commands.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { commandAuthorizationResource as dispatchCommandAuthorizationResource, d
|
|
|
14
14
|
import { ServiceAuthError } from "../services/errors";
|
|
15
15
|
import { notifyBranchLanded } from "../branch-landed";
|
|
16
16
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
17
|
+
import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
17
18
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
18
19
|
|
|
19
20
|
const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
|
|
@@ -188,6 +189,11 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
188
189
|
releaseMergeLease({ commandId: command.id });
|
|
189
190
|
}
|
|
190
191
|
if (command.status === "succeeded" && isRecord(command.result)) {
|
|
192
|
+
if (handleReviewerMergeResult(command, command.result)) {
|
|
193
|
+
emitCommand(command);
|
|
194
|
+
auditCommandOutcome(command);
|
|
195
|
+
return json(command);
|
|
196
|
+
}
|
|
191
197
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
192
198
|
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
193
199
|
if (workspaceId && resultStatus) {
|
|
@@ -279,6 +285,28 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
279
285
|
}
|
|
280
286
|
}
|
|
281
287
|
}
|
|
288
|
+
if (command.type === "workspace.pr-merge") {
|
|
289
|
+
const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
|
|
290
|
+
const workspaceId = resultWorkspaceId ?? command.correlationId;
|
|
291
|
+
if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.relayMerged === true) {
|
|
292
|
+
patchWorkspaceMetadata(workspaceId, {
|
|
293
|
+
relayMerged: true,
|
|
294
|
+
relayMergeCommandId: command.id,
|
|
295
|
+
relayMergedAt: Date.now(),
|
|
296
|
+
relayMergeResult: command.result,
|
|
297
|
+
relayMergeError: undefined,
|
|
298
|
+
relayMergeFailedAt: undefined,
|
|
299
|
+
});
|
|
300
|
+
} else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
|
|
301
|
+
patchWorkspaceMetadata(workspaceId, {
|
|
302
|
+
relayMerged: false,
|
|
303
|
+
relayMergeCommandId: undefined,
|
|
304
|
+
relayMergeError: command.error ?? "PR merge failed",
|
|
305
|
+
relayMergeFailedAt: Date.now(),
|
|
306
|
+
relayMergeResult: command.result,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
282
310
|
if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
|
|
283
311
|
// Record the outcome on the row without touching status (#51) — observability
|
|
284
312
|
// for the dashboard; the CLI reads the result straight off the command.
|
|
@@ -51,6 +51,7 @@ export function markManagedAgentRunning(
|
|
|
51
51
|
if (!input.policyName || !input.spawnRequestId) return;
|
|
52
52
|
const state = getManagedAgentState(input.policyName);
|
|
53
53
|
if (!state || state.spawnRequestId !== input.spawnRequestId) return;
|
|
54
|
+
if (state.status === "failed") return;
|
|
54
55
|
const now = opts.now ?? (() => Date.now());
|
|
55
56
|
|
|
56
57
|
const fromState = state.status;
|
|
@@ -172,6 +172,10 @@ export function sendMessageService(
|
|
|
172
172
|
} else {
|
|
173
173
|
emitNewMessage(result.message);
|
|
174
174
|
}
|
|
175
|
+
} else if (result.updated) {
|
|
176
|
+
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
177
|
+
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
178
|
+
emitNewMessage(result.message);
|
|
175
179
|
}
|
|
176
180
|
return { message: result.message, created: result.created, receipt };
|
|
177
181
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { createCommand, getCommand } from "./commands-db";
|
|
3
|
+
import { patchWorkspaceMetadata } from "./db";
|
|
4
|
+
import { handleReviewerPipelineScan, isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
5
|
+
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
6
|
+
|
|
7
|
+
const ACTIVE_COMMAND_STATUSES = new Set(["pending", "accepted", "running"]);
|
|
8
|
+
const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
9
|
+
const PASSING_STATES = new Set(["SUCCESS"]);
|
|
10
|
+
const READY_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
|
|
11
|
+
|
|
12
|
+
function relayPrMergeAlreadyHandled(workspace: WorkspaceRecord): boolean {
|
|
13
|
+
const commandId = text(workspace.metadata.relayMergeCommandId);
|
|
14
|
+
if (commandId) {
|
|
15
|
+
const command = getCommand(commandId);
|
|
16
|
+
if (command && ACTIVE_COMMAND_STATUSES.has(command.status)) return true;
|
|
17
|
+
if (command?.status === "succeeded" && command.result?.relayMerged === true) return true;
|
|
18
|
+
}
|
|
19
|
+
return workspace.metadata.relayMerged === true && !commandId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nativeAutoMergeArmed(workspace: WorkspaceRecord): boolean {
|
|
23
|
+
if (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.autoMergeArmed === true) return true;
|
|
24
|
+
const commandId = text(workspace.metadata.autoMergeArmCommandId);
|
|
25
|
+
if (!commandId) return false;
|
|
26
|
+
const command = getCommand(commandId);
|
|
27
|
+
return command?.status === "succeeded" && command.result?.autoMergeArmed === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function prAutoMergePolicy(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): WorkspaceAutoMergePolicy {
|
|
31
|
+
const commandId = text(workspace.metadata.mergeCommandId);
|
|
32
|
+
if (commandId) {
|
|
33
|
+
const command = getCommand(commandId);
|
|
34
|
+
const autoMerge = text(command?.params.autoMerge);
|
|
35
|
+
if (autoMerge === "on-green" || autoMerge === "on-approval" || autoMerge === "manual") return autoMerge;
|
|
36
|
+
}
|
|
37
|
+
if (workspace.metadata.awaitingApproval === true || (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.awaitingApproval === true)) return "on-approval";
|
|
38
|
+
return "on-green";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function prReadyForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): { ok: true; policy: WorkspaceAutoMergePolicy } | { ok: false; reason: string; policy?: WorkspaceAutoMergePolicy } {
|
|
42
|
+
if (preview.strategy !== "pr") return { ok: false, reason: "not-pr-strategy" };
|
|
43
|
+
if (preview.prMerged === true || preview.prState === "merged") return { ok: false, reason: "already-merged" };
|
|
44
|
+
if (!preview.prNumber && !preview.prUrl) return { ok: false, reason: "missing-pr" };
|
|
45
|
+
if (nativeAutoMergeArmed(workspace)) return { ok: false, reason: "native-auto-merge-armed" };
|
|
46
|
+
if (relayPrMergeAlreadyHandled(workspace)) return { ok: false, reason: "relay-merge-already-dispatched" };
|
|
47
|
+
|
|
48
|
+
const policy = prAutoMergePolicy(workspace, preview);
|
|
49
|
+
if (policy === "manual") return { ok: false, reason: "manual", policy };
|
|
50
|
+
if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false, reason: "approval-required", policy };
|
|
51
|
+
if (!prMergeable(preview)) return { ok: false, reason: "not-mergeable", policy };
|
|
52
|
+
if (!statusChecksGreen(preview.statusCheckRollup)) return { ok: false, reason: "checks-not-green", policy };
|
|
53
|
+
return { ok: true, policy };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function preparePrCompletionScan(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string): {
|
|
57
|
+
armCommand?: Command;
|
|
58
|
+
notifiedOwner?: string;
|
|
59
|
+
mergeCommand?: Command;
|
|
60
|
+
waitReason?: string;
|
|
61
|
+
stop?: boolean;
|
|
62
|
+
} {
|
|
63
|
+
if (isAwaitingReviewerApproval(workspace)) {
|
|
64
|
+
const action = handleReviewerPipelineScan(workspace, preview, orchestratorAgentId);
|
|
65
|
+
if (action.armCommand) return { armCommand: action.armCommand, stop: true };
|
|
66
|
+
if (action.notifiedOwner) return { notifiedOwner: action.notifiedOwner, stop: true };
|
|
67
|
+
}
|
|
68
|
+
const relayPr = prReadyForRelayMerge(workspace, preview);
|
|
69
|
+
if (relayPr.ok) return { mergeCommand: dispatchRelayPrMergeCommand(workspace, preview, orchestratorAgentId, relayPr.policy), stop: true };
|
|
70
|
+
return {
|
|
71
|
+
waitReason: preview.strategy === "pr" && relayPr.reason !== "not-pr-strategy" ? relayPr.reason : undefined,
|
|
72
|
+
stop: isAwaitingReviewerApproval(workspace),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dispatchRelayPrMergeCommand(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string, policy: WorkspaceAutoMergePolicy): Command {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const prNumber = preview.prNumber ?? (typeof workspace.metadata.prNumber === "number" ? workspace.metadata.prNumber : undefined);
|
|
79
|
+
const prUrl = preview.prUrl ?? text(workspace.metadata.prUrl);
|
|
80
|
+
const command = createCommand({
|
|
81
|
+
type: "workspace.pr-merge",
|
|
82
|
+
source: "system",
|
|
83
|
+
target: orchestratorAgentId,
|
|
84
|
+
correlationId: workspace.id,
|
|
85
|
+
params: {
|
|
86
|
+
action: "pr-merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath,
|
|
87
|
+
branch: workspace.branch, baseRef: workspace.baseRef, prNumber, prUrl, policy,
|
|
88
|
+
requestedBy: "relay-pr-completion", requestedAt: now,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
patchWorkspaceMetadata(workspace.id, {
|
|
92
|
+
relayMerged: true, relayMergeCommandId: command.id, relayMergeDispatchedAt: now, relayMergePolicy: policy,
|
|
93
|
+
relayMergeError: undefined, relayMergeFailedAt: undefined,
|
|
94
|
+
});
|
|
95
|
+
return command;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function statusChecksGreen(rollup: unknown): boolean {
|
|
99
|
+
if (!Array.isArray(rollup) || rollup.length === 0) return false;
|
|
100
|
+
return rollup.every(statusNodeGreen);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function prMergeable(preview: WorkspaceMergePreview): boolean {
|
|
104
|
+
const mergeable = text(preview.mergeable)?.toUpperCase();
|
|
105
|
+
if (mergeable !== "MERGEABLE") return false;
|
|
106
|
+
const state = text(preview.mergeStateStatus)?.toUpperCase();
|
|
107
|
+
return state ? READY_MERGE_STATES.has(state) : true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusNodeGreen(node: unknown): boolean {
|
|
111
|
+
if (!isRecord(node)) return false;
|
|
112
|
+
const conclusion = text(node.conclusion)?.toUpperCase();
|
|
113
|
+
if (conclusion) return PASSING_CONCLUSIONS.has(conclusion);
|
|
114
|
+
const state = text(node.state)?.toUpperCase();
|
|
115
|
+
if (state) return PASSING_STATES.has(state);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function text(value: unknown): string | undefined {
|
|
120
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
121
|
+
}
|