Scripts: ship a sanitized kanban card-sync tool; refresh board column names

This commit is contained in:
2026-11-02 20:07:08 +08:00
parent cf40686ab1
commit 3cb821477f
2 changed files with 148 additions and 1 deletions
+147
View File
@@ -0,0 +1,147 @@
#!/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();
}
+1 -1
View File
@@ -128,7 +128,7 @@ export const giteaKind: SourceKind<GiteaConfig> = {
{ key: 'DSH_SUPERAGENT_GITEA_URL', description: { zh: 'Gitea 实例地址,不带末尾斜杠', en: 'The Gitea instance URL without a trailing slash' } }, { key: 'DSH_SUPERAGENT_GITEA_URL', description: { zh: 'Gitea 实例地址,不带末尾斜杠', en: 'The Gitea instance URL without a trailing slash' } },
{ key: 'DSH_SUPERAGENT_GITEA_ACCOUNT', description: { zh: '智能体的 Gitea 账号;这里的 Issue 评论都以它的名义发出', en: 'The agent\'s Gitea account; Issue comments here go out under its name' } }, { key: 'DSH_SUPERAGENT_GITEA_ACCOUNT', description: { zh: '智能体的 Gitea 账号;这里的 Issue 评论都以它的名义发出', en: 'The agent\'s Gitea account; Issue comments here go out under its name' } },
{ key: 'DSH_SUPERAGENT_GITEA_NUMBER', description: { zh: '接下工单的 Issue 或 PR 编号;工单不是从这个来源来的时为空', en: 'Number of the Issue or PR that started the ticket; empty when the ticket did not start on this Source' } }, { key: 'DSH_SUPERAGENT_GITEA_NUMBER', description: { zh: '接下工单的 Issue 或 PR 编号;工单不是从这个来源来的时为空', en: 'Number of the Issue or PR that started the ticket; empty when the ticket did not start on this Source' } },
{ key: 'DSH_SUPERAGENT_GITEA_KANBAN', description: { zh: '看板约定:仓库 wandering1/STM32F4-Base 的 Issue 挂着项目看板「项目跟踪」,列名与标签同名(Uncategorized、已完成、进行中、下一顺位、排队、等外部资源、暂缓、观察、开放问题)。这个 Gitea 版本没有 Projects API:改完标签或用户要求移卡后,运行 node C:/Users/67242/.dsh/sa-tools/card-sync.mjs <Issue 编号…> [--to 列名] [--dry-run] 同步卡片,并在回复里报告它的 moved/kept 汇总;只碰这个看板,绝不直接操作数据库', en: 'Kanban convention for wandering1/STM32F4-Base: its Issues live on the board 项目跟踪 whose columns share names with the labels (Uncategorized, 已完成, 进行中, 下一顺位, 排队, 等外部资源, 暂缓, 观察, 开放问题). This Gitea version has no Projects API, so after changing labels or when asked to move a card, run node C:/Users/67242/.dsh/sa-tools/card-sync.mjs <issue numbers…> [--to 列名] [--dry-run] and report its moved/kept summary in the reply; touch only this board, never the database directly' } }, { key: 'DSH_SUPERAGENT_GITEA_KANBAN', description: { zh: '看板约定:仓库 wandering1/STM32F4-Base 的 Issue 挂着项目看板「项目跟踪」,列名与标签同名(文档性维护、已完成、进行中、下一顺位、排队、等外部资源、暂缓、观察、开放问题)。这个 Gitea 版本没有 Projects API:改完标签或用户要求移卡后,运行 node C:/Users/67242/.dsh/sa-tools/card-sync.mjs <Issue 编号…> [--to 列名] [--dry-run] 同步卡片,并在回复里报告它的 moved/kept 汇总;只碰这个看板,绝不直接操作数据库', en: 'Kanban convention for wandering1/STM32F4-Base: its Issues live on the board 项目跟踪 whose columns share names with the labels (文档性维护, 已完成, 进行中, 下一顺位, 排队, 等外部资源, 暂缓, 观察, 开放问题). This Gitea version has no Projects API, so after changing labels or when asked to move a card, run node C:/Users/67242/.dsh/sa-tools/card-sync.mjs <issue numbers…> [--to 列名] [--dry-run] and report its moved/kept summary in the reply; touch only this board, never the database directly' } },
], ],
values: ({ deployment, config, trigger }) => { values: ({ deployment, config, trigger }) => {
const number = trigger?.threads[0]?.source === SOURCE ? Number(trigger.meta.number) || 0 : 0 const number = trigger?.threads[0]?.source === SOURCE ? Number(trigger.meta.number) || 0 : 0