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: blacksmith-2vcpu-ubuntu-2404 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, ]); 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 }, { label: "trusted contributor", minMergedPRs: 5 }, ]; const contributorTierLabels = contributorTierRules.map((rule) => rule.label); const contributorTierColor = "2ED9FF"; // Keep in sync with .github/workflows/auto-response.yml 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", "trusted 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, existing = null) { const expectedColor = colorForLabel(name); const expectedDescription = descriptionForLabel(name); try { const current = existing || (await github.rest.issues.getLabel({ owner, repo, name })).data; const currentColor = (current.color || "").toUpperCase(); const currentDescription = (current.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 isManagedLabel(label) { if (label === manualRiskOverrideLabel) return true; if (sizeLabels.includes(label) || computedRiskLabels.includes(label)) return true; if (managedPathLabelSet.has(label)) return true; if (contributorTierLabels.includes(label)) return true; if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return true; return false; } async function ensureManagedRepoLabelsMetadata() { const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100, }); for (const existingLabel of repoLabels) { const labelName = existingLabel.name || ""; if (!isManagedLabel(labelName)) continue; await ensureLabel(labelName, existingLabel); } } 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"; } await ensureManagedRepoLabelsMetadata(); 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 (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, });