Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 394 additions & 3 deletions .github/labeler.yml

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions .github/workflows/labeler-signal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
---
# A doorbell for labeler.yml, and nothing more.
#
# A `pull_request` run holds no privileges, so this one does nothing at all:
# it has no permissions, checks nothing out and runs no code. Its only effect
# is that it completes, which fires labeler.yml's `workflow_run` trigger in the
# default branch's context, where the labeler reads the pull request through
# the API and labels it. `edited` is included so a pull request that starts
# referring to an issue passes its labels on to that issue, and `closed` so a
# merge passes an outside contributor's labels on (see labeler.yml).
name: "Labeler signal"
"on":
pull_request:
types: [opened, reopened, synchronize, ready_for_review, edited, closed]

permissions: {}

jobs:
signal:
runs-on: ubuntu-slim
timeout-minutes: 2
steps:
- name: Signal the labeler
run: echo "labeler.yml runs on this workflow's completion"
177 changes: 145 additions & 32 deletions .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,65 +15,178 @@
# specific language governing permissions and limitations
# under the License.
#
# Applies the tool-capability labels (`contract:*` / `substrate:*`) to new
# pull requests from the tool directories they touch. The mapping is
# `.github/labeler.yml`, generated from each tool README's `**Capability:**`
# line by tools/dev/generate-labeler-config.py.
# Labels pull requests from the files they touch, and passes those labels on
# to the issues each pull request closes or refers to. The mapping is
# `.github/labeler.yml`, generated by tools/dev/generate-labeler-config.py
# from tool READMEs (`contract:*` / `substrate:*`) and skill frontmatter
# (`family:*` / `capability:*`); see docs/labels-and-capabilities.md.
#
# It runs on a schedule rather than on a pull-request event: a scheduled run
# executes in this repository's context, so its token can label fork PRs
# without `pull_request_target`, and no PR event ever starts it. Nothing from
# a PR is checked out or run; the labeler reads the changed-file list and the
# config (from the default branch) through the API.
# Privilege boundary — read this before changing any trigger.
#
# Each run labels the open PRs created since the previous successful run
# started, so a PR is labelled once, shortly after it is opened. Path-based
# labels are a starting point (docs/labels-and-capabilities.md asks for the
# capability the change *implements*), and a label a maintainer removes
# afterwards is not re-added.
# This workflow holds a token that can label pull requests and issues, so it
# never checks out, builds or runs anything from a pull request. It does not
# use pull_request_target. Its triggers are signals only:
# - workflow_run fires when labeler-signal.yml (an unprivileged
# `pull_request` run that does nothing) completes. It always runs this file
# from the default branch, which is the property that makes it safe, and
# labels the pull request the moment it is opened or pushed to.
# - schedule is a daily safety net: it labels any open pull request that
# still has no `family:*` label (a run that failed or was dropped).
# - workflow_dispatch labels one pull request, or runs the safety net.
# The only value taken from the triggering event is the head SHA, checked to be
# 40 hex characters and used solely to find the pull request among the open
# ones. A pull request's body is read only to extract issue numbers, which are
# checked to be issues before any label is added; no text from it reaches a
# command. The labeler action reads the changed-file list and the config (from
# the default branch) through the API.
#
# Who decides which issues get labels: a pull request's body names them, and
# its author can edit the body at any time, even after the merge. So the body
# is trusted only when the author is an OWNER, MEMBER or COLLABORATOR. For
# anyone else the body is never read: their pull request labels only the issues
# its merge actually closed, which GitHub records as the issue's closer and
# nobody can edit afterwards.
#
# Labels are only ever added. A label a maintainer removes is re-added only
# when the pull request is pushed to again and still matches the rule.
---
name: "Tool capability labels"
name: "Pull request labels"
"on":
workflow_run: # zizmor: ignore[dangerous-triggers] -- default-branch code, no PR input; see header
workflows: ["Labeler signal"]
types: [completed]
schedule:
- cron: "17 * * * *"
- cron: "17 3 * * *"
workflow_dispatch:
inputs:
pr:
description: "Label this pull request number (blank: every open pull request without a family label)"
required: false
type: string
permissions: {}
concurrency:
group: tool-capability-labels
group: pull-request-labels
cancel-in-progress: false
jobs:
label:
name: Label new pull requests
name: Label pull requests and their issues
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-slim
timeout-minutes: 5
timeout-minutes: 10
permissions:
actions: read # find the previous successful run
contents: read # read .github/labeler.yml and the PRs' changed files
pull-requests: write # add the labels
contents: read # read .github/labeler.yml and the pull requests' changed files
pull-requests: write # add labels to pull requests
issues: write # add the same labels to the issues they close or refer to
steps:
- name: Select pull requests opened since the last run
- name: Select pull requests
id: select
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT: ${{ github.event_name }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
PR_INPUT: ${{ inputs.pr }}
run: |
set -euo pipefail
since=$(gh api "repos/$REPO/actions/workflows/labeler.yml/runs?status=success&per_page=1" \
--jq '.workflow_runs[0].run_started_at // empty')
if [ -z "$since" ]; then
since=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
fi
# Dependency and version bumps implement no capability: skip bot authors.
prs=$(gh pr list --repo "$REPO" --state open --limit 100 \
--search "created:>=$since" \
--json number,author --jq '.[] | select(.author.is_bot | not) | .number')
echo "since $since: ${prs:-none}" | tr '\n' ' '
open_prs() {
gh pr list --repo "$REPO" --state open --limit 200 \
--json number,headRefOid,author,labels --jq "$1"
}
case "$EVENT" in
workflow_run)
if ! [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::unexpected head SHA"; exit 1
fi
prs=$(open_prs ".[] | select(.headRefOid == \"$HEAD_SHA\" and (.author.is_bot | not)) | .number")
if [ -z "$prs" ]; then # closed: the merged pull request this commit belongs to
prs=$(gh api "repos/$REPO/commits/$HEAD_SHA/pulls" \
--jq '.[] | select(.merged_at != null and (.user.type != "Bot")) | .number')
fi
;;
workflow_dispatch)
if [ -n "$PR_INPUT" ]; then
if ! [[ "$PR_INPUT" =~ ^[0-9]+$ ]]; then
echo "::error::pr must be a pull request number"; exit 1
fi
prs=$PR_INPUT
else
prs=$(open_prs '.[] | select((.author.is_bot | not) and ([.labels[].name | startswith("family:")] | any | not)) | .number')
fi
;;
*)
prs=$(open_prs '.[] | select((.author.is_bot | not) and ([.labels[].name | startswith("family:")] | any | not)) | .number')
;;
esac
echo "pull requests: ${prs:-none}" | tr '\n' ' '
{
echo "prs<<EOF"
if [ -n "$prs" ]; then echo "$prs"; fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

- if: steps.select.outputs.prs != ''
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
pr-number: ${{ steps.select.outputs.prs }}

- name: Pass the labels on to linked issues
if: steps.select.outputs.prs != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PRS: ${{ steps.select.outputs.prs }}
run: |
set -euo pipefail
for pr in $PRS; do
[[ "$pr" =~ ^[0-9]+$ ]] || continue
read -r assoc merged < <(gh api "repos/$REPO/pulls/$pr" --jq '"\(.author_association) \(.merged)"')
labels=$(gh pr view "$pr" --repo "$REPO" --json labels \
--jq '[.labels[].name | select(test("^(family|capability|contract|substrate):"))] | join(",")')
[ -n "$labels" ] || continue
closing=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences \
--jq '.closingIssuesReferences[].number')
case "$assoc" in
OWNER|MEMBER|COLLABORATOR)
# A project member's body is trusted: the issues it closes, and the
# issues it refers to (#N, or this repository's issue URL).
mentioned=$(gh pr view "$pr" --repo "$REPO" --json body --jq '.body // ""' \
| grep -oE "(^|[^&0-9A-Za-z_/])#[0-9]+|github\.com/${REPO}/issues/[0-9]+" \
| grep -oE '[0-9]+$' || true)
;;
*)
# Anyone else: never the body, and only once merged. Keep just the
# issues whose recorded closer is this pull request.
if [ "$merged" != "true" ]; then
echo "#$pr: author is $assoc and it is not merged; its closed issues are labelled on merge"
continue
fi
mentioned=""
verified=""
for issue in $closing; do
[[ "$issue" =~ ^[0-9]+$ ]] || continue
closer=$(gh api graphql -F owner="${REPO%/*}" -F name="${REPO#*/}" -F number="$issue" -f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
timelineItems(itemTypes: [CLOSED_EVENT], last: 10) {
nodes { ... on ClosedEvent { closer { ... on PullRequest { number } } } }
}
}
}
}' --jq '[.data.repository.issue.timelineItems.nodes[].closer.number // empty] | map(tostring) | join(" ")' 2>/dev/null || true)
if [[ " $closer " == *" $pr "* ]]; then verified=$(printf '%s\n%s' "$verified" "$issue"); fi
done
closing=$verified
;;
esac
for issue in $(printf '%s\n%s\n' "$closing" "$mentioned" | grep -E '^[0-9]+$' | sort -un | head -20); do
[ "$issue" = "$pr" ] && continue
kind=$(gh api "repos/$REPO/issues/$issue" --jq 'if .pull_request then "pull" else "issue" end' 2>/dev/null || true)
[ "$kind" = "issue" ] || continue
echo "#$pr -> issue #$issue: $labels"
gh issue edit "$issue" --repo "$REPO" --add-label "$labels"
done
done
14 changes: 12 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,20 @@ repos:
- repo: local
hooks:
- id: generate-labeler-config
name: generate-labeler-config (tool READMEs -> .github/labeler.yml)
name: generate-labeler-config (tool READMEs + skill frontmatter -> .github/labeler.yml)
language: system
entry: tools/dev/generate-labeler-config.py
files: ^(tools/[^/]+/README\.md|\.github/labeler\.yml|tools/dev/generate-labeler-config\.py)$
files: ^(tools/[^/]+/README\.md|\.github/labeler\.yml|tools/dev/generate-labeler-config\.py|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|skills/[^/]+|docs/labels-and-capabilities\.md)$
pass_filenames: false
# Every label docs/labels-and-capabilities.md defines must have a rule in
# .github/labeler.yml (or an UNMAPPED entry with a reason), and no rule
# may name an undefined label: a label nobody can apply automatically is
# how pull requests ended up unlabelled.
- id: check-labeler-coverage
name: check-labeler-coverage (every taxonomy label has a labeler rule)
language: system
entry: tools/dev/generate-labeler-config.py --check-coverage
files: ^(tools/[^/]+/README\.md|\.github/labeler\.yml|tools/dev/generate-labeler-config\.py|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|skills/[^/]+|docs/labels-and-capabilities\.md)$
pass_filenames: false
# Workspace-level static checks. Iterate over every uv-workspace
# member declared in the root `pyproject.toml`'s
Expand Down
21 changes: 13 additions & 8 deletions docs/labels-and-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,16 +445,21 @@ that adjusts the validator config to support a new triage rule is
`capability:triage` (the change's purpose), not `substrate:framework-dev`
(the file it edited).

The tool-capability labels are pre-applied within an hour of a PR being opened:
the scheduled [`.github/workflows/labeler.yml`](../.github/workflows/labeler.yml)
labels each new PR once, with the `**Capability:**` of every tool directory it
touches, from
[`.github/labeler.yml`](../.github/labeler.yml), which
Labels are pre-applied the moment a PR is opened or pushed to:
[`.github/workflows/labeler.yml`](../.github/workflows/labeler.yml)
runs on the completion of the unprivileged
[`labeler-signal.yml`](../.github/workflows/labeler-signal.yml), from the default branch,
and applies the rules in [`.github/labeler.yml`](../.github/labeler.yml), which
[`tools/dev/generate-labeler-config.py`](../tools/dev/generate-labeler-config.py)
generates from the tool READMEs.
generates from the repository's own declarations:
the `family:` and `capability:` frontmatter of every skill (covering its directory and its eval suite),
the `**Capability:**` line of every tool README,
and the paths of the non-skill families (`family:tools`, `family:ci`, `family:docs`).
The same labels are passed on to the issues the PR closes or refers to.
A daily run labels any open PR still without a `family:*` label.
That is a starting point, not the answer: remove a label the change does not
implement, and add the skill capability yourself.
Bot PRs and sweeps that would gain more than eight labels are left unlabelled.
implement, and add the capability it does implement when the paths do not show it.
Bot PRs are left unlabelled.

### A new tool under `tools/`

Expand Down
2 changes: 1 addition & 1 deletion tools/dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ installable for other members to depend on it.
| [`gh-signed-commit.py`](gh-signed-commit.py) | Commits the working tree through GitHub's `createCommitOnBranch` mutation instead of `git commit` + `git push`, so the commit is signed by GitHub and shows as **Verified** with no key material in CI. Collects changed and deleted paths from `git status --porcelain -z`, decomposes renames (the mutation has no rename concept), and pins `expectedHeadOid` so a concurrent push fails the call rather than being overwritten. Used by [`bump-dev-version.yml`](../../.github/workflows/bump-dev-version.yml). |
| [`check-placeholders.sh`](check-placeholders.sh) | Fails the build on hardcoded project references in skill and tool docs, which must use `<PROJECT>` / `<project>` / `<tracker>` / `<upstream>` instead. Carries both casings and matches spaced variants. |
| [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools/<name>/pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. Also verifies each member's tests actually run: both surfaces key off `[tool.pytest.ini_options]`, so a project can carry a full `tests/` directory and be executed by nothing. Reports tests-without-config, config-without-tests, and neither; `[tool.magpie.checks] skip = ["pytest"]` is the declared exemption. |
| [`generate-labeler-config.py`](generate-labeler-config.py) | Generates `.github/labeler.yml` — the path → label map the [`labeler.yml`](../../.github/workflows/labeler.yml) workflow uses to pre-apply `contract:*` / `substrate:*` labels to a new PR — from the `**Capability:**` line of every `tools/<name>/README.md`, so a new tool or a changed capability needs no hand-edit. A skill's eval fixtures under `tools/skill-evals/evals/` and the spec-loop specs do not count as touching their tool. Rewrites in place and exits 1 on change, which is how the prek hook runs it; `--check` only reports. |
| [`generate-labeler-config.py`](generate-labeler-config.py) | Generates `.github/labeler.yml` — the path → label map the [`labeler.yml`](../../.github/workflows/labeler.yml) workflow uses to pre-apply labels to a PR and its linked issues — from the `**Capability:**` line of every `tools/<name>/README.md` (`contract:*` / `substrate:*`), the `family:` / `capability:` frontmatter of every skill (its directory and its eval suite), and the paths of the non-skill families (`family:tools`, `family:ci`, `family:docs`), so a new tool or skill needs no hand-edit. Only labels `docs/labels-and-capabilities.md` defines are emitted. A skill's eval fixtures under `tools/skill-evals/evals/` and the spec-loop specs do not count as touching their tool. Rewrites in place and exits 1 on change, which is how the prek hook runs it; `--check` only reports. `--check-coverage` (the `check-labeler-coverage` hook) fails when a label the taxonomy defines has no rule, unless it is listed in `UNMAPPED` with a reason, or when a rule names an undefined label. |
| [`run-workspace-check.sh`](run-workspace-check.sh) | Runs one static-check or test command across every workspace member, auto-discovering which members a given check applies to. The four `workspace-*` hooks call it, so adding a tool needs no edit to the pre-commit config. |
| [`run-skill-script-tests.sh`](run-skill-script-tests.sh) | Runs the stdlib `unittest` suites under `plugins/*/skills/*/tests`, which cover skills' sibling scripts. Those scripts are not workspace members, so the `workspace-pytest` hook never reaches them. Runs as the `skill-script-tests` pre-commit hook. |
| [`add-license-headers.py`](add-license-headers.py) | Stamps the SPDX licence header into Markdown files that lack one. |
Expand Down
Loading