name: Anno Reject (close issue) on: issues: types: [labeled] workflow_dispatch: inputs: issue: description: "Issue number to reject/close" required: true env: NODE_OPTIONS: --dns-result-order=ipv4first defaults: run: shell: bash concurrency: group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} cancel-in-progress: true jobs: reject: runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm steps: - 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 || vars.FORGE_BASE_URL }} run: | set -euo pipefail export EVENT_JSON="/var/run/act/workflow/event.json" test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; } node --input-type=module - <<'NODE' > /tmp/reject.env import fs from "node:fs"; const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8")); const repoObj = ev?.repository || {}; const cloneUrl = repoObj?.clone_url || (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); 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) && cloneUrl) { const m = cloneUrl.match(/[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; } } if (!owner || !repo) throw new Error("Cannot infer owner/repo"); const issueNumber = 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"); } // label name: best-effort (non-bloquant) let labelName = "workflow_dispatch"; const lab = ev?.label; if (typeof lab === "string") labelName = lab; else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name; 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)); } process.stdout.write([ `OWNER=${sh(owner)}`, `REPO=${sh(repo)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "✅ context:" sed -n '1,120p' /tmp/reject.env - name: Early gate (fast-skip, tolerant) run: | set -euo pipefail source /tmp/reject.env echo "ℹ️ event label = $LABEL_NAME" if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then echo "ℹ️ label=$LABEL_NAME => skip early" echo "SKIP=1" >> /tmp/reject.env echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env exit 0 fi - name: Comment + close (only if label state/rejected is PRESENT now, and no conflict) 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; } curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ -o /tmp/reject.issue.json node --input-type=module - <<'NODE' > /tmp/reject.flags import fs from "node:fs"; const issue = JSON.parse(fs.readFileSync("/tmp/reject.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 # Do nothing unless state/rejected is truly present now (anti payload weird) if [[ "${HAS_REJECTED:-0}" != "1" ]]; then echo "ℹ️ state/rejected not present -> 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)." 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" curl -fsS -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ --data-binary '{"state":"closed"}' echo "✅ rejected+closed"