Newer
Older
TelosDB / tools / scripts / sync_issues.mjs
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();