|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Copyright 2025 The Cirq Developers |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | + |
| 16 | +set -euo pipefail -o errtrace |
| 17 | +shopt -s inherit_errexit |
| 18 | + |
| 19 | +declare -r usage="Usage: ${0##*/} [-h | --help | help] |
| 20 | +
|
| 21 | +Updates the size labels on a pull request based on the number of lines it |
| 22 | +changes. The script requires the following environment variables: |
| 23 | +PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN. The script is intended |
| 24 | +for automated execution from GitHub Actions workflow." |
| 25 | + |
| 26 | +declare -ar LABELS=( |
| 27 | + "Size: XS" |
| 28 | + "size: S" |
| 29 | + "size: M" |
| 30 | + "size: L" |
| 31 | + "size: XL" |
| 32 | +) |
| 33 | + |
| 34 | +declare -A LIMITS=( |
| 35 | + ["${LABELS[0]}"]=10 |
| 36 | + ["${LABELS[1]}"]=50 |
| 37 | + ["${LABELS[2]}"]=200 |
| 38 | + ["${LABELS[3]}"]=800 |
| 39 | + ["${LABELS[4]}"]="$((2 ** 63 - 1))" |
| 40 | +) |
| 41 | + |
| 42 | +declare -ar IGNORED=( |
| 43 | + "*_pb2.py" |
| 44 | + "*_pb2.pyi" |
| 45 | + "*_pb2_grpc.py" |
| 46 | + ".*.lock" |
| 47 | + "*.bundle.js" |
| 48 | +) |
| 49 | + |
| 50 | +function info() { |
| 51 | + echo >&2 "INFO: ${*}" |
| 52 | +} |
| 53 | + |
| 54 | +function error() { |
| 55 | + echo >&2 "ERROR: ${*}" |
| 56 | +} |
| 57 | + |
| 58 | +function jq_stdin() { |
| 59 | + local infile |
| 60 | + infile="$(mktemp)" |
| 61 | + readonly infile |
| 62 | + local jq_status=0 |
| 63 | + |
| 64 | + cat >"${infile}" |
| 65 | + jq_file "$@" "${infile}" || jq_status="${?}" |
| 66 | + rm "${infile}" |
| 67 | + return "${jq_status}" |
| 68 | +} |
| 69 | + |
| 70 | +function jq_file() { |
| 71 | + # Regardless of the success, store the return code. |
| 72 | + # Prepend each sttderr with command args and send back to stderr. |
| 73 | + jq "${@}" 2> >(awk -v h="stderr from jq ${*}:" '{print h, $0}' 1>&2) && |
| 74 | + rc="${?}" || |
| 75 | + rc="${?}" |
| 76 | + if [[ "${rc}" != "0" ]]; then |
| 77 | + error "The jq program failed: ${*}" |
| 78 | + error "Note the quotes above may be wrong. Here was the (possibly empty) input in ${*: -1}:" |
| 79 | + cat "${@: -1}" # Assumes last argument is input file!! |
| 80 | + fi |
| 81 | + return "${rc}" |
| 82 | +} |
| 83 | + |
| 84 | +function api_call() { |
| 85 | + local -r endpoint="${1// /%20}" # love that our label names have spaces... |
| 86 | + local -r uri="https://api.github.com/repos/${GITHUB_REPOSITORY}" |
| 87 | + local response |
| 88 | + local curl_status=0 |
| 89 | + info "Calling: ${uri}/${endpoint}" |
| 90 | + response="$(curl -sSL \ |
| 91 | + --fail-with-body \ |
| 92 | + --connect-timeout 10 --max-time 20 \ |
| 93 | + -H "Authorization: token ${GITHUB_TOKEN}" \ |
| 94 | + -H "Accept: application/vnd.github.v3.json" \ |
| 95 | + -H "X-GitHub-Api-Version:2022-11-28" \ |
| 96 | + -H "Content-Type: application/json" \ |
| 97 | + "${@:2}" \ |
| 98 | + "${uri}/${endpoint}" |
| 99 | + )" || curl_status="${?}" |
| 100 | + if [[ -n "${response}" ]]; then |
| 101 | + cat <<<"${response}" |
| 102 | + fi |
| 103 | + if (( curl_status )); then |
| 104 | + error "GitHub API call failed (curl exit $curl_status) for ${uri}/${endpoint}" |
| 105 | + error "Response body:" |
| 106 | + cat >&2 <<<"${response}" |
| 107 | + fi |
| 108 | + return "${curl_status}" |
| 109 | +} |
| 110 | + |
| 111 | +function compute_changes() { |
| 112 | + local -r pr="$1" |
| 113 | + |
| 114 | + local response |
| 115 | + local change_info |
| 116 | + local -r keys_filter='with_entries(select([.key] | inside(["changes", "filename"])))' |
| 117 | + response="$(api_call "pulls/${pr}/files")" |
| 118 | + change_info="$(jq_stdin "map(${keys_filter})" <<<"${response}")" |
| 119 | + |
| 120 | + local files total_changes |
| 121 | + readarray -t files < <(jq_stdin -c '.[]' <<<"${change_info}") |
| 122 | + total_changes=0 |
| 123 | + for file in "${files[@]}"; do |
| 124 | + local name changes |
| 125 | + name="$(jq_stdin -r '.filename' <<<"${file}")" |
| 126 | + for pattern in "${IGNORED[@]}"; do |
| 127 | + if [[ "$name" =~ ${pattern} ]]; then |
| 128 | + info "File $name ignored" |
| 129 | + continue 2 |
| 130 | + fi |
| 131 | + done |
| 132 | + changes="$(jq_stdin -r '.changes' <<<"${file}")" |
| 133 | + info "File $name +-$changes" |
| 134 | + total_changes="$((total_changes + changes))" |
| 135 | + done |
| 136 | + echo "$total_changes" |
| 137 | +} |
| 138 | + |
| 139 | +function get_size_label() { |
| 140 | + local -r changes="$1" |
| 141 | + for label in "${LABELS[@]}"; do |
| 142 | + local limit="${LIMITS["${label}"]}" |
| 143 | + if [[ "${changes}" -lt "${limit}" ]]; then |
| 144 | + echo "${label}" |
| 145 | + return |
| 146 | + fi |
| 147 | + done |
| 148 | +} |
| 149 | + |
| 150 | +function prune_stale_labels() { |
| 151 | + local -r pr="$1" |
| 152 | + local -r size_label="$2" |
| 153 | + local response |
| 154 | + local existing_labels |
| 155 | + response="$(api_call "pulls/${pr}")" |
| 156 | + existing_labels="$(jq_stdin -r '.labels[] | .name' <<<"${response}")" |
| 157 | + readarray -t existing_labels <<<"${existing_labels}" |
| 158 | + |
| 159 | + local correctly_labeled=false |
| 160 | + for label in "${existing_labels[@]}"; do |
| 161 | + [[ -z "${label}" ]] && continue |
| 162 | + # If the label we want is already present, we can just leave it there. |
| 163 | + if [[ "${label}" == "${size_label}" ]]; then |
| 164 | + info "Label '${label}' is correct, leaving it." |
| 165 | + correctly_labeled=true |
| 166 | + continue |
| 167 | + fi |
| 168 | + # If there is another size label, we need to remove it |
| 169 | + if [[ -v "LIMITS[${label}]" ]]; then |
| 170 | + info "Label '${label}' is stale, removing it." |
| 171 | + api_call "issues/${pr}/labels/${label}" -X DELETE >/dev/null |
| 172 | + continue |
| 173 | + fi |
| 174 | + info "Label '${label}' is unknown, leaving it." |
| 175 | + done |
| 176 | + echo "${correctly_labeled}" |
| 177 | +} |
| 178 | + |
| 179 | +function main() { |
| 180 | + local moreinfo="(Use --help option for more info.)" |
| 181 | + if (( $# )); then |
| 182 | + case "$1" in |
| 183 | + -h | --help | help) |
| 184 | + echo "$usage" |
| 185 | + exit 0 |
| 186 | + ;; |
| 187 | + *) |
| 188 | + error "Invalid argument '$1'. ${moreinfo}" |
| 189 | + exit 2 |
| 190 | + ;; |
| 191 | + esac |
| 192 | + fi |
| 193 | + local env_var_name |
| 194 | + local env_var_missing=0 |
| 195 | + for env_var_name in PR_NUMBER GITHUB_TOKEN GITHUB_REPOSITORY; do |
| 196 | + if [[ ! -v "${env_var_name}" ]]; then |
| 197 | + env_var_missing=1 |
| 198 | + error "Missing environment variable ${env_var_name}" |
| 199 | + fi |
| 200 | + done |
| 201 | + if (( env_var_missing )); then |
| 202 | + error "${moreinfo}" |
| 203 | + exit 2 |
| 204 | + fi |
| 205 | + |
| 206 | + local total_changes |
| 207 | + total_changes="$(compute_changes "$PR_NUMBER")" |
| 208 | + info "Lines changed: ${total_changes}" |
| 209 | + |
| 210 | + local size_label |
| 211 | + size_label="$(get_size_label "$total_changes")" |
| 212 | + info "Appropriate label is '${size_label}'" |
| 213 | + |
| 214 | + local correctly_labeled |
| 215 | + correctly_labeled="$(prune_stale_labels "$PR_NUMBER" "${size_label}")" |
| 216 | + |
| 217 | + if [[ "${correctly_labeled}" != true ]]; then |
| 218 | + api_call "issues/$PR_NUMBER/labels" -X POST -d "{\"labels\":[\"${size_label}\"]}" >/dev/null |
| 219 | + info "Added label '${size_label}'" |
| 220 | + fi |
| 221 | +} |
| 222 | + |
| 223 | +main "$@" |
0 commit comments