700 lines
35 KiB
YAML
700 lines
35 KiB
YAML
name: PR Labeler
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
|
|
|
|
concurrency:
|
|
group: pr-labeler-${{ github.event.pull_request.number }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
issues: write
|
|
|
|
jobs:
|
|
label:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Apply path labels
|
|
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
|
continue-on-error: true
|
|
with:
|
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
sync-labels: true
|
|
|
|
- name: Apply size/risk/module labels
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
|
continue-on-error: true
|
|
with:
|
|
script: |
|
|
const pr = context.payload.pull_request;
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const action = context.payload.action;
|
|
const changedLabel = context.payload.label?.name;
|
|
|
|
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
|
const computedRiskLabels = ["risk: low", "risk: medium", "risk: high"];
|
|
const manualRiskOverrideLabel = "risk: manual";
|
|
const managedEnforcedLabels = new Set([
|
|
...sizeLabels,
|
|
manualRiskOverrideLabel,
|
|
...computedRiskLabels,
|
|
]);
|
|
const legacyTrustedContributorLabel = "trusted contributor";
|
|
|
|
if ((action === "labeled" || action === "unlabeled") && !managedEnforcedLabels.has(changedLabel)) {
|
|
core.info(`skip non-size/risk label event: ${changedLabel || "unknown"}`);
|
|
return;
|
|
}
|
|
|
|
const contributorTierRules = [
|
|
{ label: "distinguished contributor", minMergedPRs: 50 },
|
|
{ label: "principal contributor", minMergedPRs: 20 },
|
|
{ label: "experienced contributor", minMergedPRs: 10 },
|
|
];
|
|
const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
|
|
const contributorTierColor = "2ED9FF";
|
|
|
|
const managedPathLabels = [
|
|
"docs",
|
|
"dependencies",
|
|
"ci",
|
|
"core",
|
|
"agent",
|
|
"channel",
|
|
"config",
|
|
"cron",
|
|
"daemon",
|
|
"doctor",
|
|
"gateway",
|
|
"health",
|
|
"heartbeat",
|
|
"integration",
|
|
"memory",
|
|
"observability",
|
|
"onboard",
|
|
"provider",
|
|
"runtime",
|
|
"security",
|
|
"service",
|
|
"skillforge",
|
|
"skills",
|
|
"tool",
|
|
"tunnel",
|
|
"tests",
|
|
"scripts",
|
|
"dev",
|
|
];
|
|
const managedPathLabelSet = new Set(managedPathLabels);
|
|
|
|
const moduleNamespaceRules = [
|
|
{ root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/channels/", prefix: "channel", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/config/", prefix: "config", coreEntries: new Set(["mod.rs", "schema.rs"]) },
|
|
{ root: "src/cron/", prefix: "cron", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/daemon/", prefix: "daemon", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/doctor/", prefix: "doctor", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/gateway/", prefix: "gateway", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/health/", prefix: "health", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/heartbeat/", prefix: "heartbeat", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/integrations/", prefix: "integration", coreEntries: new Set(["mod.rs", "registry.rs"]) },
|
|
{ root: "src/memory/", prefix: "memory", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/observability/", prefix: "observability", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/onboard/", prefix: "onboard", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/providers/", prefix: "provider", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/runtime/", prefix: "runtime", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/security/", prefix: "security", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/service/", prefix: "service", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/skillforge/", prefix: "skillforge", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/skills/", prefix: "skills", coreEntries: new Set(["mod.rs"]) },
|
|
{ root: "src/tools/", prefix: "tool", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
|
{ root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) },
|
|
];
|
|
const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))];
|
|
const orderedOtherLabelStyles = [
|
|
{ label: "health", color: "8EC9B8" },
|
|
{ label: "tool", color: "7FC4B6" },
|
|
{ label: "agent", color: "86C4A2" },
|
|
{ label: "memory", color: "8FCB99" },
|
|
{ label: "channel", color: "7EB6F2" },
|
|
{ label: "service", color: "95C7B6" },
|
|
{ label: "integration", color: "8DC9AE" },
|
|
{ label: "tunnel", color: "9FC8B3" },
|
|
{ label: "config", color: "AABCD0" },
|
|
{ label: "observability", color: "84C9D0" },
|
|
{ label: "docs", color: "8FBBE0" },
|
|
{ label: "dev", color: "B9C1CC" },
|
|
{ label: "tests", color: "9DC8C7" },
|
|
{ label: "skills", color: "BFC89B" },
|
|
{ label: "skillforge", color: "C9C39B" },
|
|
{ label: "provider", color: "958DF0" },
|
|
{ label: "runtime", color: "A3ADD8" },
|
|
{ label: "heartbeat", color: "C0C88D" },
|
|
{ label: "daemon", color: "C8C498" },
|
|
{ label: "doctor", color: "C1CF9D" },
|
|
{ label: "onboard", color: "D2BF86" },
|
|
{ label: "cron", color: "D2B490" },
|
|
{ label: "ci", color: "AEB4CE" },
|
|
{ label: "dependencies", color: "9FB1DE" },
|
|
{ label: "gateway", color: "B5A8E5" },
|
|
{ label: "security", color: "E58D85" },
|
|
{ label: "core", color: "C8A99B" },
|
|
{ label: "scripts", color: "C9B49F" },
|
|
];
|
|
const otherLabelDisplayOrder = orderedOtherLabelStyles.map((entry) => entry.label);
|
|
const modulePrefixSet = new Set(moduleNamespaceRules.map((rule) => rule.prefix));
|
|
const modulePrefixPriority = otherLabelDisplayOrder.filter((label) => modulePrefixSet.has(label));
|
|
const pathLabelPriority = [...otherLabelDisplayOrder];
|
|
const riskDisplayOrder = ["risk: high", "risk: medium", "risk: low", "risk: manual"];
|
|
const sizeDisplayOrder = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
|
const contributorDisplayOrder = [
|
|
"distinguished contributor",
|
|
"principal contributor",
|
|
"experienced contributor",
|
|
];
|
|
const modulePrefixPriorityIndex = new Map(
|
|
modulePrefixPriority.map((prefix, index) => [prefix, index])
|
|
);
|
|
const pathLabelPriorityIndex = new Map(
|
|
pathLabelPriority.map((label, index) => [label, index])
|
|
);
|
|
const riskPriorityIndex = new Map(
|
|
riskDisplayOrder.map((label, index) => [label, index])
|
|
);
|
|
const sizePriorityIndex = new Map(
|
|
sizeDisplayOrder.map((label, index) => [label, index])
|
|
);
|
|
const contributorPriorityIndex = new Map(
|
|
contributorDisplayOrder.map((label, index) => [label, index])
|
|
);
|
|
|
|
const otherLabelColors = Object.fromEntries(
|
|
orderedOtherLabelStyles.map((entry) => [entry.label, entry.color])
|
|
);
|
|
const staticLabelColors = {
|
|
"size: XS": "E7CDD3",
|
|
"size: S": "E1BEC7",
|
|
"size: M": "DBB0BB",
|
|
"size: L": "D4A2AF",
|
|
"size: XL": "CE94A4",
|
|
"risk: low": "97D3A6",
|
|
"risk: medium": "E4C47B",
|
|
"risk: high": "E98E88",
|
|
"risk: manual": "B7A4E0",
|
|
...otherLabelColors,
|
|
};
|
|
const staticLabelDescriptions = {
|
|
"size: XS": "Auto size: <=80 non-doc changed lines.",
|
|
"size: S": "Auto size: 81-250 non-doc changed lines.",
|
|
"size: M": "Auto size: 251-500 non-doc changed lines.",
|
|
"size: L": "Auto size: 501-1000 non-doc changed lines.",
|
|
"size: XL": "Auto size: >1000 non-doc changed lines.",
|
|
"risk: low": "Auto risk: docs/chore-only paths.",
|
|
"risk: medium": "Auto risk: src/** or dependency/config changes.",
|
|
"risk: high": "Auto risk: security/runtime/gateway/tools/workflows.",
|
|
"risk: manual": "Maintainer override: keep selected risk label.",
|
|
docs: "Auto scope: docs/markdown/template files changed.",
|
|
dependencies: "Auto scope: dependency manifest/lock/policy changed.",
|
|
ci: "Auto scope: CI/workflow/hook files changed.",
|
|
core: "Auto scope: root src/*.rs files changed.",
|
|
agent: "Auto scope: src/agent/** changed.",
|
|
channel: "Auto scope: src/channels/** changed.",
|
|
config: "Auto scope: src/config/** changed.",
|
|
cron: "Auto scope: src/cron/** changed.",
|
|
daemon: "Auto scope: src/daemon/** changed.",
|
|
doctor: "Auto scope: src/doctor/** changed.",
|
|
gateway: "Auto scope: src/gateway/** changed.",
|
|
health: "Auto scope: src/health/** changed.",
|
|
heartbeat: "Auto scope: src/heartbeat/** changed.",
|
|
integration: "Auto scope: src/integrations/** changed.",
|
|
memory: "Auto scope: src/memory/** changed.",
|
|
observability: "Auto scope: src/observability/** changed.",
|
|
onboard: "Auto scope: src/onboard/** changed.",
|
|
provider: "Auto scope: src/providers/** changed.",
|
|
runtime: "Auto scope: src/runtime/** changed.",
|
|
security: "Auto scope: src/security/** changed.",
|
|
service: "Auto scope: src/service/** changed.",
|
|
skillforge: "Auto scope: src/skillforge/** changed.",
|
|
skills: "Auto scope: src/skills/** changed.",
|
|
tool: "Auto scope: src/tools/** changed.",
|
|
tunnel: "Auto scope: src/tunnel/** changed.",
|
|
tests: "Auto scope: tests/** changed.",
|
|
scripts: "Auto scope: scripts/** changed.",
|
|
dev: "Auto scope: dev/** changed.",
|
|
};
|
|
for (const label of contributorTierLabels) {
|
|
staticLabelColors[label] = contributorTierColor;
|
|
const rule = contributorTierRules.find((entry) => entry.label === label);
|
|
if (rule) {
|
|
staticLabelDescriptions[label] = `Contributor with ${rule.minMergedPRs}+ merged PRs.`;
|
|
}
|
|
}
|
|
|
|
const modulePrefixColors = Object.fromEntries(
|
|
modulePrefixPriority.map((prefix) => [
|
|
`${prefix}:`,
|
|
otherLabelColors[prefix] || "BFDADC",
|
|
])
|
|
);
|
|
|
|
const providerKeywordHints = [
|
|
"deepseek",
|
|
"moonshot",
|
|
"kimi",
|
|
"qwen",
|
|
"mistral",
|
|
"doubao",
|
|
"baichuan",
|
|
"yi",
|
|
"siliconflow",
|
|
"vertex",
|
|
"azure",
|
|
"perplexity",
|
|
"venice",
|
|
"vercel",
|
|
"cloudflare",
|
|
"synthetic",
|
|
"opencode",
|
|
"zai",
|
|
"glm",
|
|
"minimax",
|
|
"bedrock",
|
|
"qianfan",
|
|
"groq",
|
|
"together",
|
|
"fireworks",
|
|
"cohere",
|
|
"openai",
|
|
"openrouter",
|
|
"anthropic",
|
|
"gemini",
|
|
"ollama",
|
|
];
|
|
|
|
const channelKeywordHints = [
|
|
"telegram",
|
|
"discord",
|
|
"slack",
|
|
"whatsapp",
|
|
"matrix",
|
|
"irc",
|
|
"imessage",
|
|
"email",
|
|
"cli",
|
|
];
|
|
|
|
function isDocsLike(path) {
|
|
return (
|
|
path.startsWith("docs/") ||
|
|
path.endsWith(".md") ||
|
|
path.endsWith(".mdx") ||
|
|
path === "LICENSE" ||
|
|
path === ".markdownlint-cli2.yaml" ||
|
|
path === ".github/pull_request_template.md" ||
|
|
path.startsWith(".github/ISSUE_TEMPLATE/")
|
|
);
|
|
}
|
|
|
|
function normalizeLabelSegment(segment) {
|
|
return (segment || "")
|
|
.toLowerCase()
|
|
.replace(/\.rs$/g, "")
|
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
.replace(/^[-_]+|[-_]+$/g, "")
|
|
.slice(0, 40);
|
|
}
|
|
|
|
function containsKeyword(text, keyword) {
|
|
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const pattern = new RegExp(`(^|[^a-z0-9_])${escaped}([^a-z0-9_]|$)`, "i");
|
|
return pattern.test(text);
|
|
}
|
|
|
|
function parseModuleLabel(label) {
|
|
const separatorIndex = label.indexOf(":");
|
|
if (separatorIndex <= 0 || separatorIndex >= label.length - 1) return null;
|
|
return {
|
|
prefix: label.slice(0, separatorIndex),
|
|
segment: label.slice(separatorIndex + 1),
|
|
};
|
|
}
|
|
|
|
function sortByPriority(labels, priorityIndex) {
|
|
return [...new Set(labels)].sort((left, right) => {
|
|
const leftPriority = priorityIndex.has(left) ? priorityIndex.get(left) : Number.MAX_SAFE_INTEGER;
|
|
const rightPriority = priorityIndex.has(right)
|
|
? priorityIndex.get(right)
|
|
: Number.MAX_SAFE_INTEGER;
|
|
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
|
return left.localeCompare(right);
|
|
});
|
|
}
|
|
|
|
function sortModuleLabels(labels) {
|
|
return [...new Set(labels)].sort((left, right) => {
|
|
const leftParsed = parseModuleLabel(left);
|
|
const rightParsed = parseModuleLabel(right);
|
|
if (!leftParsed || !rightParsed) return left.localeCompare(right);
|
|
|
|
const leftPrefixPriority = modulePrefixPriorityIndex.has(leftParsed.prefix)
|
|
? modulePrefixPriorityIndex.get(leftParsed.prefix)
|
|
: Number.MAX_SAFE_INTEGER;
|
|
const rightPrefixPriority = modulePrefixPriorityIndex.has(rightParsed.prefix)
|
|
? modulePrefixPriorityIndex.get(rightParsed.prefix)
|
|
: Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftPrefixPriority !== rightPrefixPriority) {
|
|
return leftPrefixPriority - rightPrefixPriority;
|
|
}
|
|
if (leftParsed.prefix !== rightParsed.prefix) {
|
|
return leftParsed.prefix.localeCompare(rightParsed.prefix);
|
|
}
|
|
|
|
const leftIsCore = leftParsed.segment === "core";
|
|
const rightIsCore = rightParsed.segment === "core";
|
|
if (leftIsCore !== rightIsCore) return leftIsCore ? 1 : -1;
|
|
|
|
return leftParsed.segment.localeCompare(rightParsed.segment);
|
|
});
|
|
}
|
|
|
|
function refineModuleLabels(rawLabels) {
|
|
const refined = new Set(rawLabels);
|
|
const segmentsByPrefix = new Map();
|
|
|
|
for (const label of rawLabels) {
|
|
const parsed = parseModuleLabel(label);
|
|
if (!parsed) continue;
|
|
if (!segmentsByPrefix.has(parsed.prefix)) {
|
|
segmentsByPrefix.set(parsed.prefix, new Set());
|
|
}
|
|
segmentsByPrefix.get(parsed.prefix).add(parsed.segment);
|
|
}
|
|
|
|
for (const [prefix, segments] of segmentsByPrefix) {
|
|
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
|
|
if (hasSpecificSegment) {
|
|
refined.delete(`${prefix}:core`);
|
|
}
|
|
}
|
|
|
|
return refined;
|
|
}
|
|
|
|
function compactModuleLabels(labels) {
|
|
const groupedSegments = new Map();
|
|
const compactedModuleLabels = new Set();
|
|
const forcePathPrefixes = new Set();
|
|
|
|
for (const label of labels) {
|
|
const parsed = parseModuleLabel(label);
|
|
if (!parsed) {
|
|
compactedModuleLabels.add(label);
|
|
continue;
|
|
}
|
|
if (!groupedSegments.has(parsed.prefix)) {
|
|
groupedSegments.set(parsed.prefix, new Set());
|
|
}
|
|
groupedSegments.get(parsed.prefix).add(parsed.segment);
|
|
}
|
|
|
|
for (const [prefix, segments] of groupedSegments) {
|
|
const uniqueSegments = [...new Set([...segments].filter(Boolean))];
|
|
if (uniqueSegments.length === 0) continue;
|
|
|
|
if (uniqueSegments.length === 1) {
|
|
compactedModuleLabels.add(`${prefix}:${uniqueSegments[0]}`);
|
|
} else {
|
|
forcePathPrefixes.add(prefix);
|
|
}
|
|
}
|
|
|
|
return {
|
|
moduleLabels: compactedModuleLabels,
|
|
forcePathPrefixes,
|
|
};
|
|
}
|
|
|
|
function colorForLabel(label) {
|
|
if (staticLabelColors[label]) return staticLabelColors[label];
|
|
const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix));
|
|
if (matchedPrefix) return modulePrefixColors[matchedPrefix];
|
|
return "BFDADC";
|
|
}
|
|
|
|
function descriptionForLabel(label) {
|
|
if (staticLabelDescriptions[label]) return staticLabelDescriptions[label];
|
|
|
|
const parsed = parseModuleLabel(label);
|
|
if (parsed) {
|
|
if (parsed.segment === "core") {
|
|
return `Auto module: ${parsed.prefix} core files changed.`;
|
|
}
|
|
return `Auto module: ${parsed.prefix}/${parsed.segment} changed.`;
|
|
}
|
|
|
|
return "Auto-managed label.";
|
|
}
|
|
|
|
async function ensureLabel(name) {
|
|
const expectedColor = colorForLabel(name);
|
|
const expectedDescription = descriptionForLabel(name);
|
|
try {
|
|
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name });
|
|
const currentColor = (existing.color || "").toUpperCase();
|
|
const currentDescription = (existing.description || "").trim();
|
|
if (currentColor !== expectedColor || currentDescription !== expectedDescription) {
|
|
await github.rest.issues.updateLabel({
|
|
owner,
|
|
repo,
|
|
name,
|
|
new_name: name,
|
|
color: expectedColor,
|
|
description: expectedDescription,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name,
|
|
color: expectedColor,
|
|
description: expectedDescription,
|
|
});
|
|
}
|
|
}
|
|
|
|
function selectContributorTier(mergedCount) {
|
|
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
|
|
return matchedTier ? matchedTier.label : null;
|
|
}
|
|
|
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner,
|
|
repo,
|
|
pull_number: pr.number,
|
|
per_page: 100,
|
|
});
|
|
|
|
const detectedModuleLabels = new Set();
|
|
for (const file of files) {
|
|
const path = (file.filename || "").toLowerCase();
|
|
for (const rule of moduleNamespaceRules) {
|
|
if (!path.startsWith(rule.root)) continue;
|
|
|
|
const relative = path.slice(rule.root.length);
|
|
if (!relative) continue;
|
|
|
|
const first = relative.split("/")[0];
|
|
const firstStem = first.endsWith(".rs") ? first.slice(0, -3) : first;
|
|
let segment = firstStem;
|
|
|
|
if (rule.coreEntries.has(first) || rule.coreEntries.has(firstStem)) {
|
|
segment = "core";
|
|
}
|
|
|
|
segment = normalizeLabelSegment(segment);
|
|
if (!segment) continue;
|
|
|
|
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
|
|
}
|
|
}
|
|
|
|
const providerRelevantFiles = files.filter((file) => {
|
|
const path = file.filename || "";
|
|
return (
|
|
path.startsWith("src/providers/") ||
|
|
path.startsWith("src/integrations/") ||
|
|
path.startsWith("src/onboard/") ||
|
|
path.startsWith("src/config/")
|
|
);
|
|
});
|
|
|
|
if (providerRelevantFiles.length > 0) {
|
|
const searchableText = [
|
|
pr.title || "",
|
|
pr.body || "",
|
|
...providerRelevantFiles.map((file) => file.filename || ""),
|
|
...providerRelevantFiles.map((file) => file.patch || ""),
|
|
]
|
|
.join("\n")
|
|
.toLowerCase();
|
|
|
|
for (const keyword of providerKeywordHints) {
|
|
if (containsKeyword(searchableText, keyword)) {
|
|
detectedModuleLabels.add(`provider:${keyword}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const channelRelevantFiles = files.filter((file) => {
|
|
const path = file.filename || "";
|
|
return (
|
|
path.startsWith("src/channels/") ||
|
|
path.startsWith("src/onboard/") ||
|
|
path.startsWith("src/config/")
|
|
);
|
|
});
|
|
|
|
if (channelRelevantFiles.length > 0) {
|
|
const searchableText = [
|
|
pr.title || "",
|
|
pr.body || "",
|
|
...channelRelevantFiles.map((file) => file.filename || ""),
|
|
...channelRelevantFiles.map((file) => file.patch || ""),
|
|
]
|
|
.join("\n")
|
|
.toLowerCase();
|
|
|
|
for (const keyword of channelKeywordHints) {
|
|
if (containsKeyword(searchableText, keyword)) {
|
|
detectedModuleLabels.add(`channel:${keyword}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const refinedModuleLabels = refineModuleLabels(detectedModuleLabels);
|
|
const compactedModuleState = compactModuleLabels(refinedModuleLabels);
|
|
const selectedModuleLabels = compactedModuleState.moduleLabels;
|
|
const forcePathPrefixes = compactedModuleState.forcePathPrefixes;
|
|
const modulePrefixesWithLabels = new Set(
|
|
[...selectedModuleLabels]
|
|
.map((label) => parseModuleLabel(label)?.prefix)
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
|
owner,
|
|
repo,
|
|
issue_number: pr.number,
|
|
});
|
|
const currentLabelNames = currentLabels.map((label) => label.name);
|
|
const currentPathLabels = currentLabelNames.filter((label) => managedPathLabelSet.has(label));
|
|
const candidatePathLabels = new Set([...currentPathLabels, ...forcePathPrefixes]);
|
|
|
|
const dedupedPathLabels = [...candidatePathLabels].filter((label) => {
|
|
if (label === "core") return true;
|
|
if (forcePathPrefixes.has(label)) return true;
|
|
return !modulePrefixesWithLabels.has(label);
|
|
});
|
|
|
|
const excludedLockfiles = new Set(["Cargo.lock"]);
|
|
const changedLines = files.reduce((total, file) => {
|
|
const path = file.filename || "";
|
|
if (isDocsLike(path) || excludedLockfiles.has(path)) {
|
|
return total;
|
|
}
|
|
return total + (file.additions || 0) + (file.deletions || 0);
|
|
}, 0);
|
|
|
|
let sizeLabel = "size: XL";
|
|
if (changedLines <= 80) sizeLabel = "size: XS";
|
|
else if (changedLines <= 250) sizeLabel = "size: S";
|
|
else if (changedLines <= 500) sizeLabel = "size: M";
|
|
else if (changedLines <= 1000) sizeLabel = "size: L";
|
|
|
|
const hasHighRiskPath = files.some((file) => {
|
|
const path = file.filename || "";
|
|
return (
|
|
path.startsWith("src/security/") ||
|
|
path.startsWith("src/runtime/") ||
|
|
path.startsWith("src/gateway/") ||
|
|
path.startsWith("src/tools/") ||
|
|
path.startsWith(".github/workflows/")
|
|
);
|
|
});
|
|
|
|
const hasMediumRiskPath = files.some((file) => {
|
|
const path = file.filename || "";
|
|
return (
|
|
path.startsWith("src/") ||
|
|
path === "Cargo.toml" ||
|
|
path === "Cargo.lock" ||
|
|
path === "deny.toml" ||
|
|
path.startsWith(".githooks/")
|
|
);
|
|
});
|
|
|
|
let riskLabel = "risk: low";
|
|
if (hasHighRiskPath) {
|
|
riskLabel = "risk: high";
|
|
} else if (hasMediumRiskPath) {
|
|
riskLabel = "risk: medium";
|
|
}
|
|
|
|
const labelsToEnsure = new Set([
|
|
...sizeLabels,
|
|
...computedRiskLabels,
|
|
manualRiskOverrideLabel,
|
|
...managedPathLabels,
|
|
...contributorTierLabels,
|
|
...selectedModuleLabels,
|
|
]);
|
|
|
|
for (const label of labelsToEnsure) {
|
|
await ensureLabel(label);
|
|
}
|
|
|
|
let contributorTierLabel = null;
|
|
const authorLogin = pr.user?.login;
|
|
if (authorLogin && pr.user?.type !== "Bot") {
|
|
try {
|
|
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
|
|
q: `repo:${owner}/${repo} is:pr is:merged author:${authorLogin}`,
|
|
per_page: 1,
|
|
});
|
|
const mergedCount = mergedSearch.total_count || 0;
|
|
contributorTierLabel = selectContributorTier(mergedCount);
|
|
} catch (error) {
|
|
core.warning(`failed to compute contributor tier label: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const hasManualRiskOverride = currentLabelNames.includes(manualRiskOverrideLabel);
|
|
const keepNonManagedLabels = currentLabelNames.filter((label) => {
|
|
if (label === manualRiskOverrideLabel) return true;
|
|
if (label === legacyTrustedContributorLabel) return false;
|
|
if (contributorTierLabels.includes(label)) return false;
|
|
if (sizeLabels.includes(label) || computedRiskLabels.includes(label)) return false;
|
|
if (managedPathLabelSet.has(label)) return false;
|
|
if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return false;
|
|
return true;
|
|
});
|
|
|
|
const manualRiskSelection =
|
|
currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel;
|
|
|
|
const moduleLabelList = sortModuleLabels([...selectedModuleLabels]);
|
|
const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : [];
|
|
const selectedRiskLabels = hasManualRiskOverride
|
|
? sortByPriority([manualRiskSelection, manualRiskOverrideLabel], riskPriorityIndex)
|
|
: sortByPriority([riskLabel], riskPriorityIndex);
|
|
const selectedSizeLabels = sortByPriority([sizeLabel], sizePriorityIndex);
|
|
const sortedContributorLabels = sortByPriority(contributorLabelList, contributorPriorityIndex);
|
|
const sortedPathLabels = sortByPriority(dedupedPathLabels, pathLabelPriorityIndex);
|
|
const sortedKeepNonManagedLabels = [...new Set(keepNonManagedLabels)].sort((left, right) =>
|
|
left.localeCompare(right)
|
|
);
|
|
|
|
const nextLabels = [
|
|
...new Set([
|
|
...selectedRiskLabels,
|
|
...selectedSizeLabels,
|
|
...sortedContributorLabels,
|
|
...moduleLabelList,
|
|
...sortedPathLabels,
|
|
...sortedKeepNonManagedLabels,
|
|
]),
|
|
];
|
|
|
|
await github.rest.issues.setLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: pr.number,
|
|
labels: nextLabels,
|
|
});
|