From ab6f45ed5c8ac8a16c543839e15899da92fd8146 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Sun, 15 Mar 2026 23:57:11 +0100 Subject: [PATCH] chore(editorial): harden proposer queue and apply-ticket --- .gitea/workflows/proposer-apply-pr.yml | 391 ++++++++++++++++++------- .gitignore | 3 + scripts/apply-ticket.mjs | 46 ++- scripts/pick-proposer-issue.mjs | 241 +++++++++++++++ 4 files changed, 571 insertions(+), 110 deletions(-) create mode 100644 scripts/pick-proposer-issue.mjs diff --git a/.gitea/workflows/proposer-apply-pr.yml b/.gitea/workflows/proposer-apply-pr.yml index 11c6f9f..90b2003 100644 --- a/.gitea/workflows/proposer-apply-pr.yml +++ b/.gitea/workflows/proposer-apply-pr.yml @@ -1,13 +1,16 @@ -name: Proposer Apply (PR) +name: Proposer Apply (Queue) on: issues: types: [labeled] + push: + branches: [main] workflow_dispatch: inputs: issue: - description: "Issue number to apply (Proposer: correction/fact-check)" - required: true + description: "Issue number to prioritize (optional)" + required: false + default: "" env: NODE_OPTIONS: --dns-result-order=ipv4first @@ -17,8 +20,8 @@ defaults: shell: bash concurrency: - group: proposer-apply-${{ github.event.issue.number || inputs.issue || 'manual' }} - cancel-in-progress: true + group: proposer-queue-main + cancel-in-progress: false jobs: apply-proposer: @@ -34,9 +37,10 @@ jobs: node --version npm --version - - name: Derive context (event.json / workflow_dispatch) + - name: Derive context (event.json / workflow_dispatch / push) env: INPUT_ISSUE: ${{ inputs.issue }} + EVENT_NAME_IN: ${{ github.event_name }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} run: | set -euo pipefail @@ -75,16 +79,17 @@ jobs: const issueNumber = ev?.issue?.number || ev?.issue?.index || - (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); - - if (!issueNumber || !Number.isFinite(Number(issueNumber))) { - throw new Error("No issue number in event.json or workflow_dispatch input"); - } + (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0) || + 0; const labelName = ev?.label?.name || - ev?.label || - "workflow_dispatch"; + (typeof ev?.label === "string" ? ev.label : "") || + ""; + + const eventName = + String(process.env.EVENT_NAME_IN || "").trim() || + (ev?.issue ? "issues" : (ev?.before || ev?.after ? "push" : "workflow_dispatch")); const u = new URL(cloneUrl); const origin = u.origin; @@ -101,26 +106,31 @@ jobs: `DEFAULT_BRANCH=${sh(defaultBranch)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, + `EVENT_NAME=${sh(eventName)}`, `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "✅ context:" - sed -n '1,120p' /tmp/proposer.env + sed -n '1,200p' /tmp/proposer.env - - name: Gate on label state/approved + - name: Early gate run: | set -euo pipefail source /tmp/proposer.env - if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then - echo "ℹ️ label=$LABEL_NAME => skip" - echo "SKIP=1" >> /tmp/proposer.env - exit 0 + if [[ "$EVENT_NAME" == "issues" ]]; then + if [[ "$LABEL_NAME" != "state/approved" ]]; then + 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 (issue=$ISSUE_NUMBER)" - - name: Fetch issue + API-hard gate on (state/approved present + proposer type) + echo "✅ proceed" + + - name: Select next proposer batch (by path) env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | @@ -130,53 +140,81 @@ jobs: test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + export GITEA_OWNER="$OWNER" + export GITEA_REPO="$REPO" + export FORGE_API="$API_BASE" + + 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 + + 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" + exit 0 + fi + + echo "✅ target batch:" + grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env + + - name: Inspect open proposer PRs + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - -o /tmp/issue.json + "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \ + -o /tmp/open_pulls.json node --input-type=module - <<'NODE' >> /tmp/proposer.env import fs from "node:fs"; - const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); - const title = String(issue.title || ""); - 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) : []; - function pickLine(key) { - const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi"); - const m = body.match(re); - return m ? m[1].trim() : ""; - } + const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json","utf8")); + const issues = String(process.env.TARGET_ISSUES || "") + .trim() + .split(/\s+/) + .filter(Boolean); - const typeRaw = pickLine("Type"); - const type = String(typeRaw || "").trim().toLowerCase(); + const proposerOpen = Array.isArray(pulls) + ? pulls.filter(pr => String(pr?.head?.ref || "").startsWith("bot/proposer-")) + : []; - const hasApproved = labels.includes("state/approved"); - const proposer = new Set(["type/correction","type/fact-check"]); + 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 => + ref.startsWith(`bot/proposer-${n}-`) || + title.includes(`#${n}`) || + body.includes(`#${n}`) || + body.includes(`ticket #${n}`) + ); + }); const out = []; - out.push(`ISSUE_TITLE=${JSON.stringify(title)}`); - out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); - out.push(`HAS_APPROVED=${hasApproved ? "1":"0"}`); - - if (!hasApproved) { + if (current) { out.push(`SKIP=1`); - out.push(`SKIP_REASON=${JSON.stringify("approved_not_present")}`); - } else if (!type) { + 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_REASON=${JSON.stringify("missing_type")}`); - } else if (!proposer.has(type)) { - out.push(`SKIP=1`); - out.push(`SKIP_REASON=${JSON.stringify("not_proposer:"+type)}`); + 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 || ""))}`); } - process.stdout.write(out.join("\n") + "\n"); + process.stdout.write(out.join("\n") + (out.length ? "\n" : "")); NODE + env: + TARGET_ISSUES: ${{ env.TARGET_ISSUES }} - echo "✅ proposer gating:" - grep -E '^(ISSUE_TYPE|HAS_APPROVED|SKIP|SKIP_REASON)=' /tmp/proposer.env || true - - - name: Comment issue if skipped + - name: Comment issue if queued / skipped if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -185,24 +223,48 @@ jobs: source /tmp/proposer.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 - [[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0 + [[ "${EVENT_NAME:-}" != "push" ]] || exit 0 - REASON="${SKIP_REASON:-}" - TYPE="${ISSUE_TYPE:-}" + test -n "${FORGE_TOKEN:-}" || exit 0 - if [[ "$REASON" == "approved_not_present" ]]; then - MSG="ℹ️ Proposer Apply: skip — le label **state/approved** n'est pas présent sur le ticket au moment du run (gate API-hard)." - elif [[ "$REASON" == "missing_type" ]]; then - MSG="ℹ️ Proposer Apply: skip — champ **Type:** manquant/illisible. Attendu: type/correction ou type/fact-check." - else - MSG="ℹ️ Proposer Apply: skip — Type non-Proposer (${TYPE}). (Ce workflow ne traite que correction/fact-check.)" + ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}" + if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then + ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}" fi + [[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0 + + case "${SKIP_REASON:-}" in + queue_busy_open_proposer_pr) + MSG="ℹ️ Ticket mis en file d’attente 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." + ;; + issue_already_has_open_pr) + MSG="ℹ️ Ce ticket a déjà une PR Proposer ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}" + ;; + explicit_issue_missing_chemin) + MSG="ℹ️ Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Chemin** est manquant ou illisible." + ;; + explicit_issue_missing_type) + MSG="ℹ️ Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Type** est manquant ou illisible." + ;; + explicit_issue_not_approved) + MSG="ℹ️ Proposer Apply: ce ticket n’est pas actuellement marqué **state/approved**." + ;; + explicit_issue_rejected) + MSG="ℹ️ Proposer Apply: ce ticket porte **state/rejected** et n’entre donc pas dans la file Proposer." + ;; + no_open_approved_proposer_issue) + MSG="ℹ️ Aucun ticket Proposer approuvé n’est actuellement en attente." + ;; + *) + MSG="ℹ️ Proposer Apply: skip — ${SKIP_REASON:-raison non précisée}." + ;; + esac PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \ --data-binary "$PAYLOAD" || true - name: Checkout default branch @@ -217,8 +279,6 @@ jobs: git fetch --depth 1 origin "$DEFAULT_BRANCH" git -c advice.detachedHead=false checkout -q FETCH_HEAD git log -1 --oneline - echo "✅ workspace:" - ls -la | sed -n '1,120p' - name: Detect app dir (repo-root vs ./site) run: | @@ -233,11 +293,10 @@ jobs: echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env echo "✅ APP_DIR=$APP_DIR" - ls -la "$APP_DIR" | sed -n '1,120p' 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: NPM harden (reduce flakiness) + - name: NPM harden run: | set -euo pipefail source /tmp/proposer.env @@ -248,29 +307,28 @@ jobs: npm config set fetch-retry-maxtimeout 120000 npm config set registry https://registry.npmjs.org - - name: Install deps (APP_DIR) + - name: Install deps run: | set -euo pipefail source /tmp/proposer.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + [[ "${SKIP:-0}" != "1" ]] || exit 0 cd "$APP_DIR" npm ci --no-audit --no-fund - - name: Build dist baseline (APP_DIR) + - name: Build dist baseline run: | set -euo pipefail source /tmp/proposer.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + [[ "${SKIP:-0}" != "1" ]] || exit 0 cd "$APP_DIR" npm run build - - name: Apply ticket (alias + commit) on bot branch + - name: Apply proposer batch on bot branch continue-on-error: true env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }} BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }} - FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} run: | set -euo pipefail source /tmp/proposer.env @@ -281,24 +339,38 @@ jobs: START_SHA="$(git rev-parse HEAD)" TS="$(date -u +%Y%m%d-%H%M%S)" - BR="bot/proposer-${ISSUE_NUMBER}-${TS}" + BR="bot/proposer-${TARGET_PRIMARY_ISSUE}-${TS}" echo "BRANCH=$BR" >> /tmp/proposer.env git checkout -b "$BR" export GITEA_OWNER="$OWNER" export GITEA_REPO="$REPO" - export FORGE_BASE="$API_BASE" + export FORGE_API="$API_BASE" LOG="/tmp/proposer-apply.log" - set +e - (cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE_NUMBER" --alias --commit) >"$LOG" 2>&1 - RC=$? - set -e + : > "$LOG" + + RC=0 + FAILED_ISSUE="" + for ISSUE in $TARGET_ISSUES; do + echo "" >>"$LOG" + echo "== ticket #$ISSUE ==" >>"$LOG" + set +e + (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" + break + fi + done echo "APPLY_RC=$RC" >> /tmp/proposer.env + echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env echo "== apply log (tail) ==" - tail -n 200 "$LOG" || true + tail -n 220 "$LOG" || true END_SHA="$(git rev-parse HEAD)" if [[ "$RC" -ne 0 ]]; then @@ -313,7 +385,34 @@ jobs: echo "END_SHA=$END_SHA" >> /tmp/proposer.env fi - - name: Push bot branch + - name: Rebase bot branch on latest main + continue-on-error: true + run: | + set -euo pipefail + source /tmp/proposer.env || true + [[ "${SKIP:-0}" != "1" ]] || exit 0 + [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 + [[ "${NOOP:-0}" == "0" ]] || exit 0 + + LOG="/tmp/proposer-apply.log" + + git fetch origin "$DEFAULT_BRANCH" + + set +e + git rebase "origin/$DEFAULT_BRANCH" >>"$LOG" 2>&1 + RC=$? + set -e + + if [[ "$RC" -ne 0 ]]; then + git rebase --abort || true + fi + + echo "REBASE_RC=$RC" >> /tmp/proposer.env + + echo "== rebase log (tail) ==" + tail -n 220 "$LOG" || true + + - name: Comment issues on failure if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -322,7 +421,48 @@ jobs: source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 + APPLY_RC="${APPLY_RC:-0}" + REBASE_RC="${REBASE_RC:-0}" + + if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then + echo "ℹ️ no failure detected" + exit 0 + fi + + test -n "${FORGE_TOKEN:-}" || exit 0 + + if [[ -f /tmp/proposer-apply.log ]]; then + BODY="$(tail -n 160 /tmp/proposer-apply.log | sed 's/\r$//')" + else + BODY="(no proposer log found)" + fi + + 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" + else + MSG="❌ Rebase Proposer en échec sur main (rc=${REBASE_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" + fi + + PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" + + 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 + done + + - name: Push bot branch + if: ${{ always() }} + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + 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; } @@ -337,7 +477,7 @@ jobs: git remote set-url origin "$AUTH_URL" git push -u origin "$BRANCH" - - name: Create PR + comment issue + - name: Create PR + comment issues + close issues if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -345,18 +485,52 @@ jobs: set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - [[ "${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; } - PR_TITLE="proposer: apply ticket #${ISSUE_NUMBER}" - PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA:-unknown}\n\nMerge si CI OK." + if [[ "${TARGET_COUNT:-0}" == "1" ]]; then + PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}" + else + PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}" + fi - PR_PAYLOAD="$(node --input-type=module -e ' - const [title, body, base, head] = process.argv.slice(1); - console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true })); - ' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")" + 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); + + 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"); + + 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 + })); +NODE + )" PR_JSON="$(curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ @@ -371,25 +545,40 @@ jobs: test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; } - MSG="✅ PR Proposer créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}" - C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" + 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")" - curl -fsS -X POST \ - -H "Authorization: token $FORGE_TOKEN" \ - -H "Content-Type: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ - --data-binary "$C_PAYLOAD" + 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" - - name: Finalize (fail job if apply failed) + curl -fsS -X PATCH \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \ + --data-binary '{"state":"closed"}' + done + + echo "✅ PR: $PR_URL" + + - name: Finalize if: ${{ always() }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - RC="${APPLY_RC:-0}" - if [[ "$RC" != "0" ]]; then - echo "❌ apply failed (rc=$RC)" - exit "$RC" + if [[ "${APPLY_RC:-0}" != "0" ]]; then + echo "❌ apply failed (rc=${APPLY_RC})" + exit "${APPLY_RC}" fi - echo "✅ apply ok" \ No newline at end of file + + if [[ "${REBASE_RC:-0}" != "0" ]]; then + echo "❌ rebase failed (rc=${REBASE_RC})" + exit "${REBASE_RC}" + fi + + echo "✅ proposer queue ok" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d4da79..29b3a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ public/favicon_io.zip # macOS .DS_Store + +# local temp workspace +.tmp/ \ No newline at end of file diff --git a/scripts/apply-ticket.mjs b/scripts/apply-ticket.mjs index 31dd18b..f4775df 100644 --- a/scripts/apply-ticket.mjs +++ b/scripts/apply-ticket.mjs @@ -39,7 +39,7 @@ Env (recommandé): Notes: - Si dist//index.html est absent, le script lance "npm run build" sauf si --no-build. - - Sauvegarde automatique: .bak.issue- (uniquement si on écrit) + - Sauvegarde automatique: .tmp/apply-ticket/.bak.issue- (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. `); @@ -89,6 +89,7 @@ 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"); +const BACKUP_ROOT = path.join(CWD, ".tmp", "apply-ticket"); /* -------------------------- utils texte / matching -------------------------- */ @@ -158,6 +159,17 @@ function bestBlockMatchIndex(blocks, targetText) { return best; } +function rankedBlockMatches(blocks, targetText, limit = 5) { + return blocks + .map((b, i) => ({ + i, + score: scoreText(b, targetText), + excerpt: stripMd(b).slice(0, 140), + })) + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + function splitParagraphBlocks(mdxText) { const raw = String(mdxText ?? "").replace(/\r\n/g, "\n"); return raw.split(/\n{2,}/); @@ -612,18 +624,15 @@ async function main() { const original = await fs.readFile(contentFile, "utf-8"); const blocks = splitParagraphBlocks(original); - const best = bestBlockMatchIndex(blocks, targetText); + const ranked = rankedBlockMatches(blocks, targetText, 5); + const best = ranked[0] || { i: -1, score: -1, excerpt: "" }; + const runnerUp = ranked[1] || null; - // seuil de sécurité + // 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)'.`); - const ranked = blocks - .map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) })) - .sort((a, b) => b.score - a.score) - .slice(0, 5); - console.error("Top candidates:"); for (const r of ranked) { console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`); @@ -631,6 +640,20 @@ 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) { + console.error("❌ Match ambigu: le meilleur candidat est trop proche du second."); + console.error(`➡️ best=${best.score} / second=${runnerUp.score} / gap=${ambiguityGap}`); + console.error("Top candidates:"); + for (const r of ranked) { + console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`); + } + process.exit(2); + } + } + const beforeBlock = blocks[best.i]; const afterBlock = proposition.trim(); @@ -651,7 +674,10 @@ async function main() { } // backup uniquement si on écrit - const bakPath = `${contentFile}.bak.issue-${issueNum}`; + 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"); } @@ -684,6 +710,8 @@ async function main() { } // 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 }); run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD }); } diff --git a/scripts/pick-proposer-issue.mjs b/scripts/pick-proposer-issue.mjs new file mode 100644 index 0000000..c19a978 --- /dev/null +++ b/scripts/pick-proposer-issue.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +import process from "node:process"; + +function getEnv(name, fallback = "") { + return String(process.env[name] ?? fallback).trim(); +} + +function sh(value) { + return JSON.stringify(String(value ?? "")); +} + +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 = 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 = String(body || "").match(re); + if (!m) return ""; + const lines = m[1].split(/\r?\n/).map((l) => l.trim()); + for (const l of lines) { + if (!l) continue; + if (l.startsWith("