|
|
|
|
@@ -130,8 +130,6 @@ jobs:
|
|
|
|
|
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
|
|
|
|
|
@@ -218,6 +216,43 @@ jobs:
|
|
|
|
|
echo "Target batch:"
|
|
|
|
|
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
|
|
|
|
|
|
|
|
|
|
- name: Derive deterministic batch identity
|
|
|
|
|
run: |
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
source /tmp/proposer.env
|
|
|
|
|
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
|
|
|
|
|
|
|
|
|
|
export TARGET_ISSUES TARGET_CHEMIN
|
|
|
|
|
|
|
|
|
|
node --input-type=module - <<'NODE'
|
|
|
|
|
import fs from "node:fs";
|
|
|
|
|
import crypto from "node:crypto";
|
|
|
|
|
|
|
|
|
|
const issues = String(process.env.TARGET_ISSUES || "")
|
|
|
|
|
.trim()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.sort((a, b) => Number(a) - Number(b));
|
|
|
|
|
|
|
|
|
|
const chemin = String(process.env.TARGET_CHEMIN || "").trim();
|
|
|
|
|
const keySource = `${chemin}::${issues.join(",")}`;
|
|
|
|
|
const hash = crypto.createHash("sha1").update(keySource).digest("hex").slice(0, 12);
|
|
|
|
|
const primary = issues[0] || "0";
|
|
|
|
|
const batchBranch = `bot/proposer-${primary}-${hash}`;
|
|
|
|
|
|
|
|
|
|
fs.appendFileSync(
|
|
|
|
|
"/tmp/proposer.env",
|
|
|
|
|
[
|
|
|
|
|
`BATCH_KEY=${JSON.stringify(keySource)}`,
|
|
|
|
|
`BATCH_HASH=${JSON.stringify(hash)}`,
|
|
|
|
|
`BATCH_BRANCH=${JSON.stringify(batchBranch)}`
|
|
|
|
|
].join("\n") + "\n"
|
|
|
|
|
);
|
|
|
|
|
NODE
|
|
|
|
|
|
|
|
|
|
echo "Batch identity:"
|
|
|
|
|
grep -E '^(BATCH_KEY|BATCH_HASH|BATCH_BRANCH)=' /tmp/proposer.env
|
|
|
|
|
|
|
|
|
|
- name: Inspect open proposer PRs
|
|
|
|
|
env:
|
|
|
|
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
|
|
|
|
@@ -233,6 +268,8 @@ jobs:
|
|
|
|
|
-o /tmp/open_pulls.json
|
|
|
|
|
|
|
|
|
|
export TARGET_ISSUES="${TARGET_ISSUES:-}"
|
|
|
|
|
export BATCH_BRANCH="${BATCH_BRANCH:-}"
|
|
|
|
|
export BATCH_KEY="${BATCH_KEY:-}"
|
|
|
|
|
|
|
|
|
|
node --input-type=module - <<'NODE' >> /tmp/proposer.env
|
|
|
|
|
import fs from "node:fs";
|
|
|
|
|
@@ -243,15 +280,21 @@ jobs:
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
const batchBranch = String(process.env.BATCH_BRANCH || "");
|
|
|
|
|
const batchKey = String(process.env.BATCH_KEY || "");
|
|
|
|
|
|
|
|
|
|
const proposerOpen = Array.isArray(pulls)
|
|
|
|
|
? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const current = proposerOpen.find((pr) => {
|
|
|
|
|
const sameBatch = proposerOpen.find((pr) => {
|
|
|
|
|
const ref = String(pr?.head?.ref || "");
|
|
|
|
|
const title = String(pr?.title || "");
|
|
|
|
|
const body = String(pr?.body || "");
|
|
|
|
|
|
|
|
|
|
if (batchBranch && ref === batchBranch) return true;
|
|
|
|
|
if (batchKey && body.includes(`Batch-Key: ${batchKey}`)) return true;
|
|
|
|
|
|
|
|
|
|
return issues.some((n) =>
|
|
|
|
|
ref.startsWith(`bot/proposer-${n}-`) ||
|
|
|
|
|
title.includes(`#${n}`) ||
|
|
|
|
|
@@ -262,10 +305,11 @@ jobs:
|
|
|
|
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
|
|
|
|
if (current) {
|
|
|
|
|
if (sameBatch) {
|
|
|
|
|
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 || ""))}`);
|
|
|
|
|
out.push(`OPEN_PR_URL=${JSON.stringify(String(sameBatch.html_url || sameBatch.url || ""))}`);
|
|
|
|
|
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(sameBatch?.head?.ref || ""))}`);
|
|
|
|
|
} else if (proposerOpen.length > 0) {
|
|
|
|
|
const first = proposerOpen[0];
|
|
|
|
|
out.push("SKIP=1");
|
|
|
|
|
@@ -277,6 +321,22 @@ jobs:
|
|
|
|
|
process.stdout.write(out.join("\n") + (out.length ? "\n" : ""));
|
|
|
|
|
NODE
|
|
|
|
|
|
|
|
|
|
- name: Guard on remote batch branch before heavy work
|
|
|
|
|
run: |
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
source /tmp/proposer.env
|
|
|
|
|
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
|
|
|
|
|
|
|
|
|
|
if git ls-remote --exit-code --heads origin "$BATCH_BRANCH" >/dev/null 2>&1; then
|
|
|
|
|
echo 'SKIP=1' >> /tmp/proposer.env
|
|
|
|
|
echo 'SKIP_REASON="batch_branch_exists_without_pr"' >> /tmp/proposer.env
|
|
|
|
|
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
|
|
|
|
|
echo "Remote batch branch already exists -> skip duplicate materialization"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "Remote batch branch is free"
|
|
|
|
|
|
|
|
|
|
- name: Comment issue if queued / skipped
|
|
|
|
|
if: ${{ always() }}
|
|
|
|
|
env:
|
|
|
|
|
@@ -306,7 +366,13 @@ jobs:
|
|
|
|
|
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)"}"
|
|
|
|
|
MSG="This batch already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
|
|
|
|
|
;;
|
|
|
|
|
batch_branch_exists_without_pr)
|
|
|
|
|
MSG="This batch already has a remote batch branch (${OPEN_PR_BRANCH:-"(unknown branch)"}). Manual inspection is required before any new proposer PR is created."
|
|
|
|
|
;;
|
|
|
|
|
batch_branch_already_materialized)
|
|
|
|
|
MSG="This batch was already materialized by another run on branch ${OPEN_PR_BRANCH:-"(unknown branch)"}. No duplicate PR was created."
|
|
|
|
|
;;
|
|
|
|
|
explicit_issue_missing_chemin)
|
|
|
|
|
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
|
|
|
|
|
@@ -328,6 +394,7 @@ jobs:
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
export MSG
|
|
|
|
|
node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json
|
|
|
|
|
const msg = process.env.MSG || "";
|
|
|
|
|
process.stdout.write(JSON.stringify({ body: msg }));
|
|
|
|
|
@@ -384,8 +451,7 @@ jobs:
|
|
|
|
|
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}"
|
|
|
|
|
BR="$BATCH_BRANCH"
|
|
|
|
|
echo "BRANCH=$BR" >> /tmp/proposer.env
|
|
|
|
|
git checkout -b "$BR"
|
|
|
|
|
|
|
|
|
|
@@ -518,6 +584,27 @@ jobs:
|
|
|
|
|
--data-binary @/tmp/proposer.failure.comment.json || true
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
- name: Late guard against duplicate batch materialization
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
REMOTE_SHA="$(git ls-remote --heads origin "$BATCH_BRANCH" | awk 'NR==1 {print $1}')"
|
|
|
|
|
|
|
|
|
|
if [[ -n "${REMOTE_SHA:-}" && "${REMOTE_SHA}" != "${END_SHA:-}" ]]; then
|
|
|
|
|
echo 'SKIP=1' >> /tmp/proposer.env
|
|
|
|
|
echo 'SKIP_REASON="batch_branch_already_materialized"' >> /tmp/proposer.env
|
|
|
|
|
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
|
|
|
|
|
echo "Remote batch branch already exists at $REMOTE_SHA -> skip duplicate push/PR"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "Late guard OK"
|
|
|
|
|
|
|
|
|
|
- name: Push bot branch
|
|
|
|
|
if: ${{ always() }}
|
|
|
|
|
env:
|
|
|
|
|
@@ -555,46 +642,74 @@ jobs:
|
|
|
|
|
[[ "${NOOP:-0}" == "0" ]] || exit 0
|
|
|
|
|
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; }
|
|
|
|
|
|
|
|
|
|
test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; }
|
|
|
|
|
|
|
|
|
|
OPEN_PRS_JSON="$(curl -fsS \
|
|
|
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
|
|
|
-H "Accept: application/json" \
|
|
|
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100")"
|
|
|
|
|
|
|
|
|
|
export OPEN_PRS_JSON BATCH_BRANCH BATCH_KEY
|
|
|
|
|
|
|
|
|
|
EXISTING_PR_URL="$(node --input-type=module -e '
|
|
|
|
|
const pulls = JSON.parse(process.env.OPEN_PRS_JSON || "[]");
|
|
|
|
|
const branch = String(process.env.BATCH_BRANCH || "");
|
|
|
|
|
const key = String(process.env.BATCH_KEY || "");
|
|
|
|
|
const current = Array.isArray(pulls)
|
|
|
|
|
? pulls.find((pr) => {
|
|
|
|
|
const ref = String(pr?.head?.ref || "");
|
|
|
|
|
const body = String(pr?.body || "");
|
|
|
|
|
return (branch && ref === branch) || (key && body.includes(`Batch-Key: ${key}`));
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
process.stdout.write(current ? String(current.html_url || current.url || "") : "");
|
|
|
|
|
')"
|
|
|
|
|
|
|
|
|
|
if [[ -n "${EXISTING_PR_URL:-}" ]]; then
|
|
|
|
|
echo "PR already exists for this batch: $EXISTING_PR_URL"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
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 TITLE="$PR_TITLE"
|
|
|
|
|
export CHEMIN="$TARGET_CHEMIN"
|
|
|
|
|
export ISSUES="$TARGET_ISSUES"
|
|
|
|
|
export BRANCH="$BRANCH"
|
|
|
|
|
export END_SHA="${END_SHA:-unknown}"
|
|
|
|
|
export DEFAULT_BRANCH="$DEFAULT_BRANCH"
|
|
|
|
|
export OWNER="$OWNER"
|
|
|
|
|
export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER BATCH_KEY
|
|
|
|
|
|
|
|
|
|
node --input-type=module - <<'NODE' > /tmp/proposer.pr.json
|
|
|
|
|
const issues = String(process.env.ISSUES || "")
|
|
|
|
|
.trim()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
node --input-type=module -e '
|
|
|
|
|
import fs from "node:fs";
|
|
|
|
|
|
|
|
|
|
const body = [
|
|
|
|
|
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`,
|
|
|
|
|
"",
|
|
|
|
|
`- Chemin: ${process.env.CHEMIN || "(inconnu)"}`,
|
|
|
|
|
"- Tickets:",
|
|
|
|
|
...issues.map((n) => ` - #${n}`),
|
|
|
|
|
`- Branche: ${process.env.BRANCH || ""}`,
|
|
|
|
|
`- Commit: ${process.env.END_SHA || "unknown"}`,
|
|
|
|
|
"",
|
|
|
|
|
"Merge si CI OK."
|
|
|
|
|
].join("\n");
|
|
|
|
|
const issues = String(process.env.TARGET_ISSUES || "")
|
|
|
|
|
.trim()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
process.stdout.write(JSON.stringify({
|
|
|
|
|
title: process.env.TITLE || "proposer: apply tickets",
|
|
|
|
|
body,
|
|
|
|
|
base: process.env.DEFAULT_BRANCH || "main",
|
|
|
|
|
head: `${process.env.OWNER}:${process.env.BRANCH}`,
|
|
|
|
|
allow_maintainer_edit: true
|
|
|
|
|
}));
|
|
|
|
|
NODE
|
|
|
|
|
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"}`,
|
|
|
|
|
`- Batch-Key: ${process.env.BATCH_KEY || ""}`,
|
|
|
|
|
"",
|
|
|
|
|
"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
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
'
|
|
|
|
|
|
|
|
|
|
PR_JSON="$(curl -fsS -X POST \
|
|
|
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
|
|
|
@@ -602,10 +717,7 @@ jobs:
|
|
|
|
|
"$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")"
|
|
|
|
|
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"
|
|
|
|
|
@@ -614,15 +726,21 @@ jobs:
|
|
|
|
|
|
|
|
|
|
for ISSUE in $TARGET_ISSUES; do
|
|
|
|
|
export ISSUE PR_URL
|
|
|
|
|
node --input-type=module - <<'NODE' > /tmp/proposer.issue.close.comment.json
|
|
|
|
|
const issue = process.env.ISSUE || "";
|
|
|
|
|
const url = process.env.PR_URL || "";
|
|
|
|
|
const msg =
|
|
|
|
|
`PR proposer created for ticket #${issue}: ${url}\n\n` +
|
|
|
|
|
`The ticket is closed automatically. Discussion can continue in the PR.`;
|
|
|
|
|
|
|
|
|
|
process.stdout.write(JSON.stringify({ body: msg }));
|
|
|
|
|
NODE
|
|
|
|
|
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 creee pour le ticket #${issue} : ${url}\n\n` +
|
|
|
|
|
`Le ticket est cloture 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" \
|
|
|
|
|
@@ -635,6 +753,17 @@ jobs:
|
|
|
|
|
-H "Content-Type: application/json" \
|
|
|
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \
|
|
|
|
|
--data-binary '{"state":"closed"}'
|
|
|
|
|
|
|
|
|
|
ISSUE_STATE="$(curl -fsS \
|
|
|
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
|
|
|
-H "Accept: application/json" \
|
|
|
|
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" | \
|
|
|
|
|
node --input-type=module -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { const j = JSON.parse(s || "{}"); process.stdout.write(String(j.state || "")); });')"
|
|
|
|
|
|
|
|
|
|
[[ "$ISSUE_STATE" == "closed" ]] || {
|
|
|
|
|
echo "Issue #$ISSUE is still not closed after PATCH"
|
|
|
|
|
exit 1
|
|
|
|
|
}
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
echo "PR: $PR_URL"
|
|
|
|
|
|