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(/[:/](?<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 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:-<empty>}"

          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"