@bongos/core 1.21.103 → 1.21.105

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.103",
6
- "core_contract": "1.21.103",
7
- "source_commit": "56f5f9df610b5d9079d28702989bf24a9a462db9",
5
+ "core_version": "1.21.105",
6
+ "core_contract": "1.21.105",
7
+ "source_commit": "12969410e588a86536f6426de5dcd95390b3be6b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T13:27:17.718Z",
9
+ "built_at": "2026-10-09T14:04:06.264Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 592,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3020,
14
+ "functional_verbatim": 3021,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3641,
20
- "tree_sha256": "3e52cfca3b8385e564d956ceaae717fbedb093d4db368a3c960d3f6db8c6139a",
19
+ "file_count": 3642,
20
+ "tree_sha256": "10dd1c422b8f42c90e7521862acb305b14d72053ddbe6c75511033ab149b694b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2872,7 +2872,7 @@
2872
2872
  {
2873
2873
  "path": "docs/module-api-changelog.md",
2874
2874
  "mode": "0000644",
2875
- "sha256": "53e81515be4483fdb9a4d361335cbe9bac0efb82bd28946cf6b70d0ca28b0b3e"
2875
+ "sha256": "9e09c3f6c9d01b93d3a0857d2115578a4e2268491278f11656efcf499c20b766"
2876
2876
  },
2877
2877
  {
2878
2878
  "path": "docs/modules-contract.md",
@@ -6832,7 +6832,7 @@
6832
6832
  {
6833
6833
  "path": "modules/lifecycle/check-rollup.js",
6834
6834
  "mode": "0000644",
6835
- "sha256": "3a69defc4e2157c104c9a95d2b67f4b68053c0a0e049683ddd9eb970d3f69871"
6835
+ "sha256": "9109d29e5fc6739419c6584d4be2990516c2736c398ed8e3b916bb8b4627e0bb"
6836
6836
  },
6837
6837
  {
6838
6838
  "path": "modules/lifecycle/claim-eligibility.js",
@@ -6992,7 +6992,7 @@
6992
6992
  {
6993
6993
  "path": "modules/lifecycle/github-push.js",
6994
6994
  "mode": "0000644",
6995
- "sha256": "e52b3d90814272cacce172bccd24f437bac666dcb593b4604d435a5504c18ba7"
6995
+ "sha256": "65386fff8736c1b55c1f105c595758161e718352077603d1ba9aa7dd05d6d1d6"
6996
6996
  },
6997
6997
  {
6998
6998
  "path": "modules/lifecycle/goal-advisory.js",
@@ -10137,12 +10137,12 @@
10137
10137
  {
10138
10138
  "path": "package-lock.json",
10139
10139
  "mode": "0000644",
10140
- "sha256": "bc23786ce9a171a96d049bc9568d8a73e513c0aa66783cedae0e51d4b16b6d93"
10140
+ "sha256": "967618b349cf51650bbb10bb29c9304205ce8b84b0a61b0f26a84ff6b65751e8"
10141
10141
  },
10142
10142
  {
10143
10143
  "path": "package.json",
10144
10144
  "mode": "0000644",
10145
- "sha256": "923e849fd1ededbb5563d4c81dc9013c308a99a53f9d8595724fa8b35e831914"
10145
+ "sha256": "6312c6a7441ab5c39cb567c6272d4a753edf0e2896a53b6cbb83e8c0713866d9"
10146
10146
  },
10147
10147
  {
10148
10148
  "path": "public-docs/index.html",
@@ -10162,7 +10162,7 @@
10162
10162
  {
10163
10163
  "path": "release-notes.json",
10164
10164
  "mode": "0000644",
10165
- "sha256": "5f152e713ca6f702119fe887e076a370e72ad380cd3c880ef56683202f3b573f"
10165
+ "sha256": "7a99e0dae60fe796637cc0190ef51e49b0ff707b1a193bdab8b7f9444b953134"
10166
10166
  },
10167
10167
  {
10168
10168
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11642,7 +11642,7 @@
11642
11642
  {
11643
11643
  "path": "scripts/gds/ship-flow.js",
11644
11644
  "mode": "0000644",
11645
- "sha256": "d114ae690d86270db76770fc3531a5530d1ab64a031a2528368864f4b330ac56"
11645
+ "sha256": "ad768320095b1d6693e6da5cee1f8e857ac69cbbfbea0a64dfeaa7092c432de2"
11646
11646
  },
11647
11647
  {
11648
11648
  "path": "scripts/gds/ship-git.js",
@@ -11672,7 +11672,7 @@
11672
11672
  {
11673
11673
  "path": "scripts/gds/ship-land.js",
11674
11674
  "mode": "0000644",
11675
- "sha256": "e518039d82343b5ff3bb21e1a6e0a5a8e0b9368d05b791be4910d76ea075378e"
11675
+ "sha256": "0b60f062ccf052a0eecb71fff3e0abf3c75cb2399786a286ab54df4091fff787"
11676
11676
  },
11677
11677
  {
11678
11678
  "path": "scripts/gds/ship-memory-sync.js",
@@ -11722,7 +11722,7 @@
11722
11722
  {
11723
11723
  "path": "scripts/gds/ship-visual.js",
11724
11724
  "mode": "0000644",
11725
- "sha256": "21ce15f1e49388bb3f8a46b9cfd8b05b23b77efbe93228ca04d9b13740e6d901"
11725
+ "sha256": "a97c5628609b622ad8972e93af13ff32a85138c350756f4895bea24d469bc2fc"
11726
11726
  },
11727
11727
  {
11728
11728
  "path": "scripts/gds/ship.js",
@@ -12437,7 +12437,7 @@
12437
12437
  {
12438
12438
  "path": "src/module-api.js",
12439
12439
  "mode": "0000644",
12440
- "sha256": "0228b28afc1fe4d1cd2a3e59ceeb9411a5ac0477bbdf95baf8a8de2100c2863a"
12440
+ "sha256": "9f15b0adaa8dea1358b3c6d154c7ff2b378ce4bb7c8ba23c21f611861836aa11"
12441
12441
  },
12442
12442
  {
12443
12443
  "path": "src/module-loader/catalog.js",
@@ -14092,7 +14092,7 @@
14092
14092
  {
14093
14093
  "path": "tests/github_push_merge_driver.mjs",
14094
14094
  "mode": "0000644",
14095
- "sha256": "c4783b6e8171f96f4a83520cfc4335a313dbfce4702cccea6972e520d6f08deb"
14095
+ "sha256": "5bf4d9ab391f0e457ec11b47197dd812487098d120f8fbba87dbddf7e23e92b9"
14096
14096
  },
14097
14097
  {
14098
14098
  "path": "tests/github_push_stderr_truncation.mjs",
@@ -17444,6 +17444,11 @@
17444
17444
  "mode": "0000644",
17445
17445
  "sha256": "75aa59accad12d197202457ed36281e5d3eca1040762a5045972c4b10cf09006"
17446
17446
  },
17447
+ {
17448
+ "path": "tests/ship_visual_regrade.mjs",
17449
+ "mode": "0000644",
17450
+ "sha256": "4e469f6f5801032dff196a57a84eeb3603e7a53760144a7ed4d582699c9747f9"
17451
+ },
17447
17452
  {
17448
17453
  "path": "tests/ship_zero_commit.mjs",
17449
17454
  "mode": "0000644",
@@ -3043,5 +3043,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3043
3043
  landed since 1.21.101 with no explicit bump. run 37935415352. (task 1002620)
3044
3044
  1.21.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3045
3045
  landed since 1.21.102 with no explicit bump. run 37936846665. (task 1002620)
3046
+ 1.21.104 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3047
+ landed since 1.21.103 with no explicit bump. run 37939497390. (task 1002620)
3048
+ 1.21.105 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3049
+ landed since 1.21.104 with no explicit bump. run 37941330141. (task 1002620)
3046
3050
  ---------------------------------------------------------------------------
3047
3051
  ```
@@ -39,4 +39,14 @@ function readCheckRollup(rollup) {
39
39
  };
40
40
  }
41
41
 
42
- module.exports = { readCheckRollup, UNIT_REPORT_CONTEXT };
42
+ // The conclusions that mean a check really FAILED (task 1004666). One set for both
43
+ // readers: the laptop's ship-land.js (task 1003764) and the server's mergeBlockReason.
44
+ // readCheckRollup maps a status context's state into `conclusion`, so PENDING and
45
+ // EXPECTED (posted but unfinished, or required but not yet reported) land there too —
46
+ // those are waiting, not red. The server reader used to count anything outside
47
+ // SUCCESS/NEUTRAL/SKIPPED as failing, which named a still-running check as the reason
48
+ // a PR was red and sent the publish reconciler after a regen-heal it did not need.
49
+ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
50
+ const isRedCheck = (c) => !!c && RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase());
51
+
52
+ module.exports = { readCheckRollup, UNIT_REPORT_CONTEXT, RED_CONCLUSIONS, isRedCheck };
@@ -40,7 +40,7 @@ const os = require('node:os');
40
40
  const path = require('node:path');
41
41
  const { promisify } = require('node:util');
42
42
  const { repoInfo, userAgent } = require('../../src/module-api');
43
- const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('./check-rollup');
43
+ const { readCheckRollup, UNIT_REPORT_CONTEXT, isRedCheck } = require('./check-rollup'); // isRedCheck: only a FAILED check is red, never a pending one (task 1004666)
44
44
  const { loadRepoInfo } = repoInfo;
45
45
 
46
46
  const execFileP = promisify(execFile);
@@ -951,10 +951,6 @@ async function getMergeState({ branch }, deps = {}) {
951
951
  };
952
952
  }
953
953
 
954
- // A non-passing check conclusion (everything that isn't a clean success/skip).
955
- const _BAD_CHECK = (c) =>
956
- c.conclusion && !['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(String(c.conclusion).toUpperCase());
957
-
958
954
  // Human-readable reason a not-yet-merged PR isn't mergeable, surfaced to a
959
955
  // credential-less session so it reports the truth instead of guessing.
960
956
  function mergeBlockReason(s) {
@@ -967,7 +963,7 @@ function mergeBlockReason(s) {
967
963
  if (s.review_decision === 'CHANGES_REQUESTED') return 'changes_requested';
968
964
  if (s.review_decision === 'REVIEW_REQUIRED') return 'review_required';
969
965
  if (s.merge_state === 'BLOCKED') {
970
- const bad = s.checks.filter(_BAD_CHECK);
966
+ const bad = s.checks.filter(isRedCheck);
971
967
  if (bad.length) return `failing_check:${bad[0].name}`;
972
968
  // Red, and the failing check is one of the nulled nodes: say the list is blind
973
969
  // rather than guess "review or pending", which sent a ship retrying (task 1003988).
@@ -975,7 +971,7 @@ function mergeBlockReason(s) {
975
971
  return 'blocked_required_review_or_pending_check';
976
972
  }
977
973
  if (s.merge_state === 'UNSTABLE') {
978
- const bad = s.checks.filter(_BAD_CHECK);
974
+ const bad = s.checks.filter(isRedCheck);
979
975
  return bad.length ? `non_required_check_failing:${bad[0].name}` : 'non_required_check_pending';
980
976
  }
981
977
  if (s.mergeable === 'UNKNOWN' || s.merge_state === 'UNKNOWN' || s.merge_state == null) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.103",
3
+ "version": "1.21.105",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.103",
9
+ "version": "1.21.105",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.103",
3
+ "version": "1.21.105",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9464,5 +9464,17 @@
9464
9464
  "id": "1004684",
9465
9465
  "text": "New house rule, Slop Bongo: when an AI realises it was wrong about something, it now says 'Bongo Slop Detected, Filing Bug' in chat and files a bug against the doc or message that misled it, so the same mistake stops repeating"
9466
9466
  }
9467
+ ],
9468
+ "1.21.104": [
9469
+ {
9470
+ "id": "1004665",
9471
+ "text": "When a builder re-runs a review and attaches a screenshot, the screenshot now actually gets attached to the task. Before, it was quietly thrown away, so reviewers asking for a picture never saw one unless someone added it by h"
9472
+ }
9473
+ ],
9474
+ "1.21.105": [
9475
+ {
9476
+ "id": "1004666",
9477
+ "text": "A check that is still running is no longer reported as a failed one. Before, a project whose test service posts a \"still working\" status would see its change called broken and trigger a pointless repair attempt; now it is corr"
9478
+ }
9467
9479
  ]
9468
9480
  }
@@ -139,7 +139,7 @@ async function shipMain() {
139
139
  const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
140
140
  if (action.action === 'resume-grade') {
141
141
  console.log(`Task #${taskId} is at 'completed' (grade stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the grade (no re-claim needed).`);
142
- return regradeMain();
142
+ return regradeMain({ visual });
143
143
  }
144
144
  if (action.action === 'resume-merge') {
145
145
  console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
@@ -822,12 +822,12 @@ async function persistRegradeSummary({ taskId, summaryArg, task }) {
822
822
  // promotes completed→confirmed + awards credits on pass, idempotent)
823
823
  // - on pass: the normal confirmed→shipped merge + sync tail runs.
824
824
 
825
- async function regradeMain() {
825
+ async function regradeMain(opts = {}) {
826
826
  await requireSession();
827
827
  const args = process.argv.slice(2);
828
828
  const taskId = Number(args[0]);
829
829
  if (!Number.isFinite(taskId)) {
830
- console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
830
+ console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--visual <image>] [--visual-alt "caption"] [--no-merge] [--force] [--verified] [--base <ref>]');
831
831
  process.exit(2);
832
832
  }
833
833
  // task 1003702: refuse --notes rather than swallowing it. The reasoning — why
@@ -840,6 +840,16 @@ async function regradeMain() {
840
840
  for (const line of regradeNotesRefusal(taskId)) console.error(line);
841
841
  process.exit(2);
842
842
  }
843
+ // task 1004665: --visual used to be screened by shipMain and then dropped here
844
+ // without a word — this path returned before shipMain's upload, so visual_url
845
+ // stayed null (task 1004351). Screen it at the same early position as the
846
+ // --notes refusal (bad input costs no grade spend), and attach it on every
847
+ // path below that goes on to grade or land. shipMain's resume-grade dispatch
848
+ // hands over the visual it already screened rather than reading the file twice.
849
+ const visual = opts.visual !== undefined ? opts.visual : shipVisual.fromArgs(args, { regrade: true });
850
+ const attachVisual = async () => (visual
851
+ ? shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine })
852
+ : false);
843
853
  const regradeProse = resolveProse(args, ['summary', 'response']);
844
854
  const summaryArg = regradeProse.summary;
845
855
  // task 1002662: the builder's response to the prior round's findings — how
@@ -908,6 +918,7 @@ async function regradeMain() {
908
918
  // only re-publishes the branch (the recovery for a publish that failed).
909
919
  if (tweakHold.isHeldForArtist(task, tRes.data.grade ? tRes.data.grade.passed : null)) {
910
920
  console.log(`Task #${taskId} already passed its grade and is waiting for the artist: re-publishing its branch, not re-grading.`);
921
+ await attachVisual();
911
922
  await finishShip({
912
923
  taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
913
924
  card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
@@ -989,6 +1000,7 @@ async function regradeMain() {
989
1000
  console.log('');
990
1001
  console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
991
1002
  console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
1003
+ await attachVisual();
992
1004
  // A kind='verify' task cannot reach here without --verified: the gate above
993
1005
  // exits first, so the server's verify_attestation_required can't fire below.
994
1006
  const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
@@ -1060,6 +1072,9 @@ async function regradeMain() {
1060
1072
  // the panel runs afterwards. The row, the grader prompt, the merge commit and
1061
1073
  // the PR then all carry the same sentence.
1062
1074
  await persistRegradeSummary({ taskId, summaryArg, task });
1075
+ // task 1004665: the picture goes up at the same point and for the same reason
1076
+ // as the summary — after every guard that can still refuse, before the panel.
1077
+ await attachVisual();
1063
1078
 
1064
1079
  // ---- Delta-aware prior-round context (task 1002662) ----
1065
1080
  // The prior grade's findings + the builder's response + the interim fix diff
@@ -18,7 +18,7 @@ const { normalizeApiError } = require('./ship-io.js');
18
18
  const { plainCardLines } = require('./plain-cards.js');
19
19
  // The server's own reader of a PR's check rollup (task 1003988), reused so the laptop
20
20
  // poll names a red PR exactly the way publish-status does (task 1003764). Dependency-free.
21
- const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycle/check-rollup.js');
21
+ const { readCheckRollup, UNIT_REPORT_CONTEXT, RED_CONCLUSIONS } = require('../../modules/lifecycle/check-rollup.js');
22
22
  const { prMergeSentence } = require('../../modules/lifecycle/page-tweak-hold.js');
23
23
 
24
24
  // ---------- the land outcome (task 1002541, part 1) ----------
@@ -474,7 +474,7 @@ function redBlocksMerge(md) {
474
474
  // A check conclusion (CheckRun) or state (status context) that means FAILED. Pending and
475
475
  // expected are waiting, not red — reporting them as failures would be the mirror image of
476
476
  // the bug this names (a failure dressed as waiting).
477
- const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
477
+ // The set itself lives in check-rollup.js, shared with the server's mergeBlockReason (task 1004666).
478
478
 
479
479
  // localMergeDriver — task 1003764. The laptop path watches its PR with the builder's own
480
480
  // gh, and read only state + mergeCommit, so a red required check was invisible for the
@@ -28,17 +28,24 @@ const taskVisuals = require('../../modules/lifecycle/task-visuals');
28
28
  // EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
29
29
  // costs the builder a retyped command, not a claim. Pure enough to reason about;
30
30
  // the exit is the deliberate CLI edge.
31
- function fromArgs(args) {
31
+ //
32
+ // `regrade: true` is the `ship.js <id> --regrade` caller (task 1004665). There is
33
+ // no claim left to resolve on that path, so the wording names what IS true there:
34
+ // the task stays at completed, and the picture goes up before the grade runs.
35
+ function fromArgs(args, { regrade = false } = {}) {
32
36
  const file = arg('--visual', args) || '';
33
37
  if (!file) return null;
34
38
  const read = taskVisuals.readImageFile(file);
35
39
  if (!read.ok) {
36
- console.error(`Ship aborted: --visual ${file} — ${read.message}`);
37
- console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
40
+ console.error(`${regrade ? 'Re-grade' : 'Ship'} aborted: --visual ${file} — ${read.message}`);
41
+ console.error(regrade
42
+ ? 'Fix the path (or drop --visual: a visual is always optional). Nothing was graded; the task stays at completed.'
43
+ : 'Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
38
44
  process.exit(2);
39
45
  }
40
46
  const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
41
- console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
47
+ const when = regrade ? 'uploaded before the re-grade runs' : 'uploaded after the claim resolves';
48
+ console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — ${when}.`);
42
49
  return visual;
43
50
  }
44
51
 
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.103'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.105'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { strict as assert } from 'node:assert';
14
14
  import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
  const gh = require('../modules/lifecycle/github-push.js');
@@ -169,6 +170,51 @@ test('mergeBlockReason maps the common blocker states', () => {
169
170
  assert.equal(gh.mergeBlockReason({ ...open, merged: true }), null);
170
171
  });
171
172
 
173
+ // --- task 1004666: a check still running is waiting, not red --------------
174
+ //
175
+ // check-rollup maps a status context's state into `conclusion`, so PENDING and
176
+ // EXPECTED (posted but unfinished, or required but not yet reported) reached
177
+ // mergeBlockReason as conclusions. It counted anything outside SUCCESS/NEUTRAL/
178
+ // SKIPPED as failing, so a BLOCKED PR waiting on a third-party status read
179
+ // "failing_check:<context>" and the reconciler tried a regen-heal on it.
180
+
181
+ const ctx = (context, state) => ({ __typename: 'StatusContext', context, state, description: null });
182
+
183
+ test('1004666 a PENDING or EXPECTED status context on a BLOCKED PR reads as waiting', async () => {
184
+ for (const state of ['PENDING', 'EXPECTED']) {
185
+ const { fetchImpl } = mkFetch({ node: prNode({ mergeStateStatus: 'BLOCKED', rollup: 'PENDING', checks: [TRUST, ctx('ci/external', state)] }) });
186
+ const r = await gh.mergeIfGreen({ branch: 'claude/wt' }, DEPS(fetchImpl));
187
+ assert.equal(r.reason, 'blocked_required_review_or_pending_check', `${state} is not a failure`);
188
+ }
189
+ });
190
+
191
+ test('1004666 on an UNSTABLE PR a pending optional check is pending, not failing', () => {
192
+ const open = { pr_state: 'open', merged: false };
193
+ assert.equal(gh.mergeBlockReason({ ...open, merge_state: 'UNSTABLE', checks: [{ name: 'e2e', conclusion: 'PENDING' }] }), 'non_required_check_pending');
194
+ assert.equal(gh.mergeBlockReason({ ...open, merge_state: 'UNSTABLE', checks: [{ name: 'e2e', conclusion: 'TIMED_OUT' }] }), 'non_required_check_failing:e2e');
195
+ });
196
+
197
+ test('1004666 every red conclusion still names its check, past a pending one', () => {
198
+ for (const state of ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']) {
199
+ const open = { pr_state: 'open', merged: false, merge_state: 'BLOCKED' };
200
+ assert.equal(gh.mergeBlockReason({ ...open, checks: [{ name: 'ci/external', conclusion: 'PENDING' }, { name: 'unit', conclusion: state }] }), 'failing_check:unit', state);
201
+ }
202
+ });
203
+
204
+ test('1004666 the server and the laptop read red from ONE set', () => {
205
+ const rollup = require('../modules/lifecycle/check-rollup.js');
206
+ const land = fs.readFileSync(new URL('../scripts/gds/ship-land.js', import.meta.url), 'utf8');
207
+ const push = fs.readFileSync(new URL('../modules/lifecycle/github-push.js', import.meta.url), 'utf8');
208
+ assert.ok(land.includes("RED_CONCLUSIONS } = require('../../modules/lifecycle/check-rollup.js')"), 'ship-land imports the shared set');
209
+ assert.doesNotMatch(land, /const RED_CONCLUSIONS\s*=/, 'and no longer declares its own');
210
+ assert.doesNotMatch(push, /_BAD_CHECK|'SUCCESS', 'NEUTRAL', 'SKIPPED'/, 'the old allow-list reading is gone');
211
+ for (const c of ['PENDING', 'EXPECTED', 'SUCCESS', 'NEUTRAL', 'SKIPPED', 'STALE', null, undefined]) {
212
+ assert.equal(rollup.isRedCheck({ conclusion: c }), false, String(c));
213
+ }
214
+ assert.equal(rollup.isRedCheck({ conclusion: 'failure' }), true, 'case-insensitive like the old reader');
215
+ assert.equal(rollup.isRedCheck(null), false);
216
+ });
217
+
172
218
  // --- task 1003988: a red PR names what failed, or says it cannot see -------
173
219
  //
174
220
  // Without Checks:read GitHub nulls every CheckRun node and keeps the rollup state,
@@ -0,0 +1,167 @@
1
+ // tests/ship_visual_regrade.mjs — `ship.js <id> --regrade --visual <image>`
2
+ // attaches the picture (task 1004665).
3
+ //
4
+ // It used to be dropped without a word: shipMain screened --visual and then
5
+ // returned into regradeMain() before its own upload, and regradeMain never
6
+ // looked at the flag, so visual_url stayed null (hit on task 1004351, where a
7
+ // grader blocker asked for screenshots). These cases pin the screening wording
8
+ // on the re-grade path, the best-effort upload, and the wiring — every path
9
+ // regradeMain takes to grade or land attaches the visual first.
10
+ //
11
+ // Run: node tests/ship_visual_regrade.mjs
12
+ import { strict as assert } from 'node:assert';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const shipVisual = require('../scripts/gds/ship-visual.js');
21
+
22
+ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'), Buffer.alloc(64)]);
23
+ const TMP = mkdtempSync(path.join(os.tmpdir(), 'ship-visual-regrade-'));
24
+ const good = path.join(TMP, 'after.png');
25
+ writeFileSync(good, PNG);
26
+ process.on('exit', () => rmSync(TMP, { recursive: true, force: true }));
27
+
28
+ // Capture console output and turn process.exit into a throw, so the CLI edge of
29
+ // fromArgs can be asserted without ending the test process.
30
+ function capture(fn) {
31
+ const out = []; const err = []; const warn = [];
32
+ const saved = { log: console.log, error: console.error, warn: console.warn, exit: process.exit };
33
+ console.log = (...a) => out.push(a.join(' '));
34
+ console.error = (...a) => err.push(a.join(' '));
35
+ console.warn = (...a) => warn.push(a.join(' '));
36
+ process.exit = (code) => { const e = new Error(`exit ${code}`); e.exitCode = code; throw e; };
37
+ let result; let exitCode = null;
38
+ try {
39
+ result = fn();
40
+ } catch (e) {
41
+ if (e.exitCode === undefined) throw e;
42
+ exitCode = e.exitCode;
43
+ } finally {
44
+ Object.assign(console, { log: saved.log, error: saved.error, warn: saved.warn });
45
+ process.exit = saved.exit;
46
+ }
47
+ return { result, exitCode, out: out.join('\n'), err: err.join('\n'), warn: warn.join('\n') };
48
+ }
49
+
50
+ // ---- screening ------------------------------------------------------------
51
+
52
+ test('no --visual means null on both paths, and nothing printed', () => {
53
+ for (const opts of [undefined, { regrade: true }]) {
54
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade'], opts));
55
+ assert.equal(r.result, null);
56
+ assert.equal(r.out + r.err, '');
57
+ }
58
+ });
59
+
60
+ test('on --regrade a good image is screened and says it goes up BEFORE the grade', () => {
61
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', good, '--visual-alt', 'the after'], { regrade: true }));
62
+ assert.equal(r.exitCode, null);
63
+ assert.equal(r.result.contentType, 'image/png');
64
+ assert.equal(r.result.alt, 'the after');
65
+ assert.match(r.out, /uploaded before the re-grade runs/);
66
+ assert.doesNotMatch(r.out, /claim resolves/, 'a re-grade has no claim left to resolve');
67
+ });
68
+
69
+ test('a normal ship keeps its own wording', () => {
70
+ const r = capture(() => shipVisual.fromArgs(['1', '--visual', good]));
71
+ assert.match(r.out, /uploaded after the claim resolves/);
72
+ });
73
+
74
+ test('on --regrade a bad path aborts BEFORE any grade, with re-grade wording', () => {
75
+ const r = capture(() => shipVisual.fromArgs(['1', '--regrade', '--visual', path.join(TMP, 'missing.png')], { regrade: true }));
76
+ assert.equal(r.exitCode, 2);
77
+ assert.match(r.err, /^Re-grade aborted: --visual/);
78
+ assert.match(r.err, /Nothing was graded; the task stays at completed/);
79
+ assert.doesNotMatch(r.err, /No claim resolved/);
80
+ });
81
+
82
+ // ---- upload ---------------------------------------------------------------
83
+
84
+ function fakeClient(response) {
85
+ const calls = [];
86
+ return {
87
+ calls,
88
+ tasks: { postTasksIdVisual: async (req) => { calls.push(req); return response; } },
89
+ };
90
+ }
91
+
92
+ test('upload posts the image to the task and reports it stuck', async () => {
93
+ const client = fakeClient({ ok: true, status: 201, data: {} });
94
+ const visual = { buf: PNG, contentType: 'image/png', alt: 'cap', name: 'after.png' };
95
+ const r = await (async () => {
96
+ const saved = console.log; const out = [];
97
+ console.log = (...a) => out.push(a.join(' '));
98
+ try { return { ok: await shipVisual.upload({ taskId: 7, visual, client, errorLine: () => '' }), out }; } finally { console.log = saved; }
99
+ })();
100
+ assert.equal(r.ok, true);
101
+ assert.equal(client.calls.length, 1);
102
+ assert.equal(client.calls[0].id, 7);
103
+ assert.equal(client.calls[0].body.image_b64, PNG.toString('base64'));
104
+ assert.equal(client.calls[0].body.alt, 'cap');
105
+ });
106
+
107
+ test('a failed upload is a warning naming the manual re-attach, never a failure', async () => {
108
+ const client = fakeClient({ ok: false, status: 403, data: { error: 'nope' } });
109
+ const visual = { buf: PNG, contentType: 'image/png', alt: '', name: 'after.png' };
110
+ const saved = console.warn; const warn = [];
111
+ console.warn = (...a) => warn.push(a.join(' '));
112
+ let ok;
113
+ try { ok = await shipVisual.upload({ taskId: 7, visual, client, errorLine: (d, s) => `HTTP ${s}` }); } finally { console.warn = saved; }
114
+ assert.equal(ok, false);
115
+ assert.match(warn.join('\n'), /node scripts\/gds\/task\.js visual 7 <image>/);
116
+ });
117
+
118
+ test('no visual uploads nothing', async () => {
119
+ const client = fakeClient({ ok: true });
120
+ assert.equal(await shipVisual.upload({ taskId: 7, visual: null, client, errorLine: () => '' }), false);
121
+ assert.equal(client.calls.length, 0);
122
+ });
123
+
124
+ // ---- wiring ---------------------------------------------------------------
125
+
126
+ const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
127
+ const stripComments = (s) => s.replace(/^\s*\/\/[^\n]*$/gm, '');
128
+ const regrade = stripComments(src.slice(src.indexOf('async function regradeMain'), src.indexOf('module.exports')));
129
+ const ship = stripComments(src.slice(src.indexOf('async function shipMain'), src.indexOf('async function regradeMain')));
130
+
131
+ test('regradeMain screens --visual early, before the preflight and the panel', () => {
132
+ const screen = regrade.indexOf('shipVisual.fromArgs(args, { regrade: true })');
133
+ assert.ok(screen !== -1, 'regradeMain screens the flag itself');
134
+ assert.ok(screen < regrade.indexOf('preflight(claimLike'), 'before the preflight');
135
+ assert.ok(screen < regrade.indexOf('gradeAndRecord('), 'before any grade spend');
136
+ });
137
+
138
+ test('every path regradeMain takes to grade or land attaches the visual first', () => {
139
+ // Three ways out that do real work: the held re-publish, the artifact-free
140
+ // confirm, and the graded path. Each finishShip must be preceded by an attach
141
+ // that sits AFTER the previous finishShip — i.e. one attach per path.
142
+ const finishes = [...regrade.matchAll(/await finishShip\(/g)].map((m) => m.index);
143
+ const attaches = [...regrade.matchAll(/await attachVisual\(\)/g)].map((m) => m.index);
144
+ assert.equal(finishes.length, 3, 'three landing paths (update this pin if one is added)');
145
+ assert.equal(attaches.length, 3, 'one attach per landing path');
146
+ let prev = -1;
147
+ finishes.forEach((f, i) => {
148
+ assert.ok(attaches[i] > prev && attaches[i] < f, `landing path ${i + 1} attaches before it lands`);
149
+ prev = f;
150
+ });
151
+ // The graded path attaches before the panel runs, after the summary persists.
152
+ const graded = attaches[2];
153
+ assert.ok(regrade.indexOf('await persistRegradeSummary(') < graded, 'after every guard that can refuse');
154
+ assert.ok(graded < regrade.indexOf('gradeAndRecord('), 'before the grade');
155
+ // The artifact-free path attaches before the confirm call lands it.
156
+ assert.ok(attaches[1] < regrade.indexOf('postTasksIdConfirm('), 'before the manual confirm');
157
+ });
158
+
159
+ test('a plain re-ship that resumes the grade hands over the visual it screened', () => {
160
+ assert.match(ship, /return regradeMain\(\{ visual \}\)/);
161
+ assert.match(regrade, /opts\.visual !== undefined \? opts\.visual : shipVisual\.fromArgs/);
162
+ });
163
+
164
+ test('the re-grade usage line advertises --visual', () => {
165
+ const usage = regrade.slice(0, regrade.indexOf('process.exit(2)'));
166
+ assert.match(usage, /--regrade [^\n]*--visual <image>/);
167
+ });