Compare commits

...

6 Commits

Author SHA1 Message Date
93306f360d fix(editorial): preserve frontmatter in apply-ticket
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 51s
CI / build-and-anchors (pull_request) Successful in 44s
2026-03-16 11:48:08 +01:00
52847d999d Merge pull request 'fix(actions): silence anno workflow on proposer tickets' (#258) from hotfix/fix-proposer-runtime-v2 into main
Some checks failed
Deploy staging+live (annotations) / deploy (push) Successful in 51s
CI / build-and-anchors (push) Successful in 49s
SMOKE / smoke (push) Successful in 5s
Proposer Apply (Queue) / apply-proposer (push) Failing after 48s
Reviewed-on: #258
2026-03-16 11:11:28 +01:00
b9629b43ff fix(actions): silence anno workflow on proposer tickets
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 49s
CI / build-and-anchors (pull_request) Successful in 44s
2026-03-16 11:06:11 +01:00
06482a9f8d Merge pull request 'fix(actions): make proposer queue runtime-safe' (#254) from hotfix/fix-proposer-runtime-v2 into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 21s
CI / build-and-anchors (push) Successful in 46s
SMOKE / smoke (push) Successful in 6s
Deploy staging+live (annotations) / deploy (push) Successful in 42s
Reviewed-on: #254
2026-03-16 00:59:50 +01:00
f2e4ae5ac2 fix(actions): make proposer queue runtime-safe
All checks were successful
SMOKE / smoke (push) Successful in 7s
CI / build-and-anchors (push) Successful in 44s
CI / build-and-anchors (pull_request) Successful in 42s
2026-03-16 00:58:10 +01:00
71baf0f6da Merge pull request 'fix(actions): repair proposer workflow yaml' (#253) from hotfix/fix-proposer-workflow into main
Some checks failed
CI / build-and-anchors (push) Successful in 48s
Deploy staging+live (annotations) / deploy (push) Successful in 1m0s
SMOKE / smoke (push) Successful in 4s
Proposer Apply (Queue) / apply-proposer (push) Failing after 4s
Reviewed-on: #253
2026-03-16 00:42:17 +01:00
3 changed files with 363 additions and 216 deletions

View File

@@ -41,7 +41,7 @@ jobs:
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/anno.env
import fs from "node:fs";
@@ -66,7 +66,10 @@ jobs:
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
if (m?.groups) {
owner = owner || m.groups.o;
repo = repo || m.groups.r;
}
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
@@ -81,7 +84,6 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input");
}
// label name: best-effort (non-bloquant)
let labelName = "workflow_dispatch";
const lab = ev?.label;
if (typeof lab === "string") labelName = lab;
@@ -95,7 +97,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
function sh(s){ return JSON.stringify(String(s)); }
function sh(s) { return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
@@ -108,7 +110,7 @@ jobs:
].join("\n") + "\n");
NODE
echo "context:"
echo "context:"
sed -n '1,120p' /tmp/anno.env
- name: Early gate (label event fast-skip, but tolerant)
@@ -116,18 +118,16 @@ jobs:
set -euo pipefail
source /tmp/anno.env
echo " event label = $LABEL_NAME"
echo "event label = $LABEL_NAME"
# Fast skip on obvious non-approved label events (avoid noise),
# BUT do NOT skip if label payload is weird/unknown.
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
echo " label=$LABEL_NAME => skip early"
echo "label=$LABEL_NAME => skip early"
echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
exit 0
fi
echo "continue to API gating (issue=$ISSUE_NUMBER)"
echo "continue to API gating (issue=$ISSUE_NUMBER)"
- name: Fetch issue + hard gate on labels + Type
env:
@@ -135,9 +135,9 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
@@ -148,11 +148,12 @@ jobs:
node --input-type=module - <<'NODE' >> /tmp/anno.env
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const title = String(issue.title || "");
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json", "utf8"));
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) {
@@ -164,14 +165,12 @@ jobs:
const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase();
const allowed = new Set(["type/media","type/reference","type/comment"]);
const proposer = new Set(["type/correction","type/fact-check"]);
const allowedAnno = new Set(["type/media", "type/reference", "type/comment"]);
const proposerTypes = new Set(["type/correction", "type/fact-check"]);
const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
// HARD gate: must currently have state/approved (avoids depending on event payload)
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`);
@@ -182,23 +181,23 @@ jobs:
if (!type) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowed.has(type)) {
} else if (allowedAnno.has(type)) {
// proceed
} else if (proposer.has(type)) {
} else if (proposerTypes.has(type)) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:" + type)}`);
} else {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:" + type)}`);
}
process.stdout.write(out.join("\n") + "\n");
NODE
echo "gating result:"
echo "gating result:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
- name: Comment issue if skipped (Proposer / unsupported / missing Type)
- name: Comment issue if skipped (unsupported / missing Type only)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -208,9 +207,13 @@ jobs:
[[ "${SKIP:-0}" == "1" ]] || exit 0
# IMPORTANT: do NOT comment for "not_approved_label_present" (avoid spam on other label events)
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
echo " skip reason=${SKIP_REASON} -> no comment"
echo "skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
if [[ "${SKIP_REASON:-}" == proposer_type:* ]]; then
echo "proposer ticket detected -> anno stays silent"
exit 0
fi
@@ -219,15 +222,13 @@ jobs:
REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == proposer_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors**.\n✅ Aucun traitement automatique."
elif [[ "$REASON" == unsupported_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment."
if [[ "$REASON" == unsupported_type:* ]]; then
MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment."
else
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: type/media|type/reference|type/comment"
MSG="Ticket #${ISSUE_NUMBER} ignored: missing or unreadable 'Type:'. Expected: type/media|type/reference|type/comment"
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
@@ -239,7 +240,7 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
rm -rf .git
git init -q
@@ -252,16 +253,16 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm ci --no-audit --no-fund
- name: Check apply script exists
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
test -f scripts/apply-annotation-ticket.mjs || {
echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
@@ -270,16 +271,16 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm run build
test -f dist/para-index.json || {
echo "missing dist/para-index.json after build"
echo "missing dist/para-index.json after build"
ls -la dist | sed -n '1,200p' || true
exit 1
}
echo "dist/para-index.json present"
echo "dist/para-index.json present"
- name: Apply ticket on bot branch (strict+verify, commit)
continue-on-error: true
@@ -290,10 +291,10 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -d .git || { echo "not a git repo (checkout failed)"; echo "APPLY_RC=90" >> /tmp/anno.env; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
test -d .git || { echo "not a git repo (checkout failed)"; echo "APPLY_RC=90" >> /tmp/anno.env; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
@@ -340,11 +341,11 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then
echo " no failure detected"
echo "no failure detected"
exit 0
fi
@@ -356,8 +357,8 @@ jobs:
BODY="(no apply log found)"
fi
MSG="apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
MSG="apply-annotation-ticket failed (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
@@ -374,9 +375,9 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
test -d .git || { echo " no git repo -> skip push"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip push"; exit 0; }
test -d .git || { echo "no git repo -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
@@ -398,8 +399,8 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
@@ -420,10 +421,10 @@ jobs:
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; }
test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; }
MSG="PR créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
MSG="PR created for ticket #${ISSUE_NUMBER}: ${PR_URL}"
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
@@ -431,7 +432,7 @@ jobs:
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD"
echo "PR: $PR_URL"
echo "PR: $PR_URL"
- name: Finalize (fail job if apply failed)
if: ${{ always() }}
@@ -439,11 +440,11 @@ jobs:
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then
echo "apply failed (rc=$RC)"
echo "apply failed (rc=$RC)"
exit "$RC"
fi
echo "apply ok"
echo "apply ok"

View File

@@ -45,7 +45,7 @@ jobs:
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/proposer.env
import fs from "node:fs";
@@ -55,7 +55,7 @@ jobs:
const cloneUrl =
repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/, "") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
@@ -70,8 +70,12 @@ jobs:
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
if (m?.groups) {
owner = owner || m.groups.o;
repo = repo || m.groups.r;
}
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const defaultBranch = repoObj?.default_branch || "main";
@@ -94,11 +98,15 @@ jobs:
const u = new URL(cloneUrl);
const origin = u.origin;
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim())
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
const apiBase =
(process.env.FORGE_API && String(process.env.FORGE_API).trim())
? String(process.env.FORGE_API).trim().replace(/\/+$/, "")
: origin;
function sh(s) {
return JSON.stringify(String(s));
}
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
`OWNER=${sh(owner)}`,
@@ -111,7 +119,7 @@ jobs:
].join("\n") + "\n");
NODE
echo "✅ context:"
echo "Context:"
sed -n '1,200p' /tmp/proposer.env
- name: Early gate
@@ -121,14 +129,51 @@ jobs:
if [[ "$EVENT_NAME" == "issues" ]]; then
if [[ "$LABEL_NAME" != "state/approved" ]]; then
echo " issues/labeled but label=$LABEL_NAME -> skip"
echo "issues/labeled but label=$LABEL_NAME -> skip"
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="label_not_state_approved"' >> /tmp/proposer.env
exit 0
fi
fi
echo "✅ proceed"
echo "Proceed"
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
rm -rf .git
git init -q
git remote add origin "$CLONE_URL"
git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
git log -1 --oneline
- name: Detect app dir (repo-root vs ./site)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
APP_DIR="."
if [[ -d "site" && -f "site/package.json" ]]; then
APP_DIR="site"
fi
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
echo "APP_DIR=$APP_DIR"
test -f "$APP_DIR/package.json" || {
echo "package.json missing in APP_DIR=$APP_DIR"
exit 1
}
test -d "$APP_DIR/scripts" || {
echo "scripts/ missing in APP_DIR=$APP_DIR"
exit 1
}
- name: Select next proposer batch (by path)
env:
@@ -136,14 +181,25 @@ jobs:
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
test -n "${FORGE_TOKEN:-}" || {
echo "Missing secret FORGE_TOKEN"
exit 1
}
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_API="$API_BASE"
cd "$APP_DIR"
test -f scripts/pick-proposer-issue.mjs || {
echo "missing scripts/pick-proposer-issue.mjs in APP_DIR=$APP_DIR"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env
cat /tmp/proposer.pick.env >> /tmp/proposer.env
source /tmp/proposer.pick.env
@@ -151,11 +207,11 @@ jobs:
if [[ "${TARGET_FOUND:-0}" != "1" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env
echo " no target batch"
echo "No target batch"
exit 0
fi
echo "✅ target batch:"
echo "Target batch:"
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
- name: Inspect open proposer PRs
@@ -164,7 +220,7 @@ jobs:
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
@@ -177,21 +233,22 @@ jobs:
node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs";
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json","utf8"));
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8"));
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const proposerOpen = Array.isArray(pulls)
? pulls.filter(pr => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
: [];
const current = proposerOpen.find((pr) => {
const ref = String(pr?.head?.ref || "");
const title = String(pr?.title || "");
const body = String(pr?.body || "");
return issues.some(n =>
return issues.some((n) =>
ref.startsWith(`bot/proposer-${n}-`) ||
title.includes(`#${n}`) ||
body.includes(`#${n}`) ||
@@ -200,13 +257,14 @@ jobs:
});
const out = [];
if (current) {
out.push(`SKIP=1`);
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(current.html_url || current.url || ""))}`);
} else if (proposerOpen.length > 0) {
const first = proposerOpen[0];
out.push(`SKIP=1`);
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`);
@@ -236,72 +294,48 @@ jobs:
case "${SKIP_REASON:-}" in
queue_busy_open_proposer_pr)
MSG=" Ticket mis en file dattente Proposer.\n\nUne PR Proposer est déjà ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}\n\nLe workflow reprendra automatiquement le prochain lot après intégration sur main."
MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main."
;;
issue_already_has_open_pr)
MSG=" Ce ticket a déjà une PR Proposer ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}"
MSG="This ticket already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
;;
explicit_issue_missing_chemin)
MSG=" Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Chemin** est manquant ou illisible."
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
;;
explicit_issue_missing_type)
MSG=" Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Type** est manquant ou illisible."
MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable."
;;
explicit_issue_not_approved)
MSG=" Proposer Apply: ce ticket nest pas actuellement marqué **state/approved**."
MSG="Proposer Apply: this ticket is not currently labeled state/approved."
;;
explicit_issue_rejected)
MSG=" Proposer Apply: ce ticket porte **state/rejected** et nentre donc pas dans la file Proposer."
MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue."
;;
no_open_approved_proposer_issue)
MSG=" Aucun ticket Proposer approuvé nest actuellement en attente."
MSG="No approved proposer ticket is currently waiting."
;;
*)
MSG=" Proposer Apply: skip ${SKIP_REASON:-raison non précisée}."
MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}."
;;
esac
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json
const msg = process.env.MSG || "";
process.stdout.write(JSON.stringify({ body: msg }));
NODE
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \
--data-binary "$PAYLOAD" || true
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
rm -rf .git
git init -q
git remote add origin "$CLONE_URL"
git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
git log -1 --oneline
- name: Detect app dir (repo-root vs ./site)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
APP_DIR="."
if [[ -d "site" && -f "site/package.json" ]]; then
APP_DIR="site"
fi
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
echo "✅ APP_DIR=$APP_DIR"
test -f "$APP_DIR/package.json" || { echo "❌ package.json missing in APP_DIR=$APP_DIR"; exit 1; }
test -d "$APP_DIR/scripts" || { echo "❌ scripts/ missing in APP_DIR=$APP_DIR"; exit 1; }
--data-binary @/tmp/proposer.skip.comment.json || true
- name: NPM harden
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
@@ -313,6 +347,7 @@ jobs:
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm ci --no-audit --no-fund
@@ -321,6 +356,7 @@ jobs:
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm run build
@@ -333,9 +369,9 @@ jobs:
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
START_SHA="$(git rev-parse HEAD)"
@@ -353,13 +389,16 @@ jobs:
RC=0
FAILED_ISSUE=""
for ISSUE in $TARGET_ISSUES; do
echo "" >>"$LOG"
echo "== ticket #$ISSUE ==" >>"$LOG"
echo "" >> "$LOG"
echo "== ticket #$ISSUE ==" >> "$LOG"
set +e
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >>"$LOG" 2>&1
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >> "$LOG" 2>&1
STEP_RC=$?
set -e
if [[ "$STEP_RC" -ne 0 ]]; then
RC="$STEP_RC"
FAILED_ISSUE="$ISSUE"
@@ -370,10 +409,11 @@ jobs:
echo "APPLY_RC=$RC" >> /tmp/proposer.env
echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env
echo "== apply log (tail) =="
echo "Apply log (tail):"
tail -n 220 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/proposer.env
exit 0
@@ -400,7 +440,7 @@ jobs:
git fetch origin "$DEFAULT_BRANCH"
set +e
git rebase "origin/$DEFAULT_BRANCH" >>"$LOG" 2>&1
git rebase "origin/$DEFAULT_BRANCH" >> "$LOG" 2>&1
RC=$?
set -e
@@ -410,7 +450,7 @@ jobs:
echo "REBASE_RC=$RC" >> /tmp/proposer.env
echo "== rebase log (tail) =="
echo "Rebase log (tail):"
tail -n 220 "$LOG" || true
- name: Comment issues on failure
@@ -426,7 +466,7 @@ jobs:
REBASE_RC="${REBASE_RC:-0}"
if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then
echo " no failure detected"
echo "No failure detected"
exit 0
fi
@@ -438,20 +478,35 @@ jobs:
BODY="(no proposer log found)"
fi
export BODY APPLY_RC REBASE_RC FAILED_ISSUE
if [[ "$APPLY_RC" != "0" ]]; then
MSG="❌ Batch Proposer en échec sur le ticket #${FAILED_ISSUE:-"(inconnu)"} (rc=${APPLY_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
export FAILURE_KIND="apply"
else
MSG="❌ Rebase Proposer en échec sur main (rc=${REBASE_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
export FAILURE_KIND="rebase"
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
node --input-type=module - <<'NODE' > /tmp/proposer.failure.comment.json
const body = process.env.BODY || "";
const applyRc = process.env.APPLY_RC || "0";
const rebaseRc = process.env.REBASE_RC || "0";
const failedIssue = process.env.FAILED_ISSUE || "unknown";
const kind = process.env.FAILURE_KIND || "apply";
const msg =
kind === "apply"
? `Batch proposer failed on ticket #${failedIssue} (rc=${applyRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`
: `Rebase proposer failed on main (rc=${rebaseRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`;
process.stdout.write(JSON.stringify({ body: msg }));
NODE
for ISSUE in ${TARGET_ISSUES:-}; do
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary "$PAYLOAD" || true
--data-binary @/tmp/proposer.failure.comment.json || true
done
- name: Push bot branch
@@ -462,10 +517,10 @@ jobs:
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${REBASE_RC:-0}" == "0" ]] || { echo " rebase failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip push"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "Apply failed -> skip push"; exit 0; }
[[ "${REBASE_RC:-0}" == "0" ]] || { echo "Rebase failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "No-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
@@ -489,7 +544,7 @@ jobs:
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip PR"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; }
if [[ "${TARGET_COUNT:-0}" == "1" ]]; then
PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}"
@@ -497,64 +552,74 @@ jobs:
PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}"
fi
PR_PAYLOAD="$(
TITLE="$PR_TITLE" \
CHEMIN="$TARGET_CHEMIN" \
ISSUES="$TARGET_ISSUES" \
BRANCH="$BRANCH" \
END_SHA="${END_SHA:-unknown}" \
DEFAULT_BRANCH="$DEFAULT_BRANCH" \
OWNER="$OWNER" \
node --input-type=module <<'NODE'
const issues = String(process.env.ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
export TITLE="$PR_TITLE"
export CHEMIN="$TARGET_CHEMIN"
export ISSUES="$TARGET_ISSUES"
export BRANCH="$BRANCH"
export END_SHA="${END_SHA:-unknown}"
export DEFAULT_BRANCH="$DEFAULT_BRANCH"
export OWNER="$OWNER"
const body = [
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map(n => `#${n}`).join(", ")} (state/approved).`,
"",
`- Chemin: ${process.env.CHEMIN || "(inconnu)"}`,
"- Tickets:",
...issues.map(n => ` - #${n}`),
`- Branche: ${process.env.BRANCH}`,
`- Commit: ${process.env.END_SHA || "unknown"}`,
"",
"Merge si CI OK."
].join("\n");
node --input-type=module - <<'NODE' > /tmp/proposer.pr.json
const issues = String(process.env.ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
console.log(JSON.stringify({
title: process.env.TITLE,
body,
base: process.env.DEFAULT_BRANCH,
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
}));
const body = [
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`,
"",
`- Chemin: ${process.env.CHEMIN || "(inconnu)"}`,
"- Tickets:",
...issues.map((n) => ` - #${n}`),
`- Branche: ${process.env.BRANCH || ""}`,
`- Commit: ${process.env.END_SHA || "unknown"}`,
"",
"Merge si CI OK."
].join("\n");
process.stdout.write(JSON.stringify({
title: process.env.TITLE || "proposer: apply tickets",
body,
base: process.env.DEFAULT_BRANCH || "main",
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
}));
NODE
)"
PR_JSON="$(curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \
--data-binary "$PR_PAYLOAD")"
--data-binary @/tmp/proposer.pr.json)"
PR_URL="$(node --input-type=module -e '
const pr = JSON.parse(process.argv[1] || "{}");
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; }
test -n "$PR_URL" || {
echo "PR URL missing. Raw: $PR_JSON"
exit 1
}
for ISSUE in $TARGET_ISSUES; do
MSG="✅ PR Proposer créée pour le ticket #${ISSUE} : ${PR_URL}\n\nLe ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR."
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
export ISSUE PR_URL
node --input-type=module - <<'NODE' > /tmp/proposer.issue.close.comment.json
const issue = process.env.ISSUE || "";
const url = process.env.PR_URL || "";
const msg =
`PR proposer created for ticket #${issue}: ${url}\n\n` +
`The ticket is closed automatically. Discussion can continue in the PR.`;
process.stdout.write(JSON.stringify({ body: msg }));
NODE
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary "$C_PAYLOAD"
--data-binary @/tmp/proposer.issue.close.comment.json
curl -fsS -X PATCH \
-H "Authorization: token $FORGE_TOKEN" \
@@ -563,7 +628,7 @@ jobs:
--data-binary '{"state":"closed"}'
done
echo "PR: $PR_URL"
echo "PR: $PR_URL"
- name: Finalize
if: ${{ always() }}
@@ -573,13 +638,13 @@ jobs:
[[ "${SKIP:-0}" != "1" ]] || exit 0
if [[ "${APPLY_RC:-0}" != "0" ]]; then
echo "❌ apply failed (rc=${APPLY_RC})"
echo "Apply failed (rc=${APPLY_RC})"
exit "${APPLY_RC}"
fi
if [[ "${REBASE_RC:-0}" != "0" ]]; then
echo "❌ rebase failed (rc=${REBASE_RC})"
echo "Rebase failed (rc=${REBASE_RC})"
exit "${REBASE_RC}"
fi
echo "✅ proposer queue ok"
echo "Proposer queue OK"

View File

@@ -9,8 +9,9 @@ import { spawnSync } from "node:child_process";
*
* Conçu pour:
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
* - retrouver le bon paragraphe dans le .mdx
* - retrouver le bon paragraphe dans le .mdx/.md
* - remplacer proprement
* - ne JAMAIS toucher au frontmatter
* - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit)
@@ -137,28 +138,17 @@ function scoreText(candidate, targetText) {
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 rankedBlockMatches(blocks, targetText, limit = 5) {
return blocks
.map((b, i) => ({
@@ -170,11 +160,6 @@ function rankedBlockMatches(blocks, targetText, limit = 5) {
.slice(0, limit);
}
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;
@@ -184,6 +169,89 @@ function isLikelyExcerpt(s) {
return false;
}
/* --------------------------- frontmatter / structure ------------------------ */
function normalizeNewlines(s) {
return String(s ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
}
function splitMdxFrontmatter(src) {
const text = normalizeNewlines(src);
const m = text.match(/^---\n[\s\S]*?\n---\n?/);
if (!m) {
return {
hasFrontmatter: false,
frontmatter: "",
body: text,
};
}
const frontmatter = m[0];
const body = text.slice(frontmatter.length);
return {
hasFrontmatter: true,
frontmatter,
body,
};
}
function joinMdxFrontmatter(frontmatter, body) {
if (!frontmatter) return String(body ?? "");
return String(frontmatter) + String(body ?? "");
}
function assertFrontmatterIntegrity({ hadFrontmatter, originalFrontmatter, finalText, filePath }) {
if (!hadFrontmatter) return;
const text = normalizeNewlines(finalText);
if (!text.startsWith("---\n")) {
throw new Error(`Frontmatter perdu pendant la mise à jour de ${filePath}`);
}
if (!text.startsWith(originalFrontmatter)) {
throw new Error(`Frontmatter altéré pendant la mise à jour de ${filePath}`);
}
}
function splitParagraphBlocksPreserve(bodyText) {
const text = normalizeNewlines(bodyText);
if (!text) {
return { blocks: [], separators: [] };
}
const blocks = [];
const separators = [];
const re = /(\n{2,})/g;
let last = 0;
let m;
while ((m = re.exec(text))) {
blocks.push(text.slice(last, m.index));
separators.push(m[1]);
last = m.index + m[1].length;
}
blocks.push(text.slice(last));
return { blocks, separators };
}
function joinParagraphBlocksPreserve(blocks, separators) {
if (!Array.isArray(blocks) || blocks.length === 0) return "";
let out = "";
for (let i = 0; i < blocks.length; i++) {
out += blocks[i];
if (i < separators.length) out += separators[i];
}
return out;
}
/* ------------------------------ utils système ------------------------------ */
function run(cmd, args, opts = {}) {
@@ -263,7 +331,9 @@ function pickSection(body, 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);
@@ -278,11 +348,13 @@ function pickSection(body, markers) {
"\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();
}
@@ -310,8 +382,6 @@ function extractAnchorIdAnywhere(text) {
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] : "";
}
@@ -412,7 +482,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
"User-Agent": "archicratie-apply-ticket/2.1",
},
});
if (!res.ok) {
@@ -428,7 +498,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
"User-Agent": "archicratie-apply-ticket/2.1",
};
if (comment) {
@@ -437,7 +507,11 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
}
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
const res = await fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "closed" }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
@@ -541,10 +615,9 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`);
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
console.error("➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.");
process.exit(2);
}
@@ -565,7 +638,6 @@ async function main() {
ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1);
// fallback si ticket mal formé
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin);
@@ -604,7 +676,6 @@ async function main() {
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
await ensureBuildIfNeeded(distHtmlPath);
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel;
let distText = "";
@@ -621,18 +692,24 @@ async function main() {
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
}
const original = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original);
const originalRaw = await fs.readFile(contentFile, "utf-8");
const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw);
const split = splitParagraphBlocksPreserve(originalBody);
const blocks = split.blocks;
const separators = split.separators;
if (!blocks.length) {
throw new Error(`Aucun bloc éditorial exploitable dans ${path.relative(CWD, contentFile)}`);
}
const ranked = rankedBlockMatches(blocks, targetText, 5);
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
const runnerUp = ranked[1] || null;
// seuil absolu
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)'.`);
console.error("Top candidates:");
for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
@@ -640,7 +717,6 @@ async function main() {
process.exit(2);
}
// seuil relatif : si le 2e est trop proche du 1er, on refuse aussi
if (runnerUp) {
const ambiguityGap = best.score - runnerUp.score;
if (ambiguityGap < 15) {
@@ -659,7 +735,16 @@ async function main() {
const nextBlocks = blocks.slice();
nextBlocks[best.i] = afterBlock;
const updated = nextBlocks.join("\n\n");
const updatedBody = joinParagraphBlocksPreserve(nextBlocks, separators);
const updatedRaw = joinMdxFrontmatter(frontmatter, updatedBody);
assertFrontmatterIntegrity({
hadFrontmatter: hasFrontmatter,
originalFrontmatter: frontmatter,
finalText: updatedRaw,
filePath: path.relative(CWD, contentFile),
});
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
@@ -673,16 +758,15 @@ async function main() {
return;
}
// backup uniquement si on écrit
const relContentFile = path.relative(CWD, contentFile);
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
await fs.mkdir(path.dirname(bakPath), { recursive: true });
if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, original, "utf-8");
await fs.writeFile(bakPath, originalRaw, "utf-8");
}
await fs.writeFile(contentFile, updated, "utf-8");
await fs.writeFile(contentFile, updatedRaw, "utf-8");
console.log("✅ Applied.");
let aliasChanged = false;
@@ -703,13 +787,11 @@ async function main() {
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("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD });
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
run("npm", ["run", "test:anchors"], { cwd: CWD });
@@ -741,7 +823,6 @@ async function main() {
return;
}
// mode manuel
console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log(
@@ -758,4 +839,4 @@ async function main() {
main().catch((e) => {
console.error("💥", e?.message || e);
process.exit(1);
});
});