docs: strengthen collaboration governance and AGENTS engineering protocol (#263)

* 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>
This commit is contained in:
Chummy 2026-02-16 18:59:04 +08:00 committed by GitHub
parent b5d9f72023
commit 6d56a040ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1635 additions and 154 deletions

View file

@ -2,14 +2,123 @@ name: Auto Response
on:
issues:
types: [opened]
types: [opened, reopened, labeled, unlabeled]
pull_request_target:
types: [opened]
types: [opened, labeled, unlabeled]
permissions: {}
jobs:
contributor-tier-issues:
if: >-
(github.event_name == 'issues' &&
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) ||
(github.event_name == 'pull_request_target' &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled'))
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Apply contributor tier label for issue author
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const target = issue ?? pullRequest;
const legacyTrustedContributorLabel = "trusted contributor";
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 managedContributorLabels = new Set([
legacyTrustedContributorLabel,
...contributorTierLabels,
]);
const action = context.payload.action;
const changedLabel = context.payload.label?.name;
if (!target) return;
if ((action === "labeled" || action === "unlabeled") && !managedContributorLabels.has(changedLabel)) {
return;
}
const author = target.user;
if (!author || author.type === "Bot") return;
async function ensureContributorTierLabels() {
for (const label of contributorTierLabels) {
try {
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name: label });
const currentColor = (existing.color || "").toUpperCase();
if (currentColor !== contributorTierColor) {
await github.rest.issues.updateLabel({
owner,
repo,
name: label,
new_name: label,
color: contributorTierColor,
});
}
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: contributorTierColor,
});
}
}
}
function selectContributorTier(mergedCount) {
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
return matchedTier ? matchedTier.label : null;
}
let contributorTierLabel = null;
try {
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:${author.login}`,
per_page: 1,
});
const mergedCount = mergedSearch.total_count || 0;
contributorTierLabel = selectContributorTier(mergedCount);
} catch (error) {
core.warning(`failed to evaluate contributor tier status: ${error.message}`);
return;
}
await ensureContributorTierLabels();
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: target.number,
});
const keepLabels = currentLabels
.map((label) => label.name)
.filter((label) => label !== legacyTrustedContributorLabel && !contributorTierLabels.includes(label));
if (contributorTierLabel) {
keepLabels.push(contributorTierLabel);
}
await github.rest.issues.setLabels({
owner,
repo,
issue_number: target.number,
labels: [...new Set(keepLabels)],
});
first-interaction:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
permissions:
issues: write
@ -38,3 +147,105 @@ jobs:
- Scope is focused (prefer one concern per PR)
See `CONTRIBUTING.md` and `docs/pr-workflow.md` for full collaboration rules.
labeled-routes:
if: github.event.action == 'labeled'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Handle label-driven responses
uses: actions/github-script@v7
with:
script: |
const label = context.payload.label?.name;
if (!label) return;
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const target = issue ?? pullRequest;
if (!target) return;
const isIssue = Boolean(issue);
const issueNumber = target.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const rules = [
{
label: "r:support",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"This looks like a usage/support request. Please use README + docs first, then open a focused bug with repro details if behavior is incorrect.",
},
{
label: "r:needs-repro",
close: false,
message:
"Thanks for the report. Please add deterministic repro steps, exact environment, and redacted logs so maintainers can triage quickly.",
},
{
label: "invalid",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"Closing as invalid based on current information. If this is still relevant, open a new issue with updated evidence and reproducible steps.",
},
{
label: "duplicate",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"Closing as duplicate. Please continue discussion in the canonical linked issue/PR.",
},
];
const rule = rules.find((entry) => entry.label === label);
if (!rule) return;
const marker = `<!-- auto-response:${rule.label} -->`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyCommented = comments.some((comment) =>
(comment.body || "").includes(marker)
);
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${rule.message}\n\n${marker}`,
});
}
if (!rule.close) return;
if (rule.closeIssuesOnly && !isIssue) return;
if (target.state === "closed") return;
if (isIssue) {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: rule.closeReason || "not_planned",
});
} else {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
});
}

View file

@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main, develop]
branches: [main]
pull_request:
branches: [main]
@ -22,6 +22,9 @@ jobs:
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.scope.outputs.docs_only }}
docs_changed: ${{ steps.scope.outputs.docs_changed }}
rust_changed: ${{ steps.scope.outputs.rust_changed }}
docs_files: ${{ steps.scope.outputs.docs_files }}
steps:
- uses: actions/checkout@v4
with:
@ -33,6 +36,13 @@ jobs:
run: |
set -euo pipefail
write_empty_docs_files() {
{
echo "docs_files<<EOF"
echo "EOF"
} >> "$GITHUB_OUTPUT"
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
@ -40,17 +50,30 @@ jobs:
fi
if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
{
echo "docs_only=false"
echo "docs_changed=false"
echo "rust_changed=true"
} >> "$GITHUB_OUTPUT"
write_empty_docs_files
exit 0
fi
CHANGED="$(git diff --name-only "$BASE" HEAD || true)"
if [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
{
echo "docs_only=false"
echo "docs_changed=false"
echo "rust_changed=false"
} >> "$GITHUB_OUTPUT"
write_empty_docs_files
exit 0
fi
docs_only=true
docs_changed=false
rust_changed=false
docs_files=()
while IFS= read -r file; do
[ -z "$file" ] && continue
@ -58,21 +81,43 @@ jobs:
|| [[ "$file" == *.md ]] \
|| [[ "$file" == *.mdx ]] \
|| [[ "$file" == "LICENSE" ]] \
|| [[ "$file" == ".markdownlint-cli2.yaml" ]] \
|| [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \
|| [[ "$file" == .github/pull_request_template.md ]]; then
if [[ "$file" == *.md ]] \
|| [[ "$file" == *.mdx ]] \
|| [[ "$file" == "LICENSE" ]] \
|| [[ "$file" == .github/pull_request_template.md ]]; then
docs_changed=true
docs_files+=("$file")
fi
continue
fi
docs_only=false
break
if [[ "$file" == src/* ]] \
|| [[ "$file" == tests/* ]] \
|| [[ "$file" == "Cargo.toml" ]] \
|| [[ "$file" == "Cargo.lock" ]] \
|| [[ "$file" == "deny.toml" ]]; then
rust_changed=true
fi
done <<< "$CHANGED"
echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"
{
echo "docs_only=$docs_only"
echo "docs_changed=$docs_changed"
echo "rust_changed=$rust_changed"
echo "docs_files<<EOF"
printf '%s\n' "${docs_files[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
lint:
name: Format & Lint
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@ -92,7 +137,7 @@ jobs:
test:
name: Test
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@ -107,7 +152,7 @@ jobs:
build:
name: Build (Smoke)
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
@ -129,10 +174,45 @@ jobs:
- name: Skip heavy jobs for docs-only change
run: echo "Docs-only change detected. Rust lint/test/build skipped."
non-rust:
name: Non-Rust Fast Path
needs: [changes]
if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.rust_changed != 'true'
runs-on: ubuntu-latest
steps:
- name: Skip Rust jobs for non-Rust change scope
run: echo "No Rust-impacting files changed. Rust lint/test/build skipped."
docs-quality:
name: Docs Quality
needs: [changes]
if: needs.changes.outputs.docs_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Markdown lint
uses: DavidAnson/markdownlint-cli2-action@v20
with:
globs: ${{ needs.changes.outputs.docs_files }}
- name: Link check (offline)
uses: lycheeverse/lychee-action@v2
with:
fail: true
args: >-
--offline
--no-progress
--format detailed
${{ needs.changes.outputs.docs_files }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ci-required:
name: CI Required Gate
if: always()
needs: [changes, lint, test, build, docs-only]
needs: [changes, lint, test, build, docs-only, non-rust, docs-quality]
runs-on: ubuntu-latest
steps:
- name: Enforce required status
@ -140,11 +220,31 @@ jobs:
run: |
set -euo pipefail
docs_changed="${{ needs.changes.outputs.docs_changed }}"
rust_changed="${{ needs.changes.outputs.rust_changed }}"
docs_result="${{ needs.docs-quality.result }}"
if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then
echo "docs=${docs_result}"
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs-only change touched markdown docs, but docs-quality did not pass."
exit 1
fi
echo "Docs-only fast path passed."
exit 0
fi
if [ "$rust_changed" != "true" ]; then
echo "rust_changed=false (non-rust fast path)"
echo "docs=${docs_result}"
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs changed but docs-quality did not pass."
exit 1
fi
echo "Non-rust fast path passed."
exit 0
fi
lint_result="${{ needs.lint.result }}"
test_result="${{ needs.test.result }}"
build_result="${{ needs.build.result }}"
@ -152,10 +252,16 @@ jobs:
echo "lint=${lint_result}"
echo "test=${test_result}"
echo "build=${build_result}"
echo "docs=${docs_result}"
if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
echo "Required CI jobs did not pass."
exit 1
fi
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs changed but docs-quality did not pass."
exit 1
fi
echo "All required CI jobs passed."

View file

@ -2,7 +2,7 @@ name: PR Labeler
on:
pull_request_target:
types: [opened, reopened, synchronize, edited]
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
permissions:
contents: read
@ -17,15 +17,373 @@ jobs:
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: true
- name: Apply size label
- 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 labelColor = "BFDADC";
const changedLines = (pr.additions || 0) + (pr.deletions || 0);
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";
@ -33,38 +391,85 @@ jobs:
else if (changedLines <= 500) sizeLabel = "size: M";
else if (changedLines <= 1000) sizeLabel = "size: L";
for (const label of sizeLabels) {
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 {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
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) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColor,
});
core.warning(`failed to compute contributor tier label: ${error.message}`);
}
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
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 keepLabels = currentLabels
.map((label) => label.name)
.filter((label) => !sizeLabels.includes(label));
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])];
const nextLabels = [...new Set([...keepLabels, sizeLabel])];
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
issue_number: pr.number,
labels: nextLabels,
});