Compare commits
23 Commits
chore/rese
...
f9d34110e4
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d34110e4 | |||
|
|
84e9c3ead4 | ||
|
|
72e59175fc | ||
| 81b69ac6d5 | |||
| 513ae72e85 | |||
| 4c4dd1c515 | |||
| 46b15ed6ab | |||
| a015e72f7c | |||
|
|
d5df7d77a0 | ||
| ec3ceee862 | |||
| 867475c3ff | |||
| b024c5557c | |||
| 93306f360d | |||
| 52847d999d | |||
| b9629b43ff | |||
| 06482a9f8d | |||
| f2e4ae5ac2 | |||
| 71baf0f6da | |||
| d02b6fc347 | |||
| 431f1e347b | |||
| ab6f45ed5c | |||
| 02c060d239 | |||
| b3a73a7781 |
@@ -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"
|
||||||
@@ -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
3
.gitignore
vendored
@@ -28,3 +28,6 @@ public/favicon_io.zip
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# local temp workspace
|
||||||
|
.tmp/
|
||||||
@@ -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 d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
|
* - optionnel: écrire un alias d’ancre 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 d’ajout), pas un ticket éditorial.`);
|
console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`);
|
||||||
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
|
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);
|
||||||
});
|
});
|
||||||
241
scripts/pick-proposer-issue.mjs
Normal file
241
scripts/pick-proposer-issue.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -1 +1,7 @@
|
|||||||
{}
|
{
|
||||||
|
"/archicrat-ia/prologue/": {
|
||||||
|
"p-0-d7974f88": "p-0-e729df02",
|
||||||
|
"p-4-8ed4f807": "p-4-90b2a1cc",
|
||||||
|
"p-5-85126fa5": "p-5-d788c546"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 d’années, les appellations s’accumulent : *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 d’années, les appellations s’accumulent : 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.
|
||||||
|
|
||||||
C’est 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 d’un point plus fondamental, presque en-deçà de la question politique classique. Ce point, c’est celui de la *tenue d’un monde commun* — c’est-à-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 s’annihiler.
|
C’est 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 d’un point plus fondamental, presque en-deçà de la question politique classique. Ce point, c’est celui de la *tenue d’un monde commun* — c’est-à-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 s’annihiler.
|
||||||
|
|
||||||
@@ -20,10 +20,9 @@ Cette tenue du monde n’équivaut ni à la paix civile, ni à la stabilité des
|
|||||||
|
|
||||||
Le terme n’est pas trivial. Il ne s’agit pas simplement d’une viabilité partagée, ni d’une coexistence pacifique, ni même d’une durabilité écologique élargie. Il s’agit d’un état dynamique, instable, fragile, dans lequel un ensemble — une société, d’un système biologique, d’une formation historique, d’un milieu technique ou d’un monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*.
|
Le terme n’est pas trivial. Il ne s’agit pas simplement d’une viabilité partagée, ni d’une coexistence pacifique, ni même d’une durabilité écologique élargie. Il s’agit d’un état dynamique, instable, fragile, dans lequel un ensemble — une société, d’un système biologique, d’une formation historique, d’un milieu technique ou d’un 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 qu’il 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 d’inertie et d’innovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. C’est cette disposition active, faite de compromis fragiles et d’ajustements 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 qu’il 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 d’inertie et d’innovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. C’est cette disposition active, faite de compromis fragiles et d’ajustements toujours révisables, que nous tenons pour première.
|
||||||
|
|
||||||
Ce qui revient à dire que la question politique — au sens fort — n’a 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 l’effet 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 — n’a 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 l’effet 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 n’est pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (La dynamique de l’Occident, 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 s’inscrit dans ce sillage : travailler cette interrogation sur les conditions de viabilité d’un 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 n’est pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (*La dynamique de l’Occident*, 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 s’inscrit dans ce sillage : travailler cette interrogation sur les *conditions de viabilité d’un 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 l’ordre en surplomb. Comme le rappelle Michel Foucault, il n’y a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. C’est 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 l’ordre en surplomb. Comme le rappelle Michel Foucault, il n’y a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. C’est 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user