@bongos/core 1.21.152 → 1.21.154
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 +44 -29
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +315 -307
- package/docs/copy-registry.json +431 -359
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -0
- package/docs/page-readings.json +224 -216
- package/modules/hall-ui/public/modules.html +16 -0
- package/modules/hall-ui/public/modules.js +116 -0
- package/modules/hall-ui/public/modules.states.json +2 -1
- package/modules/hall-ui/records/catalog.md +4 -0
- package/modules/lifecycle/github-failure.js +50 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/modules/provisioning/github-setup.js +51 -1
- package/modules/provisioning/routes/provisioning.js +7 -2
- package/modules/public-landing/public/projects.html +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/module-listing.js +116 -6
- package/src/bongos/routes/modules.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/github_failure.mjs +79 -0
- package/tests/github_install_nudge.mjs +219 -0
- package/tests/hall_catalog_world.mjs +31 -2
- package/tests/module_store_listing.mjs +156 -3
|
@@ -73,6 +73,22 @@
|
|
|
73
73
|
<ul class="ov-rows" id="own-grid"></ul>
|
|
74
74
|
<p class="ov-foot" id="submit-lede">A module built here can be submitted upstream for review, so it can join the shared catalog with your name and this project credited.</p>
|
|
75
75
|
</div>
|
|
76
|
+
|
|
77
|
+
<!-- In the store — what GET /store/modules lists, each with what it is
|
|
78
|
+
strong and weak at (task 1003799, ADR 0343 D4), so two modules with
|
|
79
|
+
the same score can be told apart. Hidden until the store answers
|
|
80
|
+
with something to list. -->
|
|
81
|
+
<div class="ov-group" id="store-group" hidden>
|
|
82
|
+
<div class="ov-group__head">
|
|
83
|
+
<span class="ov-group__name">In the store</span>
|
|
84
|
+
<span class="ov-group__n" id="store-n"></span>
|
|
85
|
+
<!-- task 1003800: the buyer picks the part that matters to them, and
|
|
86
|
+
the server orders by it (sortByPart). Filled from sort_parts. -->
|
|
87
|
+
<label class="ov-group__note">Sort by <select id="store-sort"><option value="">Name</option></select></label>
|
|
88
|
+
</div>
|
|
89
|
+
<ul class="ov-rows" id="store-grid"></ul>
|
|
90
|
+
<p class="ov-foot" id="store-lede">A part is strong at 80 out of 100 or more, and weak below the listing floor. A part with no data yet is not counted against a module. Sort by a part to see the modules strongest at it first; a module with no score for that part goes last, and a new module is never ranked.</p>
|
|
91
|
+
</div>
|
|
76
92
|
</section>
|
|
77
93
|
|
|
78
94
|
<!-- One store version's how-to (task 1004366, ADR 0347): the HOWTO.md that
|
|
@@ -204,6 +204,120 @@
|
|
|
204
204
|
</li>`;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ---- the store listing (task 1003799, ADR 0343 D4) ----
|
|
208
|
+
//
|
|
209
|
+
// GET /store/modules entries carry their parts read as strengths, weaknesses and
|
|
210
|
+
// unmeasured parts (src/bongos/module-listing.js readParts). The row shows the
|
|
211
|
+
// short labels; the sentence behind each is one click away in the details, the
|
|
212
|
+
// splitCriterion idea: scannable in a list, thorough on demand.
|
|
213
|
+
let storeModules = [];
|
|
214
|
+
// The part the buyer sorts by (task 1003800): '' is name order. The server owns
|
|
215
|
+
// the order and the list of parts; this page only asks for one and shows it.
|
|
216
|
+
let storeSort = '';
|
|
217
|
+
let storeSortTitle = '';
|
|
218
|
+
let storeSortWired = false;
|
|
219
|
+
|
|
220
|
+
// The sorted-by part's own score, so the order explains itself on every row.
|
|
221
|
+
function sortedPartFact(m) {
|
|
222
|
+
if (!storeSort || m.is_new) return '';
|
|
223
|
+
const p = m.parts && m.parts[storeSort];
|
|
224
|
+
return p && p.score != null
|
|
225
|
+
? `<span class="ov-fact">${escapeHtml(storeSortTitle)} <b>${escapeHtml(String(p.score))}</b></span>`
|
|
226
|
+
: `<span class="ov-fact">${escapeHtml(storeSortTitle)} not measured yet</span>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function partsList(items) {
|
|
230
|
+
return items.map((p) => `${escapeHtml(p.title)} <b>${escapeHtml(String(p.score))}</b>`).join(', ');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function storeFacts(m) {
|
|
234
|
+
const facts = [m.is_new
|
|
235
|
+
? '<span class="fact-pill fact-pill--info" title="No real-world data yet, so it is listed but not ranked.">new</span>'
|
|
236
|
+
: `<span class="ov-fact">scores <b>${escapeHtml(String(m.overall))}</b></span>`];
|
|
237
|
+
const sorted = sortedPartFact(m);
|
|
238
|
+
if (sorted) facts.push(sorted);
|
|
239
|
+
const strong = Array.isArray(m.strengths) ? m.strengths : [];
|
|
240
|
+
const weak = Array.isArray(m.weaknesses) ? m.weaknesses : [];
|
|
241
|
+
if (strong.length) facts.push(`<span class="ov-fact">strong at ${partsList(strong)}</span>`);
|
|
242
|
+
// A weakness is the exception a buyer must not miss, so it is the warn pill.
|
|
243
|
+
if (weak.length) facts.push(`<span class="fact-pill fact-pill--warn">weak at ${partsList(weak)}</span>`);
|
|
244
|
+
return facts.join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function storeDetailLines(m) {
|
|
248
|
+
const lines = [...(m.strengths || []), ...(m.weaknesses || [])]
|
|
249
|
+
.map((p) => `<li><span class="ov-key ov-key--faint">${escapeHtml(p.title)}</span><span class="ov-lines__v">${escapeHtml(p.detail)}</span></li>`);
|
|
250
|
+
const missing = (m.unmeasured || []).map((p) => p.title);
|
|
251
|
+
if (missing.length) {
|
|
252
|
+
lines.push(`<li><span class="ov-key ov-key--faint">not measured yet</span><span class="ov-lines__v">${escapeHtml(missing.join(', '))}</span></li>`);
|
|
253
|
+
}
|
|
254
|
+
return lines.length ? `<ul class="ov-lines">${lines.join('')}</ul>` : '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function storeRowHTML(m) {
|
|
258
|
+
const key = `store:${m.key}`;
|
|
259
|
+
const lines = storeDetailLines(m);
|
|
260
|
+
return `
|
|
261
|
+
<li class="ov-row mod-row" data-key="${escapeHtml(key)}">
|
|
262
|
+
<div class="ov-row__main">
|
|
263
|
+
<span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
|
|
264
|
+
<div class="ov-row__facts">${storeFacts(m)}</div>
|
|
265
|
+
${lines ? `<details class="mod-more"${openKeys.has(key) ? ' open' : ''}><summary aria-label="Strengths and weaknesses of ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
|
|
266
|
+
</div>
|
|
267
|
+
<div class="ov-row__right mod-row__right"><span class="ov-mono">${escapeHtml(String(m.version || ''))}</span></div>
|
|
268
|
+
</li>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function matchesStoreQuery(m, q) {
|
|
272
|
+
if (!q) return true;
|
|
273
|
+
return [m.key, m.title, ...(m.strengths || []).map((p) => p.title), ...(m.weaknesses || []).map((p) => p.title)]
|
|
274
|
+
.some((f) => f && String(f).toLowerCase().includes(q));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderStore() {
|
|
278
|
+
const group = $('#store-group');
|
|
279
|
+
if (!group) return;
|
|
280
|
+
group.hidden = !storeModules.length;
|
|
281
|
+
if (!storeModules.length) return;
|
|
282
|
+
const shown = storeModules.filter((m) => matchesStoreQuery(m, query));
|
|
283
|
+
const list = $('#store-grid');
|
|
284
|
+
if (list) {
|
|
285
|
+
list.innerHTML = shown.length ? shown.map(storeRowHTML).join('')
|
|
286
|
+
: `<li>${kit.emptyStateHtml('No store module matches that search.')}</li>`;
|
|
287
|
+
}
|
|
288
|
+
const n = $('#store-n');
|
|
289
|
+
if (n) n.textContent = String(shown.length);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Best-effort and last: the store is a second read, and the modules on disk ARE
|
|
293
|
+
// this page, so a store that cannot be read costs this group and nothing else.
|
|
294
|
+
// Filled and wired once, on the first store read; a re-read only re-renders.
|
|
295
|
+
function fillStoreSort(parts) {
|
|
296
|
+
const select = $('#store-sort');
|
|
297
|
+
if (storeSortWired || !select || !Array.isArray(parts)) return;
|
|
298
|
+
storeSortWired = true;
|
|
299
|
+
for (const p of parts) select.add(new Option(p.title, p.part));
|
|
300
|
+
select.addEventListener('change', () => {
|
|
301
|
+
storeSort = select.value;
|
|
302
|
+
storeSortTitle = storeSort ? select.options[select.selectedIndex].text : '';
|
|
303
|
+
loadStore();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function loadStore() {
|
|
308
|
+
try {
|
|
309
|
+
const q = storeSort ? `?sort=${encodeURIComponent(storeSort)}` : '';
|
|
310
|
+
const res = await api.request('GET', `${API}/store/modules${q}`);
|
|
311
|
+
if (!res.ok) return;
|
|
312
|
+
const body = res.data || {};
|
|
313
|
+
fillStoreSort(body.sort_parts);
|
|
314
|
+
storeModules = [...(body.listed || []), ...(body.new || [])];
|
|
315
|
+
renderStore();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error('[modules] store listing failed to load', err);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
207
321
|
// `source` splits the page: 'core' = shipped by the core package (the
|
|
208
322
|
// catalog you opt into), 'instance' = lives in this repo. Older payloads
|
|
209
323
|
// predate the field, so fall back to the credit axis rather than emptying
|
|
@@ -248,6 +362,7 @@
|
|
|
248
362
|
if (coreN) coreN.textContent = String(core.length);
|
|
249
363
|
const ownN = $('#own-n');
|
|
250
364
|
if (ownN) ownN.textContent = String(own.length);
|
|
365
|
+
renderStore();
|
|
251
366
|
|
|
252
367
|
// The count line speaks only while a filter narrows the list — the figure
|
|
253
368
|
// band under it already says how many there are.
|
|
@@ -366,6 +481,7 @@
|
|
|
366
481
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
367
482
|
if (!res.ok) throw new Error(`modules → ${res.status}`);
|
|
368
483
|
render(res.data);
|
|
484
|
+
loadStore();
|
|
369
485
|
} catch (err) {
|
|
370
486
|
console.error('[modules] failed to load', err);
|
|
371
487
|
const list = $('#core-grid');
|
|
@@ -10,3 +10,7 @@
|
|
|
10
10
|
Also gone, and the one rule in that list: `.ab-caption` takes the lede's `--measure`, never a fresh `90ch`. **Gotcha:** `gen-atlas.js` run from the MAIN checkout walks `.claude/worktrees/*` and emits thousands of context nodes — run it from a worktree, or copy a worktree's `atlas.json` over, before previewing the atlas there.
|
|
11
11
|
|
|
12
12
|
**A store version's how-to is a view of the Modules page, not a new page (task 1004366).** `/builders/modules?howto=<key>&version=<X.Y.Z>` hides the catalog and shows that version's `HOWTO.md` in the reading room's article card: the page adds `body.room` so `room.css`'s sizes apply, and the markdown goes through `md-reader.js` (author-written, so every byte is escaped; only absolute http(s), mailto and #anchor links become links). A query, not a `#/` route — the hash namespace is frozen to the idea and blocker overlays. "Save as PDF" is `window.print()` with an `@media print` block in `modules.css` that keeps only the document and prints in the system ink (`CanvasText`), because a literal colour is the token tier's. Harness fixture: `store__modules__howto.json`.
|
|
13
|
+
|
|
14
|
+
**The store's modules are a third group on the same panel, read as strengths and weaknesses (task 1003799, ADR 0343 D4).** `#store-group` ("In the store") lists what `GET /store/modules` lists, hidden until that read answers with something; it loads after the modules on disk and best-effort, so a store that cannot be read costs this group and nothing else. A row is the same `.ov-row`: the overall score as a plain fact (or the `new` info pill), `strong at Tests 96, How-to 88` as a plain fact, and `weak at …` as the warn pill, because a weakness is the exception a buyer must not miss. The sentence behind each part ("Tests scored 96 out of 100, from 40 test cases.") and the parts with no data yet sit in the row's `<details class="mod-more">`, the `splitCriterion` idea: short label in the list, the detail one click away. The reading is the server's (`readParts` in `src/bongos/module-listing.js`: strong at 80 or more, weak below the listing floor, unmeasured never weak); the page re-derives nothing. Harness fixture: `store__modules.json`.
|
|
15
|
+
|
|
16
|
+
**The buyer sorts the store by the part that matters to them (task 1003800, owner decision 2026-10-22).** A "Sort by" menu sits in the store group's head (`#store-sort`, `.ov-group__note`): Name, then the parts the server lists in `sort_parts`. Picking one re-reads `GET /store/modules?sort=<part>`, and the server orders the listed modules (`sortByPart` in `src/bongos/module-listing.js`: highest for that part first, a tie to the overall score then the key, a module with no score for the part last). The page never sorts the rows itself, and New modules keep name order because they are never ranked. While a part is picked, each listed row carries that part's score as a plain fact (`Reliability 91`, or `Reliability not measured yet`), so the order explains itself. The owner chose the buyer over a per-category "key part" because nothing records what a category is for, and a hidden weighting would break the score an author can check (ADR 0343 D3).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/github-failure.js — say WHICH GitHub call failed, and what
|
|
4
|
+
// GitHub answered (task 1004766).
|
|
5
|
+
//
|
|
6
|
+
// WHY. GET /tasks/:id/publish-status answered every unexpected GitHub failure with
|
|
7
|
+
// a bare 502 `status_failed`, and logged only the error's code. On the toast
|
|
8
|
+
// instance (2026-10-20) the GitHub App was installed and the merge was on main,
|
|
9
|
+
// yet every probe 502'd: the builder saw "no explanation", and the operator's log
|
|
10
|
+
// said one word. The thrown errors already carry what is needed — the code says
|
|
11
|
+
// which step broke (minting the App token, listing PRs, reading a branch) and
|
|
12
|
+
// `status` / `detail` hold GitHub's own HTTP status and message — they were just
|
|
13
|
+
// dropped on the floor.
|
|
14
|
+
//
|
|
15
|
+
// SAFE TO SHOW. `detail` is GitHub's response body (an error message such as
|
|
16
|
+
// "Bad credentials" or "Resource not accessible by integration"), never a token:
|
|
17
|
+
// tokens travel in request headers, and no caller puts one in an error. It is
|
|
18
|
+
// trimmed and capped anyway. PURE.
|
|
19
|
+
|
|
20
|
+
const STEPS = Object.freeze({
|
|
21
|
+
APP_TOKEN_FAILED: "the server could not get a GitHub App token (GitHub refused the App's key or its installation)",
|
|
22
|
+
STATUS_FETCH_FAILED: 'GitHub refused a read of the repository',
|
|
23
|
+
PUSH_UNCONFIGURED: 'the server has no GitHub credential',
|
|
24
|
+
BAD_BRANCH: 'the branch name is not one the server will ask GitHub about',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const MAX_DETAIL = 300;
|
|
28
|
+
|
|
29
|
+
function describeGithubFailure(e) {
|
|
30
|
+
const code = (e && e.code) || null;
|
|
31
|
+
const status = e && Number.isInteger(e.status) ? e.status : null;
|
|
32
|
+
const detail = e && e.detail ? String(e.detail).replace(/\s+/g, ' ').trim().slice(0, MAX_DETAIL) : null;
|
|
33
|
+
const step = (code && STEPS[code]) || 'an unexpected error while asking GitHub';
|
|
34
|
+
const github = status ? ` GitHub answered HTTP ${status}${detail ? `: ${detail}` : ''}.` : '';
|
|
35
|
+
return {
|
|
36
|
+
message: `Could not read the land status: ${step}.${github}`,
|
|
37
|
+
details: { cause: code, github_status: status, github_detail: detail },
|
|
38
|
+
log: `${code || (e && e.message) || 'unknown'}${status ? ` (GitHub HTTP ${status}${detail ? `: ${detail}` : ''})` : ''}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The publish-status route's answer, in one call so the (size-capped) route file
|
|
43
|
+
// stays one line: log the step + GitHub's reply, answer 502 with the same words.
|
|
44
|
+
function failGithubStatus(res, log, e) {
|
|
45
|
+
const why = describeGithubFailure(e);
|
|
46
|
+
if (log) log.error('[gds] GET /tasks/:id/publish-status', why.log);
|
|
47
|
+
return res.fail('status_failed', { status: 502, message: why.message, details: why.details });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { describeGithubFailure, failGithubStatus };
|
|
@@ -55,6 +55,7 @@ const { makeAbandonHandler } = require('./abandon-handler');
|
|
|
55
55
|
const githubPush = require('../github-push');
|
|
56
56
|
const prAssign = require('../pr-assign');
|
|
57
57
|
const shipAssurance = require('../ship-assurance');
|
|
58
|
+
const { failGithubStatus } = require('../github-failure');
|
|
58
59
|
const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
|
|
59
60
|
// task 1003764: publish-status with no ?branch= and no PR titled for the task — the shape
|
|
60
61
|
// getPublishStatus gives a branch with no PR, plus how to ask about one. Never a 400.
|
|
@@ -1345,8 +1346,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1345
1346
|
} catch (e) {
|
|
1346
1347
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1347
1348
|
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
1348
|
-
|
|
1349
|
-
return res.fail('status_failed', 502);
|
|
1349
|
+
return failGithubStatus(res, log, e); // task 1004766: names the step + GitHub's answer
|
|
1350
1350
|
}
|
|
1351
1351
|
});
|
|
1352
1352
|
|
|
@@ -247,7 +247,57 @@ async function githubSetupRead(db, inst, row, status, { now = Date.now(), withSt
|
|
|
247
247
|
};
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// The standing install nudge (task 1004763). A project made before the wizard's install
|
|
251
|
+
// step existed (task 1004649), or whose founder closed the GitHub tab, has an app nobody
|
|
252
|
+
// installed on its repo, and every ship then ends land-not-proven because the platform
|
|
253
|
+
// cannot read the repo. The checklist above only shows inside the creation wizard, so the
|
|
254
|
+
// project list and the project's own page carry this instead: the install link and the
|
|
255
|
+
// repo, from the latest attempt, until the runner has SEEN the install. null when there is
|
|
256
|
+
// no app of ours (nothing to install) or GitHub has confirmed it. PURE.
|
|
257
|
+
function installNudge(setup) {
|
|
258
|
+
if (!setup || !setup.checklist || setup.checklist.install === 'done' || !setup.install_url) return null;
|
|
259
|
+
return { install_url: setup.install_url, repo: setup.repo || null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The nudges for a whole project list in TWO reads, never one per project: each project's
|
|
263
|
+
// latest attempt, then which of those GitHub has been seen installed on since that attempt
|
|
264
|
+
// began. Only the install fact matters here: with an app of ours recorded, the checklist's
|
|
265
|
+
// install step is 'done' exactly when the runner saw it (githubChecklist), so the full
|
|
266
|
+
// read is not needed. Returns a Map of instance id → nudge or null. A switched-off project
|
|
267
|
+
// is silent; a failed read is no nudge for anyone, never a failed list.
|
|
268
|
+
async function installNudgesFor(db, insts) {
|
|
269
|
+
const out = new Map();
|
|
270
|
+
const live = (insts || []).filter((i) => i && i.id != null && i.status !== 'torn_down' && i.status !== 'tearing_down');
|
|
271
|
+
for (const i of insts || []) if (i && i.id != null) out.set(String(i.id), null);
|
|
272
|
+
if (!live.length) return out;
|
|
273
|
+
try {
|
|
274
|
+
const { rows: attempts } = await db.query(
|
|
275
|
+
`SELECT DISTINCT ON (instance_id) instance_id, app_slug, created_at
|
|
276
|
+
FROM provisioning_oauth_manifests
|
|
277
|
+
WHERE instance_id = ANY($1::bigint[])
|
|
278
|
+
ORDER BY instance_id, created_at DESC`,
|
|
279
|
+
[live.map((i) => i.id)]);
|
|
280
|
+
const withApp = attempts.filter((a) => a.app_slug);
|
|
281
|
+
if (!withApp.length) return out;
|
|
282
|
+
const { rows: seen } = await db.query(
|
|
283
|
+
`SELECT DISTINCT e.instance_id
|
|
284
|
+
FROM provisioning_events e
|
|
285
|
+
JOIN unnest($1::bigint[], $2::timestamptz[]) AS s(id, since)
|
|
286
|
+
ON e.instance_id = s.id AND e.created_at >= s.since
|
|
287
|
+
WHERE e.event = $3`,
|
|
288
|
+
[withApp.map((a) => a.instance_id), withApp.map((a) => new Date(a.created_at).toISOString()), SETUP_EVENTS.installed]);
|
|
289
|
+
const installed = new Set(seen.map((r) => String(r.instance_id)));
|
|
290
|
+
const byId = new Map(live.map((i) => [String(i.id), i]));
|
|
291
|
+
for (const a of withApp) {
|
|
292
|
+
const id = String(a.instance_id);
|
|
293
|
+
if (installed.has(id) || !byId.has(id)) continue;
|
|
294
|
+
out.set(id, { install_url: installUrlFor({ appSlug: a.app_slug }), repo: targetRepo(byId.get(id)) });
|
|
295
|
+
}
|
|
296
|
+
} catch (_) { for (const k of out.keys()) out.set(k, null); }
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
250
300
|
module.exports = {
|
|
251
301
|
SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead,
|
|
252
|
-
validGithubLogin, pickUpFor, mintGuard, requeueConversion, recordSignInSeen,
|
|
302
|
+
validGithubLogin, pickUpFor, mintGuard, requeueConversion, recordSignInSeen, installNudge, installNudgesFor,
|
|
253
303
|
};
|
|
@@ -621,12 +621,15 @@ module.exports = function provisioningRoutes() {
|
|
|
621
621
|
router.get('/provisioning/instances', auth.requireBuilder, async (req, res) => {
|
|
622
622
|
try {
|
|
623
623
|
const rows = await provisioning.listInstancesForOwner(pool, req.builder.id);
|
|
624
|
+
// the standing 'Install the GitHub App' nudge (task 1004763): two reads for the whole list
|
|
625
|
+
const nudges = await githubSetup.installNudgesFor(pool, rows);
|
|
624
626
|
const instances = [];
|
|
625
627
|
for (const row of rows) {
|
|
626
628
|
const open = await provisioning.getOpenIntent(pool, row.id);
|
|
627
629
|
instances.push({
|
|
628
630
|
...ownerInstance(row),
|
|
629
631
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
632
|
+
github_install: nudges.get(String(row.id)) || null,
|
|
630
633
|
});
|
|
631
634
|
}
|
|
632
635
|
res.json({ instances });
|
|
@@ -660,13 +663,15 @@ module.exports = function provisioningRoutes() {
|
|
|
660
663
|
// The GitHub-App leg's truth rides the same read (task 1003235), so the done
|
|
661
664
|
// panel recovers it from server state after a reload — not from sessionStorage.
|
|
662
665
|
const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
|
|
666
|
+
const githubSetupNow = await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false });
|
|
663
667
|
res.json({
|
|
664
|
-
instance
|
|
668
|
+
// the standing install nudge rides the instance, as on the list read (task 1004763)
|
|
669
|
+
instance: { ...ownerInstance(inst), github_install: githubSetup.installNudge(githubSetupNow) },
|
|
665
670
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
666
671
|
github_app: provisioning.publicGithubApp(manifest),
|
|
667
672
|
// the four-step checklist from what the runner verified (task 1004649) — the
|
|
668
673
|
// install link rides WITHOUT the state token this read never carries
|
|
669
|
-
github_setup:
|
|
674
|
+
github_setup: githubSetupNow,
|
|
670
675
|
});
|
|
671
676
|
} catch (err) {
|
|
672
677
|
log.error('[provisioning] GET /provisioning/instances/:id', err);
|
|
@@ -1140,6 +1140,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1140
1140
|
stripFor/errMarkup/canRetry/canRevive the list row reads -->
|
|
1141
1141
|
<div id="mStrip"></div>
|
|
1142
1142
|
<div class="projMsg err" id="mErr" hidden></div>
|
|
1143
|
+
<div class="projMsg" id="mGh" hidden></div>
|
|
1143
1144
|
<div class="projMsg" id="mMsg" role="status" aria-live="polite"></div>
|
|
1144
1145
|
<div class="actions" id="mActs" hidden></div>
|
|
1145
1146
|
</div>
|
|
@@ -8357,6 +8358,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8357
8358
|
(renderCard) — a project the owner is being told about cannot sit under
|
|
8358
8359
|
Live. The intent-vs-status contract is deliberately left alone. */
|
|
8359
8360
|
if (inst.error_note && !inst.open_intent) return 'attention';
|
|
8361
|
+
/* an app nobody installed blocks every ship until the owner acts (task 1004763) */
|
|
8362
|
+
if (ghInstallRow(inst)) return 'attention';
|
|
8360
8363
|
return STATE_GROUP[inst.status] || 'all';
|
|
8361
8364
|
}
|
|
8362
8365
|
function findText(inst) {
|
|
@@ -8828,6 +8831,21 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8828
8831
|
'">Say what it is</a> and it goes on.';
|
|
8829
8832
|
}
|
|
8830
8833
|
|
|
8834
|
+
/* the standing GitHub install nudge (task 1004763). A project whose app was made but never
|
|
8835
|
+
installed on its repo cannot have finished work added: the platform cannot read the
|
|
8836
|
+
repo, so every ship stops short. The wizard's checklist shows only while a project is
|
|
8837
|
+
being made, so the card and the project's own page say it, with GitHub's own install
|
|
8838
|
+
link, until the runner has seen the install. The click also asks for a fresh look
|
|
8839
|
+
(the a[data-gh-install] handler below). '' when there is nothing to install. */
|
|
8840
|
+
function ghInstallRow(inst) {
|
|
8841
|
+
var g = inst && inst.github_install;
|
|
8842
|
+
if (!g || !/^https:\/\/github\.com\/apps\//.test(String(g.install_url || ''))) return '';
|
|
8843
|
+
if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
|
|
8844
|
+
return 'Finished work can’t be added to ' + (g.repo ? '<code>' + esc(g.repo) + '</code>' : 'its repo') +
|
|
8845
|
+
' until the GitHub App is installed on it. <a class="btn small" href="' + esc(g.install_url) +
|
|
8846
|
+
'" target="_blank" rel="noopener" data-gh-install="' + esc(String(inst.id)) + '">Install the GitHub App ↗</a>';
|
|
8847
|
+
}
|
|
8848
|
+
|
|
8831
8849
|
function renderCard(inst) {
|
|
8832
8850
|
/* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
|
|
8833
8851
|
lookup goes through liFor(), which CSS-escapes — the two escapes are
|
|
@@ -8851,6 +8869,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8851
8869
|
if (addr) body += '<div class="addr">' + addr + '</div>';
|
|
8852
8870
|
var pub = publishRow(inst);
|
|
8853
8871
|
if (pub) body += '<div class="projMsg">' + pub + '</div>';
|
|
8872
|
+
var gh = ghInstallRow(inst);
|
|
8873
|
+
if (gh) body += '<div class="projMsg">' + gh + '</div>';
|
|
8854
8874
|
if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
|
|
8855
8875
|
/* once a retry is queued the strip tells the story — the stale error
|
|
8856
8876
|
paragraph would just contradict it (owner walk, 2026-08-12). Also
|
|
@@ -9198,6 +9218,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
9198
9218
|
var snag = (inst.status === 'error' || inst.error_note) && !inst.open_intent;
|
|
9199
9219
|
$('mErr').innerHTML = snag ? errMarkup(inst) : '';
|
|
9200
9220
|
$('mErr').hidden = !snag;
|
|
9221
|
+
var gh = ghInstallRow(inst);
|
|
9222
|
+
$('mGh').innerHTML = gh;
|
|
9223
|
+
$('mGh').hidden = !gh;
|
|
9201
9224
|
var acts = [];
|
|
9202
9225
|
if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
|
|
9203
9226
|
if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
|
|
@@ -11231,6 +11254,16 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
11231
11254
|
return e.message || 'The restart could not be queued — try again in a moment.';
|
|
11232
11255
|
}
|
|
11233
11256
|
|
|
11257
|
+
/* the install nudge's click (task 1004763), on the card and the project's own page alike:
|
|
11258
|
+
GitHub opens in its own tab as the link says, and the platform is asked to look again.
|
|
11259
|
+
GitHub's hand-back from this link carries no attempt, so without the ask nothing would
|
|
11260
|
+
re-check and the nudge would never clear. Best-effort: a refused ask changes nothing. */
|
|
11261
|
+
document.addEventListener('click', function (ev) {
|
|
11262
|
+
var a = ev.target.closest && ev.target.closest('a[data-gh-install]');
|
|
11263
|
+
if (!a) return;
|
|
11264
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(a.getAttribute('data-gh-install')) + '/github-app/pick-up', { method: 'POST', credentials: 'same-origin' }).catch(function () {});
|
|
11265
|
+
});
|
|
11266
|
+
|
|
11234
11267
|
/* the list's one delegated handler. A row holds no sender since task 1003355
|
|
11235
11268
|
— only its two doors — so the Manage link is all there is to upgrade: it is
|
|
11236
11269
|
a real anchor, a modified click keeps its browser meaning and a plain one
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.154",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.154",
|
|
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.
|
|
3
|
+
"version": "1.21.154",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9926,5 +9926,25 @@
|
|
|
9926
9926
|
"id": "1003784",
|
|
9927
9927
|
"text": "Every module now says which of the eight categories it belongs to, and the module list in the terminal and the hall's Modules tab both show it and let you search by it."
|
|
9928
9928
|
}
|
|
9929
|
+
],
|
|
9930
|
+
"1.21.153": [
|
|
9931
|
+
{
|
|
9932
|
+
"id": "1004763",
|
|
9933
|
+
"text": "Project owners whose GitHub App was never installed now see an \"Install the GitHub App\" button on their project until GitHub confirms it."
|
|
9934
|
+
},
|
|
9935
|
+
{
|
|
9936
|
+
"id": "1003799",
|
|
9937
|
+
"text": "Two store modules with the same score no longer look identical: each now shows what it is strong at and weak at (tests, installs, reliability, testers, how-to), in the store listing and in the hall's Modules tab."
|
|
9938
|
+
},
|
|
9939
|
+
{
|
|
9940
|
+
"id": "1004766",
|
|
9941
|
+
"text": "When a project's server can't talk to GitHub, the land-status check now says which step failed and what GitHub answered, instead of a bare error."
|
|
9942
|
+
}
|
|
9943
|
+
],
|
|
9944
|
+
"1.21.154": [
|
|
9945
|
+
{
|
|
9946
|
+
"id": "1003800",
|
|
9947
|
+
"text": "In the module store, a buyer can now sort by the score that matters to them, like reliability or tests, so the module strongest at that comes first."
|
|
9948
|
+
}
|
|
9929
9949
|
]
|
|
9930
9950
|
}
|
|
@@ -35,6 +35,97 @@ function placeVersion(score, floor = QUALITY_FLOOR) {
|
|
|
35
35
|
return { placement: 'listed', reason: 'at_or_above_floor' };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Strengths and weaknesses (task 1003799, ADR 0343 D4)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
//
|
|
42
|
+
// The overall number sorts the listing; the parts explain it. Two modules that both
|
|
43
|
+
// score 74 can be good at different things, and a buyer who cares about reliability
|
|
44
|
+
// should be able to see which one is. So each listed entry carries its parts and a
|
|
45
|
+
// short reading of them:
|
|
46
|
+
//
|
|
47
|
+
// strength a part scored at or above STRONG_AT
|
|
48
|
+
// weakness a part scored below the floor — the same bar the listing uses
|
|
49
|
+
// unmeasured a part with no score yet (no installs, no testers, still running).
|
|
50
|
+
// Never a weakness: a missing part is never a zero (D3/D5).
|
|
51
|
+
//
|
|
52
|
+
// Security is not on the list: it is a pass/fail gate (D2), and every listed
|
|
53
|
+
// version has passed it. The thresholds are fixed numbers on purpose, so an author
|
|
54
|
+
// can check why a part reads as strong or weak, the same way the floor works.
|
|
55
|
+
const STRONG_AT = 80;
|
|
56
|
+
|
|
57
|
+
// The parts a buyer compares, in the order they are shown, with what their
|
|
58
|
+
// sample size counts. `docs` is shown but never averaged (ADR 0347).
|
|
59
|
+
const PARTS = [
|
|
60
|
+
{ part: 'tests', title: 'Tests', counts: 'test cases' },
|
|
61
|
+
{ part: 'install', title: 'Clean installs', counts: 'installs' },
|
|
62
|
+
{ part: 'reliability', title: 'Reliability', counts: 'installs' },
|
|
63
|
+
{ part: 'tester_feedback', title: 'Tester feedback', counts: 'testers' },
|
|
64
|
+
{ part: 'docs', title: 'How-to', counts: null },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// One reading of one version's current part results. `signals` is the newest
|
|
68
|
+
// module_assessment_signals row per part ({ part, outcome, score, sample_size });
|
|
69
|
+
// a part with no row is unmeasured. Pure.
|
|
70
|
+
function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
|
|
71
|
+
const byPart = new Map(signals.map((s) => [s.part, s]));
|
|
72
|
+
const parts = {};
|
|
73
|
+
const strengths = [];
|
|
74
|
+
const weaknesses = [];
|
|
75
|
+
const unmeasured = [];
|
|
76
|
+
for (const { part, title, counts } of PARTS) {
|
|
77
|
+
const s = byPart.get(part);
|
|
78
|
+
const scored = s && s.outcome === 'scored' && Number.isFinite(Number(s.score));
|
|
79
|
+
parts[part] = s
|
|
80
|
+
? { outcome: s.outcome, score: scored ? Number(s.score) : null, sample_size: s.sample_size == null ? null : Number(s.sample_size) }
|
|
81
|
+
: { outcome: 'no_data', score: null, sample_size: null };
|
|
82
|
+
if (!scored) { unmeasured.push({ part, title }); continue; }
|
|
83
|
+
const score = Number(s.score);
|
|
84
|
+
const n = parts[part].sample_size;
|
|
85
|
+
const from = counts && n ? `, from ${n} ${n === 1 ? counts.replace(/s$/, '') : counts}` : '';
|
|
86
|
+
const item = { part, title, score, detail: `${title} scored ${score} out of 100${from}.` };
|
|
87
|
+
if (score >= STRONG_AT) strengths.push(item);
|
|
88
|
+
else if (score < floor) weaknesses.push(item);
|
|
89
|
+
}
|
|
90
|
+
strengths.sort((a, b) => b.score - a.score);
|
|
91
|
+
weaknesses.sort((a, b) => a.score - b.score);
|
|
92
|
+
return { parts, strengths, weaknesses, unmeasured };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
//
|
|
99
|
+
// A module strong at what a buyer needs should come first for THAT buyer. Nothing
|
|
100
|
+
// records what a category is "for", and a hidden per-category weighting would break
|
|
101
|
+
// the score an author can check (ADR 0343 D3), so the owner chose: the buyer picks
|
|
102
|
+
// the part. The rule, in a sentence an author can check:
|
|
103
|
+
//
|
|
104
|
+
// modules with a score for the part come first, highest first; a tie goes to the
|
|
105
|
+
// higher overall score, then the key. A module with no score for the part goes
|
|
106
|
+
// last, in key order: a missing part is never a zero, and never a first place.
|
|
107
|
+
//
|
|
108
|
+
// Only `listed` is sorted. A "New" version is never ranked (owner decision,
|
|
109
|
+
// 2026-10-01), so `new` keeps key order whatever the buyer picks. Today this runs
|
|
110
|
+
// over the whole store; once approved sub-categories exist (task 1004355) the same
|
|
111
|
+
// rule orders each one (ADR 0342 D2).
|
|
112
|
+
const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
|
|
113
|
+
|
|
114
|
+
// `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
|
|
115
|
+
function sortByPart(entries, part) {
|
|
116
|
+
const scoreOf = (e) => (e.parts && e.parts[part] ? e.parts[part].score : null);
|
|
117
|
+
return [...entries].sort((a, b) => {
|
|
118
|
+
const sa = scoreOf(a);
|
|
119
|
+
const sb = scoreOf(b);
|
|
120
|
+
if (sa == null || sb == null) {
|
|
121
|
+
if (sa != null) return -1;
|
|
122
|
+
if (sb != null) return 1;
|
|
123
|
+
return a.key.localeCompare(b.key);
|
|
124
|
+
}
|
|
125
|
+
return (sb - sa) || ((b.overall ?? -1) - (a.overall ?? -1)) || a.key.localeCompare(b.key);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
38
129
|
// The newest version row per module key, in semver order (a version that does not
|
|
39
130
|
// parse is skipped, as latestVersions does in module-store.js).
|
|
40
131
|
function newestPerModule(rows) {
|
|
@@ -49,11 +140,13 @@ function newestPerModule(rows) {
|
|
|
49
140
|
return [...out.values()];
|
|
50
141
|
}
|
|
51
142
|
|
|
52
|
-
// The catalog: { floor, listed: [...], new: [...] }, each entry
|
|
53
|
-
// { key, title, version, published_at, overall, is_new
|
|
143
|
+
// The catalog: { floor, sort, sort_parts, listed: [...], new: [...] }, each entry
|
|
144
|
+
// { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
|
|
145
|
+
// unmeasured } (the last four from readParts). Excluded versions are left
|
|
54
146
|
// out entirely: a module below the bar is not listed at all. Both lists are in key
|
|
55
|
-
// order
|
|
56
|
-
|
|
147
|
+
// order unless `sort` names a part, which orders `listed` by sortByPart; ranking
|
|
148
|
+
// is a separate task (1003808) and does not happen here.
|
|
149
|
+
async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
|
|
57
150
|
const pool = db || require('./pool').pool;
|
|
58
151
|
const { rows: versions } = await pool.query(
|
|
59
152
|
`SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
|
|
@@ -69,7 +162,22 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
|
|
|
69
162
|
ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
|
|
70
163
|
for (const s of rows) scores.set(String(s.version_id), s);
|
|
71
164
|
}
|
|
72
|
-
|
|
165
|
+
// Each version's current result per part: the newest row per (version, part),
|
|
166
|
+
// the read idx_module_assessment_signals_current exists for (core_265).
|
|
167
|
+
const signals = new Map();
|
|
168
|
+
if (newest.length) {
|
|
169
|
+
const { rows } = await pool.query(
|
|
170
|
+
`SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score, sample_size
|
|
171
|
+
FROM module_assessment_signals
|
|
172
|
+
WHERE version_id = ANY($1)
|
|
173
|
+
ORDER BY version_id, part, measured_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
|
|
174
|
+
for (const s of rows) {
|
|
175
|
+
const k = String(s.version_id);
|
|
176
|
+
if (!signals.has(k)) signals.set(k, []);
|
|
177
|
+
signals.get(k).push(s);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const out = { floor, sort: sort || null, sort_parts: SORT_PARTS, listed: [], new: [] };
|
|
73
181
|
for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
|
|
74
182
|
const score = scores.get(String(r.version_id)) || null;
|
|
75
183
|
const { placement } = placeVersion(score, floor);
|
|
@@ -77,9 +185,11 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
|
|
|
77
185
|
out[placement].push({
|
|
78
186
|
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
79
187
|
overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
|
|
188
|
+
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
80
189
|
});
|
|
81
190
|
}
|
|
191
|
+
if (sort) out.listed = sortByPart(out.listed, sort);
|
|
82
192
|
return out;
|
|
83
193
|
}
|
|
84
194
|
|
|
85
|
-
module.exports = { QUALITY_FLOOR, placeVersion, listCatalog };
|
|
195
|
+
module.exports = { QUALITY_FLOOR, STRONG_AT, SORT_PARTS, placeVersion, readParts, sortByPart, listCatalog };
|