feat/m2-apply-ticket-confort #42
@@ -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 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) {
|
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")
|
||||||
@@ -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,42 +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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function extractAnchorIdAnywhere(text) {
|
||||||
const s = String(text || "");
|
const s = String(text || "");
|
||||||
// accepte "#p-3-abcdef12" ou "p-3-abcdef12"
|
|
||||||
const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
|
const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
|
||||||
return m ? m[1] : "";
|
return m ? m[1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCheminFromAnyUrl(body) {
|
function extractCheminFromAnyUrl(text) {
|
||||||
const s = String(body || "");
|
const s = String(text || "");
|
||||||
// Cherche un chemin du type "/archicratie/prologue/#p-..."
|
// 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);
|
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
|
||||||
return m ? m[1] : "";
|
return m ? m[1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readHtmlParagraphText(htmlPath, anchorId) {
|
/* --------------------------- lecture HTML paragraphe ------------------------ */
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = [];
|
||||||
@@ -227,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) {
|
||||||
@@ -308,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");
|
||||||
@@ -354,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]);
|
||||||
}
|
}
|
||||||
@@ -374,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" };
|
||||||
|
|
||||||
@@ -383,24 +481,18 @@ 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` +
|
||||||
@@ -411,6 +503,8 @@ async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
|
|||||||
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) {
|
||||||
@@ -435,14 +529,21 @@ 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 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 || "");
|
const title = String(issue.title || "");
|
||||||
|
|
||||||
let chemin =
|
let chemin =
|
||||||
pickLine(body, "Chemin") ||
|
pickLine(body, "Chemin") ||
|
||||||
pickHeadingValue(body, "Chemin") ||
|
pickHeadingValue(body, "Chemin") ||
|
||||||
extractCheminFromAnyUrl(body);
|
extractCheminFromAnyUrl(body) ||
|
||||||
|
extractCheminFromAnyUrl(title);
|
||||||
|
|
||||||
let ancre =
|
let ancre =
|
||||||
pickLine(body, "Ancre") ||
|
pickLine(body, "Ancre") ||
|
||||||
@@ -452,15 +553,30 @@ async function main() {
|
|||||||
ancre = (ancre || "").trim();
|
ancre = (ancre || "").trim();
|
||||||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
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);
|
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
|
||||||
|
|
||||||
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
chemin = normalizeChemin(chemin);
|
||||||
const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
|
||||||
|
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.");
|
||||||
@@ -473,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);
|
||||||
}
|
}
|
||||||
@@ -502,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)
|
||||||
@@ -560,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}).`);
|
||||||
@@ -584,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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user