@bongos/core 1.19.372 → 1.19.374
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 +54 -34
- package/.claude/skills/goal-create/SKILL.md +7 -4
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
- package/docs/api/openapi.json +79 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +6 -3
- package/docs/copy-registry.json +32 -5
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/lifecycle/goal-category.js +28 -0
- package/modules/lifecycle/routes/goals.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +41 -1
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/ship-land.js +94 -3
- package/src/module-api.js +5 -1
- package/tests/bump_version.mjs +77 -1
- package/tests/goal_category.mjs +28 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
package/scripts/gds/ship-land.js
CHANGED
|
@@ -406,6 +406,80 @@ function rerunGateReviewIfStale(branch, headSha) {
|
|
|
406
406
|
return ghArgvOk(['run', 'rerun', String(id)]);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
// ---------- why the push failed, and whether retrying can ever help ----------
|
|
410
|
+
//
|
|
411
|
+
// A failed `git push` already parks the task at `confirmed` and opens no PR,
|
|
412
|
+
// which is correct. What was wrong (task 1003293) was the ADVICE: "Fix the push
|
|
413
|
+
// and re-ship" is useless for the most common cause, because re-shipping fails
|
|
414
|
+
// identically every time and the reconciler cannot heal a branch that was never
|
|
415
|
+
// pushed. The task then sits at `confirmed` with its code on one laptop, looking
|
|
416
|
+
// like an ordinary pending land. That happened twice in one session — both times
|
|
417
|
+
// on a commit touching .github/workflows/, both times invisible until someone
|
|
418
|
+
// checked git instead of the log.
|
|
419
|
+
//
|
|
420
|
+
// So the distinction that matters is TERMINAL vs transient. A terminal failure
|
|
421
|
+
// must name the actual remedy; a transient one may honestly say "re-ship".
|
|
422
|
+
// `argvOk` runs the push with stdio:'inherit', so git's own message reaches the
|
|
423
|
+
// log but nothing ever reads it — these patterns read it.
|
|
424
|
+
const PUSH_DIAGNOSES = [
|
|
425
|
+
{
|
|
426
|
+
id: 'workflow_scope',
|
|
427
|
+
// GitHub's wording: "refusing to allow an OAuth App to create or update
|
|
428
|
+
// workflow `.github/workflows/x.yml` without `workflow` scope".
|
|
429
|
+
match: /without .{0,3}workflow.{0,3} scope|refusing to allow (?:an?|the) \S+ (?:App|integration)/i,
|
|
430
|
+
terminal: true,
|
|
431
|
+
why: 'this branch changes a file under .github/workflows/, and the credential git used has no `workflow` scope — GitHub refuses that push regardless of how often it is retried',
|
|
432
|
+
fix: 'push over SSH once, then re-run the ship:\n git push git@github.com:<owner>/<repo>.git <branch>\n (or re-authenticate the CLI with the `workflow` scope: gh auth refresh -h github.com -s workflow)',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'protected_branch',
|
|
436
|
+
match: /GH006|protected branch|required status check/i,
|
|
437
|
+
terminal: true,
|
|
438
|
+
why: 'branch protection refused the push outright',
|
|
439
|
+
fix: 'land through a pull request instead of pushing directly, or have an admin adjust the rule',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: 'auth',
|
|
443
|
+
match: /Authentication failed|could not read Username|Permission denied \(publickey\)|remote: Invalid username or password|403 Forbidden/i,
|
|
444
|
+
terminal: true,
|
|
445
|
+
why: 'git could not authenticate to the remote at all',
|
|
446
|
+
fix: 'restore the credential (gh auth login, or an SSH key the remote accepts), then re-run the ship',
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: 'stale_branch',
|
|
450
|
+
match: /non-fast-forward|fetch first|behind its remote counterpart|Updates were rejected/i,
|
|
451
|
+
terminal: false,
|
|
452
|
+
why: 'the remote branch has commits this checkout does not',
|
|
453
|
+
fix: 'git fetch && git rebase origin/<branch>, then re-run the ship',
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
// Pure: classify a failed push from git's stderr. Exported + tested.
|
|
458
|
+
// An unrecognised failure is reported as NON-terminal on purpose — "try again"
|
|
459
|
+
// is the safe default when we do not know, and a wrong "this can never work"
|
|
460
|
+
// would send someone hunting a problem that does not exist.
|
|
461
|
+
function pushDiagnosis(stderr) {
|
|
462
|
+
const text = String(stderr == null ? '' : stderr);
|
|
463
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
464
|
+
if (d.match.test(text)) return { id: d.id, terminal: d.terminal, why: d.why, fix: d.fix };
|
|
465
|
+
}
|
|
466
|
+
return { id: 'unknown', terminal: false, why: 'the push failed for a reason this lane does not recognise', fix: 'read git\'s message above, resolve it, then re-run the ship' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Push, capturing stderr so it can be diagnosed — and echoing it so the log still
|
|
470
|
+
// shows exactly what git said, which stdio:'inherit' gave us for free.
|
|
471
|
+
function pushCapturing(branch, deps = {}) {
|
|
472
|
+
const exec = deps.execFileSync || require('node:child_process').execFileSync;
|
|
473
|
+
try {
|
|
474
|
+
exec('git', ['push', 'origin', branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
475
|
+
return { ok: true, stderr: '' };
|
|
476
|
+
} catch (e) {
|
|
477
|
+
const stderr = [e && e.stderr, e && e.stdout].filter(Boolean).join('\n');
|
|
478
|
+
if (stderr) process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
|
479
|
+
return { ok: false, stderr };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
409
483
|
// ciLandLocal — today's local path: this machine pushes the branch + opens the PR
|
|
410
484
|
// + enables auto-merge via git + gh. async since task 1169 — it asks the server to
|
|
411
485
|
// post the gate-author-trust attestation between the push and the PR open.
|
|
@@ -413,9 +487,24 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
413
487
|
const headSha = gitOk('git rev-parse HEAD');
|
|
414
488
|
|
|
415
489
|
console.log(` pushing ${branch} to origin...`);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
490
|
+
const pushed = pushCapturing(branch);
|
|
491
|
+
if (!pushed.ok) {
|
|
492
|
+
const d = pushDiagnosis(pushed.stderr);
|
|
493
|
+
console.log(` ci-land: push FAILED — ${d.why}.`);
|
|
494
|
+
console.log(` ci-land: nothing was pushed and no PR exists; task stays at confirmed.`);
|
|
495
|
+
if (d.terminal) {
|
|
496
|
+
// The important line. Re-shipping cannot fix this, and the reconciler
|
|
497
|
+
// cannot heal a branch that never reached the remote — so do not say
|
|
498
|
+
// "re-ship" and leave the work stranded on one machine.
|
|
499
|
+
console.log(' ci-land: RE-SHIPPING WILL FAIL THE SAME WAY. Do this instead:');
|
|
500
|
+
console.log(` ${d.fix}`);
|
|
501
|
+
} else {
|
|
502
|
+
console.log(` ci-land: to retry — ${d.fix}`);
|
|
503
|
+
}
|
|
504
|
+
return landBailed(
|
|
505
|
+
`could not push ${branch} to origin (${d.id})`,
|
|
506
|
+
d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
|
|
507
|
+
);
|
|
419
508
|
}
|
|
420
509
|
|
|
421
510
|
// task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
|
|
@@ -545,6 +634,8 @@ function pollCiDeploy(taskId, branch, headSha) {
|
|
|
545
634
|
|
|
546
635
|
module.exports = {
|
|
547
636
|
localHistoryRewritten, // task 1003221 — the append-only discriminator
|
|
637
|
+
pushDiagnosis, // task 1003293 — a terminal push failure must not advise a retry
|
|
638
|
+
PUSH_DIAGNOSES,
|
|
548
639
|
ciLand,
|
|
549
640
|
classifyLandOutcome,
|
|
550
641
|
formatPublishFailure,
|
package/src/module-api.js
CHANGED
|
@@ -1174,8 +1174,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1174
1174
|
// landed since 1.19.370 with no explicit bump. run 32904582252. (task 1002620)
|
|
1175
1175
|
// 1.19.372 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1176
1176
|
// landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
|
|
1177
|
+
// 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1178
|
+
// landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
|
|
1179
|
+
// 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1180
|
+
// landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
|
|
1177
1181
|
// ---------------------------------------------------------------------------
|
|
1178
|
-
const CORE_VERSION = '1.19.
|
|
1182
|
+
const CORE_VERSION = '1.19.374'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1179
1183
|
|
|
1180
1184
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1181
1185
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/bump_version.mjs
CHANGED
|
@@ -16,7 +16,10 @@ import { createRequire } from 'node:module';
|
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
-
const
|
|
19
|
+
const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
|
|
20
|
+
const { bumpPatch, CONST_RE } = bump;
|
|
21
|
+
// The LIVE test below reads this repo's real release history (task 1003202).
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
20
23
|
|
|
21
24
|
const SAMPLE = [
|
|
22
25
|
'// prologue',
|
|
@@ -111,3 +114,76 @@ test('publish.yml wires the terminator: guard step present, every armed step gat
|
|
|
111
114
|
const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
|
|
112
115
|
assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
|
|
113
116
|
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// laneReleasedVersions — the token-free "is this version already published?"
|
|
120
|
+
// (task 1003202 / blocker 1000115, the OIDC trusted-publishing migration).
|
|
121
|
+
//
|
|
122
|
+
// publish.yml used to ask the REGISTRY, which needs a credential: @bongos/core is
|
|
123
|
+
// private, so an unauthenticated `npm view` is a flat E404 rather than a "no".
|
|
124
|
+
// OIDC authenticates `npm publish` and nothing else, so keeping that check would
|
|
125
|
+
// have reported "not taken" every run, skipped the auto-bump, and collided on the
|
|
126
|
+
// published version — working for exactly one release before breaking. git holds
|
|
127
|
+
// the same answer, needs no auth, and is the source the cascade terminator above
|
|
128
|
+
// already trusts.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('laneReleasedVersions reads the versions this lane announced', () => {
|
|
132
|
+
const subjects = [
|
|
133
|
+
'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
|
|
134
|
+
'Merge pull request #385 from lars589/some-branch',
|
|
135
|
+
'release: core 1.19.367 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
|
|
136
|
+
'feat(security): something unrelated',
|
|
137
|
+
];
|
|
138
|
+
const v = bump.laneReleasedVersions(subjects);
|
|
139
|
+
assert.deepEqual([...v].sort(), ['1.19.367', '1.19.368']);
|
|
140
|
+
assert.equal(v.has('1.19.369'), false, 'a version never announced must read as free');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a HUMAN release commit is NOT counted — that version still needs publishing', () => {
|
|
144
|
+
// The same distinction RELEASE_COMMIT_RE was written to make for the cascade
|
|
145
|
+
// terminator. Counting it here would skip the publish of a hand-cut release.
|
|
146
|
+
const v = bump.laneReleasedVersions([
|
|
147
|
+
'release: core 1.19.2 — carrier for the init-blocker fixes',
|
|
148
|
+
'release: core 1.20.0 — hand-cut minor',
|
|
149
|
+
]);
|
|
150
|
+
assert.equal(v.size, 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('malformed and empty input yields an empty set, never a throw', () => {
|
|
154
|
+
// This runs inside the publish lane. A crash here would take down releases;
|
|
155
|
+
// an empty set degrades to "nothing is taken", which the publish itself
|
|
156
|
+
// catches with EPUBLISHCONFLICT — loud, and nothing wrong ships.
|
|
157
|
+
assert.equal(bump.laneReleasedVersions([]).size, 0);
|
|
158
|
+
assert.equal(bump.laneReleasedVersions().size, 0);
|
|
159
|
+
assert.equal(bump.laneReleasedVersions([null, undefined, '', 42]).size, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('laneReleasedVersions and isReleaseCommit agree on what a lane release looks like', () => {
|
|
163
|
+
// They share RELEASE_COMMIT_RE on purpose: if one ever recognises a subject the
|
|
164
|
+
// other does not, the cascade terminator and the version check disagree about
|
|
165
|
+
// the same commit, and the lane either double-publishes or silently no-ops.
|
|
166
|
+
const subjects = [
|
|
167
|
+
'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
|
|
168
|
+
'release: core 1.19.2 — carrier for the init-blocker fixes',
|
|
169
|
+
'chore: not a release at all',
|
|
170
|
+
];
|
|
171
|
+
for (const s of subjects) {
|
|
172
|
+
const counted = bump.laneReleasedVersions([s]).size === 1;
|
|
173
|
+
assert.equal(counted, bump.isReleaseCommit(s), `disagreement on: ${s}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('LIVE: the version HEAD declares is one this lane already released', () => {
|
|
178
|
+
// The steady state the lane depends on: HEAD carries the version set by the
|
|
179
|
+
// previous release commit, so `taken` is true and the auto-bump fires. If this
|
|
180
|
+
// ever reads false on a normal main checkout, the lane would try to republish
|
|
181
|
+
// an existing version.
|
|
182
|
+
const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
|
|
183
|
+
.exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
|
|
184
|
+
const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
|
|
185
|
+
const released = bump.laneReleasedVersions(subjects);
|
|
186
|
+
assert.ok(released.size > 100, `expected a long release history, saw ${released.size}`);
|
|
187
|
+
assert.equal(released.has(headVersion), true,
|
|
188
|
+
`HEAD declares ${headVersion}, which this lane has no release commit for — the auto-bump would not fire`);
|
|
189
|
+
});
|
package/tests/goal_category.mjs
CHANGED
|
@@ -145,6 +145,34 @@ test('an unknown category is a 404, not a silently-created one', async () => {
|
|
|
145
145
|
assert.equal(res.statusCode, 404);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// ---- the advisory (task 1003277) --------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('an uncategorised goal earns an advisory that carries the live set and the create path', () => {
|
|
151
|
+
const adv = gc.adviseCategory({ categoryId: null, categories: [
|
|
152
|
+
{ id: 3, slug: 'security', label: 'Security' },
|
|
153
|
+
{ id: 6, slug: 'economics', label: 'Economics' },
|
|
154
|
+
] });
|
|
155
|
+
assert.equal(adv.reason, 'uncategorised');
|
|
156
|
+
assert.deepEqual(adv.categories.map((c) => c.slug), ['security', 'economics'],
|
|
157
|
+
'the live set rides along so a client offers real choices, never hard-coded labels');
|
|
158
|
+
assert.match(adv.message, /POST \/work-categories/, 'the prompt has somewhere to write into');
|
|
159
|
+
assert.match(adv.message, /designed prompt, not an error/, 'the criterion\'s own framing travels with the advice');
|
|
160
|
+
assert.match(adv.message, /gates nothing/, 'and so does the rule that outranks the feature');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a categorised goal gets NO advisory — silence, the fail-open posture', () => {
|
|
164
|
+
assert.equal(gc.adviseCategory({ categoryId: 3, categories: [] }), null);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the advisory is advice on a 201, never a refusal', () => {
|
|
168
|
+
// Pinned in source: POST /goals attaches category_advisory to its SUCCESS body
|
|
169
|
+
// and never fails the create over it (best-effort, wrapped in try).
|
|
170
|
+
const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
|
|
171
|
+
assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
|
|
172
|
+
assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory \}\)/,
|
|
173
|
+
'it rides the 201 — advice appended to a request that already succeeded');
|
|
174
|
+
});
|
|
175
|
+
|
|
148
176
|
// ---- decoration ------------------------------------------------------------
|
|
149
177
|
|
|
150
178
|
test('a goal carries a readable category, and an uncategorised one carries null', () => {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tests/profile_nudge_prefs.mjs — the "Complete your profile" nudge shaper
|
|
2
|
+
// (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// The nudge replaced the profile-setup wizard spec Decision 2 rejected: a
|
|
5
|
+
// DISMISSIBLE line item in the hall's needs-attention inbox, shown when bio or
|
|
6
|
+
// preferred_disciplines are empty. These pin the pure decision layer — what
|
|
7
|
+
// counts as missing, when the line shows, and that a dismissal is durable and
|
|
8
|
+
// reversible without disturbing the sibling knobs sharing behavior_prefs.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/profile_nudge_prefs.mjs
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const p = require('../modules/builder-settings/profile-nudge-prefs.js');
|
|
18
|
+
|
|
19
|
+
test('missingProfileFields: empty, whitespace, and absent all count as missing', () => {
|
|
20
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
21
|
+
assert.deepEqual(p.missingProfileFields({ bio: ' \n\t ', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
22
|
+
assert.deepEqual(p.missingProfileFields({}), ['bio', 'disciplines']);
|
|
23
|
+
assert.deepEqual(p.missingProfileFields({ bio: null, preferredDisciplines: null }), ['bio', 'disciplines']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('missingProfileFields: a real value on either side drops only that entry', () => {
|
|
27
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'I build things', preferredDisciplines: [] }), ['disciplines']);
|
|
28
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: ['engineer'] }), ['bio']);
|
|
29
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: ['engineer'] }), []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('missingProfileFields: a non-array disciplines value is missing, not a crash', () => {
|
|
33
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: 'engineer' }), ['disciplines']);
|
|
34
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: {} }), ['disciplines']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('nudgeDismissed: defaults to NOT dismissed; only a real boolean overrides', () => {
|
|
38
|
+
assert.equal(p.nudgeDismissed({}), false);
|
|
39
|
+
assert.equal(p.nudgeDismissed(null), false);
|
|
40
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: true }), true);
|
|
41
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: false }), false);
|
|
42
|
+
// Garbage in the blob must not read as dismissed — that would silence the
|
|
43
|
+
// nudge for a builder who never dismissed it.
|
|
44
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 'yes' }), false);
|
|
45
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 1 }), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('shouldNudge: shows only when something is missing AND not dismissed', () => {
|
|
49
|
+
const missing = { bio: '', preferredDisciplines: [] };
|
|
50
|
+
const complete = { bio: 'x', preferredDisciplines: ['engineer'] };
|
|
51
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...missing }), true);
|
|
52
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...missing }), false);
|
|
53
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...complete }), false);
|
|
54
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...complete }), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('describeForApi: reports what is missing even while dismissed (show is the gate)', () => {
|
|
58
|
+
const d = p.describeForApi({ profile_nudge_dismissed: true }, { bio: '', preferredDisciplines: [] });
|
|
59
|
+
assert.equal(d.dismissed, true);
|
|
60
|
+
assert.equal(d.show, false, 'a dismissed nudge never shows');
|
|
61
|
+
assert.deepEqual(d.missing, ['bio', 'disciplines'], 'but the API still says what is empty');
|
|
62
|
+
assert.equal(d.override, true);
|
|
63
|
+
assert.equal(d.default_dismissed, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('validatePatch: accepts dismissed true/false/null, refuses everything else', () => {
|
|
67
|
+
assert.deepEqual(p.validatePatch({ dismissed: true }), { ok: true, patch: { profile_nudge_dismissed: true } });
|
|
68
|
+
assert.deepEqual(p.validatePatch({ dismissed: false }), { ok: true, patch: { profile_nudge_dismissed: false } });
|
|
69
|
+
assert.deepEqual(p.validatePatch({ dismissed: null }), { ok: true, patch: { profile_nudge_dismissed: null } });
|
|
70
|
+
assert.deepEqual(p.validatePatch({}), { ok: true, patch: {} }, 'empty body is a valid no-op');
|
|
71
|
+
assert.equal(p.validatePatch({ dismissed: 'yes' }).ok, false);
|
|
72
|
+
assert.equal(p.validatePatch({ nope: true }).ok, false);
|
|
73
|
+
assert.equal(p.validatePatch(null).ok, false);
|
|
74
|
+
assert.equal(p.validatePatch([]).ok, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('applyPatch: null clears the key; sibling behavior knobs are never disturbed', () => {
|
|
78
|
+
const existing = { wandering: 'focused', widgets: false, interaction: { technical_level: 'engineer' } };
|
|
79
|
+
const set = p.applyPatch(existing, { profile_nudge_dismissed: true });
|
|
80
|
+
assert.equal(set.profile_nudge_dismissed, true);
|
|
81
|
+
assert.equal(set.wandering, 'focused');
|
|
82
|
+
assert.equal(set.widgets, false);
|
|
83
|
+
assert.deepEqual(set.interaction, { technical_level: 'engineer' });
|
|
84
|
+
// Clearing removes the key entirely (back to the sparse default) and still
|
|
85
|
+
// leaves the siblings alone — the undismiss path.
|
|
86
|
+
const cleared = p.applyPatch(set, { profile_nudge_dismissed: null });
|
|
87
|
+
assert.equal('profile_nudge_dismissed' in cleared, false);
|
|
88
|
+
assert.equal(cleared.wandering, 'focused');
|
|
89
|
+
assert.equal(p.nudgeDismissed(cleared), false, 'cleared → the nudge can return');
|
|
90
|
+
// Pure: the input blob is not mutated.
|
|
91
|
+
assert.equal('profile_nudge_dismissed' in existing, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the reserved key is the documented one (behavior_prefs is shared)', () => {
|
|
95
|
+
assert.equal(p.RESERVED_KEY, 'profile_nudge_dismissed');
|
|
96
|
+
assert.equal(p.DEFAULT_DISMISSED, false);
|
|
97
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tests/ship_push_diagnosis.mjs — a terminal push failure must not advise a retry
|
|
2
|
+
// (task 1003293).
|
|
3
|
+
//
|
|
4
|
+
// A failed `git push` in ciLandLocal already parks the task at `confirmed` and
|
|
5
|
+
// opens no PR, which is correct. The ADVICE was the defect: "Fix the push and
|
|
6
|
+
// re-ship" cannot work for the most common cause — a commit touching
|
|
7
|
+
// .github/workflows/ pushed with a credential lacking GitHub's `workflow` scope.
|
|
8
|
+
// Re-shipping fails identically, and the reconciler cannot heal a branch that
|
|
9
|
+
// never reached the remote, so the task sits at `confirmed` looking like an
|
|
10
|
+
// ordinary pending land while its code exists on exactly one machine. That
|
|
11
|
+
// happened twice in one session.
|
|
12
|
+
//
|
|
13
|
+
// These tests pin the distinction the fix turns on: TERMINAL (retrying is
|
|
14
|
+
// pointless — say what actually resolves it) vs transient (retrying is the
|
|
15
|
+
// answer).
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { pushDiagnosis, PUSH_DIAGNOSES } = require(path.join(ROOT, 'scripts', 'gds', 'ship-land.js'));
|
|
25
|
+
|
|
26
|
+
test('THE case that stranded two tasks: the workflow scope rejection', () => {
|
|
27
|
+
// GitHub's real wording, verbatim from the run that stranded task 1003283.
|
|
28
|
+
const d = pushDiagnosis(
|
|
29
|
+
'remote: error: refusing to allow an OAuth App to create or update workflow '
|
|
30
|
+
+ '`.github/workflows/publish.yml` without `workflow` scope\n'
|
|
31
|
+
+ 'To https://github.com/owner/repo.git\n ! [remote rejected] branch -> branch');
|
|
32
|
+
assert.equal(d.id, 'workflow_scope');
|
|
33
|
+
assert.equal(d.terminal, true, 'retrying this can never succeed — saying "re-ship" is what stranded the work');
|
|
34
|
+
assert.match(d.fix, /ssh|workflow/i, 'the advice must name the remedy that actually works');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('branch protection and a dead credential are terminal too', () => {
|
|
38
|
+
assert.equal(pushDiagnosis('remote: error: GH006: Protected branch update failed').terminal, true);
|
|
39
|
+
assert.equal(pushDiagnosis('fatal: Authentication failed for https://github.com/o/r.git/').terminal, true);
|
|
40
|
+
assert.equal(pushDiagnosis('git@github.com: Permission denied (publickey).').terminal, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a stale branch IS retryable — the honest "re-ship" case', () => {
|
|
44
|
+
const d = pushDiagnosis('error: failed to push some refs\nUpdates were rejected because the remote contains work that you do not have locally');
|
|
45
|
+
assert.equal(d.id, 'stale_branch');
|
|
46
|
+
assert.equal(d.terminal, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an unrecognised failure defaults to RETRYABLE, never to terminal', () => {
|
|
50
|
+
// The safe direction. A wrong "this can never work" sends someone hunting a
|
|
51
|
+
// problem that does not exist; a wrong "try again" costs one command.
|
|
52
|
+
const d = pushDiagnosis('fatal: the remote end hung up unexpectedly');
|
|
53
|
+
assert.equal(d.id, 'unknown');
|
|
54
|
+
assert.equal(d.terminal, false);
|
|
55
|
+
assert.equal(pushDiagnosis('').terminal, false);
|
|
56
|
+
assert.equal(pushDiagnosis(null).terminal, false);
|
|
57
|
+
assert.equal(pushDiagnosis(undefined).terminal, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('every diagnosis carries both a cause and a remedy', () => {
|
|
61
|
+
// A classification with no `fix` is just a nicer way of saying "it broke",
|
|
62
|
+
// which is the state this replaced.
|
|
63
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
64
|
+
assert.ok(d.id && d.why && d.fix, `incomplete diagnosis: ${JSON.stringify(d)}`);
|
|
65
|
+
assert.ok(d.why.length > 20, `${d.id}: "why" must explain, not label`);
|
|
66
|
+
assert.equal(typeof d.terminal, 'boolean');
|
|
67
|
+
}
|
|
68
|
+
const fallback = pushDiagnosis('anything at all');
|
|
69
|
+
assert.ok(fallback.why && fallback.fix);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the patterns do not collide — the first match is the RIGHT match', () => {
|
|
73
|
+
// A workflow-scope rejection also contains "error:" and often "403"; if the
|
|
74
|
+
// auth pattern matched it first, the advice would send someone to re-login
|
|
75
|
+
// instead of to SSH, and the push would fail again.
|
|
76
|
+
const workflowMsg = 'remote: error: refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope';
|
|
77
|
+
assert.equal(pushDiagnosis(workflowMsg).id, 'workflow_scope');
|
|
78
|
+
});
|
package/tests/sound_player.mjs
CHANGED
|
@@ -343,3 +343,41 @@ test('playerFor never throws and returns null or a [cmd, args] pair', () => {
|
|
|
343
343
|
assert.ok(Array.isArray(got[1]));
|
|
344
344
|
}
|
|
345
345
|
});
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// readPrefs honours the path it was GIVEN (task 1003293).
|
|
349
|
+
//
|
|
350
|
+
// The legacy-path fallback used to fire for any unreadable path, so a caller
|
|
351
|
+
// naming an explicit file silently received a different one — the user's real
|
|
352
|
+
// prefs. That made the two fail-open tests above pass in CI (no prefs on a
|
|
353
|
+
// runner) and fail on any machine where the builder had saved sound settings:
|
|
354
|
+
// green where nobody looks, red where everybody does. The fallback is still
|
|
355
|
+
// right for the DEFAULT call; it is wrong the moment a caller names a file.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
test('an explicit missing path fails open — it never substitutes another file', () => {
|
|
359
|
+
// The regression: write a legacy-shaped prefs file at the pre-rename location
|
|
360
|
+
// and prove an explicit path still ignores it. Skipped if the real file exists,
|
|
361
|
+
// because this test must never clobber a builder's own settings.
|
|
362
|
+
const legacy = player.LEGACY_PREFS_PATH;
|
|
363
|
+
if (!legacy || fs.existsSync(legacy)) {
|
|
364
|
+
// Nothing to plant, but the invariant is still checkable: an absent explicit
|
|
365
|
+
// path must yield the fail-open default, whatever the legacy file holds.
|
|
366
|
+
const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
|
|
367
|
+
assert.equal(p.volume, 100);
|
|
368
|
+
assert.equal(p.enabled('ship'), true);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
|
372
|
+
fs.writeFileSync(legacy, JSON.stringify({ volume: 11, ship: false }));
|
|
373
|
+
try {
|
|
374
|
+
const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
|
|
375
|
+
assert.equal(p.volume, 100, 'an explicit path must not inherit the legacy file volume');
|
|
376
|
+
assert.equal(p.enabled('ship'), true, 'an explicit path must not inherit legacy mutes');
|
|
377
|
+
// …while the DEFAULT call still honours it — the fallback is not removed.
|
|
378
|
+
const d = player.readPrefs();
|
|
379
|
+
assert.equal(d.volume, 11, 'the default path must still fall back to the legacy file');
|
|
380
|
+
} finally {
|
|
381
|
+
fs.unlinkSync(legacy);
|
|
382
|
+
}
|
|
383
|
+
});
|