#!/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(); }