Files
dsh-superagent/scripts/gitea-kanban-sync.mjs
T

148 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// gitea-kanban-sync.mjs — sync Gitea issue labels to kanban card columns (direct database write).
// Why: open-source Gitea 1.25.x-1.27.x expose NO Projects REST API; those endpoints land in 1.28.0
// (PR #38691 on main). Until then this script is the write path; switch to the official API afterwards
// and retire this file.
// Usage:
// node scripts/gitea-kanban-sync.mjs 12 13 [--repo owner/name] [--project N] [--to COLUMN_TITLE] [--all] [--dry-run]
// --all sweep every open issue of the repo (add missing cards, move label-matched ones)
// --to force the target column title for the listed issues (works on closed issues too)
// Defaults (overridable per run or via environment):
// GITEA_KANBAN_REPO owner/name this deployment syncs; required unless --repo is passed
// GITEA_KANBAN_PROJECT project id, default 1
// GITEA_KANBAN_DEFAULT_COL title of the board's default column, default "Uncategorized";
// deployments often rename it, and --to never resolves through it, so it is only required
// when no --to is given
// Database: GITEA_DB_HOST (default 127.0.0.1), GITEA_DB_PORT (3306), GITEA_DB_USER (required),
// GITEA_DB_NAME (gitea). Password: GITEA_DB_PASSWORD, else a sibling gitea-db.pass file next to
// this script. Never commit credentials.
// Output: one JSON line per issue, then a summary JSON line (processed/created/moved/kept/skipped).
// Exit 0 on success, 1 on failure. Convention in this deployment: column titles mirror label names;
// the agent changes labels through the API first, then runs this script to move the cards.
import mysql from 'mysql2/promise';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const HERE = path.dirname(path.resolve(process.argv[1] || process.execPath));
function readPassword() {
if (process.env.GITEA_DB_PASSWORD) return process.env.GITEA_DB_PASSWORD.trim();
const f = path.join(HERE, 'gitea-db.pass');
if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8').trim();
throw new Error('no DB password: set GITEA_DB_PASSWORD or create ' + f);
}
const args = process.argv.slice(2);
const opt = { repo: process.env.GITEA_KANBAN_REPO || '', project: Number(process.env.GITEA_KANBAN_PROJECT || 1), to: null, all: false, dry: false, issues: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--repo') opt.repo = args[++i];
else if (a === '--project') opt.project = Number(args[++i]);
else if (a === '--to') opt.to = args[++i];
else if (a === '--all') opt.all = true;
else if (a === '--dry-run') opt.dry = true;
else if (a === '-h' || a === '--help') { console.log('see header comment'); process.exit(0); }
else if (/^#?\d+$/.test(a)) opt.issues.push(Number(a.replace('#', '')));
else { console.error('unknown argument: ' + a); process.exit(1); }
}
const m = /^([^/\s]+)\/([^/\s]+)$/.exec(opt.repo);
if (!m) { console.error('no repo: pass --repo owner/name or set GITEA_KANBAN_REPO'); process.exit(1); }
if (!opt.all && opt.issues.length === 0) { console.error('nothing to do: pass issue numbers or --all'); process.exit(1); }
if (!process.env.GITEA_DB_USER) { console.error('set GITEA_DB_USER for the Gitea database account'); process.exit(1); }
const conn = await mysql.createConnection({
host: process.env.GITEA_DB_HOST || '127.0.0.1',
port: Number(process.env.GITEA_DB_PORT || 3306),
user: process.env.GITEA_DB_USER,
password: readPassword(),
database: process.env.GITEA_DB_NAME || 'gitea',
});
const out = (o) => console.log(JSON.stringify(o));
try {
const [pj] = await conn.query('SELECT id, title FROM project WHERE id = ?', [opt.project]);
if (!pj.length) throw new Error('project ' + opt.project + ' not found');
const [bs] = await conn.query('SELECT id, title FROM project_board WHERE project_id = ? ORDER BY sorting', [opt.project]);
const colId = new Map(bs.map((b) => [b.title, b.id]));
const colOrder = bs.map((b) => b.title);
const boardTitle = new Map(bs.map((b) => [b.id, b.title]));
const DEFAULT_COL = process.env.GITEA_KANBAN_DEFAULT_COL || 'Uncategorized';
// --to never resolves through DEFAULT_COL, so a renamed default column only matters for label routing.
if (!opt.to && !colId.has(DEFAULT_COL)) throw new Error('column "' + DEFAULT_COL + '" missing in project; set GITEA_KANBAN_DEFAULT_COL to the board\'s first column title');
const [rs] = await conn.query('SELECT id FROM repository WHERE owner_name = ? AND lower_name = ?', [m[1], m[2].toLowerCase()]);
if (!rs.length) throw new Error('repo not found: ' + opt.repo);
const repoId = rs[0].id;
async function fetchIssue(n) {
const [r] = await conn.query('SELECT id, name AS title, is_closed AS closed FROM issue WHERE repo_id = ? AND `index` = ? AND is_pull = 0', [repoId, n]);
return r[0] || null;
}
async function labelsOf(id) {
const [r] = await conn.query('SELECT name FROM label WHERE id IN (SELECT label_id FROM issue_label WHERE issue_id = ?) ORDER BY id', [id]);
return r.map((x) => x.name);
}
function resolveTarget(names) {
for (const nm of names) if (colId.has(nm)) return nm;
for (const nm of names) for (const ct of colOrder) {
if (ct === DEFAULT_COL) continue;
if (nm === ct || nm.includes(ct) || ct.includes(nm)) return ct;
}
return null;
}
async function rowOf(id) {
const [r] = await conn.query('SELECT project_board_id FROM project_issue WHERE issue_id = ? AND project_id = ?', [id, opt.project]);
return r[0] || null;
}
async function maxSort(boardId, excludeIssueId) {
const [r] = await conn.query('SELECT COALESCE(MAX(sorting) + 1, 0) AS s FROM project_issue WHERE project_board_id = ? AND issue_id <> ?', [boardId, excludeIssueId || 0]);
return r[0].s;
}
const list = [];
if (opt.all) {
const [rows] = await conn.query('SELECT `index` AS n FROM issue WHERE repo_id = ? AND is_pull = 0 AND is_closed = 0 ORDER BY `index`', [repoId]);
for (const r of rows) list.push(r.n);
}
for (const n of opt.issues) if (!list.includes(n)) list.push(n);
const tally = { processed: 0, created: 0, moved: 0, kept: 0, skipped: 0 };
for (const n of list) {
const iss = await fetchIssue(n);
if (!iss) { tally.skipped++; out({ issue: n, action: 'missing-issue' }); continue; }
let target = null;
if (opt.to) {
if (!colId.has(opt.to)) throw new Error('unknown column title: ' + opt.to);
target = opt.to;
} else {
target = resolveTarget(await labelsOf(iss.id));
if (!target && iss.closed) { tally.skipped++; out({ issue: n, action: 'closed-no-label', title: iss.title }); continue; }
target = target || DEFAULT_COL;
}
const row = await rowOf(iss.id);
const want = colId.get(target);
if (!row) {
tally.created++;
out({ issue: n, action: opt.dry ? 'would-create' : 'created', to: target, title: iss.title });
if (!opt.dry) {
const s = await maxSort(want, 0);
await conn.query('INSERT INTO project_issue (issue_id, project_id, project_board_id, sorting) VALUES (?, ?, ?, ?)', [iss.id, opt.project, want, s]);
}
} else if (row.project_board_id !== want) {
tally.moved++;
out({ issue: n, action: opt.dry ? 'would-move' : 'moved', from: boardTitle.get(row.project_board_id), to: target, title: iss.title });
if (!opt.dry) {
const s = await maxSort(want, iss.id);
await conn.query('UPDATE project_issue SET project_board_id = ?, sorting = ? WHERE issue_id = ? AND project_id = ?', [want, s, iss.id, opt.project]);
}
} else {
tally.kept++;
out({ issue: n, action: 'kept', column: target });
}
tally.processed++;
}
out(Object.assign({ project: pj.title }, tally, { dryRun: opt.dry }));
} finally {
await conn.end();
}