local-kanban 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +381 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.ru.md +144 -0
- package/bin/kb +306 -0
- package/bin/local-kanban +217 -0
- package/ecosystem.example.cjs +25 -0
- package/package.json +52 -0
- package/public/app.js +18 -0
- package/public/boot-fallback.js +3 -0
- package/public/drawer-sync.js +14 -0
- package/public/favicon-180.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-192.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +138 -0
- package/public/js/board.js +698 -0
- package/public/js/chaos.js +247 -0
- package/public/js/core.js +821 -0
- package/public/js/dash.js +114 -0
- package/public/js/drawer.js +919 -0
- package/public/js/horizon.js +140 -0
- package/public/js/init.js +208 -0
- package/public/js/project.js +338 -0
- package/public/js/settings.js +905 -0
- package/public/js/sidebar.js +640 -0
- package/public/js/sse.js +386 -0
- package/public/manifest.webmanifest +32 -0
- package/public/offline.html +39 -0
- package/public/style.css +1247 -0
- package/public/sw.js +31 -0
- package/scripts/raycast/open-kanban.sh +18 -0
- package/scripts/update.js +43 -0
- package/skills/deploy/SKILL.md +70 -0
- package/skills/kanban/SKILL.md +217 -0
- package/src/backup.js +111 -0
- package/src/bus.js +10 -0
- package/src/config.js +40 -0
- package/src/db.js +321 -0
- package/src/error-handler.js +11 -0
- package/src/file-snapshot.js +22 -0
- package/src/origin-guard.js +32 -0
- package/src/pm2-services.js +31 -0
- package/src/query-params.js +17 -0
- package/src/repo-base.js +23 -0
- package/src/restore.js +153 -0
- package/src/routes/dashboard.js +132 -0
- package/src/routes/events.js +30 -0
- package/src/routes/horizons.js +66 -0
- package/src/routes/projects.js +532 -0
- package/src/routes/skills.js +116 -0
- package/src/routes/system.js +285 -0
- package/src/routes/tasks.js +477 -0
- package/src/schema.sql +135 -0
- package/src/server.js +100 -0
- package/src/skills.js +119 -0
- package/src/sync/github.js +265 -0
- package/src/sync/worker.js +136 -0
- package/src/task-shape.js +29 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import fastifyStatic from '@fastify/static';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DATA_DIR, logError } from './db.js';
|
|
6
|
+
import { PORT, ROOT, localRoot } from './config.js';
|
|
7
|
+
import { makeOriginGuard } from './origin-guard.js';
|
|
8
|
+
import { errorHandler } from './error-handler.js';
|
|
9
|
+
import projectRoutes from './routes/projects.js';
|
|
10
|
+
import taskRoutes from './routes/tasks.js';
|
|
11
|
+
import dashboardRoutes from './routes/dashboard.js';
|
|
12
|
+
import systemRoutes from './routes/system.js';
|
|
13
|
+
import skillRoutes from './routes/skills.js';
|
|
14
|
+
import eventRoutes from './routes/events.js';
|
|
15
|
+
import horizonRoutes from './routes/horizons.js';
|
|
16
|
+
import { startWorker } from './sync/worker.js';
|
|
17
|
+
import { startBackups } from './backup.js';
|
|
18
|
+
import { sweepStalePreviews } from './restore.js';
|
|
19
|
+
|
|
20
|
+
const ATTACH_DIR = join(DATA_DIR, 'attachments');
|
|
21
|
+
mkdirSync(ATTACH_DIR, { recursive: true });
|
|
22
|
+
|
|
23
|
+
const app = Fastify({ logger: { level: 'warn' } });
|
|
24
|
+
|
|
25
|
+
app.addHook('onRequest', makeOriginGuard(PORT));
|
|
26
|
+
|
|
27
|
+
const CSP = [
|
|
28
|
+
"default-src 'self'",
|
|
29
|
+
"img-src 'self' data: blob:",
|
|
30
|
+
"style-src 'self' 'unsafe-inline'",
|
|
31
|
+
"script-src 'self'",
|
|
32
|
+
"connect-src 'self'",
|
|
33
|
+
"object-src 'none'",
|
|
34
|
+
"base-uri 'none'",
|
|
35
|
+
"frame-ancestors 'none'",
|
|
36
|
+
].join('; ');
|
|
37
|
+
|
|
38
|
+
app.register(fastifyStatic, {
|
|
39
|
+
root: join(ROOT, 'public'),
|
|
40
|
+
setHeaders: (res) => {
|
|
41
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
42
|
+
res.setHeader('Content-Security-Policy', CSP);
|
|
43
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
44
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
app.register(fastifyStatic, {
|
|
48
|
+
root: ATTACH_DIR,
|
|
49
|
+
prefix: '/attachments/',
|
|
50
|
+
decorateReply: false,
|
|
51
|
+
setHeaders: (res) => { res.setHeader('X-Content-Type-Options', 'nosniff'); },
|
|
52
|
+
});
|
|
53
|
+
app.setErrorHandler(errorHandler);
|
|
54
|
+
|
|
55
|
+
app.register(projectRoutes);
|
|
56
|
+
app.register(taskRoutes);
|
|
57
|
+
app.register(dashboardRoutes);
|
|
58
|
+
app.register(systemRoutes);
|
|
59
|
+
app.register(skillRoutes);
|
|
60
|
+
app.register(eventRoutes);
|
|
61
|
+
app.register(horizonRoutes);
|
|
62
|
+
|
|
63
|
+
for (const ev of ['unhandledRejection', 'uncaughtException']) {
|
|
64
|
+
process.on(ev, (err) => {
|
|
65
|
+
logError('server', ev, err?.message || String(err), err?.stack);
|
|
66
|
+
console.error(err);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.env.KB_PREVIEW_TTL_MS) {
|
|
72
|
+
const ttl = Number(process.env.KB_PREVIEW_TTL_MS);
|
|
73
|
+
if (Number.isFinite(ttl) && ttl > 0) setTimeout(() => process.exit(0), ttl).unref();
|
|
74
|
+
process.stdin.on('end', () => process.exit(0));
|
|
75
|
+
process.stdin.on('close', () => process.exit(0));
|
|
76
|
+
process.stdin.on('error', () => process.exit(0));
|
|
77
|
+
process.stdin.resume();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
app.listen({ port: PORT, host: '127.0.0.1' }).then(() => {
|
|
81
|
+
console.log(`kanban: http://127.0.0.1:${PORT}`);
|
|
82
|
+
console.log(`kanban: data ${DATA_DIR} · project folders ${localRoot()}`);
|
|
83
|
+
startWorker();
|
|
84
|
+
startBackups();
|
|
85
|
+
if (!process.env.KB_PREVIEW_TTL_MS) {
|
|
86
|
+
const swept = sweepStalePreviews();
|
|
87
|
+
if (swept) console.log(`restore: swept ${swept} stale preview director${swept === 1 ? 'y' : 'ies'}`);
|
|
88
|
+
}
|
|
89
|
+
}).catch((err) => {
|
|
90
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
91
|
+
console.error(`\n Port ${PORT} is already taken — the board did not start.\n`);
|
|
92
|
+
console.error(' What to do:');
|
|
93
|
+
console.error(` · free the port (see who holds it: lsof -i :${PORT} )`);
|
|
94
|
+
console.error(` · or run on another one: PORT=${PORT + 1} local-kanban start`);
|
|
95
|
+
console.error(' · or change the port in ecosystem.config.cjs when running under pm2\n');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
app.log.error(err);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve, sep } from 'node:path';
|
|
5
|
+
import { skillsExtra } from './config.js';
|
|
6
|
+
import { snapshotDir, snapshotFile } from './file-snapshot.js';
|
|
7
|
+
|
|
8
|
+
export const SKILL_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
9
|
+
export const SKILL_MAX_BYTES = 512 * 1024;
|
|
10
|
+
|
|
11
|
+
export const BOARD_SKILL = 'kanban';
|
|
12
|
+
export const GENERIC_DEPLOY_SKILL = 'deploy';
|
|
13
|
+
|
|
14
|
+
export function skillRoots() {
|
|
15
|
+
const home = process.env.HOME || homedir();
|
|
16
|
+
return [join(home, '.claude', 'skills'), ...skillsExtra()].map((r) => resolve(r));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveSkillPath(name) {
|
|
20
|
+
if (!name || !SKILL_NAME_RE.test(name)) return null;
|
|
21
|
+
for (const root of skillRoots()) {
|
|
22
|
+
const p = resolve(root, name, 'SKILL.md');
|
|
23
|
+
if (!p.startsWith(root + sep)) continue;
|
|
24
|
+
if (existsSync(p)) return p;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function skillTargetPath(name) {
|
|
30
|
+
if (!name || !SKILL_NAME_RE.test(name)) return null;
|
|
31
|
+
return resolveSkillPath(name) || join(skillRoots()[0], name, 'SKILL.md');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function realPathOf(path) {
|
|
35
|
+
try { return realpathSync(path); } catch { }
|
|
36
|
+
try { return join(realpathSync(join(path, '..')), 'SKILL.md'); } catch { return path; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function skillInfo(name) {
|
|
40
|
+
if (!name || !SKILL_NAME_RE.test(name)) return null;
|
|
41
|
+
const path = skillTargetPath(name);
|
|
42
|
+
const real = realPathOf(path);
|
|
43
|
+
let size = null;
|
|
44
|
+
let mtime = null;
|
|
45
|
+
try {
|
|
46
|
+
const st = statSync(path);
|
|
47
|
+
size = st.size;
|
|
48
|
+
mtime = new Date(st.mtimeMs).toISOString();
|
|
49
|
+
} catch { }
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
path,
|
|
53
|
+
real_path: real,
|
|
54
|
+
symlink: real !== path,
|
|
55
|
+
exists: existsSync(path),
|
|
56
|
+
size,
|
|
57
|
+
mtime,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listSkills() {
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const root of skillRoots()) {
|
|
65
|
+
let entries = [];
|
|
66
|
+
try { entries = readdirSync(root); } catch { continue; }
|
|
67
|
+
for (const name of entries.sort()) {
|
|
68
|
+
if (seen.has(name) || !SKILL_NAME_RE.test(name)) continue;
|
|
69
|
+
if (!existsSync(join(root, name, 'SKILL.md'))) continue;
|
|
70
|
+
seen.add(name);
|
|
71
|
+
out.push({ ...skillInfo(name), root });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readSkill(name) {
|
|
78
|
+
const info = skillInfo(name);
|
|
79
|
+
if (!info || !info.exists) return info ? { ...info, text: null, truncated: false } : null;
|
|
80
|
+
const buf = readFileSync(info.path);
|
|
81
|
+
const truncated = buf.length > SKILL_MAX_BYTES;
|
|
82
|
+
return { ...info, text: buf.subarray(0, SKILL_MAX_BYTES).toString('utf8'), truncated };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SKILL_BACKUP_DIR = () => snapshotDir('skills');
|
|
86
|
+
|
|
87
|
+
export function writeSkill(name, text, confirmPath) {
|
|
88
|
+
if (!name || !SKILL_NAME_RE.test(name)) return { error: 'skill name: latin letters, digits, hyphen, underscore only', code: 400 };
|
|
89
|
+
if (typeof text !== 'string') return { error: 'text is required', code: 400 };
|
|
90
|
+
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) return { error: `the skill is larger than ${Math.round(SKILL_MAX_BYTES / 1024)} KB`, code: 413 };
|
|
91
|
+
const info = skillInfo(name);
|
|
92
|
+
if (confirmPath !== info.real_path) {
|
|
93
|
+
return { error: 'the file on disk is not the one the page was editing', code: 409, real_path: info.real_path };
|
|
94
|
+
}
|
|
95
|
+
const backup = snapshotFile('skills', name, info.path);
|
|
96
|
+
mkdirSync(join(info.path, '..'), { recursive: true });
|
|
97
|
+
writeFileSync(info.path, text);
|
|
98
|
+
return { ...skillInfo(name), backup, backup_name: backup ? backup.split(sep).pop() : null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function deleteSkill(name, confirmPath) {
|
|
102
|
+
if (!name || !SKILL_NAME_RE.test(name)) return { error: 'skill name: latin letters, digits, hyphen, underscore only', code: 400 };
|
|
103
|
+
const info = skillInfo(name);
|
|
104
|
+
if (!info.exists) return { error: 'there is no such skill', code: 404 };
|
|
105
|
+
if (confirmPath !== info.real_path) {
|
|
106
|
+
return { error: 'the file on disk is not the one the page was showing', code: 409, real_path: info.real_path };
|
|
107
|
+
}
|
|
108
|
+
const backup = snapshotFile('skills', name, info.path);
|
|
109
|
+
const dir = join(info.path, '..');
|
|
110
|
+
let unlinkedOnly = false;
|
|
111
|
+
try { unlinkedOnly = lstatSync(dir).isSymbolicLink(); } catch { }
|
|
112
|
+
if (unlinkedOnly) {
|
|
113
|
+
unlinkSync(dir);
|
|
114
|
+
} else {
|
|
115
|
+
rmSync(info.path, { force: true });
|
|
116
|
+
try { if (!readdirSync(dir).length) rmSync(dir, { recursive: true, force: true }); } catch { }
|
|
117
|
+
}
|
|
118
|
+
return { name, removed: true, link_only: unlinkedOnly, backup, backup_name: backup ? backup.split(sep).pop() : null };
|
|
119
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { db, ghOwner, ghRepo, uiLang } from '../db.js';
|
|
4
|
+
const GH_CANDIDATES = [
|
|
5
|
+
'/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh',
|
|
6
|
+
'/home/linuxbrew/.linuxbrew/bin/gh', `${process.env.HOME}/.local/bin/gh`,
|
|
7
|
+
];
|
|
8
|
+
const GH = GH_CANDIDATES.find((p) => existsSync(p)) || 'gh';
|
|
9
|
+
|
|
10
|
+
const PERMANENT = [
|
|
11
|
+
/validation failed/i,
|
|
12
|
+
/could not resolve to an? (repository|issue|user|organization)/i,
|
|
13
|
+
/label.{0,80}not found/i,
|
|
14
|
+
/^unprocessable entity/i,
|
|
15
|
+
];
|
|
16
|
+
export function isPermanent(stderr) {
|
|
17
|
+
return PERMANENT.some((re) => re.test(stderr));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function gh(args, { timeout = 60000 } = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
execFile(GH, args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
23
|
+
if (err) {
|
|
24
|
+
const e = new Error((stderr || err.message || '').trim().slice(0, 500));
|
|
25
|
+
e.permanent = isPermanent(String(stderr));
|
|
26
|
+
return reject(e);
|
|
27
|
+
}
|
|
28
|
+
resolve(stdout.trim());
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function ghAvailable() {
|
|
34
|
+
try { await gh(['auth', 'status'], { timeout: 15000 }); return true; }
|
|
35
|
+
catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GH_STATUS = [
|
|
39
|
+
['backlog', { ru: 'Бэклог', en: 'Backlog' }, 'PINK'],
|
|
40
|
+
['todo', { ru: 'Сделать', en: 'To do' }, 'GRAY'],
|
|
41
|
+
['prep', { ru: 'Подготовка', en: 'Prep' }, 'PURPLE'],
|
|
42
|
+
['doing', { ru: 'Делаю', en: 'Doing' }, 'YELLOW'],
|
|
43
|
+
['deploy', { ru: 'Деплою', en: 'Deploying' }, 'BLUE'],
|
|
44
|
+
['review', { ru: 'Проверяю', en: 'Review' }, 'ORANGE'],
|
|
45
|
+
['done', { ru: 'Готово', en: 'Done' }, 'GREEN'],
|
|
46
|
+
['cancelled', { ru: 'Отменено', en: 'Cancelled' }, 'RED'],
|
|
47
|
+
];
|
|
48
|
+
const statusName = (names) => names[uiLang()] || names.ru;
|
|
49
|
+
export const ghStatusName = (key) => statusName(GH_STATUS.find(([k]) => k === key)?.[1] || { ru: key });
|
|
50
|
+
export const NAME_TO_KEY = { Todo: 'todo', 'In Progress': 'doing', Done: 'done' };
|
|
51
|
+
for (const [key, names] of GH_STATUS) for (const n of Object.values(names)) NAME_TO_KEY[n] = key;
|
|
52
|
+
const PRIORITY_LABELS = { 1: 'p:low', 2: 'p:med', 3: 'p:high' };
|
|
53
|
+
|
|
54
|
+
export const LABELS = {
|
|
55
|
+
bug: 'D73A4A', documentation: '0075CA', docs: '0075CA', duplicate: 'CFD3D7', enhancement: 'A2EEEF',
|
|
56
|
+
'good first issue': '7057FF', 'help wanted': '008672', invalid: 'E4E669',
|
|
57
|
+
question: 'D876E3', wontfix: 'FFFFFF', ui: '5319E7', feature: '0E8A16',
|
|
58
|
+
blocked: 'D93F0B', 'p:low': 'C2E0C6', 'p:med': 'FBCA04', 'p:high': 'B60205',
|
|
59
|
+
noclaude: '6E7681',
|
|
60
|
+
security: 'EE0701',
|
|
61
|
+
ask: '8250DF',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const MANAGED_LABELS = new Set([...Object.values(PRIORITY_LABELS), 'blocked']);
|
|
65
|
+
|
|
66
|
+
let labelsEnsuredFor = null;
|
|
67
|
+
|
|
68
|
+
export function missingLabels(existingNames = []) {
|
|
69
|
+
const have = new Set(existingNames.map((n) => String(n).toLowerCase()));
|
|
70
|
+
return Object.entries(LABELS).filter(([name]) => !have.has(name.toLowerCase()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function ensureLabels({ run = gh } = {}) {
|
|
74
|
+
const repo = ghRepo();
|
|
75
|
+
if (labelsEnsuredFor === repo) return;
|
|
76
|
+
let existing = [];
|
|
77
|
+
let listed = false;
|
|
78
|
+
try {
|
|
79
|
+
const out = await run(['label', 'list', '-R', repo, '--limit', '200', '--json', 'name']);
|
|
80
|
+
existing = JSON.parse(out || '[]').map((l) => l.name);
|
|
81
|
+
listed = true;
|
|
82
|
+
} catch { }
|
|
83
|
+
for (const [name, color] of missingLabels(existing)) {
|
|
84
|
+
try {
|
|
85
|
+
await run(['label', 'create', name, '-R', repo, '--color', color]);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (!/already exists/i.test(e.message)) throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (listed) labelsEnsuredFor = repo;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function statusOptions(project) {
|
|
94
|
+
return JSON.parse(project.gh_status_options || '{}');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function optionsComplete(project) {
|
|
98
|
+
const opts = statusOptions(project);
|
|
99
|
+
return GH_STATUS.every(([key]) => opts[key]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function ensureProject(project) {
|
|
103
|
+
if (project.gh_project_id && project.gh_status_field_id && optionsComplete(project)) return project;
|
|
104
|
+
|
|
105
|
+
let { gh_project_number: number, gh_project_id: pid } = project;
|
|
106
|
+
if (!pid) {
|
|
107
|
+
const created = JSON.parse(await gh(['project', 'create', '--owner', ghOwner(), '--title', `kb: ${project.slug}`, '--format', 'json']));
|
|
108
|
+
number = created.number;
|
|
109
|
+
pid = created.id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fields = JSON.parse(await gh(['project', 'field-list', String(number), '--owner', ghOwner(), '--format', 'json'])).fields;
|
|
113
|
+
const statusField = fields.find((f) => f.name === 'Status');
|
|
114
|
+
|
|
115
|
+
const keep = {};
|
|
116
|
+
for (const o of statusField.options || []) {
|
|
117
|
+
const key = NAME_TO_KEY[o.name];
|
|
118
|
+
if (key && !keep[key]) keep[key] = o.id;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const optionsLiteral = '[' + GH_STATUS.map(([key, names, color]) => {
|
|
122
|
+
const idPart = keep[key] ? `id: ${JSON.stringify(keep[key])}, ` : '';
|
|
123
|
+
return `{${idPart}name: ${JSON.stringify(statusName(names))}, color: ${color}, description: ""}`;
|
|
124
|
+
}).join(', ') + ']';
|
|
125
|
+
|
|
126
|
+
const res = JSON.parse(await gh(['api', 'graphql', '-f',
|
|
127
|
+
`query=mutation { updateProjectV2Field(input: {fieldId: ${JSON.stringify(statusField.id)}, singleSelectOptions: ${optionsLiteral}}) {
|
|
128
|
+
projectV2Field { ... on ProjectV2SingleSelectField { id options { id name } } } } }`,
|
|
129
|
+
]));
|
|
130
|
+
const out = res.data.updateProjectV2Field.projectV2Field;
|
|
131
|
+
const map = {};
|
|
132
|
+
for (const o of out.options) if (NAME_TO_KEY[o.name]) map[NAME_TO_KEY[o.name]] = o.id;
|
|
133
|
+
|
|
134
|
+
db.prepare('UPDATE projects SET gh_project_number=?, gh_project_id=?, gh_status_field_id=?, gh_status_options=? WHERE id=?')
|
|
135
|
+
.run(number, pid, out.id, JSON.stringify(map), project.id);
|
|
136
|
+
return db.prepare('SELECT * FROM projects WHERE id = ?').get(project.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function setItemStatus(project, itemId, statusKey) {
|
|
140
|
+
const opt = statusOptions(project)[statusKey];
|
|
141
|
+
if (!itemId) throw new Error('the task has no card on the GitHub board yet — waiting for create_issue');
|
|
142
|
+
if (!opt) return;
|
|
143
|
+
await gh(['project', 'item-edit', '--id', itemId, '--project-id', project.gh_project_id,
|
|
144
|
+
'--field-id', project.gh_status_field_id, '--single-select-option-id', opt]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function issueBody(project, payload) {
|
|
148
|
+
return `${payload.description || ''}\n\n_project: ${project.slug} · ${payload.key} · created by local-kanban_`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function issueTitle(project, payload) {
|
|
152
|
+
const t = (payload.title || '').trim().split('\n')[0]
|
|
153
|
+
|| (payload.description || '').trim().split('\n')[0].slice(0, 80)
|
|
154
|
+
|| `task ${payload.key}`;
|
|
155
|
+
const full = `[${project.slug}] ${t}`;
|
|
156
|
+
return full.length > 200 ? `${full.slice(0, 199)}…` : full;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function createIssueOnly(project, payload) {
|
|
160
|
+
await ensureLabels();
|
|
161
|
+
const args = ['issue', 'create', '-R', ghRepo(),
|
|
162
|
+
'--title', issueTitle(project, payload),
|
|
163
|
+
'--body', issueBody(project, payload)];
|
|
164
|
+
const labels = [...(payload.labels || [])];
|
|
165
|
+
if (PRIORITY_LABELS[payload.priority]) labels.push(PRIORITY_LABELS[payload.priority]);
|
|
166
|
+
if (payload.blocked) labels.push('blocked');
|
|
167
|
+
for (const l of labels) args.push('--label', l);
|
|
168
|
+
const url = await gh(args);
|
|
169
|
+
return { number: parseIssueNumber(url), url };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function parseIssueNumber(url) {
|
|
173
|
+
const number = Number(String(url).match(/\/issues\/(\d+)/)?.[1]);
|
|
174
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
175
|
+
throw new Error(`gh issue create answered without an issue link: ${String(url).slice(0, 200)}`);
|
|
176
|
+
}
|
|
177
|
+
return number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function findIssueByKey(key) {
|
|
181
|
+
if (!key) return null;
|
|
182
|
+
const out = await gh(['issue', 'list', '-R', ghRepo(), '--search', `"${key}" in:body`,
|
|
183
|
+
'--state', 'all', '--limit', '20', '--json', 'number,url,body']);
|
|
184
|
+
const list = JSON.parse(out || '[]');
|
|
185
|
+
const hit = list.find((i) => typeof i.body === 'string' && i.body.includes(`· ${key} ·`));
|
|
186
|
+
return hit ? { number: hit.number, url: hit.url } : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function addToProject(project, url) {
|
|
190
|
+
const item = JSON.parse(await gh(['project', 'item-add', String(project.gh_project_number),
|
|
191
|
+
'--owner', ghOwner(), '--url', url, '--format', 'json']));
|
|
192
|
+
return item.id;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function updateIssue(payload, project) {
|
|
196
|
+
await gh(['issue', 'edit', String(payload.gh_issue_number), '-R', ghRepo(),
|
|
197
|
+
'--title', issueTitle(project, payload),
|
|
198
|
+
'--body', issueBody(project, payload)]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function setPriority(payload) {
|
|
202
|
+
await ensureLabels();
|
|
203
|
+
const args = ['issue', 'edit', String(payload.gh_issue_number), '-R', ghRepo()];
|
|
204
|
+
for (const l of Object.values(PRIORITY_LABELS)) args.push('--remove-label', l);
|
|
205
|
+
if (PRIORITY_LABELS[payload.priority]) args.push('--add-label', PRIORITY_LABELS[payload.priority]);
|
|
206
|
+
await gh(args);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function currentLabels(number) {
|
|
210
|
+
try {
|
|
211
|
+
const out = await gh(['issue', 'view', String(number), '-R', ghRepo(), '--json', 'labels']);
|
|
212
|
+
return new Set(JSON.parse(out).labels.map((l) => l.name));
|
|
213
|
+
} catch { return new Set(); }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function setLabels(payload) {
|
|
217
|
+
await ensureLabels();
|
|
218
|
+
const want = new Set(payload.labels || []);
|
|
219
|
+
const cur = await currentLabels(payload.gh_issue_number);
|
|
220
|
+
const args = ['issue', 'edit', String(payload.gh_issue_number), '-R', ghRepo()];
|
|
221
|
+
let changed = false;
|
|
222
|
+
for (const l of cur) if (!want.has(l) && !MANAGED_LABELS.has(l)) { args.push('--remove-label', l); changed = true; }
|
|
223
|
+
for (const l of want) if (!cur.has(l)) { args.push('--add-label', l); changed = true; }
|
|
224
|
+
if (changed) await gh(args);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function setBlocked(payload) {
|
|
228
|
+
await ensureLabels();
|
|
229
|
+
await gh(['issue', 'edit', String(payload.gh_issue_number), '-R', ghRepo(),
|
|
230
|
+
payload.blocked ? '--add-label' : '--remove-label', 'blocked']);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function addComment(payload) {
|
|
234
|
+
const prefix = payload.comment_author === 'claude' ? '🤖 ' : '';
|
|
235
|
+
await gh(['issue', 'comment', String(payload.gh_issue_number), '-R', ghRepo(), '--body', prefix + payload.comment_body]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// done → closed completed; cancelled → closed not planned
|
|
239
|
+
export async function closeIssue(payload, project) {
|
|
240
|
+
await setItemStatus(project, payload.gh_item_id, payload.status);
|
|
241
|
+
const reason = payload.status === 'cancelled' ? 'not planned' : 'completed';
|
|
242
|
+
try {
|
|
243
|
+
await gh(['issue', 'close', String(payload.gh_issue_number), '-R', ghRepo(), '--reason', reason]);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (!/already closed/i.test(err.message)) throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function reopenIssue(payload, project) {
|
|
250
|
+
try {
|
|
251
|
+
await gh(['issue', 'reopen', String(payload.gh_issue_number), '-R', ghRepo()]);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (!/already open/i.test(err.message)) throw err;
|
|
254
|
+
}
|
|
255
|
+
await setItemStatus(project, payload.gh_item_id, payload.status);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function deleteIssue(payload) {
|
|
259
|
+
if (!payload.gh_issue_number) return;
|
|
260
|
+
try {
|
|
261
|
+
await gh(['issue', 'close', String(payload.gh_issue_number), '-R', ghRepo(), '--reason', 'not planned']);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (!/already closed/i.test(err.message)) throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { db, kvGet, kvSet, syncConfigured, logError, resolveErrors, enqueue, SYNC_MAX_ATTEMPTS } from '../db.js';
|
|
2
|
+
import { emit } from '../bus.js';
|
|
3
|
+
import { taskEvent } from '../task-shape.js';
|
|
4
|
+
import * as github from './github.js';
|
|
5
|
+
|
|
6
|
+
export function syncPaused() {
|
|
7
|
+
return kvGet('sync.paused') === '1';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ghState = { available: null, lastCheck: 0 };
|
|
11
|
+
|
|
12
|
+
let draining = false;
|
|
13
|
+
|
|
14
|
+
async function checkGh() {
|
|
15
|
+
const ttl = ghState.available ? 600000 : 60000;
|
|
16
|
+
if (Date.now() - ghState.lastCheck < ttl) return ghState.available;
|
|
17
|
+
ghState.available = await github.ghAvailable();
|
|
18
|
+
ghState.lastCheck = Date.now();
|
|
19
|
+
return ghState.available;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function backoff(attempts) {
|
|
23
|
+
return Math.min(30 * 2 ** attempts, 3600);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function processOp(row, api = github) {
|
|
27
|
+
const payload = JSON.parse(row.payload);
|
|
28
|
+
const task = row.task_id
|
|
29
|
+
? db.prepare('SELECT t.*, p.slug AS project FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.id = ?').get(row.task_id)
|
|
30
|
+
: null;
|
|
31
|
+
const slug = task?.project || payload.project;
|
|
32
|
+
let project = db.prepare('SELECT * FROM projects WHERE slug = ?').get(slug);
|
|
33
|
+
if (!project) return;
|
|
34
|
+
|
|
35
|
+
const data = { ...payload, ...(task ? {
|
|
36
|
+
title: task.title, description: task.description, status: task.status,
|
|
37
|
+
priority: task.priority, blocked: task.blocked, labels: JSON.parse(task.labels || '[]'),
|
|
38
|
+
gh_issue_number: task.gh_issue_number, gh_item_id: task.gh_item_id,
|
|
39
|
+
} : {}) };
|
|
40
|
+
if (payload.comment_author) { data.comment_author = payload.comment_author; data.comment_body = payload.comment_body; }
|
|
41
|
+
if (payload.key) data.key = payload.key;
|
|
42
|
+
|
|
43
|
+
if (row.op !== 'delete_issue' && !data.gh_issue_number && row.op !== 'create_issue') {
|
|
44
|
+
if (task && !db.prepare("SELECT 1 FROM sync_queue WHERE task_id = ? AND op = 'create_issue'").get(row.task_id)) {
|
|
45
|
+
enqueue('create_issue', row.task_id, { ...data, task_id: row.task_id });
|
|
46
|
+
}
|
|
47
|
+
throw new Error('the issue does not exist yet — waiting for create_issue');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
switch (row.op) {
|
|
51
|
+
case 'create_issue': {
|
|
52
|
+
if (!task) return;
|
|
53
|
+
project = await api.ensureProject(project);
|
|
54
|
+
let { gh_issue_number: number, gh_issue_url: url, gh_item_id: itemId } = task;
|
|
55
|
+
if (!number) {
|
|
56
|
+
if (row.started_at) {
|
|
57
|
+
const found = await api.findIssueByKey(data.key);
|
|
58
|
+
if (found) ({ number, url } = found);
|
|
59
|
+
}
|
|
60
|
+
if (!number) {
|
|
61
|
+
db.prepare("UPDATE sync_queue SET started_at = datetime('now') WHERE id = ?").run(row.id);
|
|
62
|
+
({ number, url } = await api.createIssueOnly(project, data));
|
|
63
|
+
}
|
|
64
|
+
if (db.prepare('UPDATE tasks SET gh_issue_number=?, gh_issue_url=? WHERE id=?').run(number, url, task.id).changes === 0) {
|
|
65
|
+
await api.deleteIssue({ gh_issue_number: number }); return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!itemId) {
|
|
69
|
+
itemId = await api.addToProject(project, url);
|
|
70
|
+
if (db.prepare('UPDATE tasks SET gh_item_id=? WHERE id=?').run(itemId, task.id).changes === 0) {
|
|
71
|
+
await api.deleteIssue({ gh_issue_number: number }); return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await api.setItemStatus(project, itemId, data.status || 'backlog');
|
|
75
|
+
emit('task.updated', taskEvent(task.id));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'set_status': project = await api.ensureProject(project); await api.setItemStatus(project, data.gh_item_id, data.status); break;
|
|
79
|
+
case 'set_priority': await api.setPriority(data); break;
|
|
80
|
+
case 'set_blocked': await api.setBlocked(data); break;
|
|
81
|
+
case 'set_labels': await api.setLabels(data); break;
|
|
82
|
+
case 'add_comment': await api.addComment(data); break;
|
|
83
|
+
case 'update_issue': await api.updateIssue(data, project); break;
|
|
84
|
+
case 'close_issue': project = await api.ensureProject(project); await api.closeIssue(data, project); break;
|
|
85
|
+
case 'reopen_issue': project = await api.ensureProject(project); await api.reopenIssue(data, project); break;
|
|
86
|
+
case 'delete_issue': await api.deleteIssue(payload); break;
|
|
87
|
+
default: break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function drain({ handler = processOp } = {}) {
|
|
92
|
+
if (draining) return;
|
|
93
|
+
if (!syncConfigured()) return;
|
|
94
|
+
if (syncPaused()) return;
|
|
95
|
+
draining = true;
|
|
96
|
+
try {
|
|
97
|
+
const rows = db.prepare(`
|
|
98
|
+
SELECT * FROM sync_queue
|
|
99
|
+
WHERE attempts < ${SYNC_MAX_ATTEMPTS} AND next_attempt_at <= datetime('now')
|
|
100
|
+
ORDER BY id LIMIT 20
|
|
101
|
+
`).all();
|
|
102
|
+
if (!rows.length) return;
|
|
103
|
+
if (!(await checkGh())) return;
|
|
104
|
+
|
|
105
|
+
const awaitingCreate = new Set();
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (row.task_id && awaitingCreate.has(row.task_id)) continue;
|
|
108
|
+
try {
|
|
109
|
+
await handler(row);
|
|
110
|
+
db.prepare('DELETE FROM sync_queue WHERE id = ?').run(row.id);
|
|
111
|
+
resolveErrors(row.id);
|
|
112
|
+
kvSet('sync.last_ok', new Date().toISOString().slice(0, 19).replace('T', ' '));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const attempts = row.attempts + 1;
|
|
115
|
+
const delay = err.permanent ? null : backoff(attempts);
|
|
116
|
+
db.prepare(`UPDATE sync_queue SET attempts = ?, last_error = ?, next_attempt_at = datetime('now', ?) WHERE id = ?`)
|
|
117
|
+
.run(err.permanent ? SYNC_MAX_ATTEMPTS : attempts, String(err.message).slice(0, 500), `+${delay ?? 0} seconds`, row.id);
|
|
118
|
+
emit('sync.status', { error: String(err.message).slice(0, 200), op: row.op });
|
|
119
|
+
logError('sync', row.op, err.message, `attempt ${attempts}, op #${row.id}${row.task_id ? `, task #${row.task_id}` : ''}`, row.id);
|
|
120
|
+
if (row.op === 'create_issue' && row.task_id) awaitingCreate.add(row.task_id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const pending = db.prepare(`SELECT COUNT(*) AS n FROM sync_queue WHERE attempts < ${SYNC_MAX_ATTEMPTS}`).get().n;
|
|
124
|
+
emit('sync.status', { pending });
|
|
125
|
+
} finally {
|
|
126
|
+
draining = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function kick() {
|
|
131
|
+
setImmediate(() => drain().catch((e) => logError('sync', 'kick', e?.message || String(e), e?.stack)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function startWorker() {
|
|
135
|
+
setInterval(() => drain().catch((e) => logError('sync', 'drain', e?.message || String(e), e?.stack)), 5000);
|
|
136
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { db } from './db.js';
|
|
2
|
+
|
|
3
|
+
export function listShape(t) {
|
|
4
|
+
return {
|
|
5
|
+
id: t.id, key: t.key, project: t.project, title: t.title, status: t.status,
|
|
6
|
+
priority: t.priority, blocked: t.blocked, blocked_reason: t.blocked_reason || null, position: t.position,
|
|
7
|
+
pinned: t.pinned || 0,
|
|
8
|
+
labels: JSON.parse(t.labels || '[]'),
|
|
9
|
+
preview: (t.description || '').slice(0, 180),
|
|
10
|
+
comments_n: t.comments_n || 0,
|
|
11
|
+
attachments_n: t.attachments_n || 0,
|
|
12
|
+
gh_issue_number: t.gh_issue_number,
|
|
13
|
+
gh_issue_url: t.gh_issue_url || null,
|
|
14
|
+
work_seconds: t.work_seconds || 0,
|
|
15
|
+
work_started_at: t.work_started_at || null,
|
|
16
|
+
work_truncated: t.work_truncated || 0,
|
|
17
|
+
updated_at: t.updated_at,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function taskEvent(id) {
|
|
22
|
+
const row = db.prepare(`
|
|
23
|
+
SELECT t.*, p.slug AS project, (p.prefix || '-' || t.task_no) AS key,
|
|
24
|
+
(SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comments_n,
|
|
25
|
+
(SELECT COUNT(*) FROM task_attachments a WHERE a.task_id = t.id AND a.file != '') AS attachments_n
|
|
26
|
+
FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.id = ?
|
|
27
|
+
`).get(id);
|
|
28
|
+
return row ? listShape(row) : null;
|
|
29
|
+
}
|