@nanobpm/nano-workforce 0.56.0 → 0.58.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
package/app/github.ts
CHANGED
|
@@ -454,6 +454,12 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
|
|
|
454
454
|
return name;
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/** Test-only: drop the memoized default-branch entries so a suite can't leak a warmed cache
|
|
458
|
+
* (which ignores transport/token state on a hit) into a test that expects a cold lookup. */
|
|
459
|
+
export function resetDefaultBranchCache(): void {
|
|
460
|
+
defaultBranchCache.clear();
|
|
461
|
+
}
|
|
462
|
+
|
|
457
463
|
/** Whether a branch has already *landed* — i.e. it is the head of a `MERGED` PR. Returns:
|
|
458
464
|
* • `landed` — a merged PR exists from this branch → the branch is a dead-end target
|
|
459
465
|
* • `open` — an open PR exists from it (still alive)
|
|
@@ -672,3 +678,129 @@ export async function enqueueViaComment(
|
|
|
672
678
|
});
|
|
673
679
|
return r.ok;
|
|
674
680
|
}
|
|
681
|
+
|
|
682
|
+
// ── Epic base-branch admission (ADR 0003, rule 2) ───────────────────────────
|
|
683
|
+
// `ensureBaseBranch` is the create-if-missing primitive that guarantees an epic's integration
|
|
684
|
+
// branch exists BEFORE any task fans out, with an `epic/*` guard so a typo can't silently spawn a
|
|
685
|
+
// wrong-rooted branch. It is idempotent — an existing branch is a NO-OP (the ref is never reset,
|
|
686
|
+
// which would nuke in-flight task PRs stacked on it) — so it is safe to call repeatedly: at
|
|
687
|
+
// admission (fail fast), from the durable `ensure-base-branch` head task, and again on a re-plan.
|
|
688
|
+
|
|
689
|
+
/** Thrown when a base branch that does NOT match the `epic/*` convention is missing. A
|
|
690
|
+
* non-`epic/*` base must already exist — a mistyped name is an operator error, not something to
|
|
691
|
+
* auto-create off the default branch (that would silently produce a wrong-rooted branch). */
|
|
692
|
+
export class BaseBranchMustExistError extends Error {
|
|
693
|
+
readonly branch: string;
|
|
694
|
+
constructor(branch: string) {
|
|
695
|
+
super(
|
|
696
|
+
`base branch "${branch}" does not exist and is not an epic/* branch, so it will not be ` +
|
|
697
|
+
`auto-created — create it first, or use the epic/* convention for an auto-created ` +
|
|
698
|
+
`integration branch`,
|
|
699
|
+
);
|
|
700
|
+
this.name = "BaseBranchMustExistError";
|
|
701
|
+
this.branch = branch;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Whether `branch` matches the auto-creatable `epic/*` convention (migration 019). */
|
|
706
|
+
function isEpicBranch(branch: string): boolean {
|
|
707
|
+
return branch.startsWith("epic/");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
|
|
711
|
+
* (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
|
|
712
|
+
async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
|
|
713
|
+
const apiPath = `repos/${repo}/git/ref/heads/${branch}`;
|
|
714
|
+
if (await useGh()) {
|
|
715
|
+
try {
|
|
716
|
+
const out = await runGh(["api", apiPath]);
|
|
717
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
718
|
+
const j = JSON.parse(out) as { object?: { sha?: string } };
|
|
719
|
+
return j.object?.sha ?? null;
|
|
720
|
+
} catch (err) {
|
|
721
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
722
|
+
if (/\b404\b|not found|no such/i.test(msg)) return null;
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!token) throw new Error(`no GitHub transport available to read ${apiPath}`);
|
|
727
|
+
const r = await fetch(`https://api.github.com/${apiPath}`, {
|
|
728
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
729
|
+
});
|
|
730
|
+
if (r.status === 404) return null;
|
|
731
|
+
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
732
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
733
|
+
const j = (await r.json()) as { object?: { sha?: string } };
|
|
734
|
+
return j.object?.sha ?? null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Create `refs/heads/<branch>` pointing at `sha`. Idempotent: a concurrent create / re-plan
|
|
738
|
+
* that already made the ref (GitHub `422 Reference already exists`) is treated as a no-op.
|
|
739
|
+
* Returns `true` when this call actually created the ref, `false` when it lost the race and the
|
|
740
|
+
* ref already existed (the 422 case) — so the caller can report an honest exists/created outcome. */
|
|
741
|
+
async function createBranchRef(
|
|
742
|
+
repo: string,
|
|
743
|
+
branch: string,
|
|
744
|
+
sha: string,
|
|
745
|
+
token: string,
|
|
746
|
+
): Promise<boolean> {
|
|
747
|
+
const ref = `refs/heads/${branch}`;
|
|
748
|
+
if (await useGh()) {
|
|
749
|
+
try {
|
|
750
|
+
await runGh(["api", `repos/${repo}/git/refs`, "-X", "POST", "-f", `ref=${ref}`, "-f", `sha=${sha}`]);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
753
|
+
if (/\b422\b|already exists/i.test(msg)) return false; // idempotent — someone else created it
|
|
754
|
+
throw err;
|
|
755
|
+
}
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
if (!token) throw new Error(`no GitHub transport available to create ${ref}`);
|
|
759
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/git/refs`, {
|
|
760
|
+
method: "POST",
|
|
761
|
+
headers: {
|
|
762
|
+
authorization: `Bearer ${token}`,
|
|
763
|
+
accept: "application/vnd.github+json",
|
|
764
|
+
"content-type": "application/json",
|
|
765
|
+
},
|
|
766
|
+
body: JSON.stringify({ ref, sha }),
|
|
767
|
+
});
|
|
768
|
+
if (r.ok) return true;
|
|
769
|
+
if (r.status === 422) return false; // reference already exists — idempotent
|
|
770
|
+
throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The outcome of `ensureBaseBranch`: the branch was already present (`exists`, a no-op) or was
|
|
774
|
+
* just created off the default branch HEAD (`created`). */
|
|
775
|
+
export type EnsureBaseBranchResult = "exists" | "created";
|
|
776
|
+
|
|
777
|
+
/** Guarantee the epic base `branch` exists on `repo` (ADR 0003 rule 2), idempotently:
|
|
778
|
+
* • already exists → `"exists"` — NO-OP; the ref is never moved/reset.
|
|
779
|
+
* • missing and matches `epic/*` → create `refs/heads/<branch>` off the default branch HEAD,
|
|
780
|
+
* return `"created"`.
|
|
781
|
+
* • missing and not `epic/*` → throw `BaseBranchMustExistError` (a non-`epic/*` base must
|
|
782
|
+
* pre-exist; a typo must fail fast, not silently spawn a wrong-rooted branch).
|
|
783
|
+
* Safe to call repeatedly (at admission AND as the durable head task, and on a re-plan). */
|
|
784
|
+
export async function ensureBaseBranch(
|
|
785
|
+
repo: string,
|
|
786
|
+
branch: string,
|
|
787
|
+
token: string,
|
|
788
|
+
): Promise<EnsureBaseBranchResult> {
|
|
789
|
+
const existing = await branchHeadSha(repo, branch, token);
|
|
790
|
+
if (existing !== null) return "exists"; // never reset an existing ref
|
|
791
|
+
|
|
792
|
+
if (!isEpicBranch(branch)) throw new BaseBranchMustExistError(branch);
|
|
793
|
+
|
|
794
|
+
const defaultBranch = await fetchDefaultBranch(repo, token);
|
|
795
|
+
if (!defaultBranch) {
|
|
796
|
+
throw new Error(`cannot resolve the default branch of ${repo} to create ${branch}`);
|
|
797
|
+
}
|
|
798
|
+
const defaultSha = await branchHeadSha(repo, defaultBranch, token);
|
|
799
|
+
if (!defaultSha) {
|
|
800
|
+
throw new Error(`cannot resolve HEAD of default branch ${defaultBranch} on ${repo} to create ${branch}`);
|
|
801
|
+
}
|
|
802
|
+
// A concurrent create / re-plan may have raced us to the ref (GitHub 422); in that case it
|
|
803
|
+
// already exists and we did not create it, so report "exists" rather than misleading "created".
|
|
804
|
+
const created = await createBranchRef(repo, branch, defaultSha, token);
|
|
805
|
+
return created ? "created" : "exists";
|
|
806
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
9
|
import {
|
|
10
|
+
classifyMergeLanding,
|
|
10
11
|
DEFAULT_MERGE_PROTOCOL,
|
|
11
12
|
extractProtocolBlock,
|
|
12
13
|
freshHeadRunAction,
|
|
@@ -195,3 +196,17 @@ test("headRunPresenceCount: token mode (totalChecks < 0) stays conservative (-1)
|
|
|
195
196
|
assertEquals(headRunPresenceCount(NANO_REQ, { totalChecks: -1, presentCheckNames: [] }), -1);
|
|
196
197
|
assertEquals(freshHeadRunAction(NANO_REQ, "waiting", -1, false), null);
|
|
197
198
|
});
|
|
199
|
+
|
|
200
|
+
// The land method is classified against the canonical escalation taxonomy: only a `ui` method is a
|
|
201
|
+
// decision-required escalation (a human must click Merge); every machine-landable method is
|
|
202
|
+
// transient (the merge stage lands it in-process).
|
|
203
|
+
test("classifyMergeLanding: only `ui` is decision-required", () => {
|
|
204
|
+
const withMethod = (method: MergeProtocol["land"]["method"]): MergeProtocol => ({
|
|
205
|
+
...DEFAULT_MERGE_PROTOCOL,
|
|
206
|
+
land: { method },
|
|
207
|
+
});
|
|
208
|
+
assertEquals(classifyMergeLanding(withMethod("ui")), "decision-required");
|
|
209
|
+
assertEquals(classifyMergeLanding(withMethod("gh-merge")), "transient");
|
|
210
|
+
assertEquals(classifyMergeLanding(withMethod("admin")), "transient");
|
|
211
|
+
assertEquals(classifyMergeLanding(withMethod("mergify-queue")), "transient");
|
|
212
|
+
});
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// 2. `.github/merge-protocol.json`.
|
|
15
15
|
// A repo that publishes neither keeps today's behaviour (DEFAULT_MERGE_PROTOCOL).
|
|
16
16
|
|
|
17
|
+
import { classifyEscalation, type EscalationDisposition } from "./escalationTaxonomy.ts";
|
|
17
18
|
import { fetchRepoFile } from "./github.ts";
|
|
18
19
|
|
|
19
20
|
/** How to give branch protection a fresh head `pull_request` run before landing. `none` = the
|
|
@@ -66,6 +67,15 @@ export const DEFAULT_MERGE_PROTOCOL: MergeProtocol = {
|
|
|
66
67
|
requiredChecks: [],
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
/** Classify a repo's landing method against the canonical escalation taxonomy. A `ui` land
|
|
71
|
+
* method is a `decision-required` escalation (only a human can click Merge); every
|
|
72
|
+
* machine-landable method (gh-merge / admin / mergify-queue) is `transient` — the merge stage
|
|
73
|
+
* lands it in-process without paging a human. Delegates to {@link classifyEscalation} so the
|
|
74
|
+
* merge worker shares one source of truth with every other raise site. */
|
|
75
|
+
export function classifyMergeLanding(protocol: MergeProtocol): EscalationDisposition {
|
|
76
|
+
return classifyEscalation({ kind: "merge-protocol", landMethod: protocol.land.method });
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const FRESH_HEAD_RUNS: ReadonlySet<string> = new Set(["none", "ready", "reopen", "ready-or-reopen"]);
|
|
70
80
|
const LAND_METHODS: ReadonlySet<string> = new Set(["gh-merge", "admin", "mergify-queue", "ui"]);
|
|
71
81
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
|
|
8
8
|
// still record the round.
|
|
9
9
|
import { test } from "node:test";
|
|
10
|
-
import {
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
11
|
import handler from "../workers/persist-escalation/worker.ts";
|
|
12
12
|
|
|
13
13
|
function fakeApp() {
|
|
@@ -99,62 +99,60 @@ test("persist-escalation heals a missing pull_requests parent before recording",
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
test("a padded question is persisted trimmed (no whitespace drift)", async () => {
|
|
102
|
+
test("a padded question is persisted trimmed and returned trimmed (no whitespace drift)", async () => {
|
|
103
103
|
const { app, inserts, updates } = fakeApp();
|
|
104
104
|
const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
|
|
105
|
-
await handler(job as any, app as any);
|
|
105
|
+
const out = await handler(job as any, app as any);
|
|
106
106
|
assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
|
|
107
|
-
|
|
107
|
+
// The trimmed question is returned as a process variable so the downstream `wait-answer`
|
|
108
|
+
// userTask + `pr-escalation.form` can display it (there is no denormalised PR-row pointer).
|
|
109
|
+
assertEquals((out as any).question, "needs a decision", "the returned question is trimmed too");
|
|
110
|
+
const patch = (updates.pull_requests![0] as any).patch;
|
|
111
|
+
assertEquals(patch.open_escalation_question, undefined, "no denormalised question pointer is written");
|
|
112
|
+
assertEquals(patch.open_escalation_id, undefined, "no denormalised id pointer is written");
|
|
108
113
|
});
|
|
109
114
|
|
|
110
|
-
//
|
|
111
|
-
// blank-status / blank-question job here (an agent-raised `needs_input`/`blocked` with no
|
|
112
|
-
// question, or the max-rounds / review-stalled arms), the worker must NOT throw — throwing
|
|
113
|
-
// parked an un-remediable JobNoRetries incident (the empty "(no question provided)" escalations
|
|
114
|
-
// on Magikcraft/nano-bpm #597/#599). It opens an *answerable* escalation with a fabricated,
|
|
115
|
-
// concrete question and the agent's transcript attached, so a human can unblock the loop from
|
|
116
|
-
// the UI.
|
|
115
|
+
// REGRESSION (nano-workforce ADR 0002 §1 — retire the blank-question fabrication failure mode).
|
|
117
116
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
117
|
+
// A blank/absent question must be treated as a NON-escalation: no escalation row, no PR status
|
|
118
|
+
// flip to `escalated`, no wait. The worker previously FABRICATED a concrete question from the
|
|
119
|
+
// transcript and opened an answerable escalation — that is exactly the failure mode this slice
|
|
120
|
+
// retires. This test reproduces that defect (it fails against the old fabricating worker, which
|
|
121
|
+
// opened an escalation with `escalationId:42`) and pins the new non-escalation behaviour.
|
|
122
|
+
//
|
|
123
|
+
// The `gw-status` gateway is the primary guard (its `f_escalate` arm now requires a non-blank
|
|
124
|
+
// question — see roundResultDefault.test.ts), so a blank-question round never reaches this worker
|
|
125
|
+
// in practice; this asserts the worker's defence-in-depth via the canonical taxonomy.
|
|
126
|
+
test("blank question is a non-escalation (no row fabricated, no status flip)", async () => {
|
|
122
127
|
for (const question of [undefined, "", " "]) {
|
|
123
128
|
const { app, inserts, updates } = fakeApp();
|
|
124
129
|
const job = {
|
|
125
130
|
variables: {
|
|
126
131
|
prKey: "o/r#1",
|
|
127
132
|
round: 2,
|
|
133
|
+
status: "needs_input",
|
|
128
134
|
...(question === undefined ? {} : { question }),
|
|
129
135
|
"io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
|
|
130
136
|
},
|
|
131
137
|
};
|
|
132
138
|
const out = await handler(job as any, app as any);
|
|
133
|
-
assertEquals((out as any).
|
|
134
|
-
assertEquals(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
esc.question.includes("machine-readable result"),
|
|
139
|
-
"no-result rounds explain the missing status",
|
|
140
|
-
);
|
|
141
|
-
assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
|
|
142
|
-
// Default status for an unclassified escalation is a question needing input.
|
|
143
|
-
assertEquals(esc.kind, "question");
|
|
144
|
-
const pr = updates.pull_requests![0] as any;
|
|
145
|
-
assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
|
|
139
|
+
assertEquals((out as any).escalated, false, "the job reports no escalation");
|
|
140
|
+
assertEquals((out as any).escalationId, null, "no escalation id is minted");
|
|
141
|
+
assertEquals(inserts.escalations.length, 0, "no escalation row is fabricated");
|
|
142
|
+
assertEquals(inserts.rounds.length, 0, "no round row is written for a non-escalation");
|
|
143
|
+
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
|
|
146
144
|
}
|
|
147
145
|
});
|
|
148
146
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
test("
|
|
152
|
-
const { app, inserts } = fakeApp();
|
|
147
|
+
// A non-human-blocking status (never routed here by `gw-status`, but defensively handled) is a
|
|
148
|
+
// transient signal, not a decision-required escalation: it opens nothing.
|
|
149
|
+
test("a non-decision status is a non-escalation even with no question", async () => {
|
|
150
|
+
const { app, inserts, updates } = fakeApp();
|
|
153
151
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
|
|
154
|
-
await handler(job as any, app as any);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
assertEquals(
|
|
152
|
+
const out = await handler(job as any, app as any);
|
|
153
|
+
assertEquals((out as any).escalated, false);
|
|
154
|
+
assertEquals(inserts.escalations.length, 0, "no escalation is opened for a transient status");
|
|
155
|
+
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is not flipped to escalated");
|
|
158
156
|
});
|
|
159
157
|
|
|
160
158
|
// When repo/prNumber process variables are absent the heal still runs by parsing the canonical
|