754 lines
26 KiB
YAML
754 lines
26 KiB
YAML
name: Proposer Apply (Queue)
|
|
|
|
on:
|
|
issues:
|
|
types: [labeled]
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
issue:
|
|
description: "Issue number to prioritize (optional)"
|
|
required: false
|
|
default: ""
|
|
|
|
env:
|
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
concurrency:
|
|
group: proposer-queue-main
|
|
cancel-in-progress: false
|
|
|
|
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
|
|
|
|
- name: Derive context (event.json / workflow_dispatch / push)
|
|
env:
|
|
INPUT_ISSUE: ${{ inputs.issue }}
|
|
EVENT_NAME_IN: ${{ github.event_name }}
|
|
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(/[:/](?<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) ||
|
|
0;
|
|
|
|
const labelName =
|
|
ev?.label?.name ||
|
|
(typeof ev?.label === "string" ? ev.label : "") ||
|
|
"";
|
|
|
|
const eventName =
|
|
String(process.env.EVENT_NAME_IN || "").trim() ||
|
|
(ev?.issue ? "issues" : (ev?.before || ev?.after ? "push" : "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)}`,
|
|
`EVENT_NAME=${sh(eventName)}`,
|
|
`API_BASE=${sh(apiBase)}`
|
|
].join("\n") + "\n");
|
|
NODE
|
|
|
|
echo "Context:"
|
|
sed -n '1,200p' /tmp/proposer.env
|
|
|
|
- name: Early gate (tolerant on empty issue label payload)
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
|
|
echo "event=$EVENT_NAME label=${LABEL_NAME:-<empty>}"
|
|
|
|
if [[ "$EVENT_NAME" == "issues" ]]; then
|
|
# Gitea peut fournir un payload "issues/labeled" sans label exploitable.
|
|
# On ne skip QUE si le label est explicitement présent ET différent de state/approved.
|
|
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
|
|
echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip"
|
|
echo 'SKIP=1' >> /tmp/proposer.env
|
|
echo 'SKIP_REASON="label_not_state_approved_event"' >> /tmp/proposer.env
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
echo "Proceed to API-based selection/gating"
|
|
|
|
- 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: Detect app dir (repo-root vs ./site)
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
|
|
|
|
APP_DIR="."
|
|
if [[ -d "site" && -f "site/package.json" ]]; then
|
|
APP_DIR="site"
|
|
fi
|
|
|
|
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
|
|
echo "APP_DIR=$APP_DIR"
|
|
|
|
test -f "$APP_DIR/package.json" || {
|
|
echo "package.json missing in APP_DIR=$APP_DIR"
|
|
exit 1
|
|
}
|
|
|
|
test -d "$APP_DIR/scripts" || {
|
|
echo "scripts/ missing in APP_DIR=$APP_DIR"
|
|
exit 1
|
|
}
|
|
|
|
- name: Select next proposer batch (by path)
|
|
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
|
|
}
|
|
|
|
export GITEA_OWNER="$OWNER"
|
|
export GITEA_REPO="$REPO"
|
|
export FORGE_API="$API_BASE"
|
|
|
|
cd "$APP_DIR"
|
|
|
|
test -f scripts/pick-proposer-issue.mjs || {
|
|
echo "missing scripts/pick-proposer-issue.mjs in APP_DIR=$APP_DIR"
|
|
ls -la scripts | sed -n '1,200p' || true
|
|
exit 1
|
|
}
|
|
|
|
node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env
|
|
cat /tmp/proposer.pick.env >> /tmp/proposer.env
|
|
source /tmp/proposer.pick.env
|
|
|
|
if [[ "${TARGET_FOUND:-0}" != "1" ]]; then
|
|
echo 'SKIP=1' >> /tmp/proposer.env
|
|
echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env
|
|
echo "No target batch"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Target batch:"
|
|
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
|
|
|
|
- name: Inspect open proposer PRs
|
|
env:
|
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
|
|
|
|
curl -fsS \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Accept: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \
|
|
-o /tmp/open_pulls.json
|
|
|
|
export TARGET_ISSUES="${TARGET_ISSUES:-}"
|
|
|
|
node --input-type=module - <<'NODE' >> /tmp/proposer.env
|
|
import fs from "node:fs";
|
|
|
|
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8"));
|
|
const issues = String(process.env.TARGET_ISSUES || "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
const proposerOpen = Array.isArray(pulls)
|
|
? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
|
|
: [];
|
|
|
|
const current = proposerOpen.find((pr) => {
|
|
const ref = String(pr?.head?.ref || "");
|
|
const title = String(pr?.title || "");
|
|
const body = String(pr?.body || "");
|
|
|
|
return issues.some((n) =>
|
|
ref.startsWith(`bot/proposer-${n}-`) ||
|
|
title.includes(`#${n}`) ||
|
|
body.includes(`#${n}`) ||
|
|
body.includes(`ticket #${n}`)
|
|
);
|
|
});
|
|
|
|
const out = [];
|
|
|
|
if (current) {
|
|
out.push("SKIP=1");
|
|
out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
|
|
out.push(`OPEN_PR_URL=${JSON.stringify(String(current.html_url || current.url || ""))}`);
|
|
} else if (proposerOpen.length > 0) {
|
|
const first = proposerOpen[0];
|
|
out.push("SKIP=1");
|
|
out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
|
|
out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`);
|
|
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`);
|
|
}
|
|
|
|
process.stdout.write(out.join("\n") + (out.length ? "\n" : ""));
|
|
NODE
|
|
|
|
- name: Comment issue if queued / skipped
|
|
if: ${{ always() }}
|
|
env:
|
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env || true
|
|
|
|
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
|
[[ "${EVENT_NAME:-}" != "push" ]] || exit 0
|
|
|
|
if [[ "${SKIP_REASON:-}" == "label_not_state_approved_event" || "${SKIP_REASON:-}" == "label_not_state_approved" ]]; then
|
|
echo "Skip reason=${SKIP_REASON} -> no comment"
|
|
exit 0
|
|
fi
|
|
|
|
test -n "${FORGE_TOKEN:-}" || exit 0
|
|
|
|
ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}"
|
|
if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then
|
|
ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}"
|
|
fi
|
|
[[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0
|
|
|
|
case "${SKIP_REASON:-}" in
|
|
queue_busy_open_proposer_pr)
|
|
MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main."
|
|
;;
|
|
issue_already_has_open_pr)
|
|
MSG="This ticket already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
|
|
;;
|
|
explicit_issue_missing_chemin)
|
|
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
|
|
;;
|
|
explicit_issue_missing_type)
|
|
MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable."
|
|
;;
|
|
explicit_issue_not_approved)
|
|
MSG="Proposer Apply: this ticket is not currently labeled state/approved."
|
|
;;
|
|
explicit_issue_rejected)
|
|
MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue."
|
|
;;
|
|
no_open_approved_proposer_issue)
|
|
MSG="No approved proposer ticket is currently waiting."
|
|
;;
|
|
*)
|
|
MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}."
|
|
;;
|
|
esac
|
|
|
|
node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json
|
|
const msg = process.env.MSG || "";
|
|
process.stdout.write(JSON.stringify({ body: msg }));
|
|
NODE
|
|
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \
|
|
--data-binary @/tmp/proposer.skip.comment.json || true
|
|
|
|
- name: NPM harden
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
|
|
|
cd "$APP_DIR"
|
|
npm config set fetch-retries 5
|
|
npm config set fetch-retry-mintimeout 20000
|
|
npm config set fetch-retry-maxtimeout 120000
|
|
npm config set registry https://registry.npmjs.org
|
|
|
|
- name: Install deps
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
|
|
|
cd "$APP_DIR"
|
|
npm ci --no-audit --no-fund
|
|
|
|
- name: Build dist baseline
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env
|
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
|
|
|
cd "$APP_DIR"
|
|
npm run build
|
|
|
|
- name: Apply proposer batch 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 }}
|
|
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-${TARGET_PRIMARY_ISSUE}-${TS}"
|
|
echo "BRANCH=$BR" >> /tmp/proposer.env
|
|
git checkout -b "$BR"
|
|
|
|
export GITEA_OWNER="$OWNER"
|
|
export GITEA_REPO="$REPO"
|
|
export FORGE_API="$API_BASE"
|
|
|
|
LOG="/tmp/proposer-apply.log"
|
|
: > "$LOG"
|
|
|
|
RC=0
|
|
FAILED_ISSUE=""
|
|
|
|
for ISSUE in $TARGET_ISSUES; do
|
|
echo "" >> "$LOG"
|
|
echo "== ticket #$ISSUE ==" >> "$LOG"
|
|
|
|
set +e
|
|
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >> "$LOG" 2>&1
|
|
STEP_RC=$?
|
|
set -e
|
|
|
|
if [[ "$STEP_RC" -ne 0 ]]; then
|
|
RC="$STEP_RC"
|
|
FAILED_ISSUE="$ISSUE"
|
|
break
|
|
fi
|
|
done
|
|
|
|
echo "APPLY_RC=$RC" >> /tmp/proposer.env
|
|
echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env
|
|
|
|
echo "Apply log (tail):"
|
|
tail -n 220 "$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: Rebase bot branch on latest main
|
|
continue-on-error: true
|
|
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
|
|
|
|
LOG="/tmp/proposer-apply.log"
|
|
|
|
git fetch origin "$DEFAULT_BRANCH"
|
|
|
|
set +e
|
|
git rebase "origin/$DEFAULT_BRANCH" >> "$LOG" 2>&1
|
|
RC=$?
|
|
set -e
|
|
|
|
if [[ "$RC" -ne 0 ]]; then
|
|
git rebase --abort || true
|
|
fi
|
|
|
|
echo "REBASE_RC=$RC" >> /tmp/proposer.env
|
|
|
|
echo "Rebase log (tail):"
|
|
tail -n 220 "$LOG" || true
|
|
|
|
- name: Comment issues on failure
|
|
if: ${{ always() }}
|
|
env:
|
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env || true
|
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
|
|
|
APPLY_RC="${APPLY_RC:-0}"
|
|
REBASE_RC="${REBASE_RC:-0}"
|
|
|
|
if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then
|
|
echo "No failure detected"
|
|
exit 0
|
|
fi
|
|
|
|
test -n "${FORGE_TOKEN:-}" || exit 0
|
|
|
|
if [[ -f /tmp/proposer-apply.log ]]; then
|
|
BODY="$(tail -n 160 /tmp/proposer-apply.log | sed 's/\r$//')"
|
|
else
|
|
BODY="(no proposer log found)"
|
|
fi
|
|
|
|
export BODY APPLY_RC REBASE_RC FAILED_ISSUE
|
|
|
|
if [[ "$APPLY_RC" != "0" ]]; then
|
|
export FAILURE_KIND="apply"
|
|
else
|
|
export FAILURE_KIND="rebase"
|
|
fi
|
|
|
|
node --input-type=module - <<'NODE' > /tmp/proposer.failure.comment.json
|
|
const body = process.env.BODY || "";
|
|
const applyRc = process.env.APPLY_RC || "0";
|
|
const rebaseRc = process.env.REBASE_RC || "0";
|
|
const failedIssue = process.env.FAILED_ISSUE || "unknown";
|
|
const kind = process.env.FAILURE_KIND || "apply";
|
|
|
|
const msg =
|
|
kind === "apply"
|
|
? `Batch proposer failed on ticket #${failedIssue} (rc=${applyRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`
|
|
: `Rebase proposer failed on main (rc=${rebaseRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`;
|
|
|
|
process.stdout.write(JSON.stringify({ body: msg }));
|
|
NODE
|
|
|
|
for ISSUE in ${TARGET_ISSUES:-}; do
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
|
|
--data-binary @/tmp/proposer.failure.comment.json || true
|
|
done
|
|
|
|
- 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; }
|
|
[[ "${REBASE_RC:-0}" == "0" ]] || { echo "Rebase failed -> skip push"; exit 0; }
|
|
[[ "${NOOP:-0}" == "0" ]] || { echo "No-op -> skip push"; exit 0; }
|
|
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> 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 issues + close issues
|
|
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
|
|
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
|
|
[[ "${NOOP:-0}" == "0" ]] || exit 0
|
|
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; }
|
|
|
|
test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; }
|
|
|
|
if [[ "${TARGET_COUNT:-0}" == "1" ]]; then
|
|
PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}"
|
|
else
|
|
PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}"
|
|
fi
|
|
|
|
export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER
|
|
|
|
node --input-type=module -e '
|
|
import fs from "node:fs";
|
|
|
|
const issues = String(process.env.TARGET_ISSUES || "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
const body = [
|
|
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`,
|
|
"",
|
|
`- Chemin: ${process.env.TARGET_CHEMIN || "(inconnu)"}`,
|
|
"- Tickets:",
|
|
...issues.map((n) => ` - #${n}`),
|
|
`- Branche: ${process.env.BRANCH || ""}`,
|
|
`- Commit: ${process.env.END_SHA || "unknown"}`,
|
|
"",
|
|
"Merge si CI OK."
|
|
].join("\n");
|
|
|
|
fs.writeFileSync(
|
|
"/tmp/proposer.pr.json",
|
|
JSON.stringify({
|
|
title: process.env.PR_TITLE || "proposer: apply tickets",
|
|
body,
|
|
base: process.env.DEFAULT_BRANCH || "main",
|
|
head: `${process.env.OWNER}:${process.env.BRANCH}`,
|
|
allow_maintainer_edit: true
|
|
})
|
|
);
|
|
'
|
|
|
|
echo "Creating proposer PR..."
|
|
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 @/tmp/proposer.pr.json)"
|
|
|
|
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
|
|
}
|
|
|
|
echo "PR created: $PR_URL"
|
|
|
|
for ISSUE in $TARGET_ISSUES; do
|
|
export ISSUE PR_URL
|
|
|
|
node --input-type=module -e '
|
|
import fs from "node:fs";
|
|
|
|
const issue = process.env.ISSUE || "";
|
|
const url = process.env.PR_URL || "";
|
|
const msg =
|
|
`PR proposer créée pour le ticket #${issue} : ${url}\n\n` +
|
|
`Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`;
|
|
|
|
fs.writeFileSync(
|
|
"/tmp/proposer.issue.close.comment.json",
|
|
JSON.stringify({ body: msg })
|
|
);
|
|
'
|
|
|
|
echo "Commenting issue #$ISSUE ..."
|
|
COMMENT_HTTP="$(curl -sS -o /tmp/proposer.comment.out.json -w '%{http_code}' -X POST \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
|
|
--data-binary @/tmp/proposer.issue.close.comment.json || true)"
|
|
echo "Issue #$ISSUE comment HTTP=$COMMENT_HTTP"
|
|
|
|
if [[ ! "$COMMENT_HTTP" =~ ^2 ]]; then
|
|
echo "Failed to comment issue #$ISSUE"
|
|
cat /tmp/proposer.comment.out.json || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "Closing issue #$ISSUE ..."
|
|
CLOSE_HTTP="$(curl -sS -o /tmp/proposer.close.out.json -w '%{http_code}' -X PATCH \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \
|
|
--data-binary '{"state":"closed"}' || true)"
|
|
echo "Issue #$ISSUE close HTTP=$CLOSE_HTTP"
|
|
|
|
if [[ ! "$CLOSE_HTTP" =~ ^2 ]]; then
|
|
echo "Failed to close issue #$ISSUE"
|
|
cat /tmp/proposer.close.out.json || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "Verifying issue #$ISSUE state ..."
|
|
VERIFY_HTTP="$(curl -sS -o /tmp/proposer.verify.out.json -w '%{http_code}' \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Accept: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" || true)"
|
|
echo "Issue #$ISSUE verify HTTP=$VERIFY_HTTP"
|
|
|
|
if [[ ! "$VERIFY_HTTP" =~ ^2 ]]; then
|
|
echo "Failed to re-read issue #$ISSUE after close"
|
|
cat /tmp/proposer.verify.out.json || true
|
|
exit 1
|
|
fi
|
|
|
|
ISSUE_STATE="$(node --input-type=module -e '
|
|
import fs from "node:fs";
|
|
const j = JSON.parse(fs.readFileSync("/tmp/proposer.verify.out.json", "utf8"));
|
|
console.log(String(j.state || ""));
|
|
')"
|
|
|
|
echo "Issue #$ISSUE state=$ISSUE_STATE"
|
|
[[ "$ISSUE_STATE" == "closed" ]] || {
|
|
echo "Issue #$ISSUE is not closed after PATCH"
|
|
cat /tmp/proposer.verify.out.json || true
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
echo "PR: $PR_URL"
|
|
|
|
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 @/tmp/proposer.pr.json)"
|
|
|
|
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
|
|
}
|
|
|
|
for ISSUE in $TARGET_ISSUES; do
|
|
export ISSUE PR_URL
|
|
|
|
node --input-type=module -e '
|
|
import fs from "node:fs";
|
|
|
|
const issue = process.env.ISSUE || "";
|
|
const url = process.env.PR_URL || "";
|
|
const msg =
|
|
`PR proposer créée pour le ticket #${issue} : ${url}\n\n` +
|
|
`Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`;
|
|
|
|
fs.writeFileSync(
|
|
"/tmp/proposer.issue.close.comment.json",
|
|
JSON.stringify({ body: msg })
|
|
);
|
|
'
|
|
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
|
|
--data-binary @/tmp/proposer.issue.close.comment.json
|
|
|
|
curl -fsS -X PATCH \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \
|
|
--data-binary '{"state":"closed"}'
|
|
done
|
|
|
|
echo "PR: $PR_URL"
|
|
|
|
- name: Finalize
|
|
if: ${{ always() }}
|
|
run: |
|
|
set -euo pipefail
|
|
source /tmp/proposer.env || true
|
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
|
|
|
if [[ "${APPLY_RC:-0}" != "0" ]]; then
|
|
echo "Apply failed (rc=${APPLY_RC})"
|
|
exit "${APPLY_RC}"
|
|
fi
|
|
|
|
if [[ "${REBASE_RC:-0}" != "0" ]]; then
|
|
echo "Rebase failed (rc=${REBASE_RC})"
|
|
exit "${REBASE_RC}"
|
|
fi
|
|
|
|
echo "Proposer queue OK" |