From 9659dec21406ff797a942c59eb36a90908b07e92 Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 01:41:12 +0000 Subject: [PATCH 1/6] Add PR size labeling workflow This workflow is identical to the one we use in Cirq. It labels PRs with `size: S`, `size: M`, etc., to indicate the size of the changes. --- .github/workflows/pr-labeler.yaml | 73 ++++++++++ dev_tools/ci/README.md | 4 + dev_tools/ci/size-labeler.sh | 225 ++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .github/workflows/pr-labeler.yaml create mode 100644 dev_tools/ci/README.md create mode 100755 dev_tools/ci/size-labeler.sh 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..99db30389 --- /dev/null +++ b/dev_tools/ci/size-labeler.sh @@ -0,0 +1,225 @@ +#!/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))" +) + +# Note: these are Bash glob patterns and not regexes. +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 response + local change_info + local -r keys_filter='with_entries(select([.key] | inside(["changes", "filename"])))' + response="$(api_call "pulls/${pr}/files?per_page=100")" + change_info="$(jq_stdin "map(${keys_filter})" <<<"${response}")" + + local files total_changes + readarray -t files < <(jq_stdin -c '.[]' <<<"${change_info}") + total_changes=0 + for file in "${files[@]}"; do + local name changes + name="$(jq_stdin -r '.filename' <<<"${file}")" + for pattern in "${IGNORED[@]}"; do + # shellcheck disable=SC2053 # Pattern must be left unquoted here. + if [[ "$name" == ${pattern} ]]; then + info "File $name ignored" + continue 2 + fi + done + changes="$(jq_stdin -r '.changes' <<<"${file}")" + info "File $name +-$changes" + total_changes="$((total_changes + changes))" + 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 "$@" From 613697a7a3392f362cebdbcb4fe9f316490a732e Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 02:08:35 +0000 Subject: [PATCH 2/6] Fix capitalization typo --- dev_tools/ci/size-labeler.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh index 99db30389..2e58904cb 100755 --- a/dev_tools/ci/size-labeler.sh +++ b/dev_tools/ci/size-labeler.sh @@ -24,7 +24,7 @@ PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN. The script is intended for automated execution from GitHub Actions workflow." declare -ar LABELS=( - "Size: XS" + "size: XS" "size: S" "size: M" "size: L" From 6c19e668379f14d1b3761c4716f6d7e5ae22d0b2 Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 04:34:05 +0000 Subject: [PATCH 3/6] Support > 100 files in size-labeler.sh This enhances the code to support getting more than 100 files from GitHub. (Written with the help of Gemini CLI.) --- dev_tools/ci/size-labeler.sh | 56 ++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh index 2e58904cb..6dcaf4824 100755 --- a/dev_tools/ci/size-labeler.sh +++ b/dev_tools/ci/size-labeler.sh @@ -24,7 +24,7 @@ PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN. The script is intended for automated execution from GitHub Actions workflow." declare -ar LABELS=( - "size: XS" + "Size: XS" "size: S" "size: M" "size: L" @@ -39,7 +39,6 @@ declare -A LIMITS=( ["${LABELS[4]}"]="$((2 ** 63 - 1))" ) -# Note: these are Bash glob patterns and not regexes. declare -ar IGNORED=( "*_pb2.py" "*_pb2.pyi" @@ -111,31 +110,40 @@ function api_call() { function compute_changes() { local -r pr="$1" - - local response - local change_info local -r keys_filter='with_entries(select([.key] | inside(["changes", "filename"])))' - response="$(api_call "pulls/${pr}/files?per_page=100")" - change_info="$(jq_stdin "map(${keys_filter})" <<<"${response}")" - - local files total_changes - readarray -t files < <(jq_stdin -c '.[]' <<<"${change_info}") - total_changes=0 - for file in "${files[@]}"; do - local name changes - name="$(jq_stdin -r '.filename' <<<"${file}")" - for pattern in "${IGNORED[@]}"; do - # shellcheck disable=SC2053 # Pattern must be left unquoted here. - if [[ "$name" == ${pattern} ]]; then - info "File $name ignored" - continue 2 - fi + + 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 change_info + change_info="$(jq_stdin "map(${keys_filter})" <<<"${response}")" + + local files + readarray -t files < <(jq_stdin -c '.[]' <<<"${change_info}") + for file in "${files[@]}"; do + local name changes + name="$(jq_stdin -r '.filename' <<<"${file}")" + 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 + changes="$(jq_stdin -r '.changes' <<<"${file}")" + info "File ${name} +-${changes}" + total_changes="$((total_changes + changes))" done - changes="$(jq_stdin -r '.changes' <<<"${file}")" - info "File $name +-$changes" - total_changes="$((total_changes + changes))" + ((page++)) done - echo "$total_changes" + echo "${total_changes}" } function get_size_label() { From 9aa2f92a750165226b9b68676af05cfefe604bb6 Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 04:45:29 +0000 Subject: [PATCH 4/6] Implement performance improvement from Gemini Code Assist --- dev_tools/ci/size-labeler.sh | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh index 6dcaf4824..81f75052a 100755 --- a/dev_tools/ci/size-labeler.sh +++ b/dev_tools/ci/size-labeler.sh @@ -122,14 +122,8 @@ function compute_changes() { break fi - local change_info - change_info="$(jq_stdin "map(${keys_filter})" <<<"${response}")" - - local files - readarray -t files < <(jq_stdin -c '.[]' <<<"${change_info}") - for file in "${files[@]}"; do - local name changes - name="$(jq_stdin -r '.filename' <<<"${file}")" + 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 @@ -137,10 +131,9 @@ function compute_changes() { continue 2 fi done - changes="$(jq_stdin -r '.changes' <<<"${file}")" info "File ${name} +-${changes}" total_changes="$((total_changes + changes))" - done + done < <(jq_stdin -r '.[] | .filename, .changes' <<<"${response}") ((page++)) done echo "${total_changes}" From 234f926ab2a25a0b929b90547526dd4f2a1c3a70 Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 05:05:33 +0000 Subject: [PATCH 5/6] Remove no-longer-needed variable --- dev_tools/ci/size-labeler.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh index 81f75052a..2f58b4059 100755 --- a/dev_tools/ci/size-labeler.sh +++ b/dev_tools/ci/size-labeler.sh @@ -110,8 +110,6 @@ function api_call() { function compute_changes() { local -r pr="$1" - local -r keys_filter='with_entries(select([.key] | inside(["changes", "filename"])))' - local page=1 local total_changes=0 while true; do From a78f913aa8cbca36cd399d781c5d39800f4554b4 Mon Sep 17 00:00:00 2001 From: mhucka Date: Fri, 3 Apr 2026 05:06:23 +0000 Subject: [PATCH 6/6] Fix inconsistent label capitalization --- dev_tools/ci/size-labeler.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_tools/ci/size-labeler.sh b/dev_tools/ci/size-labeler.sh index 2f58b4059..4f0d0405b 100755 --- a/dev_tools/ci/size-labeler.sh +++ b/dev_tools/ci/size-labeler.sh @@ -24,7 +24,7 @@ PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN. The script is intended for automated execution from GitHub Actions workflow." declare -ar LABELS=( - "Size: XS" + "size: XS" "size: S" "size: M" "size: L"