From 7c8e49c1a9a7d2ca4253ef66fcaa356ec23b1b8f Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 2 Mar 2026 11:10:53 +0100 Subject: [PATCH 1/2] ci: stabilize anno apply/reject (event parsing + strict gating) --- .gitea/workflows/anno-apply-pr.yml | 77 ++++++++++++++++++------------ .gitea/workflows/anno-reject.yml | 33 ++++++------- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/.gitea/workflows/anno-apply-pr.yml b/.gitea/workflows/anno-apply-pr.yml index dac539a..a1a7269 100644 --- a/.gitea/workflows/anno-apply-pr.yml +++ b/.gitea/workflows/anno-apply-pr.yml @@ -22,6 +22,13 @@ concurrency: jobs: apply-approved: + # ✅ Job ne se lance QUE si le label ajouté est state/approved, ou si workflow_dispatch + if: >- + ${{ + (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'state/approved') + || github.event_name == 'workflow_dispatch' + }} + runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -35,6 +42,7 @@ jobs: npm --version - name: Derive context (event.json / workflow_dispatch) + id: ctx env: INPUT_ISSUE: ${{ inputs.issue }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} @@ -81,6 +89,8 @@ jobs: throw new Error("No issue number in event.json or workflow_dispatch input"); } + // Ici, vu job-level if, on sait déjà que c'est state/approved en issues:labeled. + // Mais on garde pour logs / workflow_dispatch. const labelName = ev?.label?.name || ev?.label || @@ -102,42 +112,35 @@ jobs: `DEFAULT_BRANCH=${sh(defaultBranch)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, - `API_BASE=${sh(apiBase)}` + `API_BASE=${sh(apiBase)}`, + `SKIP=0` ].join("\n") + "\n"); NODE echo "✅ context:" - sed -n '1,120p' /tmp/anno.env - - - name: Gate on label state/approved - run: | - set -euo pipefail - source /tmp/anno.env - - if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then - echo "ℹ️ label=$LABEL_NAME => skip" - echo "SKIP=1" >> /tmp/anno.env - exit 0 - fi - echo "✅ proceed (issue=$ISSUE_NUMBER)" + sed -n '1,160p' /tmp/anno.env - name: Fetch issue + gate on Type (skip Proposer) + id: typegate 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; } - ISSUE_JSON="$(curl -fsS \ + # ✅ récupérer issue JSON dans un fichier (zéro JSON dans argv) + curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")" + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ + -o /tmp/issue.json - node --input-type=module - <<'NODE' "$ISSUE_JSON" >> /tmp/anno.env - const issue = JSON.parse(process.argv[1] || "{}"); + 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"); @@ -185,15 +188,13 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 - [[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0 + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } - # message différent si Proposer REASON="${SKIP_REASON:-}" TYPE="${ISSUE_TYPE:-}" - TITLE="${ISSUE_TITLE:-}" 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 et n'ajoute **jamais** state/approved automatiquement.\n\n✅ Action : traitement éditorial manuel." + 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 @@ -209,9 +210,10 @@ jobs: --data-binary "$PAYLOAD" - name: Checkout default branch + if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env + source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } rm -rf .git @@ -222,16 +224,18 @@ jobs: git log -1 --oneline - name: Install deps + if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env + source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm ci --no-audit --no-fund - name: Check apply script exists + if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env + source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -f scripts/apply-annotation-ticket.mjs || { echo "❌ missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH" @@ -240,9 +244,10 @@ jobs: } - name: Build dist (needed for --verify) + if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env + source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm run build @@ -255,6 +260,7 @@ jobs: echo "✅ dist/para-index.json present" - name: Apply ticket on bot branch (strict+verify, commit) + if: ${{ always() }} continue-on-error: true env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -262,7 +268,7 @@ jobs: BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }} run: | set -euo pipefail - source /tmp/anno.env + source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } @@ -291,13 +297,12 @@ jobs: echo "== apply log (tail) ==" tail -n 180 "$LOG" || true - END_SHA="$(git rev-parse HEAD)" - if [[ "$RC" -ne 0 ]]; then echo "NOOP=0" >> /tmp/anno.env exit 0 fi + END_SHA="$(git rev-parse HEAD)" if [[ "$START_SHA" == "$END_SHA" ]]; then echo "NOOP=1" >> /tmp/anno.env else @@ -320,6 +325,8 @@ jobs: exit 0 fi + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + if [[ -f /tmp/apply.log ]]; then BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" else @@ -344,9 +351,12 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 + # ✅ si apply a réussi ET NOOP=1 => commenter [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "1" ]] || exit 0 + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)." PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" @@ -368,6 +378,9 @@ jobs: [[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip push"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; } + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + test -n "${BRANCH:-}" || { echo "❌ BRANCH missing"; exit 1; } + AUTH_URL="$(node --input-type=module -e ' const [clone, tok] = process.argv.slice(1); const u = new URL(clone); @@ -391,6 +404,10 @@ jobs: [[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip PR"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; } + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + test -n "${BRANCH:-}" || { echo "❌ BRANCH missing"; exit 1; } + test -n "${END_SHA:-}" || { echo "❌ END_SHA missing"; exit 1; } + 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." diff --git a/.gitea/workflows/anno-reject.yml b/.gitea/workflows/anno-reject.yml index c1efb4e..3d0a5cd 100644 --- a/.gitea/workflows/anno-reject.yml +++ b/.gitea/workflows/anno-reject.yml @@ -22,6 +22,13 @@ concurrency: jobs: reject: + # ✅ Job ne se lance QUE si le label ajouté est state/rejected, ou si workflow_dispatch + if: >- + ${{ + (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'state/rejected') + || github.event_name == 'workflow_dispatch' + }} + runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -96,44 +103,34 @@ jobs: `REPO=${sh(repo)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, - `API_BASE=${sh(apiBase)}` + `API_BASE=${sh(apiBase)}`, + `SKIP=0` ].join("\n") + "\n"); NODE echo "✅ context:" sed -n '1,120p' /tmp/reject.env - - name: Gate on label state/rejected only - run: | - set -euo pipefail - source /tmp/reject.env - - if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" ]]; then - echo "ℹ️ label=$LABEL_NAME => skip" - echo "SKIP=1" >> /tmp/reject.env - exit 0 - fi - echo "✅ proceed (issue=$ISSUE_NUMBER)" - - name: Comment + close (only if not conflicting with state/approved) env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/reject.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; } - ISSUE_JSON="$(curl -fsS \ + curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")" + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ + -o /tmp/issue.json # conflict guard: approved + rejected => do nothing, comment warning - node --input-type=module - <<'NODE' "$ISSUE_JSON" > /tmp/reject.flags - const issue = JSON.parse(process.argv[1] || "{}"); + 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"); From 497bddd05df95f782a85ab6a03444ec7690b0281 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 2 Mar 2026 12:47:37 +0100 Subject: [PATCH 2/2] ci: fix anno apply/reject JSON parsing + hard gates --- .gitea/workflows/anno-apply-pr.yml | 147 ++++++++++++++++------------- .gitea/workflows/anno-reject.yml | 51 ++++++---- 2 files changed, 116 insertions(+), 82 deletions(-) diff --git a/.gitea/workflows/anno-apply-pr.yml b/.gitea/workflows/anno-apply-pr.yml index a1a7269..b2c2d36 100644 --- a/.gitea/workflows/anno-apply-pr.yml +++ b/.gitea/workflows/anno-apply-pr.yml @@ -22,13 +22,6 @@ concurrency: jobs: apply-approved: - # ✅ Job ne se lance QUE si le label ajouté est state/approved, ou si workflow_dispatch - if: >- - ${{ - (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'state/approved') - || github.event_name == 'workflow_dispatch' - }} - runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -42,7 +35,6 @@ jobs: npm --version - name: Derive context (event.json / workflow_dispatch) - id: ctx env: INPUT_ISSUE: ${{ inputs.issue }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} @@ -60,18 +52,15 @@ jobs: const cloneUrl = repoObj?.clone_url || (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); - if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json"); let owner = repoObj?.owner?.login || repoObj?.owner?.username || (repoObj?.full_name ? repoObj.full_name.split("/")[0] : ""); - let repo = repoObj?.name || (repoObj?.full_name ? repoObj.full_name.split("/")[1] : ""); - if (!owner || !repo) { const m = cloneUrl.match(/[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; } @@ -84,17 +73,15 @@ jobs: ev?.issue?.number || ev?.issue?.index || (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); - if (!issueNumber || !Number.isFinite(Number(issueNumber))) { throw new Error("No issue number in event.json or workflow_dispatch input"); } - // Ici, vu job-level if, on sait déjà que c'est state/approved en issues:labeled. - // Mais on garde pour logs / workflow_dispatch. + // label triggered (best effort; may be missing depending on Gitea payload) const labelName = ev?.label?.name || - ev?.label || - "workflow_dispatch"; + (typeof ev?.label === "string" ? ev.label : "") || + ""; const u = new URL(cloneUrl); const origin = u.origin; @@ -103,7 +90,7 @@ jobs: ? String(process.env.FORGE_API).trim().replace(/\/+$/,"") : origin; - function sh(s){ return JSON.stringify(String(s)); } + function sh(s){ return JSON.stringify(String(s ?? "")); } process.stdout.write([ `CLONE_URL=${sh(cloneUrl)}`, @@ -113,37 +100,73 @@ jobs: `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, `API_BASE=${sh(apiBase)}`, - `SKIP=0` + + // 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"); NODE echo "✅ context:" sed -n '1,160p' /tmp/anno.env - - name: Fetch issue + gate on Type (skip Proposer) - id: typegate + - name: Gate fast (only if label is state/approved or workflow_dispatch) + env: + INPUT_ISSUE: ${{ inputs.issue }} + run: | + set -euo pipefail + source /tmp/anno.env + + # workflow_dispatch => allow + if [[ -n "${INPUT_ISSUE:-}" ]]; then + echo "✅ workflow_dispatch => proceed" + exit 0 + fi + + # 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; } - # ✅ récupérer issue JSON dans un fichier (zéro JSON dans argv) curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - -o /tmp/issue.json + > /tmp/issue.json - node --input-type=module <<'NODE' >> /tmp/anno.env + node --input-type=module - <<'NODE' >> /tmp/anno.env 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 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); @@ -160,7 +183,11 @@ jobs: out.push(`ISSUE_TITLE=${JSON.stringify(title)}`); out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); - if (!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)) { @@ -176,10 +203,10 @@ jobs: process.stdout.write(out.join("\n") + "\n"); NODE - echo "✅ issue type gating:" + echo "✅ issue gating:" 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 (only when state/approved was present) if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -188,7 +215,9 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + [[ "${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:-}" @@ -198,7 +227,7 @@ jobs: 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 (ajouter 'Type: type/media|type/reference|type/comment') ou traiter manuellement." + 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")" @@ -210,10 +239,9 @@ jobs: --data-binary "$PAYLOAD" - name: Checkout default branch - if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env || true + source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } rm -rf .git @@ -224,18 +252,16 @@ jobs: git log -1 --oneline - name: Install deps - if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env || true + source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm ci --no-audit --no-fund - name: Check apply script exists - if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env || true + source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -f scripts/apply-annotation-ticket.mjs || { echo "❌ missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH" @@ -244,10 +270,9 @@ jobs: } - name: Build dist (needed for --verify) - if: ${{ always() }} run: | set -euo pipefail - source /tmp/anno.env || true + source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm run build @@ -260,7 +285,6 @@ jobs: echo "✅ dist/para-index.json present" - name: Apply ticket on bot branch (strict+verify, commit) - if: ${{ always() }} continue-on-error: true env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -268,7 +292,7 @@ jobs: BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }} run: | set -euo pipefail - source /tmp/anno.env || true + source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } @@ -279,7 +303,7 @@ jobs: START_SHA="$(git rev-parse HEAD)" TS="$(date -u +%Y%m%d-%H%M%S)" BR="bot/anno-${ISSUE_NUMBER}-${TS}" - echo "BRANCH=$BR" >> /tmp/anno.env + echo "BRANCH=\"$BR\"" >> /tmp/anno.env git checkout -b "$BR" export FORGE_API="$API_BASE" @@ -292,22 +316,23 @@ jobs: RC=$? set -e - echo "APPLY_RC=$RC" >> /tmp/anno.env + echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env echo "== apply log (tail) ==" tail -n 180 "$LOG" || true + END_SHA="$(git rev-parse HEAD)" + echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env + if [[ "$RC" -ne 0 ]]; then echo "NOOP=0" >> /tmp/anno.env exit 0 fi - END_SHA="$(git rev-parse HEAD)" if [[ "$START_SHA" == "$END_SHA" ]]; then echo "NOOP=1" >> /tmp/anno.env else echo "NOOP=0" >> /tmp/anno.env - echo "END_SHA=$END_SHA" >> /tmp/anno.env fi - name: Comment issue on failure (strict/verify/etc) @@ -319,13 +344,9 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - RC="${APPLY_RC:-0}" - if [[ "$RC" == "0" ]]; then - echo "ℹ️ no failure detected" - exit 0 - fi - - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + RC="${APPLY_RC:-}" + [[ -n "$RC" ]] || { echo "ℹ️ apply not executed"; exit 0; } + [[ "$RC" != "0" ]] || { echo "ℹ️ no failure detected"; exit 0; } if [[ -f /tmp/apply.log ]]; then BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" @@ -351,12 +372,10 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - # ✅ si apply a réussi ET NOOP=1 => commenter - [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 + RC="${APPLY_RC:-}" + [[ "$RC" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "1" ]] || exit 0 - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } - MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)." PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" @@ -375,11 +394,9 @@ jobs: source /tmp/anno.env || true [[ "${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; } - - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } - test -n "${BRANCH:-}" || { echo "❌ BRANCH missing"; exit 1; } + [[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> skip push"; exit 0; } AUTH_URL="$(node --input-type=module -e ' const [clone, tok] = process.argv.slice(1); @@ -401,12 +418,10 @@ jobs: source /tmp/anno.env || true [[ "${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; } - - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } - test -n "${BRANCH:-}" || { echo "❌ BRANCH missing"; exit 1; } - test -n "${END_SHA:-}" || { echo "❌ END_SHA missing"; exit 1; } + [[ -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_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK." @@ -448,7 +463,9 @@ jobs: [[ "${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 echo "❌ apply failed (rc=$RC)" exit "$RC" diff --git a/.gitea/workflows/anno-reject.yml b/.gitea/workflows/anno-reject.yml index 3d0a5cd..e91ac63 100644 --- a/.gitea/workflows/anno-reject.yml +++ b/.gitea/workflows/anno-reject.yml @@ -22,13 +22,6 @@ concurrency: jobs: reject: - # ✅ Job ne se lance QUE si le label ajouté est state/rejected, ou si workflow_dispatch - if: >- - ${{ - (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'state/rejected') - || github.event_name == 'workflow_dispatch' - }} - runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -84,8 +77,8 @@ jobs: const labelName = ev?.label?.name || - ev?.label || - "workflow_dispatch"; + (typeof ev?.label === "string" ? ev.label : "") || + ""; let apiBase = ""; if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) { @@ -96,7 +89,7 @@ jobs: apiBase = ""; } - function sh(s){ return JSON.stringify(String(s)); } + function sh(s){ return JSON.stringify(String(s ?? "")); } process.stdout.write([ `OWNER=${sh(owner)}`, @@ -109,14 +102,35 @@ jobs: NODE echo "✅ context:" - sed -n '1,120p' /tmp/reject.env + sed -n '1,160p' /tmp/reject.env - - name: Comment + close (only if not conflicting with state/approved) + - name: Gate fast (only if label is state/rejected or workflow_dispatch) + env: + INPUT_ISSUE: ${{ inputs.issue }} + run: | + set -euo pipefail + source /tmp/reject.env + + if [[ -n "${INPUT_ISSUE:-}" ]]; then + echo "✅ workflow_dispatch => proceed" + exit 0 + fi + + 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: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/reject.env + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; } @@ -125,10 +139,9 @@ jobs: -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - -o /tmp/issue.json + > /tmp/issue.json - # conflict guard: approved + rejected => do nothing, comment warning - node --input-type=module <<'NODE' > /tmp/reject.flags + 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) : []; @@ -139,6 +152,12 @@ jobs: 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")" @@ -151,7 +170,6 @@ jobs: exit 0 fi - # comment reject MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)." PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" @@ -161,7 +179,6 @@ jobs: "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ --data-binary "$PAYLOAD" - # close issue curl -fsS -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \