Merge pull request #328 from chumyin/fix/labeler-compact-noisy-modules

ci(labeler): dedupe noisy module labels and improve label UX
This commit is contained in:
Chummy 2026-02-16 20:06:08 +08:00 committed by GitHub
commit 1851cf58a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 303 additions and 68 deletions

View file

@ -50,7 +50,7 @@ jobs:
{ label: "experienced contributor", minMergedPRs: 10 }, { label: "experienced contributor", minMergedPRs: 10 },
]; ];
const contributorTierLabels = contributorTierRules.map((rule) => rule.label); const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
const contributorTierColor = "39FF14"; const contributorTierColor = "C5D7A2";
const managedPathLabels = [ const managedPathLabels = [
"docs", "docs",
@ -82,6 +82,7 @@ jobs:
"scripts", "scripts",
"dev", "dev",
]; ];
const managedPathLabelSet = new Set(managedPathLabels);
const moduleNamespaceRules = [ const moduleNamespaceRules = [
{ root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) }, { root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) },
@ -107,73 +108,131 @@ jobs:
{ root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) }, { root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) },
]; ];
const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))]; const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))];
const orderedOtherLabelStyles = [
{ label: "health", color: "A6D3C0" },
{ label: "tool", color: "A5D3BC" },
{ label: "agent", color: "A4D3B7" },
{ label: "memory", color: "A3D2B1" },
{ label: "channel", color: "A1D2AC" },
{ label: "service", color: "A0D2A7" },
{ label: "integration", color: "9FD2A1" },
{ label: "tunnel", color: "A0D19E" },
{ label: "config", color: "A4D19C" },
{ label: "observability", color: "A8D19B" },
{ label: "docs", color: "ACD09A" },
{ label: "dev", color: "B0D099" },
{ label: "tests", color: "B4D097" },
{ label: "skills", color: "B8D096" },
{ label: "skillforge", color: "BDCF95" },
{ label: "provider", color: "C2CF94" },
{ label: "runtime", color: "C7CF92" },
{ label: "heartbeat", color: "CCCF91" },
{ label: "daemon", color: "CFCB90" },
{ label: "doctor", color: "CEC58E" },
{ label: "onboard", color: "CEBF8D" },
{ label: "cron", color: "CEB98C" },
{ label: "ci", color: "CEB28A" },
{ label: "dependencies", color: "CDAB89" },
{ label: "gateway", color: "CDA488" },
{ label: "security", color: "CD9D87" },
{ label: "core", color: "CD9585" },
{ label: "scripts", color: "CD8E84" },
];
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 = { const staticLabelColors = {
"size: XS": "BFDADC", "size: XS": "EAF1F4",
"size: S": "BFDADC", "size: S": "DEE9EF",
"size: M": "BFDADC", "size: M": "D0DDE6",
"size: L": "BFDADC", "size: L": "C1D0DC",
"size: XL": "BFDADC", "size: XL": "B2C3D1",
"risk: low": "2EA043", "risk: low": "BFD8B5",
"risk: medium": "FBCA04", "risk: medium": "E4D39B",
"risk: high": "D73A49", "risk: high": "E1A39A",
"risk: manual": "1F6FEB", "risk: manual": "B9B1D2",
docs: "1D76DB", ...otherLabelColors,
dependencies: "C26F00", };
ci: "8250DF", const staticLabelDescriptions = {
core: "24292F", "size: XS": "Auto size: <=80 non-doc changed lines.",
agent: "2EA043", "size: S": "Auto size: 81-250 non-doc changed lines.",
channel: "1D76DB", "size: M": "Auto size: 251-500 non-doc changed lines.",
config: "0969DA", "size: L": "Auto size: 501-1000 non-doc changed lines.",
cron: "9A6700", "size: XL": "Auto size: >1000 non-doc changed lines.",
daemon: "57606A", "risk: low": "Auto risk: docs/chore-only paths.",
doctor: "0E8A8A", "risk: medium": "Auto risk: src/** or dependency/config changes.",
gateway: "D73A49", "risk: high": "Auto risk: security/runtime/gateway/tools/workflows.",
health: "0E8A8A", "risk: manual": "Maintainer override: keep selected risk label.",
heartbeat: "0E8A8A", docs: "Auto scope: docs/markdown/template files changed.",
integration: "8250DF", dependencies: "Auto scope: dependency manifest/lock/policy changed.",
memory: "1F883D", ci: "Auto scope: CI/workflow/hook files changed.",
observability: "6E7781", core: "Auto scope: root src/*.rs files changed.",
onboard: "B62DBA", agent: "Auto scope: src/agent/** changed.",
provider: "5319E7", channel: "Auto scope: src/channels/** changed.",
runtime: "C26F00", config: "Auto scope: src/config/** changed.",
security: "B60205", cron: "Auto scope: src/cron/** changed.",
service: "0052CC", daemon: "Auto scope: src/daemon/** changed.",
skillforge: "A371F7", doctor: "Auto scope: src/doctor/** changed.",
skills: "6F42C1", gateway: "Auto scope: src/gateway/** changed.",
tool: "D73A49", health: "Auto scope: src/health/** changed.",
tunnel: "0052CC", heartbeat: "Auto scope: src/heartbeat/** changed.",
tests: "0E8A16", integration: "Auto scope: src/integrations/** changed.",
scripts: "B08800", memory: "Auto scope: src/memory/** changed.",
dev: "6E7781", 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) { for (const label of contributorTierLabels) {
staticLabelColors[label] = contributorTierColor; staticLabelColors[label] = contributorTierColor;
const rule = contributorTierRules.find((entry) => entry.label === label);
if (rule) {
staticLabelDescriptions[label] = `Contributor with ${rule.minMergedPRs}+ merged PRs.`;
}
} }
const modulePrefixColors = { const modulePrefixColors = Object.fromEntries(
"agent:": "2EA043", modulePrefixPriority.map((prefix) => [
"channel:": "1D76DB", `${prefix}:`,
"config:": "0969DA", otherLabelColors[prefix] || "BFDADC",
"cron:": "9A6700", ])
"daemon:": "57606A", );
"doctor:": "0E8A8A",
"gateway:": "D73A49",
"health:": "0E8A8A",
"heartbeat:": "0E8A8A",
"integration:": "8250DF",
"memory:": "1F883D",
"observability:": "6E7781",
"onboard:": "B62DBA",
"provider:": "5319E7",
"runtime:": "C26F00",
"security:": "B60205",
"service:": "0052CC",
"skillforge:": "A371F7",
"skills:": "6F42C1",
"tool:": "D73A49",
"tunnel:": "0052CC",
};
const providerKeywordHints = [ const providerKeywordHints = [
"deepseek", "deepseek",
@ -248,6 +307,119 @@ jobs:
return pattern.test(text); 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 compactNoisyModuleLabels(labels) {
const noisyPrefixes = new Set(["tool", "provider", "channel"]);
const groupedSegments = new Map();
const compacted = new Set();
const forcePathPrefixes = new Set();
for (const label of labels) {
const parsed = parseModuleLabel(label);
if (!parsed) continue;
if (!groupedSegments.has(parsed.prefix)) {
groupedSegments.set(parsed.prefix, new Set());
}
groupedSegments.get(parsed.prefix).add(parsed.segment);
}
for (const label of labels) {
const parsed = parseModuleLabel(label);
if (!parsed) continue;
if (!noisyPrefixes.has(parsed.prefix)) {
compacted.add(label);
}
}
for (const [prefix, segments] of groupedSegments) {
if (!noisyPrefixes.has(prefix)) continue;
const specificSegments = [...segments].filter((segment) => segment !== "core");
const uniqueSpecificSegments = [...new Set(specificSegments)];
if (uniqueSpecificSegments.length === 1) {
compacted.add(`${prefix}:${uniqueSpecificSegments[0]}`);
} else {
forcePathPrefixes.add(prefix);
}
}
return {
moduleLabels: compacted,
forcePathPrefixes,
};
}
function colorForLabel(label) { function colorForLabel(label) {
if (staticLabelColors[label]) return staticLabelColors[label]; if (staticLabelColors[label]) return staticLabelColors[label];
const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix)); const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix));
@ -255,18 +427,35 @@ jobs:
return "BFDADC"; 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) { async function ensureLabel(name) {
const expectedColor = colorForLabel(name); const expectedColor = colorForLabel(name);
const expectedDescription = descriptionForLabel(name);
try { try {
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name }); const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name });
const currentColor = (existing.color || "").toUpperCase(); const currentColor = (existing.color || "").toUpperCase();
if (currentColor !== expectedColor) { const currentDescription = (existing.description || "").trim();
if (currentColor !== expectedColor || currentDescription !== expectedDescription) {
await github.rest.issues.updateLabel({ await github.rest.issues.updateLabel({
owner, owner,
repo, repo,
name, name,
new_name: name, new_name: name,
color: expectedColor, color: expectedColor,
description: expectedDescription,
}); });
} }
} catch (error) { } catch (error) {
@ -276,6 +465,7 @@ jobs:
repo, repo,
name, name,
color: expectedColor, color: expectedColor,
description: expectedDescription,
}); });
} }
} }
@ -369,12 +559,30 @@ jobs:
} }
} }
const refinedModuleLabels = refineModuleLabels(detectedModuleLabels);
const compactedModuleState = compactNoisyModuleLabels(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({ const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, owner,
repo, repo,
issue_number: pr.number, issue_number: pr.number,
}); });
const currentLabelNames = currentLabels.map((label) => label.name); 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 excludedLockfiles = new Set(["Cargo.lock"]);
const changedLines = files.reduce((total, file) => { const changedLines = files.reduce((total, file) => {
@ -426,7 +634,7 @@ jobs:
manualRiskOverrideLabel, manualRiskOverrideLabel,
...managedPathLabels, ...managedPathLabels,
...contributorTierLabels, ...contributorTierLabels,
...detectedModuleLabels, ...selectedModuleLabels,
]); ]);
for (const label of labelsToEnsure) { for (const label of labelsToEnsure) {
@ -454,6 +662,7 @@ jobs:
if (label === legacyTrustedContributorLabel) return false; if (label === legacyTrustedContributorLabel) return false;
if (contributorTierLabels.includes(label)) return false; if (contributorTierLabels.includes(label)) return false;
if (sizeLabels.includes(label) || computedRiskLabels.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; if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return false;
return true; return true;
}); });
@ -461,11 +670,28 @@ jobs:
const manualRiskSelection = const manualRiskSelection =
currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel; currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel;
const moduleLabelList = [...detectedModuleLabels]; const moduleLabelList = sortModuleLabels([...selectedModuleLabels]);
const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : []; const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : [];
const nextLabels = hasManualRiskOverride const selectedRiskLabels = hasManualRiskOverride
? [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, manualRiskSelection])] ? sortByPriority([manualRiskSelection, manualRiskOverrideLabel], riskPriorityIndex)
: [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, riskLabel])]; : 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({ await github.rest.issues.setLabels({
owner, owner,

View file

@ -28,8 +28,13 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `.github/workflows/labeler.yml` (`PR Labeler`) - `.github/workflows/labeler.yml` (`PR Labeler`)
- Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>:<component>`) - Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>:<component>`)
- Additional behavior: label descriptions are auto-managed as hover tooltips to explain each auto-judgment rule
- Additional behavior: provider-related keywords in provider/config/onboard/integration changes are promoted to `provider:*` labels (for example `provider:kimi`, `provider:deepseek`) - Additional behavior: provider-related keywords in provider/config/onboard/integration changes are promoted to `provider:*` labels (for example `provider:kimi`, `provider:deepseek`)
- Additional behavior: hierarchical de-duplication keeps only the most specific scope labels (for example `tool:composio` suppresses `tool:core` and `tool`)
- Additional behavior: noisy namespaces (`tool`, `provider`, `channel`) are compacted — one specific module keeps `prefix:component`; multiple specifics collapse to just `prefix`
- Additional behavior: applies contributor tiers on PRs by merged PR count (`experienced` >=10, `principal` >=20, `distinguished` >=50) - Additional behavior: applies contributor tiers on PRs by merged PR count (`experienced` >=10, `principal` >=20, `distinguished` >=50)
- Additional behavior: final label set is priority-sorted (`risk:*` first, then `size:*`, then contributor tier, then module/path labels)
- Additional behavior: managed label colors follow display order to produce a smooth left-to-right gradient when many labels are present
- Additional behavior: risk + size labels are auto-corrected on manual PR label edits (`labeled`/`unlabeled` events); apply `risk: manual` when maintainers intentionally override automated risk selection - Additional behavior: risk + size labels are auto-corrected on manual PR label edits (`labeled`/`unlabeled` events); apply `risk: manual` when maintainers intentionally override automated risk selection
- High-risk heuristic paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**` - High-risk heuristic paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`
- Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation - Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation

View file

@ -49,7 +49,11 @@ Maintain these branch protection rules on `main`:
### Step A: Intake ### Step A: Intake
- Contributor opens PR with full `.github/pull_request_template.md`. - Contributor opens PR with full `.github/pull_request_template.md`.
- `PR Labeler` applies scope/path labels + size labels + risk labels + module labels (for example `channel:telegram`, `provider:kimi`, `tool:shell`) and contributor tiers by merged PR count (`experienced` >=10, `principal` >=20, `distinguished` >=50). - `PR Labeler` applies scope/path labels + size labels + risk labels + module labels (for example `channel:telegram`, `provider:kimi`, `tool:shell`) and contributor tiers by merged PR count (`experienced` >=10, `principal` >=20, `distinguished` >=50), while de-duplicating less-specific scope labels when a more specific module label is present.
- For `tool` / `provider` / `channel`, module labels are compacted to reduce noise: one specific module keeps `prefix:component`, but multiple specifics collapse to the base scope label.
- Label ordering is priority-first: `risk:*` -> `size:*` -> contributor tier -> module/path labels.
- Hovering a label in GitHub shows its auto-managed description (rule/threshold summary).
- Managed label colors are arranged by display order to create a smooth gradient across long label rows.
- `Auto Response` posts first-time guidance and handles label-driven routing for low-signal items. - `Auto Response` posts first-time guidance and handles label-driven routing for low-signal items.
### Step B: Validation ### Step B: Validation