tools: track apply-ticket script
This commit is contained in:
384
scripts/apply-ticket.mjs
Executable file
384
scripts/apply-ticket.mjs
Executable file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function usage(exitCode = 0) {
|
||||
console.log(`
|
||||
apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste)
|
||||
|
||||
Usage:
|
||||
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build]
|
||||
|
||||
Env (recommandé):
|
||||
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 (évite DNS)
|
||||
FORGE_BASE = base web ex: https://gitea.xxx.tld
|
||||
FORGE_TOKEN = PAT (avec accès au repo + issues)
|
||||
GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote)
|
||||
GITEA_REPO = repo (optionnel si auto-détecté depuis git remote)
|
||||
|
||||
Notes:
|
||||
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
||||
- Sauvegarde automatique: <fichier>.bak.issue-<N>
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0);
|
||||
|
||||
const issueNum = Number(argv[0]);
|
||||
if (!Number.isFinite(issueNum) || issueNum <= 0) {
|
||||
console.error("❌ Numéro de ticket invalide.");
|
||||
usage(1);
|
||||
}
|
||||
|
||||
const DRY_RUN = argv.includes("--dry-run");
|
||||
const NO_BUILD = argv.includes("--no-build");
|
||||
|
||||
const CWD = process.cwd();
|
||||
const CONTENT_ROOT = path.join(CWD, "src", "content");
|
||||
const DIST_ROOT = path.join(CWD, "dist");
|
||||
|
||||
function normalizeText(s) {
|
||||
return String(s ?? "")
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// stripping très pragmatique (anti-fragile > parfait)
|
||||
function stripMd(mdx) {
|
||||
let s = String(mdx ?? "");
|
||||
s = s.replace(/`[^`]*`/g, " "); // inline code
|
||||
s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images
|
||||
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
|
||||
s = s.replace(/[*_~]/g, " "); // emphasis-ish
|
||||
s = s.replace(/<[^>]+>/g, " "); // html tags
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
async function fileExists(p) {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function getEnv(name, fallback = "") {
|
||||
return (process.env[name] ?? fallback).trim();
|
||||
}
|
||||
|
||||
function inferOwnerRepoFromGit() {
|
||||
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
||||
if (r.status !== 0) return null;
|
||||
const u = (r.stdout || "").trim();
|
||||
// supports: https://host/owner/repo.git or ssh
|
||||
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
||||
if (!m?.groups) return null;
|
||||
return { owner: m.groups.owner, repo: m.groups.repo };
|
||||
}
|
||||
|
||||
function pickLine(body, key) {
|
||||
// tolère espaces/indent
|
||||
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||
const m = body.match(re);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
function pickHeadingValue(body, headingKey) {
|
||||
// ex: "## Chemin ..." ligne suivante contenant /...
|
||||
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
|
||||
const m = body.match(re);
|
||||
if (!m) return "";
|
||||
// première ligne non vide et non commentée
|
||||
const lines = m[1].split(/\r?\n/).map(l => l.trim());
|
||||
for (const l of lines) {
|
||||
if (!l) continue;
|
||||
if (l.startsWith("<!--")) continue;
|
||||
return l.replace(/^\/?/, "/").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickSection(body, markers) {
|
||||
// capture bloc après le 1er marker trouvé, jusqu'à un séparateur connu
|
||||
const text = body.replace(/\r\n/g, "\n");
|
||||
const idx = markers
|
||||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||
.filter(x => x.i >= 0)
|
||||
.sort((a, b) => a.i - b.i)[0];
|
||||
if (!idx) return "";
|
||||
const start = idx.i + idx.m.length;
|
||||
const tail = text.slice(start);
|
||||
|
||||
// stop markers (robuste)
|
||||
const stops = [
|
||||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||||
];
|
||||
let end = tail.length;
|
||||
for (const s of stops) {
|
||||
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
||||
if (j >= 0 && j < end) end = j;
|
||||
}
|
||||
return tail.slice(0, end).trim();
|
||||
}
|
||||
|
||||
function unquoteBlock(s) {
|
||||
// enlève ">" de citation markdown
|
||||
return String(s ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.replace(/^\s*>\s?/, ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function readHtmlParagraphText(htmlPath, anchorId) {
|
||||
const html = await fs.readFile(htmlPath, "utf-8");
|
||||
// cherche <p id="anchorId" ...> ... </p>
|
||||
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
|
||||
const m = html.match(re);
|
||||
if (!m) return "";
|
||||
let inner = m[1];
|
||||
|
||||
// supprime les outils "para-tools" si présents
|
||||
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
||||
|
||||
// strip tags
|
||||
inner = inner.replace(/<[^>]+>/g, " ");
|
||||
inner = inner.replace(/\s+/g, " ").trim();
|
||||
|
||||
// enlève artefacts éventuels
|
||||
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||||
return inner;
|
||||
}
|
||||
|
||||
function splitParagraphBlocks(mdxText) {
|
||||
// bloc = séparé par 2 sauts de ligne (pragmatique)
|
||||
const raw = mdxText.replace(/\r\n/g, "\n");
|
||||
const parts = raw.split(/\n{2,}/);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function bestBlockMatchIndex(blocks, targetText) {
|
||||
const tgt = normalizeText(stripMd(targetText));
|
||||
if (!tgt) return -1;
|
||||
|
||||
// on compare par inclusion de snippet + score "overlap"
|
||||
const snippet = tgt.slice(0, Math.min(160, tgt.length));
|
||||
let best = { i: -1, score: -1 };
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = normalizeText(stripMd(blocks[i]));
|
||||
if (!b) continue;
|
||||
|
||||
let score = 0;
|
||||
if (b.includes(snippet)) score += 1000; // jackpot
|
||||
|
||||
// overlap par mots (cheap mais robuste)
|
||||
const words = new Set(tgt.split(" ").filter(w => w.length >= 4));
|
||||
let hit = 0;
|
||||
for (const w of words) if (b.includes(w)) hit++;
|
||||
score += hit;
|
||||
|
||||
if (score > best.score) best = { i, score };
|
||||
}
|
||||
|
||||
// seuil minimal : évite remplacement sauvage
|
||||
if (best.score < 20) return -1;
|
||||
return best.i;
|
||||
}
|
||||
|
||||
async function findContentFileFromChemin(chemin) {
|
||||
const clean = chemin.replace(/^\/+|\/+$/g, "");
|
||||
const parts = clean.split("/").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
const collection = parts[0];
|
||||
const slugPath = parts.slice(1).join("/"); // support nested
|
||||
const root = path.join(CONTENT_ROOT, collection);
|
||||
if (!(await fileExists(root))) return null;
|
||||
|
||||
// cherche fichier dont le path relatif (sans ext) == slugPath
|
||||
const exts = [".mdx", ".md"];
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
const r = await walk(full);
|
||||
if (r) return r;
|
||||
} else {
|
||||
for (const ext of exts) {
|
||||
if (e.name.endsWith(ext)) {
|
||||
const rel = path.relative(root, full).replace(/\\/g, "/");
|
||||
const relNoExt = rel.slice(0, -ext.length);
|
||||
if (relNoExt === slugPath) return full;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return await walk(root);
|
||||
}
|
||||
|
||||
async function ensureBuildIfNeeded(distHtmlPath) {
|
||||
if (NO_BUILD) return;
|
||||
if (await fileExists(distHtmlPath)) return;
|
||||
|
||||
console.log("ℹ️ dist manquant pour cette page → build (npm run build) …");
|
||||
run("npm", ["run", "build"], { cwd: CWD });
|
||||
if (!(await fileExists(distHtmlPath))) {
|
||||
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `token ${token}`,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "archicratie-apply-ticket/1.0",
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(()=> "");
|
||||
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = getEnv("FORGE_TOKEN");
|
||||
if (!token) {
|
||||
console.error("❌ FORGE_TOKEN manquant (PAT). Ex: export FORGE_TOKEN='...'");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inferred = inferOwnerRepoFromGit() || {};
|
||||
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
|
||||
const repo = getEnv("GITEA_REPO", inferred.repo || "");
|
||||
if (!owner || !repo) {
|
||||
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// API base: priorise LAN (FORGE_API), sinon FORGE_BASE
|
||||
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
||||
if (!forgeApiBase) {
|
||||
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||||
|
||||
const title = issue.title || "";
|
||||
const bodyRaw = issue.body || "";
|
||||
const body = bodyRaw.replace(/\r\n/g, "\n");
|
||||
|
||||
// Chemin / Ancre: support format "Chemin:" OU "## Chemin"
|
||||
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
||||
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
|
||||
ancre = ancre.trim();
|
||||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||||
|
||||
// Texte actuel: support "Texte actuel (copie exacte...)" OU "Texte actuel (extrait)"
|
||||
const current1 = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
||||
const current2 = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
||||
const texteActuel = unquoteBlock(current1 || current2);
|
||||
|
||||
// Proposition: support 2 modèles
|
||||
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
||||
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
||||
const proposition = (prop1 || prop2).trim();
|
||||
|
||||
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
|
||||
if (!ancre) throw new Error("Ticket: Ancre introuvable dans le body.");
|
||||
if (!proposition) throw new Error("Ticket: Proposition introuvable dans le body.");
|
||||
|
||||
console.log("✅ Parsed:", { chemin, ancre: `#${ancre}`, hasTexteActuel: Boolean(texteActuel) });
|
||||
|
||||
const contentFile = await findContentFileFromChemin(chemin);
|
||||
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
||||
|
||||
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
||||
|
||||
// dist html path
|
||||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
||||
await ensureBuildIfNeeded(distHtmlPath);
|
||||
|
||||
// texte cible: priorité au texte actuel du ticket, sinon récup HTML du paragraphe via ancre
|
||||
let targetText = texteActuel;
|
||||
if (!targetText) {
|
||||
if (await fileExists(distHtmlPath)) {
|
||||
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||
if (htmlText) targetText = htmlText;
|
||||
}
|
||||
}
|
||||
if (!targetText) {
|
||||
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
||||
}
|
||||
|
||||
// lecture + split blocs
|
||||
const original = await fs.readFile(contentFile, "utf-8");
|
||||
const blocks = splitParagraphBlocks(original);
|
||||
|
||||
const idx = bestBlockMatchIndex(blocks, targetText);
|
||||
if (idx < 0) {
|
||||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
||||
console.error("➡️ Action: mets 'Texte actuel (copie exacte du paragraphe)' dans le ticket (recommandé).");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const beforeBlock = blocks[idx];
|
||||
const afterBlock = proposition.trim();
|
||||
|
||||
// garde le style: 1 bloc -> 1 bloc
|
||||
const nextBlocks = blocks.slice();
|
||||
nextBlocks[idx] = afterBlock;
|
||||
|
||||
const updated = nextBlocks.join("\n\n");
|
||||
|
||||
// backup
|
||||
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
|
||||
if (!(await fileExists(bakPath))) {
|
||||
await fs.writeFile(bakPath, original, "utf-8");
|
||||
}
|
||||
|
||||
// preview stats
|
||||
console.log(`🧩 Matched block #${idx+1}/${blocks.length} (backup: ${path.relative(CWD, bakPath)})`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log("\n--- DRY RUN (no write) ---\n");
|
||||
console.log("=== BEFORE (excerpt) ===");
|
||||
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
||||
console.log("\n=== AFTER (excerpt) ===");
|
||||
console.log(afterBlock.slice(0, 400) + (afterBlock.length > 400 ? "…" : ""));
|
||||
console.log("\n✅ Dry-run terminé.");
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(contentFile, updated, "utf-8");
|
||||
console.log("✅ Applied. Next:");
|
||||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||||
console.log(` git add ${path.relative(CWD, contentFile)}`);
|
||||
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("💥", e?.message || e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user