From 389496823debf89544be903c4809bbe1c937b74e Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 19:46:22 +0800 Subject: [PATCH 1/4] ci(labeler): dedupe labels, add hover rules, and tune low-sat palette (#6) * ci(labeler): dedupe scope labels and prioritize risk/size * ci(labeler): add hover rule descriptions and refresh label palette * style(labeler): reduce label saturation for better readability --- .github/workflows/labeler.yml | 349 +++++++++++++++++++++++++++------- docs/ci-map.md | 3 + docs/pr-workflow.md | 4 +- 3 files changed, 290 insertions(+), 66 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index ae65d94..1e97fa5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -50,7 +50,7 @@ jobs: { label: "experienced contributor", minMergedPRs: 10 }, ]; const contributorTierLabels = contributorTierRules.map((rule) => rule.label); - const contributorTierColor = "39FF14"; + const contributorTierColor = "C5D7A2"; const managedPathLabels = [ "docs", @@ -82,6 +82,7 @@ jobs: "scripts", "dev", ]; + const managedPathLabelSet = new Set(managedPathLabels); const moduleNamespaceRules = [ { root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) }, @@ -107,72 +108,170 @@ jobs: { root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) }, ]; const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))]; + const modulePrefixPriority = [ + "security", + "runtime", + "gateway", + "tool", + "provider", + "channel", + "config", + "memory", + "agent", + "integration", + "observability", + "onboard", + "service", + "tunnel", + "cron", + "daemon", + "doctor", + "health", + "heartbeat", + "skillforge", + "skills", + ]; + const pathLabelPriority = [ + ...modulePrefixPriority, + "core", + "ci", + "dependencies", + "tests", + "scripts", + "dev", + "docs", + ]; + 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 staticLabelColors = { - "size: XS": "BFDADC", - "size: S": "BFDADC", - "size: M": "BFDADC", - "size: L": "BFDADC", - "size: XL": "BFDADC", - "risk: low": "2EA043", - "risk: medium": "FBCA04", - "risk: high": "D73A49", - "risk: manual": "1F6FEB", - docs: "1D76DB", - dependencies: "C26F00", - ci: "8250DF", - core: "24292F", - agent: "2EA043", - channel: "1D76DB", - config: "0969DA", - 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", - tests: "0E8A16", - scripts: "B08800", - dev: "6E7781", + "size: XS": "E9F0F3", + "size: S": "DDE8EE", + "size: M": "CEDBE4", + "size: L": "BDCEDB", + "size: XL": "AEBFCD", + "risk: low": "B8D8B0", + "risk: medium": "E2D391", + "risk: high": "E0A090", + "risk: manual": "B7AFCF", + docs: "B7CAD6", + dependencies: "D8C99A", + ci: "AFA2CF", + core: "4A4F4A", + agent: "9FC4B8", + channel: "AFC4D6", + config: "C3BCD8", + cron: "C7D6A5", + daemon: "7C7F95", + doctor: "A8D6CD", + gateway: "D8A58F", + health: "A7DCCB", + heartbeat: "B7ACE0", + integration: "8CAFC4", + memory: "7F96B2", + observability: "6D7482", + onboard: "E6E0C8", + provider: "8A7896", + runtime: "8E88AF", + security: "D99084", + service: "B3C7D6", + skillforge: "B9B2DA", + skills: "C8C2E0", + tool: "9BCFBF", + tunnel: "8DAEC0", + tests: "DCE9EE", + scripts: "E7DFC6", + dev: "C4D3DE", + }; + 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 = { - "agent:": "2EA043", - "channel:": "1D76DB", - "config:": "0969DA", - "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", + "agent:": "9FC4B8", + "channel:": "AFC4D6", + "config:": "C3BCD8", + "cron:": "C7D6A5", + "daemon:": "7C7F95", + "doctor:": "A8D6CD", + "gateway:": "D8A58F", + "health:": "A7DCCB", + "heartbeat:": "B7ACE0", + "integration:": "8CAFC4", + "memory:": "7F96B2", + "observability:": "6D7482", + "onboard:": "E6E0C8", + "provider:": "8A7896", + "runtime:": "8E88AF", + "security:": "D99084", + "service:": "B3C7D6", + "skillforge:": "B9B2DA", + "skills:": "C8C2E0", + "tool:": "9BCFBF", + "tunnel:": "8DAEC0", }; const providerKeywordHints = [ @@ -248,6 +347,77 @@ jobs: 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 colorForLabel(label) { if (staticLabelColors[label]) return staticLabelColors[label]; const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix)); @@ -255,18 +425,35 @@ jobs: 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(); - if (currentColor !== expectedColor) { + 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) { @@ -276,6 +463,7 @@ jobs: repo, name, color: expectedColor, + description: expectedDescription, }); } } @@ -369,12 +557,25 @@ jobs: } } + const refinedModuleLabels = refineModuleLabels(detectedModuleLabels); + const modulePrefixesWithLabels = new Set( + [...refinedModuleLabels] + .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 dedupedPathLabels = currentPathLabels.filter((label) => { + if (label === "core") return true; + return !modulePrefixesWithLabels.has(label); + }); const excludedLockfiles = new Set(["Cargo.lock"]); const changedLines = files.reduce((total, file) => { @@ -426,7 +627,7 @@ jobs: manualRiskOverrideLabel, ...managedPathLabels, ...contributorTierLabels, - ...detectedModuleLabels, + ...refinedModuleLabels, ]); for (const label of labelsToEnsure) { @@ -454,6 +655,7 @@ jobs: 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; }); @@ -461,11 +663,28 @@ jobs: const manualRiskSelection = currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel; - const moduleLabelList = [...detectedModuleLabels]; + const moduleLabelList = sortModuleLabels([...refinedModuleLabels]); const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : []; - const nextLabels = hasManualRiskOverride - ? [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, manualRiskSelection])] - : [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, riskLabel])]; + 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, diff --git a/docs/ci-map.md b/docs/ci-map.md index 7e4a253..00711b3 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -28,8 +28,11 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `.github/workflows/labeler.yml` (`PR Labeler`) - Purpose: scope/path labels + size/risk labels + fine-grained module labels (`:`) + - 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: hierarchical de-duplication keeps only the most specific scope labels (for example `tool:composio` suppresses `tool:core` and `tool`) - 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: 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/**` - Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index 9ed07d2..753d44d 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -49,7 +49,9 @@ Maintain these branch protection rules on `main`: ### Step A: Intake - 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. +- 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). - `Auto Response` posts first-time guidance and handles label-driven routing for low-signal items. ### Step B: Validation From 004fc4590f60f163990e969fffce4d3994d8e8ac Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 19:49:45 +0800 Subject: [PATCH 2/4] ci(labeler): compact noisy module labels for tool/provider/channel --- .github/workflows/labeler.yml | 55 ++++++++++++++++++++++++++++++++--- docs/ci-map.md | 1 + docs/pr-workflow.md | 1 + 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 1e97fa5..a05b3f6 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -418,6 +418,48 @@ jobs: 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) { if (staticLabelColors[label]) return staticLabelColors[label]; const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix)); @@ -558,8 +600,11 @@ jobs: } const refinedModuleLabels = refineModuleLabels(detectedModuleLabels); + const compactedModuleState = compactNoisyModuleLabels(refinedModuleLabels); + const selectedModuleLabels = compactedModuleState.moduleLabels; + const forcePathPrefixes = compactedModuleState.forcePathPrefixes; const modulePrefixesWithLabels = new Set( - [...refinedModuleLabels] + [...selectedModuleLabels] .map((label) => parseModuleLabel(label)?.prefix) .filter(Boolean) ); @@ -571,9 +616,11 @@ jobs: }); const currentLabelNames = currentLabels.map((label) => label.name); const currentPathLabels = currentLabelNames.filter((label) => managedPathLabelSet.has(label)); + const candidatePathLabels = new Set([...currentPathLabels, ...forcePathPrefixes]); - const dedupedPathLabels = currentPathLabels.filter((label) => { + const dedupedPathLabels = [...candidatePathLabels].filter((label) => { if (label === "core") return true; + if (forcePathPrefixes.has(label)) return true; return !modulePrefixesWithLabels.has(label); }); @@ -627,7 +674,7 @@ jobs: manualRiskOverrideLabel, ...managedPathLabels, ...contributorTierLabels, - ...refinedModuleLabels, + ...selectedModuleLabels, ]); for (const label of labelsToEnsure) { @@ -663,7 +710,7 @@ jobs: const manualRiskSelection = currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel; - const moduleLabelList = sortModuleLabels([...refinedModuleLabels]); + const moduleLabelList = sortModuleLabels([...selectedModuleLabels]); const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : []; const selectedRiskLabels = hasManualRiskOverride ? sortByPriority([manualRiskSelection, manualRiskOverrideLabel], riskPriorityIndex) diff --git a/docs/ci-map.md b/docs/ci-map.md index 00711b3..f455ee1 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -31,6 +31,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - 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: 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: final label set is priority-sorted (`risk:*` first, then `size:*`, then contributor tier, then module/path labels) - 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 diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index 753d44d..c894652 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -50,6 +50,7 @@ Maintain these branch protection rules on `main`: - 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), 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). - `Auto Response` posts first-time guidance and handles label-driven routing for low-signal items. From 3a25f4fa3a30af03aaedfb8a3fa7f808befecb2f Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 19:52:14 +0800 Subject: [PATCH 3/4] ci(labeler): enforce ordered gradient palette and compact module labels --- .github/workflows/labeler.yml | 177 ++++++++++++++++++---------------- docs/ci-map.md | 1 + docs/pr-workflow.md | 1 + 3 files changed, 96 insertions(+), 83 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index a05b3f6..e7cfa27 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -108,39 +108,39 @@ jobs: { root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) }, ]; const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))]; - const modulePrefixPriority = [ - "security", - "runtime", - "gateway", + const otherLabelDisplayOrder = [ + "health", "tool", - "provider", - "channel", - "config", - "memory", "agent", - "integration", - "observability", - "onboard", + "memory", + "channel", "service", + "integration", "tunnel", - "cron", + "config", + "observability", + "docs", + "dev", + "tests", + "skills", + "skillforge", + "provider", + "runtime", + "heartbeat", "daemon", "doctor", - "health", - "heartbeat", - "skillforge", - "skills", - ]; - const pathLabelPriority = [ - ...modulePrefixPriority, - "core", + "onboard", + "cron", "ci", "dependencies", - "tests", + "gateway", + "security", + "core", "scripts", - "dev", - "docs", ]; + 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 = [ @@ -164,44 +164,72 @@ jobs: contributorDisplayOrder.map((label, index) => [label, index]) ); + function hslToHex(h, s, l) { + const saturation = s / 100; + const lightness = l / 100; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const huePrime = ((h % 360) + 360) % 360 / 60; + const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); + let red = 0; + let green = 0; + let blue = 0; + + if (huePrime >= 0 && huePrime < 1) { + red = chroma; + green = x; + } else if (huePrime < 2) { + red = x; + green = chroma; + } else if (huePrime < 3) { + green = chroma; + blue = x; + } else if (huePrime < 4) { + green = x; + blue = chroma; + } else if (huePrime < 5) { + red = x; + blue = chroma; + } else { + red = chroma; + blue = x; + } + + const match = lightness - chroma / 2; + const toHex = (value) => + Math.round((value + match) * 255) + .toString(16) + .padStart(2, "0"); + + return `${toHex(red)}${toHex(green)}${toHex(blue)}`.toUpperCase(); + } + + function buildGradientColorMap(labels) { + const colorMap = {}; + const lastIndex = Math.max(labels.length - 1, 1); + + for (let index = 0; index < labels.length; index += 1) { + const ratio = index / lastIndex; + const hue = 155 - ratio * 147; + const saturation = 34 + ratio * 8; + const lightness = 74 - ratio * 8; + colorMap[labels[index]] = hslToHex(hue, saturation, lightness); + } + + return colorMap; + } + + const otherLabelColors = buildGradientColorMap(otherLabelDisplayOrder); const staticLabelColors = { - "size: XS": "E9F0F3", - "size: S": "DDE8EE", - "size: M": "CEDBE4", - "size: L": "BDCEDB", - "size: XL": "AEBFCD", - "risk: low": "B8D8B0", - "risk: medium": "E2D391", - "risk: high": "E0A090", - "risk: manual": "B7AFCF", - docs: "B7CAD6", - dependencies: "D8C99A", - ci: "AFA2CF", - core: "4A4F4A", - agent: "9FC4B8", - channel: "AFC4D6", - config: "C3BCD8", - cron: "C7D6A5", - daemon: "7C7F95", - doctor: "A8D6CD", - gateway: "D8A58F", - health: "A7DCCB", - heartbeat: "B7ACE0", - integration: "8CAFC4", - memory: "7F96B2", - observability: "6D7482", - onboard: "E6E0C8", - provider: "8A7896", - runtime: "8E88AF", - security: "D99084", - service: "B3C7D6", - skillforge: "B9B2DA", - skills: "C8C2E0", - tool: "9BCFBF", - tunnel: "8DAEC0", - tests: "DCE9EE", - scripts: "E7DFC6", - dev: "C4D3DE", + "size: XS": "EAF1F4", + "size: S": "DEE9EF", + "size: M": "D0DDE6", + "size: L": "C1D0DC", + "size: XL": "B2C3D1", + "risk: low": "BFD8B5", + "risk: medium": "E4D39B", + "risk: high": "E1A39A", + "risk: manual": "B9B1D2", + ...otherLabelColors, }; const staticLabelDescriptions = { "size: XS": "Auto size: <=80 non-doc changed lines.", @@ -250,29 +278,12 @@ jobs: } } - const modulePrefixColors = { - "agent:": "9FC4B8", - "channel:": "AFC4D6", - "config:": "C3BCD8", - "cron:": "C7D6A5", - "daemon:": "7C7F95", - "doctor:": "A8D6CD", - "gateway:": "D8A58F", - "health:": "A7DCCB", - "heartbeat:": "B7ACE0", - "integration:": "8CAFC4", - "memory:": "7F96B2", - "observability:": "6D7482", - "onboard:": "E6E0C8", - "provider:": "8A7896", - "runtime:": "8E88AF", - "security:": "D99084", - "service:": "B3C7D6", - "skillforge:": "B9B2DA", - "skills:": "C8C2E0", - "tool:": "9BCFBF", - "tunnel:": "8DAEC0", - }; + const modulePrefixColors = Object.fromEntries( + modulePrefixPriority.map((prefix) => [ + `${prefix}:`, + otherLabelColors[prefix] || "BFDADC", + ]) + ); const providerKeywordHints = [ "deepseek", diff --git a/docs/ci-map.md b/docs/ci-map.md index f455ee1..d3880b5 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -34,6 +34,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - 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: 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 - 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 diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index c894652..b2cb4ea 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -53,6 +53,7 @@ Maintain these branch protection rules on `main`: - 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. ### Step B: Validation From 140dad1f72d33645729142531cb284c0d276715a Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 20:01:36 +0800 Subject: [PATCH 4/4] style(labeler): lock low-saturation ordered module palette --- .github/workflows/labeler.yml | 117 ++++++++++------------------------ 1 file changed, 33 insertions(+), 84 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e7cfa27..f3ce10c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -108,36 +108,37 @@ jobs: { root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) }, ]; const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))]; - const otherLabelDisplayOrder = [ - "health", - "tool", - "agent", - "memory", - "channel", - "service", - "integration", - "tunnel", - "config", - "observability", - "docs", - "dev", - "tests", - "skills", - "skillforge", - "provider", - "runtime", - "heartbeat", - "daemon", - "doctor", - "onboard", - "cron", - "ci", - "dependencies", - "gateway", - "security", - "core", - "scripts", + 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]; @@ -164,61 +165,9 @@ jobs: contributorDisplayOrder.map((label, index) => [label, index]) ); - function hslToHex(h, s, l) { - const saturation = s / 100; - const lightness = l / 100; - const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; - const huePrime = ((h % 360) + 360) % 360 / 60; - const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); - let red = 0; - let green = 0; - let blue = 0; - - if (huePrime >= 0 && huePrime < 1) { - red = chroma; - green = x; - } else if (huePrime < 2) { - red = x; - green = chroma; - } else if (huePrime < 3) { - green = chroma; - blue = x; - } else if (huePrime < 4) { - green = x; - blue = chroma; - } else if (huePrime < 5) { - red = x; - blue = chroma; - } else { - red = chroma; - blue = x; - } - - const match = lightness - chroma / 2; - const toHex = (value) => - Math.round((value + match) * 255) - .toString(16) - .padStart(2, "0"); - - return `${toHex(red)}${toHex(green)}${toHex(blue)}`.toUpperCase(); - } - - function buildGradientColorMap(labels) { - const colorMap = {}; - const lastIndex = Math.max(labels.length - 1, 1); - - for (let index = 0; index < labels.length; index += 1) { - const ratio = index / lastIndex; - const hue = 155 - ratio * 147; - const saturation = 34 + ratio * 8; - const lightness = 74 - ratio * 8; - colorMap[labels[index]] = hslToHex(hue, saturation, lightness); - } - - return colorMap; - } - - const otherLabelColors = buildGradientColorMap(otherLabelDisplayOrder); + const otherLabelColors = Object.fromEntries( + orderedOtherLabelStyles.map((entry) => [entry.label, entry.color]) + ); const staticLabelColors = { "size: XS": "EAF1F4", "size: S": "DEE9EF",