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" \