@nanobpm/nano-workforce 0.205.0 → 0.205.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 +6 -0
- package/app/github.test.ts +285 -1
- package/app/github.ts +160 -8
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +129 -0
- package/app/readiness.ts +1 -0
- package/app/service.ts +139 -1
- package/app/userTasks.ts +1 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +55 -9
- package/workers/merge-stall-probe/worker.ts +17 -3
|
@@ -96,6 +96,8 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
96
96
|
mergeStatus: null,
|
|
97
97
|
ciBlockedReconciled: null,
|
|
98
98
|
failingChecks: 0,
|
|
99
|
+
reviewThreads: null,
|
|
100
|
+
pendingChecks: null,
|
|
99
101
|
round: 2,
|
|
100
102
|
abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
|
|
101
103
|
};
|
|
@@ -521,6 +523,133 @@ test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#
|
|
|
521
523
|
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
|
|
522
524
|
});
|
|
523
525
|
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// BLOCKED by unresolved review threads (issue #921)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// A PR at MERGEABLE/BLOCKED with all checks green and ≥1 unresolved review thread (branch
|
|
530
|
+
// protection `required_conversation_resolution`) used to classify as "waiting": the poller never
|
|
531
|
+
// published, the mergeable-wait timer fired, the stall probe re-derived the same "waiting", and
|
|
532
|
+
// after the stall budget the escalation read "GitHub never settled this PR's mergeability" — when
|
|
533
|
+
// GitHub HAD settled. The `review-threads` verdict now routes to a dedicated escalation that names
|
|
534
|
+
// the real cause (and parks the shared `wait-merge-answer` task, whose answer re-arms the poller),
|
|
535
|
+
// never to the CI-fix or rebase agents.
|
|
536
|
+
|
|
537
|
+
test("a 'review-threads' verdict escalates naming the unresolved threads — never 'never settled', never an agent (#921)", async () => {
|
|
538
|
+
const engine = await boot();
|
|
539
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
540
|
+
await engine.publishMessage({
|
|
541
|
+
name: "merge-ready",
|
|
542
|
+
correlationKey: "pr-1",
|
|
543
|
+
variables: { mergeState: "review-threads", reviewThreads: 2, failingChecks: 0, failingChecksList: "" },
|
|
544
|
+
});
|
|
545
|
+
// The threads arm parks the shared answer task with the thread-specific question…
|
|
546
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
547
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasCompletedElements("merge-esc-threads");
|
|
548
|
+
const q = String(escalation(engine).question ?? "");
|
|
549
|
+
assertStringIncludes(q, "2 unresolved review thread(s)", "the escalation must name the actual blocker and count");
|
|
550
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
551
|
+
// …and never misroutes to the remediation agents or the generic conflict escalation.
|
|
552
|
+
assert(!completedElementIds(engine).has("fix-ci"), "green checks must not dispatch fix-ci");
|
|
553
|
+
assert(!completedElementIds(engine).has("rebase"), "a threads block must not dispatch rebase");
|
|
554
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "the threads arm has its own escalation");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("answering the review-threads escalation re-arms the merge poller (#921)", async () => {
|
|
558
|
+
// The human resolves the threads on GitHub and replies; the answer re-arms `arm-merge` so the
|
|
559
|
+
// next poll re-derives mergeability — once the threads are gone the verdict flips to `ready` and
|
|
560
|
+
// the merge proceeds (the merge step is thereby gated on zero unresolved threads).
|
|
561
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
|
|
562
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
563
|
+
await engine.publishMessage({
|
|
564
|
+
name: "merge-ready",
|
|
565
|
+
correlationKey: "pr-1",
|
|
566
|
+
variables: { mergeState: "review-threads", reviewThreads: 1, failingChecks: 0 },
|
|
567
|
+
});
|
|
568
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
569
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "resolved the threads" });
|
|
570
|
+
// Re-armed at the poller wait — not merged yet (the poller must re-derive)…
|
|
571
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
572
|
+
// …then the re-derived ready verdict lands the merge with no human in the loop.
|
|
573
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready", reviewThreads: 0 } });
|
|
574
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("attempt-merge", "mark-merged");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("a stalled probe that re-derives 'review-threads' routes to the threads arm, not the re-poll loop (#921)", async () => {
|
|
578
|
+
// The poller-dead path: the mergeable-wait timer fires, the stall probe re-derives ground truth,
|
|
579
|
+
// and a `review-threads` verdict takes the same dedicated arm a live poller message would.
|
|
580
|
+
const engine = await boot({
|
|
581
|
+
responses: {
|
|
582
|
+
"pr.merge-stall-probe": { mergeState: "review-threads", failingChecks: 0, failingChecksList: "", reviewThreads: 3 },
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
586
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
587
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
588
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-threads");
|
|
589
|
+
const q = String(escalation(engine).question ?? "");
|
|
590
|
+
assertStringIncludes(q, "3 unresolved review thread(s)", "the probe path must carry the thread count into the question");
|
|
591
|
+
assert(!completedElementIds(engine).has("wait-mergeable-repoll"), "a settled review-threads verdict must not re-poll");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("a stall-budget exhaustion on a settled BLOCKED with zero threads names the review requirement, not 'never settled' (#921)", async () => {
|
|
595
|
+
// GitHub settled: MERGEABLE/BLOCKED, checks green (none failing, none pending), every thread
|
|
596
|
+
// resolved — the block is a required human review approval. The probe reports `waiting` (the
|
|
597
|
+
// conservative verdict) with the probed counts, and the exhausted-budget escalation must name
|
|
598
|
+
// THAT cause.
|
|
599
|
+
const engine = await boot({
|
|
600
|
+
vars: { mergeStallMax: 0 },
|
|
601
|
+
responses: {
|
|
602
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
606
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
607
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
608
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
609
|
+
const q = String(escalation(engine).question ?? "");
|
|
610
|
+
assertStringIncludes(q, "required review approval", "a settled BLOCKED with clear threads must name the review requirement");
|
|
611
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a stall-budget exhaustion with checks still pending must NOT claim a verified review-approval blocker (PR #922 review)", async () => {
|
|
615
|
+
// Zero failing checks + zero unresolved threads, but a check is still queued/running: the BLOCKED
|
|
616
|
+
// cause is undetermined (the pending run may come back red), so the escalation must NOT assert
|
|
617
|
+
// "all checks green … required review approval" — it keeps the conservative unsettled wording.
|
|
618
|
+
const engine = await boot({
|
|
619
|
+
vars: { mergeStallMax: 0 },
|
|
620
|
+
responses: {
|
|
621
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 1 },
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
625
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
626
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
627
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
628
|
+
const q = String(escalation(engine).question ?? "");
|
|
629
|
+
assert(!q.includes("required review approval"), "a pending check must never read as a verified review-approval block");
|
|
630
|
+
assertStringIncludes(q, "never settled", "an undetermined BLOCKED keeps the conservative unsettled wording");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("the 'never settled' question is reserved for a genuinely unreadable/unsettled mergeability (#921)", async () => {
|
|
634
|
+
// mergeStallMax: 0 + a probe that still reads `waiting` with NOTHING probed (reviewThreads -1,
|
|
635
|
+
// checks unenumerable) — GitHub genuinely never settled (or the state could not be read). Only
|
|
636
|
+
// this case keeps the original message.
|
|
637
|
+
const engine = await boot({
|
|
638
|
+
vars: { mergeStallMax: 0 },
|
|
639
|
+
responses: {
|
|
640
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
644
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
645
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
646
|
+
assertStringIncludes(
|
|
647
|
+
String(escalation(engine).question ?? ""),
|
|
648
|
+
"never settled",
|
|
649
|
+
"a genuinely unsettled/unreadable mergeability keeps the original message",
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
524
653
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
525
654
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
526
655
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
package/app/readiness.ts
CHANGED
|
@@ -768,6 +768,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
771
|
+
unresolvedThreads: -1, // …nor the review-threads probe (merge-stage-only signal, #921) → unprobed
|
|
771
772
|
mergedSha,
|
|
772
773
|
pendingChecks: pending.length,
|
|
773
774
|
};
|
package/app/service.ts
CHANGED
|
@@ -1698,6 +1698,77 @@ export async function maybeEnsureFreshHeadRun(
|
|
|
1698
1698
|
* EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
|
|
1699
1699
|
* terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
|
|
1700
1700
|
* is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
|
|
1701
|
+
/** Durably hand a `waiting_merge` PR back into the canonical convergence loop (issue #921, PR #922
|
|
1702
|
+
* review): flip the row to `waiting_review` FIRST (so a review landing in the start window is still
|
|
1703
|
+
* collected by `pollReviews` — it scans on `waiting_review`, not on instance liveness), RETIRE the
|
|
1704
|
+
* live merge instance (while the row's `process_key` names a non-terminal merge instance, `submitPr`'s
|
|
1705
|
+
* idempotency gate classifies the PR `alreadyRunning` and starts NOTHING — the cancel flips the derived
|
|
1706
|
+
* tracking status terminal so the gate falls through), then `submitPr` re-opens the PR into a fresh
|
|
1707
|
+
* convergence instance (round 1). The review-round agent's backlog IS the open threads: it fixes or
|
|
1708
|
+
* pushes back, replies, and resolves them; on `converged` the converge-gate re-verifies zero
|
|
1709
|
+
* unresolved threads and `pr.finalize` re-enters the merge stage — merging resumes automatically.
|
|
1710
|
+
*
|
|
1711
|
+
* The escalation-steward opt-in is carried through the resubmission (PR #922 review): `submitPr`
|
|
1712
|
+
* defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
|
|
1713
|
+
* would silently lose automated escalation resolution merely because late review threads appeared.
|
|
1714
|
+
*
|
|
1715
|
+
* RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
|
|
1716
|
+
* row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
|
|
1717
|
+
* already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
|
|
1718
|
+
* no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
|
|
1719
|
+
* cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
|
|
1720
|
+
* unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
|
|
1721
|
+
* the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
|
|
1722
|
+
* cancel or restore only logs, leaving the row for the next pass. */
|
|
1723
|
+
async function handoffWaitingMergeToConvergence(
|
|
1724
|
+
data: DataLayer,
|
|
1725
|
+
engine: EngineClient,
|
|
1726
|
+
pr: PullRequest,
|
|
1727
|
+
logContext: string,
|
|
1728
|
+
) {
|
|
1729
|
+
const prKey = pr.pr_key;
|
|
1730
|
+
try {
|
|
1731
|
+
// `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
|
|
1732
|
+
// the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
|
|
1733
|
+
// run's wait.
|
|
1734
|
+
const handoffTs = now();
|
|
1735
|
+
await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
|
|
1736
|
+
const mergeKey = pr.process_key;
|
|
1737
|
+
if (mergeKey != null) {
|
|
1738
|
+
try {
|
|
1739
|
+
await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
|
|
1740
|
+
} catch (cancelErr) {
|
|
1741
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
await submitPr(
|
|
1745
|
+
data,
|
|
1746
|
+
engine,
|
|
1747
|
+
{ repo: pr.repo, number: pr.number, url: pr.url, prKey },
|
|
1748
|
+
[],
|
|
1749
|
+
MAX_ROUNDS,
|
|
1750
|
+
false,
|
|
1751
|
+
null,
|
|
1752
|
+
false,
|
|
1753
|
+
pr.auto_resolve_escalations === 1,
|
|
1754
|
+
);
|
|
1755
|
+
console.log(`[poller] ${logContext} -> handed back into convergence -> ${prKey}`);
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
|
|
1758
|
+
// Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
|
|
1759
|
+
// `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
|
|
1760
|
+
// NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
|
|
1761
|
+
// `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
|
|
1762
|
+
// parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
|
|
1763
|
+
// resurrected). Best-effort: a failed restore still leaves the original error logged.
|
|
1764
|
+
try {
|
|
1765
|
+
await prs(data).update(prKey, { status: "waiting_merge", process_key: null, updated_at: now() });
|
|
1766
|
+
} catch (restoreErr) {
|
|
1767
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1701
1772
|
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
1702
1773
|
// 1) Dependencies merged?
|
|
1703
1774
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
@@ -1731,7 +1802,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1731
1802
|
for (const pr of await prs(data).find({ status: "waiting_merge" })) {
|
|
1732
1803
|
const { repo, number, pr_key: prKey } = pr;
|
|
1733
1804
|
try {
|
|
1734
|
-
|
|
1805
|
+
// Opt into the review-threads probe so a `BLOCKED` merge state is classified by its real
|
|
1806
|
+
// cause (issue #921): unresolved review threads (branch protection
|
|
1807
|
+
// `required_conversation_resolution`) surface as the `review-threads` verdict below instead
|
|
1808
|
+
// of polling as "waiting" until the mergeable-wait timer escalates the misleading
|
|
1809
|
+
// "mergeability never settled". One extra GraphQL read per pass per waiting PR.
|
|
1810
|
+
const st = await fetchPrState(repo, number, token, { withReviewThreads: true });
|
|
1735
1811
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1736
1812
|
// Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
|
|
1737
1813
|
// routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
|
|
@@ -1739,6 +1815,39 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1739
1815
|
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1740
1816
|
// path the whole class (#368) now shares.
|
|
1741
1817
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1818
|
+
// DEAD-OWNER guard (PR #922 review, round 5): a `waiting_merge` row with `process_key === null`
|
|
1819
|
+
// has NO live owner — the unfinished-handoff shape {@link handoffWaitingMergeToConvergence}
|
|
1820
|
+
// leaves when its `submitPr` failed after the merge instance was retired (the restore nulls the
|
|
1821
|
+
// key to mark exactly this). The verdict switch below must NOT run against it: every arm ends in
|
|
1822
|
+
// `flipToMergingThenPublish`, which would publish `merge-ready`/`merge-evicted`/remediation
|
|
1823
|
+
// correlated to a merge instance that no longer exists — the message is dropped and the row wedges
|
|
1824
|
+
// at the transient `merging` with no live owner (a fresh `ready`/`conflict`/`blocked` verdict after
|
|
1825
|
+
// the threads cleared is exactly the case the retry-restore leaves behind). Persist the handoff
|
|
1826
|
+
// INDEPENDENTLY of the current GitHub verdict: re-establish a live convergence owner FIRST,
|
|
1827
|
+
// publishing nothing. Keying on the NULL key — not on the terminal `derived_status` — is what
|
|
1828
|
+
// keeps a HUMAN cancel of a parked merge wait (which leaves `process_key` naming the terminated
|
|
1829
|
+
// instance) OUT of this path: a cancelled run must stay cancelled, never auto-resurrected. A
|
|
1830
|
+
// normal merge wait always carries a live `process_key`, so this fires only on the
|
|
1831
|
+
// ownerless-handoff shape. Placed after the out-of-band terminal pre-check so a merged/closed PR
|
|
1832
|
+
// still takes the proven terminal path first.
|
|
1833
|
+
if (pr.process_key == null) {
|
|
1834
|
+
await handoffWaitingMergeToConvergence(data, engine, pr, "dead merge owner (unfinished handoff)");
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
// HUMAN-CANCEL guard (PR #922 review, round 5): a `waiting_merge` row whose `process_key` names a
|
|
1838
|
+
// TERMINAL merge instance (`derived_status` folded to `abandoned`) was cancelled out-of-band. The
|
|
1839
|
+
// verdict switch must NOT run against it either — publishing `merge-ready`/`merge-evicted`/
|
|
1840
|
+
// remediation to the dead instance is dropped and wedges the row at `merging` (the reviewer's "a
|
|
1841
|
+
// new conflict or check failure likewise sends remediation to the dead owner"). Unlike the
|
|
1842
|
+
// unfinished-handoff shape above, a cancelled run must NOT be resurrected into convergence — the
|
|
1843
|
+
// abandon protocol exists to stop exactly that orphaned side effect — so it is simply SKIPPED
|
|
1844
|
+
// (left cancelled) with nothing published. A LIVE parked merge wait reads a non-terminal
|
|
1845
|
+
// `derived_status` (`waiting_merge` ∈ activeStatuses), so this fires only on the cancelled shape.
|
|
1846
|
+
const trackedMerge = await prsTracking(data).get(prKey);
|
|
1847
|
+
if (trackedMerge && TERMINAL_STATUSES.includes(trackedMerge.derived_status)) {
|
|
1848
|
+
console.log(`[poller] merge owner terminal (cancelled) — leaving ${prKey} abandoned, no merge message published`);
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1742
1851
|
// Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
|
|
1743
1852
|
// protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
|
|
1744
1853
|
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
@@ -1788,6 +1897,25 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1788
1897
|
}
|
|
1789
1898
|
continue; // GitHub still computing / checks pending
|
|
1790
1899
|
}
|
|
1900
|
+
if (verdict === "review-threads") {
|
|
1901
|
+
// BLOCKED by unresolved review threads with every check green (issue #921): the threads are
|
|
1902
|
+
// findings from the reviewer this loop converges with, so route them back into convergence
|
|
1903
|
+
// rather than escalating to a human. Requesting another Copilot review alone does NOT
|
|
1904
|
+
// remediate them: `pollReviews` only scans `waiting_review` rows, so a review landing while
|
|
1905
|
+
// the PR sits in `waiting_merge` is never picked up and no review-fix agent ever activates
|
|
1906
|
+
// (PR #922 review). The handoff is a DURABLE re-entry into the canonical convergence loop
|
|
1907
|
+
// (see {@link handoffWaitingMergeToConvergence}). Once the fresh convergence instance exists,
|
|
1908
|
+
// `pr.progress-check` parks the row back in `waiting_merge` after the round, where this
|
|
1909
|
+
// block's verdict has by then flipped off `review-threads` (the gate guarantees it) — so this
|
|
1910
|
+
// fires at most once per thread-blocked episode.
|
|
1911
|
+
await handoffWaitingMergeToConvergence(
|
|
1912
|
+
data,
|
|
1913
|
+
engine,
|
|
1914
|
+
pr,
|
|
1915
|
+
`blocked by ${st.unresolvedThreads} unresolved review thread(s)`,
|
|
1916
|
+
);
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1791
1919
|
if (verdict === "ready") {
|
|
1792
1920
|
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
1793
1921
|
if (lane?.isHeld) {
|
|
@@ -1807,6 +1935,16 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1807
1935
|
// fix-ci task appends it to the agent prompt so the agent knows which gates to green.
|
|
1808
1936
|
failingChecks: st.failingChecks,
|
|
1809
1937
|
failingChecksList: st.failingCheckNames.join("\n"),
|
|
1938
|
+
// The probed unresolved-thread count (issue #921) — carried so a later stall-budget
|
|
1939
|
+
// escalation can name the real BLOCKED cause (and so the `review-threads` arm's question
|
|
1940
|
+
// can quote it). `-1` when the probe was unreadable.
|
|
1941
|
+
reviewThreads: st.unresolvedThreads,
|
|
1942
|
+
// The pending-check count (PR #922 review): zero failing checks does not mean checks are
|
|
1943
|
+
// green — a queued/running check leaves the BLOCKED cause undetermined, so the
|
|
1944
|
+
// stall-escalation question gates its "required review approval" wording on this being 0
|
|
1945
|
+
// too. 0 in token mode (checks unenumerable — the conservative `waiting` verdict already
|
|
1946
|
+
// keeps the approval wording from firing there).
|
|
1947
|
+
pendingChecks: st.pendingCheckNames.length,
|
|
1810
1948
|
},
|
|
1811
1949
|
});
|
|
1812
1950
|
console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
|
package/app/userTasks.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const PR_ESCALATION_PRODUCER_ELEMENTS: readonly string[] = [
|
|
|
90
90
|
"merge-esc-conflict",
|
|
91
91
|
"merge-esc-attempt",
|
|
92
92
|
"merge-esc-landed",
|
|
93
|
+
"merge-esc-threads",
|
|
93
94
|
];
|
|
94
95
|
|
|
95
96
|
/** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.205.
|
|
3
|
+
"version": "0.205.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",
|
|
@@ -53,11 +53,15 @@
|
|
|
53
53
|
<nano:extend name="mergeState" type="string" />
|
|
54
54
|
<nano:extend name="failingChecks" type="integer" optional="true" />
|
|
55
55
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
56
|
+
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
57
|
+
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
56
58
|
</nano:shape>
|
|
57
59
|
<nano:shape id="MergeReady" name="merge-ready message payload">
|
|
58
60
|
<nano:extend name="mergeState" type="string" />
|
|
59
61
|
<nano:extend name="failingChecks" type="integer" optional="true" />
|
|
60
62
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
63
|
+
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
64
|
+
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
61
65
|
</nano:shape>
|
|
62
66
|
<nano:shape id="FixCiIn" name="CI fix — input">
|
|
63
67
|
<nano:extend name="prKey" type="string" />
|
|
@@ -179,6 +183,7 @@
|
|
|
179
183
|
<bpmn:outgoing>f_m_mRebase</bpmn:outgoing>
|
|
180
184
|
<bpmn:outgoing>f_m_mWaiting</bpmn:outgoing>
|
|
181
185
|
<bpmn:outgoing>f_m_mDraft</bpmn:outgoing>
|
|
186
|
+
<bpmn:outgoing>f_m_mThreads</bpmn:outgoing>
|
|
182
187
|
<bpmn:outgoing>f_m_mBlocked</bpmn:outgoing>
|
|
183
188
|
</bpmn:exclusiveGateway>
|
|
184
189
|
<bpmn:serviceTask id="attempt-merge" name="Merge PR">
|
|
@@ -258,7 +263,7 @@
|
|
|
258
263
|
</zeebe:properties>
|
|
259
264
|
<zeebe:ioMapping>
|
|
260
265
|
<zeebe:input source="="blocked"" target="status" />
|
|
261
|
-
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
266
|
+
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with all checks green (none failing, none pending) and every review thread resolved. GitHub still reports BLOCKED, which means a required review approval (or code-owner review) is outstanding — nothing the automated loop can produce. Approve the PR (or adjust the branch-protection review requirement), then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing or checks still pending after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
262
267
|
</zeebe:ioMapping>
|
|
263
268
|
</bpmn:extensionElements>
|
|
264
269
|
<bpmn:incoming>f_m_mDraft</bpmn:incoming>
|
|
@@ -287,9 +292,26 @@
|
|
|
287
292
|
<bpmn:incoming>f_mr_giveup</bpmn:incoming>
|
|
288
293
|
<bpmn:outgoing>f_m_escA</bpmn:outgoing>
|
|
289
294
|
</bpmn:serviceTask>
|
|
295
|
+
<bpmn:serviceTask id="merge-esc-threads" name="Escalate: unresolved review threads">
|
|
296
|
+
<bpmn:extensionElements>
|
|
297
|
+
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
298
|
+
<zeebe:properties>
|
|
299
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
300
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
301
|
+
</zeebe:properties>
|
|
302
|
+
<zeebe:ioMapping>
|
|
303
|
+
<zeebe:input source="="blocked"" target="status" />
|
|
304
|
+
<zeebe:input source="="This PR is merge-blocked by " + string(reviewThreads) + " unresolved review thread(s) (branch protection requires every conversation resolved; all checks are green and there is no conflict). These are review findings the loop already converged, so a fresh review round cannot clear them without a reply. Resolve the threads on the PR (or reply with guidance — e.g. which threads to push back on), then reply to retry."" target="question" />
|
|
305
|
+
<zeebe:input source="=null" target="agentSlaJobType" />
|
|
306
|
+
</zeebe:ioMapping>
|
|
307
|
+
</bpmn:extensionElements>
|
|
308
|
+
<bpmn:incoming>f_m_mThreads</bpmn:incoming>
|
|
309
|
+
<bpmn:outgoing>f_m_escT</bpmn:outgoing>
|
|
310
|
+
</bpmn:serviceTask>
|
|
290
311
|
<bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
|
|
291
312
|
<bpmn:incoming>f_m_escA</bpmn:incoming>
|
|
292
313
|
<bpmn:incoming>f_m_escLanded</bpmn:incoming>
|
|
314
|
+
<bpmn:incoming>f_m_escT</bpmn:incoming>
|
|
293
315
|
<bpmn:outgoing>f_m_escWait</bpmn:outgoing>
|
|
294
316
|
<bpmn:outgoing>f_m_escReenter</bpmn:outgoing>
|
|
295
317
|
</bpmn:exclusiveGateway>
|
|
@@ -732,6 +754,9 @@
|
|
|
732
754
|
<bpmn:sequenceFlow id="f_m_mDraft" name="draft — mark ready (human)" sourceRef="gw-mergeable" targetRef="merge-esc-conflict">
|
|
733
755
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "draft"</bpmn:conditionExpression>
|
|
734
756
|
</bpmn:sequenceFlow>
|
|
757
|
+
<bpmn:sequenceFlow id="f_m_mThreads" name="blocked (unresolved review threads)" sourceRef="gw-mergeable" targetRef="merge-esc-threads">
|
|
758
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "review-threads"</bpmn:conditionExpression>
|
|
759
|
+
</bpmn:sequenceFlow>
|
|
735
760
|
<bpmn:sequenceFlow id="f_mrp_probe" sourceRef="wait-mergeable-repoll" targetRef="merge-stall-probe" />
|
|
736
761
|
<bpmn:sequenceFlow id="f_m_mBlocked" name="unclassified — auto-rebase" sourceRef="gw-mergeable" targetRef="gw-rebase" />
|
|
737
762
|
<bpmn:sequenceFlow id="f_ci_go" name="within budget" sourceRef="gw-ci-fix" targetRef="SP_cifix">
|
|
@@ -773,6 +798,7 @@
|
|
|
773
798
|
<bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
|
|
774
799
|
<bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
|
|
775
800
|
<bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="gw-merge-escalated" />
|
|
801
|
+
<bpmn:sequenceFlow id="f_m_escT" sourceRef="merge-esc-threads" targetRef="gw-merge-escalated" />
|
|
776
802
|
<bpmn:sequenceFlow id="f_m_escWait" sourceRef="gw-merge-escalated" targetRef="wait-merge-answer">
|
|
777
803
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
|
|
778
804
|
</bpmn:sequenceFlow>
|
|
@@ -833,7 +859,7 @@
|
|
|
833
859
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-stall" bpmnElement="gw-merge-stall" isMarkerVisible="true">
|
|
834
860
|
<dc:Bounds x="1538" y="1535" width="50" height="50" />
|
|
835
861
|
<bpmndi:BPMNLabel>
|
|
836
|
-
<dc:Bounds x="1521" y="
|
|
862
|
+
<dc:Bounds x="1521" y="1630" width="85" height="42" />
|
|
837
863
|
</bpmndi:BPMNLabel>
|
|
838
864
|
</bpmndi:BPMNShape>
|
|
839
865
|
<bpmndi:BPMNShape id="BPMNShape_gw-mergeable" bpmnElement="gw-mergeable" isMarkerVisible="true">
|
|
@@ -902,6 +928,9 @@
|
|
|
902
928
|
<bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
|
|
903
929
|
<dc:Bounds x="1688" y="720" width="100" height="80" />
|
|
904
930
|
</bpmndi:BPMNShape>
|
|
931
|
+
<bpmndi:BPMNShape id="BPMNShape_merge-esc-threads" bpmnElement="merge-esc-threads">
|
|
932
|
+
<dc:Bounds x="1138" y="1680" width="100" height="80" />
|
|
933
|
+
</bpmndi:BPMNShape>
|
|
905
934
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
|
|
906
935
|
<dc:Bounds x="2088" y="95" width="50" height="50" />
|
|
907
936
|
<bpmndi:BPMNLabel>
|
|
@@ -1002,7 +1031,7 @@
|
|
|
1002
1031
|
<di:waypoint x="402" y="1300" />
|
|
1003
1032
|
<di:waypoint x="402" y="320" />
|
|
1004
1033
|
<bpmndi:BPMNLabel>
|
|
1005
|
-
<dc:Bounds x="
|
|
1034
|
+
<dc:Bounds x="919" y="1239" width="88" height="56" />
|
|
1006
1035
|
</bpmndi:BPMNLabel>
|
|
1007
1036
|
</bpmndi:BPMNEdge>
|
|
1008
1037
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_rearm" bpmnElement="f_reb_rearm">
|
|
@@ -1179,6 +1208,14 @@
|
|
|
1179
1208
|
<dc:Bounds x="1057" y="483" width="85" height="42" />
|
|
1180
1209
|
</bpmndi:BPMNLabel>
|
|
1181
1210
|
</bpmndi:BPMNEdge>
|
|
1211
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mThreads" bpmnElement="f_m_mThreads">
|
|
1212
|
+
<di:waypoint x="1013" y="305" />
|
|
1213
|
+
<di:waypoint x="1013" y="1720" />
|
|
1214
|
+
<di:waypoint x="1138" y="1720" />
|
|
1215
|
+
<bpmndi:BPMNLabel>
|
|
1216
|
+
<dc:Bounds x="1018" y="1642" width="81" height="56" />
|
|
1217
|
+
</bpmndi:BPMNLabel>
|
|
1218
|
+
</bpmndi:BPMNEdge>
|
|
1182
1219
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mBlocked" bpmnElement="f_m_mBlocked">
|
|
1183
1220
|
<di:waypoint x="1013" y="305" />
|
|
1184
1221
|
<di:waypoint x="1013" y="440" />
|
|
@@ -1255,7 +1292,7 @@
|
|
|
1255
1292
|
<di:waypoint x="1668" y="740" />
|
|
1256
1293
|
<di:waypoint x="1688" y="740" />
|
|
1257
1294
|
<bpmndi:BPMNLabel>
|
|
1258
|
-
<dc:Bounds x="
|
|
1295
|
+
<dc:Bounds x="1932" y="616" width="53" height="14" />
|
|
1259
1296
|
</bpmndi:BPMNLabel>
|
|
1260
1297
|
</bpmndi:BPMNEdge>
|
|
1261
1298
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
|
|
@@ -1281,8 +1318,9 @@
|
|
|
1281
1318
|
<di:waypoint x="870" y="618" />
|
|
1282
1319
|
<di:waypoint x="870" y="638" />
|
|
1283
1320
|
<di:waypoint x="888" y="638" />
|
|
1284
|
-
<di:waypoint x="888" y="
|
|
1285
|
-
<di:waypoint x="
|
|
1321
|
+
<di:waypoint x="888" y="1780" />
|
|
1322
|
+
<di:waypoint x="1388" y="1780" />
|
|
1323
|
+
<di:waypoint x="1388" y="1600" />
|
|
1286
1324
|
</bpmndi:BPMNEdge>
|
|
1287
1325
|
<bpmndi:BPMNEdge id="BPMNEdge_f_msp_gate" bpmnElement="f_msp_gate">
|
|
1288
1326
|
<di:waypoint x="1438" y="1560" />
|
|
@@ -1308,6 +1346,14 @@
|
|
|
1308
1346
|
<di:waypoint x="1988" y="120" />
|
|
1309
1347
|
<di:waypoint x="2088" y="120" />
|
|
1310
1348
|
</bpmndi:BPMNEdge>
|
|
1349
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escT" bpmnElement="f_m_escT">
|
|
1350
|
+
<di:waypoint x="1238" y="1720" />
|
|
1351
|
+
<di:waypoint x="2008" y="1720" />
|
|
1352
|
+
<di:waypoint x="2008" y="145" />
|
|
1353
|
+
<di:waypoint x="2068" y="145" />
|
|
1354
|
+
<di:waypoint x="2068" y="120" />
|
|
1355
|
+
<di:waypoint x="2088" y="120" />
|
|
1356
|
+
</bpmndi:BPMNEdge>
|
|
1311
1357
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
|
|
1312
1358
|
<di:waypoint x="2138" y="120" />
|
|
1313
1359
|
<di:waypoint x="2238" y="120" />
|
|
@@ -1327,13 +1373,13 @@
|
|
|
1327
1373
|
<di:waypoint x="1013" y="1620" />
|
|
1328
1374
|
<di:waypoint x="1013" y="305" />
|
|
1329
1375
|
<bpmndi:BPMNLabel>
|
|
1330
|
-
<dc:Bounds x="
|
|
1376
|
+
<dc:Bounds x="1246" y="1587" width="85" height="28" />
|
|
1331
1377
|
</bpmndi:BPMNLabel>
|
|
1332
1378
|
</bpmndi:BPMNEdge>
|
|
1333
1379
|
<bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
|
|
1334
1380
|
<di:waypoint x="1738" y="1280" />
|
|
1335
|
-
<di:waypoint x="1738" y="
|
|
1336
|
-
<di:waypoint x="234" y="
|
|
1381
|
+
<di:waypoint x="1738" y="1300" />
|
|
1382
|
+
<di:waypoint x="234" y="1300" />
|
|
1337
1383
|
<di:waypoint x="234" y="298" />
|
|
1338
1384
|
</bpmndi:BPMNEdge>
|
|
1339
1385
|
<bpmndi:BPMNEdge id="BPMNEdge_f_mr_go" bpmnElement="f_mr_go">
|
|
@@ -33,6 +33,14 @@ interface Out extends Record<string, unknown> {
|
|
|
33
33
|
mergeState: string;
|
|
34
34
|
failingChecks: number;
|
|
35
35
|
failingChecksList: string;
|
|
36
|
+
// The unresolved-review-thread count behind a `review-threads` verdict (issue #921), mirrored
|
|
37
|
+
// into the escalation question so a human sees exactly what blocks the merge. `-1` = unprobed.
|
|
38
|
+
reviewThreads: number;
|
|
39
|
+
// The pending-check count (PR #922 review): zero failing checks does not mean checks are green —
|
|
40
|
+
// a queued/running check leaves the BLOCKED cause undetermined, so the stall-escalation question
|
|
41
|
+
// gates its "required review approval" wording on this being 0 too (never claims a verified
|
|
42
|
+
// blocker off a half-settled rollup).
|
|
43
|
+
pendingChecks: number;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
@@ -44,10 +52,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
44
52
|
// GitHub reports UNSTABLE. Any transport hiccup is treated as "still waiting" — never a spurious
|
|
45
53
|
// ready/conflict verdict — so a bad read is surfaced as `waiting`, which `gw-mergeable` routes to
|
|
46
54
|
// a bounded re-poll (#774) rather than misrouting the token to a wrong remediation arm; a
|
|
47
|
-
// genuinely stuck verdict still escalates once the `mergeStallMax` budget is exhausted.
|
|
48
|
-
|
|
55
|
+
// genuinely stuck verdict still escalates once the `mergeStallMax` budget is exhausted. The read
|
|
56
|
+
// opts into the review-threads probe (`withReviewThreads`) so a `BLOCKED` merge state is
|
|
57
|
+
// classified by its real cause (issue #921) — unresolved review threads surface as the
|
|
58
|
+
// `review-threads` verdict and route to their own arm instead of re-polling until the budget
|
|
59
|
+
// exhausts into the misleading "mergeability never settled" escalation.
|
|
60
|
+
const st = await fetchPrState(repo, prNumber, token, { withReviewThreads: true }).catch(() => null);
|
|
49
61
|
if (st === null) {
|
|
50
|
-
return { mergeState: "waiting", failingChecks: 0, failingChecksList: "" };
|
|
62
|
+
return { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 };
|
|
51
63
|
}
|
|
52
64
|
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
53
65
|
const mergeState = classifyMergeability(st, protocol ?? undefined);
|
|
@@ -64,6 +76,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
64
76
|
// poller's `merge-ready` payload so fix-ci knows which gates to green.
|
|
65
77
|
failingChecks: st.failingChecks,
|
|
66
78
|
failingChecksList: st.failingCheckNames.join("\n"),
|
|
79
|
+
reviewThreads: st.unresolvedThreads,
|
|
80
|
+
pendingChecks: st.pendingCheckNames.length,
|
|
67
81
|
};
|
|
68
82
|
};
|
|
69
83
|
|