Compare commits
10 Commits
chore/fix-
...
bot/anno-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bbee4f74 | ||
| 04cdf54eb7 | |||
|
|
d6bf645ae9 | ||
| 1ca6bcbd81 | |||
| dec5f8eba7 | |||
| 716c887045 | |||
| 9b1789a164 | |||
| 17fa39c7ff | |||
| 8132e315f4 | |||
| 8d993915d7 |
@@ -17,7 +17,7 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Derive context (event.json / workflow_dispatch)
|
- name: Derive context (event.json / workflow_dispatch)
|
||||||
env:
|
env:
|
||||||
INPUT_ISSUE: ${{ inputs.issue }}
|
INPUT_ISSUE: ${{ inputs.issue }}
|
||||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export EVENT_JSON="/var/run/act/workflow/event.json"
|
export EVENT_JSON="/var/run/act/workflow/event.json"
|
||||||
@@ -52,15 +52,18 @@ jobs:
|
|||||||
const cloneUrl =
|
const cloneUrl =
|
||||||
repoObj?.clone_url ||
|
repoObj?.clone_url ||
|
||||||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
||||||
|
|
||||||
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
|
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
|
||||||
|
|
||||||
let owner =
|
let owner =
|
||||||
repoObj?.owner?.login ||
|
repoObj?.owner?.login ||
|
||||||
repoObj?.owner?.username ||
|
repoObj?.owner?.username ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
||||||
|
|
||||||
let repo =
|
let repo =
|
||||||
repoObj?.name ||
|
repoObj?.name ||
|
||||||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
||||||
|
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
||||||
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
|
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
|
||||||
@@ -73,15 +76,17 @@ jobs:
|
|||||||
ev?.issue?.number ||
|
ev?.issue?.number ||
|
||||||
ev?.issue?.index ||
|
ev?.issue?.index ||
|
||||||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
|
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
|
||||||
|
|
||||||
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||||
throw new Error("No issue number in event.json or workflow_dispatch input");
|
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||||
}
|
}
|
||||||
|
|
||||||
// label triggered (best effort; may be missing depending on Gitea payload)
|
// label name: best-effort (non-bloquant)
|
||||||
const labelName =
|
let labelName = "workflow_dispatch";
|
||||||
ev?.label?.name ||
|
const lab = ev?.label;
|
||||||
(typeof ev?.label === "string" ? 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 u = new URL(cloneUrl);
|
||||||
const origin = u.origin;
|
const origin = u.origin;
|
||||||
@@ -90,7 +95,7 @@ jobs:
|
|||||||
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
|
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
|
||||||
: origin;
|
: origin;
|
||||||
|
|
||||||
function sh(s){ return JSON.stringify(String(s ?? "")); }
|
function sh(s){ return JSON.stringify(String(s)); }
|
||||||
|
|
||||||
process.stdout.write([
|
process.stdout.write([
|
||||||
`CLONE_URL=${sh(cloneUrl)}`,
|
`CLONE_URL=${sh(cloneUrl)}`,
|
||||||
@@ -99,47 +104,32 @@ jobs:
|
|||||||
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
|
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
|
||||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||||
`LABEL_NAME=${sh(labelName)}`,
|
`LABEL_NAME=${sh(labelName)}`,
|
||||||
`API_BASE=${sh(apiBase)}`,
|
`API_BASE=${sh(apiBase)}`
|
||||||
|
|
||||||
// init safe defaults (avoid "unbound variable" cascades)
|
|
||||||
`SKIP=0`,
|
|
||||||
`SKIP_REASON=${sh("")}`,
|
|
||||||
`ISSUE_TYPE=${sh("")}`,
|
|
||||||
`ISSUE_TITLE=${sh("")}`,
|
|
||||||
`APPLY_RC=${sh("")}`,
|
|
||||||
`NOOP=0`,
|
|
||||||
`BRANCH=${sh("")}`,
|
|
||||||
`END_SHA=${sh("")}`
|
|
||||||
].join("\n") + "\n");
|
].join("\n") + "\n");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "✅ context:"
|
echo "✅ context:"
|
||||||
sed -n '1,160p' /tmp/anno.env
|
sed -n '1,120p' /tmp/anno.env
|
||||||
|
|
||||||
- name: Gate fast (only if label is state/approved or workflow_dispatch)
|
- name: Early gate (label event fast-skip, but tolerant)
|
||||||
env:
|
|
||||||
INPUT_ISSUE: ${{ inputs.issue }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
source /tmp/anno.env
|
||||||
|
|
||||||
# workflow_dispatch => allow
|
echo "ℹ️ event label = $LABEL_NAME"
|
||||||
if [[ -n "${INPUT_ISSUE:-}" ]]; then
|
|
||||||
echo "✅ workflow_dispatch => proceed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# if payload provides the triggering label, we can skip without API call
|
# Fast skip on obvious non-approved label events (avoid noise),
|
||||||
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
|
# BUT do NOT skip if label payload is weird/unknown.
|
||||||
echo "ℹ️ triggering label='$LABEL_NAME' (not state/approved) => skip"
|
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=1" >> /tmp/anno.env
|
||||||
echo "SKIP_REASON=\"trigger_label_not_approved\"" >> /tmp/anno.env
|
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "ℹ️ label unknown or approved => continue to API gating"
|
echo "✅ continue to API gating (issue=$ISSUE_NUMBER)"
|
||||||
|
|
||||||
- name: Fetch issue + gate on state/approved + gate on Type (skip Proposer)
|
- name: Fetch issue + hard gate on labels + Type
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -153,18 +143,16 @@ jobs:
|
|||||||
-H "Authorization: token $FORGE_TOKEN" \
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||||
> /tmp/issue.json
|
-o /tmp/issue.json
|
||||||
|
|
||||||
node --input-type=module - <<'NODE' >> /tmp/anno.env
|
node --input-type=module - <<'NODE' >> /tmp/anno.env
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
||||||
const title = String(issue.title || "");
|
const title = String(issue.title || "");
|
||||||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
const labels = Array.isArray(issue.labels)
|
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
|
||||||
? issue.labels.map(l => String(l?.name || "")).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const hasApproved = labels.includes("state/approved");
|
const hasApproved = labels.includes("state/approved");
|
||||||
|
|
||||||
function pickLine(key) {
|
function pickLine(key) {
|
||||||
@@ -183,11 +171,15 @@ jobs:
|
|||||||
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
|
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
|
||||||
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
|
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
|
||||||
|
|
||||||
// main gate: only act if state/approved is actually present on the issue
|
// HARD gate: must currently have state/approved (avoids depending on event payload)
|
||||||
if (!hasApproved) {
|
if (!hasApproved) {
|
||||||
out.push(`SKIP=1`);
|
out.push(`SKIP=1`);
|
||||||
out.push(`SKIP_REASON=${JSON.stringify("no_state_approved")}`);
|
out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`);
|
||||||
} else if (!type) {
|
process.stdout.write(out.join("\n") + "\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
out.push(`SKIP=1`);
|
out.push(`SKIP=1`);
|
||||||
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
||||||
} else if (allowed.has(type)) {
|
} else if (allowed.has(type)) {
|
||||||
@@ -203,10 +195,10 @@ jobs:
|
|||||||
process.stdout.write(out.join("\n") + "\n");
|
process.stdout.write(out.join("\n") + "\n");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "✅ issue gating:"
|
echo "✅ gating result:"
|
||||||
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
|
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
|
||||||
|
|
||||||
- name: Comment issue if skipped (only when state/approved was present)
|
- name: Comment issue if skipped (Proposer / unsupported / missing Type)
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
@@ -215,7 +207,12 @@ jobs:
|
|||||||
source /tmp/anno.env || true
|
source /tmp/anno.env || true
|
||||||
|
|
||||||
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
||||||
[[ "${SKIP_REASON:-}" != "no_state_approved" ]] || exit 0 # do not comment on normal label churn
|
|
||||||
|
# IMPORTANT: do NOT comment for "not_approved_label_present" (avoid spam on other label events)
|
||||||
|
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
|
||||||
|
echo "ℹ️ skip reason=${SKIP_REASON} -> no comment"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
test -n "${FORGE_TOKEN:-}" || exit 0
|
test -n "${FORGE_TOKEN:-}" || exit 0
|
||||||
|
|
||||||
@@ -223,11 +220,11 @@ jobs:
|
|||||||
TYPE="${ISSUE_TYPE:-}"
|
TYPE="${ISSUE_TYPE:-}"
|
||||||
|
|
||||||
if [[ "$REASON" == proposer_type:* ]]; then
|
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.\n\n✅ Action : traitement éditorial manuel."
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors**.\n✅ Aucun traitement automatique."
|
||||||
elif [[ "$REASON" == unsupported_type:* ]]; then
|
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."
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment."
|
||||||
else
|
else
|
||||||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (Type: type/media|type/reference|type/comment) ou traiter manuellement."
|
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: type/media|type/reference|type/comment"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||||
@@ -294,6 +291,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
source /tmp/anno.env
|
||||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${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; }
|
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
|
||||||
|
|
||||||
@@ -303,7 +301,7 @@ jobs:
|
|||||||
START_SHA="$(git rev-parse HEAD)"
|
START_SHA="$(git rev-parse HEAD)"
|
||||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
|
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
|
||||||
echo "BRANCH=\"$BR\"" >> /tmp/anno.env
|
echo "BRANCH=$BR" >> /tmp/anno.env
|
||||||
git checkout -b "$BR"
|
git checkout -b "$BR"
|
||||||
|
|
||||||
export FORGE_API="$API_BASE"
|
export FORGE_API="$API_BASE"
|
||||||
@@ -316,13 +314,12 @@ jobs:
|
|||||||
RC=$?
|
RC=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env
|
echo "APPLY_RC=$RC" >> /tmp/anno.env
|
||||||
|
|
||||||
echo "== apply log (tail) =="
|
echo "== apply log (tail) =="
|
||||||
tail -n 180 "$LOG" || true
|
tail -n 180 "$LOG" || true
|
||||||
|
|
||||||
END_SHA="$(git rev-parse HEAD)"
|
END_SHA="$(git rev-parse HEAD)"
|
||||||
echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env
|
|
||||||
|
|
||||||
if [[ "$RC" -ne 0 ]]; then
|
if [[ "$RC" -ne 0 ]]; then
|
||||||
echo "NOOP=0" >> /tmp/anno.env
|
echo "NOOP=0" >> /tmp/anno.env
|
||||||
@@ -333,6 +330,7 @@ jobs:
|
|||||||
echo "NOOP=1" >> /tmp/anno.env
|
echo "NOOP=1" >> /tmp/anno.env
|
||||||
else
|
else
|
||||||
echo "NOOP=0" >> /tmp/anno.env
|
echo "NOOP=0" >> /tmp/anno.env
|
||||||
|
echo "END_SHA=$END_SHA" >> /tmp/anno.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Comment issue on failure (strict/verify/etc)
|
- name: Comment issue on failure (strict/verify/etc)
|
||||||
@@ -344,9 +342,13 @@ jobs:
|
|||||||
source /tmp/anno.env || true
|
source /tmp/anno.env || true
|
||||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
RC="${APPLY_RC:-}"
|
RC="${APPLY_RC:-0}"
|
||||||
[[ -n "$RC" ]] || { echo "ℹ️ apply not executed"; exit 0; }
|
if [[ "$RC" == "0" ]]; then
|
||||||
[[ "$RC" != "0" ]] || { echo "ℹ️ no failure detected"; exit 0; }
|
echo "ℹ️ no failure detected"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
test -n "${FORGE_TOKEN:-}" || exit 0
|
||||||
|
|
||||||
if [[ -f /tmp/apply.log ]]; then
|
if [[ -f /tmp/apply.log ]]; then
|
||||||
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
|
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
|
||||||
@@ -363,28 +365,6 @@ jobs:
|
|||||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||||
--data-binary "$PAYLOAD"
|
--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
|
|
||||||
|
|
||||||
RC="${APPLY_RC:-}"
|
|
||||||
[[ "$RC" == "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
|
- name: Push bot branch
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
env:
|
env:
|
||||||
@@ -394,9 +374,9 @@ jobs:
|
|||||||
source /tmp/anno.env || true
|
source /tmp/anno.env || true
|
||||||
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
||||||
|
|
||||||
[[ "${APPLY_RC:-}" == "0" ]] || { echo "ℹ️ apply not ok -> skip push"; exit 0; }
|
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip push"; exit 0; }
|
||||||
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; }
|
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; }
|
||||||
[[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> skip push"; exit 0; }
|
test -d .git || { echo "ℹ️ no git repo -> skip push"; exit 0; }
|
||||||
|
|
||||||
AUTH_URL="$(node --input-type=module -e '
|
AUTH_URL="$(node --input-type=module -e '
|
||||||
const [clone, tok] = process.argv.slice(1);
|
const [clone, tok] = process.argv.slice(1);
|
||||||
@@ -418,10 +398,8 @@ jobs:
|
|||||||
source /tmp/anno.env || true
|
source /tmp/anno.env || true
|
||||||
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
||||||
|
|
||||||
[[ "${APPLY_RC:-}" == "0" ]] || { echo "ℹ️ apply not ok -> skip PR"; exit 0; }
|
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip PR"; exit 0; }
|
||||||
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; }
|
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; }
|
||||||
[[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> skip PR"; exit 0; }
|
|
||||||
[[ -n "${END_SHA:-}" ]] || { echo "ℹ️ missing END_SHA -> skip PR"; exit 0; }
|
|
||||||
|
|
||||||
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
|
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_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
|
||||||
@@ -463,9 +441,7 @@ jobs:
|
|||||||
|
|
||||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
RC="${APPLY_RC:-}"
|
RC="${APPLY_RC:-0}"
|
||||||
[[ -n "$RC" ]] || { echo "❌ apply did not run"; exit 2; }
|
|
||||||
|
|
||||||
if [[ "$RC" != "0" ]]; then
|
if [[ "$RC" != "0" ]]; then
|
||||||
echo "❌ apply failed (rc=$RC)"
|
echo "❌ apply failed (rc=$RC)"
|
||||||
exit "$RC"
|
exit "$RC"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Derive context (event.json / workflow_dispatch)
|
- name: Derive context (event.json / workflow_dispatch)
|
||||||
env:
|
env:
|
||||||
INPUT_ISSUE: ${{ inputs.issue }}
|
INPUT_ISSUE: ${{ inputs.issue }}
|
||||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export EVENT_JSON="/var/run/act/workflow/event.json"
|
export EVENT_JSON="/var/run/act/workflow/event.json"
|
||||||
@@ -75,10 +75,11 @@ jobs:
|
|||||||
throw new Error("No issue number in event.json or workflow_dispatch input");
|
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelName =
|
// label name: best-effort (non-bloquant)
|
||||||
ev?.label?.name ||
|
let labelName = "workflow_dispatch";
|
||||||
(typeof ev?.label === "string" ? ev.label : "") ||
|
const lab = ev?.label;
|
||||||
"";
|
if (typeof lab === "string") labelName = lab;
|
||||||
|
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name;
|
||||||
|
|
||||||
let apiBase = "";
|
let apiBase = "";
|
||||||
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
|
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
|
||||||
@@ -89,42 +90,34 @@ jobs:
|
|||||||
apiBase = "";
|
apiBase = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sh(s){ return JSON.stringify(String(s ?? "")); }
|
function sh(s){ return JSON.stringify(String(s)); }
|
||||||
|
|
||||||
process.stdout.write([
|
process.stdout.write([
|
||||||
`OWNER=${sh(owner)}`,
|
`OWNER=${sh(owner)}`,
|
||||||
`REPO=${sh(repo)}`,
|
`REPO=${sh(repo)}`,
|
||||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||||
`LABEL_NAME=${sh(labelName)}`,
|
`LABEL_NAME=${sh(labelName)}`,
|
||||||
`API_BASE=${sh(apiBase)}`,
|
`API_BASE=${sh(apiBase)}`
|
||||||
`SKIP=0`
|
|
||||||
].join("\n") + "\n");
|
].join("\n") + "\n");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "✅ context:"
|
echo "✅ context:"
|
||||||
sed -n '1,160p' /tmp/reject.env
|
sed -n '1,120p' /tmp/reject.env
|
||||||
|
|
||||||
- name: Gate fast (only if label is state/rejected or workflow_dispatch)
|
- name: Early gate (fast-skip, tolerant)
|
||||||
env:
|
|
||||||
INPUT_ISSUE: ${{ inputs.issue }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/reject.env
|
source /tmp/reject.env
|
||||||
|
echo "ℹ️ event label = $LABEL_NAME"
|
||||||
|
|
||||||
if [[ -n "${INPUT_ISSUE:-}" ]]; then
|
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
|
||||||
echo "✅ workflow_dispatch => proceed"
|
echo "ℹ️ label=$LABEL_NAME => skip early"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/rejected" ]]; then
|
|
||||||
echo "ℹ️ triggering label='$LABEL_NAME' (not state/rejected) => skip"
|
|
||||||
echo "SKIP=1" >> /tmp/reject.env
|
echo "SKIP=1" >> /tmp/reject.env
|
||||||
|
echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "ℹ️ label unknown or rejected => continue to API gating"
|
- name: Comment + close (only if label state/rejected is PRESENT now, and no conflict)
|
||||||
|
|
||||||
- name: Comment + close (only if issue has state/rejected; conflict-guard approved+rejected)
|
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -139,11 +132,11 @@ jobs:
|
|||||||
-H "Authorization: token $FORGE_TOKEN" \
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||||
> /tmp/issue.json
|
-o /tmp/reject.issue.json
|
||||||
|
|
||||||
node --input-type=module - <<'NODE' > /tmp/reject.flags
|
node --input-type=module - <<'NODE' > /tmp/reject.flags
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
const issue = JSON.parse(fs.readFileSync("/tmp/reject.issue.json","utf8"));
|
||||||
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
|
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
|
||||||
const hasApproved = labels.includes("state/approved");
|
const hasApproved = labels.includes("state/approved");
|
||||||
const hasRejected = labels.includes("state/rejected");
|
const hasRejected = labels.includes("state/rejected");
|
||||||
@@ -152,9 +145,9 @@ jobs:
|
|||||||
|
|
||||||
source /tmp/reject.flags
|
source /tmp/reject.flags
|
||||||
|
|
||||||
# If issue does not actually have state/rejected -> do nothing (normal label churn)
|
# Do nothing unless state/rejected is truly present now (anti payload weird)
|
||||||
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
|
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
|
||||||
echo "ℹ️ issue has no state/rejected => skip"
|
echo "ℹ️ state/rejected not present -> skip"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,37 @@ on:
|
|||||||
issues:
|
issues:
|
||||||
types: [opened, edited]
|
types: [opened, edited]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: auto-label-${{ github.event.issue.number || github.event.issue.index || 'manual' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
runs-on: mac-ci
|
runs-on: mac-ci
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Apply labels from Type/State/Category
|
- name: Apply labels from Type/State/Category
|
||||||
env:
|
env:
|
||||||
FORGE_BASE: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
# IMPORTANT: préfère FORGE_BASE (LAN) si défini, sinon FORGE_API
|
||||||
|
FORGE_BASE: ${{ vars.FORGE_BASE || vars.FORGE_API || vars.FORGE_API_BASE }}
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
REPO_FULL: ${{ gitea.repository }}
|
REPO_FULL: ${{ gitea.repository }}
|
||||||
EVENT_PATH: ${{ github.event_path }}
|
EVENT_PATH: ${{ github.event_path }}
|
||||||
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
run: |
|
run: |
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json, os, re, urllib.request, urllib.error
|
import json, os, re, time, urllib.request, urllib.error, socket
|
||||||
|
|
||||||
|
forge = (os.environ.get("FORGE_BASE") or "").rstrip("/")
|
||||||
|
if not forge:
|
||||||
|
raise SystemExit("Missing FORGE_BASE/FORGE_API repo variable (e.g. http://192.168.1.20:3000)")
|
||||||
|
|
||||||
|
token = os.environ.get("FORGE_TOKEN") or ""
|
||||||
|
if not token:
|
||||||
|
raise SystemExit("Missing secret FORGE_TOKEN")
|
||||||
|
|
||||||
forge = os.environ["FORGE_BASE"].rstrip("/")
|
|
||||||
token = os.environ["FORGE_TOKEN"]
|
|
||||||
owner, repo = os.environ["REPO_FULL"].split("/", 1)
|
owner, repo = os.environ["REPO_FULL"].split("/", 1)
|
||||||
event_path = os.environ["EVENT_PATH"]
|
event_path = os.environ["EVENT_PATH"]
|
||||||
|
|
||||||
@@ -46,12 +61,9 @@ jobs:
|
|||||||
print("PARSED:", {"Type": t, "State": s, "Category": c})
|
print("PARSED:", {"Type": t, "State": s, "Category": c})
|
||||||
|
|
||||||
# 1) explicite depuis le body
|
# 1) explicite depuis le body
|
||||||
if t:
|
if t: desired.add(t)
|
||||||
desired.add(t)
|
if s: desired.add(s)
|
||||||
if s:
|
if c: desired.add(c)
|
||||||
desired.add(s)
|
|
||||||
if c:
|
|
||||||
desired.add(c)
|
|
||||||
|
|
||||||
# 2) fallback depuis le titre si Type absent
|
# 2) fallback depuis le titre si Type absent
|
||||||
if not t:
|
if not t:
|
||||||
@@ -76,42 +88,56 @@ jobs:
|
|||||||
"Authorization": f"token {token}",
|
"Authorization": f"token {token}",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": "archicratie-auto-label/1.0",
|
"User-Agent": "archicratie-auto-label/1.1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def jreq(method, url, payload=None):
|
def jreq(method, url, payload=None, timeout=60, retries=4, backoff=2.0):
|
||||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
last_err = None
|
||||||
|
for i in range(retries):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=20) as r:
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
b = r.read()
|
b = r.read()
|
||||||
return json.loads(b.decode("utf-8")) if b else None
|
return json.loads(b.decode("utf-8")) if b else None
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
b = e.read().decode("utf-8", errors="replace")
|
b = e.read().decode("utf-8", errors="replace")
|
||||||
raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
|
raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
|
||||||
|
except (TimeoutError, socket.timeout, urllib.error.URLError) as e:
|
||||||
|
last_err = e
|
||||||
|
# retry only on network/timeout
|
||||||
|
time.sleep(backoff * (i + 1))
|
||||||
|
raise RuntimeError(f"Network/timeout after retries: {method} {url}\n{last_err}")
|
||||||
|
|
||||||
# labels repo
|
# labels repo
|
||||||
labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000") or []
|
labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000", timeout=60) or []
|
||||||
name_to_id = {x.get("name"): x.get("id") for x in labels}
|
name_to_id = {x.get("name"): x.get("id") for x in labels}
|
||||||
|
|
||||||
missing = [x for x in desired if x not in name_to_id]
|
missing = [x for x in desired if x not in name_to_id]
|
||||||
if missing:
|
if missing:
|
||||||
raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing)))
|
raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
wanted_ids = [name_to_id[x] for x in desired]
|
wanted_ids = sorted({int(name_to_id[x]) for x in desired})
|
||||||
|
|
||||||
# labels actuels de l'issue
|
# labels actuels de l'issue
|
||||||
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels") or []
|
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
|
||||||
current_ids = {x.get("id") for x in current if x.get("id") is not None}
|
current_ids = {int(x.get("id")) for x in current if x.get("id") is not None}
|
||||||
|
|
||||||
final_ids = sorted(current_ids.union(wanted_ids))
|
final_ids = sorted(current_ids.union(wanted_ids))
|
||||||
|
|
||||||
# set labels = union (n'enlève rien)
|
# Replace labels = union (n'enlève rien)
|
||||||
url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels"
|
url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels"
|
||||||
try:
|
|
||||||
jreq("PUT", url, {"labels": final_ids})
|
# IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422)
|
||||||
except Exception:
|
jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4)
|
||||||
jreq("PUT", url, final_ids)
|
|
||||||
|
# vérif post-apply (anti "timeout mais appliqué")
|
||||||
|
post = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
|
||||||
|
post_ids = {int(x.get("id")) for x in post if x.get("id") is not None}
|
||||||
|
|
||||||
|
missing_ids = [i for i in wanted_ids if i not in post_ids]
|
||||||
|
if missing_ids:
|
||||||
|
raise RuntimeError(f"Labels not applied after PUT (missing ids): {missing_ids}")
|
||||||
|
|
||||||
print(f"OK labels #{number}: {sorted(desired)}")
|
print(f"OK labels #{number}: {sorted(desired)}")
|
||||||
PY
|
PY
|
||||||
@@ -10,3 +10,9 @@ paras:
|
|||||||
credit: ""
|
credit: ""
|
||||||
ts: 2026-02-27T12:43:14.259Z
|
ts: 2026-02-27T12:43:14.259Z
|
||||||
fromIssue: 144
|
fromIssue: 144
|
||||||
|
refs:
|
||||||
|
- url: https://gitea.archicratie.trans-hands.synology.me
|
||||||
|
label: Gitea
|
||||||
|
kind: (livre / article / vidéo / site / autre) Site
|
||||||
|
ts: 2026-03-02T19:53:21.252Z
|
||||||
|
fromIssue: 169
|
||||||
|
|||||||
10
src/annotations/archicrat-ia/chapitre-3/p-1-60c7ea48.yml
Normal file
10
src/annotations/archicrat-ia/chapitre-3/p-1-60c7ea48.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
schema: 1
|
||||||
|
page: archicrat-ia/chapitre-3
|
||||||
|
paras:
|
||||||
|
p-1-60c7ea48:
|
||||||
|
refs:
|
||||||
|
- url: https://gitea.archicratie.trans-hands.synology.me
|
||||||
|
label: Gitea
|
||||||
|
kind: (livre / article / vidéo / site / autre) Site
|
||||||
|
ts: 2026-03-02T20:01:55.858Z
|
||||||
|
fromIssue: 172
|
||||||
Reference in New Issue
Block a user