mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 09:25:57 +00:00
feat(ci): PR/issue auto-labeling + declarative label sync (#3360)
- .github/labels.yml: declarative source of truth (29 namespaced labels) - scripts/sync_labels.py + label-sync.yml: idempotent label sync (self-bootstraps on merge) - labeler.yml + pr-labeler.yml: area:* labels by changed path (actions/labeler) - pr-triage.yml: size/*, risk:*, needs-validation, first-time-contributor, reviewing - issue-triage.yml: needs-triage on new issues (self-healing) All PR workflows use pull_request_target but never check out or run PR code (read changed-file metadata via the API only).
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
name: PR Triage
|
||||
|
||||
# Two responsibilities, both pure-metadata (no PR code is checked out or run):
|
||||
# 1. On open/sync: apply size/* + risk:* labels, and needs-validation when the
|
||||
# PR touches the front/back contract surface (backend API, SSE, agents, or
|
||||
# the frontend streaming client). A `skip-validation` label opts out.
|
||||
# 2. On maintainer review: apply the `reviewing` label.
|
||||
#
|
||||
# All labels are managed within their own namespace — labels outside size/*,
|
||||
# risk:*, needs-validation and reviewing are never touched here.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-triage-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
size-and-risk:
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label size, risk and validation need
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = pr.number;
|
||||
|
||||
// ---- size, from additions + deletions ----
|
||||
const churn = (pr.additions || 0) + (pr.deletions || 0);
|
||||
const sizeLabel =
|
||||
churn < 20 ? 'size/XS' :
|
||||
churn < 100 ? 'size/S' :
|
||||
churn < 300 ? 'size/M' :
|
||||
churn < 700 ? 'size/L' : 'size/XL';
|
||||
|
||||
// ---- changed paths ----
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: prNumber, per_page: 100,
|
||||
});
|
||||
const paths = files.map(f => f.filename);
|
||||
|
||||
const matches = (re) => paths.some(p => re.test(p));
|
||||
|
||||
const docsOnly = paths.length > 0 && paths.every(p =>
|
||||
/\.(md|mdx|txt)$/i.test(p) || p.startsWith('docs/') ||
|
||||
/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(p));
|
||||
|
||||
const highRisk = matches(
|
||||
/^backend\/app\/gateway\//) || matches(
|
||||
/^backend\/packages\/harness\/deerflow\/(agents|subagents|sandbox)\//) || matches(
|
||||
/(^|\/)langgraph\.json$/) || matches(
|
||||
/(^|\/)(auth|authz|security)/i) || matches(
|
||||
/(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/) || matches(
|
||||
/^docker\//) || matches(
|
||||
/^\.github\/workflows\//);
|
||||
|
||||
const riskLabel = docsOnly ? 'risk:low' : (highRisk ? 'risk:high' : 'risk:medium');
|
||||
|
||||
// needs-validation: front/back contract surface
|
||||
const contractSurface =
|
||||
matches(/^backend\/app\/gateway\//) ||
|
||||
matches(/^backend\/packages\/harness\/deerflow\/(agents|subagents)\//) ||
|
||||
matches(/(^|\/)langgraph\.json$/) ||
|
||||
matches(/^frontend\/src\/core\/(api|threads|messages)\//);
|
||||
|
||||
const current = (pr.labels || []).map(l => l.name);
|
||||
const hasSkip = current.includes('skip-validation');
|
||||
|
||||
const desired = [sizeLabel, riskLabel];
|
||||
if (contractSurface && !hasSkip) desired.push('needs-validation');
|
||||
|
||||
const managed = (name) =>
|
||||
name.startsWith('size/') || name.startsWith('risk:') || name === 'needs-validation';
|
||||
|
||||
const toRemove = current.filter(l => managed(l) && !desired.includes(l));
|
||||
const toAdd = desired.filter(l => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd });
|
||||
}
|
||||
core.info(`size=${sizeLabel} risk=${riskLabel} churn=${churn} ` +
|
||||
`validation=${desired.includes('needs-validation')} ` +
|
||||
`(+${toAdd.join(',') || '-'} / -${toRemove.join(',') || '-'})`);
|
||||
|
||||
first-time:
|
||||
if: github.event_name == 'pull_request_target' && github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label first-time contributors
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const assoc = pr.author_association;
|
||||
const isBot = pr.user.type === 'Bot';
|
||||
core.info(`author=${pr.user.login} association=${assoc} bot=${isBot}`);
|
||||
|
||||
// FIRST_TIME_CONTRIBUTOR = no prior merged commit to this repo;
|
||||
// FIRST_TIMER = no prior commit anywhere on GitHub. Either counts.
|
||||
if (isBot || !['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(assoc)) {
|
||||
core.info('Not a first-time contributor; skipping.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: pr.number, labels: ['first-time-contributor'],
|
||||
});
|
||||
core.info(`Added first-time-contributor to #${pr.number}.`);
|
||||
|
||||
reviewing:
|
||||
if: github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add reviewing label for maintainer reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const reviewer = context.payload.review.user.login;
|
||||
|
||||
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner, repo, username: reviewer,
|
||||
});
|
||||
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
|
||||
core.info(`Reviewer ${reviewer} (${perm.permission}) is not a maintainer; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber,
|
||||
});
|
||||
if (labels.some(l => l.name === 'reviewing')) {
|
||||
core.info('Already labeled reviewing; skipping.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['reviewing'],
|
||||
});
|
||||
core.info(`Added "reviewing" (reviewer ${reviewer}).`);
|
||||
} catch (e) {
|
||||
// 403 is expected for review events on some fork PR contexts.
|
||||
if (e.status === 403) core.info('No permission to label (expected on some fork PRs).');
|
||||
else throw e;
|
||||
}
|
||||
Reference in New Issue
Block a user