148 lines
7.6 KiB
JavaScript
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();
|
|
}
|