name: Proposer Apply (Queue) on: issues: types: [labeled] push: branches: [main] workflow_dispatch: inputs: issue: description: "Issue number to prioritize (optional)" required: false default: "" env: NODE_OPTIONS: --dns-result-order=ipv4first defaults: run: shell: bash concurrency: group: proposer-queue-main cancel-in-progress: false jobs: apply-proposer: runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm steps: - name: Tools sanity run: | set -euo pipefail git --version node --version npm --version - name: Derive context (event.json / workflow_dispatch / push) env: INPUT_ISSUE: ${{ inputs.issue }} EVENT_NAME_IN: ${{ github.event_name }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} 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/proposer.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") : ""); 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; } } if (!owner || !repo) throw new Error("Cannot infer owner/repo"); const defaultBranch = repoObj?.default_branch || "main"; const issueNumber = ev?.issue?.number || ev?.issue?.index || (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0) || 0; const labelName = ev?.label?.name || (typeof ev?.label === "string" ? ev.label : "") || ""; const eventName = String(process.env.EVENT_NAME_IN || "").trim() || (ev?.issue ? "issues" : (ev?.before || ev?.after ? "push" : "workflow_dispatch")); const u = new URL(cloneUrl); const origin = u.origin; const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim()) ? String(process.env.FORGE_API).trim().replace(/\/+$/, "") : origin; function sh(s) { return JSON.stringify(String(s)); } process.stdout.write([ `CLONE_URL=${sh(cloneUrl)}`, `OWNER=${sh(owner)}`, `REPO=${sh(repo)}`, `DEFAULT_BRANCH=${sh(defaultBranch)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, `EVENT_NAME=${sh(eventName)}`, `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "Context:" sed -n '1,200p' /tmp/proposer.env - name: Early gate (tolerant on empty issue label payload) run: | set -euo pipefail source /tmp/proposer.env echo "event=$EVENT_NAME label=${LABEL_NAME:-}" if [[ "$EVENT_NAME" == "issues" ]]; then if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip" echo 'SKIP=1' >> /tmp/proposer.env echo 'SKIP_REASON="label_not_state_approved_event"' >> /tmp/proposer.env exit 0 fi fi echo "Proceed to API-based selection/gating" - name: Checkout default branch run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } rm -rf .git git init -q git remote add origin "$CLONE_URL" git fetch --depth 1 origin "$DEFAULT_BRANCH" git -c advice.detachedHead=false checkout -q FETCH_HEAD git log -1 --oneline - name: Detect app dir (repo-root vs ./site) run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } APP_DIR="." if [[ -d "site" && -f "site/package.json" ]]; then APP_DIR="site" fi echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env echo "APP_DIR=$APP_DIR" test -f "$APP_DIR/package.json" || { echo "package.json missing in APP_DIR=$APP_DIR" exit 1 } test -d "$APP_DIR/scripts" || { echo "scripts/ missing in APP_DIR=$APP_DIR" exit 1 } - name: Select next proposer batch (by path) env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN" exit 1 } export GITEA_OWNER="$OWNER" export GITEA_REPO="$REPO" export FORGE_API="$API_BASE" cd "$APP_DIR" test -f scripts/pick-proposer-issue.mjs || { echo "missing scripts/pick-proposer-issue.mjs in APP_DIR=$APP_DIR" ls -la scripts | sed -n '1,200p' || true exit 1 } node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env cat /tmp/proposer.pick.env >> /tmp/proposer.env source /tmp/proposer.pick.env if [[ "${TARGET_FOUND:-0}" != "1" ]]; then echo 'SKIP=1' >> /tmp/proposer.env echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env echo "No target batch" exit 0 fi echo "Target batch:" grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env - name: Derive deterministic batch identity run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } export TARGET_ISSUES TARGET_CHEMIN node --input-type=module - <<'NODE' import fs from "node:fs"; import crypto from "node:crypto"; const issues = String(process.env.TARGET_ISSUES || "") .trim() .split(/\s+/) .filter(Boolean) .sort((a, b) => Number(a) - Number(b)); const chemin = String(process.env.TARGET_CHEMIN || "").trim(); const keySource = `${chemin}::${issues.join(",")}`; const hash = crypto.createHash("sha1").update(keySource).digest("hex").slice(0, 12); const primary = issues[0] || "0"; const batchBranch = `bot/proposer-${primary}-${hash}`; fs.appendFileSync( "/tmp/proposer.env", [ `BATCH_KEY=${JSON.stringify(keySource)}`, `BATCH_HASH=${JSON.stringify(hash)}`, `BATCH_BRANCH=${JSON.stringify(batchBranch)}` ].join("\n") + "\n" ); NODE echo "Batch identity:" grep -E '^(BATCH_KEY|BATCH_HASH|BATCH_BRANCH)=' /tmp/proposer.env - name: Inspect open proposer PRs env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \ -o /tmp/open_pulls.json export TARGET_ISSUES="${TARGET_ISSUES:-}" export BATCH_BRANCH="${BATCH_BRANCH:-}" export BATCH_KEY="${BATCH_KEY:-}" node --input-type=module - <<'NODE' >> /tmp/proposer.env import fs from "node:fs"; const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8")); const issues = String(process.env.TARGET_ISSUES || "") .trim() .split(/\s+/) .filter(Boolean); const batchBranch = String(process.env.BATCH_BRANCH || ""); const batchKey = String(process.env.BATCH_KEY || ""); const proposerOpen = Array.isArray(pulls) ? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-")) : []; const sameBatch = proposerOpen.find((pr) => { const ref = String(pr?.head?.ref || ""); const title = String(pr?.title || ""); const body = String(pr?.body || ""); if (batchBranch && ref === batchBranch) return true; if (batchKey && body.includes(`Batch-Key: ${batchKey}`)) return true; return issues.some((n) => ref.startsWith(`bot/proposer-${n}-`) || title.includes(`#${n}`) || body.includes(`#${n}`) || body.includes(`ticket #${n}`) ); }); const out = []; if (sameBatch) { out.push("SKIP=1"); out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`); out.push(`OPEN_PR_URL=${JSON.stringify(String(sameBatch.html_url || sameBatch.url || ""))}`); out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(sameBatch?.head?.ref || ""))}`); } else if (proposerOpen.length > 0) { const first = proposerOpen[0]; out.push("SKIP=1"); out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`); out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`); out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`); } process.stdout.write(out.join("\n") + (out.length ? "\n" : "")); NODE - name: Guard on remote batch branch before heavy work run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } if git ls-remote --exit-code --heads origin "$BATCH_BRANCH" >/dev/null 2>&1; then echo 'SKIP=1' >> /tmp/proposer.env echo 'SKIP_REASON="batch_branch_exists_without_pr"' >> /tmp/proposer.env echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env echo "Remote batch branch already exists -> skip duplicate materialization" exit 0 fi echo "Remote batch branch is free" - name: Comment issue if queued / skipped if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "${EVENT_NAME:-}" != "push" ]] || exit 0 if [[ "${SKIP_REASON:-}" == "label_not_state_approved_event" || "${SKIP_REASON:-}" == "label_not_state_approved" ]]; then echo "Skip reason=${SKIP_REASON} -> no comment" exit 0 fi test -n "${FORGE_TOKEN:-}" || exit 0 ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}" if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}" fi [[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0 case "${SKIP_REASON:-}" in queue_busy_open_proposer_pr) MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main." ;; issue_already_has_open_pr) MSG="This batch already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}" ;; batch_branch_exists_without_pr) MSG="This batch already has a remote batch branch (${OPEN_PR_BRANCH:-"(unknown branch)"}). Manual inspection is required before any new proposer PR is created." ;; batch_branch_already_materialized) MSG="This batch was already materialized by another run on branch ${OPEN_PR_BRANCH:-"(unknown branch)"}. No duplicate PR was created." ;; explicit_issue_missing_chemin) MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable." ;; explicit_issue_missing_type) MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable." ;; explicit_issue_not_approved) MSG="Proposer Apply: this ticket is not currently labeled state/approved." ;; explicit_issue_rejected) MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue." ;; no_open_approved_proposer_issue) MSG="No approved proposer ticket is currently waiting." ;; *) MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}." ;; esac export MSG node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json const msg = process.env.MSG || ""; process.stdout.write(JSON.stringify({ body: msg })); NODE curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \ --data-binary @/tmp/proposer.skip.comment.json || true - name: NPM harden run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || exit 0 cd "$APP_DIR" npm config set fetch-retries 5 npm config set fetch-retry-mintimeout 20000 npm config set fetch-retry-maxtimeout 120000 npm config set registry https://registry.npmjs.org - name: Install deps run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || exit 0 cd "$APP_DIR" npm ci --no-audit --no-fund - name: Build dist baseline run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || exit 0 cd "$APP_DIR" npm run build - name: Apply proposer batch on bot branch continue-on-error: true env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }} BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }} run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } git config user.name "${BOT_GIT_NAME:-archicratie-bot}" git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}" START_SHA="$(git rev-parse HEAD)" BR="$BATCH_BRANCH" echo "BRANCH=$BR" >> /tmp/proposer.env git checkout -b "$BR" export GITEA_OWNER="$OWNER" export GITEA_REPO="$REPO" export FORGE_API="$API_BASE" LOG="/tmp/proposer-apply.log" : > "$LOG" RC=0 FAILED_ISSUE="" for ISSUE in $TARGET_ISSUES; do echo "" >> "$LOG" echo "== ticket #$ISSUE ==" >> "$LOG" set +e (cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >> "$LOG" 2>&1 STEP_RC=$? set -e if [[ "$STEP_RC" -ne 0 ]]; then RC="$STEP_RC" FAILED_ISSUE="$ISSUE" break fi done echo "APPLY_RC=$RC" >> /tmp/proposer.env echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env echo "Apply log (tail):" tail -n 220 "$LOG" || true END_SHA="$(git rev-parse HEAD)" if [[ "$RC" -ne 0 ]]; then echo "NOOP=0" >> /tmp/proposer.env exit 0 fi if [[ "$START_SHA" == "$END_SHA" ]]; then echo "NOOP=1" >> /tmp/proposer.env else echo "NOOP=0" >> /tmp/proposer.env echo "END_SHA=$END_SHA" >> /tmp/proposer.env fi - name: Rebase bot branch on latest main continue-on-error: true run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "0" ]] || exit 0 LOG="/tmp/proposer-apply.log" git fetch origin "$DEFAULT_BRANCH" set +e git rebase "origin/$DEFAULT_BRANCH" >> "$LOG" 2>&1 RC=$? set -e if [[ "$RC" -ne 0 ]]; then git rebase --abort || true fi echo "REBASE_RC=$RC" >> /tmp/proposer.env echo "Rebase log (tail):" tail -n 220 "$LOG" || true - name: Comment issues on failure if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 APPLY_RC="${APPLY_RC:-0}" REBASE_RC="${REBASE_RC:-0}" if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then echo "No failure detected" exit 0 fi test -n "${FORGE_TOKEN:-}" || exit 0 if [[ -f /tmp/proposer-apply.log ]]; then BODY="$(tail -n 160 /tmp/proposer-apply.log | sed 's/\r$//')" else BODY="(no proposer log found)" fi export BODY APPLY_RC REBASE_RC FAILED_ISSUE if [[ "$APPLY_RC" != "0" ]]; then export FAILURE_KIND="apply" else export FAILURE_KIND="rebase" fi node --input-type=module - <<'NODE' > /tmp/proposer.failure.comment.json const body = process.env.BODY || ""; const applyRc = process.env.APPLY_RC || "0"; const rebaseRc = process.env.REBASE_RC || "0"; const failedIssue = process.env.FAILED_ISSUE || "unknown"; const kind = process.env.FAILURE_KIND || "apply"; const msg = kind === "apply" ? `Batch proposer failed on ticket #${failedIssue} (rc=${applyRc}).\n\n\`\`\`\n${body}\n\`\`\`\n` : `Rebase proposer failed on main (rc=${rebaseRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`; process.stdout.write(JSON.stringify({ body: msg })); NODE for ISSUE in ${TARGET_ISSUES:-}; do curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \ --data-binary @/tmp/proposer.failure.comment.json || true done - name: Late guard against duplicate batch materialization run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${REBASE_RC:-0}" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "0" ]] || exit 0 REMOTE_SHA="$(git ls-remote --heads origin "$BATCH_BRANCH" | awk 'NR==1 {print $1}')" if [[ -n "${REMOTE_SHA:-}" && "${REMOTE_SHA}" != "${END_SHA:-}" ]]; then echo 'SKIP=1' >> /tmp/proposer.env echo 'SKIP_REASON="batch_branch_already_materialized"' >> /tmp/proposer.env echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env echo "Remote batch branch already exists at $REMOTE_SHA -> skip duplicate push/PR" exit 0 fi echo "Late guard OK" - name: Push bot branch if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || { echo "Apply failed -> skip push"; exit 0; } [[ "${REBASE_RC:-0}" == "0" ]] || { echo "Rebase failed -> skip push"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo "No-op -> skip push"; exit 0; } [[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip push"; exit 0; } AUTH_URL="$(node --input-type=module -e ' const [clone, tok] = process.argv.slice(1); const u = new URL(clone); u.username = "oauth2"; u.password = tok; console.log(u.toString()); ' "$CLONE_URL" "$FORGE_TOKEN")" git remote set-url origin "$AUTH_URL" git push -u origin "$BRANCH" - name: Create PR + comment issues + close issues if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${REBASE_RC:-0}" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "0" ]] || exit 0 [[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; } OPEN_PRS_JSON="$(curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100")" export OPEN_PRS_JSON BATCH_BRANCH BATCH_KEY EXISTING_PR_URL="$(node --input-type=module -e ' const pulls = JSON.parse(process.env.OPEN_PRS_JSON || "[]"); const branch = String(process.env.BATCH_BRANCH || ""); const key = String(process.env.BATCH_KEY || ""); const current = Array.isArray(pulls) ? pulls.find((pr) => { const ref = String(pr?.head?.ref || ""); const body = String(pr?.body || ""); return (branch && ref === branch) || (key && body.includes(`Batch-Key: ${key}`)); }) : null; process.stdout.write(current ? String(current.html_url || current.url || "") : ""); ')" if [[ -n "${EXISTING_PR_URL:-}" ]]; then echo "PR already exists for this batch: $EXISTING_PR_URL" exit 0 fi if [[ "${TARGET_COUNT:-0}" == "1" ]]; then PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}" else PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}" fi export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER BATCH_KEY node --input-type=module -e ' import fs from "node:fs"; const issues = String(process.env.TARGET_ISSUES || "") .trim() .split(/\s+/) .filter(Boolean); const body = [ `PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`, "", `- Chemin: ${process.env.TARGET_CHEMIN || "(inconnu)"}`, "- Tickets:", ...issues.map((n) => ` - #${n}`), `- Branche: ${process.env.BRANCH || ""}`, `- Commit: ${process.env.END_SHA || "unknown"}`, `- Batch-Key: ${process.env.BATCH_KEY || ""}`, "", "Merge si CI OK." ].join("\n"); fs.writeFileSync( "/tmp/proposer.pr.json", JSON.stringify({ title: process.env.PR_TITLE || "proposer: apply tickets", body, base: process.env.DEFAULT_BRANCH || "main", head: `${process.env.OWNER}:${process.env.BRANCH}`, allow_maintainer_edit: true }) ); ' PR_JSON="$(curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \ --data-binary @/tmp/proposer.pr.json)" PR_URL="$(node --input-type=module -e 'const pr = JSON.parse(process.argv[1] || "{}"); console.log(pr.html_url || pr.url || "");' "$PR_JSON")" test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON" exit 1 } for ISSUE in $TARGET_ISSUES; do export ISSUE PR_URL node --input-type=module -e ' import fs from "node:fs"; const issue = process.env.ISSUE || ""; const url = process.env.PR_URL || ""; const msg = `PR proposer creee pour le ticket #${issue} : ${url}\n\n` + `Le ticket est cloture automatiquement ; la discussion peut se poursuivre dans la PR.`; fs.writeFileSync( "/tmp/proposer.issue.close.comment.json", JSON.stringify({ body: msg }) ); ' curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \ --data-binary @/tmp/proposer.issue.close.comment.json curl -fsS -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \ --data-binary '{"state":"closed"}' ISSUE_STATE="$(curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" | \ node --input-type=module -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { const j = JSON.parse(s || "{}"); process.stdout.write(String(j.state || "")); });')" [[ "$ISSUE_STATE" == "closed" ]] || { echo "Issue #$ISSUE is still not closed after PATCH" exit 1 } done echo "PR: $PR_URL" - name: Finalize if: ${{ always() }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 if [[ "${APPLY_RC:-0}" != "0" ]]; then echo "Apply failed (rc=${APPLY_RC})" exit "${APPLY_RC}" fi if [[ "${REBASE_RC:-0}" != "0" ]]; then echo "Rebase failed (rc=${REBASE_RC})" exit "${REBASE_RC}" fi echo "Proposer queue OK"