From 0ab6b57c58f2ebf9be267c8b425de05561b663ac Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 25 Oct 2026 12:28:58 -0700 Subject: [PATCH 1/3] Switch to uv and prek and add the shared lint hooks - pyproject.toml + uv.lock replace requirements-dev.txt - CI and community-contribution-label run through uv instead of pip - Add uv-lock, zizmor, ruff, ESLint and Prettier hooks; bump the rest - Dependabot tracks uv and pre-commit instead of pip - docs/development.md covers dev setup Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 15 +- .../community-contribution-label.yml | 11 +- .github/workflows/pre-commit.yml | 15 +- .github/zizmor.yml | 5 + .pre-commit-config.yaml | 31 ++- .prettierignore | 2 + docs/automation.md | 3 +- docs/development.md | 26 +++ pyproject.toml | 26 +++ requirements-dev.txt | 1 - uv.lock | 216 ++++++++++++++++++ 11 files changed, 332 insertions(+), 19 deletions(-) create mode 100644 .github/zizmor.yml create mode 100644 docs/development.md create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt create mode 100644 uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 13e5fef..ded312e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,3 @@ -# Automatically update versions for pip - version: 2 updates: # Maintain dependencies for Node @@ -11,7 +9,7 @@ updates: cooldown: default-days: 7 # Maintain dependencies for Python - - package-ecosystem: 'pip' + - package-ecosystem: 'uv' directory: '/' schedule: interval: 'monthly' @@ -30,3 +28,14 @@ updates: github: patterns: - 'actions/*' + - package-ecosystem: 'pre-commit' + directory: '/' + schedule: + interval: 'monthly' + time: '00:00' + cooldown: + default-days: 7 + groups: + pre-commit: + patterns: + - '*' diff --git a/.github/workflows/community-contribution-label.yml b/.github/workflows/community-contribution-label.yml index a48bc52..e61683b 100644 --- a/.github/workflows/community-contribution-label.yml +++ b/.github/workflows/community-contribution-label.yml @@ -29,15 +29,10 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} - - name: Set up Python - uses: actions/setup-python@v7 + - uses: astral-sh/setup-uv@v10.3.2 with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install requests + enable-cache: false - name: Add/Remove Community Contribution Label - run: python .github-repo/scripts/community-contribution-label.py + run: uv run --project .github-repo --no-dev --locked .github-repo/scripts/community-contribution-label.py env: token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index af1ea79..35cfad3 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,9 +18,18 @@ jobs: uses: actions/setup-node@v7 with: node-version: '20' + cache: yarn - name: Install dependencies run: yarn install --frozen-lockfile - - name: Install prek - run: pipx install prek + - uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-python: true + - name: Install and verify lock + run: uv sync --group dev --locked + - uses: actions/cache@v6 + with: + path: ~/.cache/prek + key: prek-${{ hashFiles('.pre-commit-config.yaml') }} - name: Run prek - run: prek run --all-files + run: uv run prek run --all-files --show-diff-on-failure diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..2ef0d8f --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,5 @@ +rules: + unpinned-uses: + config: + policies: + '*': ref-pin diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a23477..a6d3724 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-yaml @@ -9,19 +9,44 @@ repos: - id: debug-statements - id: end-of-file-fixer exclude: '^.+?\.json$' + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.13.5 + hooks: + - id: uv-lock - repo: https://github.com/google/yamlfmt - rev: v0.14.0 + rev: v0.21.0 hooks: - id: yamlfmt exclude: '^tests/cassettes|^\.github/workflows/automation(-caller|-group-.+)?\.yml$|^automation-template\.yml$' - repo: https://github.com/rhysd/actionlint - rev: v1.7.7 + rev: v1.7.12 hooks: - id: actionlint # Expects shellcheck to be installed on the system # https://github.com/koalaman/shellcheck#installing + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.31.1 + hooks: + - id: zizmor + args: [--no-progress, --offline, --min-confidence, medium] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.2 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: local hooks: + - id: eslint + name: ESLint + entry: yarn eslint --fix + language: system + files: \.js$ + - id: prettier + name: Prettier + entry: yarn prettier --write + language: system + files: \.js$ - id: generate-automation name: Check automation.yml / automation-template.yml are up to date entry: node scripts/generate-automation.js --check diff --git a/.prettierignore b/.prettierignore index 51ddf53..99873b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ node_modules package-lock.json *.md +*.yml +*.yaml diff --git a/docs/automation.md b/docs/automation.md index 943fe89..41da082 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -40,7 +40,8 @@ reads the registry and writes: A pre-commit hook (`generate-automation`, run via `prek`) regenerates and fails the commit if the checked-in files don't match what the registry produces - so the generated files can never drift -from the registry. +from the registry. To install the hook, see +[Setting up a development environment](development.md). ## Onboarding a new repo diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..94c59a8 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,26 @@ +# Setting up a development environment + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/), Node.js 20 and +[Yarn 1](https://classic.yarnpkg.com/en/docs/install). The `actionlint` hook also needs +[shellcheck](https://github.com/koalaman/shellcheck#installing) on your `PATH`. + +Install the Python and JS dependencies: + +```bash +uv sync --group dev +yarn install --frozen-lockfile +``` + +Install the prek git hook, so the checks run on every commit: + +```bash +uv run prek install +``` + +Run every hook against the whole repo, as CI does: + +```bash +uv run prek run --all-files +``` + +Run the JS tests with `yarn test`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c447433 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "learningequality-github" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = ["requests"] + +[dependency-groups] +dev = ["prek>=0.3.6"] + +[tool.uv] +exclude-newer = "7 days" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "T10", "T20", "C90"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +combine-as-imports = true +force-single-line = true +order-by-type = false + +[tool.ruff.lint.mccabe] +max-complexity = 10 diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 416634f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pre-commit diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..eb939d8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,216 @@ +version = 1 +revision = 5 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[[package]] +name = "certifi" +version = "2026.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "learningequality-github" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "prek" }, +] + +[package.metadata] +requires-dist = [{ name = "requests" }] + +[package.metadata.requires-dev] +dev = [{ name = "prek", specifier = ">=0.3.6" }] + +[[package]] +name = "prek" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz", hash = "sha256:16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a", size = 570428, upload-time = "2026-10-05T06:08:59.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75", size = 6038669, upload-time = "2026-10-05T06:08:39.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e", size = 5548955, upload-time = "2026-10-05T06:08:41.428Z" }, + { url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b", size = 5848228, upload-time = "2026-10-05T06:08:44.051Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5", size = 6371296, upload-time = "2026-10-05T06:08:46.278Z" }, + { url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850", size = 6269922, upload-time = "2026-10-05T06:08:48.645Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99", size = 5861518, upload-time = "2026-10-05T06:08:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643", size = 6383685, upload-time = "2026-10-05T06:08:52.868Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl", hash = "sha256:15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874", size = 5784759, upload-time = "2026-10-05T06:08:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6", size = 5517355, upload-time = "2026-10-05T06:08:57.193Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] From aaafb436c5e21065cf44af436d7adf1524cc04d6 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 25 Oct 2026 12:29:16 -0700 Subject: [PATCH 2/3] Fix ESLint, Prettier, ruff and yamlfmt findings - JS scripts write output through process.stdout/stderr instead of console - Python scripts log status messages; the issue URL list stays on stdout Co-Authored-By: Claude Opus 5.5 --- automation-registry.yml | 1 - scripts/community-contribution-label.py | 22 ++- scripts/generate-automation.js | 85 +++++---- scripts/get_community_assigned_issues.py | 39 ++-- scripts/pull-request-template.test.js | 6 +- scripts/sync-automation-template.js | 54 +++--- scripts/sync-automation-template.test.js | 217 +++++++++++++++-------- 7 files changed, 281 insertions(+), 143 deletions(-) diff --git a/automation-registry.yml b/automation-registry.yml index 27a0ac9..74dd2a6 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -17,7 +17,6 @@ # Run `node scripts/generate-automation.js` after editing this file to regenerate # automation.yml and automation-template.yml. `node scripts/generate-automation.js --check` # fails if the generated files are out of date (enforced by pre-commit + CI). - retired_secrets: - CONTRIBUTIONS_SHEET_NAME - CONTRIBUTIONS_SPREADSHEET_ID diff --git a/scripts/community-contribution-label.py b/scripts/community-contribution-label.py index dc2f1a9..30e60ee 100644 --- a/scripts/community-contribution-label.py +++ b/scripts/community-contribution-label.py @@ -1,12 +1,17 @@ +import json +import logging import os + import requests -import json + +logger = logging.getLogger(__name__) + def get_user_type(owner, repo, username, token): if not username: return None - url = f'https://api.github.com/repos/{owner}/{repo}/collaborators/{username}' + url = f"https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" headers = {"Authorization": f"token {token}"} try: response = requests.get(url, headers=headers) @@ -16,9 +21,10 @@ def get_user_type(owner, repo, username, token): return "external" response.raise_for_status() except requests.exceptions.RequestException as e: - print(f"Error checking user type: {e}") + logger.error("Error checking user type: %s", e) return None + def add_label(owner, repo, issue_number, token): url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/labels" headers = {"Authorization": f"token {token}"} @@ -26,20 +32,22 @@ def add_label(owner, repo, issue_number, token): response = requests.post(url, headers=headers, json=data) response.raise_for_status() + def remove_label(owner, repo, issue_number, token): url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/labels/community-contribution-in-progress" headers = {"Authorization": f"token {token}"} try: response = requests.delete(url, headers=headers) if response.status_code == 404: - print("Label not found") + logger.info("Label not found") return response.raise_for_status() - print("Label removed") + logger.info("Label removed") except requests.exceptions.RequestException as e: - print(f"Error removing label: {e}") + logger.error("Error removing label: %s", e) raise + def main(): event_path = os.getenv("GITHUB_EVENT_PATH") token = os.getenv("token") @@ -69,5 +77,7 @@ def main(): elif action == "unassigned" and not has_external_assignee: remove_label(owner, repo_name, issue_number, token) + if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") main() diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 3988f2b..eb23290 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -21,7 +21,8 @@ const TEMPLATE_PATH = path.join(ROOT, 'automation-template.yml'); // instead of a hand-copied caller, so it stays in sync automatically. const DOGFOOD_PATH = path.join(WORKFLOWS_DIR, 'automation-caller.yml'); -const GENERATED_HEADER = (description) => `# This file is generated by scripts/generate-automation.js from automation-registry.yml. +const GENERATED_HEADER = + description => `# This file is generated by scripts/generate-automation.js from automation-registry.yml. # Do not edit it by hand - edit the registry and run: # node scripts/generate-automation.js # @@ -46,7 +47,7 @@ function unquoteOnKey(content, suffix) { function loadRegistry() { const raw = yaml.load(fs.readFileSync(REGISTRY_PATH, 'utf8')); return { - automations: raw.automations.filter((a) => a.enabled), + automations: raw.automations.filter(a => a.enabled), retiredSecrets: raw.retired_secrets || [], }; } @@ -54,7 +55,7 @@ function loadRegistry() { function titleCase(name) { return name .split('-') - .map((w) => ACRONYMS[w] || w[0].toUpperCase() + w.slice(1)) + .map(w => ACRONYMS[w] || w[0].toUpperCase() + w.slice(1)) .join(' '); } @@ -63,7 +64,7 @@ function titleCase(name) { function mixedTypesError(event, name) { return new Error( `Event "${event}" is declared both with and without types: (automation "${name}"). ` + - 'Declare types: explicitly on every entry for that event.' + 'Declare types: explicitly on every entry for that event.', ); } @@ -116,13 +117,16 @@ function leafJob(a) { function reusableYml(name, automations, jobs, description, retiredSecrets = []) { const secrets = collectSecrets(automations); - const retired = retiredSecrets.filter((s) => !secrets[s]); + const retired = retiredSecrets.filter(s => !secrets[s]); const secretsYaml = {}; for (const secret of [...Object.keys(secrets), ...retired].sort()) { const info = secrets[secret]; secretsYaml[secret] = info ? { description: `Forwarded to: ${info.usedBy.join(', ')}`, required: info.required } - : { description: 'Unused - accepted until every consumer stops forwarding it', required: false }; + : { + description: 'Unused - accepted until every consumer stops forwarding it', + required: false, + }; } const doc = { name, on: { workflow_call: { secrets: secretsYaml } }, jobs }; return unquoteOnKey(GENERATED_HEADER(description) + yaml.dump(doc, DUMP_OPTS)); @@ -131,7 +135,10 @@ function reusableYml(name, automations, jobs, description, retiredSecrets = []) function groupByEvents(automations) { const groups = new Map(); for (const a of automations) { - const key = Object.keys(a.on || {}).sort().join('-').replace(/_/g, '-'); + const key = Object.keys(a.on || {}) + .sort() + .join('-') + .replace(/_/g, '-'); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(a); } @@ -144,7 +151,10 @@ function buildReusableWorkflows(automations, retiredSecrets = []) { const files = {}; const jobs = {}; const addJob = (id, job) => { - if (jobs[id]) throw new Error(`Job id "${id}" is used by both a group and an automation in automation.yml.`); + if (jobs[id]) + throw new Error( + `Job id "${id}" is used by both a group and an automation in automation.yml.`, + ); jobs[id] = job; }; @@ -160,17 +170,17 @@ function buildReusableWorkflows(automations, retiredSecrets = []) { `Automation: ${titleCase(key)}`, members, groupJobs, - `Reusable workflow called from automation.yml: the automations triggered by ${Object.keys(members[0].on).sort().join(', ')}.` + `Reusable workflow called from automation.yml: the automations triggered by ${Object.keys(members[0].on).sort().join(', ')}.`, ); addJob( key, callJob( titleCase(key), - members.map((a) => `(${a.if})`).join(' || '), + members.map(a => `(${a.if})`).join(' || '), file, unionPermissions(members), - Object.keys(collectSecrets(members)).sort() - ) + Object.keys(collectSecrets(members)).sort(), + ), ); } @@ -180,7 +190,7 @@ function buildReusableWorkflows(automations, retiredSecrets = []) { jobs, 'Reusable workflow: one job per automation, or per group of automations sharing trigger events,\n' + '# gated on the original triggering event/action.', - retiredSecrets + retiredSecrets, ); return files; } @@ -194,7 +204,7 @@ function unionOn(automations) { const entries = Array.isArray(value) ? value : [value]; for (const entry of entries) { const { cron } = entry; - if (cron && !on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); + if (cron && !on.schedule.some(s => s.cron === cron)) on.schedule.push({ cron }); } } else if (value && Array.isArray(value.types)) { if (on[event] && !on[event].types) throw mixedTypesError(event, a.name); @@ -255,34 +265,49 @@ function main() { const secrets = collectSecrets(automations); const targets = [ - ...Object.entries(buildReusableWorkflows(automations, retiredSecrets)).map(([file, content]) => [ - path.join(WORKFLOWS_DIR, file), - content, - ]), - [TEMPLATE_PATH, buildTemplateYml(automations, secrets, - 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + - '# No edits are needed - the on: block is the exhaustive union of every enabled automation.')], - [DOGFOOD_PATH, buildTemplateYml(automations, secrets, - 'This repo\'s own automation caller - .github acts as consumer zero and stays in sync automatically.')], + ...Object.entries(buildReusableWorkflows(automations, retiredSecrets)).map( + ([file, content]) => [path.join(WORKFLOWS_DIR, file), content], + ), + [ + TEMPLATE_PATH, + buildTemplateYml( + automations, + secrets, + 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + + '# No edits are needed - the on: block is the exhaustive union of every enabled automation.', + ), + ], + [ + DOGFOOD_PATH, + buildTemplateYml( + automations, + secrets, + "This repo's own automation caller - .github acts as consumer zero and stays in sync automatically.", + ), + ], ]; const targetPaths = new Set(targets.map(([target]) => target)); const stale = fs .readdirSync(WORKFLOWS_DIR) - .filter((f) => f.startsWith(GROUP_PREFIX)) - .map((f) => path.join(WORKFLOWS_DIR, f)) - .filter((f) => !targetPaths.has(f)); + .filter(f => f.startsWith(GROUP_PREFIX)) + .map(f => path.join(WORKFLOWS_DIR, f)) + .filter(f => !targetPaths.has(f)); if (check) { let drifted = false; for (const [target, content] of targets) { const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null; if (current !== content) { - console.error(`Drift detected in ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\``); + process.stderr.write( + `Drift detected in ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\`\n`, + ); drifted = true; } } for (const target of stale) { - console.error(`Stale ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\``); + process.stderr.write( + `Stale ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\`\n`, + ); drifted = true; } process.exit(drifted ? 1 : 0); @@ -291,11 +316,11 @@ function main() { for (const [target, content] of targets) { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content); - console.log(`Wrote ${path.relative(ROOT, target)}`); + process.stdout.write(`Wrote ${path.relative(ROOT, target)}\n`); } for (const target of stale) { fs.unlinkSync(target); - console.log(`Removed ${path.relative(ROOT, target)}`); + process.stdout.write(`Removed ${path.relative(ROOT, target)}\n`); } } diff --git a/scripts/get_community_assigned_issues.py b/scripts/get_community_assigned_issues.py index 9e51dbd..56de830 100644 --- a/scripts/get_community_assigned_issues.py +++ b/scripts/get_community_assigned_issues.py @@ -4,21 +4,26 @@ # whenever need arises. The script requires `GITHUB_TOKEN` # environment variable with 'repo' and 'read:org' permissions. +import logging import os +import sys + import requests +logger = logging.getLogger(__name__) + ORG = "learningequality" -REPOS = ["kolibri", "studio", "kolibri-design-system", "le-utils", ".github","ricecooker"] +REPOS = ["kolibri", "studio", "kolibri-design-system", "le-utils", ".github", "ricecooker"] TOKEN = os.getenv("GITHUB_TOKEN") if not TOKEN: - raise EnvironmentError("Please set the GITHUB_TOKEN environment variable with 'repo' and 'read:org' permissions.") + raise EnvironmentError( + "Please set the GITHUB_TOKEN environment variable with 'repo' and 'read:org' permissions." + ) BASE_URL = "https://api.github.com" -HEADERS = { - "Authorization": f"token {TOKEN}", - "Accept": "application/vnd.github+json" -} +HEADERS = {"Authorization": f"token {TOKEN}", "Accept": "application/vnd.github+json"} + def fetch_paginated_results(url): results = [] @@ -29,41 +34,45 @@ def fetch_paginated_results(url): url = response.links.get("next", {}).get("url") return results + def get_team_members(org): """Fetch all team members for the organization.""" results = fetch_paginated_results(f"{BASE_URL}/orgs/{org}/members") team_members = [member["login"] for member in results] return team_members + def get_issues_for_repo(org, repo): """Fetch all issues for a specific repository.""" return fetch_paginated_results(f"{BASE_URL}/repos/{org}/{repo}/issues?state=open") + def filter_issues(issues, team_members): """Filter issues assigned to external contributors""" return [ - issue for issue in issues + issue + for issue in issues if issue.get("assignee") and issue["assignee"]["login"] not in team_members ] + def main(): team_members = get_team_members(ORG) - print(f"Found {len(team_members)} team members in {ORG}.") + logger.info("Found %d team members in %s.", len(team_members), ORG) all_filtered_issues = [] - print(f"Processing {len(REPOS)} repositories in {ORG}...") + logger.info("Processing %d repositories in %s...", len(REPOS), ORG) for repo in REPOS: - print(f"Processing repository: {repo}") + logger.info("Processing repository: %s", repo) issues = get_issues_for_repo(ORG, repo) filtered_issues = filter_issues(issues, team_members) - all_filtered_issues.extend( - issue["html_url"] - for issue in filtered_issues - ) + all_filtered_issues.extend(issue["html_url"] for issue in filtered_issues) for url in all_filtered_issues: - print(url) + sys.stdout.write(f"{url}\n") + if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") main() diff --git a/scripts/pull-request-template.test.js b/scripts/pull-request-template.test.js index cadfce0..d3f2db4 100644 --- a/scripts/pull-request-template.test.js +++ b/scripts/pull-request-template.test.js @@ -229,7 +229,11 @@ test('a bot author passes without a template read', async () => { test('the template is read from the base branch, never the head', async () => { const github = fakeGithub({ templates: repoTemplate(MARKED) }); - await pullRequestTemplate({ github, context: fakeContext({ base: 'release-v1' }), core: fakeCore() }); + await pullRequestTemplate({ + github, + context: fakeContext({ base: 'release-v1' }), + core: fakeCore(), + }); assert.ok(github.reads.length); for (const read of github.reads) { diff --git a/scripts/sync-automation-template.js b/scripts/sync-automation-template.js index 826ff67..142eb7e 100644 --- a/scripts/sync-automation-template.js +++ b/scripts/sync-automation-template.js @@ -62,7 +62,8 @@ const EXPLANATION = [ 'Opened automatically. A core maintainer reviews and merges it.', ].join('\n'); -const SUMMARY = 'Internal: refresh the copied automation.yml so it matches the current shared template'; +const SUMMARY = + 'Internal: refresh the copied automation.yml so it matches the current shared template'; const FIELD = /^([ \t]*-[ \t]*\*\*([^*]+?):\*\*).*$/gm; // Splits on level-two headings only, so a deeper heading stays inside its section. @@ -77,8 +78,8 @@ function sections(markdown) { function prBody(prTemplate, { keep, describe, answers }) { if (!prTemplate) return `${EXPLANATION}\n`; - const kept = sections(prTemplate).filter((s) => keep.includes(s.heading)); - const parts = kept.map((s) => { + const kept = sections(prTemplate).filter(s => keep.includes(s.heading)); + const parts = kept.map(s => { if (s.heading === describe) return `${s.heading}\n\n${EXPLANATION}`; const filled = s.lines.join('\n').replace(FIELD, (line, prefix, field) => { const answer = answers[field.trim().toLowerCase()]; @@ -87,7 +88,7 @@ function prBody(prTemplate, { keep, describe, answers }) { return `${s.heading}\n${filled.trimEnd()}`; }); // A renamed heading would otherwise leave the pull request unexplained, or empty. - if (!kept.some((s) => s.heading === describe)) parts.unshift(EXPLANATION); + if (!kept.some(s => s.heading === describe)) parts.unshift(EXPLANATION); return `${parts.join('\n\n').trimEnd()}\n`; } @@ -127,7 +128,8 @@ async function readPrTemplate(api, repo, ref) { async function readCopy(api, repo, ref) { const r = await api('GET', `/repos/${ORG}/${repo}/contents/${TARGET_PATH}?ref=${ref}`); - if (r.ok) return { sha: r.data.sha, content: Buffer.from(r.data.content, 'base64').toString('utf8') }; + if (r.ok) + return { sha: r.data.sha, content: Buffer.from(r.data.content, 'base64').toString('utf8') }; if (r.status === 404) return { missing: true }; return { error: `read failed (${detail(r)})` }; } @@ -197,7 +199,7 @@ async function openSyncPr(api, repo) { async function lastClosedSyncPr(api, repo) { const r = await api( 'GET', - `/repos/${ORG}/${repo}/pulls?state=closed&head=${ORG}:${BRANCH}&sort=updated&direction=desc&per_page=1` + `/repos/${ORG}/${repo}/pulls?state=closed&head=${ORG}:${BRANCH}&sort=updated&direction=desc&per_page=1`, ); if (!r.ok) return { error: `could not list closed pull requests (${detail(r)})` }; return { pr: r.data.length ? r.data[0] : null }; @@ -242,7 +244,9 @@ async function syncRepo(api, consumer, template, { dryRun }) { if (closed.error) return { repo, state: 'error', detail: closed.error }; if (closed.pr) { const ref = closedPrRef(closed.pr); - const proposed = ref ? await readCopy(api, repo, ref) : { error: 'no reference on the closed pull request' }; + const proposed = ref + ? await readCopy(api, repo, ref) + : { error: 'no reference on the closed pull request' }; if (proposed.error) return { repo, state: 'error', detail: proposed.error }; const verdict = classifyDrift(closed.pr, proposed.content, template); if (verdict !== 'drift') return { repo, state: verdict, pr: closed.pr.number }; @@ -254,16 +258,24 @@ async function syncRepo(api, consumer, template, { dryRun }) { } const baseRef = await api('GET', `/repos/${ORG}/${repo}/git/ref/heads/${base}`); - if (!baseRef.ok) return { repo, state: 'error', detail: `base ${base} not found (${detail(baseRef)})` }; + if (!baseRef.ok) + return { repo, state: 'error', detail: `base ${base} not found (${detail(baseRef)})` }; const baseSha = baseRef.data.object.sha; if (!open.pr) { - const made = await api('POST', `/repos/${ORG}/${repo}/git/refs`, { ref: `refs/heads/${BRANCH}`, sha: baseSha }); + const made = await api('POST', `/repos/${ORG}/${repo}/git/refs`, { + ref: `refs/heads/${BRANCH}`, + sha: baseSha, + }); if (!made.ok && made.status === 422) { // The branch outlives a closed pull request, so start it again from base // rather than carrying commits a maintainer already saw. - const reset = await api('PATCH', `/repos/${ORG}/${repo}/git/refs/heads/${BRANCH}`, { sha: baseSha, force: true }); - if (!reset.ok) return { repo, state: 'error', detail: `branch reset failed (${detail(reset)})` }; + const reset = await api('PATCH', `/repos/${ORG}/${repo}/git/refs/heads/${BRANCH}`, { + sha: baseSha, + force: true, + }); + if (!reset.ok) + return { repo, state: 'error', detail: `branch reset failed (${detail(reset)})` }; } else if (!made.ok) { return { repo, state: 'error', detail: `branch failed (${detail(made)})` }; } @@ -309,13 +321,15 @@ async function run(api, template, options) { function report(results) { for (const r of results) { const extra = r.url || r.detail || (r.pr ? `#${r.pr}` : ''); - console.log(`${r.repo.padEnd(26)} ${r.state.padEnd(20)} ${extra}`); + process.stdout.write(`${r.repo.padEnd(26)} ${r.state.padEnd(20)} ${extra}\n`); } - const problems = results.filter((r) => PROBLEM_STATES.includes(r.state)); - const drifted = results.filter((r) => r.state !== 'in-sync'); - console.log(`\n${results.length} consumers, ${drifted.length} not in sync, ${problems.length} needing attention.`); + const problems = results.filter(r => PROBLEM_STATES.includes(r.state)); + const drifted = results.filter(r => r.state !== 'in-sync'); + process.stdout.write( + `\n${results.length} consumers, ${drifted.length} not in sync, ${problems.length} needing attention.\n`, + ); for (const p of problems) { - console.log(`::error title=${p.repo}::${p.state}${p.detail ? `: ${p.detail}` : ''}`); + process.stdout.write(`::error title=${p.repo}::${p.state}${p.detail ? `: ${p.detail}` : ''}\n`); } return problems.length; } @@ -323,7 +337,7 @@ function report(results) { // Both spellings, because an --only nobody parses is a full run against every // consumer, which is the opposite of what it asks for. function parseArgs(argv) { - const i = argv.findIndex((a) => a === '--only' || a.startsWith('--only=')); + const i = argv.findIndex(a => a === '--only' || a.startsWith('--only=')); let only; if (i !== -1) { only = argv[i] === '--only' ? argv[i + 1] : argv[i].slice('--only='.length); @@ -335,7 +349,7 @@ function parseArgs(argv) { async function main() { const token = process.env.GITHUB_TOKEN; if (!token) { - console.error('GITHUB_TOKEN is not set.'); + process.stderr.write('GITHUB_TOKEN is not set.\n'); process.exit(1); } const template = fs.readFileSync(TEMPLATE_PATH, 'utf8'); @@ -344,8 +358,8 @@ async function main() { } if (require.main === module) { - main().catch((err) => { - console.error(err.message); + main().catch(err => { + process.stderr.write(`${err.message}\n`); process.exit(1); }); } diff --git a/scripts/sync-automation-template.test.js b/scripts/sync-automation-template.test.js index c92f413..db70e9c 100644 --- a/scripts/sync-automation-template.test.js +++ b/scripts/sync-automation-template.test.js @@ -11,7 +11,8 @@ const { BRANCH, } = require('./sync-automation-template'); -const USES = 'jobs:\n automation:\n uses: learningequality/.github/.github/workflows/automation.yml@main\n'; +const USES = + 'jobs:\n automation:\n uses: learningequality/.github/.github/workflows/automation.yml@main\n'; const TEMPLATE = `name: Automation\non: {}\n${USES}`; const STALE = `name: Automation\non: {old: true}\n${USES}`; // Shaped like kolibri-design-system's, including the placeholder its own @@ -41,10 +42,16 @@ const PR_TEMPLATE = [ ].join('\n'); // Not "main": a default branch that differs is the only way to catch a hardcoded base. const BASE = 'develop'; -const encode = (s) => Buffer.from(s, 'utf8').toString('base64'); -const ok = (data) => ({ ok: true, status: 200, data }); +const encode = s => Buffer.from(s, 'utf8').toString('base64'); +const ok = data => ({ ok: true, status: 200, data }); const fail = (status, message) => ({ ok: false, status, data: { message } }); -const repo = (over = {}) => ({ name: 'demo', default_branch: BASE, archived: false, fork: false, ...over }); +const repo = (over = {}) => ({ + name: 'demo', + default_branch: BASE, + archived: false, + fork: false, + ...over, +}); /** * Routes calls by method and a fragment of the path. Later entries win, so a @@ -67,7 +74,10 @@ function makeApi(routes, calls = []) { } const baseRoutes = (copy, repos = [repo()]) => [ - ['GET /repos/learningequality/.github/commits', ok([{ commit: { committer: { date: '2026-01-02T00:00:00Z' } } }])], + [ + 'GET /repos/learningequality/.github/commits', + ok([{ commit: { committer: { date: '2026-01-02T00:00:00Z' } } }]), + ], ['GET /orgs/learningequality/repos', ok(repos)], ['GET contents/.github/workflows/automation.yml', ok({ sha: 'file-sha', content: encode(copy) })], ['GET /repos/learningequality/demo/pulls?state=open', ok([])], @@ -75,7 +85,10 @@ const baseRoutes = (copy, repos = [repo()]) => [ [`GET /repos/learningequality/demo/git/ref/heads/${BASE}`, ok({ object: { sha: 'base-sha' } })], ['POST /repos/learningequality/demo/git/refs', ok({})], ['PUT /repos/learningequality/demo/contents', ok({})], - ['POST /repos/learningequality/demo/pulls', ok({ number: 7, html_url: 'https://example.test/7' })], + [ + 'POST /repos/learningequality/demo/pulls', + ok({ number: 7, html_url: 'https://example.test/7' }), + ], ]; const only = async (routes, options = {}) => (await run(makeApi(routes), TEMPLATE, options))[0]; @@ -93,7 +106,7 @@ test('a drifted copy opens a pull request', async () => { test('the write targets the sync branch and carries the template', async () => { const calls = []; await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); - const put = calls.find((c) => c.method === 'PUT'); + const put = calls.find(c => c.method === 'PUT'); assert.equal(put.body.branch, BRANCH, 'must never write to a default branch'); assert.equal(Buffer.from(put.body.content, 'base64').toString('utf8'), TEMPLATE); }); @@ -101,7 +114,7 @@ test('the write targets the sync branch and carries the template', async () => { test('the pull request targets the discovered default branch', async () => { const calls = []; await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); - const pr = calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')); + const pr = calls.find(c => c.method === 'POST' && c.url.endsWith('/pulls')); assert.equal(pr.body.base, BASE); assert.equal(pr.body.head, BRANCH); }); @@ -109,18 +122,24 @@ test('the pull request targets the discovered default branch', async () => { // The repo whose check-description job forces the template path. const CHECKED = 'kolibri-design-system'; -const checkedRoutes = (copy) => [ +const checkedRoutes = copy => [ ...baseRoutes(copy, [repo({ name: CHECKED })]), [`GET /repos/learningequality/${CHECKED}/pulls?state=open`, ok([])], [`GET /repos/learningequality/${CHECKED}/pulls?state=closed`, ok([])], - [`GET /repos/learningequality/${CHECKED}/git/ref/heads/${BASE}`, ok({ object: { sha: 'base-sha' } })], + [ + `GET /repos/learningequality/${CHECKED}/git/ref/heads/${BASE}`, + ok({ object: { sha: 'base-sha' } }), + ], [`POST /repos/learningequality/${CHECKED}/git/refs`, ok({})], [`PUT /repos/learningequality/${CHECKED}/contents`, ok({})], - [`POST /repos/learningequality/${CHECKED}/pulls`, ok({ number: 7, html_url: 'https://example.test/7' })], + [ + `POST /repos/learningequality/${CHECKED}/pulls`, + ok({ number: 7, html_url: 'https://example.test/7' }), + ], ['GET contents/.github/pull_request_template.md', ok({ content: encode(PR_TEMPLATE) })], ]; -const bodyOf = (calls) => calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')).body.body; +const bodyOf = calls => calls.find(c => c.method === 'POST' && c.url.endsWith('/pulls')).body.body; test('a repo that checks the description gets its own template, with every field answered', async () => { const calls = []; @@ -144,14 +163,20 @@ test('a renamed description heading still leaves the change explained', async () await run(makeApi(routes, calls), TEMPLATE, {}); const body = bodyOf(calls); assert.ok(body.startsWith('This replaces'), 'the explanation must not go missing'); - assert.ok(body.includes('## Changelog'), 'a section that survived is kept, so their check still passes'); + assert.ok( + body.includes('## Changelog'), + 'a section that survived is kept, so their check still passes', + ); }); test('a template with none of the kept headings still explains itself', async () => { const calls = []; const routes = [ ...checkedRoutes(STALE), - ['GET contents/.github/pull_request_template.md', ok({ content: encode('## Notes\n\nnothing here\n') })], + [ + 'GET contents/.github/pull_request_template.md', + ok({ content: encode('## Notes\n\nnothing here\n') }), + ], ]; await run(makeApi(routes, calls), TEMPLATE, {}); const body = bodyOf(calls); @@ -177,14 +202,17 @@ test('any other repo gets the plain explanation, template or not', async () => { ]; await run(makeApi(routes, calls), TEMPLATE, {}); const body = bodyOf(calls); - assert.ok(!body.includes('## Description'), 'a template is not fetched for a repo that does not need it'); + assert.ok( + !body.includes('## Description'), + 'a template is not fetched for a repo that does not need it', + ); assert.ok(body.includes('automation-template.yml')); }); test('a repo with no pull request template gets the plain explanation', async () => { const calls = []; await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, {}); - const { body } = calls.find((c) => c.method === 'POST' && c.url.endsWith('/pulls')).body; + const { body } = calls.find(c => c.method === 'POST' && c.url.endsWith('/pulls')).body; assert.ok(!body.includes('## ')); assert.ok(body.includes('automation-template.yml')); }); @@ -193,26 +221,36 @@ test('an open sync pull request is updated in place, keeping the file sha', asyn const calls = []; const routes = [ ...baseRoutes(STALE), - ['GET /repos/learningequality/demo/pulls?state=open', ok([{ number: 3, html_url: 'https://example.test/3' }])], + [ + 'GET /repos/learningequality/demo/pulls?state=open', + ok([{ number: 3, html_url: 'https://example.test/3' }]), + ], ]; const results = await run(makeApi(routes, calls), TEMPLATE, {}); assert.equal(results[0].state, 'updated'); - assert.equal(calls.filter((c) => c.method === 'POST' && c.url.endsWith('/pulls')).length, 0); - const put = calls.find((c) => c.method === 'PUT'); + assert.equal(calls.filter(c => c.method === 'POST' && c.url.endsWith('/pulls')).length, 0); + const put = calls.find(c => c.method === 'PUT'); assert.equal(put.body.sha, 'file-sha', 'an update without the sha fails with a 422'); }); test('discovery skips archived repos and forks', async () => { - const repos = [repo({ name: 'old', archived: true }), repo({ name: 'mirror', fork: true }), repo()]; + const repos = [ + repo({ name: 'old', archived: true }), + repo({ name: 'mirror', fork: true }), + repo(), + ]; const results = await run(makeApi(baseRoutes(TEMPLATE, repos)), TEMPLATE, {}); assert.deepEqual( - results.map((r) => r.repo), - ['demo'] + results.map(r => r.repo), + ['demo'], ); }); test('a repo with no copy is not a consumer', async () => { - const routes = [...baseRoutes(TEMPLATE), ['GET contents/.github/workflows/automation.yml', fail(404, 'Not Found')]]; + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET contents/.github/workflows/automation.yml', fail(404, 'Not Found')], + ]; const results = await run(makeApi(routes), TEMPLATE, {}); assert.deepEqual(results, []); }); @@ -220,26 +258,38 @@ test('a repo with no copy is not a consumer', async () => { test('a file that does not call the shared workflow is not a consumer', async () => { const routes = [ ...baseRoutes(TEMPLATE), - ['GET contents/.github/workflows/automation.yml', ok({ sha: 'x', content: encode('name: Something else\n') })], + [ + 'GET contents/.github/workflows/automation.yml', + ok({ sha: 'x', content: encode('name: Something else\n') }), + ], ]; const results = await run(makeApi(routes), TEMPLATE, {}); assert.deepEqual(results, []); }); test('a repo that cannot be read is reported, not skipped', async () => { - const routes = [...baseRoutes(TEMPLATE), ['GET contents/.github/workflows/automation.yml', fail(500, 'Server Error')]]; + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET contents/.github/workflows/automation.yml', fail(500, 'Server Error')], + ]; const result = await only(routes); assert.equal(result.state, 'error'); assert.match(result.detail, /read failed/); }); test('a failed repo listing stops the run loudly', async () => { - const routes = [...baseRoutes(TEMPLATE), ['GET /orgs/learningequality/repos', fail(403, 'Forbidden')]]; + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET /orgs/learningequality/repos', fail(403, 'Forbidden')], + ]; await assert.rejects(() => run(makeApi(routes), TEMPLATE, {}), /could not list the org's repos/); }); test('a failed pull request listing is an error, not a missing pull request', async () => { - const routes = [...baseRoutes(STALE), ['GET /repos/learningequality/demo/pulls?state=open', fail(403, 'Forbidden')]]; + const routes = [ + ...baseRoutes(STALE), + ['GET /repos/learningequality/demo/pulls?state=open', fail(403, 'Forbidden')], + ]; const result = await only(routes); assert.equal(result.state, 'error'); assert.match(result.detail, /could not list/); @@ -271,7 +321,11 @@ test('a thrown request during discovery is contained, not fatal', async () => { ], ]; const result = await only(routes); - assert.equal(result.state, 'error', 'discovery runs before the per-repo try, so it needs its own'); + assert.equal( + result.state, + 'error', + 'discovery runs before the per-repo try, so it needs its own', + ); assert.equal(result.detail, 'socket hang up'); }); @@ -279,10 +333,7 @@ test('dry run reports without writing', async () => { const calls = []; const results = await run(makeApi(baseRoutes(STALE), calls), TEMPLATE, { dryRun: true }); assert.equal(results[0].state, 'would-open'); - assert.equal( - calls.filter((c) => c.method !== 'GET').length, - 0 - ); + assert.equal(calls.filter(c => c.method !== 'GET').length, 0); }); const MERGED = { merged_at: '2026-03-01T00:00:00Z', merge_commit_sha: 'm1' }; @@ -305,7 +356,7 @@ test('classifyDrift: a pull request closed unmerged is declined until the templa assert.equal(classifyDrift(CLOSED, STALE, TEMPLATE), 'drift'); }); -const mergedSync = (sha) => [ +const mergedSync = sha => [ 'GET /repos/learningequality/demo/pulls?state=closed', ok([{ number: 9, merged_at: '2026-03-01T00:00:00Z', merge_commit_sha: sha }]), ]; @@ -320,8 +371,12 @@ test('a repo that reverted a merged sync is reported, and no pull request is ope const calls = []; const routes = [...baseRoutes(STALE), mergedSync('m1'), atRef('m1', TEMPLATE)]; const results = await run(makeApi(routes, calls), TEMPLATE, {}); - assert.equal(results[0].state, 'toolchain-conflict', 'the merge left the template, so the consumer changed it'); - assert.equal(calls.filter((c) => c.method !== 'GET').length, 0); + assert.equal( + results[0].state, + 'toolchain-conflict', + 'the merge left the template, so the consumer changed it', + ); + assert.equal(calls.filter(c => c.method !== 'GET').length, 0); }); test('a template published after the merge is drift, whatever the commit dates say', async () => { @@ -330,54 +385,61 @@ test('a template published after the merge is drift, whatever the commit dates s // but which reached main after it, because the branch was merged later. const routes = [...baseRoutes(STALE), mergedSync('m1'), atRef('m1', STALE)]; const results = await run(makeApi(routes, calls), TEMPLATE, {}); - assert.equal(results[0].state, 'opened', 'the merge left something older, so the template moved on'); + assert.equal( + results[0].state, + 'opened', + 'the merge left something older, so the template moved on', + ); }); test('a closed unmerged pull request is read at its head', async () => { const calls = []; const routes = [ ...baseRoutes(STALE), - ['GET /repos/learningequality/demo/pulls?state=closed', ok([{ number: 9, merged_at: null, head: { sha: 'h1' } }])], + [ + 'GET /repos/learningequality/demo/pulls?state=closed', + ok([{ number: 9, merged_at: null, head: { sha: 'h1' } }]), + ], atRef('h1', TEMPLATE), ]; const results = await run(makeApi(routes, calls), TEMPLATE, {}); assert.equal(results[0].state, 'declined'); assert.ok( - calls.some((c) => c.url.includes('ref=h1')), - 'the head stays readable after the branch is reset' + calls.some(c => c.url.includes('ref=h1')), + 'the head stays readable after the branch is reset', ); }); -test('report counts only the states that stop a merge', () => { - const quiet = console.log; - console.log = () => {}; - try { - const problems = report([ - { repo: 'a', state: 'in-sync' }, - { repo: 'b', state: 'opened', pr: 1 }, - { repo: 'c', state: 'declined', pr: 2 }, - { repo: 'd', state: 'error', detail: 'no access' }, - { repo: 'e', state: 'toolchain-conflict', pr: 3 }, - ]); - assert.equal(problems, 2, 'error and toolchain-conflict each need attention'); - } finally { - console.log = quiet; - } +test('report counts only the states that stop a merge', t => { + t.mock.method(process.stdout, 'write', () => true); + const problems = report([ + { repo: 'a', state: 'in-sync' }, + { repo: 'b', state: 'opened', pr: 1 }, + { repo: 'c', state: 'declined', pr: 2 }, + { repo: 'd', state: 'error', detail: 'no access' }, + { repo: 'e', state: 'toolchain-conflict', pr: 3 }, + ]); + assert.equal(problems, 2, 'error and toolchain-conflict each need attention'); }); test('the set of problem states is closed', () => { assert.deepEqual( [...PROBLEM_STATES].sort(), ['error', 'toolchain-conflict'], - 'adding a state here turns the weekly run red for repos that were passing, so change it deliberately and cover it in the report test above' + 'adding a state here turns the weekly run red for repos that were passing, so change it deliberately and cover it in the report test above', ); }); test('a stale branch is reset to base when no pull request is open', async () => { const calls = []; - const routes = [...baseRoutes(STALE), ['POST /repos/learningequality/demo/git/refs', fail(422, 'Reference exists')]]; + const routes = [ + ...baseRoutes(STALE), + ['POST /repos/learningequality/demo/git/refs', fail(422, 'Reference exists')], + ]; await run(makeApi(routes, calls), TEMPLATE, {}); - const reset = calls.find((c) => c.method === 'PATCH' && c.url.endsWith(`/git/refs/heads/${BRANCH}`)); + const reset = calls.find( + c => c.method === 'PATCH' && c.url.endsWith(`/git/refs/heads/${BRANCH}`), + ); assert.ok(reset, 'expected the branch to be reset'); assert.equal(reset.body.sha, 'base-sha'); assert.equal(reset.body.force, true); @@ -411,23 +473,32 @@ test('a named repo without the marker says why', async () => { const routes = [ ...baseRoutes(TEMPLATE), ['GET =/repos/learningequality/demo', ok(repo())], - ['GET contents/.github/workflows/automation.yml', ok({ sha: 'x', content: encode('name: other\n') })], + [ + 'GET contents/.github/workflows/automation.yml', + ok({ sha: 'x', content: encode('name: other\n') }), + ], ]; - await assert.rejects(() => run(makeApi(routes), TEMPLATE, { only: 'demo' }), /does not call the shared workflow/); + await assert.rejects( + () => run(makeApi(routes), TEMPLATE, { only: 'demo' }), + /does not call the shared workflow/, + ); }); test('only limits the run to that repo, and never lists the org', async () => { const calls = []; const others = [repo({ name: 'demo' }), repo({ name: 'production-repo' })]; - const routes = [...baseRoutes(TEMPLATE, others), ['GET =/repos/learningequality/demo', ok(repo())]]; + const routes = [ + ...baseRoutes(TEMPLATE, others), + ['GET =/repos/learningequality/demo', ok(repo())], + ]; const results = await run(makeApi(routes, calls), TEMPLATE, { only: 'demo' }); assert.deepEqual( - results.map((r) => r.repo), - ['demo'] + results.map(r => r.repo), + ['demo'], ); assert.ok( - !calls.some((c) => c.url.includes('/orgs/')), - 'the org listing is the path to every other repo, so it must not be walked' + !calls.some(c => c.url.includes('/orgs/')), + 'the org listing is the path to every other repo, so it must not be walked', ); }); @@ -435,14 +506,20 @@ test('without only, every repo in the listing is considered', async () => { const others = [repo({ name: 'demo' }), repo({ name: 'production-repo' })]; const results = await run(makeApi(baseRoutes(TEMPLATE, others)), TEMPLATE, {}); assert.deepEqual( - results.map((r) => r.repo), - ['demo', 'production-repo'] + results.map(r => r.repo), + ['demo', 'production-repo'], ); }); test('only reports an error when the repo cannot be read', async () => { - const routes = [...baseRoutes(TEMPLATE), ['GET =/repos/learningequality/missing', fail(404, 'Not Found')]]; - await assert.rejects(() => run(makeApi(routes), TEMPLATE, { only: 'missing' }), /could not read missing/); + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET =/repos/learningequality/missing', fail(404, 'Not Found')], + ]; + await assert.rejects( + () => run(makeApi(routes), TEMPLATE, { only: 'missing' }), + /could not read missing/, + ); }); test('findConsumers pages through the org listing', async () => { @@ -450,9 +527,9 @@ test('findConsumers pages through the org listing', async () => { const many = Array.from({ length: 100 }, (_, i) => repo({ name: `r${i}` })); const routes = [ ...baseRoutes(TEMPLATE, many), - ['GET /orgs/learningequality/repos', (url) => ok(url.includes('page=2') ? [repo()] : many)], + ['GET /orgs/learningequality/repos', url => ok(url.includes('page=2') ? [repo()] : many)], ]; const consumers = await findConsumers(makeApi(routes, calls)); assert.equal(consumers.length, 101); - assert.ok(calls.some((c) => c.url.includes('page=2'))); + assert.ok(calls.some(c => c.url.includes('page=2'))); }); From 719c3d0525e8aadfa16ddae759c4b1df066d7191 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 25 Oct 2026 12:29:19 -0700 Subject: [PATCH 3/3] Scope app tokens and fix zizmor findings in workflows - Each create-github-app-token step requests only the permissions its job uses - Event values reach run: through env: - Top-level permissions: contents: read Co-Authored-By: Claude Opus 5.5 --- .github/workflows/community-contribution-label.yml | 6 +++++- .github/workflows/contributor-issue-comment.yml | 5 ++++- .github/workflows/contributor-pr-reply.yml | 5 ++++- .github/workflows/dependabot-reviewer.yml | 5 ++++- .github/workflows/good-first-issue-comment.yml | 2 +- .github/workflows/holiday-message.yml | 5 ++++- .github/workflows/is-contributor.yml | 1 + .github/workflows/manage-issue-header.yml | 2 +- .github/workflows/pr-statistics.yml | 4 +++- .github/workflows/pull-request-label.yml | 4 +++- .github/workflows/pull-request-template.yml | 2 +- .github/workflows/review-requested.yml | 4 +++- .github/workflows/sync-automation-template.yml | 5 ++++- .github/workflows/test.yml | 2 ++ .github/workflows/unassign-inactive-issues.yaml | 4 +++- .github/workflows/update-pr-spreadsheet.yml | 1 + 16 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/workflows/community-contribution-label.yml b/.github/workflows/community-contribution-label.yml index e61683b..4223d10 100644 --- a/.github/workflows/community-contribution-label.yml +++ b/.github/workflows/community-contribution-label.yml @@ -13,8 +13,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Check event context + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} run: | - if [[ "${{ github.event_name }}" != "issues" ]] || [[ "${{ github.event.action }}" != "assigned" && "${{ github.event.action }}" != "unassigned" ]]; then + if [[ "$EVENT_NAME" != "issues" ]] || [[ "$EVENT_ACTION" != "assigned" && "$EVENT_ACTION" != "unassigned" ]]; then echo "This workflow should only run on issue assigned/unassigned events" exit 1 fi @@ -29,6 +32,7 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write - uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: false diff --git a/.github/workflows/contributor-issue-comment.yml b/.github/workflows/contributor-issue-comment.yml index 71ea7aa..ab085e1 100644 --- a/.github/workflows/contributor-issue-comment.yml +++ b/.github/workflows/contributor-issue-comment.yml @@ -14,6 +14,8 @@ on: SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required: true description: 'Webhook URL for Slack #support-dev-notifications channel' +permissions: + contents: read jobs: check-if-contributor: name: Check if author is contributor @@ -36,12 +38,13 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write + permission-pull-requests: read - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/contributor-pr-reply.yml b/.github/workflows/contributor-pr-reply.yml index 9276a55..31283e8 100644 --- a/.github/workflows/contributor-pr-reply.yml +++ b/.github/workflows/contributor-pr-reply.yml @@ -11,6 +11,8 @@ on: SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required: true description: 'Webhook URL for Slack #support-dev-notifications channel' +permissions: + contents: read jobs: check-if-contributor: name: Check if author is contributor @@ -33,12 +35,13 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: read + permission-pull-requests: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/dependabot-reviewer.yml b/.github/workflows/dependabot-reviewer.yml index a52b2d9..d1a795b 100644 --- a/.github/workflows/dependabot-reviewer.yml +++ b/.github/workflows/dependabot-reviewer.yml @@ -24,8 +24,11 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Request review env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} GH_REPO: ${{ github.repository }} - run: gh pr edit ${{ github.event.pull_request.number }} --add-reviewer ${{ inputs.reviewer }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REVIEWER: ${{ inputs.reviewer }} + run: gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" diff --git a/.github/workflows/good-first-issue-comment.yml b/.github/workflows/good-first-issue-comment.yml index 48e2138..cf2b61d 100644 --- a/.github/workflows/good-first-issue-comment.yml +++ b/.github/workflows/good-first-issue-comment.yml @@ -18,12 +18,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/holiday-message.yml b/.github/workflows/holiday-message.yml index 90b0de2..698a58d 100644 --- a/.github/workflows/holiday-message.yml +++ b/.github/workflows/holiday-message.yml @@ -11,6 +11,8 @@ on: SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required: true description: 'Webhook URL for Slack #support-dev-notifications channel' +permissions: + contents: read jobs: check-if-contributor: name: Check if author is contributor @@ -33,12 +35,13 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write + permission-pull-requests: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/is-contributor.yml b/.github/workflows/is-contributor.yml index 75cfc18..31400fc 100644 --- a/.github/workflows/is-contributor.yml +++ b/.github/workflows/is-contributor.yml @@ -32,6 +32,7 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-metadata: read - name: Checkout .github repository uses: actions/checkout@v7 with: diff --git a/.github/workflows/manage-issue-header.yml b/.github/workflows/manage-issue-header.yml index ca91f08..b2e561b 100644 --- a/.github/workflows/manage-issue-header.yml +++ b/.github/workflows/manage-issue-header.yml @@ -18,12 +18,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/pr-statistics.yml b/.github/workflows/pr-statistics.yml index f6cb65b..3a18245 100644 --- a/.github/workflows/pr-statistics.yml +++ b/.github/workflows/pr-statistics.yml @@ -6,6 +6,8 @@ on: - cron: '0 8 * * 1' workflow_dispatch: # Allow manual triggering for testing +permissions: + contents: read jobs: generate-pr-statistics: runs-on: ubuntu-latest @@ -16,12 +18,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-pull-requests: read - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/pull-request-label.yml b/.github/workflows/pull-request-label.yml index 55d3f47..22405af 100644 --- a/.github/workflows/pull-request-label.yml +++ b/.github/workflows/pull-request-label.yml @@ -8,6 +8,8 @@ on: LE_BOT_PRIVATE_KEY: description: 'GitHub App Private Key for authentication' required: true +permissions: + contents: read jobs: check-if-contributor: name: Check if author is contributor @@ -30,12 +32,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/pull-request-template.yml b/.github/workflows/pull-request-template.yml index 91610e9..9714f96 100644 --- a/.github/workflows/pull-request-template.yml +++ b/.github/workflows/pull-request-template.yml @@ -19,12 +19,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-contents: read - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/review-requested.yml b/.github/workflows/review-requested.yml index e01e89b..1b140a7 100644 --- a/.github/workflows/review-requested.yml +++ b/.github/workflows/review-requested.yml @@ -8,6 +8,8 @@ on: LE_BOT_PRIVATE_KEY: description: 'GitHub App Private Key for authentication' required: true +permissions: + contents: read jobs: check-if-contributor: name: Check if author is contributor @@ -30,12 +32,12 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/sync-automation-template.yml b/.github/workflows/sync-automation-template.yml index ff5f7e7..eacf79b 100644 --- a/.github/workflows/sync-automation-template.yml +++ b/.github/workflows/sync-automation-template.yml @@ -34,7 +34,10 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} - owner: learningequality + owner: learningequality # zizmor: ignore[github-app] + permission-contents: write + permission-pull-requests: write + permission-workflows: write - name: Checkout uses: actions/checkout@v7 - name: Setup Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4086c7..9b54bc8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,8 @@ on: push: branches: - main +permissions: + contents: read jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/unassign-inactive-issues.yaml b/.github/workflows/unassign-inactive-issues.yaml index 7035a72..f354f91 100644 --- a/.github/workflows/unassign-inactive-issues.yaml +++ b/.github/workflows/unassign-inactive-issues.yaml @@ -22,12 +22,14 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-issues: write + permission-members: read + permission-pull-requests: read - name: Checkout .github repository uses: actions/checkout@v7 with: repository: learningequality/.github ref: main - token: ${{ steps.generate-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/update-pr-spreadsheet.yml b/.github/workflows/update-pr-spreadsheet.yml index dca9c80..13de4dd 100644 --- a/.github/workflows/update-pr-spreadsheet.yml +++ b/.github/workflows/update-pr-spreadsheet.yml @@ -23,6 +23,7 @@ jobs: with: app-id: ${{ secrets.LE_BOT_APP_ID }} private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + permission-pull-requests: read - name: Checkout .github repository uses: actions/checkout@v7 with: