From bfbdc7b68899adb6f07f03a3b554d15f559dae73 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 2 Mar 2026 23:42:55 +0100 Subject: [PATCH] ci: add Proposer Apply workflow (apply-ticket -> PR bot) --- .gitea/workflows/proposer-apply-pr.yml | 408 +++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 .gitea/workflows/proposer-apply-pr.yml diff --git a/.gitea/workflows/proposer-apply-pr.yml b/.gitea/workflows/proposer-apply-pr.yml new file mode 100644 index 0000000..6ba1693 --- /dev/null +++ b/.gitea/workflows/proposer-apply-pr.yml @@ -0,0 +1,408 @@ +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 + python3 --version || true + + - 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 + + - name: Install deps (site/) + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + cd site + npm ci --no-audit --no-fund + + - name: Build dist baseline (site/) + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + cd site + 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" + + LOG="/tmp/proposer-apply.log" + set +e + (cd site && 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: Comment issue on failure + if: ${{ always() }} + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/proposer.env || true + [[ "${SKIP:-0}" != "1" ]] || exit 0 + + RC="${APPLY_RC:-0}" + if [[ "$RC" == "0" ]]; then + exit 0 + fi + + BODY="(no log)" + [[ -f /tmp/proposer-apply.log ]] && BODY="$(tail -n 160 /tmp/proposer-apply.log | sed 's/\r$//')" + + MSG="❌ Proposer Apply échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" + 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" + + - name: Comment issue if no-op + 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}" == "1" ]] || exit 0 + + MSG="ℹ️ Proposer Apply: rien à appliquer (déjà présent / dédupliqué)." + 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" + + - 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; } + + 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 + + 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}\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" \ No newline at end of file