@nanobpm/nano-workforce 0.191.0 → 0.192.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/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.ts +1 -1
- package/app/deliveryGraphRun.test.ts +25 -2
- package/app/deliveryGraphRun.ts +18 -6
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/github.test.ts +223 -1
- package/app/github.ts +122 -0
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/roundProgress.test.ts +108 -1
- package/app/service.test.ts +132 -3
- package/app/service.ts +130 -55
- package/app/world/checkpoint.ts +5 -0
- package/app/world/selfHeal.test.ts +296 -0
- package/app/world/selfHeal.ts +194 -0
- package/app/world/store.test.ts +49 -0
- package/app/world/store.ts +37 -1
- package/db/migrations/115_world_checkpoint_process_key.sql +22 -0
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/package.json +1 -1
- package/test/worldDb.ts +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
- package/workers/persist-round/worker.ts +6 -0
- package/workers/progress-check/worker.ts +117 -18
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
#
|
|
3
3
|
# https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
|
|
4
4
|
# install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
|
|
5
|
-
# built in a DIFFERENT repo (
|
|
5
|
+
# built in a DIFFERENT repo (nanobpm/nano-bpm) whose Pages workflow re-fetches
|
|
6
6
|
# install.sh on every deploy — but that deploy only triggers on changes to files
|
|
7
7
|
# in that repo, so an edit here would otherwise not reach the published mirror
|
|
8
8
|
# until the nightly backstop cron. This job pokes that repo's Pages deploy via a
|
|
9
9
|
# `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
|
|
10
10
|
# within minutes of merging an install.sh change.
|
|
11
11
|
#
|
|
12
|
-
# Requires a token that can write to
|
|
12
|
+
# Requires a token that can write to nanobpm/nano-bpm — the default
|
|
13
13
|
# GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
|
|
14
|
-
# App token) with `Contents: read and write` on
|
|
14
|
+
# App token) with `Contents: read and write` on nanobpm/nano-bpm as the repo
|
|
15
15
|
# secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
|
|
16
16
|
# cron on the site keeps the mirror eventually-consistent).
|
|
17
17
|
name: Refresh nanobpm.io install mirror
|
|
@@ -45,10 +45,10 @@ jobs:
|
|
|
45
45
|
-H 'Accept: application/vnd.github+json' \
|
|
46
46
|
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
|
|
47
47
|
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
|
48
|
-
https://api.github.com/repos/
|
|
48
|
+
https://api.github.com/repos/nanobpm/nano-bpm/dispatches \
|
|
49
49
|
-d '{"event_type":"install-sh-updated"}')
|
|
50
50
|
if [ "$code" != "204" ]; then
|
|
51
|
-
echo "::error::repository_dispatch to
|
|
51
|
+
echo "::error::repository_dispatch to nanobpm/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
|
|
52
52
|
exit 1
|
|
53
53
|
fi
|
|
54
|
-
echo "dispatched install-sh-updated to
|
|
54
|
+
echo "dispatched install-sh-updated to nanobpm/nano-bpm (HTTP 204)"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.192.0](https://github.com/nanobpm/nano-workforce/compare/v0.191.1...v0.192.0) (2026-09-28)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **world:** self-heal a no-advance round onto a reachable push-checkpoint ([#819](https://github.com/nanobpm/nano-workforce/issues/819)) ([891add2](https://github.com/nanobpm/nano-workforce/commit/891add2a217042dd2abaa37c8f12b1f1877c89fc)), closes [26/#28](https://github.com/26/nano-workforce/issues/28) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [704/#497](https://github.com/704/nano-workforce/issues/497)
|
|
6
|
+
|
|
7
|
+
## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **tasks:** show full delivery human-step context and lift an embedded link ([#814](https://github.com/nanobpm/nano-workforce/issues/814)) ([cca3374](https://github.com/nanobpm/nano-workforce/commit/cca33740607f80816504dd2b5c60b785cb1440c6)), closes [#813](https://github.com/nanobpm/nano-workforce/issues/813) [#fragment](https://github.com/nanobpm/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#813](https://github.com/nanobpm/nano-workforce/issues/813)
|
|
12
|
+
|
|
1
13
|
## [0.191.0](https://github.com/nanobpm/nano-workforce/compare/v0.190.0...v0.191.0) (2026-09-23)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -487,7 +487,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
487
487
|
}
|
|
488
488
|
// A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
|
|
489
489
|
// end on the not-ready-at-boundary path, but the engine treats terminate-end events as
|
|
490
|
-
// parsed-not-executed (
|
|
490
|
+
// parsed-not-executed (nanobpm/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
|
|
491
491
|
// end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
|
|
492
492
|
// until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
|
|
493
493
|
// (default) and `continue` ARE honored.
|
|
@@ -201,7 +201,7 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
|
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
204
|
-
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction
|
|
204
|
+
test("buildHumanLabels: maps each human node's compiled user-task element id → its FULL instruction", async () => {
|
|
205
205
|
const graph = {
|
|
206
206
|
nodes: [
|
|
207
207
|
{ id: "open-b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -216,7 +216,9 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
|
|
|
216
216
|
const labels = buildHumanLabels(compiled);
|
|
217
217
|
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
218
218
|
const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
|
|
219
|
-
|
|
219
|
+
// The FULL prompt is stored (issue #813) — not a clamped first line — so the Tasks "Decision
|
|
220
|
+
// context" can show the whole instruction. The phase pill clamps this at display (deriveDeliveryPhase).
|
|
221
|
+
assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish\nsecond line");
|
|
220
222
|
assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
|
|
221
223
|
});
|
|
222
224
|
|
|
@@ -264,6 +266,16 @@ test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that nod
|
|
|
264
266
|
assertEquals(p.phase_node_id, el);
|
|
265
267
|
});
|
|
266
268
|
|
|
269
|
+
test("deriveDeliveryPhase: clamps a long/multi-line stored instruction to a first-line phase pill (#813)", () => {
|
|
270
|
+
const el = humanTaskElementId("n2");
|
|
271
|
+
const full = `Review the console dependency-adopt PR opened by adopt-console (bumps the ranges).\nSecond line.`;
|
|
272
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: full });
|
|
273
|
+
// Full instruction is stored for the Decision context, but the phase pill shows only the clamped
|
|
274
|
+
// first line (77 chars + …), never the second line.
|
|
275
|
+
assertEquals(p.phase, "Parked on human node: Review the console dependency-adopt PR opened by adopt-console (bumps the ran…");
|
|
276
|
+
assert(!p.phase.includes("Second line"));
|
|
277
|
+
});
|
|
278
|
+
|
|
267
279
|
test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
|
|
268
280
|
const el = humanTaskElementId("n5");
|
|
269
281
|
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
|
|
@@ -271,6 +283,17 @@ test("deriveDeliveryPhase: a parked node with no stored label falls back to the
|
|
|
271
283
|
assertEquals(p.phase_node_id, el);
|
|
272
284
|
});
|
|
273
285
|
|
|
286
|
+
test("deliveryHuman: a parked bounded-timeout twin (`…__esc`) resolves the base node's stored label in the phase pill (#813)", () => {
|
|
287
|
+
// The twin's exact `…__esc` id is never stamped into `human_labels` (only the base id is), so the
|
|
288
|
+
// phase-pill lookup must strip `__esc` — the same fallback the Tasks Decision-context helpers use —
|
|
289
|
+
// else a parked timeout twin shows the raw element id instead of the instruction's first line.
|
|
290
|
+
const base = humanTaskElementId("n2");
|
|
291
|
+
const twin = `${base}__esc`;
|
|
292
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: twin }], { [base]: "manual OTP publish" });
|
|
293
|
+
assertEquals(p.phase, "Parked on human node: manual OTP publish");
|
|
294
|
+
assertEquals(p.phase_node_id, twin);
|
|
295
|
+
});
|
|
296
|
+
|
|
274
297
|
test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
|
|
275
298
|
assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
|
|
276
299
|
assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -206,9 +206,12 @@ function firstLine(text: string | undefined | null): string {
|
|
|
206
206
|
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/** Map each human node's compiled user-task element id →
|
|
210
|
-
*
|
|
211
|
-
*
|
|
209
|
+
/** Map each human node's compiled user-task element id → its FULL instruction, from the S1 compile
|
|
210
|
+
* result. Stamped on the run row at dispatch so the poller can (a) render the parked-node phase label
|
|
211
|
+
* — clamped at display by {@link deriveDeliveryPhase} — and (b) surface the WHOLE instruction as the
|
|
212
|
+
* Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
|
|
213
|
+
* what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
|
|
214
|
+
* task's clickable link. Falls back to the node id when a human node declares no prompt. */
|
|
212
215
|
export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
|
|
213
216
|
const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
|
|
214
217
|
const labels: Record<string, string> = {};
|
|
@@ -220,8 +223,11 @@ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<s
|
|
|
220
223
|
// task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
|
|
221
224
|
// must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
|
|
222
225
|
// path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
// The FULL redacted prompt is stored (not a clamped first line) so the Tasks "Decision context" can
|
|
227
|
+
// show the whole instruction and surface an embedded link; the phase pill clamps it at display via
|
|
228
|
+
// `firstLine` in `deriveDeliveryPhase` below (issue #813).
|
|
229
|
+
const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt).trim() : "";
|
|
230
|
+
labels[humanTaskElementId(element)] = safePrompt || stop.nodeId;
|
|
225
231
|
}
|
|
226
232
|
return labels;
|
|
227
233
|
}
|
|
@@ -267,7 +273,13 @@ export function deriveDeliveryPhase(
|
|
|
267
273
|
.filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
|
|
268
274
|
.sort()[0];
|
|
269
275
|
if (parkedOn !== undefined) {
|
|
270
|
-
|
|
276
|
+
// Clamp the compact phase label to the instruction's first line (the full prompt is stored in
|
|
277
|
+
// `human_labels` for the Tasks "Decision context" — issue #813 — but the phase pill wants a short
|
|
278
|
+
// one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
|
|
279
|
+
// parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
|
|
280
|
+
// node's label instead of showing the raw element id. A missing label falls back to the raw id.
|
|
281
|
+
const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
|
|
282
|
+
const label = firstLine(stored) || parkedOn;
|
|
271
283
|
return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
|
|
272
284
|
}
|
|
273
285
|
return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
bindHumanEmits,
|
|
12
12
|
DELIVERY_HUMAN_ELEMENT,
|
|
13
13
|
deliveryHumanContextQuestion,
|
|
14
|
+
deliveryHumanContextUrl,
|
|
14
15
|
deriveHumanCategory,
|
|
16
|
+
firstHttpUrl,
|
|
15
17
|
GENERIC_HUMAN_FORM,
|
|
16
18
|
HUMAN_ACK_FORM,
|
|
17
19
|
HUMAN_PUBLISH_FORM,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
renderHumanEmitBrief,
|
|
22
24
|
resolveHumanForm,
|
|
23
25
|
} from "./deliveryHuman.ts";
|
|
26
|
+
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
24
27
|
import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
|
|
25
28
|
import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
|
|
26
29
|
|
|
@@ -322,8 +325,82 @@ test("deliveryHumanContextQuestion: derives the node instruction from human_labe
|
|
|
322
325
|
);
|
|
323
326
|
});
|
|
324
327
|
|
|
325
|
-
test("
|
|
326
|
-
|
|
328
|
+
test("firstHttpUrl: extracts the first http(s) URL and trims trailing prose punctuation (#813)", () => {
|
|
329
|
+
assertEquals(firstHttpUrl("see https://github.com/o/r/pull/5 for details"), "https://github.com/o/r/pull/5");
|
|
330
|
+
// Trailing sentence punctuation / closing bracket is stripped so the link resolves.
|
|
331
|
+
assertEquals(firstHttpUrl("Review the PR (https://github.com/o/r/pull/5)."), "https://github.com/o/r/pull/5");
|
|
332
|
+
assertEquals(firstHttpUrl("http://example.test/a,"), "http://example.test/a");
|
|
333
|
+
// A balanced closing bracket is part of the URL and preserved (not truncated into a broken link).
|
|
334
|
+
assertEquals(
|
|
335
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/Nano_(technology) here"),
|
|
336
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
337
|
+
);
|
|
338
|
+
// A balanced bracket followed by prose punctuation: strip only the punctuation, keep the pair.
|
|
339
|
+
assertEquals(
|
|
340
|
+
firstHttpUrl("ref https://en.wikipedia.org/wiki/Nano_(technology)."),
|
|
341
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
342
|
+
);
|
|
343
|
+
// An UNBALANCED wrapping bracket is prose and still stripped.
|
|
344
|
+
assertEquals(firstHttpUrl("(https://github.com/o/r/pull/9)"), "https://github.com/o/r/pull/9");
|
|
345
|
+
// Multiple/nested UNBALANCED trailing closers + prose punctuation are all peeled off in one linear
|
|
346
|
+
// pass (guards the O(n) trim refactor — #813 review), while an inner balanced pair is preserved.
|
|
347
|
+
assertEquals(
|
|
348
|
+
firstHttpUrl("(see [https://en.wikipedia.org/wiki/Nano_(technology)])."),
|
|
349
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
350
|
+
);
|
|
351
|
+
assertEquals(firstHttpUrl("look: {[(https://example.test/a)]}"), "https://example.test/a");
|
|
352
|
+
// An apostrophe is a valid URL sub-delimiter and preserved WITHIN the URL (not truncated at it)…
|
|
353
|
+
assertEquals(
|
|
354
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/It's_a_Wonderful_Life here"),
|
|
355
|
+
"https://en.wikipedia.org/wiki/It's_a_Wonderful_Life",
|
|
356
|
+
);
|
|
357
|
+
// …while a TRAILING apostrophe (a prose quote closer) is still trimmed.
|
|
358
|
+
assertEquals(firstHttpUrl("the link 'https://example.test/a' works"), "https://example.test/a");
|
|
359
|
+
// Scheme match is case-insensitive (RFC 3986 schemes are case-insensitive).
|
|
360
|
+
assertEquals(firstHttpUrl("see HTTPS://github.com/o/r/pull/5 now"), "HTTPS://github.com/o/r/pull/5");
|
|
361
|
+
assertEquals(firstHttpUrl("Http://example.test/b"), "Http://example.test/b");
|
|
362
|
+
// First wins when several are present.
|
|
363
|
+
assertEquals(firstHttpUrl("a https://one.test b https://two.test"), "https://one.test");
|
|
364
|
+
// No URL / non-string → null (preserves today's linkless behaviour).
|
|
365
|
+
assertEquals(firstHttpUrl("merge the PR opened by adopt-console"), null);
|
|
366
|
+
assertEquals(firstHttpUrl(undefined), null);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("deliveryHumanContextUrl: lifts a prompt-embedded URL onto the task link (base + __esc twin), else null (#813)", () => {
|
|
370
|
+
const labels = {
|
|
371
|
+
"delivery-human-task__n7": "Review the adopt PR at https://github.com/nanobpm/nano-ide/pull/42 and merge it.",
|
|
372
|
+
"delivery-human-task__n8": "Confirm CI is green, then merge — no link here.",
|
|
373
|
+
};
|
|
374
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
375
|
+
// The bounded-timeout twin resolves the same node instruction.
|
|
376
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7__esc"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
377
|
+
// A node whose instruction names no URL yields null (the linkless default).
|
|
378
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n8"), null);
|
|
379
|
+
assertEquals(deliveryHumanContextUrl(undefined, "delivery-human-task__n1"), null);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("deliveryHumanContextUrl: declines to link a redaction-altered URL, links a credential-free one (#814)", () => {
|
|
383
|
+
// The stored `human_labels` are display-REDACTED via the SAME `redactFreeText` `buildHumanLabels` uses,
|
|
384
|
+
// so build the labels through it to mirror production exactly.
|
|
385
|
+
const withQuery = redactFreeText("Review the checks tab at https://example.test/o/r/pull/5?tab=checks now");
|
|
386
|
+
const withCred = redactFreeText("Open https://user:pass@example.test/o/r/pull/5 to review");
|
|
387
|
+
const withFragment = redactFreeText("Jump to https://example.test/o/r/pull/5#files section");
|
|
388
|
+
const clean = redactFreeText("Review the PR at https://example.test/o/r/pull/5 and merge it");
|
|
389
|
+
const labels = {
|
|
390
|
+
"delivery-human-task__q": withQuery,
|
|
391
|
+
"delivery-human-task__c": withCred,
|
|
392
|
+
"delivery-human-task__f": withFragment,
|
|
393
|
+
"delivery-human-task__ok": clean,
|
|
394
|
+
};
|
|
395
|
+
// A redacted query/userinfo/fragment would publish a DIFFERENT, broken link → decline (null).
|
|
396
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__q"), null);
|
|
397
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__c"), null);
|
|
398
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__f"), null);
|
|
399
|
+
// A credential-free URL is untouched by redaction and links faithfully.
|
|
400
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__ok"), "https://example.test/o/r/pull/5");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still
|
|
327
404
|
// yields actionable guidance rather than null (which would leave the panel empty, issue #772).
|
|
328
405
|
assertEquals(
|
|
329
406
|
deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
|
package/app/deliveryHuman.ts
CHANGED
|
@@ -83,6 +83,98 @@ export function deliveryHumanContextQuestion(
|
|
|
83
83
|
return label || "A scheduled delivery-graph step is waiting to be completed.";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
|
|
87
|
+
* first `http(s)` URL embedded in the node's stored instruction, or `null` when it names none. The
|
|
88
|
+
* Tasks page renders the "Decision context" as PLAIN, non-clickable text (a `detail.fields` entry),
|
|
89
|
+
* and links only through the http(s)-gated `subject_url` `linkField` — so an author who wants the
|
|
90
|
+
* operator to have a clickable link (e.g. the PR to review) writes the URL into the node prompt and
|
|
91
|
+
* this lifts it onto `subject_url`. Uses the SAME exact-then-`__esc`-stripped lookup as the context
|
|
92
|
+
* question so the twin escalation task resolves to its real node's instruction. Returns `null` (not a
|
|
93
|
+
* fabricated link) when no URL is present, preserving today's linkless behaviour. */
|
|
94
|
+
export function deliveryHumanContextUrl(
|
|
95
|
+
humanLabels: Record<string, string> | undefined,
|
|
96
|
+
elementId: string,
|
|
97
|
+
): string | null {
|
|
98
|
+
const labels = humanLabels ?? {};
|
|
99
|
+
const base = elementId.replace(/__esc$/, "");
|
|
100
|
+
const instruction = labels[elementId] ?? labels[base] ?? "";
|
|
101
|
+
const url = firstHttpUrl(instruction);
|
|
102
|
+
// The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
|
|
103
|
+
// carried a credential — `//user:pass@` userinfo, or a `?query`/`#fragment` (which can hide a signed/
|
|
104
|
+
// authenticating token) — has that component collapsed to the `***` sentinel (`//***@`, `?***`, `#***`).
|
|
105
|
+
// Lifting such an altered URL onto `subject_url` would publish a DIFFERENT, broken link (a truncated
|
|
106
|
+
// query changes the referenced resource; a signed URL becomes unusable). We cannot recover the real URL
|
|
107
|
+
// here — the raw prompt is deliberately NOT stored (issue #778) — so decline to link rather than silently
|
|
108
|
+
// publishing a redaction-mangled one. A URL with no credential component is untouched by redaction and
|
|
109
|
+
// links faithfully (issue #813 review). Callers wanting a clickable link should embed a credential-free
|
|
110
|
+
// URL in the prompt.
|
|
111
|
+
return url !== null && urlBearsRedactionMarker(url) ? null : url;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `***` sentinel {@link redactFreeText}/`redactString` leaves where it collapsed a URL's credential
|
|
115
|
+
* span — `//***@` (userinfo), `?***` (query), `#***` (fragment). Its presence in an extracted URL means
|
|
116
|
+
* redaction ALTERED the link, so it must not be published as a `subject_url` (issue #813 review). */
|
|
117
|
+
const URL_REDACTION_MARKER = /\/\/\*\*\*@|[?#]\*\*\*/;
|
|
118
|
+
|
|
119
|
+
function urlBearsRedactionMarker(url: string): boolean {
|
|
120
|
+
return URL_REDACTION_MARKER.test(url);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const URL_TRAILING_CLOSERS: Readonly<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
|
|
124
|
+
|
|
125
|
+
const URL_TRAILING_PUNCTUATION = ".,;:!?'\"";
|
|
126
|
+
|
|
127
|
+
/** Trim trailing prose punctuation a URL commonly picks up when embedded in a sentence (`.,;:!?'"` and
|
|
128
|
+
* a wrapping `)`/`]`/`}`), WITHOUT corrupting a URL that legitimately ends in a closing bracket. A
|
|
129
|
+
* closing bracket is only stripped when it is UNBALANCED within the URL (a prose wrapper, e.g. the `)`
|
|
130
|
+
* in "see (https://x/y)"); a balanced pair — e.g. Wikipedia `…/Foo_(disambiguation)` — is part of the
|
|
131
|
+
* URL and preserved, so the persisted link isn't truncated into a broken one. Bracket balance is
|
|
132
|
+
* tracked in running per-pair counts (seeded by one linear scan, then decremented as trailing closers
|
|
133
|
+
* are stripped) so the whole trim is a single O(n) pass — NOT the prior re-`split()`-the-prefix-each-
|
|
134
|
+
* step quadratic (issue #813 review). */
|
|
135
|
+
function trimUrlTrailingPunctuation(url: string): string {
|
|
136
|
+
const opens: Record<string, number> = { "(": 0, "[": 0, "{": 0 };
|
|
137
|
+
const closes: Record<string, number> = { ")": 0, "]": 0, "}": 0 };
|
|
138
|
+
for (const ch of url) {
|
|
139
|
+
if (ch in opens) opens[ch] += 1;
|
|
140
|
+
else if (ch in closes) closes[ch] += 1;
|
|
141
|
+
}
|
|
142
|
+
let end = url.length;
|
|
143
|
+
while (end > 0) {
|
|
144
|
+
const ch = url[end - 1];
|
|
145
|
+
const opener = URL_TRAILING_CLOSERS[ch];
|
|
146
|
+
if (opener !== undefined) {
|
|
147
|
+
// `opens`/`closes` reflect url[0:end]; only trailing closers are ever removed below, so they stay
|
|
148
|
+
// accurate. Balanced (opener count ≥ closer count) → the bracket belongs to the URL; stop.
|
|
149
|
+
if (opens[opener] >= closes[ch]) break;
|
|
150
|
+
closes[ch] -= 1;
|
|
151
|
+
end -= 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (URL_TRAILING_PUNCTUATION.includes(ch)) {
|
|
155
|
+
end -= 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
return url.slice(0, end);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Extract the first `http(s)` URL from free text, trimming trailing sentence punctuation/brackets a
|
|
164
|
+
* prose author commonly appends (`).,;` etc.) so the link resolves — but preserving a bracket that is
|
|
165
|
+
* BALANCED within the URL (e.g. Wikipedia `…_(technology)`), so a valid URL is never truncated into a
|
|
166
|
+
* broken task link. An apostrophe is a valid URL sub-delimiter (RFC 3986 §2.2), so it is allowed WITHIN
|
|
167
|
+
* the URL and only stripped when trailing (a prose quote closer) by `trimUrlTrailingPunctuation` — the
|
|
168
|
+
* same way a wrapping `)` is handled — so a link such as `…/It's_a_Wonderful_Life` is not truncated at
|
|
169
|
+
* the apostrophe (issue #813 review). The scheme match is case-insensitive (RFC 3986 schemes are
|
|
170
|
+
* case-insensitive). Returns `null` when none. */
|
|
171
|
+
export function firstHttpUrl(text: string | undefined | null): string | null {
|
|
172
|
+
if (typeof text !== "string") return null;
|
|
173
|
+
const match = text.match(/https?:\/\/[^\s<>"`]+/i);
|
|
174
|
+
if (!match) return null;
|
|
175
|
+
return trimUrlTrailingPunctuation(match[0]) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
86
178
|
/** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
|
|
87
179
|
* declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
|
|
88
180
|
export const GENERIC_HUMAN_FORM = "delivery-human-generic";
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -956,3 +956,225 @@ test("fetchBranchHead: percent-encodes a special-character branch ref (preservin
|
|
|
956
956
|
);
|
|
957
957
|
assertEquals(sha, "cafef00d");
|
|
958
958
|
});
|
|
959
|
+
|
|
960
|
+
// ── No-advance self-heal transports (issue #818) ────────────────────────────
|
|
961
|
+
// The self-heal's compare (`compareCommits`) and NON-force ref move (`updateBranchRef`) are
|
|
962
|
+
// safety-critical: a regression in the ahead/behind parsing or the `force:false` / 422-refusal
|
|
963
|
+
// handling silently changes whether a recovery mutates a PR head. These pin the token transport of
|
|
964
|
+
// both — a request-capturing fetch stub so the method/body/path and the refusal branch are covered.
|
|
965
|
+
async function withCapturingFetch<T>(
|
|
966
|
+
serve: (req: { path: string; method: string; body: unknown }) => { status: number; statusText?: string; body: unknown },
|
|
967
|
+
fn: () => Promise<T>,
|
|
968
|
+
): Promise<T> {
|
|
969
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
970
|
+
const prevFetch = globalThis.fetch;
|
|
971
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
972
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
973
|
+
const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
|
|
974
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
975
|
+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
976
|
+
const { status, statusText, body: resp } = serve({ path, method, body });
|
|
977
|
+
return Promise.resolve(new Response(JSON.stringify(resp), { status, statusText }));
|
|
978
|
+
}) as typeof fetch;
|
|
979
|
+
try {
|
|
980
|
+
return await fn();
|
|
981
|
+
} finally {
|
|
982
|
+
globalThis.fetch = prevFetch;
|
|
983
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
984
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
test("compareCommits: parses an 'ahead' fast-forward response and encodes the compare refs", async () => {
|
|
989
|
+
const cmp = await withCapturingFetch(
|
|
990
|
+
({ path, method }) => {
|
|
991
|
+
assertEquals(method, "GET");
|
|
992
|
+
// Each ref segment is percent-encoded (`#` → %23) around the compare API's own `...` separator.
|
|
993
|
+
assertEquals(path, "o/r/compare/feat/x%23123...deadbeef");
|
|
994
|
+
return { status: 200, body: { status: "ahead", ahead_by: 3, behind_by: 0 } };
|
|
995
|
+
},
|
|
996
|
+
() => compareCommits("o/r", "feat/x#123", "deadbeef", "tok"),
|
|
997
|
+
);
|
|
998
|
+
assertEquals(cmp, { status: "ahead", aheadBy: 3, behindBy: 0 });
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("compareCommits: coerces an unknown status to 'diverged' and defaults missing counts to 0", async () => {
|
|
1002
|
+
const cmp = await withCapturingFetch(
|
|
1003
|
+
() => ({ status: 200, body: { status: "wat" } }),
|
|
1004
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1005
|
+
);
|
|
1006
|
+
assertEquals(cmp, { status: "diverged", aheadBy: 0, behindBy: 0 });
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Reject COERCED/UNSAFE compare counts at the transport boundary (Copilot review of #819). A bare
|
|
1010
|
+
// `Number(...)` would turn a malformed/tampered response into a spurious ancestry proof — a string
|
|
1011
|
+
// `"1"` or boolean `true` coerces to `1`, and an unsafe magnitude passes `Number.isInteger` — either
|
|
1012
|
+
// of which could fast-forward the ref WITHOUT a genuine GitHub proof. A present count must be a real,
|
|
1013
|
+
// non-negative SAFE integer number; anything else throws so the self-heal escalates rather than heals.
|
|
1014
|
+
test("compareCommits: rejects a string-coerced count (throws, never a spurious integer proof)", async () => {
|
|
1015
|
+
await assertRejects(
|
|
1016
|
+
() =>
|
|
1017
|
+
withCapturingFetch(
|
|
1018
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: "1", behind_by: 0 } }),
|
|
1019
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1020
|
+
),
|
|
1021
|
+
Error,
|
|
1022
|
+
"invalid ahead_by",
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("compareCommits: rejects a boolean-coerced count", async () => {
|
|
1027
|
+
await assertRejects(
|
|
1028
|
+
() =>
|
|
1029
|
+
withCapturingFetch(
|
|
1030
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: 1, behind_by: true } }),
|
|
1031
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1032
|
+
),
|
|
1033
|
+
Error,
|
|
1034
|
+
"invalid behind_by",
|
|
1035
|
+
);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("compareCommits: rejects an unsafe-magnitude integer count (passes Number.isInteger but is not a safe integer)", async () => {
|
|
1039
|
+
await assertRejects(
|
|
1040
|
+
() =>
|
|
1041
|
+
withCapturingFetch(
|
|
1042
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: 1e21, behind_by: 0 } }),
|
|
1043
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1044
|
+
),
|
|
1045
|
+
Error,
|
|
1046
|
+
"invalid ahead_by",
|
|
1047
|
+
);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test("compareCommits: rejects a negative count", async () => {
|
|
1051
|
+
await assertRejects(
|
|
1052
|
+
() =>
|
|
1053
|
+
withCapturingFetch(
|
|
1054
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: -3, behind_by: 0 } }),
|
|
1055
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1056
|
+
),
|
|
1057
|
+
Error,
|
|
1058
|
+
"invalid ahead_by",
|
|
1059
|
+
);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("compareCommits: no token → null (idle transport, never a throw)", async () => {
|
|
1063
|
+
const cmp = await withCapturingFetch(
|
|
1064
|
+
() => {
|
|
1065
|
+
throw new Error("fetch must not be called without a token");
|
|
1066
|
+
},
|
|
1067
|
+
() => compareCommits("o/r", "base", "head", ""),
|
|
1068
|
+
);
|
|
1069
|
+
assertEquals(cmp, null);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("compareCommits: a non-2xx transport response throws (an outage is not a silent 'diverged')", async () => {
|
|
1073
|
+
await assertRejects(
|
|
1074
|
+
() =>
|
|
1075
|
+
withCapturingFetch(
|
|
1076
|
+
() => ({ status: 500, statusText: "Server Error", body: {} }),
|
|
1077
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1078
|
+
),
|
|
1079
|
+
Error,
|
|
1080
|
+
"500",
|
|
1081
|
+
);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("updateBranchRef: a successful fast-forward PATCHes force:false and returns true", async () => {
|
|
1085
|
+
let seen: { path: string; method: string; body: unknown } | undefined;
|
|
1086
|
+
const moved = await withCapturingFetch(
|
|
1087
|
+
(req) => {
|
|
1088
|
+
seen = req;
|
|
1089
|
+
return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
|
|
1090
|
+
},
|
|
1091
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1092
|
+
);
|
|
1093
|
+
assertEquals(moved, true);
|
|
1094
|
+
assertEquals(seen?.method, "PATCH", "the ref move is a PATCH");
|
|
1095
|
+
assertEquals(seen?.path, "o/r/git/refs/heads/feat/x", "targeting the head ref");
|
|
1096
|
+
// The whole point of the self-heal transport: it NEVER force-updates — GitHub must be free to
|
|
1097
|
+
// refuse a non-fast-forward. A regression flipping this to force:true would rewrite history.
|
|
1098
|
+
assertEquals(seen?.body, { sha: "deadbeef", force: false });
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test("updateBranchRef: GitHub's 422 non-fast-forward refusal returns false, never throws", async () => {
|
|
1102
|
+
const moved = await withCapturingFetch(
|
|
1103
|
+
() => ({ status: 422, statusText: "Unprocessable Entity", body: { message: "Update is not a fast forward" } }),
|
|
1104
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1105
|
+
);
|
|
1106
|
+
assertEquals(moved, false, "a refused non-fast-forward is a safe false (→ the caller escalates)");
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("updateBranchRef: a non-422 transport error throws (a real outage is not a silent refusal)", async () => {
|
|
1110
|
+
await assertRejects(
|
|
1111
|
+
() =>
|
|
1112
|
+
withCapturingFetch(
|
|
1113
|
+
() => ({ status: 500, statusText: "Server Error", body: { message: "boom" } }),
|
|
1114
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1115
|
+
),
|
|
1116
|
+
Error,
|
|
1117
|
+
"500",
|
|
1118
|
+
);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("updateBranchRef: no token in token mode throws (no usable transport to move the ref)", async () => {
|
|
1122
|
+
await assertRejects(
|
|
1123
|
+
() =>
|
|
1124
|
+
withCapturingFetch(
|
|
1125
|
+
() => {
|
|
1126
|
+
throw new Error("fetch must not be called without a token");
|
|
1127
|
+
},
|
|
1128
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", ""),
|
|
1129
|
+
),
|
|
1130
|
+
Error,
|
|
1131
|
+
"no GitHub transport available",
|
|
1132
|
+
);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// ── updateBranchRef compare-and-swap (Copilot review on #819) ────────────────
|
|
1136
|
+
// When `expectedSha` is given the move is a CAS on the ref's prior value: read the ref right before
|
|
1137
|
+
// the PATCH and refuse unless it still equals the validated base, so a head advanced/replaced by a
|
|
1138
|
+
// concurrent (superseded) run after the caller's fast-forward proof is never PATCHed.
|
|
1139
|
+
test("updateBranchRef: CAS advances when the ref still equals expectedSha (reads then PATCHes)", async () => {
|
|
1140
|
+
const seen: string[] = [];
|
|
1141
|
+
const moved = await withCapturingFetch(
|
|
1142
|
+
(req) => {
|
|
1143
|
+
seen.push(`${req.method} ${req.path}`);
|
|
1144
|
+
if (req.method === "GET") return { status: 200, body: { object: { sha: "basebase" } } };
|
|
1145
|
+
return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
|
|
1146
|
+
},
|
|
1147
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1148
|
+
);
|
|
1149
|
+
assertEquals(moved, true, "the ref still pointed at the validated base, so the fast-forward applied");
|
|
1150
|
+
assertEquals(
|
|
1151
|
+
seen,
|
|
1152
|
+
["GET o/r/git/ref/heads/feat/x", "PATCH o/r/git/refs/heads/feat/x"],
|
|
1153
|
+
"the CAS reads the ref before mutating it",
|
|
1154
|
+
);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("updateBranchRef: CAS refuses (false, no PATCH) when the ref has moved off expectedSha", async () => {
|
|
1158
|
+
const methods: string[] = [];
|
|
1159
|
+
const moved = await withCapturingFetch(
|
|
1160
|
+
(req) => {
|
|
1161
|
+
methods.push(req.method);
|
|
1162
|
+
if (req.method === "GET") return { status: 200, body: { object: { sha: "movedmoved" } } };
|
|
1163
|
+
throw new Error("must not PATCH once the compare-and-swap has lost");
|
|
1164
|
+
},
|
|
1165
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1166
|
+
);
|
|
1167
|
+
assertEquals(moved, false, "a ref moved off the validated base refuses the move (→ the caller escalates)");
|
|
1168
|
+
assertEquals(methods, ["GET"], "a lost CAS never issues the PATCH");
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("updateBranchRef: CAS refuses (false) when the ref is absent/unreadable (null)", async () => {
|
|
1172
|
+
const moved = await withCapturingFetch(
|
|
1173
|
+
(req) => {
|
|
1174
|
+
if (req.method === "GET") return { status: 404, statusText: "Not Found", body: { message: "Not Found" } };
|
|
1175
|
+
throw new Error("must not PATCH when the base ref is gone");
|
|
1176
|
+
},
|
|
1177
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1178
|
+
);
|
|
1179
|
+
assertEquals(moved, false, "a deleted/unreadable base ref is not the validated state, so the move is refused");
|
|
1180
|
+
});
|