import fs from 'fs';
import path from 'path';
// If running Node < 20.6, we might need dotenv, but let's try to load .env manually for zero-dependency
function loadEnv() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf-8');
content.split('\n').forEach(line => {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
const key = match[1];
let value = match[2] || '';
if (value.length > 0 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') {
value = value.replace(/\\n/gm, '\n');
}
value = value.replace(/(^['"]|['"]$)/g, '').trim();
process.env[key] = value;
}
});
}
}
loadEnv();
const TOKEN = process.env.GITBUCKET_TOKEN;
const API_BASE = "https://gitbucket.tmworks.club/api/v3/repos/dtmoyaji/TelosDB/issues";
const ISSUES_DIR = path.join(process.cwd(), 'docs', 'issues');
if (!fs.existsSync(ISSUES_DIR)) {
fs.mkdirSync(ISSUES_DIR, { recursive: true });
}
// ----------------------------------------------------
// Frontmatter Parser
// ----------------------------------------------------
function parseMarkdown(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: { id: 'new' }, body: content.trim() };
const metaRaw = match[1];
const body = match[2].trim();
const meta = {};
metaRaw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > -1) {
const key = line.substring(0, idx).trim();
const val = line.substring(idx + 1).trim();
meta[key] = val;
}
});
return { meta, body };
}
function stringifyMarkdown(meta, body) {
let yaml = '---\n';
for (const [k, v] of Object.entries(meta)) {
yaml += `${k}: ${v}\n`;
}
yaml += '---\n\n' + body + '\n';
return yaml;
}
// ----------------------------------------------------
// API Helpers
// ----------------------------------------------------
async function apiCall(url, method = 'GET', bodyObj = null) {
const headers = { 'Content-Type': 'application/json' };
if (TOKEN) {
headers['Authorization'] = `token ${TOKEN}`;
}
const options = { method, headers };
if (bodyObj) {
options.body = JSON.stringify(bodyObj);
}
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`API Error ${res.status}: ${await res.text()}`);
}
return res.json();
}
async function fetchRemoteIssues() {
console.log("Fetching remote issues...");
return await apiCall(`${API_BASE}?state=all`);
}
async function updateRemoteIssue(number, title, body, state) {
console.log(`Pushing changes to remote issue #${number}...`);
try {
return await apiCall(`${API_BASE}/${number}`, 'PATCH', { title, body, state });
} catch (err) {
if (err.message.includes('404')) {
const htmlUrl = `https://gitbucket.tmworks.club/dtmoyaji/TelosDB/issues/${number}`;
console.warn(`\n[Manual Action Required]`);
console.warn(`GitBucket API (PATCH) returned 404. Your version might not support issue updates via API.`);
console.warn(`Please manually close issue #${number} at: ${htmlUrl}\n`);
return { _manual: true, html_url: htmlUrl };
}
throw err;
}
}
async function createRemoteIssue(title, body) {
console.log(`Creating new remote issue: ${title}...`);
return await apiCall(`${API_BASE}`, 'POST', { title, body });
}
// ----------------------------------------------------
// Sync Logic
// ----------------------------------------------------
async function sync() {
try {
const remoteIssues = await fetchRemoteIssues();
const remoteMap = new Map(remoteIssues.map(i => [i.number.toString(), i]));
const localFiles = fs.readdirSync(ISSUES_DIR).filter(f => f.endsWith('.md'));
// 1. Local -> Remote (Push or Create)
for (const file of localFiles) {
const filePath = path.join(ISSUES_DIR, file);
const content = fs.readFileSync(filePath, 'utf-8');
const localStat = fs.statSync(filePath);
const { meta, body } = parseMarkdown(content);
if (!meta.title) meta.title = `Issue ${meta.id || 'New'}`;
if (!meta.id || meta.id.toString() === 'new') {
if (!TOKEN) {
console.warn(`[Skip] Cannot create new issue from ${file} because GITBUCKET_TOKEN is missing.`);
continue;
}
// Create
const created = await createRemoteIssue(meta.title, body);
// Rename file and update meta
const newMeta = {
id: created.number,
state: created.state,
title: created.title,
updated_at: created.updated_at
};
const newContent = stringifyMarkdown(newMeta, created.body);
const newFileName = `Issue-${created.number}.md`;
fs.writeFileSync(path.join(ISSUES_DIR, newFileName), newContent);
fs.unlinkSync(filePath);
console.log(`[Created] ${newFileName}`);
remoteMap.set(created.number.toString(), created); // prevent re-pulling below
} else {
// Update existing if local is newer
const idStr = meta.id.toString();
const remote = remoteMap.get(idStr);
if (remote) {
const localMtime = new Date(localStat.mtime).getTime();
const remoteUtime = new Date(remote.updated_at).getTime();
// Add 5 seconds buffer to prevent infinite loop
if (localMtime > remoteUtime + 5000) {
if (!TOKEN) {
console.warn(`[Skip] Cannot push updates to #${idStr} because GITBUCKET_TOKEN is missing.`);
continue;
}
const updated = await updateRemoteIssue(idStr, meta.title, body, meta.state || 'open');
if (updated && !updated._manual) {
// Update local metadata timestamp to match remote
const newMeta = { ...meta, state: updated.state, updated_at: updated.updated_at };
fs.writeFileSync(filePath, stringifyMarkdown(newMeta, updated.body));
console.log(`[Pushed] Updated #${idStr} on remote.`);
} else {
console.log(`[Skip] Remote update for #${idStr} requires manual intervention or failed.`);
}
// Remove from remote map so we don't overwrite it in Step 2
remoteMap.delete(idStr);
}
}
}
}
// 2. Remote -> Local (Pull)
for (const [idStr, remote] of remoteMap.entries()) {
const fileName = `Issue-${idStr}.md`;
const filePath = path.join(ISSUES_DIR, fileName);
const newMeta = {
id: remote.number,
state: remote.state,
title: remote.title,
updated_at: remote.updated_at
};
const newContent = stringifyMarkdown(newMeta, remote.body || '');
if (fs.existsSync(filePath)) {
const currentContent = fs.readFileSync(filePath, 'utf-8');
const { meta } = parseMarkdown(currentContent);
// Only overwrite if remote is newer
const localUtime = meta.updated_at ? new Date(meta.updated_at).getTime() : 0;
const remoteUtime = new Date(remote.updated_at).getTime();
if (remoteUtime > localUtime) {
fs.writeFileSync(filePath, newContent);
console.log(`[Pulled] Updated local #${idStr}.`);
}
} else {
// New file from remote
fs.writeFileSync(filePath, newContent);
console.log(`[Pulled] Created local #${idStr}.`);
}
}
console.log("Sync Complete.");
} catch (err) {
console.error("Sync failed:", err);
}
}
sync();