@bongos/core 1.19.562 → 1.19.563
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 +56 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +69 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +127 -127
- package/docs/copy-registry.json +162 -162
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/lifecycle/db-versions.js +22 -0
- package/modules/lifecycle/db.js +2 -1
- package/modules/lifecycle/routes/version-route-authz.js +56 -0
- package/modules/lifecycle/routes/versions.js +22 -0
- package/modules/platform-identity/invite-suggestions.js +57 -0
- package/modules/platform-identity/routes/sso.js +8 -0
- package/modules/provisioning/routes/provisioning.js +56 -0
- package/modules/public-landing/public/assets/cosmos.css +16 -0
- package/modules/public-landing/public/projects.html +85 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/one_planning_version.mjs +161 -0
- package/tests/project_invite_suggestions.mjs +133 -0
- package/tests/project_invite_ui.mjs +171 -18
|
@@ -1573,5 +1573,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1573
1573
|
landed since 1.19.560 with no explicit bump. run 34048169727. (task 1002620)
|
|
1574
1574
|
1.19.562 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1575
1575
|
landed since 1.19.561 with no explicit bump. run 34048961786. (task 1002620)
|
|
1576
|
+
1.19.563 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1577
|
+
landed since 1.19.562 with no explicit bump. run 34049959536. (task 1002620)
|
|
1576
1578
|
---------------------------------------------------------------------------
|
|
1577
1579
|
```
|
|
@@ -676,6 +676,8 @@ function createClient(opts = {}) {
|
|
|
676
676
|
postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
|
|
677
677
|
// POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
|
|
678
678
|
postProvisioningInstancesIdGithubApp: (args) => request("POST", "/provisioning/instances/{id}/github-app", { hasBody: true }, args),
|
|
679
|
+
// GET /provisioning/instances/{id}/invite-suggestions — rank: any-builder — GET /provisioning/instances/:id/invite-suggestions
|
|
680
|
+
getProvisioningInstancesIdInviteSuggestions: (args) => request("GET", "/provisioning/instances/{id}/invite-suggestions", { hasBody: false }, args),
|
|
679
681
|
// POST /provisioning/instances/{id}/restart — rank: any-builder — POST /provisioning/instances/:id/restart
|
|
680
682
|
postProvisioningInstancesIdRestart: (args) => request("POST", "/provisioning/instances/{id}/restart", { hasBody: true }, args),
|
|
681
683
|
// GET /provisioning/instances/{id}/settings — rank: any-builder — GET /provisioning/instances/:id/settings
|
|
@@ -81,6 +81,27 @@ async function versionProgress() {
|
|
|
81
81
|
// commit together, so a bad criterion leaves nothing half-created. Criteria are
|
|
82
82
|
// idempotent on (version_id, criterion_id) so a re-run can't double-insert.
|
|
83
83
|
|
|
84
|
+
// versionsWithStatus — the versions currently in a given state, id + name only.
|
|
85
|
+
// Added for the one-planning-version rule (BV1.R04, task 1003592, goal 1000086,
|
|
86
|
+
// ADR 0250 D5): with R03 closing a building version's goal set, cutting a second
|
|
87
|
+
// planning version is the obvious way around it — file the goal on a fresh
|
|
88
|
+
// version instead. One planning version means one queue for future work and no
|
|
89
|
+
// parallel escape hatch.
|
|
90
|
+
//
|
|
91
|
+
// NOTE ON "per project": this instance's `versions` table has no project column
|
|
92
|
+
// (migration 003) — a Bongos database IS one project — so "for a project" and
|
|
93
|
+
// "in this instance" are the same question here. If a project column ever lands,
|
|
94
|
+
// this is the read that needs the extra predicate, and the route above it needs
|
|
95
|
+
// nothing.
|
|
96
|
+
async function versionsWithStatus(status, deps = {}) {
|
|
97
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
98
|
+
const { rows } = await activePool.query(
|
|
99
|
+
`SELECT id, name FROM versions WHERE status = $1 ORDER BY id`,
|
|
100
|
+
[status]
|
|
101
|
+
);
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
async function createVersion({ id, name, status = 'planning', track = 'product', scopeDocPath = null, doneWhen = null, criteria = [] }) {
|
|
85
106
|
// versions.track is NOT NULL with no DB default (migration 003) — omitting it
|
|
86
107
|
// here made every create 500. An instance's first version is its product
|
|
@@ -115,4 +136,5 @@ module.exports = {
|
|
|
115
136
|
getVersion,
|
|
116
137
|
listVersions,
|
|
117
138
|
versionProgress,
|
|
139
|
+
versionsWithStatus,
|
|
118
140
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -63,7 +63,7 @@ const {
|
|
|
63
63
|
singleRungPromotionError,
|
|
64
64
|
xenosClaimAllowed,
|
|
65
65
|
} = require('./db-rank-authz.js');
|
|
66
|
-
const { createVersion, getVersion, listVersions, versionProgress } = require('./db-versions.js');
|
|
66
|
+
const { createVersion, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
|
|
67
67
|
const {
|
|
68
68
|
acceptMembershipRequestAndAddMember,
|
|
69
69
|
achieveGoalIfComplete,
|
|
@@ -213,6 +213,7 @@ module.exports = {
|
|
|
213
213
|
listVersions,
|
|
214
214
|
getVersion,
|
|
215
215
|
versionProgress,
|
|
216
|
+
versionsWithStatus,
|
|
216
217
|
createVersion,
|
|
217
218
|
createGoal,
|
|
218
219
|
getGoal,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/version-route-authz.js — the PURE predicates behind
|
|
4
|
+
// the version routes. Sibling of goal-route-authz.js, same contract: no express,
|
|
5
|
+
// no DB, no pool — the route gathers the facts, this decides, the route renders
|
|
6
|
+
// the decision verbatim.
|
|
7
|
+
//
|
|
8
|
+
// Carved on the same argument that file was: a decision expressed as a pure
|
|
9
|
+
// function has its whole truth table exercised by a DB-free unit test, including
|
|
10
|
+
// the shapes a live-Postgres test would never bother to set up (an unrecognised
|
|
11
|
+
// status, a null, a value that merely coerces to true). Inlining the same rule in
|
|
12
|
+
// the handler leaves it testable only by string-matching the source, which passes
|
|
13
|
+
// just as happily on a wrong branch or dead code.
|
|
14
|
+
|
|
15
|
+
// authorizeVersionCreate — may a version be created in this state?
|
|
16
|
+
// (BV1.R04, task 1003592, goal 1000086, ADR 0250 D5.)
|
|
17
|
+
//
|
|
18
|
+
// THE RULE: exactly one version sits in `planning` at a time. This is the second
|
|
19
|
+
// half of R03 (`authorizeGoalCreate`). Once a building version's goal set is
|
|
20
|
+
// closed, the first thing a refused caller reaches for is a NEW version to put
|
|
21
|
+
// the goal on — and the scope gate becomes decorative. One planning version means
|
|
22
|
+
// one queue for future work and no parallel escape hatch.
|
|
23
|
+
//
|
|
24
|
+
// Guards the PLANNING slot only: creating a `shipped` or `frozen` version is
|
|
25
|
+
// archival bookkeeping and no business of this rule. Refusing a second BUILDING
|
|
26
|
+
// version is D5's other half, task 1003606 (R19).
|
|
27
|
+
//
|
|
28
|
+
// `planning` is the versions already in that state (id + name), which the route
|
|
29
|
+
// reads. Passing the rows rather than a boolean is what lets the refusal NAME the
|
|
30
|
+
// version already holding the queue: "one version plans at a time" without saying
|
|
31
|
+
// which one sends the caller off to look it up, and a caller who looks it up is
|
|
32
|
+
// one step from cutting a version anyway.
|
|
33
|
+
//
|
|
34
|
+
// ON "per project": this instance's `versions` table has no project column
|
|
35
|
+
// (migration 003) — a Bongos database IS one project — so "for a project" and
|
|
36
|
+
// "in this instance" are the same question here. If a project column ever lands,
|
|
37
|
+
// the READ the route performs grows a predicate; this function does not change.
|
|
38
|
+
//
|
|
39
|
+
// Returns { ok: true } or { ok: false, status, body } — the exact HTTP status and
|
|
40
|
+
// JSON the route sends.
|
|
41
|
+
function authorizeVersionCreate({ status, planning = [] }) {
|
|
42
|
+
if (status !== 'planning') return { ok: true };
|
|
43
|
+
const existing = Array.isArray(planning) ? planning.filter(Boolean) : [];
|
|
44
|
+
if (existing.length === 0) return { ok: true };
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
status: 409,
|
|
48
|
+
body: {
|
|
49
|
+
error: 'planning_version_exists',
|
|
50
|
+
message: `Version '${existing[0].id}' is already in planning — scope this work onto it, or ship it first. One version plans at a time.`,
|
|
51
|
+
planning: existing,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { authorizeVersionCreate };
|
|
@@ -23,6 +23,7 @@ const auth = api;
|
|
|
23
23
|
const db = require('../db');
|
|
24
24
|
const versionClose = require('../version-close');
|
|
25
25
|
const doneWhen = require('../done-when');
|
|
26
|
+
const { authorizeVersionCreate } = require('./version-route-authz.js');
|
|
26
27
|
const { asyncHandler, validateOrRespond } = api;
|
|
27
28
|
|
|
28
29
|
// versions.status CHECK (migration 003). A new version starts 'planning' or
|
|
@@ -114,6 +115,27 @@ module.exports = function buildVersionsRouter() {
|
|
|
114
115
|
return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// BV1.R04 (task 1003592, goal 1000086, ADR 0250 D5): exactly one version may
|
|
119
|
+
// sit in `planning`. The decision is the pure authorizeVersionCreate, the
|
|
120
|
+
// sibling of R03's authorizeGoalCreate — the route gathers the facts and
|
|
121
|
+
// renders the verdict, it does not carry the rule.
|
|
122
|
+
//
|
|
123
|
+
// RACE, ACCEPTED AND UNMITIGATED: this is check-then-insert, and no database
|
|
124
|
+
// constraint backs it — two concurrent POST /versions with status=planning
|
|
125
|
+
// could both pass. Unlike the id collision above, which Postgres catches on
|
|
126
|
+
// the primary key, nothing catches this one. The real fix is a partial unique
|
|
127
|
+
// index (`ON versions (status) WHERE status = 'planning'`), which is a
|
|
128
|
+
// migration this task did not reserve a number for; task 1003606 (R19) adds
|
|
129
|
+
// the second-BUILDING-version rule of the same shape and is the natural place
|
|
130
|
+
// to add the index for both. Until then the exposure is bounded by the route
|
|
131
|
+
// being Archon-gated and used a few times a year — small, not zero.
|
|
132
|
+
const versionDecision = authorizeVersionCreate({
|
|
133
|
+
status,
|
|
134
|
+
planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
|
|
135
|
+
});
|
|
136
|
+
if (!versionDecision.ok) {
|
|
137
|
+
return res.status(versionDecision.status).json(versionDecision.body);
|
|
138
|
+
}
|
|
117
139
|
// Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
|
|
118
140
|
const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
|
|
119
141
|
const criteria = [];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// modules/platform-identity/invite-suggestions.js — WHO is worth inviting to a
|
|
2
|
+
// project (BV1.R28, task 1002347, ADR 0251): the names behind the creation
|
|
3
|
+
// wizard's `invites.seeking`.
|
|
4
|
+
//
|
|
5
|
+
// The wizard's rule engine (modules/provisioning/recommendations.js) states WHAT
|
|
6
|
+
// to look for and cannot run the lookup — it is a pure file, and the directory
|
|
7
|
+
// lives in a module `provisioning` may not import (ADR 0239 §6, ADR 0083). This
|
|
8
|
+
// is the lookup, reached through the `platform-identity.hub` port.
|
|
9
|
+
//
|
|
10
|
+
// Its own file rather than another function in platform-identity.js: that file
|
|
11
|
+
// sits at the size ceiling, and this is a cohesive unit with one caller.
|
|
12
|
+
|
|
13
|
+
const { listScoutingRollup } = require('./platform-identity');
|
|
14
|
+
|
|
15
|
+
// Never suggest more than this many people however large a seek asks for — a
|
|
16
|
+
// suggestion is a short list, and the ceiling is what keeps the route from being
|
|
17
|
+
// turned into a directory dump by a caller that controls the limit.
|
|
18
|
+
const MAX_SUGGESTIONS = 25;
|
|
19
|
+
|
|
20
|
+
// listInviteSuggestions({ limit, exclude, pool }) → [{ github_login, display_name,
|
|
21
|
+
// avatar_url, disciplines, project_count, total_tasks_shipped }]
|
|
22
|
+
//
|
|
23
|
+
// IT READS listScoutingRollup AND NOT THE LEADERBOARD, WHICH IS THE WHOLE POINT.
|
|
24
|
+
// A suggestion of who to invite IS the recruiting surface, so it owes the
|
|
25
|
+
// recruiting opt-out — the axis `scoutingListableSql` carries and the economy
|
|
26
|
+
// leaderboard deliberately does not (community spec D3). Sourcing this from the
|
|
27
|
+
// public leaderboard would be cheaper, ungated, and would hand an owner the name
|
|
28
|
+
// of every builder who asked not to be recruited.
|
|
29
|
+
//
|
|
30
|
+
// `github_id` DOES NOT CROSS THE PORT. Callers get the login (what the invite
|
|
31
|
+
// route actually takes), the display name and the avatar — the same whitelist the
|
|
32
|
+
// invite widget already renders. The id identifies someone who has consented to
|
|
33
|
+
// nothing here, and the surface has a test pinning that it never appears.
|
|
34
|
+
//
|
|
35
|
+
// `exclude` is matched INSIDE, against the ids this function can see, so a caller
|
|
36
|
+
// can drop the owner from their own suggestion list without ever holding one.
|
|
37
|
+
async function listInviteSuggestions({ limit = 5, exclude = [], pool } = {}) {
|
|
38
|
+
const want = Math.max(0, Math.min(MAX_SUGGESTIONS, Number(limit) || 0));
|
|
39
|
+
if (want === 0) return [];
|
|
40
|
+
const skip = new Set((Array.isArray(exclude) ? exclude : [exclude]).map((v) => String(v ?? '')));
|
|
41
|
+
// Over-read by the exclusion count so filtering cannot return short of `want`
|
|
42
|
+
// when the owner is themselves near the top of their own directory.
|
|
43
|
+
const rows = await listScoutingRollup({ limit: want + skip.size, pool });
|
|
44
|
+
return rows
|
|
45
|
+
.filter((r) => !skip.has(String(r.github_id)) && r.github_login)
|
|
46
|
+
.slice(0, want)
|
|
47
|
+
.map((r) => ({
|
|
48
|
+
github_login: r.github_login,
|
|
49
|
+
display_name: r.display_name,
|
|
50
|
+
avatar_url: r.avatar_url,
|
|
51
|
+
disciplines: Array.isArray(r.disciplines) ? r.disciplines : [],
|
|
52
|
+
project_count: r.project_count,
|
|
53
|
+
total_tasks_shipped: r.total_tasks_shipped,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { listInviteSuggestions, MAX_SUGGESTIONS };
|
|
@@ -20,6 +20,7 @@ const pi = require('../platform-identity');
|
|
|
20
20
|
const projectCatalog = require('../project-catalog');
|
|
21
21
|
const scoutingAuthz = require('../scouting-authz');
|
|
22
22
|
const canonicalProfile = require('../canonical-profile');
|
|
23
|
+
const inviteSuggestions = require('../invite-suggestions');
|
|
23
24
|
const hubKeys = require('../hub-keys');
|
|
24
25
|
const hubDevice = require('../hub-device');
|
|
25
26
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -122,6 +123,13 @@ function registerSeams() {
|
|
|
122
123
|
// The ownership fact lives in this module, so the answer does too; core
|
|
123
124
|
// asks through the port and treats an absent one as a denial.
|
|
124
125
|
isProjectOwner: (githubId) => scoutingAuthz.hasOwnerMembership(githubId, { pool }),
|
|
126
|
+
// task 1002347 (BV1.R28, ADR 0251): the names behind `invites.seeking` —
|
|
127
|
+
// the creation wizard's "who is worth inviting" list. Consent-filtered by
|
|
128
|
+
// construction (it reads the recruiting directory, never the leaderboard)
|
|
129
|
+
// and projected without `github_id`. Provisioning asks through the port
|
|
130
|
+
// because the directory lives here; an absent provider means an instance
|
|
131
|
+
// with no hub to ask, which is "no suggestions", never an error.
|
|
132
|
+
listInviteSuggestions: (opts) => inviteSuggestions.listInviteSuggestions({ ...opts, pool }),
|
|
125
133
|
// task 1002804 (P08, ADR 0214 D3): the account's PUBLIC handle — the
|
|
126
134
|
// canonical profile address — so the numeric profile route can name a
|
|
127
135
|
// builder by the rename-proof key instead of a mutable github_login.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// (also SELF-HEALS its catalog projection,
|
|
18
18
|
// task 1003581 — see the handler)
|
|
19
19
|
// POST /provisioning/instances/:id/teardown — request teardown of one's own
|
|
20
|
+
// GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
|
|
20
21
|
// Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
|
|
21
22
|
// GET /provisioning/fleet — the full instance roster
|
|
22
23
|
// GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
|
|
@@ -771,6 +772,61 @@ module.exports = function provisioningRoutes() {
|
|
|
771
772
|
}
|
|
772
773
|
});
|
|
773
774
|
|
|
775
|
+
// ── Who is worth inviting (BV1.R28, task 1002347, goal 1000046 C6) ────────
|
|
776
|
+
// GET /provisioning/instances/:id/invite-suggestions — turn the engine's
|
|
777
|
+
// `invites.seeking` into NAMES. ADR 0239 §6 left `invites.suggested` empty on
|
|
778
|
+
// purpose: ranking builders is a directory read the pure engine may not run.
|
|
779
|
+
// This is the resolution, and ADR 0251 is why it looks like this.
|
|
780
|
+
//
|
|
781
|
+
// rank: any authenticated builder, own resource — `instanceAccessDenied`, the
|
|
782
|
+
// same gate and the same 404 as the manage read above. That is deliberate and
|
|
783
|
+
// not a new authorization idea: REACHING this route IS the ownership answer
|
|
784
|
+
// (ADR 0249 §4), so the surface never re-derives "is this theirs" and can never
|
|
785
|
+
// become a weaker second copy of a server rule (ADR 0016). It also 404s rather
|
|
786
|
+
// than 403s, so it leaks no more instance existence than the read beside it.
|
|
787
|
+
//
|
|
788
|
+
// `seeking: null` COSTS NOTHING AND ASKS NOBODY. A solo owner is suggested
|
|
789
|
+
// nobody on purpose (ADR 0239 §6), which is a different statement from an empty
|
|
790
|
+
// list nobody filled — so that answer returns its own reason and never touches
|
|
791
|
+
// the directory at all.
|
|
792
|
+
router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, async (req, res) => {
|
|
793
|
+
const id = parseId(req, res);
|
|
794
|
+
if (!id) return;
|
|
795
|
+
try {
|
|
796
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
797
|
+
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
798
|
+
|
|
799
|
+
const detail = provisioning.projectDetail(inst) || {};
|
|
800
|
+
const { invites } = recommendationsForDetails({ type: inst.type, ...detail });
|
|
801
|
+
|
|
802
|
+
// Nobody to look for — the engine's own answer, returned with its reason.
|
|
803
|
+
if (!invites.seeking) {
|
|
804
|
+
return res.json({ suggested: [], seeking: null, reason: invites.reason });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// resolveOptional, not resolve, and NOT declared in `consumes` — the
|
|
808
|
+
// catalog-bridge precedent: platform-identity is hub-only (`default:
|
|
809
|
+
// false`), so an instance running provisioning without it has no directory
|
|
810
|
+
// to ask and the honest answer is "no suggestions", never a 500.
|
|
811
|
+
const hub = api.resolveOptional('platform-identity.hub');
|
|
812
|
+
if (!hub || typeof hub.listInviteSuggestions !== 'function') {
|
|
813
|
+
return res.json({ suggested: [], seeking: invites.seeking, reason: invites.reason, unavailable: 'no_directory' });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The owner is dropped from their own list. They are frequently the top
|
|
817
|
+
// builder on their own hub, and "invite yourself" is noise on the one
|
|
818
|
+
// screen whose whole job is naming other people.
|
|
819
|
+
const suggested = await hub.listInviteSuggestions({
|
|
820
|
+
limit: invites.seeking.limit,
|
|
821
|
+
exclude: [req.builder.github_id].filter(Boolean),
|
|
822
|
+
});
|
|
823
|
+
res.json({ suggested, seeking: invites.seeking, reason: invites.reason });
|
|
824
|
+
} catch (err) {
|
|
825
|
+
log.error('[provisioning] GET /provisioning/instances/:id/invite-suggestions', err);
|
|
826
|
+
res.fail('invite_suggestions_failed', { status: 500, message: 'internal error' });
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
774
830
|
// ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
|
|
775
831
|
// The foundation the manage-page wave hangs off: effective settings are the
|
|
776
832
|
// DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
|
|
@@ -421,6 +421,22 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
|
|
|
421
421
|
.inviteList>li::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:7px;border-radius:50%;border:1px solid var(--accent);background:var(--bg);}
|
|
422
422
|
.inviteList b{grid-column:2;font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
|
|
423
423
|
.inviteList small{grid-column:2;font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
|
|
424
|
+
/* who is worth inviting (task 1002347, BV1.R28) — the recommendation's own
|
|
425
|
+
sentence, in the .modWhy voice: the panel explaining itself, never a thing to
|
|
426
|
+
act on, so it never competes with the names under it. */
|
|
427
|
+
.inviteWhy{display:none;}
|
|
428
|
+
.inviteWhy.on{display:block;margin:12px 0 0;max-width:62ch;font-size:13.5px;font-weight:400;color:var(--ink-soft);line-height:1.5;}
|
|
429
|
+
/* the suggested names. The SAME hairline ledger as .inviteList — they are two
|
|
430
|
+
halves of one answer and stack directly on each other — with the action on the
|
|
431
|
+
end of the row instead of a dot on the front of it. */
|
|
432
|
+
.suggList{list-style:none;display:grid;margin:10px 0 0;padding:0;}
|
|
433
|
+
.suggList:empty{display:none;}
|
|
434
|
+
.suggList>li{display:flex;align-items:center;gap:12px;padding:10px 0 11px;border-top:1px solid var(--rule-soft);}
|
|
435
|
+
.suggList>li:first-child{border-top:0;}
|
|
436
|
+
.suggWho{display:grid;gap:2px;flex:1 1 auto;min-width:0;}
|
|
437
|
+
.suggWho b,.suggWho{font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
|
|
438
|
+
.suggWho small{font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
|
|
439
|
+
.suggList .btn{flex:0 0 auto;}
|
|
424
440
|
.attachNote code{font-family:var(--font-mono);}
|
|
425
441
|
.projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
|
|
426
442
|
.projMsg.ok{color:var(--ok);}
|
|
@@ -865,6 +865,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
865
865
|
<input type="text" id="mInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
|
|
866
866
|
<button type="button" class="btn small" data-invite-send="mInvite">Invite</button>
|
|
867
867
|
</div>
|
|
868
|
+
<div class="inviteWhy" id="mInviteWhy"></div>
|
|
869
|
+
<ul class="suggList" id="mInviteSugg"></ul>
|
|
868
870
|
<ul class="inviteList" id="mInviteList"></ul>
|
|
869
871
|
<div class="projMsg" id="mInviteMsg" role="status" aria-live="polite"></div>
|
|
870
872
|
</div>
|
|
@@ -1240,6 +1242,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1240
1242
|
<button type="button" class="btn small" data-invite-send="wizInvite">Invite</button>
|
|
1241
1243
|
<button type="button" class="btn small ghost" data-invite-skip="wizInvite">Skip</button>
|
|
1242
1244
|
</div>
|
|
1245
|
+
<div class="inviteWhy" id="wizInviteWhy"></div>
|
|
1246
|
+
<ul class="suggList" id="wizInviteSugg"></ul>
|
|
1243
1247
|
<ul class="inviteList" id="wizInviteList"></ul>
|
|
1244
1248
|
<div class="projMsg" id="wizInviteMsg" role="status" aria-live="polite"></div>
|
|
1245
1249
|
</div>
|
|
@@ -4148,6 +4152,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4148
4152
|
sets only AFTER this runs. */
|
|
4149
4153
|
var created = createdRecord();
|
|
4150
4154
|
setInviteSlug('wizInvite', (me && created && created.id && created.slug) ? created.slug : '');
|
|
4155
|
+
loadInviteSuggestions('wizInvite', (me && created && created.slug) ? created.id : '');
|
|
4151
4156
|
$('wizInviteLater').innerHTML = 'You can invite builders any time from ' + projectPageLink('the project’s own page') + '.';
|
|
4152
4157
|
renderSteps();
|
|
4153
4158
|
}
|
|
@@ -5336,6 +5341,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5336
5341
|
who is not the owner, so reaching renderManage at all IS the ownership
|
|
5337
5342
|
answer the widget does not ask for itself. */
|
|
5338
5343
|
setInviteSlug('mInvite', inst.slug || '');
|
|
5344
|
+
loadInviteSuggestions('mInvite', inst.slug ? inst.id : '');
|
|
5339
5345
|
/* the page names its project — the heading holds #mName; and in the tab */
|
|
5340
5346
|
document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
|
|
5341
5347
|
/* the card's own story, on the project's own page (task 1003232): the
|
|
@@ -6777,12 +6783,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6777
6783
|
Proof: ../../../tests/project_invite_ui.mjs ══ */
|
|
6778
6784
|
|
|
6779
6785
|
var INVITE_MOUNTS = {
|
|
6780
|
-
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead' },
|
|
6781
|
-
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null }
|
|
6786
|
+
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
|
|
6787
|
+
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg' }
|
|
6782
6788
|
};
|
|
6783
6789
|
var inviteSlug = {}; /* mount id → the slug it invites to; '' means no step at all */
|
|
6784
6790
|
var inviteRows = {}; /* mount id → the invites filed on THIS project this session */
|
|
6785
6791
|
var inviteBusy = {}; /* a send is in flight — the mount is inert until it answers */
|
|
6792
|
+
var inviteSugg = {}; /* mount id → { suggested, reason } — who is worth inviting (R28) */
|
|
6786
6793
|
|
|
6787
6794
|
/* setInviteSlug(id, slug) — the only door in. A falsy slug closes the mount, which
|
|
6788
6795
|
is how "signed out", "not the owner" and "no project yet" all reach this widget.
|
|
@@ -6795,6 +6802,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6795
6802
|
if (inviteSlug[id] !== next) {
|
|
6796
6803
|
inviteSlug[id] = next;
|
|
6797
6804
|
inviteRows[id] = [];
|
|
6805
|
+
inviteSugg[id] = null;
|
|
6798
6806
|
inviteSay(id, '', '');
|
|
6799
6807
|
}
|
|
6800
6808
|
var box = $(id);
|
|
@@ -6802,6 +6810,68 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6802
6810
|
renderInviteRows(id);
|
|
6803
6811
|
}
|
|
6804
6812
|
|
|
6813
|
+
/* setInviteSuggestions(id, payload) — the SECOND door in, and deliberately a
|
|
6814
|
+
separate one. The widget still answers neither "is anyone signed in" nor "is
|
|
6815
|
+
this their project" (ADR 0249): the caller has already been told both by the
|
|
6816
|
+
route it fetched, and hands over the answer or nothing.
|
|
6817
|
+
|
|
6818
|
+
`payload.suggested` empty with a reason is a real answer, not a blank state —
|
|
6819
|
+
a solo owner is suggested nobody ON PURPOSE (ADR 0239 §6), and the reason is
|
|
6820
|
+
the sentence that says so in their own words. */
|
|
6821
|
+
function setInviteSuggestions(id, payload) {
|
|
6822
|
+
if (!INVITE_MOUNTS[id]) return;
|
|
6823
|
+
inviteSugg[id] = payload || null;
|
|
6824
|
+
renderInviteSugg(id);
|
|
6825
|
+
}
|
|
6826
|
+
|
|
6827
|
+
/* The suggestion rows, each carrying its own Invite button. A name already
|
|
6828
|
+
invited this session drops out — the pending line below IS its receipt, and
|
|
6829
|
+
offering the same person twice invites a refusal the owner cannot act on. */
|
|
6830
|
+
function renderInviteSugg(id) {
|
|
6831
|
+
var m = INVITE_MOUNTS[id];
|
|
6832
|
+
var list = m && $(m.sugg);
|
|
6833
|
+
var why = m && $(m.why);
|
|
6834
|
+
if (!list || !why) return;
|
|
6835
|
+
var payload = inviteSugg[id];
|
|
6836
|
+
var reason = (payload && payload.reason) || '';
|
|
6837
|
+
why.textContent = reason;
|
|
6838
|
+
why.classList.toggle('on', !!reason);
|
|
6839
|
+
var already = {};
|
|
6840
|
+
(inviteRows[id] || []).forEach(function (r) { already[String(r.login).toLowerCase()] = true; });
|
|
6841
|
+
var rows = ((payload && payload.suggested) || []).filter(function (b) {
|
|
6842
|
+
return b && b.github_login && !already[String(b.github_login).toLowerCase()];
|
|
6843
|
+
});
|
|
6844
|
+
list.innerHTML = rows.map(function (b) {
|
|
6845
|
+
/* the login is the address AND the label: display_name is a nicety the row
|
|
6846
|
+
may not carry, and github_id is never shown (it names someone who has
|
|
6847
|
+
accepted nothing — the same pin the pending lines carry). */
|
|
6848
|
+
var named = b.display_name && b.display_name !== b.github_login
|
|
6849
|
+
? esc(b.display_name) + ' <small>@' + esc(b.github_login) + '</small>'
|
|
6850
|
+
: '<b>@' + esc(b.github_login) + '</b>';
|
|
6851
|
+
var shipped = Number(b.total_tasks_shipped || 0);
|
|
6852
|
+
var projects = Number(b.project_count || 0);
|
|
6853
|
+
var record = shipped
|
|
6854
|
+
? shipped + (shipped === 1 ? ' task shipped' : ' tasks shipped') + (projects ? ' across ' + projects + (projects === 1 ? ' project' : ' projects') : '')
|
|
6855
|
+
: '';
|
|
6856
|
+
return '<li><span class="suggWho">' + named + (record ? '<small>' + esc(record) + '</small>' : '') + '</span>'
|
|
6857
|
+
+ '<button type="button" class="btn small ghost" data-invite-pick="' + esc(b.github_login) + '">Invite</button></li>';
|
|
6858
|
+
}).join('');
|
|
6859
|
+
}
|
|
6860
|
+
|
|
6861
|
+
/* loadInviteSuggestions(id, instanceId) — ask the own-scoped route who is worth
|
|
6862
|
+
inviting. Every failure is SILENT by construction: a suggestion is an extra,
|
|
6863
|
+
and a project that was just created successfully must never look broken
|
|
6864
|
+
because an optional directory read did not answer. */
|
|
6865
|
+
function loadInviteSuggestions(id, instanceId) {
|
|
6866
|
+
if (!instanceId) { setInviteSuggestions(id, null); return; }
|
|
6867
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(instanceId) + '/invite-suggestions', {
|
|
6868
|
+
credentials: 'same-origin'
|
|
6869
|
+
}).then(function (res) {
|
|
6870
|
+
if (!res.ok) { setInviteSuggestions(id, null); return; }
|
|
6871
|
+
return res.json().then(function (data) { setInviteSuggestions(id, data); });
|
|
6872
|
+
}).catch(function () { setInviteSuggestions(id, null); });
|
|
6873
|
+
}
|
|
6874
|
+
|
|
6805
6875
|
/* One line per invite this session filed: the login the inviter typed, and the
|
|
6806
6876
|
WORD THE ROW ITSELF CARRIES — so a membership_kind other than 'pending' shows
|
|
6807
6877
|
up here rather than being papered over as one. */
|
|
@@ -6813,6 +6883,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6813
6883
|
var said = r.kind === 'pending' ? 'invited — waiting for them to accept.' : r.kind;
|
|
6814
6884
|
return '<li><b>' + esc(r.login) + '</b><small>' + esc(said) + '</small></li>';
|
|
6815
6885
|
}).join('');
|
|
6886
|
+
/* the two lists are one answer: a name that just became a pending row is no
|
|
6887
|
+
longer a suggestion, and they are always redrawn together. */
|
|
6888
|
+
renderInviteSugg(id);
|
|
6816
6889
|
}
|
|
6817
6890
|
|
|
6818
6891
|
function inviteSay(id, text, cls, isHtml) {
|
|
@@ -6896,8 +6969,17 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6896
6969
|
var box = $(id);
|
|
6897
6970
|
if (box) {
|
|
6898
6971
|
box.addEventListener('click', function (ev) {
|
|
6899
|
-
var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip]');
|
|
6972
|
+
var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip],[data-invite-pick]');
|
|
6900
6973
|
if (!t) return;
|
|
6974
|
+
/* a picked suggestion fills the field and takes the SAME path as a typed
|
|
6975
|
+
name — one write path, so the refusal vocabulary, the busy guard and the
|
|
6976
|
+
pending receipt can never drift into a second copy. */
|
|
6977
|
+
if (t.hasAttribute('data-invite-pick')) {
|
|
6978
|
+
var field = $(INVITE_MOUNTS[id].who);
|
|
6979
|
+
if (field) field.value = t.getAttribute('data-invite-pick') || '';
|
|
6980
|
+
doInvite(id);
|
|
6981
|
+
return;
|
|
6982
|
+
}
|
|
6901
6983
|
if (t.hasAttribute('data-invite-skip')) skipInvite(id); else doInvite(id);
|
|
6902
6984
|
});
|
|
6903
6985
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.563",
|
|
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.563",
|
|
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.563",
|
|
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/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.563'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|