@bongos/core 1.19.975 → 1.19.977
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 +63 -23
- package/.claude/skills/goal-create/SKILL.md +8 -0
- package/.claude/skills/goal-review/SKILL.md +8 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +4 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-tasks.js +43 -0
- package/modules/lifecycle/dependency-advisory.js +99 -0
- package/modules/lifecycle/missing-dep-classify.js +95 -0
- package/modules/lifecycle/routes/task-write-routes.js +10 -1
- package/modules/provisioning/migrations/provisioning_025_repo_private.sql +34 -0
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/repo-private.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-deps.js +13 -61
- package/scripts/gds/provision-repo-private.js +86 -0
- package/scripts/gds/provision.js +1 -1
- package/src/bongos/github-repos.js +22 -9
- package/src/module-api.js +1 -1
- package/tests/dependency_advisory.mjs +231 -0
- package/tests/github_repos_helpers.mjs +55 -4
- package/tests/missing_dep_classify.mjs +108 -0
- package/tests/provision_repo_private.mjs +260 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
|
@@ -58,7 +58,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
58
58
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
59
59
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
60
60
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
61
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
|
|
61
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
|
|
62
62
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
63
63
|
// state where there is provably nothing left to release. DERIVED from
|
|
64
64
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -1158,7 +1158,7 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
|
|
|
1158
1158
|
// (the state CHECK has no 'superseded'; last_error says why) so the runner never
|
|
1159
1159
|
// stands it up. A PENDING settings push is superseded the same way (task 1003140):
|
|
1160
1160
|
// a push into a project about to be torn down is dead work, and letting it hold the
|
|
1161
|
-
// delete hostage would repeat 1002691. A pending 'restart' joins them for the same
|
|
1161
|
+
// delete hostage would repeat 1002691. A pending 'restart' or 'repo-private' (task 1004193) joins them for the same
|
|
1162
1162
|
// reason (task 1003238) — and more sharply, since the owner reaches for Restart
|
|
1163
1163
|
// precisely when a project looks broken, which is also when they give up and delete
|
|
1164
1164
|
// it. A RUNNING intent is mid-flight and never yanked —
|
|
@@ -1171,7 +1171,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
1171
1171
|
`UPDATE provisioning_intents
|
|
1172
1172
|
SET state = 'error', last_error = 'superseded: teardown requested',
|
|
1173
1173
|
updated_at = now(), resolved_at = now()
|
|
1174
|
-
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart') AND state = 'pending'
|
|
1174
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
|
|
1175
1175
|
RETURNING id`,
|
|
1176
1176
|
[instanceId]
|
|
1177
1177
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// modules/provisioning/repo-private.js — the queue half of "make this project's repository
|
|
2
|
+
// private" (task 1004193, split (b) of task 1003078; ADR 0176).
|
|
3
|
+
//
|
|
4
|
+
// A project stood up with a public repository reads its code with the platform's shared
|
|
5
|
+
// key, which cannot read a private one — so the flip must wait until the project has its
|
|
6
|
+
// own read-only deploy key. That wiring needs the box and the owner's stored credential, so
|
|
7
|
+
// the web tier only enqueues; scripts/gds/provision-repo-private.js is the runner leg that
|
|
8
|
+
// wires the key and THEN flips.
|
|
9
|
+
//
|
|
10
|
+
// Its own file because provisioning.js sits at the size ratchet. It reaches the queue
|
|
11
|
+
// through the provisioning module OBJECT, so a test can stub enqueueIntent.
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const provisioning = require('./provisioning');
|
|
15
|
+
|
|
16
|
+
// Queue a flip-to-private. Arbitration is the restart shape: an open repo-private in
|
|
17
|
+
// either state makes a repeat idempotent (the flip has no payload, so there is nothing a
|
|
18
|
+
// second request could change); any OTHER open intent refuses it with
|
|
19
|
+
// { conflict: 'open_intent' } rather than racing a standup, teardown or settings push over
|
|
20
|
+
// the same checkout. The partial unique index ux_provisioning_intents_open stays the
|
|
21
|
+
// arbiter if two requests interleave.
|
|
22
|
+
async function enqueueRepoPrivate(db, instanceId, requestedBy) {
|
|
23
|
+
const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'repo-private', requestedBy);
|
|
24
|
+
if (created) return { intent, created: true };
|
|
25
|
+
if (intent.action === 'repo-private') return { intent, created: false };
|
|
26
|
+
return { intent, created: false, conflict: 'open_intent' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { enqueueRepoPrivate };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.977",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.977",
|
|
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.19.
|
|
3
|
+
"version": "1.19.977",
|
|
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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// scripts/gds/audit-deps.js — likely-missing-dependency detector (task 349).
|
|
2
|
+
// scripts/gds/audit-deps.js — likely-missing-dependency detector CLI (task 349).
|
|
3
3
|
//
|
|
4
4
|
// WHY: an under-specified dependencies[] lets work start before the task it
|
|
5
5
|
// implicitly builds on has shipped, racing already-built work (the original
|
|
@@ -11,16 +11,21 @@
|
|
|
11
11
|
// (src/bongos/db.js, migrations/, CLAUDE.md, …), NOT a build-order dependency.
|
|
12
12
|
// Backfilling a wrong edge GATES the dependent (POST /claims → 409
|
|
13
13
|
// DEPS_NOT_SHIPPED) until the named blocker ships. So: act only on `high`
|
|
14
|
-
// confidence, by hand, after reading both task descriptions.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// confidence, by hand, after reading both task descriptions.
|
|
15
|
+
//
|
|
16
|
+
// PURE CORE MOVED to modules/lifecycle/missing-dep-classify.js (task 1002827),
|
|
17
|
+
// so dependency-advisory.js's live POST /tasks path can reuse the ONE canonical
|
|
18
|
+
// `classify`/`findMissingDeps` — the same one tests/audit_deps.mjs covers —
|
|
19
|
+
// without pulling this file's CLI-only machinery (cliClient's session-token
|
|
20
|
+
// I/O, resolveDefaultVersion) into the running server. This file is now the
|
|
21
|
+
// CLI wrapper only: argv parsing, the corpus fetch, and --stranded's dead-edge
|
|
22
|
+
// report. Every name below is RE-EXPORTED unchanged so no caller of this file
|
|
23
|
+
// (including tests/audit_deps.mjs) needs to know it moved.
|
|
18
24
|
//
|
|
19
25
|
// node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
|
|
20
26
|
// node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153),
|
|
21
27
|
// # plus prose-declared gates naming a dead task (task 1003857, advisory)
|
|
22
28
|
|
|
23
|
-
const { touchesOverlap } = require('../../src/bongos/path-match.js');
|
|
24
29
|
const { cliClient } = require('./cli-lib');
|
|
25
30
|
const { resolveDefaultVersion } = require('./version-select.js');
|
|
26
31
|
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
@@ -28,61 +33,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
|
|
|
28
33
|
const { findStrandedTasks, findProseGatedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
29
34
|
// task 1003752 — the route's page bound + the first-page offset rule, declared once.
|
|
30
35
|
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
34
|
-
const isUnshippedBlocker = (s) => s !== 'shipped' && s !== 'abandoned';
|
|
35
|
-
|
|
36
|
-
// Shared collaboration surfaces: an overlap here is a parallel-safety (touches[])
|
|
37
|
-
// signal, not a dependency. A candidate whose ONLY overlap is one of these (or a
|
|
38
|
-
// bare directory) is demoted to `low`. Deliberately broad; tune as the repo moves.
|
|
39
|
-
const HOTSPOTS = [
|
|
40
|
-
'src/bongos/db.js', 'src/bongos/routes.js', 'server.js', 'migrations/', 'CLAUDE.md',
|
|
41
|
-
'modules/hall-ui/', 'tests/', 'scripts/gds/', 'src/bongos/', 'src/bongos/routes/',
|
|
42
|
-
'scripts/gds/ship.js', 'scripts/gds/api.js', 'scripts/gds/cli-lib.js',
|
|
43
|
-
'modules/grading/grader.js', 'docs/', 'docs/adr/', 'docs/adr/README.md', 'docs/recipes/',
|
|
44
|
-
'docs/architecture.md', 'docs/file-map.md', '.claude/skills/', '.claude/hooks/',
|
|
45
|
-
'infra/', 'public/assets/tiles/',
|
|
46
|
-
];
|
|
47
|
-
const isHotspot = (p) => HOTSPOTS.includes(p);
|
|
48
|
-
|
|
49
|
-
// Confidence heuristic (advisory; a human decides):
|
|
50
|
-
// high — an exact SAME non-hotspot, non-bare-dir FILE both tasks name.
|
|
51
|
-
// medium — a narrow (non-hotspot, non-bare-dir) overlap exists.
|
|
52
|
-
// low — overlap only on a shared hotspot / bare directory.
|
|
53
|
-
function classify(tTouches, oTouches) {
|
|
54
|
-
const pairs = [];
|
|
55
|
-
for (const a of tTouches) for (const b of oTouches) {
|
|
56
|
-
if (touchesOverlap([a], [b])) pairs.push({ a, b, exact: a === b });
|
|
57
|
-
}
|
|
58
|
-
if (!pairs.length) return null;
|
|
59
|
-
const narrow = pairs.filter((p) => !isHotspot(p.a) && !isHotspot(p.b) && !p.a.endsWith('/') && !p.b.endsWith('/'));
|
|
60
|
-
const confidence = narrow.some((p) => p.exact) ? 'high' : narrow.length ? 'medium' : 'low';
|
|
61
|
-
const paths = [...new Set(pairs.map((p) => (p.exact ? p.a : `${p.a} ~ ${p.b}`)))];
|
|
62
|
-
return { confidence, paths };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Pure core — unit-tested with fixtures (no API). `tasks` is an array of
|
|
66
|
-
// { id, status, touches:[], dependencies:[ids] }. Returns candidate edges
|
|
67
|
-
// { task_id, dep_id, dep_status, confidence, paths }.
|
|
68
|
-
function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker } = {}) {
|
|
69
|
-
const out = [];
|
|
70
|
-
for (const t of tasks) {
|
|
71
|
-
if (!open.has(t.status)) continue;
|
|
72
|
-
if (!Array.isArray(t.touches) || !t.touches.length) continue;
|
|
73
|
-
const declared = new Set((t.dependencies || []).map(String));
|
|
74
|
-
for (const o of tasks) {
|
|
75
|
-
if (String(o.id) === String(t.id)) continue;
|
|
76
|
-
if (!isBlocker(o.status)) continue;
|
|
77
|
-
if (!Array.isArray(o.touches) || !o.touches.length) continue;
|
|
78
|
-
if (declared.has(String(o.id))) continue;
|
|
79
|
-
if (!touchesOverlap(t.touches, o.touches)) continue;
|
|
80
|
-
const c = classify(t.touches, o.touches);
|
|
81
|
-
if (c) out.push({ task_id: t.id, dep_id: o.id, dep_status: o.status, ...c });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
36
|
+
const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, findMissingDeps } =
|
|
37
|
+
require('../../modules/lifecycle/missing-dep-classify.js');
|
|
86
38
|
|
|
87
39
|
// Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
|
|
88
40
|
// was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-repo-private.js — "make this project's repository private": the runner leg
|
|
3
|
+
// (task 1004193, split (b) of task 1003078; ADR 0176).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A project stood up while its repository was public reads its code with the
|
|
6
|
+
// platform's shared deploy key, which cannot read a private repository. Flip such a
|
|
7
|
+
// repository private and the project's next deploy pull fails (walk finding N4). Standups
|
|
8
|
+
// since ADR 0176 give a private repository the project's own read-only deploy key; this
|
|
9
|
+
// leg gives an EXISTING project the same wiring, and only then flips.
|
|
10
|
+
//
|
|
11
|
+
// ORDER IS THE SAFETY PROPERTY. Wire the key FIRST, flip SECOND. A deploy key authenticates
|
|
12
|
+
// against a still-public repository, so wiring first leaves no window in which the repo is
|
|
13
|
+
// private and the checkout still reads with the shared key. ensurePrivateRepoAccess would
|
|
14
|
+
// see the repo as public and do nothing, so it is told where the repo is going
|
|
15
|
+
// (`intendedPrivate`, task 1004167). If the flip then fails, the repository is public with
|
|
16
|
+
// a working key — harmless, and the retry re-runs both steps idempotently.
|
|
17
|
+
//
|
|
18
|
+
// Every expected failure returns { ok:false } rather than throwing: a throw makes the
|
|
19
|
+
// runner mark the whole project 'error', and a project that could not be made private is
|
|
20
|
+
// still running fine. The error text is the owner's sentence — it rides error_note onto
|
|
21
|
+
// the project card once the retries are spent.
|
|
22
|
+
//
|
|
23
|
+
// Its own file because provision.js sits at the size ratchet (the provision-disconnect.js
|
|
24
|
+
// precedent). The web tier only enqueues (modules/provisioning/repo-private.js).
|
|
25
|
+
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { REPO_ROOT } = require('./provision-config.js');
|
|
28
|
+
const { parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
|
|
29
|
+
const { ensurePrivateRepoAccess } = require('./provision-repo.js');
|
|
30
|
+
|
|
31
|
+
async function repoPrivateInstance(inst, deps) {
|
|
32
|
+
const { db, provisioning, log, apply } = deps;
|
|
33
|
+
log(`repo-private ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
34
|
+
|
|
35
|
+
// Only a standalone project has its own checkout to wire; any other shape reads from a
|
|
36
|
+
// checkout this project does not own, so a key minted here would change nothing.
|
|
37
|
+
if (inst.hosting_shape !== 'standalone') {
|
|
38
|
+
return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
|
|
39
|
+
}
|
|
40
|
+
if (inst.status !== 'active') {
|
|
41
|
+
return { ok: false, error: `This project is ${inst.status}, not running, so its repository was not changed. Try again once it is live.` };
|
|
42
|
+
}
|
|
43
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
44
|
+
if (!repo) return { ok: false, error: 'This project has no GitHub repository on record, so there is nothing to make private.' };
|
|
45
|
+
|
|
46
|
+
let wired;
|
|
47
|
+
try {
|
|
48
|
+
wired = await ensurePrivateRepoAccess(inst, { ...deps, intendedPrivate: true });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
51
|
+
}
|
|
52
|
+
if (!apply) {
|
|
53
|
+
log(` [repo-private] would then make ${repo.owner}/${repo.name} private with the owner's credential — only after the deploy key above is wired (dry-run — nothing ran)`);
|
|
54
|
+
return { ok: true, planned: true };
|
|
55
|
+
}
|
|
56
|
+
if (!wired || wired.private !== true) {
|
|
57
|
+
// Defensive: with intendedPrivate set, a standalone row with a repo either wires or
|
|
58
|
+
// throws. Anything else means the checkout is unwired, and flipping would break it.
|
|
59
|
+
return { ok: false, error: `The deploy key for ${repo.owner}/${repo.name} could not be set up, so the repository was left public.` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const token = await resolveOwnerGithubToken(inst, deps);
|
|
63
|
+
if (!token) {
|
|
64
|
+
return { ok: false, error: `${repo.owner}/${repo.name} was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.` };
|
|
65
|
+
}
|
|
66
|
+
const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
|
|
67
|
+
try {
|
|
68
|
+
log(` [repo-private] making ${repo.owner}/${repo.name} private`);
|
|
69
|
+
await ghRepos.makeRepoPrivate(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
const why = e && e.code === 'not_repo_admin' ? 'GitHub refused — the connected account cannot change this repository’s settings'
|
|
72
|
+
: e && e.code === 'repo_not_found' ? 'GitHub could not find the repository with the connected account'
|
|
73
|
+
: `GitHub could not make the change (${(e && e.message) || e})`;
|
|
74
|
+
return { ok: false, error: `${repo.owner}/${repo.name} is still public: ${why}. Its deploy key is already in place, so trying again is safe.` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await provisioning.recordEvent(db, {
|
|
78
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
|
|
79
|
+
detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
|
|
80
|
+
actor: 'runner',
|
|
81
|
+
}).catch(() => {});
|
|
82
|
+
log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
|
|
83
|
+
return { ok: true, minted: !!wired.minted };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { repoPrivateInstance };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
//
|
|
4
3
|
// scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
|
|
5
4
|
// (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
|
|
6
5
|
//
|
|
@@ -1294,6 +1293,7 @@ async function cmdRunIntents(deps) {
|
|
|
1294
1293
|
else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
|
|
1295
1294
|
else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
|
|
1296
1295
|
else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
|
|
1296
|
+
else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
|
|
1297
1297
|
else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
|
|
1298
1298
|
if (result && result.ok === false) {
|
|
1299
1299
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
@@ -129,17 +129,14 @@ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
// route refuses the request; this helper makes the refusal structural
|
|
137
|
-
// (task 1003045). Returns the same shape as getUserRepo.
|
|
138
|
-
async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
132
|
+
// The visibility PATCH both directions share. Deliberately NOT exported: callers get
|
|
133
|
+
// one named function per direction, so the name stays the contract and no caller can
|
|
134
|
+
// pass a direction it did not mean (the createUserRepo private:false precedent).
|
|
135
|
+
async function setRepoPrivate(token, { owner, name, fetchImpl = fetch }, isPrivate) {
|
|
139
136
|
const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
|
|
140
137
|
method: 'PATCH',
|
|
141
138
|
headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
|
|
142
|
-
body: JSON.stringify({ private:
|
|
139
|
+
body: JSON.stringify({ private: isPrivate }),
|
|
143
140
|
});
|
|
144
141
|
if (res.status === 401) {
|
|
145
142
|
throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
|
|
@@ -162,6 +159,22 @@ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
|
162
159
|
};
|
|
163
160
|
}
|
|
164
161
|
|
|
162
|
+
// Flip one repo PUBLIC (task 1003045). Needs no deploy-key wiring — the shared key
|
|
163
|
+
// reads a public repo — so the core route may call it directly. Returns the same
|
|
164
|
+
// shape as getUserRepo.
|
|
165
|
+
async function makeRepoPublic(token, ref = {}) {
|
|
166
|
+
return setRepoPrivate(token, ref, false);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Flip one repo PRIVATE. Only the control-plane runner may call this, and only AFTER
|
|
170
|
+
// ensurePrivateRepoAccess has wired the project's own read-only deploy key: the
|
|
171
|
+
// shared key cannot read a private repo, so flipping first breaks the next deploy
|
|
172
|
+
// pull (walk finding N4, ADR 0176). scripts/gds/provision-repo-private.js is that
|
|
173
|
+
// caller (task 1004193); no web route calls it. Returns the same shape as getUserRepo.
|
|
174
|
+
async function makeRepoPrivate(token, ref = {}) {
|
|
175
|
+
return setRepoPrivate(token, ref, true);
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
// ---- deploy keys (task 1002735 / ADR 0176) ----
|
|
166
179
|
// The per-repo credential a PRIVATE repo's recurring deploy pull authenticates
|
|
167
180
|
// with: provisioning mints an ed25519 keypair, registers its public half here with
|
|
@@ -237,7 +250,7 @@ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch }
|
|
|
237
250
|
}
|
|
238
251
|
|
|
239
252
|
module.exports = {
|
|
240
|
-
listUserRepos, createUserRepo, getUserRepo, makeRepoPublic,
|
|
253
|
+
listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, makeRepoPrivate,
|
|
241
254
|
listDeployKeys, addDeployKey, deleteDeployKey,
|
|
242
255
|
GithubApiError, MAX_PAGES,
|
|
243
256
|
};
|
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.977'; // 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,231 @@
|
|
|
1
|
+
// tests/dependency_advisory.mjs — the missing-dependency advisory on POST
|
|
2
|
+
// /tasks (task 1002827, goal 1000089).
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/audit-deps.js has shipped a working likely-missing-dependency
|
|
5
|
+
// detector since task 1000349 (tests/audit_deps.mjs, tests/missing_dep_classify.mjs)
|
|
6
|
+
// and nothing called it. This wires the SAME detector's pure core
|
|
7
|
+
// (modules/lifecycle/missing-dep-classify.js) live, mirroring goal-advisory.js's
|
|
8
|
+
// two properties:
|
|
9
|
+
// 1. DETECTION reuses the one canonical overlap matcher — no second copy.
|
|
10
|
+
// 2. The advisory can NEVER fail the create — asserted here by driving it
|
|
11
|
+
// with a db that throws.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/dependency_advisory.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { makeRunner, taskRoutesSource } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { adviseDependencies, buildDependencyAdvisory, CANDIDATE_LIMIT } = require('../modules/lifecycle/dependency-advisory.js');
|
|
25
|
+
|
|
26
|
+
const tasksSrc = taskRoutesSource();
|
|
27
|
+
const { test, summary } = makeRunner();
|
|
28
|
+
|
|
29
|
+
// ---- adviseDependencies: the pure core ----
|
|
30
|
+
|
|
31
|
+
await test('no touches[] on the new task — silent, no candidates read', () => {
|
|
32
|
+
assert.equal(adviseDependencies({ task: { id: 1, touches: [] }, candidates: [{ id: 2, status: 'ready', touches: ['a.js'] }] }), null);
|
|
33
|
+
assert.equal(adviseDependencies({ task: { id: 1 }, candidates: [] }), null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await test('an exact narrow-file overlap with an open candidate is flagged high', () => {
|
|
37
|
+
const a = adviseDependencies({
|
|
38
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
39
|
+
candidates: [{ id: 2, title: 'Sibling work', status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
40
|
+
});
|
|
41
|
+
assert.equal(a.reason, 'possible_missing_dependency');
|
|
42
|
+
assert.equal(a.candidates.length, 1);
|
|
43
|
+
assert.equal(a.candidates[0].dep_id, 2);
|
|
44
|
+
assert.equal(a.candidates[0].confidence, 'high');
|
|
45
|
+
assert.equal(a.candidates[0].dep_title, 'Sibling work');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await test('an already-declared dependency is never flagged', () => {
|
|
49
|
+
const a = adviseDependencies({
|
|
50
|
+
task: { id: 1, touches: ['modules/foo/bar.js'], dependencies: ['2'] },
|
|
51
|
+
candidates: [{ id: 2, status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
52
|
+
});
|
|
53
|
+
assert.equal(a, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('a SHIPPED candidate never gates — the dependency system already treats it as satisfied', () => {
|
|
57
|
+
const a = adviseDependencies({
|
|
58
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
59
|
+
candidates: [{ id: 2, status: 'shipped', touches: ['modules/foo/bar.js'] }],
|
|
60
|
+
});
|
|
61
|
+
assert.equal(a, null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('an ABANDONED candidate never gates', () => {
|
|
65
|
+
const a = adviseDependencies({
|
|
66
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
67
|
+
candidates: [{ id: 2, status: 'abandoned', touches: ['modules/foo/bar.js'] }],
|
|
68
|
+
});
|
|
69
|
+
assert.equal(a, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('a candidate never flags itself', () => {
|
|
73
|
+
const a = adviseDependencies({
|
|
74
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
75
|
+
candidates: [{ id: 1, status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
76
|
+
});
|
|
77
|
+
assert.equal(a, null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('a hotspot-only overlap (e.g. CLAUDE.md) is flagged low, sorted after a high candidate', () => {
|
|
81
|
+
const a = adviseDependencies({
|
|
82
|
+
task: { id: 1, touches: ['CLAUDE.md', 'modules/foo/bar.js'] },
|
|
83
|
+
candidates: [
|
|
84
|
+
{ id: 2, status: 'ready', touches: ['CLAUDE.md'] },
|
|
85
|
+
{ id: 3, status: 'ready', touches: ['modules/foo/bar.js'] },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
assert.equal(a.candidates.length, 2);
|
|
89
|
+
assert.equal(a.candidates[0].dep_id, 3, 'high confidence sorts first');
|
|
90
|
+
assert.equal(a.candidates[0].confidence, 'high');
|
|
91
|
+
assert.equal(a.candidates[1].confidence, 'low');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('candidates are capped at CANDIDATE_LIMIT', () => {
|
|
95
|
+
const candidates = Array.from({ length: CANDIDATE_LIMIT + 3 }, (_, i) => ({ id: i + 2, status: 'ready', touches: ['modules/foo/bar.js'] }));
|
|
96
|
+
const a = adviseDependencies({ task: { id: 1, touches: ['modules/foo/bar.js'] }, candidates });
|
|
97
|
+
assert.equal(a.candidates.length, CANDIDATE_LIMIT);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---- buildDependencyAdvisory: DB-read wrapper, fail-open ----
|
|
101
|
+
|
|
102
|
+
await test('buildDependencyAdvisory: no touches[] skips the DB read entirely', async () => {
|
|
103
|
+
let read = false;
|
|
104
|
+
const db = { async listOpenTasksWithTouches() { read = true; return []; } };
|
|
105
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: [] } });
|
|
106
|
+
assert.equal(a, null);
|
|
107
|
+
assert.equal(read, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('buildDependencyAdvisory: a real candidate yields a next_step naming the actual task id', async () => {
|
|
111
|
+
const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
|
|
112
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['modules/foo/bar.js'] } });
|
|
113
|
+
assert.equal(a.reason, 'possible_missing_dependency');
|
|
114
|
+
assert.match(a.next_step, /POST \/tasks\/42\/dependencies/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('NON-BLOCKING: buildDependencyAdvisory returns null when db.listOpenTasksWithTouches throws', async () => {
|
|
118
|
+
const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
|
|
119
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
120
|
+
assert.equal(a, null, 'degrades to silence — never propagates into the already-committed create');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('NON-BLOCKING: a db missing the method entirely still yields null, not a crash', async () => {
|
|
124
|
+
const a = await buildDependencyAdvisory({ db: {}, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
125
|
+
assert.equal(a, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('buildDependencyAdvisory forwards viewerId/viewerIsArchon to the query (ADR 0112 visibility)', async () => {
|
|
129
|
+
let seen = null;
|
|
130
|
+
const db = {
|
|
131
|
+
async listOpenTasksWithTouches(versionId, excludeId, opts) { seen = { versionId, excludeId, opts }; return []; },
|
|
132
|
+
};
|
|
133
|
+
await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['a.js'] }, viewerId: 7, viewerIsArchon: true });
|
|
134
|
+
assert.deepEqual(seen, { versionId: 'V', excludeId: 42, opts: { viewerId: 7, viewerIsArchon: true } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('buildDependencyAdvisory defaults viewerId/viewerIsArchon when the caller omits them', async () => {
|
|
138
|
+
let seen = null;
|
|
139
|
+
const db = { async listOpenTasksWithTouches(v, e, opts) { seen = opts; return []; } };
|
|
140
|
+
await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
141
|
+
assert.deepEqual(seen, { viewerId: null, viewerIsArchon: false });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---- ADR 0112: the query itself must carry the visibility predicate ----
|
|
145
|
+
// (SQL correctness for this file is integration-tested elsewhere in this
|
|
146
|
+
// codebase's own convention — no local Postgres here — so this is the same
|
|
147
|
+
// source-scan safety net the goal-advisory/goal-graph precedents use to
|
|
148
|
+
// catch an accidental removal of the join/predicate.)
|
|
149
|
+
|
|
150
|
+
await test('listOpenTasksWithTouches SQL joins goals + goal_members and excludes an unseen private goal', () => {
|
|
151
|
+
const src = readFileSync(new URL('../modules/lifecycle/db-tasks.js', import.meta.url), 'utf8');
|
|
152
|
+
const fn = src.slice(src.indexOf('async function listOpenTasksWithTouches'), src.indexOf('async function listOpenTasksWithTouches') + 1200);
|
|
153
|
+
assert.match(fn, /LEFT JOIN goals g ON g\.id = t\.goal_id/);
|
|
154
|
+
assert.match(fn, /LEFT JOIN goal_members gm ON gm\.goal_id = g\.id/);
|
|
155
|
+
assert.match(fn, /g\.visibility IS DISTINCT FROM 'private'/);
|
|
156
|
+
assert.match(fn, /gm\.builder_id IS NOT NULL/, 'a viewer\'s own membership admits a private goal\'s task');
|
|
157
|
+
assert.match(fn, /LIMIT \$\{DEPENDENCY_ADVISORY_CANDIDATE_LIMIT\}/, 'the candidate pool is bounded');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---- the route wiring, asserted over source (the goal_advisory.mjs precedent) ----
|
|
161
|
+
|
|
162
|
+
await test('the route delegates to buildDependencyAdvisory — no inline logic to drift — and forwards the viewer identity for ADR 0112', () => {
|
|
163
|
+
assert.match(tasksSrc, /await buildDependencyAdvisory\(\{[\s\S]{0,80}?db, versionId: body\.version_id, task,[\s\S]{0,200}?viewerId: req\.builder\.id, viewerIsArchon: req\.builder\.rank === 'archon',[\s\S]{0,20}?\}\);/);
|
|
164
|
+
const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
165
|
+
assert.ok(body201, 'found the 201 response body');
|
|
166
|
+
assert.match(body201, /dependency_advisory,/, 'included in the 201 body');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---- ROUTER-LEVEL: the non-blocking invariant asserted over real HTTP ----
|
|
170
|
+
|
|
171
|
+
function listen(app) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function post(port, path, body) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const payload = JSON.stringify(body);
|
|
180
|
+
const req = http.request(
|
|
181
|
+
{ host: '127.0.0.1', port, path, method: 'POST',
|
|
182
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
|
|
183
|
+
(res) => {
|
|
184
|
+
let data = '';
|
|
185
|
+
res.on('data', (c) => { data += c; });
|
|
186
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data || '{}') }));
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
req.on('error', reject);
|
|
190
|
+
req.end(payload);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeApp(db) {
|
|
195
|
+
const express = require('express');
|
|
196
|
+
const app = express();
|
|
197
|
+
app.use(express.json());
|
|
198
|
+
app.post('/tasks', async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const task = { id: 99, touches: req.body.touches || [], credits_reward: 60 }; // already committed
|
|
201
|
+
const dependency_advisory = await buildDependencyAdvisory({ db, versionId: req.body.version_id, task });
|
|
202
|
+
res.status(201).json({ task, criteria: [], dependency_advisory });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
res.status(500).json({ error: { code: 'create_failed' } });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return app;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await test('HTTP: a healthy db yields 201 with a populated dependency_advisory', async () => {
|
|
211
|
+
const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
|
|
212
|
+
const server = await listen(makeApp(db));
|
|
213
|
+
try {
|
|
214
|
+
const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
|
|
215
|
+
assert.equal(r.status, 201);
|
|
216
|
+
assert.equal(r.body.dependency_advisory.reason, 'possible_missing_dependency');
|
|
217
|
+
} finally { server.close(); }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('HTTP: db.listOpenTasksWithTouches THROWS → still 201, task returned, dependency_advisory null (never a 500)', async () => {
|
|
221
|
+
const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
|
|
222
|
+
const server = await listen(makeApp(db));
|
|
223
|
+
try {
|
|
224
|
+
const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
|
|
225
|
+
assert.equal(r.status, 201, 'the create must NOT become a 500 the caller retries into a duplicate');
|
|
226
|
+
assert.equal(r.body.task.id, 99, 'the created task is still returned');
|
|
227
|
+
assert.equal(r.body.dependency_advisory, null, 'the advisory degrades to silence');
|
|
228
|
+
} finally { server.close(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
summary();
|