From 0abf98aa1f4ebd6a18724de9c5e89d656dec3300 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Tue, 20 Jan 2026 20:24:57 +0100 Subject: [PATCH 1/2] m2: apply-ticket fallback anchor/chemin parsing --- scripts/apply-ticket.mjs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/apply-ticket.mjs b/scripts/apply-ticket.mjs index af3abc3..8892e19 100755 --- a/scripts/apply-ticket.mjs +++ b/scripts/apply-ticket.mjs @@ -185,6 +185,20 @@ function unquoteBlock(s) { .trim(); } +function extractAnchorIdAnywhere(text) { + const s = String(text || ""); + // accepte "#p-3-abcdef12" ou "p-3-abcdef12" + const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i); + return m ? m[1] : ""; +} + +function extractCheminFromAnyUrl(body) { + const s = String(body || ""); + // Cherche un chemin du type "/archicratie/prologue/#p-..." + const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i); + return m ? m[1] : ""; +} + async function readHtmlParagraphText(htmlPath, anchorId) { const html = await fs.readFile(htmlPath, "utf-8"); const re = new RegExp(`]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i"); @@ -423,12 +437,24 @@ async function main() { const body = String(issue.body || "").replace(/\r\n/g, "\n"); - let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin"); - let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre"); - chemin = normalizeChemin(chemin); + const title = String(issue.title || ""); + + let chemin = + pickLine(body, "Chemin") || + pickHeadingValue(body, "Chemin") || + extractCheminFromAnyUrl(body); + + let ancre = + pickLine(body, "Ancre") || + pickHeadingValue(body, "Ancre paragraphe") || + pickHeadingValue(body, "Ancre"); + ancre = (ancre || "").trim(); if (ancre.startsWith("#")) ancre = ancre.slice(1); + // ✅ fallback si le ticket est mal formé + if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body); + const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]); const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]); const texteActuel = unquoteBlock(currentFull || currentEx); From 30d5a2057219ecfc766d7ede4a50c9965434514c Mon Sep 17 00:00:00 2001 From: Archicratia Date: Tue, 20 Jan 2026 21:54:36 +0100 Subject: [PATCH 2/2] m2: apply-ticket supports --close (+ PR guard) --- scripts/apply-ticket.mjs | 362 ++++++++++++++++++++++++++------------- 1 file changed, 247 insertions(+), 115 deletions(-) diff --git a/scripts/apply-ticket.mjs b/scripts/apply-ticket.mjs index 8892e19..31dd18b 100755 --- a/scripts/apply-ticket.mjs +++ b/scripts/apply-ticket.mjs @@ -4,22 +4,35 @@ import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; +/** + * apply-ticket — applique une proposition de correction depuis un ticket Gitea + * + * Conçu pour: + * - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition + * - retrouver le bon paragraphe dans le .mdx + * - remplacer proprement + * - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json + * - optionnel: committer automatiquement + * - optionnel: fermer le ticket (après commit) + */ + 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] [--alias] [--commit] + node scripts/apply-ticket.mjs [--dry-run] [--no-build] [--alias] [--commit] [--close] Flags: --dry-run : ne modifie rien, affiche BEFORE/AFTER --no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias) --alias : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json --commit : git add + git commit automatiquement (inclut alias si --alias) + --close : ferme automatiquement le ticket après commit (+ commentaire avec SHA) Env (recommandé): FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 - FORGE_BASE = base web ex: https://gitea.xxx.tld + FORGE_BASE = base web ex: https://gitea.xxx.tld (fallback si FORGE_API absent) FORGE_TOKEN = PAT (accès repo + issues) GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote) GITEA_REPO = repo (optionnel si auto-détecté depuis git remote) @@ -27,7 +40,8 @@ Env (recommandé): Notes: - Si dist//index.html est absent, le script lance "npm run build" sauf si --no-build. - Sauvegarde automatique: .bak.issue- (uniquement si on écrit) - - Avec --alias : le script build pour obtenir le NOUVEL id, puis écrit l'alias old->new. + - Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new. + - Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux. `); process.exit(exitCode); } @@ -45,6 +59,7 @@ const DRY_RUN = argv.includes("--dry-run"); const NO_BUILD = argv.includes("--no-build"); const DO_ALIAS = argv.includes("--alias"); const DO_COMMIT = argv.includes("--commit"); +const DO_CLOSE = argv.includes("--close"); if (DO_ALIAS && NO_BUILD) { console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux)."); @@ -52,8 +67,17 @@ if (DO_ALIAS && NO_BUILD) { process.exit(1); } -if (DRY_RUN && (DO_ALIAS || DO_COMMIT)) { - console.warn("ℹ️ --dry-run : --alias/--commit sont ignorés (aucune écriture)."); +if (DRY_RUN && (DO_ALIAS || DO_COMMIT || DO_CLOSE)) { + console.warn("ℹ️ --dry-run : --alias/--commit/--close sont ignorés (aucune écriture)."); +} + +if (DO_CLOSE && DRY_RUN) { + console.error("❌ --close est incompatible avec --dry-run."); + process.exit(1); +} +if (DO_CLOSE && !DO_COMMIT) { + console.error("❌ --close nécessite --commit (on ne ferme jamais un ticket sans commit)."); + process.exit(1); } if (typeof fetch !== "function") { @@ -66,6 +90,8 @@ const CONTENT_ROOT = path.join(CWD, "src", "content"); const DIST_ROOT = path.join(CWD, "dist"); const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); +/* -------------------------- utils texte / matching -------------------------- */ + function normalizeText(s) { return String(s ?? "") .normalize("NFKD") @@ -82,11 +108,11 @@ function normalizeText(s) { // stripping très pragmatique function stripMd(mdx) { let s = String(mdx ?? ""); - s = s.replace(/`[^`]*`/g, " "); // inline code + 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(/\[[^\]]*\]\([^)]+\)/g, " "); // links + s = s.replace(/[*_~]/g, " "); // emphasis-ish + s = s.replace(/<[^>]+>/g, " "); // html tags s = s.replace(/\s+/g, " ").trim(); return s; } @@ -99,6 +125,55 @@ function tokenize(s) { .filter((w) => w.length >= 4); } +function scoreText(candidate, targetText) { + const tgt = tokenize(targetText); + const blk = tokenize(candidate); + if (!tgt.length || !blk.length) return 0; + + const tgtSet = new Set(tgt); + const blkSet = new Set(blk); + + let hit = 0; + for (const w of tgtSet) if (blkSet.has(w)) hit++; + + // Bonus si un long préfixe ressemble + const tgtNorm = normalizeText(stripMd(targetText)); + const blkNorm = normalizeText(stripMd(candidate)); + const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length)); + const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0; + + // Ratio bonus (0..100) + const ratio = hit / Math.max(1, tgtSet.size); + const ratioBonus = Math.round(ratio * 100); + + return prefixBonus + hit + ratioBonus; +} + +function bestBlockMatchIndex(blocks, targetText) { + let best = { i: -1, score: -1 }; + for (let i = 0; i < blocks.length; i++) { + const sc = scoreText(blocks[i], targetText); + if (sc > best.score) best = { i, score: sc }; + } + return best; +} + +function splitParagraphBlocks(mdxText) { + const raw = String(mdxText ?? "").replace(/\r\n/g, "\n"); + return raw.split(/\n{2,}/); +} + +function isLikelyExcerpt(s) { + const t = String(s || "").trim(); + if (!t) return true; + if (t.length < 120) return true; + if (/[.…]$/.test(t)) return true; + if (normalizeText(t).includes("tronqu")) return true; + return false; +} + +/* ------------------------------ utils système ------------------------------ */ + function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); if (r.error) throw r.error; @@ -116,7 +191,12 @@ function runQuiet(cmd, args, opts = {}) { } async function fileExists(p) { - try { await fs.access(p); return true; } catch { return false; } + try { + await fs.access(p); + return true; + } catch { + return false; + } } function getEnv(name, fallback = "") { @@ -132,21 +212,31 @@ function inferOwnerRepoFromGit() { return { owner: m.groups.owner, repo: m.groups.repo }; } +function gitHasStagedChanges() { + const r = spawnSync("git", ["diff", "--cached", "--quiet"]); + return r.status === 1; +} + +/* ------------------------------ parsing ticket ----------------------------- */ + function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function pickLine(body, key) { const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi"); - const m = body.match(re); + const m = String(body || "").match(re); return m ? m[1].trim() : ""; } function pickHeadingValue(body, headingKey) { - const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi"); - const m = body.match(re); + const re = new RegExp( + `^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, + "mi" + ); + const m = String(body || "").match(re); if (!m) return ""; - const lines = m[1].split(/\r?\n/).map(l => l.trim()); + const lines = m[1].split(/\r?\n/).map((l) => l.trim()); for (const l of lines) { if (!l) continue; if (l.startsWith("