feat/m2-apply-ticket-confort #42
@@ -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 <issue_number> [--dry-run] [--no-build] [--alias] [--commit]
|
||||
node scripts/apply-ticket.mjs <issue_number> [--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/<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)
|
||||
- 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")
|
||||
@@ -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("<!--")) continue;
|
||||
@@ -156,18 +246,25 @@ function pickHeadingValue(body, headingKey) {
|
||||
}
|
||||
|
||||
function pickSection(body, markers) {
|
||||
const text = body.replace(/\r\n/g, "\n");
|
||||
const text = String(body || "").replace(/\r\n/g, "\n");
|
||||
const idx = markers
|
||||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||
.filter(x => x.i >= 0)
|
||||
.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);
|
||||
|
||||
const stops = [
|
||||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||||
"\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) {
|
||||
@@ -180,42 +277,58 @@ function pickSection(body, markers) {
|
||||
function unquoteBlock(s) {
|
||||
return String(s ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.replace(/^\s*>\s?/, ""))
|
||||
.map((l) => l.replace(/^\s*>\s?/, ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeChemin(chemin) {
|
||||
let c = String(chemin || "").trim();
|
||||
if (!c) return "";
|
||||
if (!c.startsWith("/")) c = "/" + c;
|
||||
if (!c.endsWith("/")) c = c + "/";
|
||||
return c;
|
||||
}
|
||||
|
||||
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-..."
|
||||
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] : "";
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
/* --------------------------- lecture HTML paragraphe ------------------------ */
|
||||
|
||||
function cleanHtmlInner(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(/\s+/g, " ").trim();
|
||||
s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||||
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) {
|
||||
const html = await fs.readFile(htmlPath, "utf-8");
|
||||
const out = [];
|
||||
@@ -227,68 +340,21 @@ async function readAllHtmlParagraphs(htmlPath) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitParagraphBlocks(mdxText) {
|
||||
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;
|
||||
}
|
||||
/* --------------------------- localisation fichier contenu ------------------- */
|
||||
|
||||
async function findContentFileFromChemin(chemin) {
|
||||
const clean = normalizeChemin(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("/");
|
||||
|
||||
const root = path.join(CONTENT_ROOT, collection);
|
||||
if (!(await fileExists(root))) return null;
|
||||
|
||||
const exts = [".mdx", ".md"];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
@@ -308,28 +374,34 @@ async function findContentFileFromChemin(chemin) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return await walk(root);
|
||||
}
|
||||
|
||||
/* -------------------------------- build helper ----------------------------- */
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- API Gitea helpers --------------------------- */
|
||||
|
||||
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.2",
|
||||
}
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "archicratie-apply-ticket/2.0",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
@@ -338,6 +410,31 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||
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() {
|
||||
try {
|
||||
const s = await fs.readFile(ALIASES_FILE, "utf8");
|
||||
@@ -354,7 +451,6 @@ function sortObjectKeys(obj) {
|
||||
|
||||
async function saveAliases(obj) {
|
||||
let out = obj || {};
|
||||
// tri stable
|
||||
for (const k of Object.keys(out)) {
|
||||
if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
|
||||
}
|
||||
@@ -374,7 +470,9 @@ async function upsertAlias({ chemin, oldId, newId }) {
|
||||
|
||||
const prev = data[route][oldId];
|
||||
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" };
|
||||
|
||||
@@ -383,24 +481,18 @@ async function upsertAlias({ chemin, oldId, newId }) {
|
||||
return { changed: true, reason: "written" };
|
||||
}
|
||||
|
||||
function gitHasStagedChanges() {
|
||||
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
|
||||
return r.status === 1;
|
||||
}
|
||||
|
||||
async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
|
||||
const paras = await readAllHtmlParagraphs(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);
|
||||
|
||||
for (const p of paras) {
|
||||
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) {
|
||||
throw new Error(
|
||||
`Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
|
||||
@@ -411,6 +503,8 @@ async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
|
||||
return best.id;
|
||||
}
|
||||
|
||||
/* ----------------------------------- MAIN ---------------------------------- */
|
||||
|
||||
async function main() {
|
||||
const token = getEnv("FORGE_TOKEN");
|
||||
if (!token) {
|
||||
@@ -435,14 +529,21 @@ async function main() {
|
||||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||
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 d’ajout), 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(body) ||
|
||||
extractCheminFromAnyUrl(title);
|
||||
|
||||
let ancre =
|
||||
pickLine(body, "Ancre") ||
|
||||
@@ -452,15 +553,30 @@ async function main() {
|
||||
ancre = (ancre || "").trim();
|
||||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||||
|
||||
// ✅ fallback si le ticket est mal formé
|
||||
// fallback si ticket 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"]);
|
||||
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 prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
||||
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
||||
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.");
|
||||
@@ -476,10 +592,10 @@ async function main() {
|
||||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
|
||||
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 distText = "";
|
||||
|
||||
if (await fileExists(distHtmlPath)) {
|
||||
distText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||
}
|
||||
@@ -502,6 +618,7 @@ async function main() {
|
||||
if (best.i < 0 || best.score < 40) {
|
||||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
||||
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
|
||||
|
||||
const ranked = blocks
|
||||
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
@@ -560,7 +677,7 @@ async function main() {
|
||||
|
||||
if (aliasChanged) {
|
||||
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 });
|
||||
} else {
|
||||
console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
|
||||
@@ -584,15 +701,30 @@ async function main() {
|
||||
|
||||
const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
|
||||
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;
|
||||
}
|
||||
|
||||
// mode manuel (historique)
|
||||
// mode manuel
|
||||
console.log("Next (manuel) :");
|
||||
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})"`);
|
||||
if (DO_CLOSE) {
|
||||
console.log(" (puis relance avec --commit --close pour fermer automatiquement)");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
Reference in New Issue
Block a user