diff --git a/.github/workflows/pr-labeler.yaml b/.github/workflows/pr-labeler.yaml new file mode 100644 index 000000000..f1b3e9d08 --- /dev/null +++ b/.github/workflows/pr-labeler.yaml @@ -0,0 +1,73 @@ +# Copyright 2026 Google LLC +# +# Licensed 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 +# +# https://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. + +name: Pull request labeler +run-name: >- + Label pull request ${{github.event.pull_request.number}} by ${{github.actor}} + +# This workflow is designed NOT to fail if labeling actions encounter errors; +# instead, it notes errors as annotations on the workflow's run summary page. If +# labels don't seem to be getting applied, check there for errors. + +on: + # Note: do not copy-paste this workflow with `pull_request_target` left as-is. + # Its use here is a special case where security implications are understood. + # Workflows should normally use `pull_request` instead. + pull_request_target: + types: + - opened + - synchronize + + # Allow manual invocation. + workflow_dispatch: + inputs: + pr-number: + description: 'The PR number of the PR to label:' + type: string + required: true + debug: + description: 'Run with debugging options' + type: boolean + default: true + +# Declare default workflow permissions as read only. +permissions: read-all + +jobs: + label-pr-size: + if: github.repository_owner == 'quantumlib' + name: Update PR size labels + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: write + env: + # Environment variable PR_NUMBER is needed by size-labeler.sh. + PR_NUMBER: ${{inputs.pr-number || github.event.pull_request.number}} + # Add xtrace to SHELLOPTS for all Bash scripts when doing debug runs. + SHELLOPTS: ${{inputs.debug && 'xtrace' || '' }} + steps: + - name: Check out a copy of the git repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + ./dev_tools/ci/size-labeler.sh + + - name: Label the PR with a size label + continue-on-error: true + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: ./dev_tools/ci/size-labeler.sh diff --git a/dev_tools/ci/README.md b/dev_tools/ci/README.md new file mode 100644 index 000000000..49e789ba0 --- /dev/null +++ b/dev_tools/ci/README.md @@ -0,0 +1,4 @@ +# Continuous integration scripts + +The scripts in this directory are used by the workflows in +[`../../.github/workflows/`](../../.github/workflows/). diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh new file mode 100755 index 000000000..4f0d0405b --- /dev/null +++ b/dev_tools/ci/size-labeler.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Copyright 2025 The Cirq Developers +# +# Licensed 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 +# +# https://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. + +set -euo pipefail -o errtrace +shopt -s inherit_errexit + +declare -r usage="Usage: ${0##*/} [-h | --help | help] + +Updates the size labels on a pull request based on the number of lines it +changes. The script requires the following environment variables: +PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN. The script is intended +for automated execution from GitHub Actions workflow." + +declare -ar LABELS=( + "size: XS" + "size: S" + "size: M" + "size: L" + "size: XL" +) + +declare -A LIMITS=( + ["${LABELS[0]}"]=10 + ["${LABELS[1]}"]=50 + ["${LABELS[2]}"]=200 + ["${LABELS[3]}"]=800 + ["${LABELS[4]}"]="$((2 ** 63 - 1))" +) + +declare -ar IGNORED=( + "*_pb2.py" + "*_pb2.pyi" + "*_pb2_grpc.py" + ".*.lock" + "*.bundle.js" +) + +function info() { + echo >&2 "INFO: ${*}" +} + +function error() { + echo >&2 "ERROR: ${*}" +} + +function jq_stdin() { + local infile + infile="$(mktemp)" + readonly infile + local jq_status=0 + + cat >"${infile}" + jq_file "$@" "${infile}" || jq_status="${?}" + rm "${infile}" + return "${jq_status}" +} + +function jq_file() { + # Regardless of the success, store the return code. + # Prepend each sttderr with command args and send back to stderr. + jq "${@}" 2> >(awk -v h="stderr from jq ${*}:" '{print h, $0}' 1>&2) && + rc="${?}" || + rc="${?}" + if [[ "${rc}" != "0" ]]; then + error "The jq program failed: ${*}" + error "Note the quotes above may be wrong. Here was the (possibly empty) input in ${*: -1}:" + cat "${@: -1}" # Assumes last argument is input file!! + fi + return "${rc}" +} + +function api_call() { + local -r endpoint="${1// /%20}" # love that our label names have spaces... + local -r uri="https://api.github.com/repos/${GITHUB_REPOSITORY}" + local response + local curl_status=0 + info "Calling: ${uri}/${endpoint}" + response="$(curl -sSL \ + --fail-with-body \ + --connect-timeout 10 --max-time 20 \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3.json" \ + -H "X-GitHub-Api-Version:2022-11-28" \ + -H "Content-Type: application/json" \ + "${@:2}" \ + "${uri}/${endpoint}" + )" || curl_status="${?}" + if [[ -n "${response}" ]]; then + cat <<<"${response}" + fi + if (( curl_status )); then + error "GitHub API call failed (curl exit $curl_status) for ${uri}/${endpoint}" + error "Response body:" + cat >&2 <<<"${response}" + fi + return "${curl_status}" +} + +function compute_changes() { + local -r pr="$1" + local page=1 + local total_changes=0 + while true; do + local response + response="$(api_call "pulls/${pr}/files?per_page=100&page=${page}")" + + if [[ "$(jq_stdin '. | length' <<<"${response}")" -eq 0 ]]; then + break + fi + + local name changes + while IFS= read -r name && IFS= read -r changes; do + for pattern in "${IGNORED[@]}"; do + # shellcheck disable=SC2053 # Need leave the pattern unquoted. + if [[ "${name}" == ${pattern} ]]; then + info "File ${name} ignored" + continue 2 + fi + done + info "File ${name} +-${changes}" + total_changes="$((total_changes + changes))" + done < <(jq_stdin -r '.[] | .filename, .changes' <<<"${response}") + ((page++)) + done + echo "${total_changes}" +} + +function get_size_label() { + local -r changes="$1" + for label in "${LABELS[@]}"; do + local limit="${LIMITS["${label}"]}" + if [[ "${changes}" -lt "${limit}" ]]; then + echo "${label}" + return + fi + done +} + +function prune_stale_labels() { + local -r pr="$1" + local -r size_label="$2" + local response + local existing_labels + response="$(api_call "pulls/${pr}")" + existing_labels="$(jq_stdin -r '.labels[] | .name' <<<"${response}")" + readarray -t existing_labels <<<"${existing_labels}" + + local correctly_labeled=false + for label in "${existing_labels[@]}"; do + [[ -z "${label}" ]] && continue + # If the label we want is already present, we can just leave it there. + if [[ "${label}" == "${size_label}" ]]; then + info "Label '${label}' is correct, leaving it." + correctly_labeled=true + continue + fi + # If there is another size label, we need to remove it + if [[ -v "LIMITS[${label}]" ]]; then + info "Label '${label}' is stale, removing it." + api_call "issues/${pr}/labels/${label}" -X DELETE >/dev/null + continue + fi + info "Label '${label}' is unknown, leaving it." + done + echo "${correctly_labeled}" +} + +function main() { + local moreinfo="(Use --help option for more info.)" + if (( $# )); then + case "$1" in + -h | --help | help) + echo "$usage" + exit 0 + ;; + *) + error "Invalid argument '$1'. ${moreinfo}" + exit 2 + ;; + esac + fi + local env_var_name + local env_var_missing=0 + for env_var_name in PR_NUMBER GITHUB_TOKEN GITHUB_REPOSITORY; do + if [[ ! -v "${env_var_name}" ]]; then + env_var_missing=1 + error "Missing environment variable ${env_var_name}" + fi + done + if (( env_var_missing )); then + error "${moreinfo}" + exit 2 + fi + + local total_changes + total_changes="$(compute_changes "$PR_NUMBER")" + info "Lines changed: ${total_changes}" + + local size_label + size_label="$(get_size_label "$total_changes")" + info "Appropriate label is '${size_label}'" + + local correctly_labeled + correctly_labeled="$(prune_stale_labels "$PR_NUMBER" "${size_label}")" + + if [[ "${correctly_labeled}" != true ]]; then + api_call "issues/$PR_NUMBER/labels" -X POST -d "{\"labels\":[\"${size_label}\"]}" >/dev/null + info "Added label '${size_label}'" + fi +} + +main "$@"