diff --git a/.gitea/workflows/anno-reject.yml b/.gitea/workflows/anno-reject.yml index 2017283..5fcd57a 100644 --- a/.gitea/workflows/anno-reject.yml +++ b/.gitea/workflows/anno-reject.yml @@ -1,8 +1,13 @@ -name: Anno Reject +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 @@ -11,6 +16,10 @@ defaults: run: shell: bash +concurrency: + group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }} + cancel-in-progress: true + jobs: reject: runs-on: ubuntu-latest @@ -18,7 +27,15 @@ jobs: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm 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: | set -euo pipefail export EVENT_JSON="/var/run/act/workflow/event.json" @@ -29,59 +46,115 @@ jobs: 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") : ""); - if (!cloneUrl) throw new Error("No repository url"); 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) { + 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; - if (!issueNumber) throw new Error("No issue number"); + const issueNumber = + ev?.issue?.number || + ev?.issue?.index || + (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); - const labelName = ev?.label?.name || ev?.label || ""; - const u = new URL(cloneUrl); + if (!issueNumber || !Number.isFinite(Number(issueNumber))) { + throw new Error("No issue number in event.json or workflow_dispatch input"); + } + + const labelName = + ev?.label?.name || + ev?.label || + "workflow_dispatch"; + + 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(u.origin)}` + `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE - - name: Gate on label state/rejected + 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" ]]; then + + 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 "✅ reject issue=$ISSUE_NUMBER" + echo "✅ proceed (issue=$ISSUE_NUMBER)" - - name: Comment + close issue + - name: Comment + close (only if not conflicting with state/approved) env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/reject.env - test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + [[ "${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 \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")" + + # 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] || "{}"); + 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 [[ "${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 + + # 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")" @@ -91,8 +164,11 @@ 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" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - --data-binary '{"state":"closed"}' \ No newline at end of file + --data-binary '{"state":"closed"}' + + echo "✅ rejected+closed" \ No newline at end of file