diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..343b87496 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,72 @@ +# Path-based PR auto-labeling config for actions/labeler@v5. +# Each key is a label (must exist — see .github/labels.yml); the globs decide +# when it is applied. A PR can match several areas, which is expected. + +"area:frontend": + - changed-files: + - any-glob-to-any-file: + - "frontend/**" + +"area:backend": + - changed-files: + - any-glob-to-any-file: + - "backend/app/**" + - "backend/packages/harness/deerflow/runtime/**" + - "backend/packages/harness/deerflow/persistence/**" + - "backend/packages/harness/deerflow/config/**" + - "backend/packages/harness/deerflow/tools/**" + - "backend/packages/harness/deerflow/guardrails/**" + - "backend/packages/harness/deerflow/tracing/**" + - "backend/packages/harness/deerflow/models/**" + - "backend/packages/harness/deerflow/utils/**" + - "backend/packages/harness/deerflow/uploads/**" + +"area:agents": + - changed-files: + - any-glob-to-any-file: + - "backend/packages/harness/deerflow/agents/**" + - "backend/packages/harness/deerflow/subagents/**" + - "backend/packages/harness/deerflow/reflection/**" + - "backend/langgraph.json" + - "backend/**/prompts/**" + +"area:sandbox": + - changed-files: + - any-glob-to-any-file: + - "docker/**" + - "backend/packages/harness/deerflow/sandbox/**" + - "backend/Dockerfile" + - "frontend/Dockerfile" + +"area:skills": + - changed-files: + - any-glob-to-any-file: + - "skills/**" + - "backend/packages/harness/deerflow/skills/**" + - "frontend/src/core/skills/**" + +"area:mcp": + - changed-files: + - any-glob-to-any-file: + - "backend/packages/harness/deerflow/mcp/**" + - "frontend/src/core/mcp/**" + +"area:ci": + - changed-files: + - any-glob-to-any-file: + - ".github/**" + - "scripts/**" + +"area:docs": + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - "**/*.md" + +"area:deps": + - changed-files: + - any-glob-to-any-file: + - "backend/pyproject.toml" + - "backend/uv.lock" + - "frontend/package.json" + - "frontend/pnpm-lock.yaml" diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 000000000..50e9e86c0 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,119 @@ +# Declarative label source of truth for DeerFlow. +# +# This file is the single source of truth for repository labels used by the +# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml, +# issue-triage.yml). Auto-labelers can only apply labels that already exist, +# so every label referenced by a workflow MUST be declared here. +# +# Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME] +# CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here). +# +# Sync is additive/update-only: it creates or updates the labels listed below +# and never deletes labels that are not listed. +# +# Color = 6-digit hex without the leading '#'. + +labels: + # ── Type ───────────────────────────────────────────────────────────────── + # Mostly GitHub defaults; declared here so colors/descriptions stay stable + # and so issue templates can rely on them existing. + - name: bug + color: d73a4a + description: Something isn't working + - name: enhancement + color: a2eeef + description: New feature or request + - name: documentation + color: 0075ca + description: Improvements or additions to documentation + - name: question + color: d876e3 + description: Further information is requested + + # ── Area (auto, by changed paths — see .github/labeler.yml) ─────────────── + # Mirrors the "Surface area" section of the pull request template. + - name: "area:frontend" + color: c5def5 + description: Next.js frontend under frontend/ + - name: "area:backend" + color: c5def5 + description: Gateway / runtime / core backend under backend/ + - name: "area:agents" + color: c5def5 + description: Agents, subagents, graph wiring, prompts, langgraph.json + - name: "area:sandbox" + color: c5def5 + description: Sandboxed execution and docker/ + - name: "area:skills" + color: c5def5 + description: Skills under skills/ or the skills harness + - name: "area:mcp" + color: c5def5 + description: Model Context Protocol integration + - name: "area:ci" + color: c5def5 + description: GitHub Actions, CI config, repo tooling + - name: "area:docs" + color: c5def5 + description: Documentation and Markdown only + - name: "area:deps" + color: c5def5 + description: Dependency manifests / lockfiles + + # ── Size (auto, by additions + deletions — see pr-triage.yml) ───────────── + - name: "size/XS" + color: "009900" + description: PR changes < 20 lines + - name: "size/S" + color: 77bb00 + description: PR changes 20-100 lines + - name: "size/M" + color: eebb00 + description: PR changes 100-300 lines + - name: "size/L" + color: ee9900 + description: PR changes 300-700 lines + - name: "size/XL" + color: ee5500 + description: PR changes 700+ lines + + # ── Risk (auto, by changed paths — see pr-triage.yml) ───────────────────── + - name: "risk:low" + color: 0e8a16 + description: "Low risk: docs / i18n / assets only" + - name: "risk:medium" + color: fbca04 + description: "Medium risk: regular code changes" + - name: "risk:high" + color: b60205 + description: "High risk: backend API, agents, sandbox, auth, deps, CI" + + # ── Priority (manual) ───────────────────────────────────────────────────── + - name: P0 + color: b60205 + description: Critical priority + - name: P1 + color: d93f0b + description: Major priority + - name: P2 + color: e99695 + description: Normal priority + + # ── Status (auto + manual) ──────────────────────────────────────────────── + - name: needs-triage + color: fef2c0 + description: Awaiting maintainer triage + - name: needs-validation + color: d4c5f9 + description: Touches front/back contract surface; needs real-path validation + - name: skip-validation + color: cccccc + description: "Maintainer override: do not auto-add needs-validation on this PR" + - name: reviewing + color: 5319e7 + description: A maintainer is reviewing this PR + + # ── Contributor ─────────────────────────────────────────────────────────── + - name: first-time-contributor + color: c2e0c6 + description: First contribution to this repository — be welcoming diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 000000000..927561be7 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,44 @@ +name: Issue Triage + +# Ensures every newly opened issue carries `needs-triage`, even blank or +# API-created ones that bypass the issue templates. Creates the label if it is +# somehow missing, so the workflow is self-healing. + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + needs-triage: + runs-on: ubuntu-latest + steps: + - name: Add needs-triage label + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + + const current = (context.payload.issue.labels || []).map(l => l.name); + if (current.includes('needs-triage')) { + core.info('Issue already has needs-triage; nothing to do.'); + return; + } + + // Self-heal: create the label if it does not exist yet. + try { + await github.rest.issues.createLabel({ + owner, repo, name: 'needs-triage', color: 'fef2c0', + description: 'Awaiting maintainer triage', + }); + } catch (e) { + if (e.status !== 422) throw e; // 422 = already exists + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['needs-triage'], + }); + core.info(`Added needs-triage to #${issue_number}.`); diff --git a/.github/workflows/label-sync.yml b/.github/workflows/label-sync.yml new file mode 100644 index 000000000..c270e06f6 --- /dev/null +++ b/.github/workflows/label-sync.yml @@ -0,0 +1,38 @@ +name: Label Sync + +# Keeps repository labels in sync with the declarative source of truth +# (.github/labels.yml). Runs whenever that file changes on main, and can be +# triggered manually. Additive/update-only — never deletes labels. + +on: + push: + branches: [main] + paths: + - ".github/labels.yml" + - "scripts/sync_labels.py" + - ".github/workflows/label-sync.yml" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: label-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Sync labels + run: uv run --with pyyaml python scripts/sync_labels.py + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..6f4e9e279 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,28 @@ +name: PR Labeler + +# Applies area:* labels based on which files a PR changes (see .github/labeler.yml). +# Uses pull_request_target so it also works on fork PRs. SAFE: actions/labeler +# only reads the changed-file list via the API — it never checks out or runs PR code. + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Apply area labels + uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 000000000..c139e995e --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -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; + } diff --git a/scripts/sync_labels.py b/scripts/sync_labels.py new file mode 100644 index 000000000..2264573b5 --- /dev/null +++ b/scripts/sync_labels.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Sync GitHub labels from the declarative source of truth. + +Reads ``.github/labels.yml`` and creates/updates each label via the GitHub CLI +(``gh label create --force``). Sync is additive/update-only: labels not listed +in the file are left untouched (never deleted). + +Usage: + uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME] [--dry-run] + +Requires the ``gh`` CLI to be installed and authenticated (or ``GH_TOKEN`` set, +as in CI). When ``--repo`` is omitted, ``gh`` uses the current repository. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +try: + import yaml +except ModuleNotFoundError: # pragma: no cover - guidance for local runs + sys.exit( + "PyYAML is required. Run via:\n" + " uv run --with pyyaml python scripts/sync_labels.py" + ) + +LABELS_FILE = Path(__file__).resolve().parent.parent / ".github" / "labels.yml" + + +def load_labels(path: Path) -> list[dict[str, str]]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + labels = data.get("labels") + if not isinstance(labels, list) or not labels: + sys.exit(f"No labels found in {path}") + for label in labels: + if not isinstance(label, dict) or "name" not in label: + sys.exit(f"Invalid label entry (missing 'name'): {label!r}") + return labels + + +def sync_label(label: dict[str, str], repo: str | None, dry_run: bool) -> bool: + name = str(label["name"]) + color = str(label.get("color", "ededed")).lstrip("#") + description = str(label.get("description", "")) + + cmd = ["gh", "label", "create", name, "--color", color, "--force"] + if description: + cmd += ["--description", description] + if repo: + cmd += ["--repo", repo] + + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return True + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f" ✗ {name}: {result.stderr.strip()}", file=sys.stderr) + return False + print(f" ✓ {name}") + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="Target repository as OWNER/NAME") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the gh commands without executing them", + ) + args = parser.parse_args() + + labels = load_labels(LABELS_FILE) + target = args.repo or "(current repository)" + print(f"Syncing {len(labels)} labels to {target}") + + failures = sum( + 0 if sync_label(label, args.repo, args.dry_run) else 1 for label in labels + ) + + if failures: + print(f"\n{failures} label(s) failed to sync", file=sys.stderr) + return 1 + print(f"\nDone — {len(labels)} labels in sync.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())