@@ -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,14 +16,26 @@ 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
runs-on : mac-ci
container :
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(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.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"}'
--data-binary '{"state":"closed"}'
echo "✅ rejected+closed"