name: Proposer Apply (PR) on: issues: types: [labeled] workflow_dispatch: inputs: issue: description: "Issue number to apply (Proposer: correction/fact-check)" required: true env: NODE_OPTIONS: --dns-result-order=ipv4first defaults: run: shell: bash concurrency: group: proposer-apply-${{ github.event.issue.number || inputs.issue || 'manual' }} cancel-in-progress: true 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) 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" 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); 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"; 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)}`, `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "✅ context:" sed -n '1,120p' /tmp/proposer.env - name: Gate on label state/approved run: | set -euo pipefail source /tmp/proposer.env if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then echo "ℹ️ label=$LABEL_NAME => skip" echo "SKIP=1" >> /tmp/proposer.env exit 0 fi echo "✅ proceed (issue=$ISSUE_NUMBER)" - name: Fetch issue + API-hard gate on (state/approved present + proposer type) 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; } curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ -o /tmp/issue.json node --input-type=module - <<'NODE' >> /tmp/proposer.env import fs from "node:fs"; const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); const title = String(issue.title || ""); const body = String(issue.body || "").replace(/\r\n/g, "\n"); const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name||"")).filter(Boolean) : []; function pickLine(key) { const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi"); const m = body.match(re); return m ? m[1].trim() : ""; } const typeRaw = pickLine("Type"); const type = String(typeRaw || "").trim().toLowerCase(); const hasApproved = labels.includes("state/approved"); const proposer = new Set(["type/correction","type/fact-check"]); const out = []; out.push(`ISSUE_TITLE=${JSON.stringify(title)}`); out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); out.push(`HAS_APPROVED=${hasApproved ? "1":"0"}`); if (!hasApproved) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("approved_not_present")}`); } else if (!type) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); } else if (!proposer.has(type)) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("not_proposer:"+type)}`); } process.stdout.write(out.join("\n") + "\n"); NODE echo "✅ proposer gating:" grep -E '^(ISSUE_TYPE|HAS_APPROVED|SKIP|SKIP_REASON)=' /tmp/proposer.env || true - name: Comment issue if skipped if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0 REASON="${SKIP_REASON:-}" TYPE="${ISSUE_TYPE:-}" if [[ "$REASON" == "approved_not_present" ]]; then MSG="ℹ️ Proposer Apply: skip — le label **state/approved** n'est pas présent sur le ticket au moment du run (gate API-hard)." elif [[ "$REASON" == "missing_type" ]]; then MSG="ℹ️ Proposer Apply: skip — champ **Type:** manquant/illisible. Attendu: type/correction ou type/fact-check." else MSG="ℹ️ Proposer Apply: skip — Type non-Proposer (${TYPE}). (Ce workflow ne traite que correction/fact-check.)" fi 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" || true - 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 echo "✅ workspace:" ls -la | sed -n '1,120p' - 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" ls -la "$APP_DIR" | sed -n '1,120p' 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: NPM harden (reduce flakiness) 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 (APP_DIR) run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } cd "$APP_DIR" npm ci --no-audit --no-fund - name: Build dist baseline (APP_DIR) run: | set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } cd "$APP_DIR" npm run build - name: Apply ticket (alias + commit) 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 }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} 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)" TS="$(date -u +%Y%m%d-%H%M%S)" BR="bot/proposer-${ISSUE_NUMBER}-${TS}" echo "BRANCH=$BR" >> /tmp/proposer.env git checkout -b "$BR" export GITEA_OWNER="$OWNER" export GITEA_REPO="$REPO" export FORGE_BASE="$API_BASE" LOG="/tmp/proposer-apply.log" set +e (cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE_NUMBER" --alias --commit) >"$LOG" 2>&1 RC=$? set -e echo "APPLY_RC=$RC" >> /tmp/proposer.env echo "== apply log (tail) ==" tail -n 200 "$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: 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; } [[ "${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 issue 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 [[ "${NOOP:-0}" == "0" ]] || exit 0 [[ -n "${BRANCH:-}" ]] || { echo "ℹ️ BRANCH unset -> skip PR"; exit 0; } PR_TITLE="proposer: apply ticket #${ISSUE_NUMBER}" PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA:-unknown}\n\nMerge si CI OK." PR_PAYLOAD="$(node --input-type=module -e ' const [title, body, base, head] = process.argv.slice(1); console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true })); ' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")" 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 "$PR_PAYLOAD")" 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; } MSG="✅ PR Proposer créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}" C_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 "$C_PAYLOAD" - name: Finalize (fail job if apply failed) if: ${{ always() }} run: | set -euo pipefail source /tmp/proposer.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 RC="${APPLY_RC:-0}" if [[ "$RC" != "0" ]]; then echo "❌ apply failed (rc=$RC)" exit "$RC" fi echo "✅ apply ok"