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 concurrency: group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} cancel-in-progress: true jobs: apply-approved: 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 || vars.FORGE_BASE_URL }} 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"); } let labelName = "workflow_dispatch"; const lab = ev?.label; if (typeof lab === "string") labelName = lab; else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name; else if (ev?.label?.name) labelName = ev.label.name; 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: Early gate (label event fast-skip, but tolerant) run: | set -euo pipefail source /tmp/anno.env echo "event label = $LABEL_NAME" if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then echo "label=$LABEL_NAME => skip early" echo "SKIP=1" >> /tmp/anno.env echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env exit 0 fi echo "continue to API gating (issue=$ISSUE_NUMBER)" - name: Fetch issue + hard gate on labels + Type env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} 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; } 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/anno.env import fs from "node:fs"; const issue = JSON.parse(fs.readFileSync("/tmp/issue.json", "utf8")); 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) : []; const hasApproved = labels.includes("state/approved"); 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 allowedAnno = new Set(["type/media", "type/reference", "type/comment"]); const proposerTypes = new Set(["type/correction", "type/fact-check"]); const out = []; out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); if (!hasApproved) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`); process.stdout.write(out.join("\n") + "\n"); process.exit(0); } if (!type) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); } else if (allowedAnno.has(type)) { // proceed } else if (proposerTypes.has(type)) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("proposer_type:" + type)}`); } else { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:" + type)}`); } process.stdout.write(out.join("\n") + "\n"); NODE echo "gating result:" grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true - name: Comment issue if skipped (unsupported / missing Type only) if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/anno.env || true [[ "${SKIP:-0}" == "1" ]] || exit 0 if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then echo "skip reason=${SKIP_REASON} -> no comment" exit 0 fi if [[ "${SKIP_REASON:-}" == proposer_type:* ]]; then echo "proposer ticket detected -> anno stays silent" exit 0 fi test -n "${FORGE_TOKEN:-}" || exit 0 REASON="${SKIP_REASON:-}" TYPE="${ISSUE_TYPE:-}" if [[ "$REASON" == unsupported_type:* ]]; then MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment." else MSG="Ticket #${ISSUE_NUMBER} ignored: missing or unreadable 'Type:'. Expected: type/media|type/reference|type/comment" 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" - 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 --no-audit --no-fund - 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 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 -d .git || { echo "not a git repo (checkout failed)"; echo "APPLY_RC=90" >> /tmp/anno.env; 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 || true [[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } RC="${APPLY_RC:-0}" if [[ "$RC" == "0" ]]; then echo "no failure detected" exit 0 fi test -n "${FORGE_TOKEN:-}" || exit 0 if [[ -f /tmp/apply.log ]]; then BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" else BODY="(no apply log found)" fi MSG="apply-annotation-ticket failed (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: Push bot branch if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/anno.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; } test -d .git || { echo "no git repo -> 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 || true [[ "${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 created for 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"