@@ -4,23 +4,44 @@ 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]
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 (évite DNS)
FORGE_BASE = base web ex: https://gitea.xxx.tld
FORGE_TOKEN = PAT (avec accès au repo + issues)
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000
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)
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 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 ) ;
}
@@ -36,10 +57,40 @@ if (!Number.isFinite(issueNum) || issueNum <= 0) {
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)." ) ;
console . error ( "➡️ Relance sans --no-build." ) ;
process . exit ( 1 ) ;
}
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" ) {
console . error ( "❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus)." ) ;
process . exit ( 1 ) ;
}
const CWD = process . cwd ( ) ;
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 ? ? "" )
@@ -57,11 +108,11 @@ function normalizeText(s) {
// stripping très pragmatique
function stripMd ( mdx ) {
let s = String ( mdx ? ? "" ) ;
s = s . replace ( /`[^`]*`/g , " " ) ; // inline code
s = s . replace ( /`[^`]*`/g , " " ) ; // inline code
s = s . replace ( /!\[[^\]]*\]\([^)]+\)/g , " " ) ; // images
s = s . replace ( /\[[^\]]*\]\([^)]+\)/g , " " ) ; // links
s = s . replace ( /[*_~]/g , " " ) ; // emphasis-ish
s = s . replace ( /<[^>]+>/g , " " ) ; // html tags
s = s . replace ( /\[[^\]]*\]\([^)]+\)/g , " " ) ; // links
s = s . replace ( /[*_~]/g , " " ) ; // emphasis-ish
s = s . replace ( /<[^>]+>/g , " " ) ; // html tags
s = s . replace ( /\s+/g , " " ) . trim ( ) ;
return s ;
}
@@ -74,13 +125,78 @@ 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 ;
if ( r . status !== 0 ) throw new Error ( ` Command failed: ${ cmd } ${ args . join ( " " ) } ` ) ;
}
function runQuiet ( cmd , args , opts = { } ) {
const r = spawnSync ( cmd , args , { encoding : "utf8" , stdio : "pipe" , ... opts } ) ;
if ( r . error ) throw r . error ;
if ( r . status !== 0 ) {
const out = ( r . stdout || "" ) + ( r . stderr || "" ) ;
throw new Error ( ` Command failed: ${ cmd } ${ args . join ( " " ) } \n ${ out } ` ) ;
}
return r . stdout || "" ;
}
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 = "" ) {
@@ -96,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 ;
@@ -120,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 ) {
@@ -144,83 +277,84 @@ 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 || "" ) ;
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 ) {
let s = String ( inner ? ? "" ) ;
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 re = new RegExp (
` <p[^>]* \\ bid=["'] ${ escapeRegExp ( anchorId ) } ["'][^>]*>([ \\ s \\ S]*?)< \\ /p> ` ,
"i"
) ;
const m = html . match ( re ) ;
if ( ! m ) return "" ;
let inner = m [ 1 ] ;
inner = inner . replace ( /<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi , " " ) ;
inner = inner . replace ( /<[^>]+>/g , " " ) ;
inner = inner . replace ( /\s+/g , " " ) . trim ( ) ;
inner = inner . replace ( /\b(¶|Citer|Proposer|Copié)\b/gi , "" ) . replace ( /\s+/g , " " ) . trim ( ) ;
return inner ;
return cleanHtmlInner ( m [ 1 ] ) ;
}
function split ParagraphBlock s( 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 ; // tronqué/tronquee etc (sans diacritiques)
return false ;
}
function scoreBlock ( block , targetText ) {
const tgt = tokenize ( targetText ) ;
const blk = tokenize ( block ) ;
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 (moins strict qu'un includes brut)
const tgtNorm = normalizeText ( stripMd ( targetText ) ) ;
const blkNorm = normalizeText ( stripMd ( block ) ) ;
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 b = blocks [ i ] ;
const sc = scoreBlock ( b , targetText ) ;
if ( sc > best . score ) best = { i , score : sc } ;
async function readAllHtml Paragraphs( htmlPath ) {
const html = await fs . readFile ( htmlPath , "utf-8 " ) ;
const out = [ ] ;
const re = /<p\b[^>]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi ;
let m ;
while ( ( m = re . exec ( html ) ) ) {
out . push ( { id : m [ 1 ] , text : cleanHtmlInner ( m [ 2 ] ) } ) ;
}
return bes t;
return ou t;
}
/* --------------------------- localisation fichier contenu ------------------- */
async function findContentFileFromChemin ( chemin ) {
const clean = chemin . replace ( /^\/+|\/+$/g , "" ) ;
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 ) {
@@ -240,36 +374,137 @@ 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 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.1 " ,
}
Authorization: ` token ${ token } ` ,
Accept: "application/json" ,
"User-Agent" : "archicratie-apply-ticket/2.0 " ,
} ,
} ) ;
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 } ` ) ;
}
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" ) ;
const obj = JSON . parse ( s ) ;
return obj && typeof obj === "object" ? obj : { } ;
} catch {
return { } ;
}
}
function sortObjectKeys ( obj ) {
return Object . fromEntries ( Object . keys ( obj ) . sort ( ) . map ( ( k ) => [ k , obj [ k ] ] ) ) ;
}
async function saveAliases ( obj ) {
let out = obj || { } ;
for ( const k of Object . keys ( out ) ) {
if ( out [ k ] && typeof out [ k ] === "object" ) out [ k ] = sortObjectKeys ( out [ k ] ) ;
}
out = sortObjectKeys ( out ) ;
await fs . mkdir ( path . dirname ( ALIASES _FILE ) , { recursive : true } ) ;
await fs . writeFile ( ALIASES _FILE , JSON . stringify ( out , null , 2 ) + "\n" , "utf8" ) ;
}
async function upsertAlias ( { chemin , oldId , newId } ) {
const route = normalizeChemin ( chemin ) ;
if ( ! oldId || ! newId ) throw new Error ( "Alias: oldId/newId requis" ) ;
if ( oldId === newId ) return { changed : false , reason : "same" } ;
const data = await loadAliases ( ) ;
if ( ! data [ route ] ) data [ route ] = { } ;
const prev = data [ route ] [ oldId ] ;
if ( prev && prev !== newId ) {
throw new Error (
` Alias conflict: ${ route } ${ oldId } already mapped to ${ prev } (new= ${ newId } ) `
) ;
}
if ( prev === newId ) return { changed : false , reason : "already" } ;
data [ route ] [ oldId ] = newId ;
await saveAliases ( data ) ;
return { changed : true , reason : "written" } ;
}
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 } ;
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 } ;
}
if ( ! best . id || best . score < 60 ) {
throw new Error (
` Impossible d'identifier le nouvel id dans dist (score trop faible: ${ best . score } ). \n ` +
` ➡️ Vérifie que la proposition correspond bien à UN paragraphe. `
) ;
}
return best . id ;
}
/* ----------------------------------- MAIN ---------------------------------- */
async function main ( ) {
const token = getEnv ( "FORGE_TOKEN" ) ;
if ( ! token ) {
@@ -279,7 +514,7 @@ async function main() {
const inferred = inferOwnerRepoFromGit ( ) || { } ;
const owner = getEnv ( "GITEA_OWNER" , inferred . owner || "" ) ;
const repo = getEnv ( "GITEA_REPO" , inferred . repo || "" ) ;
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 ) ;
@@ -294,19 +529,54 @@ 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 ( 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" ) ;
ancre = ( ancre || "" ) . trim ( ) ;
if ( ancre . startsWith ( "#" ) ) ancre = ancre . slice ( 1 ) ;
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" ] ) ;
// fallback si ticket mal formé
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 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." ) ;
@@ -319,13 +589,13 @@ async function main() {
if ( ! contentFile ) throw new Error ( ` Fichier contenu introuvable pour Chemin= ${ chemin } ` ) ;
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 ) ;
// 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 ) ;
}
@@ -344,14 +614,13 @@ async function main() {
const best = bestBlockMatchIndex ( blocks , targetText ) ;
// seuil de sécurité : on veut au moins un overlap raisonnable.
// Avec le bonus prefix+ratio, un match correct dépasse très vite ~60– 80.
// seuil de sécurité
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)'. ` ) ;
// debug: top 5
const ranked = blocks
. map ( ( b , i ) => ( { i , score : scoreBlock ( 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 )
. slice ( 0 , 5 ) ;
@@ -388,10 +657,74 @@ async function main() {
}
await fs . writeFile ( contentFile , updated , "utf-8" ) ;
console . log ( "✅ Applied. Next: " ) ;
console . log ( "✅ Applied." ) ;
let aliasChanged = false ;
let newId = null ;
if ( DO _ALIAS ) {
console . log ( "🔁 Rebuild to compute new anchor ids (npm run build) …" ) ;
run ( "npm" , [ "run" , "build" ] , { cwd : CWD } ) ;
if ( ! ( await fileExists ( distHtmlPath ) ) ) {
throw new Error ( ` dist introuvable après build: ${ distHtmlPath } ` ) ;
}
newId = await computeNewIdFromDistByContent ( distHtmlPath , afterBlock ) ;
const res = await upsertAlias ( { chemin , oldId : ancre , newId } ) ;
aliasChanged = res . changed ;
if ( aliasChanged ) {
console . log ( ` ✅ Alias ajouté: ${ chemin } ${ ancre } -> ${ newId } ` ) ;
// 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 } ). ` ) ;
}
// garde-fous rapides
run ( "npm" , [ "run" , "test:anchors" ] , { cwd : CWD } ) ;
run ( "node" , [ "scripts/check-inline-js.mjs" ] , { cwd : CWD } ) ;
}
if ( DO _COMMIT ) {
const files = [ path . relative ( CWD , contentFile ) ] ;
if ( DO _ALIAS && aliasChanged ) files . push ( path . relative ( CWD , ALIASES _FILE ) ) ;
run ( "git" , [ "add" , ... files ] , { cwd : CWD } ) ;
if ( ! gitHasStagedChanges ( ) ) {
console . log ( "ℹ ️ Nothing to commit (aucun changement staged)." ) ;
return ;
}
const msg = ` edit: apply ticket # ${ issueNum } ( ${ chemin } # ${ ancre } ) ` ;
run ( "git" , [ "commit" , "-m" , msg ] , { cwd : CWD } ) ;
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. \n Commit: ${ sha } ` ;
await closeIssue ( { forgeApiBase , owner , repo , token , issueNum , comment } ) ;
console . log ( ` ✅ Ticket # ${ issueNum } fermé. ` ) ;
}
return ;
}
// mode manuel
console . log ( "Next (manuel) :" ) ;
console . log ( ` git diff -- ${ path . relative ( CWD , contentFile ) } ` ) ;
console . log ( ` git add ${ path . relative ( CWD , contentFile ) } ` ) ;
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 ) => {