Compare commits

...

23 Commits

Author SHA1 Message Date
f9d34110e4 Merge pull request 'proposer: apply 2 tickets on /archicrat-ia/prologue/' (#266) from bot/proposer-264-20260316-120249 into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 20s
CI / build-and-anchors (push) Successful in 41s
SMOKE / smoke (push) Successful in 7s
Deploy staging+live (annotations) / deploy (push) Successful in 7m47s
Reviewed-on: #266
2026-03-16 13:18:27 +01:00
archicratie-bot
84e9c3ead4 edit: apply ticket #265 (/archicrat-ia/prologue/#p-5-85126fa5)
All checks were successful
CI / build-and-anchors (pull_request) Successful in 43s
SMOKE / smoke (push) Successful in 4s
CI / build-and-anchors (push) Successful in 39s
2026-03-16 12:03:37 +00:00
archicratie-bot
72e59175fc edit: apply ticket #264 (/archicrat-ia/prologue/#p-4-8ed4f807) 2026-03-16 12:03:13 +00:00
81b69ac6d5 Merge pull request 'fix(actions): verify proposer issue closure after PR creation' (#263) from hotfix/proposer-close-verify into main
All checks were successful
CI / build-and-anchors (push) Successful in 43s
Deploy staging+live (annotations) / deploy (push) Successful in 47s
Proposer Apply (Queue) / apply-proposer (push) Successful in 33s
SMOKE / smoke (push) Successful in 2s
Reviewed-on: #263
2026-03-16 12:54:26 +01:00
513ae72e85 fix(actions): verify proposer issue closure after PR creation
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 43s
CI / build-and-anchors (pull_request) Successful in 47s
2026-03-16 12:52:47 +01:00
4c4dd1c515 Merge pull request 'fix(actions): remove fragile heredocs from proposer PR step' (#262) from hotfix/proposer-no-heredoc-pr-step into main
All checks were successful
Deploy staging+live (annotations) / deploy (push) Successful in 39s
CI / build-and-anchors (push) Successful in 46s
SMOKE / smoke (push) Successful in 7s
Proposer Apply (Queue) / apply-proposer (push) Successful in 1m30s
Reviewed-on: #262
2026-03-16 12:34:12 +01:00
46b15ed6ab fix(actions): remove fragile heredocs from proposer PR step
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 44s
CI / build-and-anchors (pull_request) Successful in 40s
2026-03-16 12:30:43 +01:00
a015e72f7c Merge pull request 'proposer: apply ticket #257' (#261) from bot/proposer-257-20260316-111742 into main
All checks were successful
CI / build-and-anchors (push) Successful in 42s
SMOKE / smoke (push) Successful in 8s
Proposer Apply (Queue) / apply-proposer (push) Successful in 1m26s
Deploy staging+live (annotations) / deploy (push) Successful in 7m52s
Reviewed-on: #261
2026-03-16 12:21:55 +01:00
archicratie-bot
d5df7d77a0 edit: apply ticket #257 (/archicrat-ia/prologue/#p-0-d7974f88)
All checks were successful
CI / build-and-anchors (push) Successful in 42s
CI / build-and-anchors (pull_request) Successful in 40s
SMOKE / smoke (push) Successful in 4s
2026-03-16 11:18:06 +00:00
ec3ceee862 Merge pull request 'fix(actions): tolerate empty label payload in proposer gate' (#260) from debug/proposer-257 into main
Some checks failed
CI / build-and-anchors (push) Successful in 46s
Deploy staging+live (annotations) / deploy (push) Successful in 48s
SMOKE / smoke (push) Successful in 2s
Proposer Apply (Queue) / apply-proposer (push) Failing after 1m39s
Reviewed-on: #260
2026-03-16 12:16:12 +01:00
867475c3ff fix(actions): tolerate empty label payload in proposer gate
All checks were successful
SMOKE / smoke (push) Successful in 8s
CI / build-and-anchors (push) Successful in 45s
CI / build-and-anchors (pull_request) Successful in 39s
2026-03-16 12:14:01 +01:00
b024c5557c Merge pull request 'fix(editorial): preserve frontmatter in apply-ticket' (#259) from hotfix/preserve-frontmatter-apply-ticket into main
All checks were successful
CI / build-and-anchors (push) Successful in 47s
Proposer Apply (Queue) / apply-proposer (push) Successful in 38s
SMOKE / smoke (push) Successful in 11s
Deploy staging+live (annotations) / deploy (push) Successful in 9m38s
Reviewed-on: #259
2026-03-16 11:52:07 +01:00
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
d02b6fc347 fix(actions): repair proposer workflow yaml
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 43s
CI / build-and-anchors (pull_request) Successful in 45s
2026-03-16 00:38:46 +01:00
431f1e347b Merge pull request 'chore(editorial): harden proposer queue and apply-ticket' (#252) from chore/editorial-hardening-v1-clean into main
Some checks are pending
Deploy staging+live (annotations) / deploy (push) Waiting to run
CI / build-and-anchors (push) Successful in 1m6s
SMOKE / smoke (push) Successful in 39s
Reviewed-on: #252
2026-03-16 00:02:31 +01:00
ab6f45ed5c chore(editorial): harden proposer queue and apply-ticket
All checks were successful
SMOKE / smoke (push) Successful in 6s
CI / build-and-anchors (push) Successful in 43s
CI / build-and-anchors (pull_request) Successful in 46s
2026-03-15 23:58:11 +01:00
02c060d239 Merge pull request 'chore/reset-from-docx-clean-base' (#251) from chore/reset-from-docx-clean-base into main
All checks were successful
SMOKE / smoke (push) Successful in 8s
CI / build-and-anchors (push) Successful in 45s
Deploy staging+live (annotations) / deploy (push) Successful in 9m50s
Reviewed-on: #251
2026-03-15 21:19:24 +01:00
b3a73a7781 Merge pull request 'chore(reset): reimport chapitre 1 from docx clean base' (#250) from chore/reset-from-docx-clean-base into main
All checks were successful
SMOKE / smoke (push) Successful in 8s
CI / build-and-anchors (push) Successful in 37s
Deploy staging+live (annotations) / deploy (push) Successful in 9m32s
Reviewed-on: #250
2026-03-15 15:31:37 +01:00
7 changed files with 984 additions and 266 deletions

View File

@@ -41,7 +41,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" 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 node --input-type=module - <<'NODE' > /tmp/anno.env
import fs from "node:fs"; import fs from "node:fs";
@@ -66,7 +66,10 @@ jobs:
if (!owner || !repo) { if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/); 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"); 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"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
// label name: best-effort (non-bloquant)
let labelName = "workflow_dispatch"; let labelName = "workflow_dispatch";
const lab = ev?.label; const lab = ev?.label;
if (typeof lab === "string") labelName = lab; if (typeof lab === "string") labelName = lab;
@@ -95,7 +97,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"") ? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin; : origin;
function sh(s){ return JSON.stringify(String(s)); } function sh(s) { return JSON.stringify(String(s)); }
process.stdout.write([ process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`, `CLONE_URL=${sh(cloneUrl)}`,
@@ -108,7 +110,7 @@ jobs:
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
echo "context:" echo "context:"
sed -n '1,120p' /tmp/anno.env sed -n '1,120p' /tmp/anno.env
- name: Early gate (label event fast-skip, but tolerant) - name: Early gate (label event fast-skip, but tolerant)
@@ -116,18 +118,16 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/anno.env 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 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=1" >> /tmp/anno.env
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
exit 0 exit 0
fi 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 - name: Fetch issue + hard gate on labels + Type
env: env:
@@ -135,9 +135,9 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env 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 \ curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -148,11 +148,12 @@ jobs:
node --input-type=module - <<'NODE' >> /tmp/anno.env node --input-type=module - <<'NODE' >> /tmp/anno.env
import fs from "node:fs"; import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); 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 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"); const hasApproved = labels.includes("state/approved");
function pickLine(key) { function pickLine(key) {
@@ -164,14 +165,12 @@ jobs:
const typeRaw = pickLine("Type"); const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase(); const type = String(typeRaw || "").trim().toLowerCase();
const allowed = new Set(["type/media","type/reference","type/comment"]); const allowedAnno = new Set(["type/media", "type/reference", "type/comment"]);
const proposer = new Set(["type/correction","type/fact-check"]); const proposerTypes = new Set(["type/correction", "type/fact-check"]);
const out = []; const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
// HARD gate: must currently have state/approved (avoids depending on event payload)
if (!hasApproved) { if (!hasApproved) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`); out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`);
@@ -182,23 +181,23 @@ jobs:
if (!type) { if (!type) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowed.has(type)) { } else if (allowedAnno.has(type)) {
// proceed // proceed
} else if (proposer.has(type)) { } else if (proposerTypes.has(type)) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`); out.push(`SKIP_REASON=${JSON.stringify("proposer_type:" + type)}`);
} else { } else {
out.push(`SKIP=1`); 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"); process.stdout.write(out.join("\n") + "\n");
NODE NODE
echo "gating result:" echo "gating result:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true 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() }} if: ${{ always() }}
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -208,9 +207,13 @@ jobs:
[[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "${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 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 exit 0
fi fi
@@ -219,15 +222,13 @@ jobs:
REASON="${SKIP_REASON:-}" REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}" TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == proposer_type:* ]]; then if [[ "$REASON" == unsupported_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." MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment."
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."
else 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 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 \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -239,7 +240,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
rm -rf .git rm -rf .git
git init -q git init -q
@@ -252,16 +253,16 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm ci --no-audit --no-fund npm ci --no-audit --no-fund
- name: Check apply script exists - name: Check apply script exists
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env 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 || { 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 ls -la scripts | sed -n '1,200p' || true
exit 1 exit 1
} }
@@ -270,16 +271,16 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm run build npm run build
test -f dist/para-index.json || { 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 ls -la dist | sed -n '1,200p' || true
exit 1 exit 1
} }
echo "dist/para-index.json present" echo "dist/para-index.json present"
- name: Apply ticket on bot branch (strict+verify, commit) - name: Apply ticket on bot branch (strict+verify, commit)
continue-on-error: true continue-on-error: true
@@ -290,10 +291,10 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; 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 -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.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}" git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
@@ -340,11 +341,11 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then if [[ "$RC" == "0" ]]; then
echo " no failure detected" echo "no failure detected"
exit 0 exit 0
fi fi
@@ -356,8 +357,8 @@ jobs:
BODY="(no apply log found)" BODY="(no apply log found)"
fi fi
MSG="apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" 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")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -374,9 +375,9 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> 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; } [[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip push"; exit 0; }
test -d .git || { echo " no git repo -> skip push"; exit 0; } test -d .git || { echo "no git repo -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e ' AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1); const [clone, tok] = process.argv.slice(1);
@@ -398,8 +399,8 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> 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; } [[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}" 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." 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 || ""); console.log(pr.html_url || pr.url || "");
' "$PR_JSON")" ' "$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}" 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")" C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -431,7 +432,7 @@ jobs:
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD" --data-binary "$C_PAYLOAD"
echo "PR: $PR_URL" echo "PR: $PR_URL"
- name: Finalize (fail job if apply failed) - name: Finalize (fail job if apply failed)
if: ${{ always() }} if: ${{ always() }}
@@ -439,11 +440,11 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then if [[ "$RC" != "0" ]]; then
echo "apply failed (rc=$RC)" echo "apply failed (rc=$RC)"
exit "$RC" exit "$RC"
fi fi
echo "apply ok" echo "apply ok"

View File

@@ -1,13 +1,16 @@
name: Proposer Apply (PR) name: Proposer Apply (Queue)
on: on:
issues: issues:
types: [labeled] types: [labeled]
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
issue: issue:
description: "Issue number to apply (Proposer: correction/fact-check)" description: "Issue number to prioritize (optional)"
required: true required: false
default: ""
env: env:
NODE_OPTIONS: --dns-result-order=ipv4first NODE_OPTIONS: --dns-result-order=ipv4first
@@ -17,8 +20,8 @@ defaults:
shell: bash shell: bash
concurrency: concurrency:
group: proposer-apply-${{ github.event.issue.number || inputs.issue || 'manual' }} group: proposer-queue-main
cancel-in-progress: true cancel-in-progress: false
jobs: jobs:
apply-proposer: apply-proposer:
@@ -34,14 +37,15 @@ jobs:
node --version node --version
npm --version npm --version
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch / push)
env: env:
INPUT_ISSUE: ${{ inputs.issue }} INPUT_ISSUE: ${{ inputs.issue }}
EVENT_NAME_IN: ${{ github.event_name }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" 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 node --input-type=module - <<'NODE' > /tmp/proposer.env
import fs from "node:fs"; import fs from "node:fs";
@@ -51,7 +55,7 @@ jobs:
const cloneUrl = const cloneUrl =
repoObj?.clone_url || 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"); if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
@@ -66,8 +70,12 @@ jobs:
if (!owner || !repo) { if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/); 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"); if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const defaultBranch = repoObj?.default_branch || "main"; const defaultBranch = repoObj?.default_branch || "main";
@@ -75,25 +83,30 @@ jobs:
const issueNumber = const issueNumber =
ev?.issue?.number || ev?.issue?.number ||
ev?.issue?.index || ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0) ||
0;
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
throw new Error("No issue number in event.json or workflow_dispatch input");
}
const labelName = const labelName =
ev?.label?.name || ev?.label?.name ||
ev?.label || (typeof ev?.label === "string" ? ev.label : "") ||
"workflow_dispatch"; "";
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 u = new URL(cloneUrl);
const origin = u.origin; const origin = u.origin;
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim()) const apiBase =
? String(process.env.FORGE_API).trim().replace(/\/+$/,"") (process.env.FORGE_API && String(process.env.FORGE_API).trim())
: origin; ? 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([ process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`, `CLONE_URL=${sh(cloneUrl)}`,
`OWNER=${sh(owner)}`, `OWNER=${sh(owner)}`,
@@ -101,82 +114,170 @@ jobs:
`DEFAULT_BRANCH=${sh(defaultBranch)}`, `DEFAULT_BRANCH=${sh(defaultBranch)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`, `ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`, `LABEL_NAME=${sh(labelName)}`,
`EVENT_NAME=${sh(eventName)}`,
`API_BASE=${sh(apiBase)}` `API_BASE=${sh(apiBase)}`
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
echo "✅ context:" 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 (tolerant on empty issue label payload)
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env source /tmp/proposer.env
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then echo "event=$EVENT_NAME label=${LABEL_NAME:-<empty>}"
echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/proposer.env
exit 0
fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
- name: Fetch issue + API-hard gate on (state/approved present + proposer type) if [[ "$EVENT_NAME" == "issues" ]]; then
# Gitea peut fournir un payload "issues/labeled" sans label exploitable.
# On ne skip QUE si le label est explicitement présent ET différent de state/approved.
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip"
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="label_not_state_approved_event"' >> /tmp/proposer.env
exit 0
fi
fi
echo "Proceed to API-based selection/gating"
- 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: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env 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
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 \ curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \
-o /tmp/issue.json -o /tmp/open_pulls.json
export TARGET_ISSUES="${TARGET_ISSUES:-}"
node --input-type=module - <<'NODE' >> /tmp/proposer.env node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs"; 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 pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8"));
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi"); const issues = String(process.env.TARGET_ISSUES || "")
const m = body.match(re); .trim()
return m ? m[1].trim() : ""; .split(/\s+/)
} .filter(Boolean);
const typeRaw = pickLine("Type"); const proposerOpen = Array.isArray(pulls)
const type = String(typeRaw || "").trim().toLowerCase(); ? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
: [];
const hasApproved = labels.includes("state/approved"); const current = proposerOpen.find((pr) => {
const proposer = new Set(["type/correction","type/fact-check"]); 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 = []; 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=1");
out.push(`SKIP_REASON=${JSON.stringify("approved_not_present")}`); out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
} else if (!type) { out.push(`OPEN_PR_URL=${JSON.stringify(String(current.html_url || current.url || ""))}`);
out.push(`SKIP=1`); } else if (proposerOpen.length > 0) {
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); const first = proposerOpen[0];
} else if (!proposer.has(type)) { out.push("SKIP=1");
out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
out.push(`SKIP_REASON=${JSON.stringify("not_proposer:"+type)}`); 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 NODE
echo "✅ proposer gating:" - name: Comment issue if queued / skipped
grep -E '^(ISSUE_TYPE|HAS_APPROVED|SKIP|SKIP_REASON)=' /tmp/proposer.env || true
- name: Comment issue if skipped
if: ${{ always() }} if: ${{ always() }}
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -185,122 +286,143 @@ jobs:
source /tmp/proposer.env || true source /tmp/proposer.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0 [[ "${EVENT_NAME:-}" != "push" ]] || exit 0
REASON="${SKIP_REASON:-}" if [[ "${SKIP_REASON:-}" == "label_not_state_approved_event" || "${SKIP_REASON:-}" == "label_not_state_approved" ]]; then
TYPE="${ISSUE_TYPE:-}" echo "Skip reason=${SKIP_REASON} -> no comment"
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.)"
fi fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" test -n "${FORGE_TOKEN:-}" || exit 0
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 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="This ticket already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
;;
explicit_issue_missing_chemin)
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
;;
explicit_issue_missing_type)
MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable."
;;
explicit_issue_not_approved)
MSG="Proposer Apply: this ticket is not currently labeled state/approved."
;;
explicit_issue_rejected)
MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue."
;;
no_open_approved_proposer_issue)
MSG="No approved proposer ticket is currently waiting."
;;
*)
MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}."
;;
esac
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 \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -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 --data-binary @/tmp/proposer.skip.comment.json || true
- name: Checkout default branch - name: NPM harden
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
echo "✅ workspace:"
ls -la | sed -n '1,120p'
- 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"
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)
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR" cd "$APP_DIR"
npm config set fetch-retries 5 npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000 npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000 npm config set fetch-retry-maxtimeout 120000
npm config set registry https://registry.npmjs.org npm config set registry https://registry.npmjs.org
- name: Install deps (APP_DIR) - name: Install deps
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR" cd "$APP_DIR"
npm ci --no-audit --no-fund npm ci --no-audit --no-fund
- name: Build dist baseline (APP_DIR) - name: Build dist baseline
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR" cd "$APP_DIR"
npm run build npm run build
- name: Apply ticket (alias + commit) on bot branch - name: Apply proposer batch on bot branch
continue-on-error: true continue-on-error: true
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }} BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }}
BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }} BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env 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}" git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
START_SHA="$(git rev-parse HEAD)" START_SHA="$(git rev-parse HEAD)"
TS="$(date -u +%Y%m%d-%H%M%S)" 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 echo "BRANCH=$BR" >> /tmp/proposer.env
git checkout -b "$BR" git checkout -b "$BR"
export GITEA_OWNER="$OWNER" export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO" export GITEA_REPO="$REPO"
export FORGE_BASE="$API_BASE" export FORGE_API="$API_BASE"
LOG="/tmp/proposer-apply.log" LOG="/tmp/proposer-apply.log"
set +e : > "$LOG"
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE_NUMBER" --alias --commit) >"$LOG" 2>&1
RC=$? RC=0
set -e 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 "APPLY_RC=$RC" >> /tmp/proposer.env
echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env
echo "== apply log (tail) ==" echo "Apply log (tail):"
tail -n 200 "$LOG" || true tail -n 220 "$LOG" || true
END_SHA="$(git rev-parse HEAD)" END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/proposer.env echo "NOOP=0" >> /tmp/proposer.env
exit 0 exit 0
@@ -313,7 +435,34 @@ jobs:
echo "END_SHA=$END_SHA" >> /tmp/proposer.env echo "END_SHA=$END_SHA" >> /tmp/proposer.env
fi 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() }} if: ${{ always() }}
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -322,9 +471,65 @@ jobs:
source /tmp/proposer.env || true source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; } APPLY_RC="${APPLY_RC:-0}"
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; } REBASE_RC="${REBASE_RC:-0}"
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip push"; exit 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
export BODY APPLY_RC REBASE_RC FAILED_ISSUE
if [[ "$APPLY_RC" != "0" ]]; then
export FAILURE_KIND="apply"
else
export FAILURE_KIND="rebase"
fi
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 @/tmp/proposer.failure.comment.json || 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; }
AUTH_URL="$(node --input-type=module -e ' AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1); const [clone, tok] = process.argv.slice(1);
@@ -337,7 +542,7 @@ jobs:
git remote set-url origin "$AUTH_URL" git remote set-url origin "$AUTH_URL"
git push -u origin "$BRANCH" git push -u origin "$BRANCH"
- name: Create PR + comment issue - name: Create PR + comment issues + close issues
if: ${{ always() }} if: ${{ always() }}
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -345,51 +550,205 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/proposer.env || true source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-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; }
PR_TITLE="proposer: apply ticket #${ISSUE_NUMBER}" test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; }
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA:-unknown}\n\nMerge si CI OK."
PR_PAYLOAD="$(node --input-type=module -e ' if [[ "${TARGET_COUNT:-0}" == "1" ]]; then
const [title, body, base, head] = process.argv.slice(1); PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}"
console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true })); else
' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")" PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}"
fi
export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER
node --input-type=module -e '
import fs from "node:fs";
const issues = String(process.env.TARGET_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.TARGET_CHEMIN || "(inconnu)"}`,
"- Tickets:",
...issues.map((n) => ` - #${n}`),
`- Branche: ${process.env.BRANCH || ""}`,
`- Commit: ${process.env.END_SHA || "unknown"}`,
"",
"Merge si CI OK."
].join("\n");
fs.writeFileSync(
"/tmp/proposer.pr.json",
JSON.stringify({
title: process.env.PR_TITLE || "proposer: apply tickets",
body,
base: process.env.DEFAULT_BRANCH || "main",
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
})
);
'
echo "Creating proposer PR..."
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 @/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
}
echo "PR created: $PR_URL"
for ISSUE in $TARGET_ISSUES; do
export ISSUE PR_URL
node --input-type=module -e '
import fs from "node:fs";
const issue = process.env.ISSUE || "";
const url = process.env.PR_URL || "";
const msg =
`PR proposer créée pour le ticket #${issue} : ${url}\n\n` +
`Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`;
fs.writeFileSync(
"/tmp/proposer.issue.close.comment.json",
JSON.stringify({ body: msg })
);
'
echo "Commenting issue #$ISSUE ..."
COMMENT_HTTP="$(curl -sS -o /tmp/proposer.comment.out.json -w '%{http_code}' -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary @/tmp/proposer.issue.close.comment.json || true)"
echo "Issue #$ISSUE comment HTTP=$COMMENT_HTTP"
if [[ ! "$COMMENT_HTTP" =~ ^2 ]]; then
echo "Failed to comment issue #$ISSUE"
cat /tmp/proposer.comment.out.json || true
exit 1
fi
echo "Closing issue #$ISSUE ..."
CLOSE_HTTP="$(curl -sS -o /tmp/proposer.close.out.json -w '%{http_code}' -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"}' || true)"
echo "Issue #$ISSUE close HTTP=$CLOSE_HTTP"
if [[ ! "$CLOSE_HTTP" =~ ^2 ]]; then
echo "Failed to close issue #$ISSUE"
cat /tmp/proposer.close.out.json || true
exit 1
fi
echo "Verifying issue #$ISSUE state ..."
VERIFY_HTTP="$(curl -sS -o /tmp/proposer.verify.out.json -w '%{http_code}' \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" || true)"
echo "Issue #$ISSUE verify HTTP=$VERIFY_HTTP"
if [[ ! "$VERIFY_HTTP" =~ ^2 ]]; then
echo "Failed to re-read issue #$ISSUE after close"
cat /tmp/proposer.verify.out.json || true
exit 1
fi
ISSUE_STATE="$(node --input-type=module -e '
import fs from "node:fs";
const j = JSON.parse(fs.readFileSync("/tmp/proposer.verify.out.json", "utf8"));
console.log(String(j.state || ""));
')"
echo "Issue #$ISSUE state=$ISSUE_STATE"
[[ "$ISSUE_STATE" == "closed" ]] || {
echo "Issue #$ISSUE is not closed after PATCH"
cat /tmp/proposer.verify.out.json || true
exit 1
}
done
echo "PR: $PR_URL"
PR_JSON="$(curl -fsS -X POST \ PR_JSON="$(curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \ "$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 ' PR_URL="$(node --input-type=module -e 'const pr = JSON.parse(process.argv[1] || "{}"); console.log(pr.html_url || pr.url || "");' "$PR_JSON")"
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
}
MSG="✅ PR Proposer créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}" for ISSUE in $TARGET_ISSUES; do
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" export ISSUE PR_URL
curl -fsS -X POST \ node --input-type=module -e '
-H "Authorization: token $FORGE_TOKEN" \ import fs from "node:fs";
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD"
- name: Finalize (fail job if apply failed) const issue = process.env.ISSUE || "";
const url = process.env.PR_URL || "";
const msg =
`PR proposer créée pour le ticket #${issue} : ${url}\n\n` +
`Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`;
fs.writeFileSync(
"/tmp/proposer.issue.close.comment.json",
JSON.stringify({ body: msg })
);
'
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 @/tmp/proposer.issue.close.comment.json
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() }} if: ${{ always() }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/proposer.env || true source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
RC="${APPLY_RC:-0}" if [[ "${APPLY_RC:-0}" != "0" ]]; then
if [[ "$RC" != "0" ]]; then echo "Apply failed (rc=${APPLY_RC})"
echo "❌ apply failed (rc=$RC)" exit "${APPLY_RC}"
exit "$RC"
fi fi
echo "✅ apply ok"
if [[ "${REBASE_RC:-0}" != "0" ]]; then
echo "Rebase failed (rc=${REBASE_RC})"
exit "${REBASE_RC}"
fi
echo "Proposer queue OK"

3
.gitignore vendored
View File

@@ -28,3 +28,6 @@ public/favicon_io.zip
# macOS # macOS
.DS_Store .DS_Store
# local temp workspace
.tmp/

View File

@@ -9,8 +9,9 @@ import { spawnSync } from "node:child_process";
* *
* Conçu pour: * Conçu pour:
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition * - 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 * - remplacer proprement
* - ne JAMAIS toucher au frontmatter
* - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json * - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement * - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit) * - optionnel: fermer le ticket (après commit)
@@ -39,7 +40,7 @@ Env (recommandé):
Notes: Notes:
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build. - Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit) - Sauvegarde automatique: .tmp/apply-ticket/<fichier>.bak.issue-<N> (uniquement si on écrit)
- Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new. - Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new.
- Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux. - Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux.
`); `);
@@ -89,6 +90,7 @@ const CWD = process.cwd();
const CONTENT_ROOT = path.join(CWD, "src", "content"); const CONTENT_ROOT = path.join(CWD, "src", "content");
const DIST_ROOT = path.join(CWD, "dist"); const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const BACKUP_ROOT = path.join(CWD, ".tmp", "apply-ticket");
/* -------------------------- utils texte / matching -------------------------- */ /* -------------------------- utils texte / matching -------------------------- */
@@ -136,31 +138,26 @@ function scoreText(candidate, targetText) {
let hit = 0; let hit = 0;
for (const w of tgtSet) if (blkSet.has(w)) hit++; for (const w of tgtSet) if (blkSet.has(w)) hit++;
// Bonus si un long préfixe ressemble
const tgtNorm = normalizeText(stripMd(targetText)); const tgtNorm = normalizeText(stripMd(targetText));
const blkNorm = normalizeText(stripMd(candidate)); const blkNorm = normalizeText(stripMd(candidate));
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length)); const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0; const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
// Ratio bonus (0..100)
const ratio = hit / Math.max(1, tgtSet.size); const ratio = hit / Math.max(1, tgtSet.size);
const ratioBonus = Math.round(ratio * 100); const ratioBonus = Math.round(ratio * 100);
return prefixBonus + hit + ratioBonus; return prefixBonus + hit + ratioBonus;
} }
function bestBlockMatchIndex(blocks, targetText) { function rankedBlockMatches(blocks, targetText, limit = 5) {
let best = { i: -1, score: -1 }; return blocks
for (let i = 0; i < blocks.length; i++) { .map((b, i) => ({
const sc = scoreText(blocks[i], targetText); i,
if (sc > best.score) best = { i, score: sc }; score: scoreText(b, targetText),
} excerpt: stripMd(b).slice(0, 140),
return best; }))
} .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,}/);
} }
function isLikelyExcerpt(s) { function isLikelyExcerpt(s) {
@@ -172,6 +169,89 @@ function isLikelyExcerpt(s) {
return false; 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 ------------------------------ */ /* ------------------------------ utils système ------------------------------ */
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
@@ -251,7 +331,9 @@ function pickSection(body, markers) {
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) })) .map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter((x) => x.i >= 0) .filter((x) => x.i >= 0)
.sort((a, b) => a.i - b.i)[0]; .sort((a, b) => a.i - b.i)[0];
if (!idx) return ""; if (!idx) return "";
const start = idx.i + idx.m.length; const start = idx.i + idx.m.length;
const tail = text.slice(start); const tail = text.slice(start);
@@ -266,11 +348,13 @@ function pickSection(body, markers) {
"\n## Proposition", "\n## Proposition",
"\n## Problème", "\n## Problème",
]; ];
let end = tail.length; let end = tail.length;
for (const s of stops) { for (const s of stops) {
const j = tail.toLowerCase().indexOf(s.toLowerCase()); const j = tail.toLowerCase().indexOf(s.toLowerCase());
if (j >= 0 && j < end) end = j; if (j >= 0 && j < end) end = j;
} }
return tail.slice(0, end).trim(); return tail.slice(0, end).trim();
} }
@@ -298,8 +382,6 @@ function extractAnchorIdAnywhere(text) {
function extractCheminFromAnyUrl(text) { function extractCheminFromAnyUrl(text) {
const s = String(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); const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : ""; return m ? m[1] : "";
} }
@@ -400,7 +482,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
headers: { headers: {
Authorization: `token ${token}`, Authorization: `token ${token}`,
Accept: "application/json", Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/2.0", "User-Agent": "archicratie-apply-ticket/2.1",
}, },
}); });
if (!res.ok) { if (!res.ok) {
@@ -416,7 +498,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
Authorization: `token ${token}`, Authorization: `token ${token}`,
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.0", "User-Agent": "archicratie-apply-ticket/2.1",
}; };
if (comment) { if (comment) {
@@ -425,7 +507,11 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
} }
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`; 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) { if (!res.ok) {
const t = await res.text().catch(() => ""); const t = await res.text().catch(() => "");
@@ -529,10 +615,9 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`); console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum }); const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) { if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`); 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); process.exit(2);
} }
@@ -553,7 +638,6 @@ async function main() {
ancre = (ancre || "").trim(); ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1); if (ancre.startsWith("#")) ancre = ancre.slice(1);
// fallback si ticket mal formé
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body); if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin); chemin = normalizeChemin(chemin);
@@ -592,7 +676,6 @@ async function main() {
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html"); const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
await ensureBuildIfNeeded(distHtmlPath); await ensureBuildIfNeeded(distHtmlPath);
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel; let targetText = texteActuel;
let distText = ""; let distText = "";
@@ -609,21 +692,24 @@ async function main() {
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html)."); 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 originalRaw = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original); const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw);
const best = bestBlockMatchIndex(blocks, targetText); 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 de sécurité
if (best.i < 0 || best.score < 40) { if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement."); console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`); console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
const ranked = blocks
.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:"); console.error("Top candidates:");
for (const r of ranked) { for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`); console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
@@ -631,12 +717,34 @@ async function main() {
process.exit(2); process.exit(2);
} }
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 beforeBlock = blocks[best.i];
const afterBlock = proposition.trim(); const afterBlock = proposition.trim();
const nextBlocks = blocks.slice(); const nextBlocks = blocks.slice();
nextBlocks[best.i] = afterBlock; 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}`); console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
@@ -650,13 +758,15 @@ async function main() {
return; return;
} }
// backup uniquement si on écrit const relContentFile = path.relative(CWD, contentFile);
const bakPath = `${contentFile}.bak.issue-${issueNum}`; const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
await fs.mkdir(path.dirname(bakPath), { recursive: true });
if (!(await fileExists(bakPath))) { 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."); console.log("✅ Applied.");
let aliasChanged = false; let aliasChanged = false;
@@ -677,13 +787,13 @@ async function main() {
if (aliasChanged) { if (aliasChanged) {
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`); console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
// MàJ dist sans rebuild complet (inject seulement)
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD }); run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
} else { } else {
console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`); console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
} }
// 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("npm", ["run", "test:anchors"], { cwd: CWD });
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD }); run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
} }
@@ -713,7 +823,6 @@ async function main() {
return; return;
} }
// mode manuel
console.log("Next (manuel) :"); console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`); console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log( console.log(
@@ -730,4 +839,4 @@ async function main() {
main().catch((e) => { main().catch((e) => {
console.error("💥", e?.message || e); console.error("💥", e?.message || e);
process.exit(1); process.exit(1);
}); });

View File

@@ -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("<!--")) continue;
return l.replace(/^\/?/, "/").trim();
}
return "";
}
function normalizeChemin(chemin) {
let c = String(chemin || "").trim();
if (!c) return "";
if (!c.startsWith("/")) c = "/" + c;
if (!c.endsWith("/")) c += "/";
return c;
}
function extractCheminFromAnyUrl(text) {
const s = String(text || "");
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : "";
}
function inferType(issue) {
const title = String(issue?.title || "");
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
const fromBody = String(pickLine(body, "Type") || "").trim().toLowerCase();
if (fromBody) return fromBody;
if (title.startsWith("[Correction]")) return "type/correction";
if (title.startsWith("[Fact-check]") || title.startsWith("[Vérification]")) return "type/fact-check";
return "";
}
function inferChemin(issue) {
const title = String(issue?.title || "");
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
return normalizeChemin(
pickLine(body, "Chemin") ||
pickHeadingValue(body, "Chemin") ||
extractCheminFromAnyUrl(body) ||
extractCheminFromAnyUrl(title)
);
}
function labelsOf(issue) {
return Array.isArray(issue?.labels)
? issue.labels.map((l) => String(l?.name || "")).filter(Boolean)
: [];
}
function issueNumber(issue) {
return Number(issue?.number || issue?.index || 0);
}
function parseMeta(issue) {
const labels = labelsOf(issue);
const type = inferType(issue);
const chemin = inferChemin(issue);
const number = issueNumber(issue);
const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected");
const isProposer = type === "type/correction" || type === "type/fact-check";
const isOpen = String(issue?.state || "open") === "open";
const isPR = Boolean(issue?.pull_request);
const eligible =
number > 0 &&
isOpen &&
!isPR &&
hasApproved &&
!hasRejected &&
isProposer &&
Boolean(chemin);
return {
issue,
number,
type,
chemin,
labels,
hasApproved,
hasRejected,
eligible,
};
}
async function fetchJson(url, token) {
const res = await fetch(url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-pick-proposer-issue/1.0",
},
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${url}\n${t}`);
}
return await res.json();
}
async function fetchIssue(apiBase, owner, repo, token, n) {
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues/${n}`;
return await fetchJson(url, token);
}
async function listOpenIssues(apiBase, owner, repo, token) {
const out = [];
let page = 1;
const limit = 100;
while (true) {
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues?state=open&page=${page}&limit=${limit}`;
const batch = await fetchJson(url, token);
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
page += 1;
}
return out;
}
function emitNone(reason) {
process.stdout.write(
[
`TARGET_FOUND="0"`,
`TARGET_REASON=${sh(reason)}`,
`TARGET_PRIMARY_ISSUE=""`,
`TARGET_ISSUES=""`,
`TARGET_COUNT="0"`,
`TARGET_CHEMIN=""`,
].join("\n") + "\n"
);
}
async function main() {
const token = getEnv("FORGE_TOKEN");
const owner = getEnv("GITEA_OWNER");
const repo = getEnv("GITEA_REPO");
const apiBase = (getEnv("FORGE_API") || getEnv("FORGE_BASE")).replace(/\/+$/, "");
const explicit = Number(process.argv[2] || 0);
if (!token) throw new Error("Missing FORGE_TOKEN");
if (!owner || !repo) throw new Error("Missing GITEA_OWNER / GITEA_REPO");
if (!apiBase) throw new Error("Missing FORGE_API / FORGE_BASE");
let metas = [];
if (explicit > 0) {
const issue = await fetchIssue(apiBase, owner, repo, token, explicit);
const meta = parseMeta(issue);
if (!meta.eligible) {
emitNone(
!meta.hasApproved
? "explicit_issue_not_approved"
: meta.hasRejected
? "explicit_issue_rejected"
: !meta.type
? "explicit_issue_missing_type"
: !meta.chemin
? "explicit_issue_missing_chemin"
: "explicit_issue_not_eligible"
);
return;
}
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
metas = openIssues.map(parseMeta).filter((m) => m.eligible && m.chemin === meta.chemin);
} else {
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
metas = openIssues.map(parseMeta).filter((m) => m.eligible);
if (metas.length === 0) {
emitNone("no_open_approved_proposer_issue");
return;
}
metas.sort((a, b) => a.number - b.number);
const first = metas[0];
metas = metas.filter((m) => m.chemin === first.chemin);
}
metas.sort((a, b) => a.number - b.number);
if (metas.length === 0) {
emitNone("no_batch_for_path");
return;
}
const primary = metas[0];
const issues = metas.map((m) => String(m.number));
process.stdout.write(
[
`TARGET_FOUND="1"`,
`TARGET_REASON="ok"`,
`TARGET_PRIMARY_ISSUE=${sh(primary.number)}`,
`TARGET_ISSUES=${sh(issues.join(" "))}`,
`TARGET_COUNT=${sh(issues.length)}`,
`TARGET_CHEMIN=${sh(primary.chemin)}`,
].join("\n") + "\n"
);
}
main().catch((e) => {
console.error("💥 pick-proposer-issue:", e?.message || e);
process.exit(1);
});

View File

@@ -1 +1,7 @@
{} {
"/archicrat-ia/prologue/": {
"p-0-d7974f88": "p-0-e729df02",
"p-4-8ed4f807": "p-4-90b2a1cc",
"p-5-85126fa5": "p-5-d788c546"
}
}

View File

@@ -12,7 +12,7 @@ source:
kind: docx kind: docx
path: "sources/docx/archicrat-ia/Prologue—Archicratie-fondation_et_finalite_sociopolitique_et_historique-version_officielle.docx" path: "sources/docx/archicrat-ia/Prologue—Archicratie-fondation_et_finalite_sociopolitique_et_historique-version_officielle.docx"
--- ---
Nous vivons dans une époque saturée de diagnostics sur les formes de domination, les mutations du pouvoir, les détournements de la souveraineté. Depuis une vingtaine dannées, les appellations saccumulent : *démocratie illibérale*, *ploutocratie*, *happycratie*, *gouvernement algorithmique*, *démocrature*… À travers ces tentatives de nommer le désordre du présent, un fait se répète, de manière sourde : la scène politique semble désorientée. Les catégories héritées — *État*, *pouvoir*, *représentation*, *volonté générale*, *contrat social* — apparaissent de moins en moins capables de décrire ce qui nous gouverne effectivement. Nous vivons une époque saturée de diagnostics sur les formes de domination, les mutations du pouvoir, les détournements de la souveraineté. Depuis une vingtaine dannées, les appellations saccumulent : démocratie illibérale, ploutocratie, happycratie, gouvernement algorithmique, démocrature… À travers ces tentatives de nommer le désordre du présent, un fait se répète, de manière sourde : la scène politique semble désorientée. Les catégories héritées — État, pouvoir, représentation, volonté générale, contrat social — apparaissent de moins en moins capables de décrire ce qui nous gouverne effectivement.
Cest cette perte de prise sur le réel que ce livre souhaite prendre au sérieux. Non pour lui ajouter un terme de plus au lexique fatigué des contre-pouvoirs ou des impuissances, mais pour repartir dun point plus fondamental, presque en-deçà de la question politique classique. Ce point, cest celui de la *tenue dun monde commun* — cest-à-dire la possibilité, pour des êtres dissemblables, vulnérables, inégaux, traversés de contradictions et situés dans des temporalités hétérogènes, de coexister sans sannihiler. Cest cette perte de prise sur le réel que ce livre souhaite prendre au sérieux. Non pour lui ajouter un terme de plus au lexique fatigué des contre-pouvoirs ou des impuissances, mais pour repartir dun point plus fondamental, presque en-deçà de la question politique classique. Ce point, cest celui de la *tenue dun monde commun* — cest-à-dire la possibilité, pour des êtres dissemblables, vulnérables, inégaux, traversés de contradictions et situés dans des temporalités hétérogènes, de coexister sans sannihiler.
@@ -20,10 +20,9 @@ Cette tenue du monde néquivaut ni à la paix civile, ni à la stabilité des
Le terme nest pas trivial. Il ne sagit pas simplement dune viabilité partagée, ni dune coexistence pacifique, ni même dune durabilité écologique élargie. Il sagit dun état dynamique, instable, fragile, dans lequel un ensemble — une société, dun système biologique, dune formation historique, dun milieu technique ou dun monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*. Le terme nest pas trivial. Il ne sagit pas simplement dune viabilité partagée, ni dune coexistence pacifique, ni même dune durabilité écologique élargie. Il sagit dun état dynamique, instable, fragile, dans lequel un ensemble — une société, dun système biologique, dune formation historique, dun milieu technique ou dun monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*.
La *co-viabilité* ne désigne ni un état déquilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce quil parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces dinertie et dinnovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. Cest cette disposition active, faite de compromis fragiles et dajustements toujours révisables, que nous tenons pour première, et non dérivée. La co-viabilité ne désigne ni un état déquilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce quil parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces dinertie et dinnovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. Cest cette disposition active, faite de compromis fragiles et dajustements toujours révisables, que nous tenons pour première.
Ce qui revient à dire que la question politique — au sens fort — na peut-être jamais été qui commande ? Mais bien plus : *Comment un ordre tient-il malgré ce qui le défait ?* *Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous leffet de ses propres contradictions ?* *Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ?* Ce qui revient à dire que la question politique — au sens fort — na peut-être jamais été qui commande ? Mais bien plus : Comment un ordre tient-il malgré ce qui le défait ? Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous leffet de ses propres contradictions ? Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ? Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (Économie et société, 1922) rappelait que ce qui fait tenir un ordre, ce nest pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (La dynamique de lOccident, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche sinscrit dans ce sillage : travailler cette interrogation sur les conditions de viabilité dun monde commun soumis à ses propres tensions constitutives.
Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (*Économie et société*, 1922) rappelait que ce qui fait tenir un ordre, ce nest pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (*La dynamique de lOccident*, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche sinscrit dans ce sillage : travailler cette interrogation sur les *conditions de viabilité dun monde commun*.
Ce changement de perspective implique une rupture profonde dans la manière même de poser la question politique. Pendant des siècles, les sociétés ont pensé le politique à partir de principes transcendants — Dieu, Nature, Volonté générale, Pacte social. Ces principes, supposés extérieurs aux conflits du présent, garantissaient lordre en surplomb. Comme le rappelle Michel Foucault, il ny a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. Cest précisément cette exigence — trouver dans les relations elles-mêmes les ressources nécessaires pour maintenir des mondes vivables — qui définit notre époque. Ce changement de perspective implique une rupture profonde dans la manière même de poser la question politique. Pendant des siècles, les sociétés ont pensé le politique à partir de principes transcendants — Dieu, Nature, Volonté générale, Pacte social. Ces principes, supposés extérieurs aux conflits du présent, garantissaient lordre en surplomb. Comme le rappelle Michel Foucault, il ny a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. Cest précisément cette exigence — trouver dans les relations elles-mêmes les ressources nécessaires pour maintenir des mondes vivables — qui définit notre époque.