name: Anno Apply (PR) on: issues: types: [labeled] workflow_dispatch: inputs: issue: description: "Issue number to apply" required: true env: NODE_OPTIONS: --dns-result-order=ipv4first defaults: run: shell: bash jobs: apply-approved: runs-on: ubuntu-latest container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm steps: - name: Tools sanity run: | set -euo pipefail git --version node --version npm --version npm ping --registry=https://registry.npmjs.org - 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/anno.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/anno.env - name: Gate on label state/approved run: | set -euo pipefail source /tmp/anno.env if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then echo "ℹ️ label=$LABEL_NAME => skip" echo "SKIP=1" >> /tmp/anno.env exit 0 fi echo "✅ proceed (issue=$ISSUE_NUMBER)" - name: Checkout default branch run: | set -euo pipefail source /tmp/anno.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 run: | set -euo pipefail source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm ci - name: Check apply script exists run: | set -euo pipefail source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -f scripts/apply-annotation-ticket.mjs || { echo "❌ missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH" ls -la scripts | sed -n '1,200p' || true exit 1 } - name: Build dist (needed for --verify) run: | set -euo pipefail source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm run build:clean test -f dist/para-index.json || { echo "❌ missing dist/para-index.json after build" ls -la dist | sed -n '1,200p' || true exit 1 } echo "✅ dist/para-index.json present" - name: Apply ticket on bot branch (strict+verify, commit) 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/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } 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/anno-${ISSUE_NUMBER}-${TS}" echo "BRANCH=$BR" >> /tmp/anno.env git checkout -b "$BR" export FORGE_API="$API_BASE" export GITEA_OWNER="$OWNER" export GITEA_REPO="$REPO" LOG="/tmp/apply.log" set +e node scripts/apply-annotation-ticket.mjs "$ISSUE_NUMBER" --strict --verify --commit >"$LOG" 2>&1 RC=$? set -e echo "APPLY_RC=$RC" >> /tmp/anno.env echo "== apply log (tail) ==" tail -n 180 "$LOG" || true END_SHA="$(git rev-parse HEAD)" if [[ "$RC" -ne 0 ]]; then echo "NOOP=0" >> /tmp/anno.env exit 0 fi if [[ "$START_SHA" == "$END_SHA" ]]; then echo "NOOP=1" >> /tmp/anno.env else echo "NOOP=0" >> /tmp/anno.env echo "END_SHA=$END_SHA" >> /tmp/anno.env fi - name: Comment issue on failure (strict/verify/etc) if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } RC="${APPLY_RC:-0}" if [[ "$RC" == "0" ]]; then echo "ℹ️ no failure detected" exit 0 fi BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" MSG="❌ apply-annotation-ticket a é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 (already applied) if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/anno.env [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 [[ "${NOOP:-0}" == "1" ]] || exit 0 MSG="ℹ️ Ticket #${ISSUE_NUMBER} : 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/anno.env [[ "${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/anno.env [[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip PR"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; } PR_TITLE="anno: 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 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" echo "✅ PR: $PR_URL" - name: Finalize (fail job if apply failed) if: ${{ always() }} run: | set -euo pipefail source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } RC="${APPLY_RC:-0}" if [[ "$RC" != "0" ]]; then echo "❌ apply failed (rc=$RC)" exit "$RC" fi echo "✅ apply ok"