Merge pull request 'feat/m2-apply-ticket-confort' (#42) from feat/m2-apply-ticket-confort into master
Some checks failed
CI / build-and-anchors (push) Failing after 33s

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-01-20 22:01:05 +01:00

View File

@@ -4,22 +4,35 @@ import path from "node:path";
import process from "node:process"; import process from "node:process";
import { spawnSync } from "node:child_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 dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit)
*/
function usage(exitCode = 0) { function usage(exitCode = 0) {
console.log(` console.log(`
apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste) apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste)
Usage: Usage:
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build] [--alias] [--commit] node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build] [--alias] [--commit] [--close]
Flags: Flags:
--dry-run : ne modifie rien, affiche BEFORE/AFTER --dry-run : ne modifie rien, affiche BEFORE/AFTER
--no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias) --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 --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) --commit : git add + git commit automatiquement (inclut alias si --alias)
--close : ferme automatiquement le ticket après commit (+ commentaire avec SHA)
Env (recommandé): Env (recommandé):
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 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) FORGE_TOKEN = PAT (accès repo + issues)
GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote) GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote)
GITEA_REPO = repo (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: Notes:
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build. - Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit) - Sauvegarde automatique: <fichier>.bak.issue-<N> (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); process.exit(exitCode);
} }
@@ -45,6 +59,7 @@ const DRY_RUN = argv.includes("--dry-run");
const NO_BUILD = argv.includes("--no-build"); const NO_BUILD = argv.includes("--no-build");
const DO_ALIAS = argv.includes("--alias"); const DO_ALIAS = argv.includes("--alias");
const DO_COMMIT = argv.includes("--commit"); const DO_COMMIT = argv.includes("--commit");
const DO_CLOSE = argv.includes("--close");
if (DO_ALIAS && NO_BUILD) { if (DO_ALIAS && NO_BUILD) {
console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux)."); console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux).");
@@ -52,8 +67,17 @@ if (DO_ALIAS && NO_BUILD) {
process.exit(1); process.exit(1);
} }
if (DRY_RUN && (DO_ALIAS || DO_COMMIT)) { if (DRY_RUN && (DO_ALIAS || DO_COMMIT || DO_CLOSE)) {
console.warn(" --dry-run : --alias/--commit sont ignorés (aucune écriture)."); 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") { if (typeof fetch !== "function") {
@@ -66,6 +90,8 @@ const CONTENT_ROOT = path.join(CWD, "src", "content");
const DIST_ROOT = path.join(CWD, "dist"); const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
/* -------------------------- utils texte / matching -------------------------- */
function normalizeText(s) { function normalizeText(s) {
return String(s ?? "") return String(s ?? "")
.normalize("NFKD") .normalize("NFKD")
@@ -82,11 +108,11 @@ function normalizeText(s) {
// stripping très pragmatique // stripping très pragmatique
function stripMd(mdx) { function stripMd(mdx) {
let s = String(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, " "); // images
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
s = s.replace(/[*_~]/g, " "); // emphasis-ish s = s.replace(/[*_~]/g, " "); // emphasis-ish
s = s.replace(/<[^>]+>/g, " "); // html tags s = s.replace(/<[^>]+>/g, " "); // html tags
s = s.replace(/\s+/g, " ").trim(); s = s.replace(/\s+/g, " ").trim();
return s; return s;
} }
@@ -99,6 +125,55 @@ function tokenize(s) {
.filter((w) => w.length >= 4); .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 = {}) { function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
if (r.error) throw r.error; if (r.error) throw r.error;
@@ -116,7 +191,12 @@ function runQuiet(cmd, args, opts = {}) {
} }
async function fileExists(p) { 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 = "") { function getEnv(name, fallback = "") {
@@ -132,21 +212,31 @@ function inferOwnerRepoFromGit() {
return { owner: m.groups.owner, repo: m.groups.repo }; 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) { function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function pickLine(body, key) { function pickLine(body, key) {
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi"); 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() : ""; return m ? m[1].trim() : "";
} }
function pickHeadingValue(body, headingKey) { function pickHeadingValue(body, headingKey) {
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi"); const re = new RegExp(
const m = body.match(re); `^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
"mi"
);
const m = String(body || "").match(re);
if (!m) return ""; 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) { for (const l of lines) {
if (!l) continue; if (!l) continue;
if (l.startsWith("<!--")) continue; if (l.startsWith("<!--")) continue;
@@ -156,18 +246,25 @@ function pickHeadingValue(body, headingKey) {
} }
function pickSection(body, markers) { function pickSection(body, markers) {
const text = body.replace(/\r\n/g, "\n"); const text = String(body || "").replace(/\r\n/g, "\n");
const idx = markers const idx = markers
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) })) .map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter(x => x.i >= 0) .filter((x) => x.i >= 0)
.sort((a, b) => a.i - b.i)[0]; .sort((a, b) => a.i - b.i)[0];
if (!idx) return ""; if (!idx) return "";
const start = idx.i + idx.m.length; const start = idx.i + idx.m.length;
const tail = text.slice(start); const tail = text.slice(start);
const stops = [ const stops = [
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources", "\n## ",
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème" "\nJustification",
"\n---",
"\n## Justification",
"\n## Sources",
"\nProblème identifié",
"\nSources proposées",
"\n## Proposition",
"\n## Problème",
]; ];
let end = tail.length; let end = tail.length;
for (const s of stops) { for (const s of stops) {
@@ -180,28 +277,58 @@ function pickSection(body, markers) {
function unquoteBlock(s) { function unquoteBlock(s) {
return String(s ?? "") return String(s ?? "")
.split(/\r?\n/) .split(/\r?\n/)
.map(l => l.replace(/^\s*>\s?/, "")) .map((l) => l.replace(/^\s*>\s?/, ""))
.join("\n") .join("\n")
.trim(); .trim();
} }
async function readHtmlParagraphText(htmlPath, anchorId) { function normalizeChemin(chemin) {
const html = await fs.readFile(htmlPath, "utf-8"); let c = String(chemin || "").trim();
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i"); if (!c) return "";
const m = html.match(re); if (!c.startsWith("/")) c = "/" + c;
if (!m) return ""; if (!c.endsWith("/")) c = c + "/";
return cleanHtmlInner(m[1]); return c;
} }
function extractAnchorIdAnywhere(text) {
const s = String(text || "");
const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
return m ? m[1] : "";
}
function extractCheminFromAnyUrl(text) {
const s = String(text || "");
// Exemple: http://localhost:4321/archicratie/prologue/#p-3-xxxx
// ou: /archicratie/prologue/#p-3-xxxx
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : "";
}
/* --------------------------- lecture HTML paragraphe ------------------------ */
function cleanHtmlInner(inner) { function cleanHtmlInner(inner) {
let s = String(inner ?? ""); let s = String(inner ?? "");
s = s.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " "); s = s.replace(
/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi,
" "
);
s = s.replace(/<[^>]+>/g, " "); s = s.replace(/<[^>]+>/g, " ");
s = s.replace(/\s+/g, " ").trim(); s = s.replace(/\s+/g, " ").trim();
s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim(); s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
return s; return s;
} }
async function readHtmlParagraphText(htmlPath, anchorId) {
const html = await fs.readFile(htmlPath, "utf-8");
const re = new RegExp(
`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`,
"i"
);
const m = html.match(re);
if (!m) return "";
return cleanHtmlInner(m[1]);
}
async function readAllHtmlParagraphs(htmlPath) { async function readAllHtmlParagraphs(htmlPath) {
const html = await fs.readFile(htmlPath, "utf-8"); const html = await fs.readFile(htmlPath, "utf-8");
const out = []; const out = [];
@@ -213,68 +340,21 @@ async function readAllHtmlParagraphs(htmlPath) {
return out; return out;
} }
function splitParagraphBlocks(mdxText) { /* --------------------------- localisation fichier contenu ------------------- */
const raw = 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 (t.includes("tronqu")) return true;
return false;
}
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++;
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;
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 normalizeChemin(chemin) {
let c = String(chemin || "").trim();
if (!c.startsWith("/")) c = "/" + c;
if (!c.endsWith("/")) c = c + "/";
return c;
}
async function findContentFileFromChemin(chemin) { async function findContentFileFromChemin(chemin) {
const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, ""); const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
const parts = clean.split("/").filter(Boolean); const parts = clean.split("/").filter(Boolean);
if (parts.length < 2) return null; if (parts.length < 2) return null;
const collection = parts[0]; const collection = parts[0];
const slugPath = parts.slice(1).join("/"); const slugPath = parts.slice(1).join("/");
const root = path.join(CONTENT_ROOT, collection); const root = path.join(CONTENT_ROOT, collection);
if (!(await fileExists(root))) return null; if (!(await fileExists(root))) return null;
const exts = [".mdx", ".md"]; const exts = [".mdx", ".md"];
async function walk(dir) { async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) { for (const e of entries) {
@@ -294,36 +374,67 @@ async function findContentFileFromChemin(chemin) {
} }
return null; return null;
} }
return await walk(root); return await walk(root);
} }
/* -------------------------------- build helper ----------------------------- */
async function ensureBuildIfNeeded(distHtmlPath) { async function ensureBuildIfNeeded(distHtmlPath) {
if (NO_BUILD) return; if (NO_BUILD) return;
if (await fileExists(distHtmlPath)) return; if (await fileExists(distHtmlPath)) return;
console.log(" dist manquant pour cette page → build (npm run build) …"); console.log(" dist manquant pour cette page → build (npm run build) …");
run("npm", ["run", "build"], { cwd: CWD }); run("npm", ["run", "build"], { cwd: CWD });
if (!(await fileExists(distHtmlPath))) { if (!(await fileExists(distHtmlPath))) {
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`); throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
} }
} }
/* ----------------------------- API Gitea helpers --------------------------- */
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) { async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`; const url = `${forgeApiBase.replace(/\/+$/, "")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
"Authorization": `token ${token}`, Authorization: `token ${token}`,
"Accept": "application/json", Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/1.2", "User-Agent": "archicratie-apply-ticket/2.0",
} },
}); });
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(()=> ""); const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`); throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
} }
return await res.json(); return await res.json();
} }
async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
const base = forgeApiBase.replace(/\/+$/, "");
const headers = {
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
};
if (comment) {
const urlC = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
await fetch(urlC, { method: "POST", headers, body: JSON.stringify({ body: comment }) });
}
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} closing issue: ${url}\n${t}`);
}
}
/* ------------------------------ Aliases helpers ---------------------------- */
async function loadAliases() { async function loadAliases() {
try { try {
const s = await fs.readFile(ALIASES_FILE, "utf8"); const s = await fs.readFile(ALIASES_FILE, "utf8");
@@ -340,7 +451,6 @@ function sortObjectKeys(obj) {
async function saveAliases(obj) { async function saveAliases(obj) {
let out = obj || {}; let out = obj || {};
// tri stable
for (const k of Object.keys(out)) { for (const k of Object.keys(out)) {
if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]); if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
} }
@@ -360,7 +470,9 @@ async function upsertAlias({ chemin, oldId, newId }) {
const prev = data[route][oldId]; const prev = data[route][oldId];
if (prev && prev !== newId) { if (prev && prev !== newId) {
throw new Error(`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`); throw new Error(
`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`
);
} }
if (prev === newId) return { changed: false, reason: "already" }; if (prev === newId) return { changed: false, reason: "already" };
@@ -369,34 +481,30 @@ async function upsertAlias({ chemin, oldId, newId }) {
return { changed: true, reason: "written" }; return { changed: true, reason: "written" };
} }
function gitHasStagedChanges() {
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
return r.status === 1;
}
async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) { async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
const paras = await readAllHtmlParagraphs(distHtmlPath); const paras = await readAllHtmlParagraphs(distHtmlPath);
if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`); if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`);
let best = { id: null, score: -1, text: "" }; let best = { id: null, score: -1 };
const target = stripMd(afterBlock).slice(0, 1200); const target = stripMd(afterBlock).slice(0, 1200);
for (const p of paras) { for (const p of paras) {
const sc = scoreText(p.text, target); const sc = scoreText(p.text, target);
if (sc > best.score) best = { id: p.id, score: sc, text: p.text }; if (sc > best.score) best = { id: p.id, score: sc };
} }
// seuil de sécurité (évite alias faux)
if (!best.id || best.score < 60) { if (!best.id || best.score < 60) {
throw new Error( throw new Error(
`Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` + `Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
`➡️ Vérifie que la proposition correspond bien à UN paragraphe.` `➡️ Vérifie que la proposition correspond bien à UN paragraphe.`
); );
} }
return best.id; return best.id;
} }
/* ----------------------------------- MAIN ---------------------------------- */
async function main() { async function main() {
const token = getEnv("FORGE_TOKEN"); const token = getEnv("FORGE_TOKEN");
if (!token) { if (!token) {
@@ -406,7 +514,7 @@ async function main() {
const inferred = inferOwnerRepoFromGit() || {}; const inferred = inferOwnerRepoFromGit() || {};
const owner = getEnv("GITEA_OWNER", inferred.owner || ""); const owner = getEnv("GITEA_OWNER", inferred.owner || "");
const repo = getEnv("GITEA_REPO", inferred.repo || ""); const repo = getEnv("GITEA_REPO", inferred.repo || "");
if (!owner || !repo) { if (!owner || !repo) {
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=..."); console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
process.exit(1); process.exit(1);
@@ -421,20 +529,54 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`); console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum }); const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
const body = String(issue.body || "").replace(/\r\n/g, "\n"); // Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`);
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
process.exit(2);
}
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const title = String(issue.title || "");
let chemin =
pickLine(body, "Chemin") ||
pickHeadingValue(body, "Chemin") ||
extractCheminFromAnyUrl(body) ||
extractCheminFromAnyUrl(title);
let ancre =
pickLine(body, "Ancre") ||
pickHeadingValue(body, "Ancre paragraphe") ||
pickHeadingValue(body, "Ancre");
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
chemin = normalizeChemin(chemin);
ancre = (ancre || "").trim(); ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1); if (ancre.startsWith("#")) ancre = ancre.slice(1);
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]); // fallback si ticket mal formé
const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]); if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin);
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); const texteActuel = unquoteBlock(currentFull || currentEx);
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]); const prop1 = pickSection(body, [
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]); "Proposition (texte corrigé complet)",
"## Proposition (texte corrigé complet)",
]);
const prop2 = pickSection(body, [
"Proposition (remplacer par):",
"## Proposition (remplacer par)",
]);
const proposition = (prop1 || prop2).trim(); const proposition = (prop1 || prop2).trim();
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body."); if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
@@ -447,13 +589,13 @@ async function main() {
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`); if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`); console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html"); const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
await ensureBuildIfNeeded(distHtmlPath); await ensureBuildIfNeeded(distHtmlPath);
// targetText: préférence au texte complet (ticket), sinon dist si extrait probable // Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel; let targetText = texteActuel;
let distText = ""; let distText = "";
if (await fileExists(distHtmlPath)) { if (await fileExists(distHtmlPath)) {
distText = await readHtmlParagraphText(distHtmlPath, ancre); distText = await readHtmlParagraphText(distHtmlPath, ancre);
} }
@@ -476,6 +618,7 @@ async function main() {
if (best.i < 0 || best.score < 40) { if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement."); console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`); console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
const ranked = blocks const ranked = blocks
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) })) .map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
@@ -534,7 +677,7 @@ async function main() {
if (aliasChanged) { if (aliasChanged) {
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`); console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
// met à jour dist immédiatement (sans rebuild complet) // MàJ dist sans rebuild complet (inject seulement)
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD }); run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
} else { } else {
console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`); console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
@@ -558,15 +701,30 @@ async function main() {
const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`; const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
run("git", ["commit", "-m", msg], { cwd: CWD }); run("git", ["commit", "-m", msg], { cwd: CWD });
console.log(`✅ Committed: ${msg}`);
const sha = runQuiet("git", ["rev-parse", "--short", "HEAD"], { cwd: CWD }).trim();
console.log(`✅ Committed: ${msg} (${sha})`);
if (DO_CLOSE) {
const comment = `✅ Appliqué par apply-ticket.\nCommit: ${sha}`;
await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
console.log(`✅ Ticket #${issueNum} fermé.`);
}
return; return;
} }
// mode manuel (historique) // mode manuel
console.log("Next (manuel) :"); console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`); console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log(` git add ${path.relative(CWD, contentFile)}${DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""}`); console.log(
` git add ${path.relative(CWD, contentFile)}${
DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""
}`
);
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`); console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
if (DO_CLOSE) {
console.log(" (puis relance avec --commit --close pour fermer automatiquement)");
}
} }
main().catch((e) => { main().catch((e) => {