241 lines
6.4 KiB
JavaScript
241 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
import process from "node:process";
|
|
|
|
function getEnv(name, fallback = "") {
|
|
return String(process.env[name] ?? fallback).trim();
|
|
}
|
|
|
|
function sh(value) {
|
|
return JSON.stringify(String(value ?? ""));
|
|
}
|
|
|
|
function escapeRegExp(s) {
|
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function pickLine(body, key) {
|
|
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
|
const m = String(body || "").match(re);
|
|
return m ? m[1].trim() : "";
|
|
}
|
|
|
|
function pickHeadingValue(body, headingKey) {
|
|
const re = new RegExp(
|
|
`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
|
|
"mi"
|
|
);
|
|
const m = String(body || "").match(re);
|
|
if (!m) return "";
|
|
const lines = m[1].split(/\r?\n/).map((l) => l.trim());
|
|
for (const l of lines) {
|
|
if (!l) continue;
|
|
if (l.startsWith("<!--")) continue;
|
|
return l.replace(/^\/?/, "/").trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function normalizeChemin(chemin) {
|
|
let c = String(chemin || "").trim();
|
|
if (!c) return "";
|
|
if (!c.startsWith("/")) c = "/" + c;
|
|
if (!c.endsWith("/")) c += "/";
|
|
return c;
|
|
}
|
|
|
|
function extractCheminFromAnyUrl(text) {
|
|
const s = String(text || "");
|
|
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
|
|
return m ? m[1] : "";
|
|
}
|
|
|
|
function inferType(issue) {
|
|
const title = String(issue?.title || "");
|
|
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
|
|
const fromBody = String(pickLine(body, "Type") || "").trim().toLowerCase();
|
|
if (fromBody) return fromBody;
|
|
|
|
if (title.startsWith("[Correction]")) return "type/correction";
|
|
if (title.startsWith("[Fact-check]") || title.startsWith("[Vérification]")) return "type/fact-check";
|
|
return "";
|
|
}
|
|
|
|
function inferChemin(issue) {
|
|
const title = String(issue?.title || "");
|
|
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
|
|
|
|
return normalizeChemin(
|
|
pickLine(body, "Chemin") ||
|
|
pickHeadingValue(body, "Chemin") ||
|
|
extractCheminFromAnyUrl(body) ||
|
|
extractCheminFromAnyUrl(title)
|
|
);
|
|
}
|
|
|
|
function labelsOf(issue) {
|
|
return Array.isArray(issue?.labels)
|
|
? issue.labels.map((l) => String(l?.name || "")).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function issueNumber(issue) {
|
|
return Number(issue?.number || issue?.index || 0);
|
|
}
|
|
|
|
function parseMeta(issue) {
|
|
const labels = labelsOf(issue);
|
|
const type = inferType(issue);
|
|
const chemin = inferChemin(issue);
|
|
const number = issueNumber(issue);
|
|
|
|
const hasApproved = labels.includes("state/approved");
|
|
const hasRejected = labels.includes("state/rejected");
|
|
const isProposer = type === "type/correction" || type === "type/fact-check";
|
|
const isOpen = String(issue?.state || "open") === "open";
|
|
const isPR = Boolean(issue?.pull_request);
|
|
|
|
const eligible =
|
|
number > 0 &&
|
|
isOpen &&
|
|
!isPR &&
|
|
hasApproved &&
|
|
!hasRejected &&
|
|
isProposer &&
|
|
Boolean(chemin);
|
|
|
|
return {
|
|
issue,
|
|
number,
|
|
type,
|
|
chemin,
|
|
labels,
|
|
hasApproved,
|
|
hasRejected,
|
|
eligible,
|
|
};
|
|
}
|
|
|
|
async function fetchJson(url, token) {
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
Authorization: `token ${token}`,
|
|
Accept: "application/json",
|
|
"User-Agent": "archicratie-pick-proposer-issue/1.0",
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const t = await res.text().catch(() => "");
|
|
throw new Error(`HTTP ${res.status} ${url}\n${t}`);
|
|
}
|
|
return await res.json();
|
|
}
|
|
|
|
async function fetchIssue(apiBase, owner, repo, token, n) {
|
|
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues/${n}`;
|
|
return await fetchJson(url, token);
|
|
}
|
|
|
|
async function listOpenIssues(apiBase, owner, repo, token) {
|
|
const out = [];
|
|
let page = 1;
|
|
const limit = 100;
|
|
|
|
while (true) {
|
|
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues?state=open&page=${page}&limit=${limit}`;
|
|
const batch = await fetchJson(url, token);
|
|
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
out.push(...batch);
|
|
if (batch.length < limit) break;
|
|
page += 1;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function emitNone(reason) {
|
|
process.stdout.write(
|
|
[
|
|
`TARGET_FOUND="0"`,
|
|
`TARGET_REASON=${sh(reason)}`,
|
|
`TARGET_PRIMARY_ISSUE=""`,
|
|
`TARGET_ISSUES=""`,
|
|
`TARGET_COUNT="0"`,
|
|
`TARGET_CHEMIN=""`,
|
|
].join("\n") + "\n"
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const token = getEnv("FORGE_TOKEN");
|
|
const owner = getEnv("GITEA_OWNER");
|
|
const repo = getEnv("GITEA_REPO");
|
|
const apiBase = (getEnv("FORGE_API") || getEnv("FORGE_BASE")).replace(/\/+$/, "");
|
|
const explicit = Number(process.argv[2] || 0);
|
|
|
|
if (!token) throw new Error("Missing FORGE_TOKEN");
|
|
if (!owner || !repo) throw new Error("Missing GITEA_OWNER / GITEA_REPO");
|
|
if (!apiBase) throw new Error("Missing FORGE_API / FORGE_BASE");
|
|
|
|
let metas = [];
|
|
|
|
if (explicit > 0) {
|
|
const issue = await fetchIssue(apiBase, owner, repo, token, explicit);
|
|
const meta = parseMeta(issue);
|
|
|
|
if (!meta.eligible) {
|
|
emitNone(
|
|
!meta.hasApproved
|
|
? "explicit_issue_not_approved"
|
|
: meta.hasRejected
|
|
? "explicit_issue_rejected"
|
|
: !meta.type
|
|
? "explicit_issue_missing_type"
|
|
: !meta.chemin
|
|
? "explicit_issue_missing_chemin"
|
|
: "explicit_issue_not_eligible"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
|
|
metas = openIssues.map(parseMeta).filter((m) => m.eligible && m.chemin === meta.chemin);
|
|
} else {
|
|
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
|
|
metas = openIssues.map(parseMeta).filter((m) => m.eligible);
|
|
|
|
if (metas.length === 0) {
|
|
emitNone("no_open_approved_proposer_issue");
|
|
return;
|
|
}
|
|
|
|
metas.sort((a, b) => a.number - b.number);
|
|
const first = metas[0];
|
|
metas = metas.filter((m) => m.chemin === first.chemin);
|
|
}
|
|
|
|
metas.sort((a, b) => a.number - b.number);
|
|
|
|
if (metas.length === 0) {
|
|
emitNone("no_batch_for_path");
|
|
return;
|
|
}
|
|
|
|
const primary = metas[0];
|
|
const issues = metas.map((m) => String(m.number));
|
|
|
|
process.stdout.write(
|
|
[
|
|
`TARGET_FOUND="1"`,
|
|
`TARGET_REASON="ok"`,
|
|
`TARGET_PRIMARY_ISSUE=${sh(primary.number)}`,
|
|
`TARGET_ISSUES=${sh(issues.join(" "))}`,
|
|
`TARGET_COUNT=${sh(issues.length)}`,
|
|
`TARGET_CHEMIN=${sh(primary.chemin)}`,
|
|
].join("\n") + "\n"
|
|
);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("💥 pick-proposer-issue:", e?.message || e);
|
|
process.exit(1);
|
|
}); |