@bongos/core 1.19.674 → 1.19.675
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 +27 -22
- 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/api/openapi.json +61 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/discord/discord-channels.js +107 -0
- package/modules/discord/routes/discord.js +26 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/discord-channels.js +14 -3
- package/src/module-api.js +1 -1
- package/tests/discord_channels_snapshot.mjs +198 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.675",
|
|
6
|
+
"core_contract": "1.19.675",
|
|
7
|
+
"source_commit": "1341cb9d37a0965db7f80483f7f1cb8f3622b2c5",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T19:32:52.079Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 474,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2123,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2621,
|
|
20
|
+
"tree_sha256": "554ecf20aaad28b8461021791d1e5c4d2c890a860a458885227f20429b946339",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -367,12 +367,12 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/README.md",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "b15290057c38e4b80db4136d7fdd32549718e31f5975484e77098c66050c4366"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "bdf715d3196df99d675570748f7ac054522046cbe89d78b84c27eef89a52d18f"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -382,17 +382,17 @@
|
|
|
382
382
|
{
|
|
383
383
|
"path": "clients/bongos-client/index.cjs",
|
|
384
384
|
"mode": "0000644",
|
|
385
|
-
"sha256": "
|
|
385
|
+
"sha256": "2fbbab84a89021945a707fd21d72acbad7f03b77c5fb6eb39abce44a64d4cc1b"
|
|
386
386
|
},
|
|
387
387
|
{
|
|
388
388
|
"path": "clients/bongos-client/index.d.ts",
|
|
389
389
|
"mode": "0000644",
|
|
390
|
-
"sha256": "
|
|
390
|
+
"sha256": "22523f2527d8849445122c5a03332d777a63d849a5806e33d387cbd83c54fb2f"
|
|
391
391
|
},
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.mjs",
|
|
394
394
|
"mode": "0000644",
|
|
395
|
-
"sha256": "
|
|
395
|
+
"sha256": "eeb72bda7a89900279647d6d999d2a920a986bf2f4ed3a63782cd4e36b1b3dfc"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1912,12 +1912,12 @@
|
|
|
1912
1912
|
{
|
|
1913
1913
|
"path": "docs/api-reference.md",
|
|
1914
1914
|
"mode": "0000644",
|
|
1915
|
-
"sha256": "
|
|
1915
|
+
"sha256": "6b681ad5de20a67efa1d6d024d5cb5dbe5313add4ec51d94a30a0025fa567ba2"
|
|
1916
1916
|
},
|
|
1917
1917
|
{
|
|
1918
1918
|
"path": "docs/api/openapi.json",
|
|
1919
1919
|
"mode": "0000644",
|
|
1920
|
-
"sha256": "
|
|
1920
|
+
"sha256": "301cf1db00b9f18b84cb765bbaa359329a665b1ef754dff4c21c1550ec60cae5"
|
|
1921
1921
|
},
|
|
1922
1922
|
{
|
|
1923
1923
|
"path": "docs/architecture.md",
|
|
@@ -2792,7 +2792,7 @@
|
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/module-api-changelog.md",
|
|
2794
2794
|
"mode": "0000644",
|
|
2795
|
-
"sha256": "
|
|
2795
|
+
"sha256": "af50f99c27d85db0067d45e5e6eba133edcae8b3dd7388041b6824f4cb192e22"
|
|
2796
2796
|
},
|
|
2797
2797
|
{
|
|
2798
2798
|
"path": "docs/modules-contract.md",
|
|
@@ -4242,7 +4242,7 @@
|
|
|
4242
4242
|
{
|
|
4243
4243
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
4244
4244
|
"mode": "0000644",
|
|
4245
|
-
"sha256": "
|
|
4245
|
+
"sha256": "2fbbab84a89021945a707fd21d72acbad7f03b77c5fb6eb39abce44a64d4cc1b"
|
|
4246
4246
|
},
|
|
4247
4247
|
{
|
|
4248
4248
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -4332,7 +4332,7 @@
|
|
|
4332
4332
|
{
|
|
4333
4333
|
"path": "modules/discord/discord-channels.js",
|
|
4334
4334
|
"mode": "0000644",
|
|
4335
|
-
"sha256": "
|
|
4335
|
+
"sha256": "aeeb86c5d61b4a06ae5429fb69bb4720d54f4782ead5d23cdce640344a568246"
|
|
4336
4336
|
},
|
|
4337
4337
|
{
|
|
4338
4338
|
"path": "modules/discord/discord-inbound.js",
|
|
@@ -4382,7 +4382,7 @@
|
|
|
4382
4382
|
{
|
|
4383
4383
|
"path": "modules/discord/routes/discord.js",
|
|
4384
4384
|
"mode": "0000644",
|
|
4385
|
-
"sha256": "
|
|
4385
|
+
"sha256": "2229a563e11bd05f0eb047f0afc0e1d66eaddb6a7fa2fb07c429e162684246f0"
|
|
4386
4386
|
},
|
|
4387
4387
|
{
|
|
4388
4388
|
"path": "modules/discord/ship-broadcast.js",
|
|
@@ -7767,12 +7767,12 @@
|
|
|
7767
7767
|
{
|
|
7768
7768
|
"path": "package-lock.json",
|
|
7769
7769
|
"mode": "0000644",
|
|
7770
|
-
"sha256": "
|
|
7770
|
+
"sha256": "795b9955321b5ecffd9d78f515649442016285cb21f728c4001ee97ebcc32ac5"
|
|
7771
7771
|
},
|
|
7772
7772
|
{
|
|
7773
7773
|
"path": "package.json",
|
|
7774
7774
|
"mode": "0000644",
|
|
7775
|
-
"sha256": "
|
|
7775
|
+
"sha256": "33bcb1180ab5e54281e7c50d39a712d142b259f77f37b685f2bb4512b1c010fa"
|
|
7776
7776
|
},
|
|
7777
7777
|
{
|
|
7778
7778
|
"path": "public-docs/index.html",
|
|
@@ -8152,7 +8152,7 @@
|
|
|
8152
8152
|
{
|
|
8153
8153
|
"path": "scripts/gds/discord-channels.js",
|
|
8154
8154
|
"mode": "0000644",
|
|
8155
|
-
"sha256": "
|
|
8155
|
+
"sha256": "e142b2d64e919adc4781ea6a55796113ebde3a3645bd4c4ed113ff9f13cd7f6a"
|
|
8156
8156
|
},
|
|
8157
8157
|
{
|
|
8158
8158
|
"path": "scripts/gds/do-api.js",
|
|
@@ -9532,7 +9532,7 @@
|
|
|
9532
9532
|
{
|
|
9533
9533
|
"path": "src/module-api.js",
|
|
9534
9534
|
"mode": "0000644",
|
|
9535
|
-
"sha256": "
|
|
9535
|
+
"sha256": "35f42575db36eb956e2479c681562e18a93d2d4973c8df0d79ac5a54048d4542"
|
|
9536
9536
|
},
|
|
9537
9537
|
{
|
|
9538
9538
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10439,6 +10439,11 @@
|
|
|
10439
10439
|
"mode": "0000644",
|
|
10440
10440
|
"sha256": "981d34c2fb5ecb6e9b9f38ece947c6c7a400dceadf8bbcf09546c8b3a92eb899"
|
|
10441
10441
|
},
|
|
10442
|
+
{
|
|
10443
|
+
"path": "tests/discord_channels_snapshot.mjs",
|
|
10444
|
+
"mode": "0000644",
|
|
10445
|
+
"sha256": "d4e43501ac29ea920c0c064afe601bd9fc24f87808f10986a51d981b3111946b"
|
|
10446
|
+
},
|
|
10442
10447
|
{
|
|
10443
10448
|
"path": "tests/discord_foundation.mjs",
|
|
10444
10449
|
"mode": "0000644",
|
|
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
|
|
|
5
5
|
by hand; it regenerates when the spec changes, so it can never drift from the routes.
|
|
6
6
|
|
|
7
7
|
- API version: **v1** (served at `/api/bongos/v1`)
|
|
8
|
-
-
|
|
8
|
+
- 363 operations across 55 resource groups
|
|
9
9
|
|
|
10
10
|
## Use it from your project
|
|
11
11
|
|
|
@@ -313,6 +313,8 @@ function createClient(opts = {}) {
|
|
|
313
313
|
getDiscordChannelsPlan: (args) => request("GET", "/discord/channels/plan", { hasBody: false }, args),
|
|
314
314
|
// POST /discord/channels/reconcile — rank: metic+archon — POST /discord/channels/reconcile
|
|
315
315
|
postDiscordChannelsReconcile: (args) => request("POST", "/discord/channels/reconcile", { hasBody: true }, args),
|
|
316
|
+
// GET /discord/channels/snapshot — rank: metic+archon — GET /discord/channels/snapshot
|
|
317
|
+
getDiscordChannelsSnapshot: (args) => request("GET", "/discord/channels/snapshot", { hasBody: false }, args),
|
|
316
318
|
},
|
|
317
319
|
"doneWhen": {
|
|
318
320
|
// PATCH /done-when/{criterionId} — rank: metic+archon — PATCH /done-when/:criterionId
|
|
@@ -312,6 +312,8 @@ function createClient(opts = {}) {
|
|
|
312
312
|
getDiscordChannelsPlan: (args) => request("GET", "/discord/channels/plan", { hasBody: false }, args),
|
|
313
313
|
// POST /discord/channels/reconcile — rank: metic+archon — POST /discord/channels/reconcile
|
|
314
314
|
postDiscordChannelsReconcile: (args) => request("POST", "/discord/channels/reconcile", { hasBody: true }, args),
|
|
315
|
+
// GET /discord/channels/snapshot — rank: metic+archon — GET /discord/channels/snapshot
|
|
316
|
+
getDiscordChannelsSnapshot: (args) => request("GET", "/discord/channels/snapshot", { hasBody: false }, args),
|
|
315
317
|
},
|
|
316
318
|
"doneWhen": {
|
|
317
319
|
// PATCH /done-when/{criterionId} — rank: metic+archon — PATCH /done-when/:criterionId
|
|
@@ -45,6 +45,7 @@ export interface GetConnectionsDegreeHandleResponse { handle: unknown; degree: u
|
|
|
45
45
|
export interface GetConnectionsRequestsResponse { incoming: unknown; outgoing: unknown; incoming_count: unknown; outgoing_count: unknown }
|
|
46
46
|
export interface GetConnectionsResponse { connections: unknown; count: unknown }
|
|
47
47
|
export interface GetDiscordChannelsPlanResponse { ok: boolean; guild_id: unknown; op_count: unknown; ops: unknown; warnings: unknown }
|
|
48
|
+
export interface GetDiscordChannelsSnapshotResponse { ok: boolean; guild_id: unknown; config: unknown; warnings: unknown }
|
|
48
49
|
export interface GetDoneWhenPendingReviewResponse { criteria: unknown; count: unknown }
|
|
49
50
|
export interface GetGithubRepoResponse { ok: boolean; repo: unknown; private_deployable: boolean }
|
|
50
51
|
export interface GetGithubReposResponse { repos: unknown }
|
|
@@ -647,6 +648,8 @@ export interface BongosClient {
|
|
|
647
648
|
getDiscordChannelsPlan(args?: RequestArgs): Promise<GetDiscordChannelsPlanResponse>;
|
|
648
649
|
/** POST /discord/channels/reconcile — rank: metic+archon */
|
|
649
650
|
postDiscordChannelsReconcile(args?: RequestArgs): Promise<PostDiscordChannelsReconcileResponse>;
|
|
651
|
+
/** GET /discord/channels/snapshot — rank: metic+archon */
|
|
652
|
+
getDiscordChannelsSnapshot(args?: RequestArgs): Promise<GetDiscordChannelsSnapshotResponse>;
|
|
650
653
|
};
|
|
651
654
|
"doneWhen": {
|
|
652
655
|
/** PATCH /done-when/{criterionId} — rank: metic+archon */
|
|
@@ -309,6 +309,8 @@ export function createClient(opts = {}) {
|
|
|
309
309
|
getDiscordChannelsPlan: (args) => request("GET", "/discord/channels/plan", { hasBody: false }, args),
|
|
310
310
|
// POST /discord/channels/reconcile — rank: metic+archon — POST /discord/channels/reconcile
|
|
311
311
|
postDiscordChannelsReconcile: (args) => request("POST", "/discord/channels/reconcile", { hasBody: true }, args),
|
|
312
|
+
// GET /discord/channels/snapshot — rank: metic+archon — GET /discord/channels/snapshot
|
|
313
|
+
getDiscordChannelsSnapshot: (args) => request("GET", "/discord/channels/snapshot", { hasBody: false }, args),
|
|
312
314
|
},
|
|
313
315
|
"doneWhen": {
|
|
314
316
|
// PATCH /done-when/{criterionId} — rank: metic+archon — PATCH /done-when/:criterionId
|
package/docs/api/openapi.json
CHANGED
|
@@ -4338,6 +4338,47 @@
|
|
|
4338
4338
|
]
|
|
4339
4339
|
}
|
|
4340
4340
|
},
|
|
4341
|
+
"/discord/channels/snapshot": {
|
|
4342
|
+
"get": {
|
|
4343
|
+
"operationId": "get_discord_channels_snapshot",
|
|
4344
|
+
"tags": [
|
|
4345
|
+
"discord"
|
|
4346
|
+
],
|
|
4347
|
+
"summary": "GET /discord/channels/snapshot",
|
|
4348
|
+
"description": "GET /api/bongos/discord/channels/snapshot — what docs/discord/channels.json WOULD say if it described the guild exactly as it is right now (task 1003510). Read-only in both directions: it writes nothing to Discord and nothing to the repo — the operator reviews the output and commits it, so the layout stays Archon-gated by merge, which is the property ADR 0037 §1 rests on. 503 when the bot is disabled / not connected to a guild. rank: archon — channel administration command.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `discord.channels.manage` (all required).",
|
|
4349
|
+
"x-rank": "metic+archon",
|
|
4350
|
+
"x-source": "modules/discord/routes/discord.js",
|
|
4351
|
+
"x-permissions": [
|
|
4352
|
+
"discord.channels.manage"
|
|
4353
|
+
],
|
|
4354
|
+
"responses": {
|
|
4355
|
+
"200": {
|
|
4356
|
+
"description": "Success.",
|
|
4357
|
+
"content": {
|
|
4358
|
+
"application/json": {
|
|
4359
|
+
"schema": {
|
|
4360
|
+
"$ref": "#/components/schemas/GetDiscordChannelsSnapshotResponse"
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
},
|
|
4365
|
+
"400": {
|
|
4366
|
+
"$ref": "#/components/responses/BadRequest"
|
|
4367
|
+
},
|
|
4368
|
+
"401": {
|
|
4369
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
4370
|
+
},
|
|
4371
|
+
"403": {
|
|
4372
|
+
"$ref": "#/components/responses/Forbidden"
|
|
4373
|
+
}
|
|
4374
|
+
},
|
|
4375
|
+
"security": [
|
|
4376
|
+
{
|
|
4377
|
+
"builderSession": []
|
|
4378
|
+
}
|
|
4379
|
+
]
|
|
4380
|
+
}
|
|
4381
|
+
},
|
|
4341
4382
|
"/done-when/pending-review": {
|
|
4342
4383
|
"get": {
|
|
4343
4384
|
"operationId": "get_done_when_pending_review",
|
|
@@ -17101,6 +17142,23 @@
|
|
|
17101
17142
|
"warnings"
|
|
17102
17143
|
]
|
|
17103
17144
|
},
|
|
17145
|
+
"GetDiscordChannelsSnapshotResponse": {
|
|
17146
|
+
"type": "object",
|
|
17147
|
+
"properties": {
|
|
17148
|
+
"ok": {
|
|
17149
|
+
"type": "boolean"
|
|
17150
|
+
},
|
|
17151
|
+
"guild_id": {},
|
|
17152
|
+
"config": {},
|
|
17153
|
+
"warnings": {}
|
|
17154
|
+
},
|
|
17155
|
+
"required": [
|
|
17156
|
+
"ok",
|
|
17157
|
+
"guild_id",
|
|
17158
|
+
"config",
|
|
17159
|
+
"warnings"
|
|
17160
|
+
]
|
|
17161
|
+
},
|
|
17104
17162
|
"GetDoneWhenPendingReviewResponse": {
|
|
17105
17163
|
"type": "object",
|
|
17106
17164
|
"properties": {
|
|
@@ -22955,9 +23013,9 @@
|
|
|
22955
23013
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
22956
23014
|
}
|
|
22957
23015
|
},
|
|
22958
|
-
"x-endpoint-count":
|
|
22959
|
-
"x-schema-count":
|
|
23016
|
+
"x-endpoint-count": 363,
|
|
23017
|
+
"x-schema-count": 402,
|
|
22960
23018
|
"x-undocumented-bodies": 9,
|
|
22961
|
-
"x-response-schemas":
|
|
23019
|
+
"x-response-schemas": 271,
|
|
22962
23020
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
22963
23021
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 363 endpoints across 58 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -191,12 +191,13 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
191
191
|
| POST | `/api/bongos/dependencies` | `archon` | — | POST /dependencies — create an edge "from is blocked until to is done". |
|
|
192
192
|
| DELETE | `/api/bongos/dependencies` | `archon` | — | DELETE /dependencies — remove an edge (body carries the 4-tuple). |
|
|
193
193
|
|
|
194
|
-
## `discord` (
|
|
194
|
+
## `discord` (3)
|
|
195
195
|
|
|
196
196
|
| Method | Path | Rank | Body | Description |
|
|
197
197
|
|---|---|---|---|---|
|
|
198
198
|
| GET | `/api/bongos/discord/channels/plan` | `metic+archon` | — | GET /api/bongos/discord/channels/plan — dry run. |
|
|
199
199
|
| POST | `/api/bongos/discord/channels/reconcile` | `metic+archon` | — | POST /api/bongos/discord/channels/reconcile — apply the config to Discord now. |
|
|
200
|
+
| GET | `/api/bongos/discord/channels/snapshot` | `metic+archon` | — | GET /api/bongos/discord/channels/snapshot — what docs/discord/channels.json WOULD say if it described the guild exactly as it is right no… |
|
|
200
201
|
|
|
201
202
|
## `done-when` (4)
|
|
202
203
|
|
|
@@ -1807,5 +1807,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1807
1807
|
landed since 1.19.672 with no explicit bump. run 34632328471. (task 1002620)
|
|
1808
1808
|
1.19.674 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1809
1809
|
landed since 1.19.673 with no explicit bump. run 34634515595. (task 1002620)
|
|
1810
|
+
1.19.675 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1811
|
+
landed since 1.19.674 with no explicit bump. run 34639371461. (task 1002620)
|
|
1810
1812
|
---------------------------------------------------------------------------
|
|
1811
1813
|
```
|
|
@@ -312,6 +312,8 @@ function createClient(opts = {}) {
|
|
|
312
312
|
getDiscordChannelsPlan: (args) => request("GET", "/discord/channels/plan", { hasBody: false }, args),
|
|
313
313
|
// POST /discord/channels/reconcile — rank: metic+archon — POST /discord/channels/reconcile
|
|
314
314
|
postDiscordChannelsReconcile: (args) => request("POST", "/discord/channels/reconcile", { hasBody: true }, args),
|
|
315
|
+
// GET /discord/channels/snapshot — rank: metic+archon — GET /discord/channels/snapshot
|
|
316
|
+
getDiscordChannelsSnapshot: (args) => request("GET", "/discord/channels/snapshot", { hasBody: false }, args),
|
|
315
317
|
},
|
|
316
318
|
"doneWhen": {
|
|
317
319
|
// PATCH /done-when/{criterionId} — rank: metic+archon — PATCH /done-when/:criterionId
|
|
@@ -178,6 +178,98 @@ function viewOverwritesEqual(a, b, ctx) {
|
|
|
178
178
|
// categories: [{ id, name }],
|
|
179
179
|
// channels: [{ id, name, type, parentId, topic, overwrites:[{id,type,allow,deny}] }]
|
|
180
180
|
// }
|
|
181
|
+
// viewFromOverwrites — the INVERSE of resolveViewRoles + mergeViewOverwrites:
|
|
182
|
+
// read a channel's real permission overwrites and say which `view` value would
|
|
183
|
+
// produce them. Pure (task 1003510).
|
|
184
|
+
//
|
|
185
|
+
// `view: R` grants R and every rank ABOVE it, so the answer is the LOWEST rank
|
|
186
|
+
// on the ladder holding an explicit VIEW allow. @everyone allowed outranks all
|
|
187
|
+
// of it and means "everyone".
|
|
188
|
+
//
|
|
189
|
+
// Returns null for "cannot tell" — no @everyone overwrite AND no rank role
|
|
190
|
+
// allowed, which is a channel the bot has never touched and which simply
|
|
191
|
+
// inherits its category. Null is the honest answer there, and the caller emits
|
|
192
|
+
// no `view` key so the channel inherits in the config exactly as it does in the
|
|
193
|
+
// guild. Do NOT collapse null to 'everyone': that would write a claim the guild
|
|
194
|
+
// never made, and a later reconcile would act on it.
|
|
195
|
+
function viewFromOverwrites(overwrites, ctx) {
|
|
196
|
+
const { everyoneId, roleMap = {} } = ctx || {};
|
|
197
|
+
const has = (o, field) => (BigInt((o && o[field]) || 0) & VIEW_CHANNEL) !== 0n;
|
|
198
|
+
const byId = new Map((overwrites || []).map((o) => [String(o.id), o]));
|
|
199
|
+
|
|
200
|
+
const everyone = byId.get(String(everyoneId));
|
|
201
|
+
if (everyone && has(everyone, 'allow')) return 'everyone';
|
|
202
|
+
|
|
203
|
+
for (const rank of RANK_LADDER) { // low -> high: first allow wins
|
|
204
|
+
const rid = roleMap[rank] ? String(roleMap[rank]) : null;
|
|
205
|
+
if (rid && has(byId.get(rid), 'allow')) return rank;
|
|
206
|
+
}
|
|
207
|
+
// @everyone explicitly DENIED but no rank allowed: restricted to nobody we
|
|
208
|
+
// manage. resolveViewRoles fails safe to archon, so mirror that rather than
|
|
209
|
+
// inventing wider access.
|
|
210
|
+
if (everyone && has(everyone, 'deny')) return 'archon';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// configFromSnapshot — turn a LIVE guild snapshot into channels.json shape, so
|
|
215
|
+
// the file can be seeded from what the guild really is instead of from a guess
|
|
216
|
+
// (task 1003510; ADR 0037 §1 calls channels.json the single source of truth, and
|
|
217
|
+
// that is only true once the channels actually in the guild are written down).
|
|
218
|
+
//
|
|
219
|
+
// THE CORRECTNESS PROPERTY, and the reason this is worth having as a pure
|
|
220
|
+
// function: feeding the result straight back to planChannelReconcile against the
|
|
221
|
+
// SAME snapshot must produce ZERO ops. If any field is inverted wrongly — a view,
|
|
222
|
+
// a topic, a name, a parent — the planner emits an op for it, so the round trip
|
|
223
|
+
// is a total test of this function rather than a field-by-field restatement of it.
|
|
224
|
+
//
|
|
225
|
+
// That zero-ops guarantee holds for a guild the bot has already reconciled. On a
|
|
226
|
+
// guild it has NOT, a channel may carry no @everyone overwrite at all; the config
|
|
227
|
+
// then omits its `view`, it inherits the category, and the planner may rightly
|
|
228
|
+
// emit an edit. That difference is real and belongs in the operator's review, not
|
|
229
|
+
// hidden here — which is why `warnings` is returned rather than swallowed.
|
|
230
|
+
//
|
|
231
|
+
// Channels with no parent category are REPORTED, never invented: channels.json
|
|
232
|
+
// can only express a channel nested in a category, and the omitted-is-untouched
|
|
233
|
+
// rule (see the file's own header) makes leaving them out the safe default.
|
|
234
|
+
function configFromSnapshot(snapshot, ctx) {
|
|
235
|
+
const warnings = [];
|
|
236
|
+
const cats = (snapshot.categories || []).map((c) => ({
|
|
237
|
+
id: String(c.id),
|
|
238
|
+
name: c.name,
|
|
239
|
+
view: viewFromOverwrites(c.overwrites, ctx),
|
|
240
|
+
channels: [],
|
|
241
|
+
}));
|
|
242
|
+
const byId = new Map(cats.map((c) => [c.id, c]));
|
|
243
|
+
|
|
244
|
+
for (const ch of snapshot.channels || []) {
|
|
245
|
+
if (ch.type !== 0) continue; // text channels only, as snapshotFromGuild already filters
|
|
246
|
+
const parent = ch.parentId ? byId.get(String(ch.parentId)) : null;
|
|
247
|
+
if (!parent) {
|
|
248
|
+
warnings.push(`channel "${ch.name}" has no category — channels.json cannot express an uncategorised channel, so it is omitted (and therefore left untouched).`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const view = viewFromOverwrites(ch.overwrites, ctx);
|
|
252
|
+
const entry = { key: slugify(ch.name), name: ch.name, topic: ch.topic || '' };
|
|
253
|
+
// Emit `view` only when it DIFFERS from the category's — the planner reads
|
|
254
|
+
// `ch.view || cat.view`, so an identical value is noise that drifts later.
|
|
255
|
+
if (view !== null && view !== parent.view) entry.view = view;
|
|
256
|
+
if (view === null) {
|
|
257
|
+
warnings.push(`channel "${ch.name}" carries no VIEW overwrite the bot manages — it inherits category "${parent.name}"; confirm that is intended before applying.`);
|
|
258
|
+
}
|
|
259
|
+
parent.channels.push(entry);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const categories = cats.map((c) => ({
|
|
263
|
+
name: c.name,
|
|
264
|
+
view: c.view === null ? 'everyone' : c.view,
|
|
265
|
+
channels: c.channels,
|
|
266
|
+
}));
|
|
267
|
+
for (const c of cats) {
|
|
268
|
+
if (c.view === null) warnings.push(`category "${c.name}" carries no VIEW overwrite the bot manages — recorded as "everyone"; confirm before applying.`);
|
|
269
|
+
}
|
|
270
|
+
return { config: { categories }, warnings };
|
|
271
|
+
}
|
|
272
|
+
|
|
181
273
|
// Ops never include deletions. Categories are planned before their channels so
|
|
182
274
|
// the apply step can resolve a freshly-created parent id.
|
|
183
275
|
function planChannelReconcile(snapshot, config, ctx) {
|
|
@@ -323,6 +415,18 @@ function computePlan(deps = realDeps) {
|
|
|
323
415
|
return { ok: true, ops, warnings, guildId: String(guild.id) };
|
|
324
416
|
}
|
|
325
417
|
|
|
418
|
+
// computeSnapshotConfig — what channels.json WOULD say if it described the guild
|
|
419
|
+
// as it is right now (task 1003510). Read-only: it touches Discord only through
|
|
420
|
+
// the same getGuild() the planner uses, and writes nothing anywhere.
|
|
421
|
+
function computeSnapshotConfig(deps = realDeps) {
|
|
422
|
+
const guild = deps.getGuild();
|
|
423
|
+
if (!guild) return { ok: false, reason: 'no_guild', config: null, warnings: [] };
|
|
424
|
+
const roleMap = deps.loadRoleMap();
|
|
425
|
+
const ctx = { everyoneId: String(guild.id), roleMap, botId: botIdFromGuild(guild) };
|
|
426
|
+
const { config, warnings } = configFromSnapshot(snapshotFromGuild(guild), ctx);
|
|
427
|
+
return { ok: true, config, warnings, guildId: String(guild.id) };
|
|
428
|
+
}
|
|
429
|
+
|
|
326
430
|
// Apply the plan to Discord. Best-effort, never throws. Returns a summary.
|
|
327
431
|
// Categories are created first so child channels can resolve their parent id.
|
|
328
432
|
async function reconcileChannels(deps = realDeps) {
|
|
@@ -398,6 +502,8 @@ module.exports = {
|
|
|
398
502
|
// pure (tested)
|
|
399
503
|
slugify,
|
|
400
504
|
resolveViewRoles,
|
|
505
|
+
viewFromOverwrites,
|
|
506
|
+
configFromSnapshot,
|
|
401
507
|
mergeViewOverwrites,
|
|
402
508
|
viewOverwritesEqual,
|
|
403
509
|
planChannelReconcile,
|
|
@@ -405,6 +511,7 @@ module.exports = {
|
|
|
405
511
|
loadConfig,
|
|
406
512
|
snapshotFromGuild,
|
|
407
513
|
computePlan,
|
|
514
|
+
computeSnapshotConfig,
|
|
408
515
|
reconcileChannels,
|
|
409
516
|
CONFIG_PATH,
|
|
410
517
|
VIEW_CHANNEL,
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
const express = require('express');
|
|
16
16
|
const api = require('../../../src/module-api');
|
|
17
|
+
// Structured + attributable, and the console ratchet counts raw console calls —
|
|
18
|
+
// the api.logger(<module key>) precedent every other module's routes follow.
|
|
19
|
+
const log = api.logger('discord');
|
|
17
20
|
const auth = require('../auth');
|
|
18
21
|
const db = require('../db');
|
|
19
22
|
const shipBroadcast = require('../ship-broadcast');
|
|
@@ -93,7 +96,28 @@ module.exports = function buildDiscordRouter() {
|
|
|
93
96
|
}
|
|
94
97
|
return res.json({ ok: true, guild_id: plan.guildId, op_count: plan.ops.length, ops: plan.ops, warnings: plan.warnings });
|
|
95
98
|
} catch (err) {
|
|
96
|
-
|
|
99
|
+
log.error({ err: err && err.message }, 'GET /discord/channels/plan failed');
|
|
100
|
+
return res.fail('internal_error', 500);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// GET /api/bongos/discord/channels/snapshot — what docs/discord/channels.json
|
|
105
|
+
// WOULD say if it described the guild exactly as it is right now (task 1003510).
|
|
106
|
+
// Read-only in both directions: it writes nothing to Discord and nothing to the
|
|
107
|
+
// repo — the operator reviews the output and commits it, so the layout stays
|
|
108
|
+
// Archon-gated by merge, which is the property ADR 0037 §1 rests on.
|
|
109
|
+
// 503 when the bot is disabled / not connected to a guild.
|
|
110
|
+
// rank: archon — channel administration command.
|
|
111
|
+
router.get('/discord/channels/snapshot', auth.requireBuilder, auth.requirePermission('discord.channels.manage'), async (req, res) => {
|
|
112
|
+
try {
|
|
113
|
+
const channels = require('../discord-channels');
|
|
114
|
+
const snap = channels.computeSnapshotConfig();
|
|
115
|
+
if (!snap.ok) {
|
|
116
|
+
return res.fail('bot_unavailable', 503, { reason: snap.reason });
|
|
117
|
+
}
|
|
118
|
+
return res.json({ ok: true, guild_id: snap.guildId, config: snap.config, warnings: snap.warnings });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.error({ err: err && err.message }, 'GET /discord/channels/snapshot failed');
|
|
97
121
|
return res.fail('internal_error', 500);
|
|
98
122
|
}
|
|
99
123
|
});
|
|
@@ -111,7 +135,7 @@ module.exports = function buildDiscordRouter() {
|
|
|
111
135
|
}
|
|
112
136
|
return res.json({ ok: true, applied: result.applied, failed: result.failed, total: result.total, warnings: result.warnings });
|
|
113
137
|
} catch (err) {
|
|
114
|
-
|
|
138
|
+
log.error({ err: err && err.message }, 'POST /discord/channels/reconcile failed');
|
|
115
139
|
return res.fail('internal_error', 500);
|
|
116
140
|
}
|
|
117
141
|
});
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.675",
|
|
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.675",
|
|
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.675",
|
|
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",
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// directly — it asks the deployed bot (via the API) to do the work.
|
|
11
11
|
//
|
|
12
12
|
// Usage:
|
|
13
|
-
// node scripts/gds/discord-channels.js plan
|
|
13
|
+
// node scripts/gds/discord-channels.js plan # dry run: what WOULD change
|
|
14
14
|
// node scripts/gds/discord-channels.js apply # apply docs/discord/channels.json to Discord
|
|
15
|
+
// node scripts/gds/discord-channels.js snapshot # print the guild AS channels.json (seed the file)
|
|
15
16
|
//
|
|
16
17
|
// The channel layout itself is edited in docs/discord/channels.json and merged
|
|
17
18
|
// (rank-gated) — this command just triggers the reconcile.
|
|
@@ -20,8 +21,8 @@ const { cliClient } = require('./cli-lib');
|
|
|
20
21
|
|
|
21
22
|
async function main() {
|
|
22
23
|
const cmd = (process.argv[2] || '').toLowerCase();
|
|
23
|
-
if (cmd !== 'plan' && cmd !== 'apply') {
|
|
24
|
-
console.error('usage: node scripts/gds/discord-channels.js <plan|apply>');
|
|
24
|
+
if (cmd !== 'plan' && cmd !== 'apply' && cmd !== 'snapshot') {
|
|
25
|
+
console.error('usage: node scripts/gds/discord-channels.js <plan|apply|snapshot>');
|
|
25
26
|
process.exit(2);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -29,6 +30,8 @@ async function main() {
|
|
|
29
30
|
let res;
|
|
30
31
|
if (cmd === 'plan') {
|
|
31
32
|
res = await api.discord.getDiscordChannelsPlan();
|
|
33
|
+
} else if (cmd === 'snapshot') {
|
|
34
|
+
res = await api.discord.getDiscordChannelsSnapshot();
|
|
32
35
|
} else {
|
|
33
36
|
res = await api.discord.postDiscordChannelsReconcile({ body: {} });
|
|
34
37
|
}
|
|
@@ -47,7 +50,15 @@ async function main() {
|
|
|
47
50
|
process.exit(1);
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// Warnings go to STDERR so `snapshot > channels.json` writes clean JSON.
|
|
50
54
|
for (const w of body.warnings || []) console.warn(`warning: ${w}`);
|
|
55
|
+
if (cmd === 'snapshot') {
|
|
56
|
+
// STDOUT is the file body and nothing else — this command exists to be
|
|
57
|
+
// redirected. Review the diff before committing: it is the guild's real
|
|
58
|
+
// state, which is not necessarily the state anyone intended.
|
|
59
|
+
console.log(JSON.stringify(body.config, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
51
62
|
if (cmd === 'plan') {
|
|
52
63
|
const ops = body.ops || [];
|
|
53
64
|
if (!ops.length) { console.log('In sync — no channel changes needed.'); return; }
|
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.675'; // 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,198 @@
|
|
|
1
|
+
// tests/discord_channels_snapshot.mjs
|
|
2
|
+
//
|
|
3
|
+
// Seeding docs/discord/channels.json from the LIVE guild (task 1003510).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. ADR 0037 §1 calls channels.json "the single source of truth"
|
|
6
|
+
// and rests its security argument on it — changing channels means editing a repo
|
|
7
|
+
// file and merging it, and merging is rank-gated, so the layout is Archon-gated
|
|
8
|
+
// by construction. That property only holds for channels the file actually
|
|
9
|
+
// describes. It described one (board-room, task 1003033); the rest of the guild
|
|
10
|
+
// was configured somewhere untracked. Reconstructing them by hand from the ADR's
|
|
11
|
+
// access table was rejected on purpose: the table lists `view` but no names and
|
|
12
|
+
// no topics, so guessing would make the bot EDIT live channels to match an
|
|
13
|
+
// invention. `configFromSnapshot` reads the real values instead.
|
|
14
|
+
//
|
|
15
|
+
// THE PROPERTY THAT MAKES IT TRUSTWORTHY — and the whole point of this file:
|
|
16
|
+
//
|
|
17
|
+
// planChannelReconcile(snapshot, configFromSnapshot(snapshot).config) === no ops
|
|
18
|
+
//
|
|
19
|
+
// Feeding the derived config straight back at the guild it came from must ask for
|
|
20
|
+
// NOTHING. Any field inverted wrongly — a view, a name, a topic, a parent — makes
|
|
21
|
+
// the planner emit an op, so this one assertion tests the whole function instead
|
|
22
|
+
// of restating it field by field. A config that round-trips is one an operator can
|
|
23
|
+
// commit without the next reconcile rewriting the guild.
|
|
24
|
+
//
|
|
25
|
+
// Pure: no Discord, no network, no DB.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/discord_channels_snapshot.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ch = require('../modules/discord/discord-channels.js');
|
|
34
|
+
const { configFromSnapshot, viewFromOverwrites, planChannelReconcile, mergeViewOverwrites, resolveViewRoles } = ch;
|
|
35
|
+
|
|
36
|
+
const EVERYONE = '100';
|
|
37
|
+
const ROLES = { xenos: '201', thetes: '202', metic: '203', archon: '204' };
|
|
38
|
+
const BOT = '999';
|
|
39
|
+
const ctx = { everyoneId: EVERYONE, roleMap: ROLES, botId: BOT };
|
|
40
|
+
|
|
41
|
+
let passed = 0, failed = 0;
|
|
42
|
+
function test(name, fn) {
|
|
43
|
+
try { fn(); console.log(` ok ${name}`); passed++; }
|
|
44
|
+
catch (e) { console.error(` FAIL ${name}\n ${e.message}`); failed++; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the overwrites a REAL reconciled channel carries for a given view, using
|
|
48
|
+
// the production forward path — so the fixtures are what the bot actually writes,
|
|
49
|
+
// not a hand-rolled guess at it.
|
|
50
|
+
const owFor = (view) => mergeViewOverwrites([], resolveViewRoles(view, ROLES), ctx);
|
|
51
|
+
|
|
52
|
+
// ---- the inverse, on its own ----------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('everyone-visible reads back as "everyone"', () => {
|
|
55
|
+
assert.equal(viewFromOverwrites(owFor('everyone'), ctx), 'everyone');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('each rank round-trips through the overwrites the bot writes', () => {
|
|
59
|
+
for (const rank of ['xenos', 'thetes', 'metic', 'archon']) {
|
|
60
|
+
assert.equal(viewFromOverwrites(owFor(rank), ctx), rank, `view=${rank}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the LOWEST allowed rank wins — view grants that rank and every rank above', () => {
|
|
65
|
+
// thetes' overwrites allow thetes+metic+archon. The answer must be the floor,
|
|
66
|
+
// not whichever happens to be first in the array.
|
|
67
|
+
const ow = owFor('thetes');
|
|
68
|
+
assert.ok(ow.some((o) => o.id === ROLES.archon), 'fixture really does allow archon too');
|
|
69
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'thetes');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no managed overwrite at all reads back as null, never "everyone"', () => {
|
|
73
|
+
// A channel the bot has never touched. Claiming "everyone" here would write a
|
|
74
|
+
// claim the guild never made, and the next reconcile would act on it.
|
|
75
|
+
assert.equal(viewFromOverwrites([], ctx), null);
|
|
76
|
+
assert.equal(viewFromOverwrites([{ id: '777', type: 0, allow: '0', deny: '0' }], ctx), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('@everyone denied with no rank allowed fails safe to archon', () => {
|
|
80
|
+
const ow = [{ id: EVERYONE, type: 0, allow: '0', deny: '1024' }];
|
|
81
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'archon', 'mirrors resolveViewRoles fail-safe');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---- the round trip --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// A guild shaped like the real one: a public category, a restricted one, emoji in
|
|
87
|
+
// the names, and a channel whose view is TIGHTER than its category's.
|
|
88
|
+
function guild() {
|
|
89
|
+
return {
|
|
90
|
+
categories: [
|
|
91
|
+
{ id: '1', name: 'COMMONS', overwrites: owFor('everyone') },
|
|
92
|
+
{ id: '2', name: 'BUILDERS', overwrites: owFor('xenos') },
|
|
93
|
+
],
|
|
94
|
+
channels: [
|
|
95
|
+
{ id: '10', name: '👋-welcome', type: 0, parentId: '1', topic: 'Start here.', overwrites: owFor('everyone') },
|
|
96
|
+
{ id: '11', name: '📯-ship-news', type: 0, parentId: '1', topic: 'What shipped.', overwrites: owFor('everyone') },
|
|
97
|
+
{ id: '12', name: '🏛️-board-room', type: 0, parentId: '2', topic: 'Ratification votes.', overwrites: owFor('xenos') },
|
|
98
|
+
{ id: '13', name: '🔒-archon-only', type: 0, parentId: '2', topic: 'Archon business.', overwrites: owFor('archon') },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('ROUND TRIP: the derived config asks the planner for nothing', () => {
|
|
104
|
+
const snapshot = guild();
|
|
105
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
106
|
+
assert.deepEqual(warnings, [], 'a fully-reconciled guild produces no warnings');
|
|
107
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
108
|
+
assert.deepEqual(ops, [], `expected zero ops, got: ${JSON.stringify(ops.map((o) => [o.op, o.name]))}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the real names survive, emoji and all — they are what slug matching needs', () => {
|
|
112
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
113
|
+
const names = config.categories.flatMap((c) => c.channels.map((x) => x.name));
|
|
114
|
+
assert.deepEqual(names, ['👋-welcome', '📯-ship-news', '🏛️-board-room', '🔒-archon-only']);
|
|
115
|
+
const keys = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
116
|
+
assert.deepEqual(keys, ['welcome', 'ship-news', 'board-room', 'archon-only'], 'key is the slug');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('topics are carried verbatim — the ADR table has none, which is why guessing was refused', () => {
|
|
120
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
121
|
+
const commons = config.categories.find((c) => c.name === 'COMMONS');
|
|
122
|
+
assert.equal(commons.channels.find((c) => c.key === 'ship-news').topic, 'What shipped.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a channel view EQUAL to its category is omitted; a tighter one is stated', () => {
|
|
126
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
127
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
128
|
+
assert.equal(builders.view, 'xenos');
|
|
129
|
+
// board-room matches its category → inherits, so no `view` key to drift later.
|
|
130
|
+
assert.equal('view' in builders.channels.find((c) => c.key === 'board-room'), false);
|
|
131
|
+
// archon-only is tighter → must be stated or the config would widen it.
|
|
132
|
+
assert.equal(builders.channels.find((c) => c.key === 'archon-only').view, 'archon');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a tighter channel view is not merely recorded — it survives the round trip', () => {
|
|
136
|
+
// Guard against "stated but wrong": drop the view and the planner must object.
|
|
137
|
+
const snapshot = guild();
|
|
138
|
+
const { config } = configFromSnapshot(snapshot, ctx);
|
|
139
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
140
|
+
delete builders.channels.find((c) => c.key === 'archon-only').view;
|
|
141
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
142
|
+
assert.ok(ops.length > 0, 'removing the tighter view must make the planner want to widen it');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---- the honest edges ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test('an uncategorised channel is REPORTED and omitted, never invented into a category', () => {
|
|
148
|
+
const snapshot = guild();
|
|
149
|
+
snapshot.channels.push({ id: '14', name: 'stray', type: 0, parentId: null, topic: '', overwrites: owFor('everyone') });
|
|
150
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
151
|
+
const all = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
152
|
+
assert.ok(!all.includes('stray'), 'omitted — channels.json cannot express it');
|
|
153
|
+
assert.ok(warnings.some((w) => /stray/.test(w) && /no category/.test(w)), 'and said so');
|
|
154
|
+
// Omitted means UNTOUCHED, which is the file header's own rule.
|
|
155
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
156
|
+
assert.deepEqual(ops, [], 'an omitted channel still produces no op');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a never-reconciled channel warns instead of silently claiming a view', () => {
|
|
160
|
+
const snapshot = guild();
|
|
161
|
+
snapshot.channels.push({ id: '15', name: 'untouched', type: 0, parentId: '1', topic: '', overwrites: [] });
|
|
162
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
163
|
+
const entry = config.categories.find((c) => c.name === 'COMMONS').channels.find((c) => c.key === 'untouched');
|
|
164
|
+
assert.equal('view' in entry, false, 'inherits the category, as it does in the guild');
|
|
165
|
+
assert.ok(warnings.some((w) => /untouched/.test(w)), 'the operator is told before they apply');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a CATEGORY with no managed overwrite is recorded as everyone, and warns', () => {
|
|
169
|
+
// The category-side twin of the channel null path above. A guild can carry a
|
|
170
|
+
// category the bot has never restricted; channels.json has no way to say
|
|
171
|
+
// "inherits" for a category (it is the top of the tree), so 'everyone' is the
|
|
172
|
+
// only expressible answer — and it is a WIDER claim than the guild made, which
|
|
173
|
+
// is exactly why it has to warn rather than pass silently.
|
|
174
|
+
const snapshot = {
|
|
175
|
+
categories: [{ id: '1', name: 'UNMANAGED', overwrites: [] }],
|
|
176
|
+
channels: [{ id: '10', name: 'chat', type: 0, parentId: '1', topic: '', overwrites: owFor('everyone') }],
|
|
177
|
+
};
|
|
178
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
179
|
+
assert.equal(config.categories[0].view, 'everyone');
|
|
180
|
+
assert.ok(
|
|
181
|
+
warnings.some((w) => /UNMANAGED/.test(w) && /confirm/i.test(w)),
|
|
182
|
+
'the operator is told the category view was assumed, not read'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('the output is the shape channels.json actually declares', () => {
|
|
187
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
188
|
+
assert.deepEqual(Object.keys(config), ['categories']);
|
|
189
|
+
for (const c of config.categories) {
|
|
190
|
+
assert.deepEqual(Object.keys(c).sort(), ['channels', 'name', 'view']);
|
|
191
|
+
for (const x of c.channels) {
|
|
192
|
+
for (const k of Object.keys(x)) assert.ok(['key', 'name', 'topic', 'view'].includes(k), `unexpected key ${k}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log(`\ndiscord_channels_snapshot: ${passed} passed, ${failed} failed`);
|
|
198
|
+
process.exit(failed === 0 ? 0 : 1);
|