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:
parent
b5d9f72023
commit
6d56a040ce
16 changed files with 1635 additions and 154 deletions
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,12 +1,15 @@
|
|||
name: Bug Report
|
||||
description: Report a reproducible defect in ZeroClaw
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
Please provide a minimal reproducible case so maintainers can triage quickly.
|
||||
Do not include personal/sensitive data; redact and anonymize all logs/payloads.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
|
|
@ -17,6 +20,34 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Affected component
|
||||
options:
|
||||
- runtime/daemon
|
||||
- provider
|
||||
- channel
|
||||
- memory
|
||||
- security/sandbox
|
||||
- tooling/ci
|
||||
- docs
|
||||
- unknown
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Severity
|
||||
options:
|
||||
- S0 - data loss / security risk
|
||||
- S1 - workflow blocked
|
||||
- S2 - degraded behavior
|
||||
- S3 - minor issue
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current
|
||||
attributes:
|
||||
|
|
@ -48,11 +79,23 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: Who is affected, how often, and practical consequences.
|
||||
placeholder: |
|
||||
Affected users: ...
|
||||
Frequency: always/intermittent
|
||||
Consequence: ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / stack traces
|
||||
description: Paste relevant logs (redact secrets).
|
||||
description: Paste relevant logs (redact secrets, personal identifiers, and sensitive data).
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
|
|
@ -91,3 +134,15 @@ body:
|
|||
- No, first-time setup
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-flight checks
|
||||
options:
|
||||
- label: I reproduced this on the latest main branch or latest release.
|
||||
required: true
|
||||
- label: I redacted secrets/tokens from logs.
|
||||
required: true
|
||||
- label: I removed personal identifiers and replaced identity-specific data with neutral placeholders.
|
||||
required: true
|
||||
|
|
|
|||
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,11 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability report
|
||||
url: https://github.com/theonlyhennygod/zeroclaw/security/policy
|
||||
url: https://github.com/zeroclaw-labs/zeroclaw/security/policy
|
||||
about: Please report security vulnerabilities privately via SECURITY.md policy.
|
||||
- name: Contribution guide
|
||||
url: https://github.com/theonlyhennygod/zeroclaw/blob/main/CONTRIBUTING.md
|
||||
url: https://github.com/zeroclaw-labs/zeroclaw/blob/main/CONTRIBUTING.md
|
||||
about: Please read contribution and PR requirements before opening an issue.
|
||||
- name: PR workflow & reviewer expectations
|
||||
url: https://github.com/zeroclaw-labs/zeroclaw/blob/main/docs/pr-workflow.md
|
||||
about: Read risk-based PR tracks, CI gates, and merge criteria before filing feature requests.
|
||||
|
|
|
|||
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,19 +1,31 @@
|
|||
name: Feature Request
|
||||
description: Propose an improvement or new capability
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for sharing your idea.
|
||||
Please focus on user value, constraints, and rollout safety.
|
||||
Do not include personal/sensitive data; use neutral project-scoped placeholders.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-line statement of the requested capability.
|
||||
placeholder: Add a provider-level retry budget override for long-running channels.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem statement
|
||||
description: What user problem are you trying to solve?
|
||||
placeholder: Teams need a way to ...
|
||||
description: What user pain does this solve and why is current behavior insufficient?
|
||||
placeholder: Teams operating in unstable networks cannot tune retries per provider...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -21,8 +33,17 @@ body:
|
|||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the preferred solution.
|
||||
placeholder: Add a new subcommand / trait implementation ...
|
||||
description: Describe preferred behavior and interfaces.
|
||||
placeholder: Add `[provider.retry]` config and enforce bounds in config validation.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: non_goals
|
||||
attributes:
|
||||
label: Non-goals / out of scope
|
||||
description: Clarify what should not be included in the first iteration.
|
||||
placeholder: No UI changes, no cross-provider dynamic adaptation in v1.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -31,16 +52,28 @@ body:
|
|||
attributes:
|
||||
label: Alternatives considered
|
||||
description: What alternatives did you evaluate?
|
||||
placeholder: Keep current behavior, use external tool, etc.
|
||||
placeholder: Keep current behavior, use wrapper scripts, etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: What outcomes would make this request complete?
|
||||
placeholder: |
|
||||
- Config key is documented and validated
|
||||
- Runtime path uses configured retry budget
|
||||
- Regression tests cover fallback and invalid config
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: architecture
|
||||
attributes:
|
||||
label: Architecture impact
|
||||
description: Which subsystem(s) are affected?
|
||||
placeholder: providers/, channels/, memory/, runtime/, security/ ...
|
||||
placeholder: providers/, channels/, memory/, runtime/, security/, docs/ ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -62,3 +95,13 @@ body:
|
|||
- Yes
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: hygiene
|
||||
attributes:
|
||||
label: Data hygiene checks
|
||||
options:
|
||||
- label: I removed personal/sensitive data from examples, payloads, and logs.
|
||||
required: true
|
||||
- label: I used neutral, project-focused wording and placeholders.
|
||||
required: true
|
||||
|
|
|
|||
35
.github/dependabot.yml
vendored
Normal file
35
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: main
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
rust-minor-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: main
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
- "dependencies"
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
112
.github/labeler.yml
vendored
112
.github/labeler.yml
vendored
|
|
@ -1,59 +1,147 @@
|
|||
"type: docs":
|
||||
"docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- "LICENSE"
|
||||
- ".markdownlint-cli2.yaml"
|
||||
|
||||
"type: dependencies":
|
||||
"dependencies":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "deny.toml"
|
||||
- ".github/dependabot.yml"
|
||||
|
||||
"type: ci":
|
||||
"ci":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- ".github/**"
|
||||
- ".githooks/**"
|
||||
|
||||
"area: providers":
|
||||
"core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/providers/**"
|
||||
- "src/*.rs"
|
||||
|
||||
"area: channels":
|
||||
"agent":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/agent/**"
|
||||
|
||||
"channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/channels/**"
|
||||
|
||||
"area: memory":
|
||||
"gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
|
||||
"config":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/config/**"
|
||||
|
||||
"cron":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/cron/**"
|
||||
|
||||
"daemon":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/daemon/**"
|
||||
|
||||
"doctor":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/doctor/**"
|
||||
|
||||
"health":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/health/**"
|
||||
|
||||
"heartbeat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/heartbeat/**"
|
||||
|
||||
"integration":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/integrations/**"
|
||||
|
||||
"memory":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/memory/**"
|
||||
|
||||
"area: security":
|
||||
"security":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/security/**"
|
||||
|
||||
"area: runtime":
|
||||
"runtime":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/runtime/**"
|
||||
|
||||
"area: tools":
|
||||
"onboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/onboard/**"
|
||||
|
||||
"provider":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/providers/**"
|
||||
|
||||
"service":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/service/**"
|
||||
|
||||
"skillforge":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/skillforge/**"
|
||||
|
||||
"skills":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/skills/**"
|
||||
|
||||
"tool":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/tools/**"
|
||||
|
||||
"area: observability":
|
||||
"tunnel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/tunnel/**"
|
||||
|
||||
"observability":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/observability/**"
|
||||
|
||||
"area: tests":
|
||||
"tests":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "tests/**"
|
||||
|
||||
"scripts":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "scripts/**"
|
||||
|
||||
"dev":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "dev/**"
|
||||
|
|
|
|||
83
.github/pull_request_template.md
vendored
83
.github/pull_request_template.md
vendored
|
|
@ -7,33 +7,30 @@ Describe this PR in 2-5 bullets:
|
|||
- What changed:
|
||||
- What did **not** change (scope boundary):
|
||||
|
||||
## Change Type
|
||||
## Label Snapshot (required)
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore / infra
|
||||
- Risk label (`risk: low|medium|high`):
|
||||
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
|
||||
- Scope labels (`core|agent|channel|config|cron|daemon|doctor|gateway|health|heartbeat|integration|memory|observability|onboard|provider|runtime|security|service|skillforge|skills|tool|tunnel|docs|dependencies|ci|tests|scripts|dev`, comma-separated):
|
||||
- Module labels (`<module>:<component>`, for example `channel:telegram`, `provider:kimi`, `tool:shell`):
|
||||
- Contributor tier label (`experienced contributor|principal contributor|distinguished contributor`, auto-managed/read-only; author merged PRs >=10/20/50):
|
||||
- If any auto-label is incorrect, note requested correction:
|
||||
|
||||
## Scope
|
||||
## Change Metadata
|
||||
|
||||
- [ ] Core runtime / daemon
|
||||
- [ ] Provider integration
|
||||
- [ ] Channel integration
|
||||
- [ ] Memory / storage
|
||||
- [ ] Security / sandbox
|
||||
- [ ] CI / release / tooling
|
||||
- [ ] Documentation
|
||||
- Change type (`bug|feature|refactor|docs|security|chore`):
|
||||
- Primary scope (`runtime|provider|channel|memory|security|ci|docs|multi`):
|
||||
|
||||
## Linked Issue
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
- Depends on # (if stacked)
|
||||
- Supersedes # (if replacing older PR)
|
||||
|
||||
## Testing
|
||||
## Validation Evidence (required)
|
||||
|
||||
Commands and result summary (required):
|
||||
Commands and result summary:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
|
|
@ -41,9 +38,10 @@ cargo clippy --all-targets -- -D warnings
|
|||
cargo test
|
||||
```
|
||||
|
||||
If any command is intentionally skipped, explain why.
|
||||
- Evidence provided (test/log/trace/screenshot/perf):
|
||||
- If any command is intentionally skipped, explain why:
|
||||
|
||||
## Security Impact
|
||||
## Security Impact (required)
|
||||
|
||||
- New permissions/capabilities? (`Yes/No`)
|
||||
- New external network calls? (`Yes/No`)
|
||||
|
|
@ -51,20 +49,49 @@ If any command is intentionally skipped, explain why.
|
|||
- File system access scope changed? (`Yes/No`)
|
||||
- If any `Yes`, describe risk and mitigation:
|
||||
|
||||
## Privacy and Data Hygiene (required)
|
||||
|
||||
- Data-hygiene status (`pass|needs-follow-up`):
|
||||
- Redaction/anonymization notes:
|
||||
- Neutral wording confirmation (use ZeroClaw/project-native labels if identity-like wording is needed):
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, exact upgrade steps:
|
||||
|
||||
## Human Verification (required)
|
||||
|
||||
What was personally validated beyond CI:
|
||||
|
||||
- Verified scenarios:
|
||||
- Edge cases checked:
|
||||
- What was not verified:
|
||||
|
||||
## Side Effects / Blast Radius (required)
|
||||
|
||||
- Affected subsystems/workflows:
|
||||
- Potential unintended effects:
|
||||
- Guardrails/monitoring for early detection:
|
||||
|
||||
## Agent Collaboration Notes (recommended)
|
||||
|
||||
- [ ] If agent/automation tools were used, I added brief workflow notes.
|
||||
- [ ] I included concrete validation evidence for this change.
|
||||
- [ ] I can explain design choices and rollback steps.
|
||||
|
||||
If agent tools were used, optional context:
|
||||
|
||||
- Tool(s):
|
||||
- Prompt/plan summary:
|
||||
- Agent tools used (if any):
|
||||
- Workflow/plan summary (if any):
|
||||
- Verification focus:
|
||||
- Confirmation: naming + architecture boundaries followed (`AGENTS.md` + `CONTRIBUTING.md`):
|
||||
|
||||
## Rollback Plan
|
||||
## Rollback Plan (required)
|
||||
|
||||
- Fast rollback command/path:
|
||||
- Feature flags or config toggles (if any):
|
||||
- Observable failure symptoms:
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
List real risks in this PR (or write `None`).
|
||||
|
||||
- Risk:
|
||||
- Mitigation:
|
||||
|
|
|
|||
215
.github/workflows/auto-response.yml
vendored
215
.github/workflows/auto-response.yml
vendored
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
124
.github/workflows/ci.yml
vendored
124
.github/workflows/ci.yml
vendored
|
|
@ -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."
|
||||
|
|
|
|||
457
.github/workflows/labeler.yml
vendored
457
.github/workflows/labeler.yml
vendored
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue