* docs: harden collaboration policy and review automation * ci(docs): remove unsupported lychee --exclude-mail flag * docs(governance): reduce automation side-effects and tighten risk controls * docs(governance): add backlog pruning and supersede protocol * docs(agents): codify engineering principles and risk-tier workflow * docs(readme): add centered star history section at bottom * docs(agents): enforce privacy-safe and neutral test wording * docs(governance): enforce privacy-safe and neutral collaboration checks * fix(ci): satisfy rustfmt and discord schema test fields * docs(governance): require ZeroClaw-native identity wording * docs(agents): add ZeroClaw identity-safe naming palette * docs(governance): codify code naming and architecture contracts * docs(contributing): add naming and architecture good/bad examples * docs(pr): reduce checkbox TODOs and shift to label-first metadata * docs(pr): remove duplicate collaboration track field * ci(labeler): auto-derive module labels and expand provider hints * ci(labeler): auto-apply trusted contributor on PRs and issues * fix(ci): apply rustfmt updates from latest main * ci(labels): flatten namespaces and add contributor tiers * chore: drop stale rustfmt-only drift * ci: scope Rust and docs checks by change set * ci: exclude non-markdown docs from docs-quality targets * ci: satisfy actionlint shellcheck output style * ci(labels): auto-correct manual contributor tier edits * ci(labeler): auto-correct risk label edits * ci(labeler): auto-correct size label edits --------- Co-authored-by: Chummy <183474434+chumyin@users.noreply.github.com>
475 lines
18 KiB
YAML
475 lines
18 KiB
YAML
name: PR Labeler
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
issues: write
|
|
|
|
jobs:
|
|
label:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Apply path labels
|
|
uses: actions/labeler@v5
|
|
with:
|
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
sync-labels: true
|
|
|
|
- name: Apply size/risk/module labels
|
|
uses: actions/github-script@v7
|
|
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 = "39FF14";
|
|
|
|
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 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 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",
|
|
};
|
|
for (const label of contributorTierLabels) {
|
|
staticLabelColors[label] = contributorTierColor;
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
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 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";
|
|
}
|
|
|
|
async function ensureLabel(name) {
|
|
const expectedColor = colorForLabel(name);
|
|
try {
|
|
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name });
|
|
const currentColor = (existing.color || "").toUpperCase();
|
|
if (currentColor !== expectedColor) {
|
|
await github.rest.issues.updateLabel({
|
|
owner,
|
|
repo,
|
|
name,
|
|
new_name: name,
|
|
color: expectedColor,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name,
|
|
color: expectedColor,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
|
owner,
|
|
repo,
|
|
issue_number: pr.number,
|
|
});
|
|
const currentLabelNames = currentLabels.map((label) => label.name);
|
|
|
|
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,
|
|
...detectedModuleLabels,
|
|
]);
|
|
|
|
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 (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return false;
|
|
return true;
|
|
});
|
|
|
|
const manualRiskSelection =
|
|
currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel;
|
|
|
|
const moduleLabelList = [...detectedModuleLabels];
|
|
const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : [];
|
|
const nextLabels = hasManualRiskOverride
|
|
? [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, manualRiskSelection])]
|
|
: [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, riskLabel])];
|
|
|
|
await github.rest.issues.setLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: pr.number,
|
|
labels: nextLabels,
|
|
});
|