@bongos/core 1.19.550 → 1.19.551
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 +57 -27
- 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 +2 -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-ship.js +147 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/tasks.js +42 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-triage.js +292 -0
- package/scripts/gds/triage.js +40 -21
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +17 -0
- package/tests/bug_triage.mjs +205 -0
- package/tests/task_merge.mjs +198 -0
|
@@ -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;
|
|
@@ -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
|
|
@@ -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
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.551",
|
|
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.551",
|
|
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.551",
|
|
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",
|