439 lines
16 KiB
YAML
439 lines
16 KiB
YAML
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 || inputs.issue || 'manual' }}
|
||
cancel-in-progress: true
|
||
|
||
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
|
||
|
||
- 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(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.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: Fetch issue + gate on Type (skip Proposer)
|
||
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; }
|
||
|
||
ISSUE_JSON="$(curl -fsS \
|
||
-H "Authorization: token $FORGE_TOKEN" \
|
||
-H "Accept: application/json" \
|
||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
|
||
|
||
node --input-type=module - <<'NODE' "$ISSUE_JSON" >> /tmp/anno.env
|
||
const issue = JSON.parse(process.argv[1] || "{}");
|
||
const title = String(issue.title || "");
|
||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||
|
||
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 allowed = new Set(["type/media","type/reference","type/comment"]);
|
||
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)}`);
|
||
|
||
if (!type) {
|
||
out.push(`SKIP=1`);
|
||
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
||
} else if (allowed.has(type)) {
|
||
// proceed
|
||
} else if (proposer.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 "✅ issue type gating:"
|
||
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
|
||
|
||
- name: Comment issue if skipped (Proposer / unsupported / missing Type)
|
||
if: ${{ always() }}
|
||
env:
|
||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
source /tmp/anno.env || true
|
||
|
||
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
||
[[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0
|
||
|
||
# message différent si Proposer
|
||
REASON="${SKIP_REASON:-}"
|
||
TYPE="${ISSUE_TYPE:-}"
|
||
TITLE="${ISSUE_TITLE:-}"
|
||
|
||
if [[ "$REASON" == proposer_type:* ]]; then
|
||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer et n'ajoute **jamais** state/approved automatiquement.\n\n✅ Action : traitement éditorial manuel."
|
||
elif [[ "$REASON" == unsupported_type:* ]]; then
|
||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment.\n✅ Action : traitement manuel si nécessaire."
|
||
else
|
||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (ajouter 'Type: type/media|type/reference|type/comment') ou traiter manuellement."
|
||
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 -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
|
||
|
||
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 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 || true
|
||
[[ "${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 || 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/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 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" |