#!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; function usage(exitCode = 0) { console.log(` apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste) Usage: node scripts/apply-ticket.mjs [--dry-run] [--no-build] Env (recommandé): FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 (évite DNS) FORGE_BASE = base web ex: https://gitea.xxx.tld FORGE_TOKEN = PAT (avec accès au repo + issues) GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote) GITEA_REPO = repo (optionnel si auto-détecté depuis git remote) Notes: - Si dist//index.html est absent, le script lance "npm run build" sauf si --no-build. - Sauvegarde automatique: .bak.issue- `); process.exit(exitCode); } const argv = process.argv.slice(2); if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0); const issueNum = Number(argv[0]); if (!Number.isFinite(issueNum) || issueNum <= 0) { console.error("❌ Numéro de ticket invalide."); usage(1); } const DRY_RUN = argv.includes("--dry-run"); const NO_BUILD = argv.includes("--no-build"); const CWD = process.cwd(); const CONTENT_ROOT = path.join(CWD, "src", "content"); const DIST_ROOT = path.join(CWD, "dist"); function normalizeText(s) { return String(s ?? "") .normalize("NFKD") .replace(/\p{Diacritic}/gu, "") .replace(/\s+/g, " ") .trim() .toLowerCase(); } // stripping très pragmatique (anti-fragile > parfait) function stripMd(mdx) { let s = String(mdx ?? ""); s = s.replace(/`[^`]*`/g, " "); // inline code s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links s = s.replace(/[*_~]/g, " "); // emphasis-ish s = s.replace(/<[^>]+>/g, " "); // html tags s = s.replace(/\s+/g, " ").trim(); return s; } function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); } async function fileExists(p) { try { await fs.access(p); return true; } catch { return false; } } function getEnv(name, fallback = "") { return (process.env[name] ?? fallback).trim(); } function inferOwnerRepoFromGit() { const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }); if (r.status !== 0) return null; const u = (r.stdout || "").trim(); // supports: https://host/owner/repo.git or ssh const m = u.match(/[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); if (!m?.groups) return null; return { owner: m.groups.owner, repo: m.groups.repo }; } function pickLine(body, key) { // tolère espaces/indent const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi"); const m = body.match(re); return m ? m[1].trim() : ""; } function pickHeadingValue(body, headingKey) { // ex: "## Chemin ..." ligne suivante contenant /... const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi"); const m = body.match(re); if (!m) return ""; // première ligne non vide et non commentée const lines = m[1].split(/\r?\n/).map(l => l.trim()); for (const l of lines) { if (!l) continue; if (l.startsWith("