@bongos/core 1.19.550 → 1.19.552
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 +69 -34
- package/.claude/skills/bug-triage/SKILL.md +191 -0
- 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 +77 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_232_task_merge_provenance.sql +64 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_011_task_merge_metic.sql +38 -0
- package/modules/lifecycle/db-goals.js +2 -1
- package/modules/lifecycle/db-ship.js +147 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/done-when.js +3 -2
- package/modules/lifecycle/goal-rollup.js +6 -4
- package/modules/lifecycle/routes/tasks.js +42 -0
- package/modules/lifecycle/task-status.js +72 -0
- package/modules/lifecycle/version-close.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-triage.js +292 -0
- package/scripts/gds/sequence.js +4 -2
- package/scripts/gds/triage.js +40 -21
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +17 -0
- package/tests/auto_satisfy_criteria.mjs +24 -2
- package/tests/bug_triage.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +9 -3
- package/tests/task_merge.mjs +198 -0
package/docs/api/openapi.json
CHANGED
|
@@ -15213,6 +15213,65 @@
|
|
|
15213
15213
|
]
|
|
15214
15214
|
}
|
|
15215
15215
|
},
|
|
15216
|
+
"/tasks/{id}/merge": {
|
|
15217
|
+
"post": {
|
|
15218
|
+
"operationId": "post_tasks_id_merge",
|
|
15219
|
+
"tags": [
|
|
15220
|
+
"tasks"
|
|
15221
|
+
],
|
|
15222
|
+
"summary": "POST /tasks/:id/merge",
|
|
15223
|
+
"description": "POST /tasks/:id/merge — mark a duplicate task merged into its canonical one (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict). Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather than a new `tasks.status` value — see that function's header for why. Refuses a self-merge, a shipped task, a task with an active claim, a target that doesn't exist, and a target that is itself merged elsewhere (no chains); idempotent when re-merging into the SAME target already recorded. rank: metic+archon — same floor as task.abandon, which this reuses under the hood; a Metic who may retire a task may also say which task it duplicates.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `task.merge` (all required).",
|
|
15224
|
+
"x-rank": "metic+archon",
|
|
15225
|
+
"x-source": "modules/lifecycle/routes/tasks.js",
|
|
15226
|
+
"x-permissions": [
|
|
15227
|
+
"task.merge"
|
|
15228
|
+
],
|
|
15229
|
+
"parameters": [
|
|
15230
|
+
{
|
|
15231
|
+
"name": "id",
|
|
15232
|
+
"in": "path",
|
|
15233
|
+
"required": true,
|
|
15234
|
+
"schema": {
|
|
15235
|
+
"type": "string"
|
|
15236
|
+
},
|
|
15237
|
+
"description": "Path parameter `id`."
|
|
15238
|
+
}
|
|
15239
|
+
],
|
|
15240
|
+
"requestBody": {
|
|
15241
|
+
"required": true,
|
|
15242
|
+
"content": {
|
|
15243
|
+
"application/json": {
|
|
15244
|
+
"schema": {
|
|
15245
|
+
"$ref": "#/components/schemas/PostTasksIdMergeRequest"
|
|
15246
|
+
}
|
|
15247
|
+
}
|
|
15248
|
+
},
|
|
15249
|
+
"x-validated": true
|
|
15250
|
+
},
|
|
15251
|
+
"responses": {
|
|
15252
|
+
"200": {
|
|
15253
|
+
"description": "Success."
|
|
15254
|
+
},
|
|
15255
|
+
"400": {
|
|
15256
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
15257
|
+
},
|
|
15258
|
+
"401": {
|
|
15259
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
15260
|
+
},
|
|
15261
|
+
"403": {
|
|
15262
|
+
"$ref": "#/components/responses/Forbidden"
|
|
15263
|
+
},
|
|
15264
|
+
"404": {
|
|
15265
|
+
"$ref": "#/components/responses/NotFound"
|
|
15266
|
+
}
|
|
15267
|
+
},
|
|
15268
|
+
"security": [
|
|
15269
|
+
{
|
|
15270
|
+
"builderSession": []
|
|
15271
|
+
}
|
|
15272
|
+
]
|
|
15273
|
+
}
|
|
15274
|
+
},
|
|
15216
15275
|
"/tasks/{id}/override-request": {
|
|
15217
15276
|
"post": {
|
|
15218
15277
|
"operationId": "post_tasks_id_override_request",
|
|
@@ -21790,6 +21849,22 @@
|
|
|
21790
21849
|
},
|
|
21791
21850
|
"additionalProperties": false
|
|
21792
21851
|
},
|
|
21852
|
+
"PostTasksIdMergeRequest": {
|
|
21853
|
+
"type": "object",
|
|
21854
|
+
"properties": {
|
|
21855
|
+
"into_task_id": {
|
|
21856
|
+
"type": "integer",
|
|
21857
|
+
"minimum": 1
|
|
21858
|
+
},
|
|
21859
|
+
"reason": {
|
|
21860
|
+
"type": "string"
|
|
21861
|
+
}
|
|
21862
|
+
},
|
|
21863
|
+
"required": [
|
|
21864
|
+
"into_task_id"
|
|
21865
|
+
],
|
|
21866
|
+
"additionalProperties": false
|
|
21867
|
+
},
|
|
21793
21868
|
"PostTasksIdOverrideRequestRequest": {
|
|
21794
21869
|
"type": "object",
|
|
21795
21870
|
"properties": {
|
|
@@ -22384,8 +22459,8 @@
|
|
|
22384
22459
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
22385
22460
|
}
|
|
22386
22461
|
},
|
|
22387
|
-
"x-endpoint-count":
|
|
22388
|
-
"x-schema-count":
|
|
22462
|
+
"x-endpoint-count": 356,
|
|
22463
|
+
"x-schema-count": 391,
|
|
22389
22464
|
"x-undocumented-bodies": 9,
|
|
22390
22465
|
"x-response-schemas": 265,
|
|
22391
22466
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
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. 356 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`.
|
|
@@ -581,7 +581,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
581
581
|
|---|---|---|---|---|
|
|
582
582
|
| GET | `/api/bongos/task-visuals/:name` | `public` | — | the WIDEST path this route can take, declared honestly. |
|
|
583
583
|
|
|
584
|
-
## `tasks` (
|
|
584
|
+
## `tasks` (32)
|
|
585
585
|
|
|
586
586
|
| Method | Path | Rank | Body | Description |
|
|
587
587
|
|---|---|---|---|---|
|
|
@@ -601,6 +601,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
601
601
|
| PUT | `/api/bongos/tasks/:id/dependencies` | `metic+archon` | `depends_on_task_ids` | PUT /tasks/:id/dependencies — replace the full dep set. |
|
|
602
602
|
| DELETE | `/api/bongos/tasks/:id/dependencies/:depId` | `metic+archon` | — | DELETE /tasks/:id/dependencies/:depId — removes the edge. |
|
|
603
603
|
| POST | `/api/bongos/tasks/:id/grade` | `any-builder` | `subagent_output`, `signals`, `committed_files`, `diff_hash` | GDS-V3 task V3.R18 (task 209). |
|
|
604
|
+
| POST | `/api/bongos/tasks/:id/merge` | `metic+archon` | `into_task_id`, `reason` | POST /tasks/:id/merge — mark a duplicate task merged into its canonical one (task 1001478 — the /bug-triage counterpart to idea_inbox's m… |
|
|
604
605
|
| POST | `/api/bongos/tasks/:id/override-request` | `any-builder` | `rationale` | POST /tasks/:id/override-request — V3.R74 (task 293). |
|
|
605
606
|
| POST | `/api/bongos/tasks/:id/promote` | `metic+archon` | — | backlog→ready promotion; scope mutation; ADR 0090 (was archon). |
|
|
606
607
|
| POST | `/api/bongos/tasks/:id/publish-branch` | `any-builder` | `branch`, `head_sha`, `bundle_b64`, `value_summary` | |
|
package/docs/file-map.md
CHANGED
|
@@ -375,6 +375,7 @@ tests/
|
|
|
375
375
|
.claude/skills/
|
|
376
376
|
├── blocker-review/SKILL.md ← /blocker-review — daily review of open GDS blockers (resolve / escalate / note); Metic+
|
|
377
377
|
├── blocker-solve/SKILL.md ← /blocker-solve N — drive ONE blocker to done: do the doable parts, hand back owner-only steps, verify, auto-resolve (auto-promotes waiters); Metic+
|
|
378
|
+
├── bug-triage/SKILL.md ← /bug-triage — daily walk through open kind=bug tasks (batch-cluster near-dups, merge / won't-fix); the idea-triage counterpart built on the new tasks.merge primitive (task 1001478); Metic+
|
|
378
379
|
├── builder-backup/SKILL.md ← /builder-backup — check DB backup status or trigger a fresh local dump before risky changes (migrations, destructive SQL); no SSH required
|
|
379
380
|
├── builder-box/SKILL.md ← /builder-box — show the builder their own dev box (state, IP/host, cost, SSH keys) and help them (re)connect
|
|
380
381
|
├── builder-claim/SKILL.md ← /builder-claim N — atomic claim; prints the claim-time context pack + discipline playbook routing
|
|
@@ -1549,5 +1549,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1549
1549
|
landed since 1.19.548 with no explicit bump. run 33906859934. (task 1002620)
|
|
1550
1550
|
1.19.550 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1551
1551
|
landed since 1.19.549 with no explicit bump. run 33907331050. (task 1002620)
|
|
1552
|
+
1.19.551 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1553
|
+
landed since 1.19.550 with no explicit bump. run 33917261720. (task 1002620)
|
|
1554
|
+
1.19.552 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1555
|
+
landed since 1.19.551 with no explicit bump. run 34043771286. (task 1002620)
|
|
1552
1556
|
---------------------------------------------------------------------------
|
|
1553
1557
|
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- core_232_task_merge_provenance.sql — give a task-triage MERGE verdict
|
|
2
|
+
-- somewhere to record what it decided (task 1001478).
|
|
3
|
+
--
|
|
4
|
+
-- Bugs now land directly as kind=bug tasks with no triage gate (task 1003071),
|
|
5
|
+
-- so near-duplicate bug reports accumulate with nothing to record "this is the
|
|
6
|
+
-- same report as #N". idea_inbox solved the identical problem for ideas in
|
|
7
|
+
-- core_195 (`merged_into_idea_id` + `triage_note`); this is the same shape for
|
|
8
|
+
-- `tasks`, reusing the SAME column names so a reader who knows one surface
|
|
9
|
+
-- already knows the other.
|
|
10
|
+
--
|
|
11
|
+
-- Deliberately NOT a new `tasks.status` value. Adding `'merged'` to the status
|
|
12
|
+
-- CHECK would touch every place that already enumerates task statuses
|
|
13
|
+
-- (VOTABLE_TASK_STATUSES, the claimable feed, dependency satisfaction, the
|
|
14
|
+
-- version-close gate, the diagrams generator's state machine) for a distinction
|
|
15
|
+
-- that is really just "abandoned, and here is which task absorbed it". A merged
|
|
16
|
+
-- task instead reuses the existing terminal `abandoned` status — it already
|
|
17
|
+
-- means "retired, not shippable, dependents on it are stranded by owner ruling"
|
|
18
|
+
-- (modules/lifecycle/CLAUDE.md), which is exactly what a merged-away duplicate
|
|
19
|
+
-- is — and `merged_into_task_id` carries the one thing `abandoned` alone can't:
|
|
20
|
+
-- which task is now the canonical one.
|
|
21
|
+
--
|
|
22
|
+
-- CORE, not modules/lifecycle/migrations/: `tasks` is a CORE table (created
|
|
23
|
+
-- before the module system), and a module migration may only touch its own
|
|
24
|
+
-- <key>_-prefixed objects — fitness.js checkModuleMigrationsAdditive hard-fails
|
|
25
|
+
-- an `ALTER TABLE tasks` from a module (ADR 0083 Decision #5).
|
|
26
|
+
--
|
|
27
|
+
-- Two additive, nullable columns + an index. Forward-safe by construction:
|
|
28
|
+
-- every existing reader SELECTs an explicit column list, so old code on the new
|
|
29
|
+
-- schema neither sees nor needs these (tests/migrations_forward_safe.mjs).
|
|
30
|
+
|
|
31
|
+
BEGIN;
|
|
32
|
+
|
|
33
|
+
ALTER TABLE tasks
|
|
34
|
+
ADD COLUMN IF NOT EXISTS merged_into_task_id bigint REFERENCES tasks(id);
|
|
35
|
+
|
|
36
|
+
ALTER TABLE tasks
|
|
37
|
+
ADD COLUMN IF NOT EXISTS triage_note text;
|
|
38
|
+
|
|
39
|
+
-- A target exists IFF the task is abandoned. Mirrors idea_inbox's
|
|
40
|
+
-- merge_target_needs_merged_status guard (core_195) one status value later:
|
|
41
|
+
-- 'abandoned' is where a normal (non-merge) retirement already lands, so this
|
|
42
|
+
-- CHECK only rules out the shape a merge target with a still-live task behind
|
|
43
|
+
-- it would produce — never a normal abandon, which sets no target at all.
|
|
44
|
+
DO $$
|
|
45
|
+
BEGIN
|
|
46
|
+
IF NOT EXISTS (
|
|
47
|
+
SELECT 1 FROM pg_constraint
|
|
48
|
+
WHERE conrelid = 'tasks'::regclass
|
|
49
|
+
AND conname = 'tasks_merge_target_needs_abandoned_status'
|
|
50
|
+
) THEN
|
|
51
|
+
ALTER TABLE tasks
|
|
52
|
+
ADD CONSTRAINT tasks_merge_target_needs_abandoned_status
|
|
53
|
+
CHECK (merged_into_task_id IS NULL OR status = 'abandoned');
|
|
54
|
+
END IF;
|
|
55
|
+
END $$;
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS tasks_merged_into_task_id_idx
|
|
58
|
+
ON tasks (merged_into_task_id)
|
|
59
|
+
WHERE merged_into_task_id IS NOT NULL;
|
|
60
|
+
|
|
61
|
+
INSERT INTO schema_migrations (version) VALUES ('core_232_task_merge_provenance')
|
|
62
|
+
ON CONFLICT DO NOTHING;
|
|
63
|
+
|
|
64
|
+
COMMIT;
|
|
@@ -848,6 +848,8 @@ function createClient(opts = {}) {
|
|
|
848
848
|
deleteTasksIdDependenciesDepId: (args) => request("DELETE", "/tasks/{id}/dependencies/{depId}", { hasBody: false }, args),
|
|
849
849
|
// POST /tasks/{id}/grade — rank: any-builder — POST /tasks/:id/grade
|
|
850
850
|
postTasksIdGrade: (args) => request("POST", "/tasks/{id}/grade", { hasBody: true }, args),
|
|
851
|
+
// POST /tasks/{id}/merge — rank: metic+archon — POST /tasks/:id/merge
|
|
852
|
+
postTasksIdMerge: (args) => request("POST", "/tasks/{id}/merge", { hasBody: true }, args),
|
|
851
853
|
// POST /tasks/{id}/override-request — rank: any-builder — POST /tasks/:id/override-request
|
|
852
854
|
postTasksIdOverrideRequest: (args) => request("POST", "/tasks/{id}/override-request", { hasBody: true }, args),
|
|
853
855
|
// POST /tasks/{id}/promote — rank: metic+archon — POST /tasks/:id/promote
|
|
@@ -137,6 +137,7 @@ const PERMISSIONS = [
|
|
|
137
137
|
{ key: 'task.promote', system: false, floor: 'metic', guards: 'POST /tasks/:id/promote' },
|
|
138
138
|
{ key: 'task.demote', system: false, floor: 'metic', guards: 'POST /tasks/:id/demote' },
|
|
139
139
|
{ key: 'task.abandon', system: false, floor: 'metic', guards: 'POST /tasks/:id/abandon — scope removal; undo = POST /tasks/:id/promote (ADR 0152, was archon)' },
|
|
140
|
+
{ key: 'task.merge', system: false, floor: 'metic', guards: 'POST /tasks/:id/merge — mark a duplicate task merged into another (task 1001478, the idea.triage merge analogue)' },
|
|
140
141
|
{ key: 'task.vote', system: false, floor: 'metic', guards: 'POST /tasks/:id/vote' },
|
|
141
142
|
{ key: 'task.dependency.manage', system: false, floor: 'metic', guards: 'POST|PUT|DELETE /tasks/:id/dependencies (same-tier)' },
|
|
142
143
|
{ key: 'task.criterion.manage', system: false, floor: 'metic', guards: 'POST|DELETE /tasks/:id/criteria' },
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- government_011_task_merge_metic.sql — grant the new `task.merge` atom to the
|
|
2
|
+
-- Metic and Archon rank-roles (task 1001478).
|
|
3
|
+
--
|
|
4
|
+
-- A BRAND-NEW key: no rank holds it yet, so BOTH rungs the floor implies are
|
|
5
|
+
-- granted here. RANK_SEED is a cumulative superset — floor `metic` means
|
|
6
|
+
-- {metic, archon} — and the drift guard (tests/government_seed.mjs) compares
|
|
7
|
+
-- the UNION of the run-once seed plus every later grant migration against
|
|
8
|
+
-- that floor-derived seed, per rank. Minting the catalog row WITHOUT this
|
|
9
|
+
-- migration is exactly what that guard catches.
|
|
10
|
+
--
|
|
11
|
+
-- Mirrors government_010_project_settings_manage.sql, NOT a `governance_*`
|
|
12
|
+
-- file — those predate government_001_rename_from_governance, which renamed
|
|
13
|
+
-- `governance_role_permissions` -> `government_rank_permissions` and
|
|
14
|
+
-- `role_key` -> `rank_key` with no compatibility shim.
|
|
15
|
+
--
|
|
16
|
+
-- Additive only: one new permission key granted to two ranks; nothing is
|
|
17
|
+
-- revoked. Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
|
|
18
|
+
--
|
|
19
|
+
-- One block PER RANK, in the government_004/009/010 shape: the drift guard
|
|
20
|
+
-- scans for `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
|
|
21
|
+
-- `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants and
|
|
22
|
+
-- reds it.
|
|
23
|
+
|
|
24
|
+
BEGIN;
|
|
25
|
+
|
|
26
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
27
|
+
SELECT 'metic', unnest(ARRAY[
|
|
28
|
+
'task.merge'
|
|
29
|
+
])
|
|
30
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
31
|
+
|
|
32
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
33
|
+
SELECT 'archon', unnest(ARRAY[
|
|
34
|
+
'task.merge'
|
|
35
|
+
])
|
|
36
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
37
|
+
|
|
38
|
+
COMMIT;
|
|
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
|
|
|
14
14
|
const { pool } = api;
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
16
|
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
17
|
+
const { nonTerminalSql } = require('./task-status');
|
|
17
18
|
|
|
18
19
|
// -------------------------------------------------------------------------
|
|
19
20
|
// Goals (ADR 0086 §2 / BV1.R56 — task 1510)
|
|
@@ -328,7 +329,7 @@ async function listCriteriaForGoal(goalId, deps = {}) {
|
|
|
328
329
|
-- task 1002651 (migration 017's family): abandoned is a terminal
|
|
329
330
|
-- state (deliberately cut), not pending work — it must not hold a
|
|
330
331
|
-- criterion's "met — pending review" flip hostage forever.
|
|
331
|
-
count(*) FILTER (WHERE
|
|
332
|
+
count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks
|
|
332
333
|
FROM task_criteria tc
|
|
333
334
|
JOIN tasks t ON t.id = tc.task_id
|
|
334
335
|
WHERE tc.criterion_id IN (SELECT id FROM crit)
|
|
@@ -963,9 +963,22 @@ async function restoreTask({ taskId }, deps = {}) {
|
|
|
963
963
|
throw Object.assign(new Error('task is not abandoned'), { code: 'TASK_NOT_ABANDONED' });
|
|
964
964
|
}
|
|
965
965
|
|
|
966
|
+
// Clearing merged_into_task_id/triage_note here is not cosmetic — it is
|
|
967
|
+
// what keeps this UPDATE legal. core_232's CHECK is
|
|
968
|
+
// `merged_into_task_id IS NULL OR status = 'abandoned'`, and this UPDATE
|
|
969
|
+
// sets status = 'backlog', so a restore that left a stale target would
|
|
970
|
+
// 23514 (constraint violation) on exactly the merged tasks this route
|
|
971
|
+
// exists to un-merge — mergeTask's own header names "retarget by
|
|
972
|
+
// restoring first" as the intended path, so restore must actually work
|
|
973
|
+
// on one (task 1001478 grade finding). value_summary is left alone here
|
|
974
|
+
// beyond the existing 'Abandoned: %' stamp: mergeTask PRESERVES whatever
|
|
975
|
+
// value_summary a task already had unless a human reason overwrote it, so
|
|
976
|
+
// there is no mechanical merge-default text to clean up on the way back.
|
|
966
977
|
const { rows: updated } = await client.query(
|
|
967
978
|
`UPDATE tasks
|
|
968
979
|
SET status = 'backlog',
|
|
980
|
+
merged_into_task_id = NULL,
|
|
981
|
+
triage_note = NULL,
|
|
969
982
|
value_summary = CASE WHEN value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END,
|
|
970
983
|
updated_at = now()
|
|
971
984
|
WHERE id = $1
|
|
@@ -988,7 +1001,141 @@ async function restoreTask({ taskId }, deps = {}) {
|
|
|
988
1001
|
}
|
|
989
1002
|
}
|
|
990
1003
|
|
|
1004
|
+
// mergeTask (task 1001478) — the idea_inbox merge verdict's twin for `tasks`,
|
|
1005
|
+
// so /bug-triage can fold a near-duplicate kind=bug report into its canonical
|
|
1006
|
+
// task the same way /idea-triage folds a duplicate idea (modules/ideas/inbox.js
|
|
1007
|
+
// updateIdea's MERGE_SELF/MERGE_TARGET_NOT_FOUND/MERGE_TARGET_MERGED guards).
|
|
1008
|
+
//
|
|
1009
|
+
// A merge is `abandonTask` with a target attached, not a new status: 'merged'
|
|
1010
|
+
// would need its own line in every place that already enumerates task statuses
|
|
1011
|
+
// (VOTABLE_TASK_STATUSES, the claimable feed, dependency satisfaction, the
|
|
1012
|
+
// diagrams state machine) to answer a question 'abandoned' already answers —
|
|
1013
|
+
// this task is retired, its dependents are stranded by owner ruling
|
|
1014
|
+
// (see this file's CLAUDE.md). `merged_into_task_id` (core_232) carries the one
|
|
1015
|
+
// thing bare abandon can't: which task absorbed it.
|
|
1016
|
+
//
|
|
1017
|
+
// Guards, checked in this order (mirrors abandonTask, then layers the merge
|
|
1018
|
+
// checks on top):
|
|
1019
|
+
// - MERGE_SELF — a task cannot merge into itself.
|
|
1020
|
+
// - TASK_NOT_FOUND — the task being merged away doesn't exist.
|
|
1021
|
+
// - CANNOT_MERGE_SHIPPED — shipped work is immutable history (abandonTask's
|
|
1022
|
+
// CANNOT_ABANDON_SHIPPED, same reasoning).
|
|
1023
|
+
// - already merged, SAME target → idempotent no-op (alreadyMerged:true).
|
|
1024
|
+
// - already merged, DIFFERENT target → ALREADY_MERGED (no re-merge chains —
|
|
1025
|
+
// retarget by restoring first, the abandon/restore precedent, not a second
|
|
1026
|
+
// merge call silently overwriting provenance).
|
|
1027
|
+
// - TASK_HAS_ACTIVE_CLAIM — a live claim means a session still owns the
|
|
1028
|
+
// work; release it first (same as abandonTask).
|
|
1029
|
+
// - MERGE_TARGET_NOT_FOUND — the canonical task doesn't exist.
|
|
1030
|
+
// - MERGE_TARGET_MERGED — the target is ITSELF merged elsewhere (tests the
|
|
1031
|
+
// target's own `merged_into_task_id` column, not
|
|
1032
|
+
// its status — the idea precedent's "no chains"
|
|
1033
|
+
// check reads the column because a status-based
|
|
1034
|
+
// check can be raced by an ordinary PATCH).
|
|
1035
|
+
//
|
|
1036
|
+
// The target's own status is deliberately UNCHECKED beyond that — a duplicate
|
|
1037
|
+
// bug report can legitimately point at a task that already shipped (the fix
|
|
1038
|
+
// landed before the dup was noticed), mirroring idea merge's target-status
|
|
1039
|
+
// silence.
|
|
1040
|
+
//
|
|
1041
|
+
// #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
|
|
1042
|
+
// (matches abandonTask/restoreTask).
|
|
1043
|
+
async function mergeTask({ taskId, intoTaskId, reason }, deps = {}) {
|
|
1044
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
1045
|
+
const client = await activePool.connect();
|
|
1046
|
+
try {
|
|
1047
|
+
await client.query('BEGIN');
|
|
1048
|
+
|
|
1049
|
+
if (String(intoTaskId) === String(taskId)) {
|
|
1050
|
+
throw Object.assign(new Error('a task cannot merge into itself'), { code: 'MERGE_SELF' });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const { rows: taskRows } = await client.query(
|
|
1054
|
+
`SELECT id, title, status, merged_into_task_id FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
1055
|
+
[taskId]
|
|
1056
|
+
);
|
|
1057
|
+
if (taskRows.length === 0) {
|
|
1058
|
+
throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
|
|
1059
|
+
}
|
|
1060
|
+
const task = taskRows[0];
|
|
1061
|
+
if (task.status === 'shipped') {
|
|
1062
|
+
throw Object.assign(new Error('cannot merge a shipped task'), { code: 'CANNOT_MERGE_SHIPPED' });
|
|
1063
|
+
}
|
|
1064
|
+
if (task.merged_into_task_id != null) {
|
|
1065
|
+
if (String(task.merged_into_task_id) === String(intoTaskId)) {
|
|
1066
|
+
await client.query('COMMIT');
|
|
1067
|
+
return {
|
|
1068
|
+
taskId, taskTitle: task.title, taskStatus: 'abandoned',
|
|
1069
|
+
mergedIntoTaskId: Number(task.merged_into_task_id), alreadyMerged: true,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
throw Object.assign(
|
|
1073
|
+
new Error('task is already merged into a different task'),
|
|
1074
|
+
{ code: 'ALREADY_MERGED', merged_into: String(task.merged_into_task_id) }
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const { rows: activeClaims } = await client.query(
|
|
1079
|
+
`SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`,
|
|
1080
|
+
[taskId]
|
|
1081
|
+
);
|
|
1082
|
+
if (activeClaims.length > 0) {
|
|
1083
|
+
throw Object.assign(new Error('task has an active claim; release it first'), { code: 'TASK_HAS_ACTIVE_CLAIM' });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const { rows: targetRows } = await client.query(
|
|
1087
|
+
`SELECT id, merged_into_task_id FROM tasks WHERE id = $1`,
|
|
1088
|
+
[intoTaskId]
|
|
1089
|
+
);
|
|
1090
|
+
if (targetRows.length === 0) {
|
|
1091
|
+
throw Object.assign(new Error('merge target task not found'), { code: 'MERGE_TARGET_NOT_FOUND' });
|
|
1092
|
+
}
|
|
1093
|
+
if (targetRows[0].merged_into_task_id != null) {
|
|
1094
|
+
throw Object.assign(
|
|
1095
|
+
new Error('merge target is itself merged into another task'),
|
|
1096
|
+
{ code: 'MERGE_TARGET_MERGED', merged_into: String(targetRows[0].merged_into_task_id) }
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// value_summary PRESERVES on an empty reason, exactly like abandonTask's
|
|
1101
|
+
// `COALESCE(NULLIF($2,''), value_summary)` — not a synthesized "Merged
|
|
1102
|
+
// into #N" default. A `completed`/`confirmed` bug task can already carry
|
|
1103
|
+
// a real value_summary (its own shipped-value sentence), and merging it
|
|
1104
|
+
// with no explicit reason must not clobber that; `merged_into_task_id`
|
|
1105
|
+
// is the queryable source of truth for where it went (grade finding,
|
|
1106
|
+
// task 1001478 round 2).
|
|
1107
|
+
const noteText = typeof reason === 'string' ? reason.trim().slice(0, 2000) : '';
|
|
1108
|
+
const { rows: updated } = await client.query(
|
|
1109
|
+
`UPDATE tasks
|
|
1110
|
+
SET status = 'abandoned',
|
|
1111
|
+
merged_into_task_id = $2,
|
|
1112
|
+
triage_note = NULLIF($3, ''),
|
|
1113
|
+
value_summary = COALESCE(NULLIF($3, ''), value_summary),
|
|
1114
|
+
updated_at = now()
|
|
1115
|
+
WHERE id = $1
|
|
1116
|
+
RETURNING id, title, status`,
|
|
1117
|
+
[taskId, intoTaskId, noteText]
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
await client.query('COMMIT');
|
|
1121
|
+
return {
|
|
1122
|
+
taskId,
|
|
1123
|
+
taskTitle: updated[0].title,
|
|
1124
|
+
taskStatus: 'abandoned',
|
|
1125
|
+
previousStatus: task.status,
|
|
1126
|
+
mergedIntoTaskId: Number(intoTaskId),
|
|
1127
|
+
alreadyMerged: false,
|
|
1128
|
+
};
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
1131
|
+
throw err;
|
|
1132
|
+
} finally {
|
|
1133
|
+
client.release();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
991
1137
|
module.exports = {
|
|
1138
|
+
mergeTask,
|
|
992
1139
|
abandonTask,
|
|
993
1140
|
confirmTask,
|
|
994
1141
|
flagTaskNeedsRebase,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -173,6 +173,7 @@ const {
|
|
|
173
173
|
getBuildersRoster,
|
|
174
174
|
getClaimPublishedSha,
|
|
175
175
|
listConfirmedTasksForReconcile,
|
|
176
|
+
mergeTask,
|
|
176
177
|
needsRebaseTasksForBuilder,
|
|
177
178
|
releaseClaimOnBehalf,
|
|
178
179
|
resolveClaim,
|
|
@@ -320,6 +321,7 @@ module.exports = {
|
|
|
320
321
|
confirmTask,
|
|
321
322
|
abandonTask,
|
|
322
323
|
restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
|
|
324
|
+
mergeTask, // task 1001478 — the idea_inbox merge verdict's twin for tasks (/bug-triage)
|
|
323
325
|
applyGrade,
|
|
324
326
|
shipTask,
|
|
325
327
|
normalizeShipAssurance, SHIP_ASSURANCE_KINDS, // ADR 0184 — re-exported from ./ship-assurance
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// POST /api/gds/done-when/:criterionId/unsatisfy — auth
|
|
25
25
|
|
|
26
26
|
const { pool } = require('../../src/module-api');
|
|
27
|
+
const { nonTerminalSql } = require('./task-status');
|
|
27
28
|
|
|
28
29
|
// isPendingReview — the ONE derivation of the "met — pending review" auto-flag
|
|
29
30
|
// (ADR 0086 §6 / BV1.R63): a criterion with >=1 linked task, every one shipped,
|
|
@@ -134,7 +135,7 @@ async function listPendingReviewCriteria({ versionId = null } = {}, deps = {}) {
|
|
|
134
135
|
JOIN tasks t ON t.id = tc.task_id
|
|
135
136
|
WHERE c.satisfied = false
|
|
136
137
|
GROUP BY c.id
|
|
137
|
-
HAVING count(*) FILTER (WHERE
|
|
138
|
+
HAVING count(*) FILTER (WHERE ${nonTerminalSql()}) = 0
|
|
138
139
|
),
|
|
139
140
|
goal_unsat AS (
|
|
140
141
|
SELECT goal_id, count(*)::int AS unsatisfied
|
|
@@ -426,7 +427,7 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
|
|
|
426
427
|
AND ($1::bigint IS NULL OR c.id IN (
|
|
427
428
|
SELECT criterion_id FROM task_criteria WHERE task_id = $1::bigint))
|
|
428
429
|
GROUP BY c.id
|
|
429
|
-
HAVING count(*) FILTER (WHERE
|
|
430
|
+
HAVING count(*) FILTER (WHERE ${nonTerminalSql()}) = 0
|
|
430
431
|
AND count(*) FILTER (WHERE t.status = 'shipped') >= 1
|
|
431
432
|
)
|
|
432
433
|
UPDATE done_when_criteria c
|
|
@@ -32,10 +32,12 @@
|
|
|
32
32
|
const api = require('../../src/module-api');
|
|
33
33
|
const { pool } = api;
|
|
34
34
|
|
|
35
|
-
// The status vocabularies
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// The status vocabularies live in task-status.js (task 1003588) so this module
|
|
36
|
+
// has ONE spelling of each. These two are the PRESENTATIONAL buckets — they
|
|
37
|
+
// mirror goals-lib.js `readinessOf`, and they are NOT the terminal set: `done`
|
|
38
|
+
// here includes the mid-ship states on purpose, which is right for a progress
|
|
39
|
+
// ring and wrong for any question about whether a goal may close.
|
|
40
|
+
const { DONE_STATUSES, CUT_STATUSES } = require('./task-status');
|
|
39
41
|
|
|
40
42
|
// goalRollups — one row per goal that has at least one task in `versionId`.
|
|
41
43
|
// Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
|
|
@@ -664,6 +664,48 @@ module.exports = function buildTasksRouter() {
|
|
|
664
664
|
}
|
|
665
665
|
});
|
|
666
666
|
|
|
667
|
+
// POST /tasks/:id/merge — mark a duplicate task merged into its canonical one
|
|
668
|
+
// (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict).
|
|
669
|
+
// Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather
|
|
670
|
+
// than a new `tasks.status` value — see that function's header for why. Refuses
|
|
671
|
+
// a self-merge, a shipped task, a task with an active claim, a target that
|
|
672
|
+
// doesn't exist, and a target that is itself merged elsewhere (no chains);
|
|
673
|
+
// idempotent when re-merging into the SAME target already recorded.
|
|
674
|
+
// rank: metic+archon — same floor as task.abandon, which this reuses under the
|
|
675
|
+
// hood; a Metic who may retire a task may also say which task it duplicates.
|
|
676
|
+
router.post('/tasks/:id/merge', auth.requireBuilder, auth.requirePermission('task.merge'), async (req, res) => {
|
|
677
|
+
if (validateOrRespond(req, res, {
|
|
678
|
+
into_task_id: { required: true, type: 'integer', min: 1 },
|
|
679
|
+
reason: { type: 'string' }, // free-text triage note (sliced to 2000 below)
|
|
680
|
+
})) return;
|
|
681
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
682
|
+
if (id === null) return;
|
|
683
|
+
const intoTaskId = req.body.into_task_id;
|
|
684
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
685
|
+
try {
|
|
686
|
+
const result = await db.mergeTask({ taskId: id, intoTaskId, reason });
|
|
687
|
+
res.json({ ok: true, ...result });
|
|
688
|
+
} catch (err) {
|
|
689
|
+
if (err.code === 'MERGE_SELF') return res.fail('merge_self', { status: 400, message: 'a task cannot merge into itself' });
|
|
690
|
+
if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
691
|
+
if (err.code === 'CANNOT_MERGE_SHIPPED') {
|
|
692
|
+
return res.fail('cannot_merge_shipped', { status: 409, message: 'shipped tasks are immutable history' });
|
|
693
|
+
}
|
|
694
|
+
if (err.code === 'ALREADY_MERGED') {
|
|
695
|
+
return res.fail('already_merged', { status: 409, message: `already merged into #${err.merged_into}`, merged_into: err.merged_into });
|
|
696
|
+
}
|
|
697
|
+
if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
698
|
+
return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before merging' });
|
|
699
|
+
}
|
|
700
|
+
if (err.code === 'MERGE_TARGET_NOT_FOUND') return res.fail('merge_target_not_found', 404);
|
|
701
|
+
if (err.code === 'MERGE_TARGET_MERGED') {
|
|
702
|
+
return res.fail('merge_target_merged', { status: 409, message: `merge target is itself merged into #${err.merged_into}`, merged_into: err.merged_into });
|
|
703
|
+
}
|
|
704
|
+
log.error('[gds] POST /tasks/:id/merge', err);
|
|
705
|
+
res.fail('merge_failed', { status: 500, message: 'internal error' });
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
667
709
|
// ==== peer votes (V3.R25 #245) — criterion C2 karma signal sourcing =======
|
|
668
710
|
// These three sit BEFORE the generic /tasks/:id-shaped routes that take a
|
|
669
711
|
// numeric id, but Express keys on the literal path so /tasks/peer-votes/feed
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/task-status.js — the task-status vocabulary, declared once
|
|
4
|
+
// (task 1003588, goal 1000086, criterion sv-goal-outlives-nothing).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS FILE EXISTS. "Finished" had two spellings inside one module.
|
|
7
|
+
// `goal-rollup.js` counted shipped|confirmed|completed as done; `done-when.js`
|
|
8
|
+
// counted only shipped and treated abandoned as accounted-for; `db-goals.js`,
|
|
9
|
+
// `version-close.js` and the `sequence` CLI each re-typed the SQL literal
|
|
10
|
+
// `NOT IN ('shipped', 'abandoned')` by hand. Two definitions of finished in one
|
|
11
|
+
// module is precisely what lets a goal read closed on one surface and open on
|
|
12
|
+
// another — the failure goal 1000086 exists to make impossible. So the
|
|
13
|
+
// vocabulary is declared here, once, and every consumer reads it.
|
|
14
|
+
//
|
|
15
|
+
// THE TWO SETS ARE NOT THE SAME QUESTION, and conflating them is the bug in the
|
|
16
|
+
// other direction:
|
|
17
|
+
//
|
|
18
|
+
// TERMINAL_STATUSES — the LEDGER truth. A task is finished with the project
|
|
19
|
+
// when it shipped or was deliberately abandoned. Nothing else is terminal:
|
|
20
|
+
// `completed` and `confirmed` are mid-ship states that can still fail a
|
|
21
|
+
// grade or strand, so work sitting in them is unfinished work and must hold
|
|
22
|
+
// its goal open. This is the set the closure invariants read.
|
|
23
|
+
//
|
|
24
|
+
// DONE_STATUSES / CUT_STATUSES — a PRESENTATIONAL bucketing for the goals
|
|
25
|
+
// board, mirroring the client's `goals-lib.js readinessOf` verbatim (a
|
|
26
|
+
// divergence shows a person different numbers per surface, which
|
|
27
|
+
// tests/goal_sort_staleness.mjs pins). "Done" there means "no longer in the
|
|
28
|
+
// queue", which deliberately includes the mid-ship states. It is a progress
|
|
29
|
+
// ring, not a ledger, and must never be used to decide whether a goal may
|
|
30
|
+
// close.
|
|
31
|
+
//
|
|
32
|
+
// NO DEPENDENCIES, deliberately. This file requires nothing — not even
|
|
33
|
+
// `module-api` — so a CLI (`scripts/gds/sequence.js`) can read the vocabulary
|
|
34
|
+
// without dragging the server's `db.js` and its connection pool into a process
|
|
35
|
+
// that only needs a Set of two strings.
|
|
36
|
+
|
|
37
|
+
// TERMINAL_STATUSES — the only spelling of "this task is finished with the
|
|
38
|
+
// project". shipped = delivered; abandoned = deliberately cut (task 1002651,
|
|
39
|
+
// migration 017's family). Everything else holds work open.
|
|
40
|
+
const TERMINAL_STATUSES = ['shipped', 'abandoned'];
|
|
41
|
+
|
|
42
|
+
// TERMINAL_STATUS_SET — the same set for JS membership tests.
|
|
43
|
+
const TERMINAL_STATUS_SET = new Set(TERMINAL_STATUSES);
|
|
44
|
+
|
|
45
|
+
// isTerminalStatus — true when a task in this status is finished with the
|
|
46
|
+
// project and can no longer hold a criterion, a goal, or a version open.
|
|
47
|
+
function isTerminalStatus(status) {
|
|
48
|
+
return TERMINAL_STATUS_SET.has(status);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// nonTerminalSql — the SQL predicate for "this task is still unfinished",
|
|
52
|
+
// rendered from TERMINAL_STATUSES so the literal exists in exactly one place.
|
|
53
|
+
// `column` is the status expression in the caller's query (`t.status` under a
|
|
54
|
+
// join, bare `status` in a single-table read). Output is the byte-identical
|
|
55
|
+
// `<column> NOT IN ('shipped', 'abandoned')` every call site used to hardcode.
|
|
56
|
+
function nonTerminalSql(column = 't.status') {
|
|
57
|
+
return `${column} NOT IN (${TERMINAL_STATUSES.map((s) => `'${s}'`).join(', ')})`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// DONE_STATUSES / CUT_STATUSES — the goals-board buckets. Presentational only;
|
|
61
|
+
// see the header. Mirrors goals-lib.js `readinessOf`.
|
|
62
|
+
const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
|
|
63
|
+
const CUT_STATUSES = ['abandoned', 'dropped'];
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
TERMINAL_STATUSES,
|
|
67
|
+
TERMINAL_STATUS_SET,
|
|
68
|
+
isTerminalStatus,
|
|
69
|
+
nonTerminalSql,
|
|
70
|
+
DONE_STATUSES,
|
|
71
|
+
CUT_STATUSES,
|
|
72
|
+
};
|