Compare commits
28 Commits
chore/appl
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 497bddd05d | |||
| 7c8e49c1a9 | |||
| 901d28b89b | |||
| 43e2862c89 | |||
| 73fb38c4d1 | |||
| a81d206aba | |||
| 9801ea3cea | |||
| c11189fe11 | |||
| b47edb24cf | |||
| be191b09a0 | |||
| e06587478d | |||
| 402ffb04cd | |||
| 1cbfc02670 | |||
| 28d2fbbd2f | |||
| 225368a952 | |||
| 3574695041 | |||
| ea68025a1d | |||
| 3a08698003 | |||
| 3d583608c2 | |||
|
|
01ae95ab43 | ||
|
|
0d5821c640 | ||
|
|
2bcea39558 | ||
| af85970d4a | |||
| 210f621487 | |||
| 8ad960dc69 | |||
| 41b0517c6c | |||
| d40f24e92d | |||
| a5d68d6a7e |
@@ -16,9 +16,13 @@ defaults:
|
|||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
apply-approved:
|
apply-approved:
|
||||||
runs-on: ubuntu-latest
|
runs-on: mac-ci
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||||
|
|
||||||
@@ -29,7 +33,6 @@ jobs:
|
|||||||
git --version
|
git --version
|
||||||
node --version
|
node --version
|
||||||
npm --version
|
npm --version
|
||||||
npm ping --registry=https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Derive context (event.json / workflow_dispatch)
|
- name: Derive context (event.json / workflow_dispatch)
|
||||||
env:
|
env:
|
||||||
@@ -49,18 +52,15 @@ 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");
|
||||||
|
|
||||||
let owner =
|
let owner =
|
||||||
repoObj?.owner?.login ||
|
repoObj?.owner?.login ||
|
||||||
repoObj?.owner?.username ||
|
repoObj?.owner?.username ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
||||||
|
|
||||||
let repo =
|
let repo =
|
||||||
repoObj?.name ||
|
repoObj?.name ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
||||||
|
|
||||||
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; }
|
||||||
@@ -73,15 +73,15 @@ jobs:
|
|||||||
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);
|
||||||
|
|
||||||
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||||
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 triggered (best effort; may be missing depending on Gitea payload)
|
||||||
const labelName =
|
const labelName =
|
||||||
ev?.label?.name ||
|
ev?.label?.name ||
|
||||||
ev?.label ||
|
(typeof ev?.label === "string" ? ev.label : "") ||
|
||||||
"workflow_dispatch";
|
"";
|
||||||
|
|
||||||
const u = new URL(cloneUrl);
|
const u = new URL(cloneUrl);
|
||||||
const origin = u.origin;
|
const origin = u.origin;
|
||||||
@@ -90,7 +90,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)}`,
|
||||||
@@ -99,23 +99,144 @@ 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)}`,
|
||||||
`API_BASE=${sh(apiBase)}`
|
`API_BASE=${sh(apiBase)}`,
|
||||||
|
|
||||||
|
// init safe defaults (avoid "unbound variable" cascades)
|
||||||
|
`SKIP=0`,
|
||||||
|
`SKIP_REASON=${sh("")}`,
|
||||||
|
`ISSUE_TYPE=${sh("")}`,
|
||||||
|
`ISSUE_TITLE=${sh("")}`,
|
||||||
|
`APPLY_RC=${sh("")}`,
|
||||||
|
`NOOP=0`,
|
||||||
|
`BRANCH=${sh("")}`,
|
||||||
|
`END_SHA=${sh("")}`
|
||||||
].join("\n") + "\n");
|
].join("\n") + "\n");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "✅ context:"
|
echo "✅ context:"
|
||||||
sed -n '1,120p' /tmp/anno.env
|
sed -n '1,160p' /tmp/anno.env
|
||||||
|
|
||||||
- name: Gate on label state/approved
|
- name: Gate fast (only if label is state/approved or workflow_dispatch)
|
||||||
|
env:
|
||||||
|
INPUT_ISSUE: ${{ inputs.issue }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
source /tmp/anno.env
|
||||||
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
|
|
||||||
echo "ℹ️ label=$LABEL_NAME => skip"
|
# workflow_dispatch => allow
|
||||||
echo "SKIP=1" >> /tmp/anno.env
|
if [[ -n "${INPUT_ISSUE:-}" ]]; then
|
||||||
|
echo "✅ workflow_dispatch => proceed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "✅ proceed (issue=$ISSUE_NUMBER)"
|
|
||||||
|
# if payload provides the triggering label, we can skip without API call
|
||||||
|
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
|
||||||
|
echo "ℹ️ triggering label='$LABEL_NAME' (not state/approved) => skip"
|
||||||
|
echo "SKIP=1" >> /tmp/anno.env
|
||||||
|
echo "SKIP_REASON=\"trigger_label_not_approved\"" >> /tmp/anno.env
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ℹ️ label unknown or approved => continue to API gating"
|
||||||
|
|
||||||
|
- name: Fetch issue + gate on state/approved + gate on Type (skip Proposer)
|
||||||
|
env:
|
||||||
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source /tmp/anno.env
|
||||||
|
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
|
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||||
|
> /tmp/issue.json
|
||||||
|
|
||||||
|
node --input-type=module - <<'NODE' >> /tmp/anno.env
|
||||||
|
import fs from "node:fs";
|
||||||
|
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
||||||
|
const title = String(issue.title || "");
|
||||||
|
const 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 hasApproved = labels.includes("state/approved");
|
||||||
|
|
||||||
|
function pickLine(key) {
|
||||||
|
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||||
|
const m = body.match(re);
|
||||||
|
return m ? m[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = pickLine("Type");
|
||||||
|
const type = String(typeRaw || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const allowed = new Set(["type/media","type/reference","type/comment"]);
|
||||||
|
const proposer = new Set(["type/correction","type/fact-check"]);
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
|
||||||
|
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
|
||||||
|
|
||||||
|
// main gate: only act if state/approved is actually present on the issue
|
||||||
|
if (!hasApproved) {
|
||||||
|
out.push(`SKIP=1`);
|
||||||
|
out.push(`SKIP_REASON=${JSON.stringify("no_state_approved")}`);
|
||||||
|
} else if (!type) {
|
||||||
|
out.push(`SKIP=1`);
|
||||||
|
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
||||||
|
} else if (allowed.has(type)) {
|
||||||
|
// proceed
|
||||||
|
} else if (proposer.has(type)) {
|
||||||
|
out.push(`SKIP=1`);
|
||||||
|
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
|
||||||
|
} else {
|
||||||
|
out.push(`SKIP=1`);
|
||||||
|
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(out.join("\n") + "\n");
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo "✅ issue gating:"
|
||||||
|
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
|
||||||
|
|
||||||
|
- name: Comment issue if skipped (only when state/approved was present)
|
||||||
|
if: ${{ always() }}
|
||||||
|
env:
|
||||||
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source /tmp/anno.env || true
|
||||||
|
|
||||||
|
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
||||||
|
[[ "${SKIP_REASON:-}" != "no_state_approved" ]] || exit 0 # do not comment on normal label churn
|
||||||
|
|
||||||
|
test -n "${FORGE_TOKEN:-}" || exit 0
|
||||||
|
|
||||||
|
REASON="${SKIP_REASON:-}"
|
||||||
|
TYPE="${ISSUE_TYPE:-}"
|
||||||
|
|
||||||
|
if [[ "$REASON" == proposer_type:* ]]; then
|
||||||
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer.\n\n✅ Action : traitement éditorial manuel."
|
||||||
|
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.\n✅ Action : traitement manuel si nécessaire."
|
||||||
|
else
|
||||||
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (Type: type/media|type/reference|type/comment) ou traiter manuellement."
|
||||||
|
fi
|
||||||
|
|
||||||
|
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||||
|
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||||
|
--data-binary "$PAYLOAD"
|
||||||
|
|
||||||
- name: Checkout default branch
|
- name: Checkout default branch
|
||||||
run: |
|
run: |
|
||||||
@@ -135,7 +256,7 @@ jobs:
|
|||||||
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
|
npm ci --no-audit --no-fund
|
||||||
|
|
||||||
- name: Check apply script exists
|
- name: Check apply script exists
|
||||||
run: |
|
run: |
|
||||||
@@ -154,7 +275,7 @@ jobs:
|
|||||||
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:clean
|
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"
|
||||||
@@ -182,7 +303,7 @@ jobs:
|
|||||||
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/anno-${ISSUE_NUMBER}-${TS}"
|
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
|
||||||
echo "BRANCH=$BR" >> /tmp/anno.env
|
echo "BRANCH=\"$BR\"" >> /tmp/anno.env
|
||||||
git checkout -b "$BR"
|
git checkout -b "$BR"
|
||||||
|
|
||||||
export FORGE_API="$API_BASE"
|
export FORGE_API="$API_BASE"
|
||||||
@@ -195,12 +316,13 @@ jobs:
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "APPLY_RC=$RC" >> /tmp/anno.env
|
echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env
|
||||||
|
|
||||||
echo "== apply log (tail) =="
|
echo "== apply log (tail) =="
|
||||||
tail -n 180 "$LOG" || true
|
tail -n 180 "$LOG" || true
|
||||||
|
|
||||||
END_SHA="$(git rev-parse HEAD)"
|
END_SHA="$(git rev-parse HEAD)"
|
||||||
|
echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env
|
||||||
|
|
||||||
if [[ "$RC" -ne 0 ]]; then
|
if [[ "$RC" -ne 0 ]]; then
|
||||||
echo "NOOP=0" >> /tmp/anno.env
|
echo "NOOP=0" >> /tmp/anno.env
|
||||||
@@ -211,7 +333,6 @@ jobs:
|
|||||||
echo "NOOP=1" >> /tmp/anno.env
|
echo "NOOP=1" >> /tmp/anno.env
|
||||||
else
|
else
|
||||||
echo "NOOP=0" >> /tmp/anno.env
|
echo "NOOP=0" >> /tmp/anno.env
|
||||||
echo "END_SHA=$END_SHA" >> /tmp/anno.env
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Comment issue on failure (strict/verify/etc)
|
- name: Comment issue on failure (strict/verify/etc)
|
||||||
@@ -220,18 +341,20 @@ jobs:
|
|||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
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:-}"
|
||||||
if [[ "$RC" == "0" ]]; then
|
[[ -n "$RC" ]] || { echo "ℹ️ apply not executed"; exit 0; }
|
||||||
echo "ℹ️ no failure detected"
|
[[ "$RC" != "0" ]] || { echo "ℹ️ no failure detected"; exit 0; }
|
||||||
exit 0
|
|
||||||
|
if [[ -f /tmp/apply.log ]]; then
|
||||||
|
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
|
||||||
|
else
|
||||||
|
BODY="(no apply log found)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
|
|
||||||
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
|
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
|
||||||
|
|
||||||
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||||
|
|
||||||
curl -fsS -X POST \
|
curl -fsS -X POST \
|
||||||
@@ -246,10 +369,11 @@ jobs:
|
|||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
source /tmp/anno.env || true
|
||||||
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
||||||
|
|
||||||
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
|
RC="${APPLY_RC:-}"
|
||||||
|
[[ "$RC" == "0" ]] || exit 0
|
||||||
[[ "${NOOP:-0}" == "1" ]] || exit 0
|
[[ "${NOOP:-0}" == "1" ]] || exit 0
|
||||||
|
|
||||||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
|
||||||
@@ -267,11 +391,12 @@ jobs:
|
|||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
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" ]] || { echo "ℹ️ apply not ok -> skip push"; exit 0; }
|
||||||
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; }
|
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; }
|
||||||
|
[[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> 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);
|
||||||
@@ -290,11 +415,13 @@ jobs:
|
|||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
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" ]] || { echo "ℹ️ apply not ok -> skip PR"; exit 0; }
|
||||||
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; }
|
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; }
|
||||||
|
[[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> skip PR"; exit 0; }
|
||||||
|
[[ -n "${END_SHA:-}" ]] || { echo "ℹ️ missing END_SHA -> 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."
|
||||||
@@ -333,9 +460,12 @@ 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:-}"
|
||||||
|
[[ -n "$RC" ]] || { echo "❌ apply did not run"; exit 2; }
|
||||||
|
|
||||||
if [[ "$RC" != "0" ]]; then
|
if [[ "$RC" != "0" ]]; then
|
||||||
echo "❌ apply failed (rc=$RC)"
|
echo "❌ apply failed (rc=$RC)"
|
||||||
exit "$RC"
|
exit "$RC"
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
name: Anno Reject
|
name: Anno Reject (close issue)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
issue:
|
||||||
|
description: "Issue number to reject/close"
|
||||||
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
@@ -11,14 +16,26 @@ defaults:
|
|||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
reject:
|
reject:
|
||||||
runs-on: ubuntu-latest
|
runs-on: mac-ci
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Derive context
|
- name: Tools sanity
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node --version
|
||||||
|
|
||||||
|
- name: Derive context (event.json / workflow_dispatch)
|
||||||
|
env:
|
||||||
|
INPUT_ISSUE: ${{ inputs.issue }}
|
||||||
|
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"
|
||||||
@@ -29,58 +46,129 @@ jobs:
|
|||||||
|
|
||||||
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
|
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
|
||||||
const repoObj = ev?.repository || {};
|
const repoObj = ev?.repository || {};
|
||||||
|
|
||||||
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 url");
|
|
||||||
|
|
||||||
let owner =
|
let owner =
|
||||||
repoObj?.owner?.login ||
|
repoObj?.owner?.login ||
|
||||||
repoObj?.owner?.username ||
|
repoObj?.owner?.username ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
||||||
|
|
||||||
let repo =
|
let repo =
|
||||||
repoObj?.name ||
|
repoObj?.name ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
||||||
|
|
||||||
if (!owner || !repo) {
|
if ((!owner || !repo) && cloneUrl) {
|
||||||
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 issueNumber = ev?.issue?.number || ev?.issue?.index;
|
const issueNumber =
|
||||||
if (!issueNumber) throw new Error("No issue number");
|
ev?.issue?.number ||
|
||||||
|
ev?.issue?.index ||
|
||||||
|
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
|
||||||
|
|
||||||
const labelName = ev?.label?.name || ev?.label || "";
|
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||||
const u = new URL(cloneUrl);
|
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelName =
|
||||||
|
ev?.label?.name ||
|
||||||
|
(typeof ev?.label === "string" ? ev.label : "") ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
let apiBase = "";
|
||||||
|
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
|
||||||
|
apiBase = String(process.env.FORGE_API).trim().replace(/\/+$/,"");
|
||||||
|
} else if (cloneUrl) {
|
||||||
|
apiBase = new URL(cloneUrl).origin;
|
||||||
|
} else {
|
||||||
|
apiBase = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sh(s){ return JSON.stringify(String(s ?? "")); }
|
||||||
|
|
||||||
function sh(s){ return JSON.stringify(String(s)); }
|
|
||||||
process.stdout.write([
|
process.stdout.write([
|
||||||
`OWNER=${sh(owner)}`,
|
`OWNER=${sh(owner)}`,
|
||||||
`REPO=${sh(repo)}`,
|
`REPO=${sh(repo)}`,
|
||||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||||
`LABEL_NAME=${sh(labelName)}`,
|
`LABEL_NAME=${sh(labelName)}`,
|
||||||
`API_BASE=${sh(u.origin)}`
|
`API_BASE=${sh(apiBase)}`,
|
||||||
|
`SKIP=0`
|
||||||
].join("\n") + "\n");
|
].join("\n") + "\n");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
- name: Gate on label state/rejected
|
echo "✅ context:"
|
||||||
|
sed -n '1,160p' /tmp/reject.env
|
||||||
|
|
||||||
|
- name: Gate fast (only if label is state/rejected or workflow_dispatch)
|
||||||
|
env:
|
||||||
|
INPUT_ISSUE: ${{ inputs.issue }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/reject.env
|
source /tmp/reject.env
|
||||||
if [[ "$LABEL_NAME" != "state/rejected" ]]; then
|
|
||||||
echo "ℹ️ label=$LABEL_NAME => skip"
|
if [[ -n "${INPUT_ISSUE:-}" ]]; then
|
||||||
|
echo "✅ workflow_dispatch => proceed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "✅ reject issue=$ISSUE_NUMBER"
|
|
||||||
|
|
||||||
- name: Comment + close issue
|
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/rejected" ]]; then
|
||||||
|
echo "ℹ️ triggering label='$LABEL_NAME' (not state/rejected) => skip"
|
||||||
|
echo "SKIP=1" >> /tmp/reject.env
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ℹ️ label unknown or rejected => continue to API gating"
|
||||||
|
|
||||||
|
- name: Comment + close (only if issue has state/rejected; conflict-guard approved+rejected)
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/reject.env
|
source /tmp/reject.env
|
||||||
|
[[ "${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; }
|
||||||
|
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||||
|
> /tmp/issue.json
|
||||||
|
|
||||||
|
node --input-type=module - <<'NODE' > /tmp/reject.flags
|
||||||
|
import fs from "node:fs";
|
||||||
|
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
||||||
|
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
|
||||||
|
const hasApproved = labels.includes("state/approved");
|
||||||
|
const hasRejected = labels.includes("state/rejected");
|
||||||
|
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
source /tmp/reject.flags
|
||||||
|
|
||||||
|
# If issue does not actually have state/rejected -> do nothing (normal label churn)
|
||||||
|
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
|
||||||
|
echo "ℹ️ issue has no state/rejected => skip"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then
|
||||||
|
MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡️ Action manuelle requise : retirer l'un des deux labels avant relance."
|
||||||
|
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||||
|
--data-binary "$PAYLOAD"
|
||||||
|
echo "ℹ️ conflict => stop"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
|
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
|
||||||
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")"
|
||||||
@@ -96,3 +184,5 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||||
--data-binary '{"state":"closed"}'
|
--data-binary '{"state":"closed"}'
|
||||||
|
|
||||||
|
echo "✅ rejected+closed"
|
||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: mac-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Apply labels from Type/State/Category
|
- name: Apply labels from Type/State/Category
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -15,7 +15,7 @@ defaults:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-anchors:
|
build-and-anchors:
|
||||||
runs-on: ubuntu-latest
|
runs-on: mac-ci
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas-deploy
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Tools sanity
|
- name: Tools sanity
|
||||||
@@ -127,25 +127,17 @@ jobs:
|
|||||||
echo "ℹ️ no annotations/media change -> skip deploy"
|
echo "ℹ️ no annotations/media change -> skip deploy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Install docker client + docker compose plugin (v2) + python yaml
|
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
|
# tools are prebaked in the image
|
||||||
apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml
|
git --version
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
|
||||||
curl -fsSL \
|
|
||||||
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
|
|
||||||
-o /usr/local/lib/docker/cli-plugins/docker-compose
|
|
||||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
|
||||||
|
|
||||||
docker version
|
docker version
|
||||||
docker compose version
|
docker compose version
|
||||||
python3 --version
|
python3 -c 'import yaml; print("PyYAML OK")'
|
||||||
|
|
||||||
# Reuse existing compose project name if containers already exist
|
# Reuse existing compose project name if containers already exist
|
||||||
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ on: [push, workflow_dispatch]
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke:
|
smoke:
|
||||||
runs-on: ubuntu-latest
|
runs-on: mac-ci
|
||||||
steps:
|
steps:
|
||||||
- run: node -v && npm -v
|
- run: node -v && npm -v
|
||||||
- run: echo "runner OK"
|
- run: echo "runner OK"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -1,28 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
// scripts/build-annotations-index.mjs
|
// scripts/build-annotations-index.mjs
|
||||||
|
// Construit dist/annotations-index.json à partir de src/annotations/**/*.yml
|
||||||
|
// Supporte:
|
||||||
|
// - monolith : src/annotations/<pageKey>.yml
|
||||||
|
// - shard : src/annotations/<pageKey>/<paraId>.yml (paraId = p-<n>-...)
|
||||||
|
// Invariants:
|
||||||
|
// - doc.schema === 1
|
||||||
|
// - doc.page (si présent) == pageKey déduit du chemin
|
||||||
|
// - shard: doc.paras doit contenir EXACTEMENT la clé paraId (sinon fail)
|
||||||
|
//
|
||||||
|
// Deep-merge non destructif (media/refs/comments dédupliqués), tri stable.
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
|
|
||||||
function parseArgs(argv) {
|
const ROOT = process.cwd();
|
||||||
const out = {
|
const ANNO_ROOT = path.join(ROOT, "src", "annotations");
|
||||||
inDir: "src/annotations",
|
const DIST_DIR = path.join(ROOT, "dist");
|
||||||
outFile: "dist/annotations-index.json",
|
const OUT = path.join(DIST_DIR, "annotations-index.json");
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
function assert(cond, msg) {
|
||||||
const a = argv[i];
|
if (!cond) throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--in" && argv[i + 1]) out.inDir = argv[++i];
|
function isObj(x) {
|
||||||
else if (a.startsWith("--in=")) out.inDir = a.slice("--in=".length);
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
|
}
|
||||||
|
function isArr(x) {
|
||||||
|
return Array.isArray(x);
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--out" && argv[i + 1]) out.outFile = argv[++i];
|
function normPath(s) {
|
||||||
else if (a.startsWith("--out=")) out.outFile = a.slice("--out=".length);
|
return String(s || "")
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paraNum(pid) {
|
||||||
|
const m = String(pid).match(/^p-(\d+)-/i);
|
||||||
|
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableSortByTs(arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ta = Date.parse(a?.ts || "") || 0;
|
||||||
|
const tb = Date.parse(b?.ts || "") || 0;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyMedia(x) { return String(x?.src || ""); }
|
||||||
|
function keyRef(x) {
|
||||||
|
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||||
|
}
|
||||||
|
function keyComment(x) { return String(x?.text || "").trim(); }
|
||||||
|
|
||||||
|
function uniqUnion(dst, src, keyFn) {
|
||||||
|
const out = isArr(dst) ? [...dst] : [];
|
||||||
|
const seen = new Set(out.map((x) => keyFn(x)));
|
||||||
|
for (const it of (isArr(src) ? src : [])) {
|
||||||
|
const k = keyFn(it);
|
||||||
|
if (!k) continue;
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
out.push(it);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exists(p) {
|
function deepMergeEntry(dst, src) {
|
||||||
try { await fs.access(p); return true; } catch { return false; }
|
if (!isObj(dst) || !isObj(src)) return;
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; }
|
||||||
|
if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; }
|
||||||
|
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||||
|
|
||||||
|
if (isObj(v)) {
|
||||||
|
if (!isObj(dst[k])) dst[k] = {};
|
||||||
|
deepMergeEntry(dst[k], v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArr(v)) {
|
||||||
|
const cur = isArr(dst[k]) ? dst[k] : [];
|
||||||
|
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
||||||
|
const out = [...cur];
|
||||||
|
for (const it of v) {
|
||||||
|
const s = JSON.stringify(it);
|
||||||
|
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||||
|
}
|
||||||
|
dst[k] = out;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scalar: set only if missing/empty
|
||||||
|
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
@@ -30,111 +108,116 @@ async function walk(dir) {
|
|||||||
const ents = await fs.readdir(dir, { withFileTypes: true });
|
const ents = await fs.readdir(dir, { withFileTypes: true });
|
||||||
for (const e of ents) {
|
for (const e of ents) {
|
||||||
const p = path.join(dir, e.name);
|
const p = path.join(dir, e.name);
|
||||||
if (e.isDirectory()) out.push(...(await walk(p)));
|
if (e.isDirectory()) out.push(...await walk(p));
|
||||||
else out.push(p);
|
else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPageKeyFromFile(inDirAbs, fileAbs) {
|
function inferExpectedFromRel(relNoExt) {
|
||||||
// src/annotations/<page>.yml -> "<page>"
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/");
|
const last = parts.at(-1) || "";
|
||||||
return rel.replace(/\.(ya?ml|json)$/i, "");
|
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||||
|
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||||
|
const paraId = isShard ? last : null;
|
||||||
|
return { isShard, pageKey, paraId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function assert(cond, msg) {
|
function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId) {
|
||||||
if (!cond) throw new Error(msg);
|
assert(isObj(doc), `${relFile}: doc must be an object`);
|
||||||
}
|
assert(doc.schema === 1, `${relFile}: schema must be 1`);
|
||||||
|
assert(isObj(doc.paras), `${relFile}: missing object key "paras"`);
|
||||||
|
|
||||||
function isPlainObject(x) {
|
const gotPage = doc.page != null ? normPath(doc.page) : "";
|
||||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
const expPage = normPath(expectedPageKey);
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageKey(s) {
|
if (gotPage) {
|
||||||
// pas de / en tête/fin
|
|
||||||
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAndNormalizeDoc(doc, pageKey, fileRel) {
|
|
||||||
assert(isPlainObject(doc), `${fileRel}: document must be an object`);
|
|
||||||
assert(doc.schema === 1, `${fileRel}: schema must be 1`);
|
|
||||||
if (doc.page != null) {
|
|
||||||
assert(
|
assert(
|
||||||
normalizePageKey(doc.page) === pageKey,
|
gotPage === expPage,
|
||||||
`${fileRel}: page mismatch (page="${doc.page}" vs path="${pageKey}")`
|
`${relFile}: page mismatch (page="${doc.page}" vs path="${expectedPageKey}")`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
doc.page = expPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedParaId) {
|
||||||
|
const keys = Object.keys(doc.paras || {}).map(String);
|
||||||
|
assert(
|
||||||
|
keys.includes(expectedParaId),
|
||||||
|
`${relFile}: shard mismatch: must contain paras["${expectedParaId}"]`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
keys.length === 1 && keys[0] === expectedParaId,
|
||||||
|
`${relFile}: shard invariant violated: shard file must contain ONLY paras["${expectedParaId}"] (got: ${keys.join(", ")})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert(isPlainObject(doc.paras), `${fileRel}: missing object key "paras"`);
|
|
||||||
|
|
||||||
const parasOut = Object.create(null);
|
return doc;
|
||||||
|
|
||||||
for (const [paraId, entry] of Object.entries(doc.paras)) {
|
|
||||||
assert(/^p-\d+-/i.test(paraId), `${fileRel}: invalid para id "${paraId}"`);
|
|
||||||
|
|
||||||
// entry peut être vide, mais doit être un objet si présent
|
|
||||||
assert(entry == null || isPlainObject(entry), `${fileRel}: paras.${paraId} must be an object`);
|
|
||||||
|
|
||||||
const e = entry ? { ...entry } : {};
|
|
||||||
|
|
||||||
// Sanity checks (non destructifs : on n’écrase pas, on vérifie juste les types)
|
|
||||||
if (e.refs != null) assert(Array.isArray(e.refs), `${fileRel}: paras.${paraId}.refs must be an array`);
|
|
||||||
if (e.authors != null) assert(Array.isArray(e.authors), `${fileRel}: paras.${paraId}.authors must be an array`);
|
|
||||||
if (e.quotes != null) assert(Array.isArray(e.quotes), `${fileRel}: paras.${paraId}.quotes must be an array`);
|
|
||||||
if (e.media != null) assert(Array.isArray(e.media), `${fileRel}: paras.${paraId}.media must be an array`);
|
|
||||||
if (e.comments_editorial != null) assert(Array.isArray(e.comments_editorial), `${fileRel}: paras.${paraId}.comments_editorial must be an array`);
|
|
||||||
|
|
||||||
parasOut[paraId] = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parasOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDoc(fileAbs) {
|
|
||||||
const raw = await fs.readFile(fileAbs, "utf8");
|
|
||||||
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
|
|
||||||
return YAML.parse(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { inDir, outFile } = parseArgs(process.argv.slice(2));
|
const pages = {};
|
||||||
const CWD = process.cwd();
|
const errors = [];
|
||||||
|
|
||||||
const inDirAbs = path.isAbsolute(inDir) ? inDir : path.join(CWD, inDir);
|
await fs.mkdir(DIST_DIR, { recursive: true });
|
||||||
const outAbs = path.isAbsolute(outFile) ? outFile : path.join(CWD, outFile);
|
|
||||||
|
|
||||||
// antifragile
|
const files = await walk(ANNO_ROOT);
|
||||||
if (!(await exists(inDirAbs))) {
|
|
||||||
console.log(`ℹ️ annotations-index: skip (input missing): ${inDir}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = (await walk(inDirAbs)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
for (const fp of files) {
|
||||||
if (!files.length) {
|
const rel = normPath(path.relative(ANNO_ROOT, fp));
|
||||||
console.log(`ℹ️ annotations-index: skip (no .yml/.yaml/.json found in): ${inDir}`);
|
const relNoExt = rel.replace(/\.ya?ml$/i, "");
|
||||||
process.exit(0);
|
const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt);
|
||||||
}
|
|
||||||
|
|
||||||
const pages = Object.create(null);
|
|
||||||
let paraCount = 0;
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
const fileRel = path.relative(CWD, f).replace(/\\/g, "/");
|
|
||||||
const pageKey = normalizePageKey(inferPageKeyFromFile(inDirAbs, f));
|
|
||||||
assert(pageKey, `${fileRel}: cannot infer page key`);
|
|
||||||
|
|
||||||
let doc;
|
|
||||||
try {
|
try {
|
||||||
doc = await readDoc(f);
|
const raw = await fs.readFile(fp, "utf8");
|
||||||
|
const doc = YAML.parse(raw) || {};
|
||||||
|
|
||||||
|
if (!isObj(doc) || doc.schema !== 1) continue;
|
||||||
|
|
||||||
|
validateAndNormalizeDoc(
|
||||||
|
doc,
|
||||||
|
`src/annotations/${rel}`,
|
||||||
|
pageKey,
|
||||||
|
isShard ? paraId : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const pg = (pages[pageKey] ??= { paras: {} });
|
||||||
|
|
||||||
|
if (isShard) {
|
||||||
|
const entry = doc.paras[paraId];
|
||||||
|
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||||
|
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||||
|
|
||||||
|
stableSortByTs(pg.paras[paraId].media);
|
||||||
|
stableSortByTs(pg.paras[paraId].refs);
|
||||||
|
stableSortByTs(pg.paras[paraId].comments_editorial);
|
||||||
|
} else {
|
||||||
|
for (const [pid, entry] of Object.entries(doc.paras || {})) {
|
||||||
|
const p = String(pid);
|
||||||
|
if (!isObj(pg.paras[p])) pg.paras[p] = {};
|
||||||
|
if (isObj(entry)) deepMergeEntry(pg.paras[p], entry);
|
||||||
|
|
||||||
|
stableSortByTs(pg.paras[p].media);
|
||||||
|
stableSortByTs(pg.paras[p].refs);
|
||||||
|
stableSortByTs(pg.paras[p].comments_editorial);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`${fileRel}: parse failed: ${String(e?.message ?? e)}`);
|
errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const paras = validateAndNormalizeDoc(doc, pageKey, fileRel);
|
for (const [pageKey, pg] of Object.entries(pages)) {
|
||||||
|
const keys = Object.keys(pg.paras || {});
|
||||||
// 1 fichier = 1 page (canon)
|
keys.sort((a, b) => {
|
||||||
assert(!pages[pageKey], `${fileRel}: duplicate page "${pageKey}" (only one file per page)`);
|
const ia = paraNum(a);
|
||||||
pages[pageKey] = { paras };
|
const ib = paraNum(b);
|
||||||
paraCount += Object.keys(paras).length;
|
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
const next = {};
|
||||||
|
for (const k of keys) next[k] = pg.paras[k];
|
||||||
|
pg.paras = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = {
|
const out = {
|
||||||
@@ -143,17 +226,21 @@ async function main() {
|
|||||||
pages,
|
pages,
|
||||||
stats: {
|
stats: {
|
||||||
pages: Object.keys(pages).length,
|
pages: Object.keys(pages).length,
|
||||||
paras: paraCount,
|
paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
|
||||||
|
errors: errors.length,
|
||||||
},
|
},
|
||||||
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
if (errors.length) {
|
||||||
await fs.writeFile(outAbs, JSON.stringify(out), "utf8");
|
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> ${path.relative(CWD, outAbs)}`);
|
await fs.writeFile(OUT, JSON.stringify(out), "utf8");
|
||||||
|
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> dist/annotations-index.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error("FAIL: build-annotations-index crashed:", e);
|
console.error(`FAIL: build-annotations-index crashed: ${e?.stack || e?.message || e}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,9 @@ async function main() {
|
|||||||
let missing = 0;
|
let missing = 0;
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
|
||||||
|
// Optim: éviter de vérifier 100 fois le même fichier media
|
||||||
|
const seenMedia = new Set(); // src string
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const rel = path.relative(CWD, f).replace(/\\/g, "/");
|
const rel = path.relative(CWD, f).replace(/\\/g, "/");
|
||||||
const raw = await fs.readFile(f, "utf8");
|
const raw = await fs.readFile(f, "utf8");
|
||||||
@@ -70,6 +73,10 @@ async function main() {
|
|||||||
const src = String(m?.src || "");
|
const src = String(m?.src || "");
|
||||||
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
|
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
|
||||||
|
|
||||||
|
// dédupe
|
||||||
|
if (seenMedia.has(src)) continue;
|
||||||
|
seenMedia.add(src);
|
||||||
|
|
||||||
checked++;
|
checked++;
|
||||||
const p = toPublicPathFromUrl(src);
|
const p = toPublicPathFromUrl(src);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
|
|||||||
@@ -27,11 +27,6 @@ function escRe(s) {
|
|||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPageKeyFromFile(fileAbs) {
|
|
||||||
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
|
||||||
return rel.replace(/\.(ya?ml|json)$/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageKey(s) {
|
function normalizePageKey(s) {
|
||||||
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
@@ -40,6 +35,31 @@ function isPlainObject(x) {
|
|||||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isParaId(s) {
|
||||||
|
return /^p-\d+-/i.test(String(s || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supporte:
|
||||||
|
* - monolith: src/annotations/<pageKey>.yml -> pageKey = rel sans ext
|
||||||
|
* - shard : src/annotations/<pageKey>/<paraId>.yml -> pageKey = dirname(rel), paraId = basename
|
||||||
|
*
|
||||||
|
* shard seulement si le fichier est dans un sous-dossier (anti cas pathologique).
|
||||||
|
*/
|
||||||
|
function inferFromFile(fileAbs) {
|
||||||
|
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
||||||
|
const relNoExt = rel.replace(/\.(ya?ml|json)$/i, "");
|
||||||
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
|
const base = parts[parts.length - 1] || "";
|
||||||
|
const dirParts = parts.slice(0, -1);
|
||||||
|
|
||||||
|
const isShard = dirParts.length > 0 && isParaId(base);
|
||||||
|
const pageKey = isShard ? dirParts.join("/") : relNoExt;
|
||||||
|
const paraId = isShard ? base : "";
|
||||||
|
|
||||||
|
return { pageKey: normalizePageKey(pageKey), paraId };
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAliases() {
|
async function loadAliases() {
|
||||||
if (!(await exists(ALIASES_PATH))) return {};
|
if (!(await exists(ALIASES_PATH))) return {};
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +103,11 @@ async function main() {
|
|||||||
const aliases = await loadAliases();
|
const aliases = await loadAliases();
|
||||||
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
||||||
|
|
||||||
let pages = 0;
|
// perf: cache HTML par page (shards = beaucoup de fichiers pour 1 page)
|
||||||
|
const htmlCache = new Map(); // pageKey -> html
|
||||||
|
const missingDistPage = new Set(); // pageKey
|
||||||
|
|
||||||
|
let pagesSeen = new Set();
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
const notes = [];
|
const notes = [];
|
||||||
@@ -107,7 +131,7 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageKey = normalizePageKey(inferPageKeyFromFile(f));
|
const { pageKey, paraId: shardParaId } = inferFromFile(f);
|
||||||
|
|
||||||
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
|
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
|
||||||
failures++;
|
failures++;
|
||||||
@@ -121,20 +145,44 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shard invariant (fort) : doit contenir paras[paraId]
|
||||||
|
if (shardParaId) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(doc.paras, shardParaId)) {
|
||||||
|
failures++;
|
||||||
|
notes.push(`- SHARD MISMATCH: ${rel} (expected paras["${shardParaId}"] present)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// si extras -> warning (non destructif)
|
||||||
|
const keys = Object.keys(doc.paras);
|
||||||
|
if (!(keys.length === 1 && keys[0] === shardParaId)) {
|
||||||
|
notes.push(`- WARN shard has extra paras: ${rel} (expected only "${shardParaId}", got ${keys.join(", ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesSeen.add(pageKey);
|
||||||
|
|
||||||
const distFile = path.join(DIST_DIR, pageKey, "index.html");
|
const distFile = path.join(DIST_DIR, pageKey, "index.html");
|
||||||
if (!(await exists(distFile))) {
|
if (!(await exists(distFile))) {
|
||||||
|
if (!missingDistPage.has(pageKey)) {
|
||||||
|
missingDistPage.add(pageKey);
|
||||||
failures++;
|
failures++;
|
||||||
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
||||||
|
} else {
|
||||||
|
notes.push(`- WARN missing page already reported: dist/${pageKey}/index.html (from ${rel})`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
pages++;
|
let html = htmlCache.get(pageKey);
|
||||||
const html = await fs.readFile(distFile, "utf8");
|
if (!html) {
|
||||||
|
html = await fs.readFile(distFile, "utf8");
|
||||||
|
htmlCache.set(pageKey, html);
|
||||||
|
}
|
||||||
|
|
||||||
for (const paraId of Object.keys(doc.paras)) {
|
for (const paraId of Object.keys(doc.paras)) {
|
||||||
checked++;
|
checked++;
|
||||||
|
|
||||||
if (!/^p-\d+-/i.test(paraId)) {
|
if (!isParaId(paraId)) {
|
||||||
failures++;
|
failures++;
|
||||||
notes.push(`- INVALID ID: ${rel} (${paraId})`);
|
notes.push(`- INVALID ID: ${rel} (${paraId})`);
|
||||||
continue;
|
continue;
|
||||||
@@ -158,6 +206,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warns = notes.filter((x) => x.startsWith("- WARN"));
|
const warns = notes.filter((x) => x.startsWith("- WARN"));
|
||||||
|
const pages = pagesSeen.size;
|
||||||
|
|
||||||
if (failures > 0) {
|
if (failures > 0) {
|
||||||
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);
|
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);
|
||||||
|
|||||||
10
src/annotations/archicrat-ia/chapitre-1/p-0-8d27a7f5.yml
Normal file
10
src/annotations/archicrat-ia/chapitre-1/p-0-8d27a7f5.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
schema: 1
|
||||||
|
page: archicrat-ia/chapitre-1
|
||||||
|
paras:
|
||||||
|
p-0-8d27a7f5:
|
||||||
|
refs:
|
||||||
|
- url: https://auth.archicratie.trans-hands.synology.me/authenticated
|
||||||
|
label: Lien web
|
||||||
|
kind: (livre / article / vidéo / site / autre) Site
|
||||||
|
ts: 2026-02-27T12:34:31.704Z
|
||||||
|
fromIssue: 142
|
||||||
9
src/annotations/archicrat-ia/chapitre-1/p-1-8a6c18bf.yml
Normal file
9
src/annotations/archicrat-ia/chapitre-1/p-1-8a6c18bf.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
schema: 1
|
||||||
|
page: archicrat-ia/chapitre-1
|
||||||
|
paras:
|
||||||
|
p-1-8a6c18bf:
|
||||||
|
comments_editorial:
|
||||||
|
- text: Yeaha
|
||||||
|
status: new
|
||||||
|
ts: 2026-02-27T12:40:39.462Z
|
||||||
|
fromIssue: 143
|
||||||
12
src/annotations/archicrat-ia/chapitre-3/p-0-ace27175.yml
Normal file
12
src/annotations/archicrat-ia/chapitre-3/p-0-ace27175.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
schema: 1
|
||||||
|
page: archicrat-ia/chapitre-3
|
||||||
|
paras:
|
||||||
|
p-0-ace27175:
|
||||||
|
media:
|
||||||
|
- type: image
|
||||||
|
src: /media/archicrat-ia/chapitre-3/p-0-ace27175/Capture_d_e_cran_2025-05-05_a_19.20.40.png
|
||||||
|
caption: "[Media] p-0-ace27175 — Chapitre 3 — Philosophies du pouvoir et
|
||||||
|
archicration"
|
||||||
|
credit: ""
|
||||||
|
ts: 2026-02-27T12:43:14.259Z
|
||||||
|
fromIssue: 144
|
||||||
@@ -57,25 +57,24 @@ function deepMergeEntry(dst: any, src: any) {
|
|||||||
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||||
|
|
||||||
if (isObj(v)) {
|
if (isObj(v)) {
|
||||||
if (!isObj(dst[k])) dst[k] = {};
|
if (!isObj((dst as any)[k])) (dst as any)[k] = {};
|
||||||
deepMergeEntry(dst[k], v);
|
deepMergeEntry((dst as any)[k], v);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArr(v)) {
|
if (isArr(v)) {
|
||||||
const cur = isArr(dst[k]) ? dst[k] : [];
|
const cur = isArr((dst as any)[k]) ? (dst as any)[k] : [];
|
||||||
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
||||||
const out = [...cur];
|
const out = [...cur];
|
||||||
for (const it of v) {
|
for (const it of v) {
|
||||||
const s = JSON.stringify(it);
|
const s = JSON.stringify(it);
|
||||||
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||||
}
|
}
|
||||||
dst[k] = out;
|
(dst as any)[k] = out;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// scalar: set only if missing/empty
|
if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v;
|
||||||
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ async function walk(dir: string): Promise<string[]> {
|
|||||||
function inferExpected(relNoExt: string) {
|
function inferExpected(relNoExt: string) {
|
||||||
const parts = relNoExt.split("/").filter(Boolean);
|
const parts = relNoExt.split("/").filter(Boolean);
|
||||||
const last = parts.at(-1) || "";
|
const last = parts.at(-1) || "";
|
||||||
const isShard = /^p-\d+-/i.test(last);
|
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||||
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||||
const paraId = isShard ? last : null;
|
const paraId = isShard ? last : null;
|
||||||
return { isShard, pageKey, paraId };
|
return { isShard, pageKey, paraId };
|
||||||
@@ -136,6 +135,12 @@ export const GET: APIRoute = async () => {
|
|||||||
if (!(paraId in doc.paras)) {
|
if (!(paraId in doc.paras)) {
|
||||||
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
||||||
}
|
}
|
||||||
|
// ✅ invariant aligné avec build-annotations-index
|
||||||
|
const keys = Object.keys(doc.paras).map(String);
|
||||||
|
if (!(keys.length === 1 && keys[0] === paraId)) {
|
||||||
|
throw new Error(`shard invariant violated: shard must contain ONLY paras["${paraId}"] (got: ${keys.join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
const entry = doc.paras[paraId];
|
const entry = doc.paras[paraId];
|
||||||
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||||
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||||
@@ -159,8 +164,7 @@ export const GET: APIRoute = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort paras
|
for (const [pk, pg] of Object.entries(pages)) {
|
||||||
for (const [pageKey, pg] of Object.entries(pages)) {
|
|
||||||
const keys = Object.keys(pg.paras || {});
|
const keys = Object.keys(pg.paras || {});
|
||||||
keys.sort((a, b) => {
|
keys.sort((a, b) => {
|
||||||
const ia = paraNum(a);
|
const ia = paraNum(a);
|
||||||
@@ -185,7 +189,6 @@ export const GET: APIRoute = async () => {
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔥 comportement “pro CI” : si erreurs => build fail
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user