@bongos/core 1.19.1064 → 1.19.1065
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 +85 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +11 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +237 -4
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +60 -41
- package/docs/copy-registry.json +228 -47
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +11 -2
- package/docs/page-readings.json +45 -22
- package/modules/copy-desk/page-decisions.js +71 -0
- package/modules/copy-desk/page-status.js +96 -0
- package/modules/copy-desk/pages.js +30 -0
- package/modules/copy-desk/routes/copy-desk.js +153 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
- package/modules/hall-ui/public/approval-queue-lib.js +156 -0
- package/modules/hall-ui/public/approval-queue.css +286 -0
- package/modules/hall-ui/public/approval-queue.js +338 -0
- package/modules/hall-ui/public/studio.css +33 -0
- package/modules/hall-ui/public/studio.html +18 -1
- package/modules/hall-ui/public/studio.js +30 -0
- package/modules/hall-ui/public/studio.states.json +198 -0
- package/modules/hall-ui/records/approval-queue.md +32 -0
- package/modules/lifecycle/db-grade.js +80 -58
- package/modules/lifecycle/github-pr-close.js +41 -0
- package/modules/lifecycle/github-push.js +5 -2
- package/modules/lifecycle/lifecycle.js +12 -0
- package/modules/lifecycle/page-tweak-approve.js +242 -0
- package/modules/lifecycle/routes/gate-approvals.js +17 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/hall-preview/server.js +18 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_approve.mjs +307 -0
- package/tests/fixtures/page-ledger.mjs +41 -1
- package/tests/gate_approvals.mjs +93 -0
- package/tests/hall_approval_queue.mjs +327 -0
- package/tests/hall_audit.mjs +4 -0
|
@@ -136,6 +136,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
136
136
|
// page id, since the editor shows one page. A page id carries a colon, which a
|
|
137
137
|
// Windows file name cannot, so there is no per-id file to find first.
|
|
138
138
|
[/^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft'],
|
|
139
|
+
// the Approval queue's renders (task 1004323): a round's named visuals, one
|
|
140
|
+
// fixture for any task id, as the editor read above serves any page.
|
|
141
|
+
[/^tasks\/\d+\/visuals$/, 'tasks__visuals'],
|
|
139
142
|
];
|
|
140
143
|
|
|
141
144
|
// CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
|
|
@@ -148,6 +151,10 @@ const CANNED_WRITES = [
|
|
|
148
151
|
['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
|
|
149
152
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
|
|
150
153
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
|
|
154
|
+
// the Approval queue's two decisions (task 1004323), so the approved and
|
|
155
|
+
// sent-back lines the artboard draws render too.
|
|
156
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/approve$/, 'copy-desk__pages__approve.post'],
|
|
157
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/send-back$/, 'copy-desk__pages__send-back.post'],
|
|
151
158
|
];
|
|
152
159
|
|
|
153
160
|
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
|
@@ -252,6 +259,17 @@ app.use((req, res, next) => {
|
|
|
252
259
|
// frame, so the one thing being reviewed is the one thing you cannot see.
|
|
253
260
|
// A generated stand-in; see scripts/hall-preview/task-visual.js.
|
|
254
261
|
if (/^task-visuals\/[A-Za-z0-9._-]+$/.test(apiPath) && req.method === 'GET') {
|
|
262
|
+
// HALL_PREVIEW_VISUALS=<dir> serves a REAL image of that name from <dir>
|
|
263
|
+
// instead (task 1004323): the Approval queue's review sheet shows the renders
|
|
264
|
+
// /tweak really attached, copied under the fixture's names into a scratch
|
|
265
|
+
// dir. Nothing is committed; unset, every visual is the stand-in as before.
|
|
266
|
+
const realDir = process.env.HALL_PREVIEW_VISUALS;
|
|
267
|
+
const name = apiPath.slice('task-visuals/'.length);
|
|
268
|
+
const real = realDir && /^task-\d+-[0-9a-f]{16}\.(png|jpg)$/.test(name) ? path.join(realDir, name) : null;
|
|
269
|
+
if (real && fs.existsSync(real)) {
|
|
270
|
+
res.set({ 'content-type': name.endsWith('.png') ? 'image/png' : 'image/jpeg', 'cache-control': 'no-store' });
|
|
271
|
+
return res.end(fs.readFileSync(real));
|
|
272
|
+
}
|
|
255
273
|
const png = require('./task-visual').placeholderPng();
|
|
256
274
|
res.set({ 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
257
275
|
return res.end(png);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1065'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// tests/copy_desk_page_approve.mjs — the Approval queue, the WHOLE path (task
|
|
2
|
+
// 1004323 / BV2.TW12, ADR 0341 D7 and D11).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: approve ships the page and advances its status; send-back
|
|
5
|
+
// re-queues it with the note.
|
|
6
|
+
//
|
|
7
|
+
// Driven through the real copy-desk routes, the real lifecycle port
|
|
8
|
+
// (approveHeldTweak, sendBackHeldTweak, the real promoteToConfirmed under
|
|
9
|
+
// approve) and the real page reads, against the shared stateful fake ledger
|
|
10
|
+
// (tests/fixtures/page-ledger.mjs). The reward port is a recording fake, so
|
|
11
|
+
// the test sees exactly what the approve pays and to whom. GitHub is a stub
|
|
12
|
+
// handed to the port directly for the land nudge and the PR close.
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
21
|
+
|
|
22
|
+
process.env.NODE_ENV = 'test';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
25
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
26
|
+
const api = require('../src/module-api.js');
|
|
27
|
+
|
|
28
|
+
const ALICE = '7'; // the author of both rounds, an artist
|
|
29
|
+
const BOB = '8'; // another artist
|
|
30
|
+
const CARL = '9'; // the applier, an engineer
|
|
31
|
+
const DAN = '10'; // neither author nor artist
|
|
32
|
+
const { db } = installPageLedger(api, {
|
|
33
|
+
builders: [
|
|
34
|
+
{ id: ALICE, github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
35
|
+
{ id: BOB, github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
36
|
+
{ id: CARL, github_login: 'carl', display_name: 'Carl', preferred_disciplines: ['engineer'] },
|
|
37
|
+
{ id: DAN, github_login: 'dan', display_name: 'Dan', preferred_disciplines: ['engineer'] },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
api.requireBuilder = (req, res, next) => {
|
|
42
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// The reward port, recording. Registered before the lifecycle port resolves it.
|
|
47
|
+
const paid = [];
|
|
48
|
+
const seams = require('../src/module-seams.js');
|
|
49
|
+
const fakeReward = {
|
|
50
|
+
awardNetNegativeBonus: async (client, builderId, taskId, diffStats) => { paid.push(['net_negative', String(builderId), String(taskId), diffStats]); return { bonus: 2, justUnlocked: [] }; },
|
|
51
|
+
evaluateAfterShip: async (client, builderId, taskId, files) => { paid.push(['after_ship', String(builderId), String(taskId), files]); return ['subtractor']; },
|
|
52
|
+
reconcileBuilderAchievements: async (client, builderId) => { paid.push(['reconcile', String(builderId)]); return []; },
|
|
53
|
+
creditLedger: async () => ({ rows: [], total: 0 }),
|
|
54
|
+
};
|
|
55
|
+
if (seams.hasProvider('reward')) throw new Error('this test must own the reward port');
|
|
56
|
+
seams.registerProvider('reward', fakeReward);
|
|
57
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
58
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
59
|
+
|
|
60
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
61
|
+
const express = require('express');
|
|
62
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
63
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
64
|
+
|
|
65
|
+
const app = express();
|
|
66
|
+
app.use(express.json());
|
|
67
|
+
app.use(attachFail);
|
|
68
|
+
app.use(buildRouter());
|
|
69
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
70
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
71
|
+
|
|
72
|
+
async function call(method, p, builder, body = {}) {
|
|
73
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
74
|
+
return { status: r.status, body: await r.json() };
|
|
75
|
+
}
|
|
76
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
77
|
+
|
|
78
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-approve-'));
|
|
79
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
81
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(F.READINGS));
|
|
82
|
+
pageData._setRoot(tmp);
|
|
83
|
+
|
|
84
|
+
// ---- the ledger: rounds as /tweak leaves them ------------------------------
|
|
85
|
+
|
|
86
|
+
let at = Date.parse('2026-09-20T00:00:00Z');
|
|
87
|
+
const stamp = () => new Date(at += 60_000).toISOString();
|
|
88
|
+
|
|
89
|
+
// A round of `pageId`, submitted by ALICE and applied by CARL: the artist's web
|
|
90
|
+
// claim released by the submit, then the applier's CLI claim released by its
|
|
91
|
+
// ship, the task at completed and its grade row as given.
|
|
92
|
+
function appliedRound({ id, pageId, round, lines, refused = [], appliedAt, status = 'completed', passed = true, applierClaimOpen = false }) {
|
|
93
|
+
const description = [
|
|
94
|
+
`Page tweak round ${round} for ${pageId}.`,
|
|
95
|
+
pages.composeBatchBlock({ page_id: pageId, reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: lines.map((l) => ({ ...l, placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' })) }),
|
|
96
|
+
pages.composeAppliedBlock({ page_id: pageId, applied_at: appliedAt, reading_hash_after: 'h2', lines_after: lines.map((l) => l.after), refused }),
|
|
97
|
+
].join('\n\n');
|
|
98
|
+
db.tasks.push({ id: String(id), status, title: `Tweak: ${pageId}`, description, source: 'page-tweak', source_ref: pages.roundSourceRef(pageId, round), touches: [], version_id: 'V9', goal_id: '77', shipped_by: null });
|
|
99
|
+
db.claims.push({ id: String(++db.seq.claim), task_id: String(id), builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
|
|
100
|
+
db.claims.push({ id: String(++db.seq.claim), task_id: String(id), builder_id: CARL, worktree_name: `task-${id}`, creator_session_id: 's1', claimed_at: stamp(), released_at: applierClaimOpen ? null : stamp(), outcome: applierClaimOpen ? null : 'shipped' });
|
|
101
|
+
if (passed !== undefined) db.grades.set(String(id), passed);
|
|
102
|
+
(db.gradeSignals = db.gradeSignals || new Map()).set(String(id), { diff_stats: { added: 2, removed: 2 } });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// landing:index already shipped once, so an approve makes it "Text tweaked 2x".
|
|
106
|
+
db.tasks.push({ id: '4001', status: 'shipped', shipped_at: '2026-09-01T00:00:00Z', title: 'Tweak: Home', description: pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h0', submitted_at: '2026-08-30T00:00:00Z', lines: [] }), source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 1), touches: [] });
|
|
107
|
+
appliedRound({
|
|
108
|
+
id: 5001, pageId: 'landing:index', round: 2, appliedAt: '2026-09-21T10:00:00Z',
|
|
109
|
+
lines: [
|
|
110
|
+
{ key: 'L0002', section: 'hero', before: 'Build here', after: 'Build it here' },
|
|
111
|
+
{ key: 'L0005', section: 'hero', before: 'Start a project', after: 'Start yours' },
|
|
112
|
+
],
|
|
113
|
+
refused: [{ key: 'L0005', code: 'target_gone' }],
|
|
114
|
+
});
|
|
115
|
+
appliedRound({
|
|
116
|
+
id: 5002, pageId: 'landing:projects', round: 1, appliedAt: '2026-09-22T10:00:00Z', applierClaimOpen: true,
|
|
117
|
+
lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All the projects' }],
|
|
118
|
+
});
|
|
119
|
+
// A completed round whose grade FAILED is still with its applier: not in the queue.
|
|
120
|
+
appliedRound({
|
|
121
|
+
id: 5003, pageId: 'status:explore', round: 1, appliedAt: '2026-09-20T10:00:00Z', passed: false,
|
|
122
|
+
lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around' }],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const task = (id) => db.tasks.find((t) => String(t.id) === String(id));
|
|
126
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
127
|
+
const PROJECTS = '/copy-desk/pages/landing:projects';
|
|
128
|
+
const { test, summary } = makeRunner();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await test('the queue: every round waiting for its artist, oldest applied first, "1 of 2"', async () => {
|
|
132
|
+
const r = await call('GET', '/copy-desk/approvals', BOB);
|
|
133
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
134
|
+
assert.equal(r.body.count, 2, 'the failed-grade round is not waiting for the artist');
|
|
135
|
+
const [first, second] = r.body.queue;
|
|
136
|
+
assert.deepEqual([first.page_id, first.position, first.of], ['landing:index', 1, 2]);
|
|
137
|
+
assert.deepEqual([second.page_id, second.position, second.of], ['landing:projects', 2, 2]);
|
|
138
|
+
assert.equal(first.task_id, '5001');
|
|
139
|
+
assert.equal(first.title, 'Home');
|
|
140
|
+
assert.equal(first.surface_word, 'landing');
|
|
141
|
+
assert.equal(first.line_count, 2);
|
|
142
|
+
assert.equal(first.applied_count, 1);
|
|
143
|
+
assert.equal(first.refused_count, 1);
|
|
144
|
+
assert.deepEqual(first.lines.map((l) => [l.key, l.before, l.after, l.refused]), [
|
|
145
|
+
['L0002', 'Build here', 'Build it here', null],
|
|
146
|
+
['L0005', 'Start a project', 'Start yours', 'target_gone'],
|
|
147
|
+
]);
|
|
148
|
+
assert.equal(first.status_label, 'Text tweaked 1x');
|
|
149
|
+
assert.equal(first.status_after_approval, 'Text tweaked 2x');
|
|
150
|
+
assert.deepEqual(first.visuals, { method: 'GET', path: '/tasks/5001/visuals' });
|
|
151
|
+
assert.deepEqual(first.author, { id: ALICE, login: 'alice' });
|
|
152
|
+
assert.equal(first.can_decide, true, 'another artist may decide');
|
|
153
|
+
assert.equal(first.by_you, false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('who may decide: the author and any artist, never anyone else', async () => {
|
|
157
|
+
const alice = (await call('GET', '/copy-desk/approvals', ALICE)).body;
|
|
158
|
+
assert.equal(alice.queue[0].by_you, true);
|
|
159
|
+
assert.equal(alice.queue[0].can_decide, true);
|
|
160
|
+
const dan = (await call('GET', '/copy-desk/approvals', DAN)).body;
|
|
161
|
+
assert.equal(dan.is_artist, false);
|
|
162
|
+
assert.equal(dan.queue.every((q) => q.can_decide === false), true, 'Dan reads the queue but cannot decide');
|
|
163
|
+
const r = await call('POST', `${INDEX}/approve`, DAN);
|
|
164
|
+
assert.equal(r.status, 403);
|
|
165
|
+
assert.equal(errCode(r.body), 'not_an_artist');
|
|
166
|
+
assert.match(r.body.error.details.what_this_means, /override request/, 'the refusal names the owner\'s escape hatch');
|
|
167
|
+
assert.equal(task(5001).status, 'completed', 'nothing moved');
|
|
168
|
+
assert.equal(paid.length, 0, 'nothing was paid');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('an approve pinned to another round is refused round_moved', async () => {
|
|
172
|
+
const r = await call('POST', `${INDEX}/approve`, BOB, { task_id: '4001' });
|
|
173
|
+
assert.equal(r.status, 409);
|
|
174
|
+
assert.equal(errCode(r.body), 'round_moved');
|
|
175
|
+
assert.equal(task(5001).status, 'completed');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('approve: completed -> confirmed, the applier rewarded as a passed grade is, the next page named', async () => {
|
|
179
|
+
const r = await call('POST', `${INDEX}/approve`, BOB, { task_id: '5001' });
|
|
180
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
181
|
+
assert.equal(r.body.outcome, 'approved');
|
|
182
|
+
assert.deepEqual(r.body.round, { task_id: '5001', state: 'landing', status: 'confirmed' });
|
|
183
|
+
assert.equal(r.body.status_after, 'Text tweaked 2x');
|
|
184
|
+
assert.deepEqual(r.body.next.page, { page_id: 'landing:projects', title: 'Projects', path: '/projects', task_id: '5002' });
|
|
185
|
+
assert.equal(r.body.next.count, 1);
|
|
186
|
+
assert.equal(task(5001).status, 'confirmed');
|
|
187
|
+
assert.equal(task(5001).shipped_by, CARL, 'attributed to the applier, never the approver');
|
|
188
|
+
assert.deepEqual(paid, [
|
|
189
|
+
['net_negative', CARL, '5001', { added: 2, removed: 2 }],
|
|
190
|
+
['after_ship', CARL, '5001', null],
|
|
191
|
+
['reconcile', CARL],
|
|
192
|
+
], 'the bonus and the achievements the held pass skipped, credited to the applier');
|
|
193
|
+
// No credential in this process: the land is the reconciler's.
|
|
194
|
+
assert.equal(r.body.land.lands_via, 'reconciler');
|
|
195
|
+
// The page reads landing; its count goes up when it ships.
|
|
196
|
+
const page = (await call('GET', INDEX, BOB)).body;
|
|
197
|
+
assert.equal(page.open_round.state, 'landing');
|
|
198
|
+
assert.equal(page.count, 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('an approved page is no longer waiting: a second approve or a send-back is not_waiting', async () => {
|
|
202
|
+
const again = await call('POST', `${INDEX}/approve`, BOB);
|
|
203
|
+
assert.equal(again.status, 409);
|
|
204
|
+
assert.equal(errCode(again.body), 'not_waiting');
|
|
205
|
+
const back = await call('POST', `${INDEX}/send-back`, BOB, { note: 'Too long on a phone.' });
|
|
206
|
+
assert.equal(back.status, 409);
|
|
207
|
+
assert.equal(errCode(back.body), 'not_waiting');
|
|
208
|
+
assert.equal(paid.length, 3, 'nothing paid twice');
|
|
209
|
+
const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
|
|
210
|
+
assert.deepEqual(q.queue.map((x) => [x.page_id, x.position, x.of]), [['landing:projects', 1, 1]]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('send-back needs a one-line note of ten characters; a short one moves nothing', async () => {
|
|
214
|
+
const r = await call('POST', `${PROJECTS}/send-back`, ALICE, { note: ' too long ' });
|
|
215
|
+
assert.equal(r.status, 400);
|
|
216
|
+
assert.equal(errCode(r.body), 'note_too_short');
|
|
217
|
+
assert.equal(task(5002).status, 'completed');
|
|
218
|
+
assert.equal(db.grades.get('5002'), true);
|
|
219
|
+
const missing = await call('POST', `${PROJECTS}/send-back`, ALICE, {});
|
|
220
|
+
assert.equal(missing.status, 400, 'no note at all is refused by the schema');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('send-back: the note rests on the task, the claim is released, the stale pass dropped, the round back in /tweak', async () => {
|
|
224
|
+
const r = await call('POST', `${PROJECTS}/send-back`, ALICE, { note: 'The heading is too long on a phone.', task_id: '5002' });
|
|
225
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
226
|
+
assert.equal(r.body.outcome, 'sent_back');
|
|
227
|
+
assert.deepEqual(r.body.round, { task_id: '5002', state: 'submitted', status: 'ready' });
|
|
228
|
+
assert.equal(r.body.sent_back.note, 'The heading is too long on a phone.');
|
|
229
|
+
assert.equal(r.body.sent_back.by, ALICE);
|
|
230
|
+
assert.equal(r.body.next.page, null);
|
|
231
|
+
assert.equal(r.body.next.count, 0);
|
|
232
|
+
const t = task(5002);
|
|
233
|
+
assert.equal(t.status, 'ready');
|
|
234
|
+
const sent = pages.parseSentBackBlocks(t.description);
|
|
235
|
+
assert.deepEqual(sent.map((s) => [s.by, s.note]), [[ALICE, 'The heading is too long on a phone.']]);
|
|
236
|
+
assert.equal(pages.parseBatchBlock(t.description).ok, true, 'the frozen batch stays for the next applier');
|
|
237
|
+
assert.equal(db.claims.filter((c) => c.task_id === '5002' && c.released_at == null).length, 0, 'the applier\'s open claim is released');
|
|
238
|
+
assert.equal(db.grades.has('5002'), false, 'the old pass cannot make the re-applied round look approved');
|
|
239
|
+
const page = (await call('GET', PROJECTS, ALICE)).body;
|
|
240
|
+
assert.equal(page.open_round.state, 'submitted', 'it reads submitted: the /tweak queue');
|
|
241
|
+
assert.equal((await call('GET', '/copy-desk/approvals', ALICE)).body.count, 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await test('re-applied after a send-back, the round waits again only once its NEW grade passes', async () => {
|
|
245
|
+
const t = task(5002);
|
|
246
|
+
// /tweak applies it again and ships: a newer applied block, completed, no grade yet.
|
|
247
|
+
t.description = pages.replaceBlock(t.description, pages.FENCES.applied, pages.composeAppliedBlock({
|
|
248
|
+
page_id: 'landing:projects', applied_at: new Date(Date.now() + 1000).toISOString(), reading_hash_after: 'h3', lines_after: ['All projects'], refused: [],
|
|
249
|
+
})).description;
|
|
250
|
+
t.status = 'completed';
|
|
251
|
+
const early = await call('POST', `${PROJECTS}/approve`, ALICE);
|
|
252
|
+
assert.equal(errCode(early.body), 'not_waiting', 'no grade yet: not approvable');
|
|
253
|
+
db.grades.set('5002', true);
|
|
254
|
+
const q = (await call('GET', '/copy-desk/approvals', ALICE)).body;
|
|
255
|
+
assert.equal(q.count, 1);
|
|
256
|
+
assert.equal(q.queue[0].sent_back_count, 1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('the port nudges the merge once after an approve, and closes the PR after a send-back', async () => {
|
|
260
|
+
// Direct port calls with a GitHub stub: the reads the reconciler makes.
|
|
261
|
+
const calls = [];
|
|
262
|
+
const push = {
|
|
263
|
+
isConfigured: () => true,
|
|
264
|
+
findTaskPullRequest: async ({ taskId }) => { calls.push(['find', taskId]); return { branch: `task-${taskId}`, pr_number: 88, pr_state: 'open', merged: false }; },
|
|
265
|
+
mergeIfGreen: async ({ branch }) => { calls.push(['merge', branch]); return { merged: true, merged_now: true }; },
|
|
266
|
+
closePullRequest: async ({ number }) => { calls.push(['close', number]); return { closed: true, number }; },
|
|
267
|
+
};
|
|
268
|
+
const approved = await lifecyclePort.approveHeldTweak({ taskId: '5002', builderId: ALICE }, { push });
|
|
269
|
+
assert.equal(approved.outcome, 'approved');
|
|
270
|
+
assert.deepEqual(approved.land, { nudged: true, merged: true, merged_now: true, reason: null, pr_number: 88, lands_via: 'merged' });
|
|
271
|
+
assert.deepEqual(calls, [['find', 5002], ['merge', 'task-5002']]);
|
|
272
|
+
// A failing GitHub read never undoes the approve: the reconciler lands it.
|
|
273
|
+
task(5002).status = 'completed';
|
|
274
|
+
const broken = { ...push, findTaskPullRequest: async () => { throw new Error('rate limited'); } };
|
|
275
|
+
const again = await lifecyclePort.approveHeldTweak({ taskId: '5002', builderId: ALICE }, { push: broken });
|
|
276
|
+
assert.equal(again.outcome, 'approved');
|
|
277
|
+
assert.deepEqual(again.land, { nudged: false, reason: 'nudge_failed', lands_via: 'reconciler' });
|
|
278
|
+
// Send-back closes the open PR.
|
|
279
|
+
task(5002).status = 'completed';
|
|
280
|
+
calls.length = 0;
|
|
281
|
+
const back = await lifecyclePort.sendBackHeldTweak({
|
|
282
|
+
taskId: '5002', builderId: BOB,
|
|
283
|
+
compose: (t) => pages.appendSentBack(t.description, { at: new Date().toISOString(), by: BOB, note: 'Still too long on a phone.' }),
|
|
284
|
+
}, { push });
|
|
285
|
+
assert.equal(back.outcome, 'sent_back');
|
|
286
|
+
assert.deepEqual(back.pr, { closed: true, pr_number: 88 });
|
|
287
|
+
assert.deepEqual(calls, [['find', 5002], ['close', 88]]);
|
|
288
|
+
assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await test('an ordinary task and a failed grade are never moved through the port', async () => {
|
|
292
|
+
db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
|
|
293
|
+
db.grades.set('6001', true);
|
|
294
|
+
assert.equal((await lifecyclePort.approveHeldTweak({ taskId: '6001', builderId: BOB })).outcome, 'wrong_source');
|
|
295
|
+
assert.equal(task(6001).status, 'completed');
|
|
296
|
+
assert.equal((await lifecyclePort.approveHeldTweak({ taskId: '5003', builderId: BOB })).outcome, 'not_waiting');
|
|
297
|
+
assert.equal(task(5003).status, 'completed');
|
|
298
|
+
const sent = await lifecyclePort.sendBackHeldTweak({ taskId: '5003', builderId: BOB, compose: () => ({ ok: true, description: 'x' }) });
|
|
299
|
+
assert.equal(sent.outcome, 'not_waiting');
|
|
300
|
+
assert.equal(db.grades.get('5003'), false, 'the failed grade stays for /grade-recover');
|
|
301
|
+
});
|
|
302
|
+
} finally {
|
|
303
|
+
server.close();
|
|
304
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
summary();
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// An unknown statement THROWS: a route that sends SQL this ledger does not
|
|
12
12
|
// model fails loudly instead of reading an empty answer.
|
|
13
13
|
//
|
|
14
|
-
// Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs
|
|
14
|
+
// Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs,
|
|
15
|
+
// tests/copy_desk_page_approve.mjs and
|
|
15
16
|
// tests/copy_desk_page_submit.mjs
|
|
16
17
|
// (a fixture, not a test: the runner discovers tests/*.mjs only).
|
|
17
18
|
|
|
@@ -147,6 +148,45 @@ export function installPageLedger(api, { builders }) {
|
|
|
147
148
|
if (c) Object.assign(c, { released_at: new Date().toISOString(), outcome: p[1] });
|
|
148
149
|
return { rows: [] };
|
|
149
150
|
}
|
|
151
|
+
// The Approval queue's decisions (task 1004323 / TW12,
|
|
152
|
+
// modules/lifecycle/page-tweak-approve.js): the grade row, the round's
|
|
153
|
+
// author (its latest WEB claim), the applier (its latest claim of any
|
|
154
|
+
// kind), applyGrade's promotion, and send-back's release and grade drop.
|
|
155
|
+
if (/^SELECT passed, signals FROM task_grades WHERE task_id = \$1$/.test(q)) {
|
|
156
|
+
const k = String(p[0]);
|
|
157
|
+
return { rows: db.grades.has(k) ? [{ passed: db.grades.get(k), signals: (db.gradeSignals && db.gradeSignals.get(k)) || {} }] : [] };
|
|
158
|
+
}
|
|
159
|
+
if (/^SELECT builder_id FROM claims WHERE task_id = \$1 AND worktree_name IS NULL AND creator_session_id IS NULL ORDER BY claimed_at DESC, id DESC LIMIT 1$/.test(q)) {
|
|
160
|
+
const web = db.claims.filter((c) => String(c.task_id) === String(p[0]) && isWeb(c)).slice(-1)[0];
|
|
161
|
+
return { rows: web ? [{ builder_id: web.builder_id }] : [] };
|
|
162
|
+
}
|
|
163
|
+
if (/^SELECT builder_id FROM claims WHERE task_id = \$1 ORDER BY claimed_at DESC, id DESC LIMIT 1$/.test(q)) {
|
|
164
|
+
const last = db.claims.filter((c) => String(c.task_id) === String(p[0])).slice(-1)[0];
|
|
165
|
+
return { rows: last ? [{ builder_id: last.builder_id }] : [] };
|
|
166
|
+
}
|
|
167
|
+
if (/^UPDATE tasks SET status = 'confirmed', shipped_by = COALESCE\(shipped_by, \$2\), updated_at = now\(\) WHERE id = \$1 AND status = 'completed'$/.test(q)) {
|
|
168
|
+
assert.ok(owner, 'the promotion runs inside the approve transaction');
|
|
169
|
+
const t = db.tasks.find((x) => String(x.id) === String(p[0]) && x.status === 'completed');
|
|
170
|
+
if (t) { t.status = 'confirmed'; if (t.shipped_by == null) t.shipped_by = String(p[1]); }
|
|
171
|
+
return { rows: [] };
|
|
172
|
+
}
|
|
173
|
+
if (/^(SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT) ach_reconcile$/.test(q)) return { rows: [] };
|
|
174
|
+
if (/^UPDATE claims SET released_at = now\(\), outcome = 'partial' WHERE task_id = \$1 AND released_at IS NULL RETURNING id$/.test(q)) {
|
|
175
|
+
assert.ok(owner, 'the release runs inside the send-back transaction');
|
|
176
|
+
const hit = db.claims.filter((x) => String(x.task_id) === String(p[0]) && x.released_at == null);
|
|
177
|
+
for (const c of hit) Object.assign(c, { released_at: new Date().toISOString(), outcome: 'partial' });
|
|
178
|
+
return { rows: hit.map((c) => ({ id: c.id })) };
|
|
179
|
+
}
|
|
180
|
+
if (/^DELETE FROM task_grades WHERE task_id = \$1$/.test(q)) {
|
|
181
|
+
assert.ok(owner, 'the grade drop runs inside the send-back transaction');
|
|
182
|
+
db.grades.delete(String(p[0]));
|
|
183
|
+
return { rows: [] };
|
|
184
|
+
}
|
|
185
|
+
// The ships that touched a page since its last round (shippedTasksTouchingSince).
|
|
186
|
+
if (/^SELECT t\.id, t\.title, t\.shipped_at FROM tasks t WHERE t\.status = 'shipped' AND t\.shipped_at > \$2::timestamptz AND t\.touches && \$1::text\[\]/.test(q)) {
|
|
187
|
+
const since = Date.parse(p[1]);
|
|
188
|
+
return { rows: db.tasks.filter((t) => t.status === 'shipped' && Date.parse(t.shipped_at) > since && (t.touches || []).some((f) => p[0].includes(f))).map((t) => ({ id: t.id, title: t.title, shipped_at: t.shipped_at })) };
|
|
189
|
+
}
|
|
150
190
|
// The page reads (listPageTweakTasks), computed from the ledger's state.
|
|
151
191
|
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
152
192
|
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
package/tests/gate_approvals.mjs
CHANGED
|
@@ -465,5 +465,98 @@ await t('route-rank auditor: gate-approval READ is metic+, APPROVE stays archon,
|
|
|
465
465
|
assert.deepEqual(myFindings, [], 'no auditor findings on the gate-approval routes');
|
|
466
466
|
});
|
|
467
467
|
|
|
468
|
+
|
|
469
|
+
// ===========================================================================
|
|
470
|
+
// Layer 3 — the page-tweak hold (task 1004323 / BV2.TW12, ADR 0341 D7). The
|
|
471
|
+
// approve re-arms auto-merge on the reopened PR; a page tweak held for its
|
|
472
|
+
// artist must be reopened (the check re-runs) but NEVER armed from here.
|
|
473
|
+
// ===========================================================================
|
|
474
|
+
|
|
475
|
+
const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
|
|
476
|
+
async function withHold(fnHeld, body) {
|
|
477
|
+
const saved = pageTweakReads.heldForArtistFailClosed;
|
|
478
|
+
pageTweakReads.heldForArtistFailClosed = fnHeld;
|
|
479
|
+
try { return await body(); } finally { pageTweakReads.heldForArtistFailClosed = saved; }
|
|
480
|
+
}
|
|
481
|
+
async function approveWith(prNode) {
|
|
482
|
+
let rechecked = null;
|
|
483
|
+
const res = fakeRes();
|
|
484
|
+
await withStubs({
|
|
485
|
+
isConfigured: () => true,
|
|
486
|
+
listOpenPullRequests: async () => [prNode],
|
|
487
|
+
setGateOwnerApproved: async () => true,
|
|
488
|
+
recheckPullRequest: async (args) => { rechecked = args; return { reopened: true, auto_merge: args.arm !== false }; },
|
|
489
|
+
}, async () => {
|
|
490
|
+
const handler = routeHandler(buildGateApprovalsRouter(), 'post', '/gate-approvals/:pr/approve');
|
|
491
|
+
await handler({ builder: ARCHON, params: { pr: String(prNode.number) }, body: {} }, res);
|
|
492
|
+
});
|
|
493
|
+
return { res, rechecked };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
await t('POST approve: a page tweak HELD for its artist is reopened but not re-armed', async () => {
|
|
497
|
+
const asked = [];
|
|
498
|
+
await withHold(async (id) => { asked.push(id); return true; }, async () => {
|
|
499
|
+
const { res, rechecked } = await approveWith(pr({ number: 301, title: 'task 5001: Tweak: Home (landing:index)', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
|
|
500
|
+
assert.equal(res.statusCode, 200);
|
|
501
|
+
assert.deepEqual(asked, [5001], 'the hold is asked of the PR\'s own task');
|
|
502
|
+
assert.equal(rechecked.number, 301);
|
|
503
|
+
assert.equal(rechecked.arm, false, 'the recheck must not arm auto-merge');
|
|
504
|
+
assert.match(rechecked.heldReason, /held for the artist/);
|
|
505
|
+
assert.equal(res.body.held_for_artist, true);
|
|
506
|
+
assert.match(res.body.message, /waiting for its artist/);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await t('POST approve: an ordinary task is re-armed exactly as before (no arm flag sent)', async () => {
|
|
511
|
+
await withHold(async () => false, async () => {
|
|
512
|
+
const { rechecked, res } = await approveWith(pr({ number: 302, title: 'task 1163: x', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
|
|
513
|
+
assert.deepEqual(rechecked, { number: 302 }, 'the pre-change call, byte for byte');
|
|
514
|
+
assert.equal(res.body.held_for_artist, false);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await t('POST approve: a PR naming no task is never asked about a hold', async () => {
|
|
519
|
+
let asked = 0;
|
|
520
|
+
await withHold(async () => { asked++; return true; }, async () => {
|
|
521
|
+
const { rechecked } = await approveWith(pr({ number: 303, title: 'chore: no task here', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
|
|
522
|
+
assert.equal(asked, 0);
|
|
523
|
+
assert.deepEqual(rechecked, { number: 303 });
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await t('POST approve: the REAL fail-closed read answers held when it cannot read the ledger', async () => {
|
|
528
|
+
// No stub: page-tweak-reads.heldForArtistFailClosed runs, its read throws
|
|
529
|
+
// (no pool in this process), and the route must then withhold the arm.
|
|
530
|
+
const { rechecked } = await approveWith(pr({ number: 304, title: 'task 5002: y', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
|
|
531
|
+
assert.equal(rechecked.arm, false, 'an unreadable hold is treated as held');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await t('recheckPullRequest({ arm: false }): closes and reopens, and never calls the auto-merge mutation', async () => {
|
|
535
|
+
const calls = [];
|
|
536
|
+
const fetchImpl = async (url, opts) => {
|
|
537
|
+
const method = (opts && opts.method) || 'GET';
|
|
538
|
+
if (method === 'PATCH') { calls.push(JSON.parse(opts.body).state); return jsonRes(200, { node_id: 'N1' }); }
|
|
539
|
+
calls.push(`unexpected ${url}`);
|
|
540
|
+
return jsonRes(200, {});
|
|
541
|
+
};
|
|
542
|
+
const r = await gp.recheckPullRequest({ number: 205, arm: false, heldReason: 'held for the artist' }, { token: 'T', repoInfo: REPO, fetchImpl, reopenDelayMs: 0 });
|
|
543
|
+
assert.deepEqual(calls, ['closed', 'open']);
|
|
544
|
+
assert.equal(r.reopened, true);
|
|
545
|
+
assert.equal(r.auto_merge, false);
|
|
546
|
+
assert.equal(r.auto_merge_reason, 'held for the artist');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await t('closePullRequest (github-pr-close.js): one PATCH to state=closed, and a refusal is thrown by name', async () => {
|
|
550
|
+
const prClose = require('../modules/lifecycle/github-pr-close.js');
|
|
551
|
+
const calls = [];
|
|
552
|
+
const fetchImpl = async (url, opts) => { calls.push([url, opts.method, JSON.parse(opts.body)]); return jsonRes(200, {}); };
|
|
553
|
+
const r = await prClose.closePullRequest({ number: 77 }, { token: 'T', repoInfo: REPO, fetchImpl });
|
|
554
|
+
assert.deepEqual(r, { closed: true, number: 77 });
|
|
555
|
+
assert.deepEqual(calls, [['https://api.github.com/repos/someowner/somerepo/pulls/77', 'PATCH', { state: 'closed' }]]);
|
|
556
|
+
await assert.rejects(
|
|
557
|
+
prClose.closePullRequest({ number: 77 }, { token: 'T', repoInfo: REPO, fetchImpl: async () => jsonRes(422, { message: 'no' }) }),
|
|
558
|
+
(e) => e.code === 'PR_PATCH_FAILED'
|
|
559
|
+
);
|
|
560
|
+
});
|
|
468
561
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
469
562
|
process.exit(fail === 0 ? 0 : 1);
|