Files
archicratie-edition/scripts/apply-ticket.mjs

385 lines
13 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <issue_number> [--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/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: <fichier>.bak.issue-<N>
`);
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(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.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("<!--")) continue;
return l.replace(/^\/?/, "/").trim();
}
return "";
}
function pickSection(body, markers) {
// capture bloc après le 1er marker trouvé, jusqu'à un séparateur connu
const text = body.replace(/\r\n/g, "\n");
const idx = markers
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter(x => x.i >= 0)
.sort((a, b) => a.i - b.i)[0];
if (!idx) return "";
const start = idx.i + idx.m.length;
const tail = text.slice(start);
// stop markers (robuste)
const stops = [
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
];
let end = tail.length;
for (const s of stops) {
const j = tail.toLowerCase().indexOf(s.toLowerCase());
if (j >= 0 && j < end) end = j;
}
return tail.slice(0, end).trim();
}
function unquoteBlock(s) {
// enlève ">" de citation markdown
return String(s ?? "")
.split(/\r?\n/)
.map(l => l.replace(/^\s*>\s?/, ""))
.join("\n")
.trim();
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function readHtmlParagraphText(htmlPath, anchorId) {
const html = await fs.readFile(htmlPath, "utf-8");
// cherche <p id="anchorId" ...> ... </p>
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
const m = html.match(re);
if (!m) return "";
let inner = m[1];
// supprime les outils "para-tools" si présents
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
// strip tags
inner = inner.replace(/<[^>]+>/g, " ");
inner = inner.replace(/\s+/g, " ").trim();
// enlève artefacts éventuels
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
return inner;
}
function splitParagraphBlocks(mdxText) {
// bloc = séparé par 2 sauts de ligne (pragmatique)
const raw = mdxText.replace(/\r\n/g, "\n");
const parts = raw.split(/\n{2,}/);
return parts;
}
function bestBlockMatchIndex(blocks, targetText) {
const tgt = normalizeText(stripMd(targetText));
if (!tgt) return -1;
// on compare par inclusion de snippet + score "overlap"
const snippet = tgt.slice(0, Math.min(160, tgt.length));
let best = { i: -1, score: -1 };
for (let i = 0; i < blocks.length; i++) {
const b = normalizeText(stripMd(blocks[i]));
if (!b) continue;
let score = 0;
if (b.includes(snippet)) score += 1000; // jackpot
// overlap par mots (cheap mais robuste)
const words = new Set(tgt.split(" ").filter(w => w.length >= 4));
let hit = 0;
for (const w of words) if (b.includes(w)) hit++;
score += hit;
if (score > best.score) best = { i, score };
}
// seuil minimal : évite remplacement sauvage
if (best.score < 20) return -1;
return best.i;
}
async function findContentFileFromChemin(chemin) {
const clean = chemin.replace(/^\/+|\/+$/g, "");
const parts = clean.split("/").filter(Boolean);
if (parts.length < 2) return null;
const collection = parts[0];
const slugPath = parts.slice(1).join("/"); // support nested
const root = path.join(CONTENT_ROOT, collection);
if (!(await fileExists(root))) return null;
// cherche fichier dont le path relatif (sans ext) == slugPath
const exts = [".mdx", ".md"];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
const r = await walk(full);
if (r) return r;
} else {
for (const ext of exts) {
if (e.name.endsWith(ext)) {
const rel = path.relative(root, full).replace(/\\/g, "/");
const relNoExt = rel.slice(0, -ext.length);
if (relNoExt === slugPath) return full;
}
}
}
}
return null;
}
return await walk(root);
}
async function ensureBuildIfNeeded(distHtmlPath) {
if (NO_BUILD) return;
if (await fileExists(distHtmlPath)) return;
console.log(" dist manquant pour cette page → build (npm run build) …");
run("npm", ["run", "build"], { cwd: CWD });
if (!(await fileExists(distHtmlPath))) {
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
}
}
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, {
headers: {
"Authorization": `token ${token}`,
"Accept": "application/json",
"User-Agent": "archicratie-apply-ticket/1.0",
}
});
if (!res.ok) {
const t = await res.text().catch(()=> "");
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
}
return await res.json();
}
async function main() {
const token = getEnv("FORGE_TOKEN");
if (!token) {
console.error("❌ FORGE_TOKEN manquant (PAT). Ex: export FORGE_TOKEN='...'");
process.exit(1);
}
const inferred = inferOwnerRepoFromGit() || {};
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
const repo = getEnv("GITEA_REPO", inferred.repo || "");
if (!owner || !repo) {
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
process.exit(1);
}
// API base: priorise LAN (FORGE_API), sinon FORGE_BASE
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
if (!forgeApiBase) {
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
process.exit(1);
}
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
const title = issue.title || "";
const bodyRaw = issue.body || "";
const body = bodyRaw.replace(/\r\n/g, "\n");
// Chemin / Ancre: support format "Chemin:" OU "## Chemin"
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
ancre = ancre.trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1);
// Texte actuel: support "Texte actuel (copie exacte...)" OU "Texte actuel (extrait)"
const current1 = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
const current2 = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
const texteActuel = unquoteBlock(current1 || current2);
// Proposition: support 2 modèles
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
const proposition = (prop1 || prop2).trim();
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
if (!ancre) throw new Error("Ticket: Ancre introuvable dans le body.");
if (!proposition) throw new Error("Ticket: Proposition introuvable dans le body.");
console.log("✅ Parsed:", { chemin, ancre: `#${ancre}`, hasTexteActuel: Boolean(texteActuel) });
const contentFile = await findContentFileFromChemin(chemin);
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
// dist html path
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
await ensureBuildIfNeeded(distHtmlPath);
// texte cible: priorité au texte actuel du ticket, sinon récup HTML du paragraphe via ancre
let targetText = texteActuel;
if (!targetText) {
if (await fileExists(distHtmlPath)) {
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
if (htmlText) targetText = htmlText;
}
}
if (!targetText) {
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
}
// lecture + split blocs
const original = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original);
const idx = bestBlockMatchIndex(blocks, targetText);
if (idx < 0) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error("➡️ Action: mets 'Texte actuel (copie exacte du paragraphe)' dans le ticket (recommandé).");
process.exit(2);
}
const beforeBlock = blocks[idx];
const afterBlock = proposition.trim();
// garde le style: 1 bloc -> 1 bloc
const nextBlocks = blocks.slice();
nextBlocks[idx] = afterBlock;
const updated = nextBlocks.join("\n\n");
// backup
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, original, "utf-8");
}
// preview stats
console.log(`🧩 Matched block #${idx+1}/${blocks.length} (backup: ${path.relative(CWD, bakPath)})`);
if (DRY_RUN) {
console.log("\n--- DRY RUN (no write) ---\n");
console.log("=== BEFORE (excerpt) ===");
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
console.log("\n=== AFTER (excerpt) ===");
console.log(afterBlock.slice(0, 400) + (afterBlock.length > 400 ? "…" : ""));
console.log("\n✅ Dry-run terminé.");
return;
}
await fs.writeFile(contentFile, updated, "utf-8");
console.log("✅ Applied. Next:");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log(` git add ${path.relative(CWD, contentFile)}`);
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
}
main().catch((e) => {
console.error("💥", e?.message || e);
process.exit(1);
});