@nanobpm/nano-workforce 0.194.0 → 0.195.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/CHANGELOG.md +12 -0
- package/README.md +6 -0
- package/SPEC.md +19 -1
- package/app/agentCompletion.ts +5 -1
- package/app/contracts.ts +1 -1
- package/app/convergeGate.test.ts +9 -2
- package/app/convergenceStaleRoundReset.test.ts +1 -1
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +8 -0
- package/app/featureDelivery.test.ts +21 -0
- package/app/fineGrainedCells.test.ts +1 -1
- package/app/mcpToolSurface.ts +6 -1
- package/app/mergeApproval.test.ts +126 -0
- package/app/repoEnvelope.ts +12 -3
- package/app/service.test.ts +198 -3
- package/app/service.ts +55 -7
- package/app/userTasks.ts +7 -0
- package/db/migrations/116_feature_runs_human_approval.sql +51 -0
- package/e2e/feature-run.e2e.ts +18 -0
- package/openapi.yaml +15 -0
- package/operations/startFeature.readiness.integration.test.ts +14 -0
- package/operations/startFeature.ts +4 -0
- package/package.json +1 -1
- package/pages/feature.page.json +1 -0
- package/resources/forms/merge-approval.form +36 -0
- package/resources/processes/convergence-loop.bpmn +216 -132
- package/resources/processes/feature.bpmn +2 -0
- package/resources/prompts/review-round.md +4 -3
- package/test/derivation-parity/README.md +4 -3
- package/test/derivation-parity/derivation-parity.test.ts +7 -7
- package/test/derivation-parity/flows.ts +6 -5
- package/workers/converge-feature/worker.ts +3 -1
package/app/service.test.ts
CHANGED
|
@@ -876,6 +876,111 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
|
|
|
876
876
|
});
|
|
877
877
|
});
|
|
878
878
|
|
|
879
|
+
test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
|
|
880
|
+
await withGithubOff(async () => {
|
|
881
|
+
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
|
882
|
+
const merging = captureVars();
|
|
883
|
+
await submitPr(merging.data, merging.engine, pr(11), [], 20, false, null, true);
|
|
884
|
+
assertEquals(merging.get()?.humanApproval, true);
|
|
885
|
+
const convergeOnly = captureVars();
|
|
886
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr(12), [], 20, true, null, true);
|
|
887
|
+
assertEquals(convergeOnly.get()?.humanApproval, false);
|
|
888
|
+
const unset = captureVars();
|
|
889
|
+
await submitPr(unset.data, unset.engine, pr(13));
|
|
890
|
+
assertEquals(unset.get()?.humanApproval, false);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Adoption (issue #826, Copilot review): `submitPr` returns `alreadyRunning` for a PR whose loop is
|
|
895
|
+
// live, so a gated caller adopting an UNGATED loop would inherit its auto-merge. `humanApproval` is
|
|
896
|
+
// monotonic: the gated caller narrows the live loop (idempotent, so a retry against its own gated loop
|
|
897
|
+
// is a no-op). A PR already finalized (`converged_at` set) is past its approval point: `startMerge`
|
|
898
|
+
// re-pointed its `process_key` at the LIVE merge-loop, which must never be written to.
|
|
899
|
+
function adoptLoop(status: string, loopState: string, convergedAt: string | null = null) {
|
|
900
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
901
|
+
pull_requests: {
|
|
902
|
+
rows: [
|
|
903
|
+
{
|
|
904
|
+
pr_key: "owner/repo#14",
|
|
905
|
+
repo: "owner/repo",
|
|
906
|
+
number: 14,
|
|
907
|
+
url: "https://github.com/owner/repo/pull/14",
|
|
908
|
+
title: "t",
|
|
909
|
+
status,
|
|
910
|
+
current_round: 1,
|
|
911
|
+
process_key: "PI-LOOP",
|
|
912
|
+
converged_at: convergedAt,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
key: "pr_key",
|
|
916
|
+
},
|
|
917
|
+
escalations: { rows: [], key: "id" },
|
|
918
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
919
|
+
};
|
|
920
|
+
const data = {
|
|
921
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
922
|
+
open: () => memOpen(stores),
|
|
923
|
+
} as any;
|
|
924
|
+
const narrowed: unknown[] = [];
|
|
925
|
+
const engine = {
|
|
926
|
+
createInstance: () => Promise.reject(new Error("a live PR is adopted, never re-created")),
|
|
927
|
+
searchProcessInstances: () => Promise.resolve([{ processInstanceKey: "PI-LOOP", state: loopState }]),
|
|
928
|
+
setVariables: (req: unknown) => {
|
|
929
|
+
narrowed.push(req);
|
|
930
|
+
return Promise.resolve();
|
|
931
|
+
},
|
|
932
|
+
} as any;
|
|
933
|
+
return { data, engine, narrowed };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
|
|
937
|
+
await withGithubOff(async () => {
|
|
938
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
939
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
940
|
+
assertEquals(await submitPr(live.data, live.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
941
|
+
assertEquals(live.narrowed, [{ scopeKey: "PI-LOOP", variables: { humanApproval: true } }]);
|
|
942
|
+
|
|
943
|
+
const ungated = adoptLoop("converging", "ACTIVE");
|
|
944
|
+
await submitPr(ungated.data, ungated.engine, pr);
|
|
945
|
+
assertEquals(ungated.narrowed, [], "an ungated adopter leaves the loop's mode alone");
|
|
946
|
+
|
|
947
|
+
const convergeOnly = adoptLoop("converging", "ACTIVE");
|
|
948
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr, [], 20, true, null, true);
|
|
949
|
+
assertEquals(convergeOnly.narrowed, [], "a converge-only adopter never merges, so never gates");
|
|
950
|
+
|
|
951
|
+
const merging = adoptLoop("waiting_deps", "ACTIVE", "2026-01-01T00:00:00.000Z");
|
|
952
|
+
assertEquals(await submitPr(merging.data, merging.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
953
|
+
assertEquals(merging.narrowed, [], "the live merge-loop of a finalized PR is never written to");
|
|
954
|
+
|
|
955
|
+
const ended = adoptLoop("converging", "COMPLETED");
|
|
956
|
+
await submitPr(ended.data, ended.engine, pr, [], 20, false, null, true);
|
|
957
|
+
assertEquals(ended.narrowed, [], "a loop the engine no longer reports running is left alone");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
|
|
962
|
+
// `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
|
|
963
|
+
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
|
964
|
+
process.env["NANO_PR_AUTO_MERGE"] = "0";
|
|
965
|
+
try {
|
|
966
|
+
const mod: typeof import("./service.ts") = await import(`./service.ts?autoMergeOff=${Date.now()}`);
|
|
967
|
+
await withGithubOff(async () => {
|
|
968
|
+
const fresh = captureVars();
|
|
969
|
+
const pr15 = { repo: "owner/repo", number: 15, url: "https://github.com/owner/repo/pull/15", prKey: "owner/repo#15" };
|
|
970
|
+
await mod.submitPr(fresh.data, fresh.engine, pr15, [], 20, false, null, true);
|
|
971
|
+
assertEquals(fresh.get()?.humanApproval, false, "no approval task for a merge that never happens");
|
|
972
|
+
|
|
973
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
974
|
+
const pr14 = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
975
|
+
await mod.submitPr(live.data, live.engine, pr14, [], 20, false, null, true);
|
|
976
|
+
assertEquals(live.narrowed, [], "nor is an adopted loop narrowed");
|
|
977
|
+
});
|
|
978
|
+
} finally {
|
|
979
|
+
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
980
|
+
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
879
984
|
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
880
985
|
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
881
986
|
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
|
@@ -1121,8 +1226,10 @@ test("repoEnvelopeVars emits the push-checkpoint under the harness-read `sha` ke
|
|
|
1121
1226
|
|
|
1122
1227
|
// Pre-PR provisioning (issue #684): the implementation path has no head branch yet, so it passes
|
|
1123
1228
|
// `ref = base` + a `branchCreate` so the harness clones the base and cuts the deterministic
|
|
1124
|
-
// `feat/<task.id>` feature branch off it. `branch.create` is emitted only for a non-blank branch
|
|
1125
|
-
//
|
|
1229
|
+
// `feat/<task.id>` feature branch off it. `branch.create` is emitted only for a non-blank branch.
|
|
1230
|
+
// The PR-based paths (review-round / fix-ci / rebase) now pass `branchCreate = headRef` too (equal
|
|
1231
|
+
// to `ref`) so the harness commits/pushes to the PR head branch instead of cutting a throwaway
|
|
1232
|
+
// `nano/agent-work/*` fallback (jwulf/c8ctl-plugin-nano#231); see the PR-path test below.
|
|
1126
1233
|
test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
|
|
1127
1234
|
const repo = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"]
|
|
1128
1235
|
.repository;
|
|
@@ -1147,7 +1254,95 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
|
|
|
1147
1254
|
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository, false);
|
|
1148
1255
|
});
|
|
1149
1256
|
|
|
1150
|
-
//
|
|
1257
|
+
// PR-path provisioning (jwulf/c8ctl-plugin-nano#231): the review-round / fix-ci / rebase call sites
|
|
1258
|
+
// pass `branchCreate = headRef` (equal to `ref`, the PR head). Without it the harness's base-branch
|
|
1259
|
+
// guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback,
|
|
1260
|
+
// stranding the round's commits OFF the PR (the head never advances → no-progress escalation). With
|
|
1261
|
+
// `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR
|
|
1262
|
+
// head branch itself.
|
|
1263
|
+
test("repoEnvelopeVars emits branch.create = head on the PR path (ref == create == head) (#231)", () => {
|
|
1264
|
+
const repo = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12") as any)[
|
|
1265
|
+
"io.nanobpm.agentTask"
|
|
1266
|
+
].repository;
|
|
1267
|
+
assertEquals(repo.ref, "feat/issue-12", "the PR-path envelope checks out the head branch");
|
|
1268
|
+
assertEquals(repo.branch.create, "feat/issue-12", "and asks the harness to work on that same head branch, not a fallback");
|
|
1269
|
+
// Still branch-scoped and blobless, and the base tip stays reachable for the 3-dot diff.
|
|
1270
|
+
assertEquals(repo.singleBranch, true);
|
|
1271
|
+
assertEquals(repo.filter, "blob:none");
|
|
1272
|
+
assertEquals(repo.baseRef, "main");
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// Regression guard for the ACTUAL call sites (Copilot PR #817 review): the helper test above would
|
|
1276
|
+
// still pass if `submitPr`/`startMerge` dropped the 5th `branchCreate` argument, because
|
|
1277
|
+
// `repoEnvelopeVars` accepted it before this change. Drive both PUBLIC entry points with a mocked
|
|
1278
|
+
// PR-head response and assert the seeded `createInstance` envelope carries `branch.create === headRef`
|
|
1279
|
+
// — i.e. the call sites really thread the PR head into the branch-create slot (jwulf/c8ctl-plugin-nano#231).
|
|
1280
|
+
// Both `fetchPrMeta` (submit) and `fetchPrHead` (merge) read the same token-transport pulls endpoint,
|
|
1281
|
+
// so one fetch stub returning `head.ref` covers both.
|
|
1282
|
+
function withGithubHead(headRef: string, baseRef: string, run: () => Promise<void>): Promise<void> {
|
|
1283
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
1284
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
1285
|
+
const prevFetch = globalThis.fetch;
|
|
1286
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
1287
|
+
process.env["GITHUB_TOKEN"] = "t0ken";
|
|
1288
|
+
globalThis.fetch = ((url: string | URL | Request) => {
|
|
1289
|
+
void url;
|
|
1290
|
+
return Promise.resolve(
|
|
1291
|
+
new Response(
|
|
1292
|
+
JSON.stringify({ title: "PR title", body: "", head: { ref: headRef, sha: "HEADSHA" }, base: { ref: baseRef } }),
|
|
1293
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1294
|
+
),
|
|
1295
|
+
);
|
|
1296
|
+
}) as typeof fetch;
|
|
1297
|
+
return run().finally(() => {
|
|
1298
|
+
globalThis.fetch = prevFetch;
|
|
1299
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
1300
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
1301
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
1302
|
+
else delete process.env["GITHUB_TOKEN"];
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
test("submitPr threads the resolved PR head into the envelope branch.create (#231)", async () => {
|
|
1307
|
+
await withGithubHead("feat/issue-12", "main", async () => {
|
|
1308
|
+
const { data, engine, get } = captureVars();
|
|
1309
|
+
await submitPr(data, engine, {
|
|
1310
|
+
repo: "owner/repo",
|
|
1311
|
+
number: 12,
|
|
1312
|
+
url: "https://github.com/owner/repo/pull/12",
|
|
1313
|
+
prKey: "owner/repo#12",
|
|
1314
|
+
});
|
|
1315
|
+
const repo = (get()?.["io.nanobpm.agentTask"] as any)?.repository;
|
|
1316
|
+
assertEquals(repo?.ref, "feat/issue-12", "submitPr checks out the PR head branch");
|
|
1317
|
+
assertEquals(repo?.branch?.create, "feat/issue-12", "submitPr passes headRef as branch.create, not a fallback");
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
test("startMerge threads the resolved PR head into the envelope branch.create (#231)", async () => {
|
|
1322
|
+
await withGithubHead("feat/issue-13", "main", async () => {
|
|
1323
|
+
const { data, engine, get } = captureVars();
|
|
1324
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
1325
|
+
pr_key: "owner/repo#13",
|
|
1326
|
+
repo: "owner/repo",
|
|
1327
|
+
number: 13,
|
|
1328
|
+
url: "https://github.com/owner/repo/pull/13",
|
|
1329
|
+
status: "converged",
|
|
1330
|
+
abandon_token: "tok-13",
|
|
1331
|
+
root_request_key: "owner/repo#13",
|
|
1332
|
+
});
|
|
1333
|
+
await startMerge(data, engine, {
|
|
1334
|
+
repo: "owner/repo",
|
|
1335
|
+
number: 13,
|
|
1336
|
+
url: "https://github.com/owner/repo/pull/13",
|
|
1337
|
+
prKey: "owner/repo#13",
|
|
1338
|
+
round: 2,
|
|
1339
|
+
});
|
|
1340
|
+
const repo = (get()?.["io.nanobpm.agentTask"] as any)?.repository;
|
|
1341
|
+
assertEquals(repo?.ref, "feat/issue-13", "startMerge checks out the PR head branch");
|
|
1342
|
+
assertEquals(repo?.branch?.create, "feat/issue-13", "startMerge passes headRef as branch.create, not a fallback");
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1151
1346
|
// `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
|
|
1152
1347
|
// ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
|
|
1153
1348
|
// so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
|
package/app/service.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
type OpenEscalation,
|
|
108
108
|
PLAN_REVIEW_ELEMENT,
|
|
109
109
|
PR_ESCALATION_PRODUCER_ELEMENTS,
|
|
110
|
+
PR_MERGE_APPROVAL_ELEMENT,
|
|
110
111
|
PR_RECORD_ANSWER_ELEMENT,
|
|
111
112
|
PR_RECORD_MERGE_ANSWER_ELEMENT,
|
|
112
113
|
PR_WAIT_ANSWER_ELEMENT,
|
|
@@ -573,9 +574,10 @@ export async function submitPr(
|
|
|
573
574
|
maxRounds: number = MAX_ROUNDS,
|
|
574
575
|
convergeOnly = false,
|
|
575
576
|
rootRequestKey: string | null = null,
|
|
577
|
+
humanApproval = false,
|
|
576
578
|
) {
|
|
577
579
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
578
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
|
|
580
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
|
|
579
581
|
);
|
|
580
582
|
}
|
|
581
583
|
|
|
@@ -587,7 +589,12 @@ async function submitPrCritical(
|
|
|
587
589
|
maxRounds: number = MAX_ROUNDS,
|
|
588
590
|
convergeOnly = false,
|
|
589
591
|
rootRequestKey: string | null = null,
|
|
592
|
+
humanApproval = false,
|
|
590
593
|
) {
|
|
594
|
+
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
595
|
+
// merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
|
|
596
|
+
// (review-only, or the global switch off) must not park for a pointless approval.
|
|
597
|
+
const gated = humanApproval && !convergeOnly && AUTO_MERGE;
|
|
591
598
|
const table = prs(data);
|
|
592
599
|
const existing = await table.get(parsed.prKey);
|
|
593
600
|
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
@@ -607,6 +614,23 @@ async function submitPrCritical(
|
|
|
607
614
|
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
608
615
|
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
609
616
|
if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
617
|
+
// Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
|
|
618
|
+
// ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
|
|
619
|
+
// Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
|
|
620
|
+
// running is left alone; one already past its `human approval?` gateway can no longer be gated. A
|
|
621
|
+
// finalized PR (`converged_at` set) is past it, and `startMerge` has re-pointed its `process_key` at
|
|
622
|
+
// the merge-loop, so it is skipped. Mid-handoff (key re-pointed, `converged_at` not yet stamped) the
|
|
623
|
+
// write reaches the merge-loop but is inert: nothing there reads `humanApproval`.
|
|
624
|
+
if (gated && existing.converged_at == null) {
|
|
625
|
+
const loopKey = existing.process_key;
|
|
626
|
+
const match = (await engine.searchProcessInstances({ processInstanceKeys: [loopKey] })).find(
|
|
627
|
+
(s) => String(s.processInstanceKey) === loopKey,
|
|
628
|
+
);
|
|
629
|
+
const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
|
|
630
|
+
if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) {
|
|
631
|
+
await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
610
634
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
611
635
|
}
|
|
612
636
|
|
|
@@ -724,6 +748,9 @@ async function submitPrCritical(
|
|
|
724
748
|
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
725
749
|
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
726
750
|
convergeOnly,
|
|
751
|
+
// Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
|
|
752
|
+
// task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
|
|
753
|
+
humanApproval: gated,
|
|
727
754
|
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
728
755
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
729
756
|
abandonUrl: abUrl,
|
|
@@ -731,7 +758,14 @@ async function submitPrCritical(
|
|
|
731
758
|
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
732
759
|
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
733
760
|
// unresolved head (`{}`) leaves the other vars untouched.
|
|
734
|
-
|
|
761
|
+
//
|
|
762
|
+
// Pass `headRef` as `branch.create` too (5th arg): on a PR path `ref` is the head branch, but
|
|
763
|
+
// WITHOUT an explicit `branch.create` the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231)
|
|
764
|
+
// treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback,
|
|
765
|
+
// stranding the round's commits OFF the PR (the head never advances → no-progress escalation).
|
|
766
|
+
// With `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes
|
|
767
|
+
// to the PR head branch itself. Mirrors the delivery-graph node path (`agentNodeRepoEnvelope`, #776).
|
|
768
|
+
...repoEnvelopeVars(parsed.repo, headRef, baseRef, worldSha, headRef),
|
|
735
769
|
},
|
|
736
770
|
});
|
|
737
771
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -892,8 +926,10 @@ export async function startMerge(
|
|
|
892
926
|
abandonUrl: abUrl,
|
|
893
927
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
894
928
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
895
|
-
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
896
|
-
|
|
929
|
+
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch. `headRef` is
|
|
930
|
+
// passed as `branch.create` (5th arg) so the harness commits/pushes to the PR head branch
|
|
931
|
+
// instead of cutting a `nano/agent-work/*` fallback (jwulf/c8ctl-plugin-nano#231); see submitPr.
|
|
932
|
+
...repoEnvelopeVars(pr.repo, headRef, baseRef, worldSha, headRef),
|
|
897
933
|
},
|
|
898
934
|
});
|
|
899
935
|
if (processInstanceKey != null) {
|
|
@@ -2469,13 +2505,24 @@ export async function pollFeatureDelivery(
|
|
|
2469
2505
|
// it would wedge `converging` forever with no PR process.
|
|
2470
2506
|
// Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
|
|
2471
2507
|
// on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
|
|
2472
|
-
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
|
|
2473
|
-
//
|
|
2508
|
+
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly` and
|
|
2509
|
+
// `humanApproval` mirror `converge-feature` — derived from the run's `auto_merge`/`human_approval`.
|
|
2474
2510
|
const partiallyEnrolled =
|
|
2475
2511
|
trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
|
|
2476
2512
|
if (prStatus === null || partiallyEnrolled) {
|
|
2477
2513
|
const parsed = parsePr(run.pr_key);
|
|
2478
|
-
if (parsed)
|
|
2514
|
+
if (parsed) {
|
|
2515
|
+
await submitPr(
|
|
2516
|
+
data,
|
|
2517
|
+
engine,
|
|
2518
|
+
parsed,
|
|
2519
|
+
[],
|
|
2520
|
+
MAX_ROUNDS,
|
|
2521
|
+
run.auto_merge !== 1,
|
|
2522
|
+
run.feature_key,
|
|
2523
|
+
run.human_approval === 1,
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2479
2526
|
}
|
|
2480
2527
|
const { status, label } = deriveFeatureDelivery(prStatus);
|
|
2481
2528
|
if (run.status !== status || run.delivery_label !== label) {
|
|
@@ -3057,6 +3104,7 @@ export async function pollUserTasks(
|
|
|
3057
3104
|
[READINESS_ESCALATION_ELEMENT]: "plan",
|
|
3058
3105
|
[PR_WAIT_ANSWER_ELEMENT]: "pr",
|
|
3059
3106
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
|
|
3107
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "pr",
|
|
3060
3108
|
};
|
|
3061
3109
|
|
|
3062
3110
|
// The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
|
package/app/userTasks.ts
CHANGED
|
@@ -52,6 +52,12 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
|
52
52
|
* canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
|
|
53
53
|
export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
54
54
|
|
|
55
|
+
/** The optional human merge-approval user task (convergence-loop.bpmn, issue #826) — a converged PR
|
|
56
|
+
* parks here before `pr.finalize` hands it to the merge-loop. Approve merges; request changes loops
|
|
57
|
+
* the guidance (`answer`) back to the review agent on the same PR. HUMAN-only: never agent-answerable,
|
|
58
|
+
* so the fleet can never auto-approve a merge. */
|
|
59
|
+
export const PR_MERGE_APPROVAL_ELEMENT = "merge-approval";
|
|
60
|
+
|
|
55
61
|
/** The PR review-loop answer-recording service task (convergence-loop.bpmn) — the `pr.answer-escalation`
|
|
56
62
|
* job the token moves to IMMEDIATELY when `wait-answer` completes. Its ACTIVE element instance is the
|
|
57
63
|
* positive-evidence marker that an operator's answer is in-flight (recorded but the loop hasn't looped
|
|
@@ -237,6 +243,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
237
243
|
[TRIAL_MERGE_ELEMENT]: "Trial merge",
|
|
238
244
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
239
245
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
246
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "PR merge approval",
|
|
240
247
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
241
248
|
[READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
|
|
242
249
|
[READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Issue #826: optional human approval before merge. 1 ⇒ the converged PR parks at the
|
|
2
|
+
-- `merge-approval` user task in convergence-loop.bpmn before the merge-loop runs. Additive (expand).
|
|
3
|
+
ALTER TABLE feature_runs ADD COLUMN human_approval INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
|
|
5
|
+
-- Mirror the new column through the `delivery_units` aggregate so the `feature_runs__units` compat VIEW
|
|
6
|
+
-- stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in app/deliveryUnit.test.ts): the
|
|
7
|
+
-- Feature sync triggers (089) and VIEW (091) are re-created verbatim plus `human_approval`.
|
|
8
|
+
ALTER TABLE delivery_units ADD COLUMN human_approval INTEGER;
|
|
9
|
+
UPDATE delivery_units SET human_approval = 0 WHERE kind = 'feature';
|
|
10
|
+
|
|
11
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
12
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
13
|
+
BEGIN
|
|
14
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
15
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
16
|
+
END;
|
|
17
|
+
|
|
18
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
19
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
20
|
+
BEGIN
|
|
21
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
22
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
23
|
+
END;
|
|
24
|
+
|
|
25
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
26
|
+
CREATE VIEW feature_runs__units AS
|
|
27
|
+
SELECT
|
|
28
|
+
du.legacy_key AS feature_key,
|
|
29
|
+
du.repo AS repo,
|
|
30
|
+
du.issue_number AS issue_number,
|
|
31
|
+
du.issue_url AS issue_url,
|
|
32
|
+
du.base_branch AS base_branch,
|
|
33
|
+
du.status AS status,
|
|
34
|
+
du.process_key AS process_key,
|
|
35
|
+
du.pr_key AS pr_key,
|
|
36
|
+
du.converge AS converge,
|
|
37
|
+
du.auto_merge AS auto_merge,
|
|
38
|
+
du.human_approval AS human_approval,
|
|
39
|
+
du.outcome AS outcome,
|
|
40
|
+
du.created_at AS created_at,
|
|
41
|
+
du.updated_at AS updated_at,
|
|
42
|
+
du.delivery_label AS delivery_label,
|
|
43
|
+
du.title AS title,
|
|
44
|
+
du.acknowledged_at AS acknowledged_at,
|
|
45
|
+
du.stage AS stage,
|
|
46
|
+
du.stage_state AS stage_state,
|
|
47
|
+
du.stage_skipped AS stage_skipped,
|
|
48
|
+
du.attention AS attention,
|
|
49
|
+
du.list_bucket AS list_bucket
|
|
50
|
+
FROM delivery_units du
|
|
51
|
+
WHERE du.kind = 'feature';
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -62,6 +62,7 @@ interface FeatureRow {
|
|
|
62
62
|
}
|
|
63
63
|
interface PrRow {
|
|
64
64
|
pr_key: string;
|
|
65
|
+
process_key: string | null;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
@@ -184,6 +185,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
184
185
|
);
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
test("raise + converge + human approval: the enrolled convergence loop carries the approval gate (issue #826)", async () => {
|
|
189
|
+
await withApp(
|
|
190
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#103", summary: "built it" }) },
|
|
191
|
+
{ baseBranch: "epic/e2e", converge: true, autoMerge: true, humanApproval: true },
|
|
192
|
+
async ({ app }) => {
|
|
193
|
+
// The flag must survive the real handoff: feature.bpmn → converge-cell callActivity →
|
|
194
|
+
// pr.converge-feature → submitPr → the live loop instance (still ACTIVE, so its vars are readable).
|
|
195
|
+
const pr = await app.db.table<PrRow>("pull_requests", "pr_key").findOne({ pr_key: "owner/repo#103" });
|
|
196
|
+
assert.ok(pr?.process_key, "converge enrolled the raised PR into a live convergence loop");
|
|
197
|
+
const instances = app.snapshot().instances;
|
|
198
|
+
assert.ok(Array.isArray(instances), "snapshot.instances is an array of instance rows");
|
|
199
|
+
const loop = instances.find((i) => i?.key === pr.process_key);
|
|
200
|
+
assert.equal(loop?.variables?.humanApproval, true, "the convergence loop was seeded with humanApproval");
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
187
205
|
test("blocked: parks at the operators' user task (non-terminal), never enrolls a PR, settles on ack", async () => {
|
|
188
206
|
await withApp(
|
|
189
207
|
{ "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
|
package/openapi.yaml
CHANGED
|
@@ -3442,6 +3442,12 @@ components:
|
|
|
3442
3442
|
When true (and `converge` is true), drive the merge-loop after convergence only when
|
|
3443
3443
|
`NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
|
|
3444
3444
|
Moot when `converge` is false.
|
|
3445
|
+
humanApproval:
|
|
3446
|
+
type: boolean
|
|
3447
|
+
description: >-
|
|
3448
|
+
When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the
|
|
3449
|
+
merge or request changes; the agent applies them to the same PR and convergence re-runs.
|
|
3450
|
+
Moot when the run does not merge (see `autoMerge`).
|
|
3445
3451
|
confirmDefaultBase:
|
|
3446
3452
|
type: boolean
|
|
3447
3453
|
description: >-
|
|
@@ -3513,6 +3519,9 @@ components:
|
|
|
3513
3519
|
autoMerge:
|
|
3514
3520
|
type: boolean
|
|
3515
3521
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
3522
|
+
humanApproval:
|
|
3523
|
+
type: boolean
|
|
3524
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
3516
3525
|
confirmDefaultBase:
|
|
3517
3526
|
type: boolean
|
|
3518
3527
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6181,6 +6190,9 @@ paths:
|
|
|
6181
6190
|
autoMerge:
|
|
6182
6191
|
type: boolean
|
|
6183
6192
|
description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
|
|
6193
|
+
humanApproval:
|
|
6194
|
+
type: boolean
|
|
6195
|
+
description: When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the merge or request changes; the agent applies them to the same PR and convergence re-runs. Moot when the run does not merge (see `autoMerge`).
|
|
6184
6196
|
confirmDefaultBase:
|
|
6185
6197
|
type: boolean
|
|
6186
6198
|
description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6326,6 +6338,9 @@ paths:
|
|
|
6326
6338
|
autoMerge:
|
|
6327
6339
|
type: boolean
|
|
6328
6340
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
6341
|
+
humanApproval:
|
|
6342
|
+
type: boolean
|
|
6343
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
6329
6344
|
confirmDefaultBase:
|
|
6330
6345
|
type: boolean
|
|
6331
6346
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -193,3 +193,17 @@ test("malformed readiness descriptor → 400 at the edge (never a 500)", async (
|
|
|
193
193
|
assertEquals(started.length, 0);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
// ── human approval before merge (issue #826): threaded through the edge, pinned off without autoMerge ─
|
|
198
|
+
|
|
199
|
+
test("humanApproval reaches the run only together with autoMerge", async () => {
|
|
200
|
+
await withGithub(REPO, async () => {
|
|
201
|
+
for (const [autoMerge, expected] of [[true, true], [false, false]] as const) {
|
|
202
|
+
const { app, started } = makeApp();
|
|
203
|
+
const body = { issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge, humanApproval: true };
|
|
204
|
+
const res = (await startFeature(input(body), app)) as any;
|
|
205
|
+
assertEquals(res.status, 202);
|
|
206
|
+
assertEquals(started[0].variables?.humanApproval, expected);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -116,6 +116,8 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
116
116
|
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
117
117
|
// the persisted row and the process variable can't disagree.
|
|
118
118
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
119
|
+
// Human approval gates the merge-loop, so it is moot (pinned off) without auto-merge (issue #826).
|
|
120
|
+
const humanApproval = autoMerge && "humanApproval" in body && body.humanApproval === true;
|
|
119
121
|
// Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
|
|
120
122
|
// normalized to null downstream (startFeature) so it never appends an empty instruction block.
|
|
121
123
|
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
@@ -176,12 +178,14 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
176
178
|
autoMerge,
|
|
177
179
|
customInstructions,
|
|
178
180
|
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
|
|
181
|
+
humanApproval,
|
|
179
182
|
);
|
|
180
183
|
app.log.info("feature run intake", {
|
|
181
184
|
featureKey: parsed.planKey,
|
|
182
185
|
requestedBaseBranch: normalizedBase,
|
|
183
186
|
converge,
|
|
184
187
|
autoMerge,
|
|
188
|
+
humanApproval,
|
|
185
189
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
186
190
|
readinessProbes: readiness.probes.length,
|
|
187
191
|
outcome: result.outcome,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/feature.page.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
91
91
|
{ "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
|
|
92
92
|
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
93
|
+
{ "key": "humanApproval", "label": "Human approval before merge \u2014 a converged PR waits in Tasks for you to approve the merge or request changes (only applies when Auto-merge is on)", "type": "checkbox" },
|
|
93
94
|
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
94
95
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
|
95
96
|
{ "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "merge-approval",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"label": "Approve merge",
|
|
9
|
+
"text": "This PR passed review convergence. Approve it to hand it to the merge loop, or request changes: your guidance goes to the review agent, which updates this same PR. Convergence then re-runs and approval is asked again."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "select",
|
|
13
|
+
"key": "mergeDecision",
|
|
14
|
+
"label": "Decision",
|
|
15
|
+
"values": [
|
|
16
|
+
{ "label": "Approve — merge the PR", "value": "approve" },
|
|
17
|
+
{ "label": "Request changes — send guidance to the agent", "value": "revise" }
|
|
18
|
+
],
|
|
19
|
+
"validate": {
|
|
20
|
+
"required": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "textarea",
|
|
25
|
+
"key": "answer",
|
|
26
|
+
"label": "Guidance for the agent",
|
|
27
|
+
"description": "What should change before this PR merges? The agent implements it on the same PR.",
|
|
28
|
+
"validate": {
|
|
29
|
+
"required": true
|
|
30
|
+
},
|
|
31
|
+
"conditional": {
|
|
32
|
+
"hide": "=mergeDecision != \"revise\""
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|