install-19.mjs renames 57 done, 159 todo, 161 in-progress, 162 blocked, adds 248 in-review and moves the default to 159; no task moved. Writes go to /api/v1 (v2.7.0 405s bucket writes under /api/v2). create-33.mjs adds #35 and #36 under #33. Records hold no secret; shares-read titles removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
97 lines
7.1 KiB
JavaScript
97 lines
7.1 KiB
JavaScript
// Sage, 2026-10-11: Q61a. The pm key installs the adapter's five buckets on #19's kanban (view 76) by
|
|
// renaming the populated buckets whose meaning is plain, adding in-review, and moving the default to
|
|
// todo. Every task stays in its bucket; nothing is deleted. The board must still match the
|
|
// before-state file, or the script refuses before any write. Each write is logged with its inverse
|
|
// as it happens, so a partial run leaves a rollback list. The sync key does every read, as the
|
|
// adapter's install check does; the pm key only writes. Prints no secret.
|
|
// Usage: node install-19.mjs https://tasks.woltje.com PM_TOKEN_FILE SYNC_TOKEN_FILE BEFORE_JSON OUT_JSON
|
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
const [ORIGIN, KEY, SYNC, BEFORE, OUT] = process.argv.slice(2);
|
|
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
|
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
|
// v2.7.0 serves the reads under /api/v2 and the bucket and view writes under /api/v1 (GET /api/v2/routes).
|
|
const BASE = ORIGIN + "/api/v2", WRITE = ORIGIN + "/api/v1", P = 19, V = 76;
|
|
const RENAME = { 159: ["To Do", "todo"], 161: ["In Progress", "in-progress"], 162: ["Blocked", "blocked"], 57: ["Done", "done"] };
|
|
const ADD = { title: "in-review", after: 161, before: 162 };
|
|
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
|
const before = JSON.parse(readFileSync(BEFORE, "utf8"));
|
|
async function api(method, path, body, auth = key) {
|
|
const r = await fetch((method === "GET" ? BASE : WRITE) + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
|
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
|
return { status: r.status, json };
|
|
}
|
|
const must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; };
|
|
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
|
async function board() {
|
|
const view = list(must(await api("GET", `/projects/${P}/views?per_page=50`, undefined, sync), "views")).find((v) => v.id === V);
|
|
if (!view) throw new Error("view 76 not listed");
|
|
const buckets = list(must(await api("GET", `/projects/${P}/views/${V}/buckets?per_page=50`, undefined, sync), "buckets"));
|
|
const tasks = Object.fromEntries(buckets.map((b) => [b.id, []]));
|
|
for (let page = 1; page < 100; page++) {
|
|
let more = false;
|
|
for (const b of list(must(await api("GET", `/projects/${P}/views/${V}/buckets/tasks?per_page=50&page=${page}`, undefined, sync), "board"))) {
|
|
(tasks[b.id] ??= []).push(...(b.tasks ?? []).map((t) => t.id));
|
|
if (Number.isSafeInteger(b.count) ? b.count > page * 50 : (b.tasks ?? []).length >= 50) more = true;
|
|
}
|
|
if (!more) break;
|
|
}
|
|
for (const id of Object.keys(tasks)) tasks[id].sort((a, b) => a - b);
|
|
return { view, buckets, tasks };
|
|
}
|
|
const rec = { at: new Date().toISOString(), project: P, view: V, writes: [], problems: [] };
|
|
const save = () => {
|
|
const out = JSON.stringify(rec, null, 1) + "\n";
|
|
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
|
writeFileSync(OUT, out);
|
|
};
|
|
|
|
// Precondition: the board is the before-state board, bucket for bucket and task for task.
|
|
const kb = before.views.find((v) => v.id === V);
|
|
const now = await board();
|
|
const was = Object.fromEntries(kb.buckets.map((b) => [b.id, { title: b.title, tasks: [...b.tasks].sort((a, c) => a - c) }]));
|
|
const is = Object.fromEntries(now.buckets.map((b) => [b.id, { title: b.title, tasks: now.tasks[b.id] ?? [] }]));
|
|
if (JSON.stringify(was) !== JSON.stringify(is)) throw new Error("board differs from the before-state; refusing");
|
|
if (now.view.done_bucket_id !== 57 || now.view.default_bucket_id !== 55 || now.view.bucket_configuration_mode !== "manual") throw new Error("view 76 differs from the before-state; refusing");
|
|
writeFileSync(OUT, "", { flag: "wx" });
|
|
|
|
try {
|
|
for (const [id, [from, to]] of Object.entries(RENAME)) {
|
|
const b = now.buckets.find((x) => x.id === Number(id));
|
|
if (b.title !== from) throw new Error(`bucket ${id} is ${JSON.stringify(b.title)}, not ${JSON.stringify(from)}`);
|
|
rec.writes.push({ op: "rename bucket", id: b.id, from, to, inverse: `POST /api/v1/projects/${P}/views/${V}/buckets/${b.id} title ${JSON.stringify(from)}` });
|
|
save();
|
|
const r = must(await api("POST", `/projects/${P}/views/${V}/buckets/${b.id}`, { ...b, title: to }), `rename ${id}`);
|
|
rec.writes.at(-1).result = { title: r.title, position: r.position, limit: r.limit };
|
|
if (r.title !== to || r.position !== b.position || r.limit !== b.limit) rec.problems.push(`bucket ${id} came back ${JSON.stringify(rec.writes.at(-1).result)}`);
|
|
}
|
|
const pos = (id) => now.buckets.find((x) => x.id === id).position;
|
|
const position = (pos(ADD.after) + pos(ADD.before)) / 2;
|
|
rec.writes.push({ op: "add bucket", title: ADD.title, position });
|
|
save();
|
|
const added = must(await api("PUT", `/projects/${P}/views/${V}/buckets`, { title: ADD.title, position, project_view_id: V }), "add in-review");
|
|
Object.assign(rec.writes.at(-1), { id: added.id, inverse: `Jason deletes bucket ${added.id} (no bot holds bucket delete)` });
|
|
const view = now.view;
|
|
rec.writes.push({ op: "view default bucket", from: view.default_bucket_id, to: 159, inverse: `POST /api/v1/projects/${P}/views/${V} default_bucket_id ${view.default_bucket_id}` });
|
|
save();
|
|
const v = must(await api("POST", `/projects/${P}/views/${V}`, { ...view, default_bucket_id: 159 }), "view default");
|
|
rec.writes.at(-1).result = { default: v.default_bucket_id, done: v.done_bucket_id, mode: v.bucket_configuration_mode, filter: v.filter ?? null };
|
|
} catch (e) {
|
|
rec.problems.push(`stopped: ${e.message}`);
|
|
save();
|
|
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read back: the adapter's install rule, and every task still in the bucket it started in.
|
|
const after = await board();
|
|
const titles = ["todo", "in-progress", "in-review", "blocked", "done"];
|
|
const ids = Object.fromEntries(titles.map((t) => [t, after.buckets.filter((b) => b.title === t).map((b) => b.id)]));
|
|
rec.after = { done: after.view.done_bucket_id, default: after.view.default_bucket_id, mode: after.view.bucket_configuration_mode, filter: after.view.filter ?? null, buckets: after.buckets.map((b) => ({ id: b.id, title: b.title, position: b.position, tasks: (after.tasks[b.id] ?? []).length })) };
|
|
for (const t of titles) if (ids[t].length !== 1) rec.problems.push(`${t} occurs ${ids[t].length} times`);
|
|
if (after.view.done_bucket_id !== ids.done[0] || after.view.default_bucket_id !== ids.todo[0]) rec.problems.push("done or default not wired to done and todo");
|
|
if (JSON.stringify(after.view.filter ?? null) !== JSON.stringify(now.view.filter ?? null)) rec.problems.push("view filter changed");
|
|
for (const [id, w] of Object.entries(was)) if (JSON.stringify(after.tasks[id] ?? []) !== JSON.stringify(w.tasks)) rec.problems.push(`bucket ${id} tasks changed`);
|
|
save();
|
|
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
|
if (rec.problems.length) process.exit(2);
|