diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..fd7da0be --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,166 @@ +# Scope labels for actions/labeler@v6 +# Maps file path globs to scope labels. Multiple labels can apply per PR. + +"scope: agent": + - changed-files: + - any-glob-to-any-file: + - src/agent/** + +"scope: channel": + - changed-files: + - any-glob-to-any-file: + - src/channels/channel.rs + - src/channels/manager.rs + - src/channels/mod.rs + +"scope: channel/cli": + - changed-files: + - any-glob-to-any-file: + - src/channels/cli/** + - src/cli/** + +"scope: channel/web": + - changed-files: + - any-glob-to-any-file: + - src/channels/web/** + +"scope: channel/wasm": + - changed-files: + - any-glob-to-any-file: + - src/channels/wasm/** + +"scope: tool": + - changed-files: + - any-glob-to-any-file: + - src/tools/tool.rs + - src/tools/registry.rs + - src/tools/mod.rs + - src/tools/sandbox.rs + +"scope: tool/builtin": + - changed-files: + - any-glob-to-any-file: + - src/tools/builtin/** + +"scope: tool/wasm": + - changed-files: + - any-glob-to-any-file: + - src/tools/wasm/** + +"scope: tool/mcp": + - changed-files: + - any-glob-to-any-file: + - src/tools/mcp/** + +"scope: tool/builder": + - changed-files: + - any-glob-to-any-file: + - src/tools/builder/** + +"scope: db": + - changed-files: + - any-glob-to-any-file: + - src/db/mod.rs + +"scope: db/postgres": + - changed-files: + - any-glob-to-any-file: + - src/db/postgres.rs + - migrations/** + +"scope: db/libsql": + - changed-files: + - any-glob-to-any-file: + - src/db/libsql_backend.rs + - src/db/libsql_migrations.rs + +"scope: safety": + - changed-files: + - any-glob-to-any-file: + - src/safety/** + +"scope: llm": + - changed-files: + - any-glob-to-any-file: + - src/llm/** + +"scope: workspace": + - changed-files: + - any-glob-to-any-file: + - src/workspace/** + +"scope: orchestrator": + - changed-files: + - any-glob-to-any-file: + - src/orchestrator/** + +"scope: worker": + - changed-files: + - any-glob-to-any-file: + - src/worker/** + +"scope: secrets": + - changed-files: + - any-glob-to-any-file: + - src/secrets/** + +"scope: config": + - changed-files: + - any-glob-to-any-file: + - src/config.rs + - src/settings.rs + +"scope: extensions": + - changed-files: + - any-glob-to-any-file: + - src/extensions/** + +"scope: setup": + - changed-files: + - any-glob-to-any-file: + - src/setup/** + +"scope: evaluation": + - changed-files: + - any-glob-to-any-file: + - src/evaluation/** + +"scope: estimation": + - changed-files: + - any-glob-to-any-file: + - src/estimation/** + +"scope: sandbox": + - changed-files: + - any-glob-to-any-file: + - src/sandbox/** + - Dockerfile* + +"scope: hooks": + - changed-files: + - any-glob-to-any-file: + - src/hooks/** + +"scope: pairing": + - changed-files: + - any-glob-to-any-file: + - src/pairing/** + +"scope: ci": + - changed-files: + - any-glob-to-any-file: + - .github/workflows/** + - .github/scripts/** + +"scope: docs": + - changed-files: + - any-glob-to-any-file: + - "**/*.md" + - docs/** + - LICENSE* + +"scope: dependencies": + - changed-files: + - any-glob-to-any-file: + - Cargo.toml + - Cargo.lock diff --git a/.github/scripts/create-labels.sh b/.github/scripts/create-labels.sh new file mode 100755 index 00000000..8386fae4 --- /dev/null +++ b/.github/scripts/create-labels.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Idempotent label bootstrap for IronClaw PR automation. +# Uses `gh label create --force` so it can be re-run safely. +# +# Usage: bash .github/scripts/create-labels.sh +# Requires: gh CLI authenticated with repo scope + +set -euo pipefail + +if ! command -v gh &>/dev/null; then + echo "Error: gh CLI is required. Install from https://cli.github.com" >&2 + exit 1 +fi + +create() { + local name="$1" color="$2" description="$3" + gh label create "$name" --color "$color" --description "$description" --force +} + +echo "==> Creating size labels..." +create "size: XS" "F9D0C4" "< 10 changed lines (excluding docs)" +create "size: S" "F5A3A3" "10-49 changed lines" +create "size: M" "E57373" "50-199 changed lines" +create "size: L" "D32F2F" "200-499 changed lines" +create "size: XL" "B71C1C" "500+ changed lines" + +echo "==> Creating risk labels..." +create "risk: low" "4CAF50" "Changes to docs, tests, or low-risk modules" +create "risk: medium" "FFC107" "Business logic, config, or moderate-risk modules" +create "risk: high" "F44336" "Safety, secrets, auth, or critical infrastructure" +create "risk: manual" "9E9E9E" "Risk level set manually (sticky, not overwritten)" + +echo "==> Creating scope labels..." +create "scope: agent" "006B75" "Agent core (agent loop, router, scheduler)" +create "scope: channel" "00838F" "Channel infrastructure" +create "scope: channel/cli" "00897B" "TUI / CLI channel" +create "scope: channel/web" "00796B" "Web gateway channel" +create "scope: channel/wasm" "00695C" "WASM channel runtime" +create "scope: tool" "1565C0" "Tool infrastructure" +create "scope: tool/builtin" "1976D2" "Built-in tools" +create "scope: tool/wasm" "1E88E5" "WASM tool sandbox" +create "scope: tool/mcp" "2196F3" "MCP client" +create "scope: tool/builder" "42A5F5" "Dynamic tool builder" +create "scope: db" "4A148C" "Database trait / abstraction" +create "scope: db/postgres" "6A1B9A" "PostgreSQL backend" +create "scope: db/libsql" "7B1FA2" "libSQL / Turso backend" +create "scope: safety" "880E4F" "Prompt injection defense" +create "scope: llm" "4527A0" "LLM integration" +create "scope: workspace" "283593" "Persistent memory / workspace" +create "scope: orchestrator" "0D47A1" "Container orchestrator" +create "scope: worker" "01579B" "Container worker" +create "scope: secrets" "BF360C" "Secrets management" +create "scope: config" "E65100" "Configuration" +create "scope: extensions" "33691E" "Extension management" +create "scope: setup" "827717" "Onboarding / setup" +create "scope: evaluation" "558B2F" "Success evaluation" +create "scope: estimation" "9E9D24" "Cost/time estimation" +create "scope: sandbox" "00BFA5" "Docker sandbox" +create "scope: hooks" "6D4C41" "Git/event hooks" +create "scope: pairing" "4E342E" "Pairing mode" +create "scope: ci" "546E7A" "CI/CD workflows" +create "scope: docs" "78909C" "Documentation" +create "scope: dependencies" "90A4AE" "Dependency updates" + +echo "==> Creating contributor labels..." +create "contributor: new" "FFF9C4" "First-time contributor" +create "contributor: regular" "FFE082" "2-5 merged PRs" +create "contributor: experienced" "FFB74D" "6-19 merged PRs" +create "contributor: core" "FF8A65" "20+ merged PRs" + +echo "Done. All labels created/updated." diff --git a/.github/scripts/pr-labeler.sh b/.github/scripts/pr-labeler.sh new file mode 100755 index 00000000..96dc0fa7 --- /dev/null +++ b/.github/scripts/pr-labeler.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Classify a PR by size, risk, and contributor tier. +# Called by the pr-label-classify workflow. +# +# Inputs (env vars): +# PR_NUMBER — pull request number +# REPO — owner/repo (e.g. "user/ironclaw") +# +# Requires: gh CLI, jq + +set -euo pipefail + +PR_NUMBER="${PR_NUMBER:?PR_NUMBER is required}" +REPO="${REPO:?REPO is required}" + +# ─── helpers ──────────────────────────────────────────────────────────────── + +# Remove all labels in a dimension except the desired one. +# Usage: set_exclusive_label "size" "size: M" +set_exclusive_label() { + local prefix="$1" desired="$2" + + # Fetch current labels on the PR + local current + current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name') + + # Remove any existing label with the same prefix + while IFS= read -r label; do + [[ -z "$label" ]] && continue + if [[ "$label" == "${prefix}:"* && "$label" != "$desired" ]]; then + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$label" 2>/dev/null || true + fi + done <<< "$current" + + # Add the desired label + gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$desired" +} + +# ─── size ─────────────────────────────────────────────────────────────────── + +classify_size() { + # Sum changed lines across non-doc files + local total + total=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \ + --paginate --jq ' + [.[] | select(.filename | test("\\.(md|txt|rst|adoc)$") | not) | .changes] + | add // 0 + ') + + local label + if (( total < 10 )); then label="size: XS" + elif (( total < 50 )); then label="size: S" + elif (( total < 200 )); then label="size: M" + elif (( total < 500 )); then label="size: L" + else label="size: XL" + fi + + echo "Size: ${total} changed lines -> ${label}" + set_exclusive_label "size" "$label" +} + +# ─── risk ─────────────────────────────────────────────────────────────────── + +classify_risk() { + # If "risk: manual" is present, skip — it's a sticky override + local current + current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name') + if echo "$current" | grep -qx "risk: manual"; then + echo "Risk: skipped (manual override)" + return + fi + + # Fetch changed file paths + local files + files=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \ + --paginate --jq '.[].filename') + + local risk="low" + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + + case "$file" in + # High risk: safety, secrets, auth, crypto, setup, orchestrator auth + src/safety/*|src/secrets/*|src/llm/session.rs|src/orchestrator/auth.rs|\ + src/channels/web/auth.rs|src/setup/*) + risk="high" + break # can't go higher + ;; + + # Medium risk: agent core, config, database, worker, tools, channels + src/agent/*|src/config.rs|src/settings.rs|src/db/*|src/worker/*|\ + src/tools/*|src/channels/*|src/orchestrator/*|src/context/*|\ + src/hooks/*|src/sandbox/*|src/extensions/*|Cargo.toml|\ + .github/workflows/*) + # Only upgrade, never downgrade + [[ "$risk" != "high" ]] && risk="medium" + ;; + + # Low risk: docs, tests, estimation, evaluation, history, etc. + *) + ;; + esac + done <<< "$files" + + echo "Risk: ${risk}" + set_exclusive_label "risk" "risk: ${risk}" +} + +# ─── contributor tier ─────────────────────────────────────────────────────── + +classify_contributor() { + # Get PR author + local author + author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author.login') + + # Count merged PRs by this author in this repo + local count + count=$(gh pr list --repo "$REPO" --state merged --author "$author" \ + --limit 100 --json number --jq 'length') + + local label + if (( count == 0 )); then label="contributor: new" + elif (( count < 6 )); then label="contributor: regular" + elif (( count < 20 )); then label="contributor: experienced" + else label="contributor: core" + fi + + echo "Contributor: ${author} has ${count} merged PRs -> ${label}" + set_exclusive_label "contributor" "$label" +} + +# ─── main ─────────────────────────────────────────────────────────────────── + +echo "Classifying PR #${PR_NUMBER} in ${REPO}..." +classify_size +classify_risk +classify_contributor +echo "Done." diff --git a/.github/workflows/pr-label-classify.yml b/.github/workflows/pr-label-classify.yml new file mode 100644 index 00000000..90f141de --- /dev/null +++ b/.github/workflows/pr-label-classify.yml @@ -0,0 +1,26 @@ +name: "PR: Classify (Size, Risk, Contributor)" + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: read # needed for search/issues API (contributor count) + +jobs: + classify: + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Classify PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: bash .github/scripts/pr-labeler.sh diff --git a/.github/workflows/pr-label-scope.yml b/.github/workflows/pr-label-scope.yml new file mode 100644 index 00000000..1c388561 --- /dev/null +++ b/.github/workflows/pr-label-scope.yml @@ -0,0 +1,18 @@ +name: "PR: Scope Labels" + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + scope: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + sync-labels: false # additive only — never remove scope labels