From 7b135a4707fdb2f678141d84e78f22a2c39da3c7 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Wed, 25 Feb 2026 17:50:45 +0100 Subject: [PATCH] ci: add anno apply bot workflow --- .gitea/workflows/anno-apply-pr.yml | 301 +++++++++++++++++++++++++++++ .gitea/workflows/anno-reject.yml | 102 ++++++++++ 2 files changed, 403 insertions(+) create mode 100644 .gitea/workflows/anno-apply-pr.yml create mode 100644 .gitea/workflows/anno-reject.yml diff --git a/.gitea/workflows/anno-apply-pr.yml b/.gitea/workflows/anno-apply-pr.yml new file mode 100644 index 0000000..5d492eb --- /dev/null +++ b/.gitea/workflows/anno-apply-pr.yml @@ -0,0 +1,301 @@ +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 }} + 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) { + // fallback parse from clone url + 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 || "master"; + +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; // https://gitea... +const apiBase = (process.env.FORGE_API && process.env.FORGE_API.trim()) + ? 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,80p' /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 (only state/approved triggers apply)" + exit 0 + fi + echo "✅ proceed (label=$LABEL_NAME issue=$ISSUE_NUMBER)" + + - name: Checkout default branch (from event.json, no external actions) + run: | + set -euo pipefail + source /tmp/anno.env + + rm -rf .git + git init -q + git remote add origin "$CLONE_URL" + + echo "Repo URL: $CLONE_URL" + echo "Base: $DEFAULT_BRANCH" + + 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 + npm ci + + - name: Apply ticket on bot branch (strict+verify, commit) + 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 + + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + + # git identity (required for commits) + 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" + + # env for script + 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 log (tail) ==" + tail -n 120 "$LOG" || true + + END_SHA="$(git rev-parse HEAD)" + + if [[ "$RC" -ne 0 ]]; then + echo "APPLY_RC=$RC" >> /tmp/anno.env + exit "$RC" + 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 + # si apply a échoué, la step précédente s'arrête => ce step tourne quand même (always) + if [[ -z "${APPLY_RC:-}" ]]; then + echo "ℹ️ no failure detected" + exit 0 + fi + + BODY="$(tail -n 120 /tmp/apply.log | sed 's/\r$//' )" + MSG="❌ apply-annotation-ticket a échoué (rc=${APPLY_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" + + PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.env.MSG}))' \ + MSG="$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" + + exit "${APPLY_RC}" + + - name: Comment issue if no-op (already applied) + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/anno.env + if [[ "${NOOP:-0}" != "1" ]]; then + echo "ℹ️ changes exist -> will create PR" + exit 0 + fi + + MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)." + PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.env.MSG}))' MSG="$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" + + echo "✅ no-op handled" + exit 0 + + - name: Push bot branch + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/anno.env + test "${NOOP:-0}" = "0" || { echo "ℹ️ no-op -> skip push"; exit 0; } + + # auth remote (Gitea supports oauth2:) + AUTH_URL="$(node --input-type=module -e ' + const u = new URL(process.env.CLONE_URL); + u.username = "oauth2"; + u.password = process.env.FORGE_TOKEN; + console.log(u.toString()); + ' CLONE_URL="$CLONE_URL" FORGE_TOKEN="$FORGE_TOKEN")" + + git remote set-url origin "$AUTH_URL" + git push -u origin "$BRANCH" + + - name: Create PR + comment issue + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/anno.env + test "${NOOP:-0}" = "0" || { echo "ℹ️ no-op -> skip PR"; exit 0; } + + PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}" + PR_BODY="PR générée automatiquement à partir du ticket #${ISSUE_NUMBER} (label state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK." + + PR_PAYLOAD="$(node --input-type=module -e ' + console.log(JSON.stringify({ + title: process.env.PR_TITLE, + body: process.env.PR_BODY, + base: process.env.DEFAULT_BRANCH, + head: `${process.env.OWNER}:${process.env.BRANCH}`, + allow_maintainer_edit: true + })); + ' PR_TITLE="$PR_TITLE" PR_BODY="$PR_BODY" OWNER="$OWNER" BRANCH="$BRANCH" DEFAULT_BRANCH="$DEFAULT_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.env.PR_JSON); + console.log(pr.html_url || pr.url || ""); + ' PR_JSON="$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.env.MSG}))' MSG="$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" \ No newline at end of file diff --git a/.gitea/workflows/anno-reject.yml b/.gitea/workflows/anno-reject.yml new file mode 100644 index 0000000..33f4d91 --- /dev/null +++ b/.gitea/workflows/anno-reject.yml @@ -0,0 +1,102 @@ +name: Anno Reject + +on: + issues: + types: [labeled] + +env: + NODE_OPTIONS: --dns-result-order=ipv4first + +defaults: + run: + shell: bash + +jobs: + reject: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm + + steps: + - name: Derive context + 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/reject.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 url"); + +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 issueNumber = ev?.issue?.number || ev?.issue?.index; +if (!issueNumber) throw new Error("No issue number"); + +const labelName = ev?.label?.name || ev?.label || ""; + +const u = new URL(cloneUrl); +const apiBase = u.origin; + +function sh(s){ return JSON.stringify(String(s)); } +process.stdout.write([ + `OWNER=${sh(owner)}`, + `REPO=${sh(repo)}`, + `ISSUE_NUMBER=${sh(issueNumber)}`, + `LABEL_NAME=${sh(labelName)}`, + `API_BASE=${sh(apiBase)}` +].join("\n") + "\n"); +NODE + + - name: Gate on label state/rejected + run: | + set -euo pipefail + source /tmp/reject.env + if [[ "$LABEL_NAME" != "state/rejected" ]]; then + echo "ℹ️ label=$LABEL_NAME => skip" + exit 0 + fi + echo "✅ reject issue=$ISSUE_NUMBER" + + - name: Comment + close issue + env: + FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} + run: | + set -euo pipefail + source /tmp/reject.env + test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } + + MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)." + PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.env.MSG}))' MSG="$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" + + curl -fsS -X PATCH \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ + --data-binary '{"state":"closed"}' + + echo "✅ closed #$ISSUE_NUMBER" \ No newline at end of file -- 2.49.1