From 731d00c3f6157411f572911a99dc616775d39cb5 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 13 May 2026 15:03:01 -0600 Subject: [PATCH 01/63] Add independent peer review validation workflow --- .../validateIndependentPeerReview.yml | 51 ++ README.md | 7 + package-lock.json | 803 ++++++++++++++++++ package.json | 17 + scripts/validateIndependentPeerReview.ts | 202 +++++ 5 files changed, 1080 insertions(+) create mode 100644 .github/workflows/validateIndependentPeerReview.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/validateIndependentPeerReview.ts diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/validateIndependentPeerReview.yml new file mode 100644 index 0000000..e962c05 --- /dev/null +++ b/.github/workflows/validateIndependentPeerReview.yml @@ -0,0 +1,51 @@ +# Note: This workflow is configured to run on all pull requests throughout the Expensify org, not just this repo. +# That has a few consequences: +# - We need to checkout the repo it's running on, and not just the GitHub-Actions repo +# - branch and path matching does not work in the workflow layer. From the docs: https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#supported-event-triggers +# > Any filters you specify for the supported events are ignored - for example, branches, branches-ignore, paths, types and so on. The workflow is only triggered, and is always triggered, by the default activity types of the supported events +name: Validate independent peer review + +on: pull_request + +permissions: + contents: read + pull-requests: read + +jobs: + validateIndependentPeerReview: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + # v3.1.1 + - name: Generate a GitHub App token + id: generateAppToken + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 + with: + app-id: ${{ secrets.OS_BOTIFY_APP_ID }} + private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + ${{ github.event.repository.name }} + GitHub-Actions + permission-administration: read + permission-contents: read + permission-members: read + permission-metadata: read + permission-pull-requests: read + + - name: Checkout repos + id: repo + uses: Expensify/GitHub-Actions/checkoutRepoAndGitHubActions@main + + # v4.3.0 + - name: Setup Node + uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e + + - name: Install npm packages + run: npm ci + working-directory: GitHub-Actions + + - name: Validate independent peer review + run: npm run validate-independent-peer-review + working-directory: GitHub-Actions + env: + GITHUB_TOKEN: ${{ steps.generateAppToken.outputs.token }} diff --git a/README.md b/README.md index fd85a3d..fd1491b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ jobs: secrets: inherit ``` +### `validateIndependentPeerReview.yml` + +Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. + +This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses `OS_BOTIFY_APP_ID` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. + ## Rulesets GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. @@ -47,3 +53,4 @@ GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/r - If you need to target or exclude specific paths, that must be implemented manually in the workflow itself. - Due to a GitHub :bug:, PRs that are open when the rule is enabled will get stuck with a pending check that will never get picked up. The easiest way to fix that is to close and reopen the PR. Consider writing a script to close and reopen all open PRs across the org after the check is enabled. - It is less disruptive to [configure the ruleset to `Evaluate` first](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#using-evaluate-mode-for-ruleset-workflows), then `Active` once the kinks are worked out. +- For `validateIndependentPeerReview.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..95df49d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,803 @@ +{ + "name": "@expensify/github-actions", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@expensify/github-actions", + "dependencies": { + "@octokit/request-error": "^7.1.0", + "@octokit/rest": "^22.0.1" + }, + "devDependencies": { + "@octokit/webhooks-types": "^7.6.1", + "@types/node": "^25.7.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", + "integrity": "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz", + "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.21.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5331a3f --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@expensify/github-actions", + "private": true, + "scripts": { + "validate-independent-peer-review": "tsx scripts/validateIndependentPeerReview.ts" + }, + "devDependencies": { + "@octokit/webhooks-types": "^7.6.1", + "@types/node": "^25.7.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + }, + "dependencies": { + "@octokit/request-error": "^7.1.0", + "@octokit/rest": "^22.0.1" + } +} diff --git a/scripts/validateIndependentPeerReview.ts b/scripts/validateIndependentPeerReview.ts new file mode 100644 index 0000000..ec90da2 --- /dev/null +++ b/scripts/validateIndependentPeerReview.ts @@ -0,0 +1,202 @@ +import {readFileSync} from 'node:fs'; +import {RequestError} from '@octokit/request-error'; +import {Octokit, type RestEndpointMethodTypes} from '@octokit/rest'; +import type {PullRequestEvent} from '@octokit/webhooks-types'; + +type Commit = RestEndpointMethodTypes['pulls']['listCommits']['response']['data'][number]; +type Review = RestEndpointMethodTypes['pulls']['listReviews']['response']['data'][number]; + +type PullRequestContext = { + owner: string; + repo: string; + number: number; + baseRef: string; +}; + +const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); +const defaultRequiredApprovingReviewCount = 1; +const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; +if (!githubToken) { + throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); +} + +const octokit = new Octokit({ + auth: githubToken, +}); + +function formatUsers(users: string[]): string { + return users.length > 0 ? users.join(', ') : '(none)'; +} + +function unique(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +function getPullRequestContext(): PullRequestContext { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + throw new Error('GITHUB_EVENT_PATH is required'); + } + + const event = JSON.parse(readFileSync(eventPath, 'utf8')) as PullRequestEvent; + return { + owner: event.repository.owner.login, + repo: event.repository.name, + number: event.pull_request.number, + baseRef: event.pull_request.base.ref, + }; +} + +async function getRequiredApprovingReviewCount({owner, repo, baseRef}: PullRequestContext): Promise { + try { + const {data} = await octokit.rest.repos.getPullRequestReviewProtection({ + owner, + repo, + branch: baseRef, + }); + return data.required_approving_review_count ?? 0; + } catch (error: unknown) { + if (error instanceof RequestError && error.status === 404) { + console.log(`${owner}/${repo}@${baseRef} did not return a branch protection review count; requiring ${defaultRequiredApprovingReviewCount} independent approval(s).`); + return defaultRequiredApprovingReviewCount; + } + throw error; + } +} + +function getLatestApprovers(reviews: Review[]): string[] { + const latestOpinionatedReviewByUser = new Map(); + const opinionatedStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + + for (const review of reviews) { + const login = review.user?.login; + if (login && opinionatedStates.has(review.state)) { + latestOpinionatedReviewByUser.set(login, review.state); + } + } + + return unique([...latestOpinionatedReviewByUser.entries()] + .filter(([, state]) => state === 'APPROVED') + .map(([login]) => login)); +} + +function coAuthorEmails(message: string): string[] { + return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); +} + +function resolveCoAuthorLogin(email: string): string | null { + return email.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; +} + +function getCommitAuthors(commits: Commit[]): {authors: string[]; unresolvedExpensifyCoAuthors: string[]} { + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = commit.author?.login ?? ''; + if (canonicalAuthor) { + authors.add(canonicalAuthor); + } + if (canonicalAuthor && !botUsers.has(canonicalAuthor)) { + continue; + } + + for (const email of coAuthorEmails(commit.commit.message)) { + const login = resolveCoAuthorLogin(email); + if (login) { + authors.add(login); + } else if (email.trim().toLowerCase().endsWith('@expensify.com')) { + unresolvedExpensifyCoAuthors.add(email.trim()); + } + } + } + + return { + authors: unique([...authors]), + unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + }; +} + +async function isEmployee(username: string): Promise { + try { + await octokit.rest.orgs.checkMembershipForUser({ + org: 'Expensify', + username, + }); + return true; + } catch (error: unknown) { + if (error instanceof RequestError && error.status === 404) { + return false; + } + throw error; + } +} + +async function isRepoWriter({owner, repo}: PullRequestContext, username: string): Promise { + const {data} = await octokit.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + return ['admin', 'maintain', 'write'].includes(data.permission); +} + +async function getIndependentEmployeeApprovers(context: PullRequestContext, approvers: string[], authors: string[]): Promise { + const authorSet = new Set(authors); + const independentEmployeeApprovers: string[] = []; + for (const approver of approvers) { + if (!authorSet.has(approver) && await isEmployee(approver) && await isRepoWriter(context, approver)) { + independentEmployeeApprovers.push(approver); + } + } + return independentEmployeeApprovers; +} + +async function main(): Promise { + const context = getPullRequestContext(); + const {owner, repo, number} = context; + const pullRequestParams = { + owner, + repo, + pull_number: number, + per_page: 100, + }; + const [requiredApprovingReviewCount, reviews, commits] = await Promise.all([ + getRequiredApprovingReviewCount(context), + octokit.paginate(octokit.rest.pulls.listReviews, pullRequestParams), + octokit.paginate(octokit.rest.pulls.listCommits, pullRequestParams), + ]); + const approvers = getLatestApprovers(reviews); + + if (requiredApprovingReviewCount === 0) { + console.log(`${owner}/${repo}#${number} targets ${context.baseRef}, which does not require approving reviews.`); + return; + } + + const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); + if (unresolvedExpensifyCoAuthors.length > 0) { + throw new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`); + } + if (authors.every((author) => botUsers.has(author))) { + throw new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`); + } + + const independentEmployeeApprovers = await getIndependentEmployeeApprovers(context, approvers, authors); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + throw new Error([ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join('\n')); + } + + console.log(`${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`::error::${message.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}`); + process.exit(1); +}); From aba7b19a50b52458f29cd40fd8836871d8aa09e4 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Wed, 13 May 2026 15:57:20 -0600 Subject: [PATCH 02/63] Use GitHub App client ID for peer review check --- .github/workflows/validateIndependentPeerReview.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/validateIndependentPeerReview.yml index e962c05..ff3aaef 100644 --- a/.github/workflows/validateIndependentPeerReview.yml +++ b/.github/workflows/validateIndependentPeerReview.yml @@ -20,7 +20,7 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - app-id: ${{ secrets.OS_BOTIFY_APP_ID }} + client-id: ${{ secrets.OS_BOTIFY_CLIENT_ID }} private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | diff --git a/README.md b/README.md index fd1491b..f630196 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ jobs: Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. -This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses `OS_BOTIFY_APP_ID` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. +This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses `OS_BOTIFY_CLIENT_ID` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. ## Rulesets GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. From 2d2a6c9324c7695ef2ee3866f9a2c87541da2afa Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:36:27 -0600 Subject: [PATCH 03/63] Use GraphQL for peer review validation --- package-lock.json | 2 +- package.json | 2 +- scripts/validateIndependentPeerReview.ts | 185 ++++++++++++++++------- 3 files changed, 133 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95df49d..814f29d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "@expensify/github-actions", "dependencies": { - "@octokit/request-error": "^7.1.0", + "@octokit/graphql": "^9.0.3", "@octokit/rest": "^22.0.1" }, "devDependencies": { diff --git a/package.json b/package.json index 5331a3f..c9fcd31 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typescript": "^6.0.3" }, "dependencies": { - "@octokit/request-error": "^7.1.0", + "@octokit/graphql": "^9.0.3", "@octokit/rest": "^22.0.1" } } diff --git a/scripts/validateIndependentPeerReview.ts b/scripts/validateIndependentPeerReview.ts index ec90da2..0e00179 100644 --- a/scripts/validateIndependentPeerReview.ts +++ b/scripts/validateIndependentPeerReview.ts @@ -1,10 +1,9 @@ import {readFileSync} from 'node:fs'; -import {RequestError} from '@octokit/request-error'; +import {graphql} from '@octokit/graphql'; import {Octokit, type RestEndpointMethodTypes} from '@octokit/rest'; import type {PullRequestEvent} from '@octokit/webhooks-types'; type Commit = RestEndpointMethodTypes['pulls']['listCommits']['response']['data'][number]; -type Review = RestEndpointMethodTypes['pulls']['listReviews']['response']['data'][number]; type PullRequestContext = { owner: string; @@ -13,8 +12,52 @@ type PullRequestContext = { baseRef: string; }; +type BranchProtectionResponse = { + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; + } | null; +}; + +type LatestOpinionatedReviewsResponse = { + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: Array<{ + state: string; + author: { + login: string; + } | null; + }>; + }; + } | null; + } | null; +}; + +type TeamMembersResponse = { + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; + } | null; +}; +type TeamResponse = NonNullable['team']; + const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); const defaultRequiredApprovingReviewCount = 1; +const expensifyOrganization = 'Expensify'; +const expensifyEmployeeTeamSlug = 'expensify-expensify'; const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!githubToken) { throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); @@ -23,6 +66,11 @@ if (!githubToken) { const octokit = new Octokit({ auth: githubToken, }); +const githubGraphql = graphql.defaults({ + headers: { + authorization: `token ${githubToken}`, + }, +}); function formatUsers(users: string[]): string { return users.length > 0 ? users.join(', ') : '(none)'; @@ -49,35 +97,55 @@ function getPullRequestContext(): PullRequestContext { async function getRequiredApprovingReviewCount({owner, repo, baseRef}: PullRequestContext): Promise { try { - const {data} = await octokit.rest.repos.getPullRequestReviewProtection({ + const response = await githubGraphql(` + query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branchRef) { + branchProtectionRule { + requiredApprovingReviewCount + } + } + } + } + `, { owner, repo, - branch: baseRef, + branchRef: `refs/heads/${baseRef}`, }); - return data.required_approving_review_count ?? 0; + return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; } catch (error: unknown) { - if (error instanceof RequestError && error.status === 404) { - console.log(`${owner}/${repo}@${baseRef} did not return a branch protection review count; requiring ${defaultRequiredApprovingReviewCount} independent approval(s).`); - return defaultRequiredApprovingReviewCount; - } - throw error; + const message = error instanceof Error ? error.message : String(error); + console.log(`${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${defaultRequiredApprovingReviewCount} independent approval(s).`); + return defaultRequiredApprovingReviewCount; } } -function getLatestApprovers(reviews: Review[]): string[] { - const latestOpinionatedReviewByUser = new Map(); - const opinionatedStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); - - for (const review of reviews) { - const login = review.user?.login; - if (login && opinionatedStates.has(review.state)) { - latestOpinionatedReviewByUser.set(login, review.state); +async function getLatestApprovers({owner, repo, number}: PullRequestContext): Promise { + const response = await githubGraphql(` + query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + latestOpinionatedReviews(last: 100, writersOnly: true) { + nodes { + state + author { + login + } + } + } + } + } } - } + `, { + owner, + repo, + prNumber: number, + }); - return unique([...latestOpinionatedReviewByUser.entries()] - .filter(([, state]) => state === 'APPROVED') - .map(([login]) => login)); + return unique(response.repository?.pullRequest?.latestOpinionatedReviews.nodes + .filter((review) => review.state === 'APPROVED') + .map((review) => review.author?.login ?? '') + .filter((login) => login !== '') ?? []); } function coAuthorEmails(message: string): string[] { @@ -117,39 +185,48 @@ function getCommitAuthors(commits: Commit[]): {authors: string[]; unresolvedExpe }; } -async function isEmployee(username: string): Promise { - try { - await octokit.rest.orgs.checkMembershipForUser({ - org: 'Expensify', - username, +async function getEmployeeLogins(): Promise> { + const employeeLogins = new Set(); + let cursor: string | null = null; + do { + const response: TeamMembersResponse = await githubGraphql(` + query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { + organization(login: $organization) { + team(slug: $teamSlug) { + members(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + login + } + } + } + } + } + `, { + organization: expensifyOrganization, + teamSlug: expensifyEmployeeTeamSlug, + cursor, }); - return true; - } catch (error: unknown) { - if (error instanceof RequestError && error.status === 404) { - return false; + const team: TeamResponse = response.organization?.team ?? null; + if (!team) { + throw new Error(`${expensifyOrganization}/${expensifyEmployeeTeamSlug} team could not be found.`); } - throw error; - } -} - -async function isRepoWriter({owner, repo}: PullRequestContext, username: string): Promise { - const {data} = await octokit.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username, - }); - return ['admin', 'maintain', 'write'].includes(data.permission); + for (const member of team.members.nodes) { + employeeLogins.add(member.login); + } + cursor = team.members.pageInfo.hasNextPage ? team.members.pageInfo.endCursor : null; + } while (cursor); + return employeeLogins; } -async function getIndependentEmployeeApprovers(context: PullRequestContext, approvers: string[], authors: string[]): Promise { +function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { const authorSet = new Set(authors); - const independentEmployeeApprovers: string[] = []; - for (const approver of approvers) { - if (!authorSet.has(approver) && await isEmployee(approver) && await isRepoWriter(context, approver)) { - independentEmployeeApprovers.push(approver); - } - } - return independentEmployeeApprovers; + return approvers.filter((approver) => { + return !authorSet.has(approver) && employeeLogins.has(approver); + }); } async function main(): Promise { @@ -161,12 +238,11 @@ async function main(): Promise { pull_number: number, per_page: 100, }; - const [requiredApprovingReviewCount, reviews, commits] = await Promise.all([ + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ getRequiredApprovingReviewCount(context), - octokit.paginate(octokit.rest.pulls.listReviews, pullRequestParams), + getLatestApprovers(context), octokit.paginate(octokit.rest.pulls.listCommits, pullRequestParams), ]); - const approvers = getLatestApprovers(reviews); if (requiredApprovingReviewCount === 0) { console.log(`${owner}/${repo}#${number} targets ${context.baseRef}, which does not require approving reviews.`); @@ -181,7 +257,8 @@ async function main(): Promise { throw new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`); } - const independentEmployeeApprovers = await getIndependentEmployeeApprovers(context, approvers, authors); + const employeeLogins = await getEmployeeLogins(); + const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { throw new Error([ `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, From 89bebf1686834247398e1f602922bdd019fe15a8 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:43:45 -0600 Subject: [PATCH 04/63] Simplify peer review validation helpers --- scripts/validateIndependentPeerReview.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/scripts/validateIndependentPeerReview.ts b/scripts/validateIndependentPeerReview.ts index 0e00179..0644a59 100644 --- a/scripts/validateIndependentPeerReview.ts +++ b/scripts/validateIndependentPeerReview.ts @@ -52,7 +52,6 @@ type TeamMembersResponse = { } | null; } | null; }; -type TeamResponse = NonNullable['team']; const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); const defaultRequiredApprovingReviewCount = 1; @@ -210,23 +209,21 @@ async function getEmployeeLogins(): Promise> { teamSlug: expensifyEmployeeTeamSlug, cursor, }); - const team: TeamResponse = response.organization?.team ?? null; - if (!team) { + const members = response.organization?.team?.members; + if (!members) { throw new Error(`${expensifyOrganization}/${expensifyEmployeeTeamSlug} team could not be found.`); } - for (const member of team.members.nodes) { + for (const member of members.nodes) { employeeLogins.add(member.login); } - cursor = team.members.pageInfo.hasNextPage ? team.members.pageInfo.endCursor : null; + cursor = members.pageInfo.hasNextPage ? members.pageInfo.endCursor : null; } while (cursor); return employeeLogins; } function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { const authorSet = new Set(authors); - return approvers.filter((approver) => { - return !authorSet.has(approver) && employeeLogins.has(approver); - }); + return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); } async function main(): Promise { From e72fba83d00ce78061e8c6da6fa9d82fdde9dafa Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:45:30 -0600 Subject: [PATCH 05/63] Add newline --- scripts/validateIndependentPeerReview.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validateIndependentPeerReview.ts b/scripts/validateIndependentPeerReview.ts index 0644a59..462818c 100644 --- a/scripts/validateIndependentPeerReview.ts +++ b/scripts/validateIndependentPeerReview.ts @@ -65,6 +65,7 @@ if (!githubToken) { const octokit = new Octokit({ auth: githubToken, }); + const githubGraphql = graphql.defaults({ headers: { authorization: `token ${githubToken}`, From f473b3b040e1b6af1d28a9858c58d71f114469d7 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:49:14 -0600 Subject: [PATCH 06/63] Hard-code OS Botify app ID --- .github/workflows/validateIndependentPeerReview.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/validateIndependentPeerReview.yml index ff3aaef..b88cd9e 100644 --- a/.github/workflows/validateIndependentPeerReview.yml +++ b/.github/workflows/validateIndependentPeerReview.yml @@ -20,7 +20,7 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - client-id: ${{ secrets.OS_BOTIFY_CLIENT_ID }} + app-id: 365978 private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | diff --git a/README.md b/README.md index f630196..d23d77a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ jobs: Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. -This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses `OS_BOTIFY_CLIENT_ID` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. +This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses the OS Botify app ID `365978` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. ## Rulesets GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. From 23b2a2c6552e7ed9c6822c4de9f320cede3dca29 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:49:48 -0600 Subject: [PATCH 07/63] use client-id --- .github/workflows/validateIndependentPeerReview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/validateIndependentPeerReview.yml index b88cd9e..556fcc0 100644 --- a/.github/workflows/validateIndependentPeerReview.yml +++ b/.github/workflows/validateIndependentPeerReview.yml @@ -20,7 +20,7 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - app-id: 365978 + client-id: 365978 private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | From 61d554cd0e52a8dbe81385de6d8f8e3f9cab969d Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:53:29 -0600 Subject: [PATCH 08/63] Fix OS Botify token permissions --- .github/workflows/validateIndependentPeerReview.yml | 3 +-- README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/validateIndependentPeerReview.yml index 556fcc0..8fe02d4 100644 --- a/.github/workflows/validateIndependentPeerReview.yml +++ b/.github/workflows/validateIndependentPeerReview.yml @@ -20,13 +20,12 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - client-id: 365978 + client-id: Iv1.26553ed1d375dd3c private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | ${{ github.event.repository.name }} GitHub-Actions - permission-administration: read permission-contents: read permission-members: read permission-metadata: read diff --git a/README.md b/README.md index d23d77a..acdf705 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ jobs: Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. -This workflow requires a GitHub App token with read access for repository metadata, pull requests, branch protection administration, and organization members. It uses the OS Botify app ID `365978` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. +This workflow requires a GitHub App token with read access for repository metadata, pull requests, and organization members. It uses the OS Botify client ID `Iv1.26553ed1d375dd3c` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. ## Rulesets GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. From 0b48d7423665b2857c748876e96fe03936a71643 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 16:57:24 -0600 Subject: [PATCH 09/63] Rename peer review workflow --- ...alidateIndependentPeerReview.yml => verifyPeerReviews.yml} | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename .github/workflows/{validateIndependentPeerReview.yml => verifyPeerReviews.yml} (98%) diff --git a/.github/workflows/validateIndependentPeerReview.yml b/.github/workflows/verifyPeerReviews.yml similarity index 98% rename from .github/workflows/validateIndependentPeerReview.yml rename to .github/workflows/verifyPeerReviews.yml index 8fe02d4..957454d 100644 --- a/.github/workflows/validateIndependentPeerReview.yml +++ b/.github/workflows/verifyPeerReviews.yml @@ -3,7 +3,7 @@ # - We need to checkout the repo it's running on, and not just the GitHub-Actions repo # - branch and path matching does not work in the workflow layer. From the docs: https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#supported-event-triggers # > Any filters you specify for the supported events are ignored - for example, branches, branches-ignore, paths, types and so on. The workflow is only triggered, and is always triggered, by the default activity types of the supported events -name: Validate independent peer review +name: Verify peer reviews on: pull_request diff --git a/README.md b/README.md index acdf705..4cbc0e6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ jobs: secrets: inherit ``` -### `validateIndependentPeerReview.yml` +### `verifyPeerReviews.yml` Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. @@ -53,4 +53,4 @@ GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/r - If you need to target or exclude specific paths, that must be implemented manually in the workflow itself. - Due to a GitHub :bug:, PRs that are open when the rule is enabled will get stuck with a pending check that will never get picked up. The easiest way to fix that is to close and reopen the PR. Consider writing a script to close and reopen all open PRs across the org after the check is enabled. - It is less disruptive to [configure the ruleset to `Evaluate` first](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#using-evaluate-mode-for-ruleset-workflows), then `Active` once the kinks are worked out. -- For `validateIndependentPeerReview.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. +- For `verifyPeerReviews.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. From e57f967c9d203a9b28c79fd519a1bc953adc8e25 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:01:48 -0600 Subject: [PATCH 10/63] Rename peer review verifier --- .../{verifyPeerReviews.yml => verifyPeerReview.yml} | 8 ++++---- README.md | 4 ++-- package.json | 2 +- ...lidateIndependentPeerReview.ts => verifyPeerReview.ts} | 0 4 files changed, 7 insertions(+), 7 deletions(-) rename .github/workflows/{verifyPeerReviews.yml => verifyPeerReview.yml} (92%) rename scripts/{validateIndependentPeerReview.ts => verifyPeerReview.ts} (100%) diff --git a/.github/workflows/verifyPeerReviews.yml b/.github/workflows/verifyPeerReview.yml similarity index 92% rename from .github/workflows/verifyPeerReviews.yml rename to .github/workflows/verifyPeerReview.yml index 957454d..99aa4dd 100644 --- a/.github/workflows/verifyPeerReviews.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -3,7 +3,7 @@ # - We need to checkout the repo it's running on, and not just the GitHub-Actions repo # - branch and path matching does not work in the workflow layer. From the docs: https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#supported-event-triggers # > Any filters you specify for the supported events are ignored - for example, branches, branches-ignore, paths, types and so on. The workflow is only triggered, and is always triggered, by the default activity types of the supported events -name: Verify peer reviews +name: Verify peer review on: pull_request @@ -12,7 +12,7 @@ permissions: pull-requests: read jobs: - validateIndependentPeerReview: + verifyPeerReview: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: # v3.1.1 @@ -43,8 +43,8 @@ jobs: run: npm ci working-directory: GitHub-Actions - - name: Validate independent peer review - run: npm run validate-independent-peer-review + - name: Verify peer review + run: npm run verify-peer-review working-directory: GitHub-Actions env: GITHUB_TOKEN: ${{ steps.generateAppToken.outputs.token }} diff --git a/README.md b/README.md index 4cbc0e6..1dbb753 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ jobs: secrets: inherit ``` -### `verifyPeerReviews.yml` +### `verifyPeerReview.yml` Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. @@ -53,4 +53,4 @@ GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/r - If you need to target or exclude specific paths, that must be implemented manually in the workflow itself. - Due to a GitHub :bug:, PRs that are open when the rule is enabled will get stuck with a pending check that will never get picked up. The easiest way to fix that is to close and reopen the PR. Consider writing a script to close and reopen all open PRs across the org after the check is enabled. - It is less disruptive to [configure the ruleset to `Evaluate` first](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#using-evaluate-mode-for-ruleset-workflows), then `Active` once the kinks are worked out. -- For `verifyPeerReviews.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. +- For `verifyPeerReview.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. diff --git a/package.json b/package.json index c9fcd31..e5eed16 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@expensify/github-actions", "private": true, "scripts": { - "validate-independent-peer-review": "tsx scripts/validateIndependentPeerReview.ts" + "verify-peer-review": "tsx scripts/verifyPeerReview.ts" }, "devDependencies": { "@octokit/webhooks-types": "^7.6.1", diff --git a/scripts/validateIndependentPeerReview.ts b/scripts/verifyPeerReview.ts similarity index 100% rename from scripts/validateIndependentPeerReview.ts rename to scripts/verifyPeerReview.ts From b6aea8531b5a7d1adc8c2d68ac9be241752a0db1 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:05:54 -0600 Subject: [PATCH 11/63] Clarify peer review failures --- .github/workflows/verifyPeerReview.yml | 2 ++ scripts/verifyPeerReview.ts | 35 ++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 99aa4dd..dd43067 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -4,6 +4,7 @@ # - branch and path matching does not work in the workflow layer. From the docs: https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#supported-event-triggers # > Any filters you specify for the supported events are ignored - for example, branches, branches-ignore, paths, types and so on. The workflow is only triggered, and is always triggered, by the default activity types of the supported events name: Verify peer review +run-name: Verify peer review for ${{ github.repository }}#${{ github.event.pull_request.number }} on: pull_request @@ -13,6 +14,7 @@ permissions: jobs: verifyPeerReview: + name: Check independent approval runs-on: blacksmith-2vcpu-ubuntu-2404 steps: # v3.1.1 diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 462818c..8c9c0f0 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,4 +1,4 @@ -import {readFileSync} from 'node:fs'; +import {appendFileSync, readFileSync} from 'node:fs'; import {graphql} from '@octokit/graphql'; import {Octokit, type RestEndpointMethodTypes} from '@octokit/rest'; import type {PullRequestEvent} from '@octokit/webhooks-types'; @@ -80,6 +80,35 @@ function unique(values: string[]): string[] { return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } +function escapeWorkflowCommandValue(value: string): string { + return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +function escapeWorkflowCommandProperty(value: string): string { + return escapeWorkflowCommandValue(value).replace(/:/g, '%3A').replace(/,/g, '%2C'); +} + +function getFailureTitle(message: string): string { + if (message.includes('does not have enough independent Expensify employee approvals')) { + return 'Missing independent peer review'; + } + if (message.includes('Unable to resolve Expensify co-author emails')) { + return 'Unresolved Expensify co-author'; + } + if (message.includes('has no human commit authors or co-authors')) { + return 'No human commit author'; + } + return 'Peer review verification failed'; +} + +function writeStepSummary(title: string, message: string): void { + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!stepSummaryPath) { + return; + } + appendFileSync(stepSummaryPath, `## ${title}\n\n${message.replace(/\n/g, '\n\n')}\n`); +} + function getPullRequestContext(): PullRequestContext { const eventPath = process.env.GITHUB_EVENT_PATH; if (!eventPath) { @@ -272,6 +301,8 @@ async function main(): Promise { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); - console.error(`::error::${message.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}`); + const title = getFailureTitle(message); + writeStepSummary(title, message); + console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); process.exit(1); }); From 2d8ce9c93a439767548d65e5275f0d1ceb84352e Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:14:22 -0600 Subject: [PATCH 12/63] Test informational peer review checks --- .github/workflows/verifyPeerReview.yml | 2 ++ scripts/verifyPeerReview.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index dd43067..7c9d40d 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -14,6 +14,7 @@ permissions: jobs: verifyPeerReview: + if: ${{ !github.event.pull_request.draft }} name: Check independent approval runs-on: blacksmith-2vcpu-ubuntu-2404 steps: @@ -50,3 +51,4 @@ jobs: working-directory: GitHub-Actions env: GITHUB_TOKEN: ${{ steps.generateAppToken.outputs.token }} + VERIFY_PEER_REVIEW_MODE: informational diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 8c9c0f0..eaa8716 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -57,6 +57,7 @@ const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); const defaultRequiredApprovingReviewCount = 1; const expensifyOrganization = 'Expensify'; const expensifyEmployeeTeamSlug = 'expensify-expensify'; +const isInformationalMode = process.env.VERIFY_PEER_REVIEW_MODE === 'informational'; const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!githubToken) { throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); @@ -302,7 +303,12 @@ async function main(): Promise { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); const title = getFailureTitle(message); - writeStepSummary(title, message); - console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); + const annotationType = isInformationalMode ? 'warning' : 'error'; + const summary = isInformationalMode ? `${message}\n\nThis check is running in informational mode, so it did not fail the workflow.` : message; + writeStepSummary(title, summary); + console.error(`::${annotationType} title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(summary)}`); + if (isInformationalMode) { + process.exit(0); + } process.exit(1); }); From 4bee49de41e066f8a3f3046d0ddb2ebcd2b9fefd Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:16:43 -0600 Subject: [PATCH 13/63] Undo draft --- .github/workflows/verifyPeerReview.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 7c9d40d..5263ac1 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -14,7 +14,6 @@ permissions: jobs: verifyPeerReview: - if: ${{ !github.event.pull_request.draft }} name: Check independent approval runs-on: blacksmith-2vcpu-ubuntu-2404 steps: From 1d3a55983946fecb3cbbaf06dc3bedcaafc4222f Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:19:47 -0600 Subject: [PATCH 14/63] Show informational peer review failures --- .github/workflows/verifyPeerReview.yml | 1 + scripts/verifyPeerReview.ts | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 5263ac1..661c783 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -46,6 +46,7 @@ jobs: working-directory: GitHub-Actions - name: Verify peer review + continue-on-error: true run: npm run verify-peer-review working-directory: GitHub-Actions env: diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index eaa8716..b26eead 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -304,11 +304,8 @@ main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); const title = getFailureTitle(message); const annotationType = isInformationalMode ? 'warning' : 'error'; - const summary = isInformationalMode ? `${message}\n\nThis check is running in informational mode, so it did not fail the workflow.` : message; + const summary = isInformationalMode ? `${message}\n\nThis check is running in informational mode, so the workflow continues after reporting this failure.` : message; writeStepSummary(title, summary); console.error(`::${annotationType} title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(summary)}`); - if (isInformationalMode) { - process.exit(0); - } process.exit(1); }); From 74415cd18562396fd201ef5f050e02509c933f60 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Fri, 15 May 2026 17:22:40 -0600 Subject: [PATCH 15/63] Restore hard peer review failure --- .github/workflows/verifyPeerReview.yml | 2 -- scripts/verifyPeerReview.ts | 7 ++----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 661c783..dd43067 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -46,9 +46,7 @@ jobs: working-directory: GitHub-Actions - name: Verify peer review - continue-on-error: true run: npm run verify-peer-review working-directory: GitHub-Actions env: GITHUB_TOKEN: ${{ steps.generateAppToken.outputs.token }} - VERIFY_PEER_REVIEW_MODE: informational diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index b26eead..8c9c0f0 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -57,7 +57,6 @@ const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); const defaultRequiredApprovingReviewCount = 1; const expensifyOrganization = 'Expensify'; const expensifyEmployeeTeamSlug = 'expensify-expensify'; -const isInformationalMode = process.env.VERIFY_PEER_REVIEW_MODE === 'informational'; const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!githubToken) { throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); @@ -303,9 +302,7 @@ async function main(): Promise { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); const title = getFailureTitle(message); - const annotationType = isInformationalMode ? 'warning' : 'error'; - const summary = isInformationalMode ? `${message}\n\nThis check is running in informational mode, so the workflow continues after reporting this failure.` : message; - writeStepSummary(title, summary); - console.error(`::${annotationType} title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(summary)}`); + writeStepSummary(title, message); + console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); process.exit(1); }); From 74efe1d9fadc94346d88057ace18629321069f46 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Mon, 18 May 2026 10:50:15 -0600 Subject: [PATCH 16/63] Pass peer review check before approval --- scripts/verifyPeerReview.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 8c9c0f0..9c1e4f9 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -275,6 +275,10 @@ async function main(): Promise { console.log(`${owner}/${repo}#${number} targets ${context.baseRef}, which does not require approving reviews.`); return; } + if (approvers.length === 0) { + console.log(`${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`); + return; + } const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); if (unresolvedExpensifyCoAuthors.length > 0) { From 72a99fd143c5af3fe4f6758e30149e310f3741b3 Mon Sep 17 00:00:00 2001 From: Andrew Gable Date: Mon, 18 May 2026 15:37:55 -0600 Subject: [PATCH 17/63] Use Melvin app for peer review check --- .github/workflows/verifyPeerReview.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index dd43067..61a99d5 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -22,8 +22,8 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - client-id: Iv1.26553ed1d375dd3c - private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + app-id: '179547' + private-key: ${{ secrets.MELVIN_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | ${{ github.event.repository.name }} diff --git a/README.md b/README.md index 1dbb753..9f9e75a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ jobs: Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. -This workflow requires a GitHub App token with read access for repository metadata, pull requests, and organization members. It uses the OS Botify client ID `Iv1.26553ed1d375dd3c` and `OS_BOTIFY_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. +This workflow requires a GitHub App token with read access for repository metadata, pull requests, and organization members. It uses the Melvin Bot app ID `179547` and `MELVIN_APP_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. ## Rulesets GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. From fec8f31a0a25b73913e11def9540c61778b23886 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:07:55 -0700 Subject: [PATCH 18/63] Pin Node 24 and upgrade setup-node to v6 with npm cache --- .github/workflows/npmPublish.yml | 4 ++-- .github/workflows/validateActions.yml | 6 ++++-- .github/workflows/verifyPeerReview.yml | 8 ++++++-- .nvmrc | 1 + package.json | 3 ++- 5 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/npmPublish.yml b/.github/workflows/npmPublish.yml index dccde2a..133c342 100644 --- a/.github/workflows/npmPublish.yml +++ b/.github/workflows/npmPublish.yml @@ -39,8 +39,8 @@ jobs: repository: ${{ inputs.repository }} token: ${{ steps.generateAppToken.outputs.token }} - # actions/setup-node@v4.3.0 - - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e + # actions/setup-node@v6.1.0 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f with: registry-url: 'https://registry.npmjs.org' node-version-file: '.nvmrc' diff --git a/.github/workflows/validateActions.yml b/.github/workflows/validateActions.yml index f99587a..d905a92 100644 --- a/.github/workflows/validateActions.yml +++ b/.github/workflows/validateActions.yml @@ -16,9 +16,11 @@ jobs: id: repo uses: Expensify/GitHub-Actions/checkoutRepoAndGitHubActions@main - # v4.3.0 + # v6.1.0 - name: Setup Node - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: GitHub-Actions/.nvmrc # Install node to get the ajv-cli - name: Install node modules diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 61a99d5..6390bb6 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -37,9 +37,13 @@ jobs: id: repo uses: Expensify/GitHub-Actions/checkoutRepoAndGitHubActions@main - # v4.3.0 + # v6.1.0 - name: Setup Node - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: GitHub-Actions/.nvmrc + cache: npm + cache-dependency-path: GitHub-Actions/package-lock.json - name: Install npm packages run: npm ci diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/package.json b/package.json index e5eed16..16174c3 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,6 @@ "dependencies": { "@octokit/graphql": "^9.0.3", "@octokit/rest": "^22.0.1" - } + }, + "packageManager": "npm@11.13.0" } From 76b600fe805fa263979104f4536b2dbbf4f9dce6 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:08:06 -0700 Subject: [PATCH 19/63] Add GithubUtils wrapper with rate limiting Co-authored-by: Cursor --- libs/github/CONST.ts | 5 +++ libs/github/GithubUtils.ts | 87 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 25 +++++++++++ package.json | 8 +++- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 libs/github/CONST.ts create mode 100644 libs/github/GithubUtils.ts diff --git a/libs/github/CONST.ts b/libs/github/CONST.ts new file mode 100644 index 0000000..d473210 --- /dev/null +++ b/libs/github/CONST.ts @@ -0,0 +1,5 @@ +export const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); + +export const EXPENSIFY_ORG = "Expensify"; + +export const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; diff --git a/libs/github/GithubUtils.ts b/libs/github/GithubUtils.ts new file mode 100644 index 0000000..9349821 --- /dev/null +++ b/libs/github/GithubUtils.ts @@ -0,0 +1,87 @@ +import { graphql as createGraphql } from "@octokit/graphql"; +import { Octokit } from "@octokit/rest"; +import { paginateRest } from "@octokit/plugin-paginate-rest"; +import type { PaginateInterface } from "@octokit/plugin-paginate-rest"; +import { throttling } from "@octokit/plugin-throttling"; + +type GraphqlQuery = ( + query: string, + variables?: Record, +) => Promise; + +type InternalOctokit = InstanceType; + +const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); + +class GithubUtils { + static internalOctokit: InternalOctokit | undefined; + + static graphqlClient: GraphqlQuery | undefined; + + static initOctokitWithToken(token: string): void { + this.internalOctokit = new OctokitWithPlugins({ + auth: token, + throttle: { + retryAfterBaseValue: 2000, + onRateLimit: (retryAfter, options) => { + console.warn( + `Request quota exhausted for request ${options.method} ${options.url}`, + ); + + if (options.request.retryCount <= 5) { + console.log(`Retrying after ${retryAfter} seconds!`); + return true; + } + + return false; + }, + onSecondaryRateLimit: (retryAfter, options) => { + console.warn( + `Abuse detected for request ${options.method} ${options.url}`, + ); + }, + }, + }); + + this.graphqlClient = createGraphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }) as GraphqlQuery; + } + + static initOctokit(): void { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN or GH_TOKEN is required"); + } + + this.initOctokitWithToken(token); + } + + static get octokit(): InternalOctokit["rest"] { + if (!this.internalOctokit) { + this.initOctokit(); + } + + return (this.internalOctokit as InternalOctokit).rest; + } + + static get graphql(): GraphqlQuery { + if (!this.graphqlClient) { + this.initOctokit(); + } + + return this.graphqlClient as GraphqlQuery; + } + + static get paginate(): PaginateInterface { + if (!this.internalOctokit) { + this.initOctokit(); + } + + return (this.internalOctokit as InternalOctokit).paginate; + } +} + +export default GithubUtils; diff --git a/package-lock.json b/package-lock.json index 814f29d..add6c47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "name": "@expensify/github-actions", "dependencies": { "@octokit/graphql": "^9.0.3", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.1" }, "devDependencies": { @@ -560,6 +563,22 @@ "@octokit/core": ">=6" } }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, "node_modules/@octokit/request": { "version": "10.0.9", "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", @@ -637,6 +656,12 @@ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "license": "MIT" + }, "node_modules/content-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", diff --git a/package.json b/package.json index 16174c3..263d688 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,11 @@ }, "dependencies": { "@octokit/graphql": "^9.0.3", - "@octokit/rest": "^22.0.1" + "@octokit/rest": "^22.0.1", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@octokit/request-error": "^7.0.0" }, - "packageManager": "npm@11.13.0" + "packageManager": "npm@11.13.0", + "type": "module" } From 9eafdd8030a8d35478e4a050c63cc4a7a8f9f20f Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:08:31 -0700 Subject: [PATCH 20/63] Extract peer review logic into testable modules Co-authored-by: Cursor --- libs/peerReview/coAuthors.ts | 51 ++++ libs/peerReview/eventContext.ts | 18 ++ libs/peerReview/githubApi.ts | 178 ++++++++++++++ libs/peerReview/policy.ts | 12 + libs/peerReview/types.ts | 6 + libs/peerReview/workflowOutput.ts | 57 +++++ scripts/verifyPeerReview.ts | 387 ++++++------------------------ tsconfig.json | 12 + 8 files changed, 410 insertions(+), 311 deletions(-) create mode 100644 libs/peerReview/coAuthors.ts create mode 100644 libs/peerReview/eventContext.ts create mode 100644 libs/peerReview/githubApi.ts create mode 100644 libs/peerReview/policy.ts create mode 100644 libs/peerReview/types.ts create mode 100644 libs/peerReview/workflowOutput.ts create mode 100644 tsconfig.json diff --git a/libs/peerReview/coAuthors.ts b/libs/peerReview/coAuthors.ts new file mode 100644 index 0000000..0d3085f --- /dev/null +++ b/libs/peerReview/coAuthors.ts @@ -0,0 +1,51 @@ +import type { RestEndpointMethodTypes } from "@octokit/rest"; +import { BOT_USERS } from "../github/CONST"; +import { unique } from "./workflowOutput"; + +export type Commit = + RestEndpointMethodTypes["pulls"]["listCommits"]["response"]["data"][number]; + +export function coAuthorEmails(message: string): string[] { + return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => + match[1].trim(), + ); +} + +export function resolveCoAuthorLogin(email: string): string | null { + return ( + email.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null + ); +} + +export function getCommitAuthors(commits: Commit[]): { + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; +} { + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = commit.author?.login ?? ""; + if (canonicalAuthor) { + authors.add(canonicalAuthor); + } + + if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { + continue; + } + + for (const email of coAuthorEmails(commit.commit.message)) { + const login = resolveCoAuthorLogin(email); + if (login) { + authors.add(login); + } else if (email.trim().toLowerCase().endsWith("@expensify.com")) { + unresolvedExpensifyCoAuthors.add(email.trim()); + } + } + } + + return { + authors: unique([...authors]), + unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + }; +} diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts new file mode 100644 index 0000000..e0123e0 --- /dev/null +++ b/libs/peerReview/eventContext.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import type { PullRequestEvent } from "@octokit/webhooks-types"; +import type { PullRequestContext } from "./types"; + +export function getPullRequestContext(): PullRequestContext { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + throw new Error("GITHUB_EVENT_PATH is required"); + } + + const event = JSON.parse(readFileSync(eventPath, "utf8")) as PullRequestEvent; + return { + owner: event.repository.owner.login, + repo: event.repository.name, + number: event.pull_request.number, + baseRef: event.pull_request.base.ref, + }; +} diff --git a/libs/peerReview/githubApi.ts b/libs/peerReview/githubApi.ts new file mode 100644 index 0000000..2e417f0 --- /dev/null +++ b/libs/peerReview/githubApi.ts @@ -0,0 +1,178 @@ +import { EXPENSIFY_EMPLOYEE_TEAM_SLUG, EXPENSIFY_ORG } from "../github/CONST"; +import GithubUtils from "../github/GithubUtils"; +import { DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT } from "./policy"; +import type { PullRequestContext } from "./types"; +import { unique } from "./workflowOutput"; + +type BranchProtectionResponse = { + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; + } | null; +}; + +type LatestOpinionatedReviewsResponse = { + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: Array<{ + state: string; + author: { + login: string; + } | null; + }>; + }; + } | null; + } | null; +}; + +type TeamMembersResponse = { + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; + } | null; +}; + +export async function getRequiredApprovingReviewCount({ + owner, + repo, + baseRef, +}: PullRequestContext): Promise { + try { + const response = await GithubUtils.graphql( + ` + query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branchRef) { + branchProtectionRule { + requiredApprovingReviewCount + } + } + } + } + `, + { + owner, + repo, + branchRef: `refs/heads/${baseRef}`, + }, + ); + + return ( + response.repository?.ref?.branchProtectionRule + ?.requiredApprovingReviewCount ?? 0 + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log( + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + ); + return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; + } +} + +export async function getLatestApprovers({ + owner, + repo, + number, +}: PullRequestContext): Promise { + const response = await GithubUtils.graphql( + ` + query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + latestOpinionatedReviews(last: 100, writersOnly: true) { + nodes { + state + author { + login + } + } + } + } + } + } + `, + { + owner, + repo, + prNumber: number, + }, + ); + + return unique( + response.repository?.pullRequest?.latestOpinionatedReviews.nodes + .filter((review) => review.state === "APPROVED") + .map((review) => review.author?.login ?? "") + .filter((login) => login !== "") ?? [], + ); +} + +export async function getEmployeeLogins(): Promise> { + const employeeLogins = new Set(); + let cursor: string | null = null; + + do { + const response: TeamMembersResponse = + await GithubUtils.graphql( + ` + query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { + organization(login: $organization) { + team(slug: $teamSlug) { + members(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + login + } + } + } + } + } + `, + { + organization: EXPENSIFY_ORG, + teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, + cursor, + }, + ); + + const members = response.organization?.team?.members; + if (!members) { + throw new Error( + `${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, + ); + } + + for (const member of members.nodes) { + employeeLogins.add(member.login); + } + + cursor = members.pageInfo.hasNextPage ? members.pageInfo.endCursor : null; + } while (cursor); + + return employeeLogins; +} + +export async function listPullRequestCommits(context: PullRequestContext) { + return GithubUtils.paginate(GithubUtils.octokit.pulls.listCommits, { + owner: context.owner, + repo: context.repo, + pull_number: context.number, + per_page: 100, + }); +} diff --git a/libs/peerReview/policy.ts b/libs/peerReview/policy.ts new file mode 100644 index 0000000..97c3229 --- /dev/null +++ b/libs/peerReview/policy.ts @@ -0,0 +1,12 @@ +export const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; + +export function getIndependentEmployeeApprovers( + approvers: string[], + authors: string[], + employeeLogins: Set, +): string[] { + const authorSet = new Set(authors); + return approvers.filter( + (approver) => !authorSet.has(approver) && employeeLogins.has(approver), + ); +} diff --git a/libs/peerReview/types.ts b/libs/peerReview/types.ts new file mode 100644 index 0000000..6191aac --- /dev/null +++ b/libs/peerReview/types.ts @@ -0,0 +1,6 @@ +export type PullRequestContext = { + owner: string; + repo: string; + number: number; + baseRef: string; +}; diff --git a/libs/peerReview/workflowOutput.ts b/libs/peerReview/workflowOutput.ts new file mode 100644 index 0000000..2aa6b5b --- /dev/null +++ b/libs/peerReview/workflowOutput.ts @@ -0,0 +1,57 @@ +import { appendFileSync } from "node:fs"; + +export function formatUsers(users: string[]): string { + return users.length > 0 ? users.join(", ") : "(none)"; +} + +export function unique(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +export function escapeWorkflowCommandValue(value: string): string { + return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +export function escapeWorkflowCommandProperty(value: string): string { + return escapeWorkflowCommandValue(value) + .replace(/:/g, "%3A") + .replace(/,/g, "%2C"); +} + +export function getFailureTitle(message: string): string { + if ( + message.includes( + "does not have enough independent Expensify employee approvals", + ) + ) { + return "Missing independent peer review"; + } + if (message.includes("Unable to resolve Expensify co-author emails")) { + return "Unresolved Expensify co-author"; + } + if (message.includes("has no human commit authors or co-authors")) { + return "No human commit author"; + } + return "Peer review verification failed"; +} + +export function writeStepSummary(title: string, message: string): void { + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!stepSummaryPath) { + return; + } + appendFileSync( + stepSummaryPath, + `## ${title}\n\n${message.replace(/\n/g, "\n\n")}\n`, + ); +} + +export function emitFailure(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + const title = getFailureTitle(message); + writeStepSummary(title, message); + console.error( + `::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`, + ); + process.exit(1); +} diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 9c1e4f9..a33f79d 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,312 +1,77 @@ -import {appendFileSync, readFileSync} from 'node:fs'; -import {graphql} from '@octokit/graphql'; -import {Octokit, type RestEndpointMethodTypes} from '@octokit/rest'; -import type {PullRequestEvent} from '@octokit/webhooks-types'; - -type Commit = RestEndpointMethodTypes['pulls']['listCommits']['response']['data'][number]; - -type PullRequestContext = { - owner: string; - repo: string; - number: number; - baseRef: string; -}; - -type BranchProtectionResponse = { - repository: { - ref: { - branchProtectionRule: { - requiredApprovingReviewCount: number; - } | null; - } | null; - } | null; -}; - -type LatestOpinionatedReviewsResponse = { - repository: { - pullRequest: { - latestOpinionatedReviews: { - nodes: Array<{ - state: string; - author: { - login: string; - } | null; - }>; - }; - } | null; - } | null; -}; - -type TeamMembersResponse = { - organization: { - team: { - members: { - pageInfo: { - hasNextPage: boolean; - endCursor: string | null; - }; - nodes: Array<{ - login: string; - }>; - }; - } | null; - } | null; -}; - -const botUsers = new Set(['botify', 'MelvinBot', 'exfy-zapier']); -const defaultRequiredApprovingReviewCount = 1; -const expensifyOrganization = 'Expensify'; -const expensifyEmployeeTeamSlug = 'expensify-expensify'; -const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; -if (!githubToken) { - throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); -} - -const octokit = new Octokit({ - auth: githubToken, -}); - -const githubGraphql = graphql.defaults({ - headers: { - authorization: `token ${githubToken}`, - }, -}); - -function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(', ') : '(none)'; -} - -function unique(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); -} - -function escapeWorkflowCommandValue(value: string): string { - return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); -} - -function escapeWorkflowCommandProperty(value: string): string { - return escapeWorkflowCommandValue(value).replace(/:/g, '%3A').replace(/,/g, '%2C'); -} - -function getFailureTitle(message: string): string { - if (message.includes('does not have enough independent Expensify employee approvals')) { - return 'Missing independent peer review'; - } - if (message.includes('Unable to resolve Expensify co-author emails')) { - return 'Unresolved Expensify co-author'; - } - if (message.includes('has no human commit authors or co-authors')) { - return 'No human commit author'; - } - return 'Peer review verification failed'; -} - -function writeStepSummary(title: string, message: string): void { - const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!stepSummaryPath) { - return; - } - appendFileSync(stepSummaryPath, `## ${title}\n\n${message.replace(/\n/g, '\n\n')}\n`); -} - -function getPullRequestContext(): PullRequestContext { - const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) { - throw new Error('GITHUB_EVENT_PATH is required'); - } - - const event = JSON.parse(readFileSync(eventPath, 'utf8')) as PullRequestEvent; - return { - owner: event.repository.owner.login, - repo: event.repository.name, - number: event.pull_request.number, - baseRef: event.pull_request.base.ref, - }; -} - -async function getRequiredApprovingReviewCount({owner, repo, baseRef}: PullRequestContext): Promise { - try { - const response = await githubGraphql(` - query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $branchRef) { - branchProtectionRule { - requiredApprovingReviewCount - } - } - } - } - `, { - owner, - repo, - branchRef: `refs/heads/${baseRef}`, - }); - return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.log(`${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${defaultRequiredApprovingReviewCount} independent approval(s).`); - return defaultRequiredApprovingReviewCount; - } -} - -async function getLatestApprovers({owner, repo, number}: PullRequestContext): Promise { - const response = await githubGraphql(` - query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - latestOpinionatedReviews(last: 100, writersOnly: true) { - nodes { - state - author { - login - } - } - } - } - } - } - `, { - owner, - repo, - prNumber: number, - }); - - return unique(response.repository?.pullRequest?.latestOpinionatedReviews.nodes - .filter((review) => review.state === 'APPROVED') - .map((review) => review.author?.login ?? '') - .filter((login) => login !== '') ?? []); -} - -function coAuthorEmails(message: string): string[] { - return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); -} - -function resolveCoAuthorLogin(email: string): string | null { - return email.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; -} - -function getCommitAuthors(commits: Commit[]): {authors: string[]; unresolvedExpensifyCoAuthors: string[]} { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); - - for (const commit of commits) { - const canonicalAuthor = commit.author?.login ?? ''; - if (canonicalAuthor) { - authors.add(canonicalAuthor); - } - if (canonicalAuthor && !botUsers.has(canonicalAuthor)) { - continue; - } - - for (const email of coAuthorEmails(commit.commit.message)) { - const login = resolveCoAuthorLogin(email); - if (login) { - authors.add(login); - } else if (email.trim().toLowerCase().endsWith('@expensify.com')) { - unresolvedExpensifyCoAuthors.add(email.trim()); - } - } - } - - return { - authors: unique([...authors]), - unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), - }; -} - -async function getEmployeeLogins(): Promise> { - const employeeLogins = new Set(); - let cursor: string | null = null; - do { - const response: TeamMembersResponse = await githubGraphql(` - query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { - organization(login: $organization) { - team(slug: $teamSlug) { - members(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - login - } - } - } - } - } - `, { - organization: expensifyOrganization, - teamSlug: expensifyEmployeeTeamSlug, - cursor, - }); - const members = response.organization?.team?.members; - if (!members) { - throw new Error(`${expensifyOrganization}/${expensifyEmployeeTeamSlug} team could not be found.`); - } - for (const member of members.nodes) { - employeeLogins.add(member.login); - } - cursor = members.pageInfo.hasNextPage ? members.pageInfo.endCursor : null; - } while (cursor); - return employeeLogins; -} - -function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { - const authorSet = new Set(authors); - return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); -} - -async function main(): Promise { - const context = getPullRequestContext(); - const {owner, repo, number} = context; - const pullRequestParams = { - owner, - repo, - pull_number: number, - per_page: 100, - }; - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - getRequiredApprovingReviewCount(context), - getLatestApprovers(context), - octokit.paginate(octokit.rest.pulls.listCommits, pullRequestParams), - ]); - - if (requiredApprovingReviewCount === 0) { - console.log(`${owner}/${repo}#${number} targets ${context.baseRef}, which does not require approving reviews.`); - return; - } - if (approvers.length === 0) { - console.log(`${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`); - return; - } - - const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); - if (unresolvedExpensifyCoAuthors.length > 0) { - throw new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`); - } - if (authors.every((author) => botUsers.has(author))) { - throw new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`); - } - - const employeeLogins = await getEmployeeLogins(); - const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { - throw new Error([ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, - ].join('\n')); - } - - console.log(`${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`); +import { BOT_USERS } from "../libs/github/CONST"; +import { getCommitAuthors } from "../libs/peerReview/coAuthors"; +import { getPullRequestContext } from "../libs/peerReview/eventContext"; +import { + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, +} from "../libs/peerReview/githubApi"; +import { getIndependentEmployeeApprovers } from "../libs/peerReview/policy"; +import { emitFailure, formatUsers } from "../libs/peerReview/workflowOutput"; + +export async function main(): Promise { + const context = getPullRequestContext(); + const { owner, repo, number, baseRef } = context; + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + getRequiredApprovingReviewCount(context), + getLatestApprovers(context), + listPullRequestCommits(context), + ]); + + if (requiredApprovingReviewCount === 0) { + console.log( + `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + ); + return; + } + + if (approvers.length === 0) { + console.log( + `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + ); + return; + } + + const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors(commits); + + if (unresolvedExpensifyCoAuthors.length > 0) { + throw new Error( + `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, + ); + } + + if (authors.every((author) => BOT_USERS.has(author))) { + throw new Error( + `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, + ); + } + + const employeeLogins = await getEmployeeLogins(); + const independentEmployeeApprovers = getIndependentEmployeeApprovers( + approvers, + authors, + employeeLogins, + ); + + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + throw new Error( + [ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join("\n"), + ); + } + + console.log( + `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + ); +} + +if (import.meta.main) { + main().catch(emitFailure); } - -main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const title = getFailureTitle(message); - writeStepSummary(title, message); - console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); - process.exit(1); -}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a1935f0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["libs/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} From da27d6e75752fb1bff396497f53c60675ab620c4 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:08:35 -0700 Subject: [PATCH 21/63] Normalize co-author email parsing to match Web-Expensify chore Co-authored-by: Cursor --- libs/peerReview/coAuthors.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/libs/peerReview/coAuthors.ts b/libs/peerReview/coAuthors.ts index 0d3085f..92bdf1d 100644 --- a/libs/peerReview/coAuthors.ts +++ b/libs/peerReview/coAuthors.ts @@ -12,11 +12,28 @@ export function coAuthorEmails(message: string): string[] { } export function resolveCoAuthorLogin(email: string): string | null { + const normalizedEmail = email.trim(); return ( - email.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null + normalizedEmail.match( + /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, + )?.[1] ?? null ); } +function isExpensifyEmail(email: string): boolean { + return email.trim().toLowerCase().endsWith("@expensify.com"); +} + +function getCanonicalAuthorLogin(commit: Commit): string { + const authorLogin = commit.author?.login ?? ""; + if (authorLogin) { + return authorLogin; + } + + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + return commit.commit.author?.name?.trim() ?? ""; +} + export function getCommitAuthors(commits: Commit[]): { authors: string[]; unresolvedExpensifyCoAuthors: string[]; @@ -25,11 +42,13 @@ export function getCommitAuthors(commits: Commit[]): { const unresolvedExpensifyCoAuthors = new Set(); for (const commit of commits) { - const canonicalAuthor = commit.author?.login ?? ""; + const canonicalAuthor = getCanonicalAuthorLogin(commit); if (canonicalAuthor) { authors.add(canonicalAuthor); } + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is missing or is a bot. if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { continue; } @@ -38,7 +57,8 @@ export function getCommitAuthors(commits: Commit[]): { const login = resolveCoAuthorLogin(email); if (login) { authors.add(login); - } else if (email.trim().toLowerCase().endsWith("@expensify.com")) { + } else if (isExpensifyEmail(email)) { + // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. unresolvedExpensifyCoAuthors.add(email.trim()); } } From 104bf8ec03bd9afc64b6123ae32e4fa59cb363f5 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:08:52 -0700 Subject: [PATCH 22/63] Extract evaluatePeerReview pass/skip/fail decision function Co-authored-by: Cursor --- libs/peerReview/policy.ts | 75 +++++++++++++++++++++++++++++++++++++ libs/peerReview/types.ts | 17 +++++++++ scripts/verifyPeerReview.ts | 64 ++++++++++--------------------- 3 files changed, 111 insertions(+), 45 deletions(-) diff --git a/libs/peerReview/policy.ts b/libs/peerReview/policy.ts index 97c3229..5d2dd1b 100644 --- a/libs/peerReview/policy.ts +++ b/libs/peerReview/policy.ts @@ -1,3 +1,7 @@ +import { BOT_USERS } from "../github/CONST"; +import { formatUsers } from "./workflowOutput"; +import type { PeerReviewInput, PeerReviewResult } from "./types"; + export const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; export function getIndependentEmployeeApprovers( @@ -10,3 +14,74 @@ export function getIndependentEmployeeApprovers( (approver) => !authorSet.has(approver) && employeeLogins.has(approver), ); } + +export function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { + const { + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + } = input; + + if (requiredApprovingReviewCount === 0) { + return { + status: "skip", + reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + }; + } + + if (approvers.length === 0) { + return { + status: "skip", + reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } + + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: "fail", + error: new Error( + `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, + ), + }; + } + + if (authors.every((author) => BOT_USERS.has(author))) { + return { + status: "fail", + error: new Error( + `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, + ), + }; + } + + const independentEmployeeApprovers = getIndependentEmployeeApprovers( + approvers, + authors, + employeeLogins, + ); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + return { + status: "fail", + error: new Error( + [ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join("\n"), + ), + }; + } + + return { + status: "pass", + reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + }; +} diff --git a/libs/peerReview/types.ts b/libs/peerReview/types.ts index 6191aac..92f5e79 100644 --- a/libs/peerReview/types.ts +++ b/libs/peerReview/types.ts @@ -4,3 +4,20 @@ export type PullRequestContext = { number: number; baseRef: string; }; + +export type PeerReviewInput = { + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; +}; + +export type PeerReviewResult = + | { status: "pass"; reason: string } + | { status: "skip"; reason: string } + | { status: "fail"; error: Error }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index a33f79d..a748d77 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,4 +1,3 @@ -import { BOT_USERS } from "../libs/github/CONST"; import { getCommitAuthors } from "../libs/peerReview/coAuthors"; import { getPullRequestContext } from "../libs/peerReview/eventContext"; import { @@ -7,8 +6,8 @@ import { getRequiredApprovingReviewCount, listPullRequestCommits, } from "../libs/peerReview/githubApi"; -import { getIndependentEmployeeApprovers } from "../libs/peerReview/policy"; -import { emitFailure, formatUsers } from "../libs/peerReview/workflowOutput"; +import { evaluatePeerReview } from "../libs/peerReview/policy"; +import { emitFailure } from "../libs/peerReview/workflowOutput"; export async function main(): Promise { const context = getPullRequestContext(); @@ -20,56 +19,31 @@ export async function main(): Promise { listPullRequestCommits(context), ]); - if (requiredApprovingReviewCount === 0) { - console.log( - `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, - ); - return; - } - - if (approvers.length === 0) { - console.log( - `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - ); - return; - } - const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors(commits); - if (unresolvedExpensifyCoAuthors.length > 0) { - throw new Error( - `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, - ); - } - - if (authors.every((author) => BOT_USERS.has(author))) { - throw new Error( - `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, - ); - } - - const employeeLogins = await getEmployeeLogins(); - const independentEmployeeApprovers = getIndependentEmployeeApprovers( + const employeeLogins = + approvers.length > 0 && requiredApprovingReviewCount > 0 + ? await getEmployeeLogins() + : new Set(); + + const result = evaluatePeerReview({ + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, approvers, authors, + unresolvedExpensifyCoAuthors, employeeLogins, - ); + }); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { - throw new Error( - [ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, - ].join("\n"), - ); + if (result.status === "skip" || result.status === "pass") { + console.log(result.reason); + return; } - console.log( - `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, - ); + throw result.error; } if (import.meta.main) { From c690b0cb8979fb5dd44a2a0ab341efdd33b9b6a6 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:08:52 -0700 Subject: [PATCH 23/63] Fail peer review check when branch protection cannot be read Co-authored-by: Cursor --- libs/peerReview/eventContext.ts | 11 ++++-- libs/peerReview/githubApi.ts | 56 ++++++++++++++++++++++++++----- libs/peerReview/workflowOutput.ts | 3 ++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts index e0123e0..c09a057 100644 --- a/libs/peerReview/eventContext.ts +++ b/libs/peerReview/eventContext.ts @@ -1,14 +1,21 @@ import { readFileSync } from "node:fs"; -import type { PullRequestEvent } from "@octokit/webhooks-types"; +import type { + PullRequestEvent, + PullRequestReviewEvent, +} from "@octokit/webhooks-types"; import type { PullRequestContext } from "./types"; +type PullRequestWebhookEvent = PullRequestEvent | PullRequestReviewEvent; + export function getPullRequestContext(): PullRequestContext { const eventPath = process.env.GITHUB_EVENT_PATH; if (!eventPath) { throw new Error("GITHUB_EVENT_PATH is required"); } - const event = JSON.parse(readFileSync(eventPath, "utf8")) as PullRequestEvent; + const event = JSON.parse( + readFileSync(eventPath, "utf8"), + ) as PullRequestWebhookEvent; return { owner: event.repository.owner.login, repo: event.repository.name, diff --git a/libs/peerReview/githubApi.ts b/libs/peerReview/githubApi.ts index 2e417f0..e020b3e 100644 --- a/libs/peerReview/githubApi.ts +++ b/libs/peerReview/githubApi.ts @@ -1,3 +1,4 @@ +import { RequestError } from "@octokit/request-error"; import { EXPENSIFY_EMPLOYEE_TEAM_SLUG, EXPENSIFY_ORG } from "../github/CONST"; import GithubUtils from "../github/GithubUtils"; import { DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT } from "./policy"; @@ -14,16 +15,18 @@ type BranchProtectionResponse = { } | null; }; +type OpinionatedReviewNode = { + state: string; + author: { + login: string; + } | null; +}; + type LatestOpinionatedReviewsResponse = { repository: { pullRequest: { latestOpinionatedReviews: { - nodes: Array<{ - state: string; - author: { - login: string; - } | null; - }>; + nodes: OpinionatedReviewNode[]; }; } | null; } | null; @@ -45,6 +48,35 @@ type TeamMembersResponse = { } | null; }; +type GraphQLErrorResponse = { + errors?: Array<{ + type?: string; + message?: string; + }>; +}; + +function isPermissionError(error: unknown): boolean { + if (error instanceof RequestError) { + return error.status === 401 || error.status === 403; + } + + if (typeof error === "object" && error !== null && "errors" in error) { + const { errors } = error as GraphQLErrorResponse; + return ( + errors?.some( + (graphQLError) => + graphQLError.type === "FORBIDDEN" || + graphQLError.type === "INSUFFICIENT_SCOPES", + ) ?? false + ); + } + + const message = error instanceof Error ? error.message : String(error); + return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test( + message, + ); +} + export async function getRequiredApprovingReviewCount({ owner, repo, @@ -75,6 +107,12 @@ export async function getRequiredApprovingReviewCount({ ?.requiredApprovingReviewCount ?? 0 ); } catch (error: unknown) { + if (isPermissionError(error)) { + throw new Error( + `Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`, + ); + } + const message = error instanceof Error ? error.message : String(error); console.log( `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, @@ -112,11 +150,13 @@ export async function getLatestApprovers({ }, ); + const reviews: OpinionatedReviewNode[] = + response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; return unique( - response.repository?.pullRequest?.latestOpinionatedReviews.nodes + reviews .filter((review) => review.state === "APPROVED") .map((review) => review.author?.login ?? "") - .filter((login) => login !== "") ?? [], + .filter((login) => login !== ""), ); } diff --git a/libs/peerReview/workflowOutput.ts b/libs/peerReview/workflowOutput.ts index 2aa6b5b..49a4da4 100644 --- a/libs/peerReview/workflowOutput.ts +++ b/libs/peerReview/workflowOutput.ts @@ -32,6 +32,9 @@ export function getFailureTitle(message: string): string { if (message.includes("has no human commit authors or co-authors")) { return "No human commit author"; } + if (message.includes("Unable to read branch protection rules")) { + return "Branch protection lookup failed"; + } return "Peer review verification failed"; } From a886588eaff20ed95ecb5c875ea158d568f33689 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:09:00 -0700 Subject: [PATCH 24/63] Add unit tests for peer review verification --- package.json | 4 +- tests/peerReview/coAuthors.test.ts | 117 +++++++++++++++++++++ tests/peerReview/githubApi.test.ts | 52 ++++++++++ tests/peerReview/policy.test.ts | 129 ++++++++++++++++++++++++ tests/peerReview/workflowOutput.test.ts | 40 ++++++++ 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 tests/peerReview/coAuthors.test.ts create mode 100644 tests/peerReview/githubApi.test.ts create mode 100644 tests/peerReview/policy.test.ts create mode 100644 tests/peerReview/workflowOutput.test.ts diff --git a/package.json b/package.json index 263d688..c9ba667 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "@expensify/github-actions", "private": true, "scripts": { - "verify-peer-review": "tsx scripts/verifyPeerReview.ts" + "verify-peer-review": "tsx scripts/verifyPeerReview.ts", + "test": "tsx --test tests/**/*.test.ts", + "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts" }, "devDependencies": { "@octokit/webhooks-types": "^7.6.1", diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts new file mode 100644 index 0000000..13db4d5 --- /dev/null +++ b/tests/peerReview/coAuthors.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Commit } from "../../libs/peerReview/coAuthors"; +import { + coAuthorEmails, + getCommitAuthors, + resolveCoAuthorLogin, +} from "../../libs/peerReview/coAuthors"; + +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, +): Commit { + return { + author: authorLogin ? { login: authorLogin } : null, + commit: { + message, + author: authorName ? { name: authorName } : {}, + }, + } as Commit; +} + +describe("resolveCoAuthorLogin", () => { + it("parses standard noreply addresses", () => { + assert.equal( + resolveCoAuthorLogin("AndrewGable@users.noreply.github.com"), + "AndrewGable", + ); + }); + + it("parses numeric noreply prefixes", () => { + assert.equal( + resolveCoAuthorLogin("2838819+AndrewGable@users.noreply.github.com"), + "AndrewGable", + ); + }); +}); + +describe("coAuthorEmails", () => { + it("extracts multiple co-author emails", () => { + const message = [ + "Some change", + "", + "Co-authored-by: Andrew Gable ", + "Co-authored-by: Monil Bhavsar ", + ].join("\n"); + + assert.deepEqual(coAuthorEmails(message), [ + "AndrewGable@users.noreply.github.com", + "MonilBhavsar@users.noreply.github.com", + ]); + }); +}); + +describe("getCommitAuthors", () => { + it("counts co-authors for bot-authored commits", () => { + const result = getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); + + it("ignores co-authors when canonical author is human", () => { + const result = getCommitAuthors([ + makeCommit( + "rafecolton", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.authors, ["rafecolton"]); + }); + + it("falls back to commit author name when github login is missing", () => { + const result = getCommitAuthors([ + makeCommit(undefined, "AndrewGable", "Change"), + ]); + + assert.deepEqual(result.authors, ["AndrewGable"]); + }); + + it("normalizes expensify email casing and whitespace for unresolved detection", () => { + const result = getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >", + ), + ]); + + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "Andrew@Expensify.com", + ]); + }); + + it("collects unresolved expensify co-author emails", () => { + const result = getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "andrew@expensify.com", + ]); + }); +}); diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts new file mode 100644 index 0000000..e612659 --- /dev/null +++ b/tests/peerReview/githubApi.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { RequestError } from "@octokit/request-error"; +import GithubUtils from "../../libs/github/GithubUtils"; +import { getRequiredApprovingReviewCount } from "../../libs/peerReview/githubApi"; + +const context = { + owner: "Expensify", + repo: "Auth", + number: 1, + baseRef: "main", +}; + +describe("getRequiredApprovingReviewCount", () => { + afterEach(() => { + GithubUtils.internalOctokit = undefined; + GithubUtils.graphqlClient = undefined; + }); + + it("returns 0 when branch protection rule is missing", async () => { + GithubUtils.graphqlClient = (async () => ({ + repository: { + ref: { + branchProtectionRule: null, + }, + }, + })) as NonNullable; + + const count = await getRequiredApprovingReviewCount({ + ...context, + baseRef: "staging", + }); + assert.equal(count, 0); + }); + + it("throws on permission errors", async () => { + GithubUtils.graphqlClient = (async () => { + throw new RequestError("Resource not accessible by integration", 403, { + request: { + method: "POST", + url: "https://api.github.com/graphql", + headers: {}, + }, + }); + }) as typeof GithubUtils.graphqlClient; + + await assert.rejects( + () => getRequiredApprovingReviewCount(context), + /Unable to read branch protection rules/, + ); + }); +}); diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts new file mode 100644 index 0000000..c12fca9 --- /dev/null +++ b/tests/peerReview/policy.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + evaluatePeerReview, + getIndependentEmployeeApprovers, +} from "../../libs/peerReview/policy"; +import type { PeerReviewInput } from "../../libs/peerReview/types"; + +const baseInput: PeerReviewInput = { + owner: "Expensify", + repo: "Auth", + number: 21136, + baseRef: "main", + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), +}; + +describe("getIndependentEmployeeApprovers", () => { + it("excludes commit authors and non-employees", () => { + const independent = getIndependentEmployeeApprovers( + ["AndrewGable", "MonilBhavsar", "externalCollab"], + ["AndrewGable"], + new Set(["AndrewGable", "MonilBhavsar"]), + ); + + assert.deepEqual(independent, ["MonilBhavsar"]); + }); +}); + +describe("evaluatePeerReview", () => { + it("skips when branch requires no approving reviews", () => { + const result = evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ["AndrewGable"], + authors: ["AndrewGable"], + }); + + assert.equal(result.status, "skip"); + }); + + it("skips when there are no approving reviews yet", () => { + const result = evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "skip"); + }); + + it("fails on self-review from commit co-author", () => { + const result = evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "fail"); + assert.match( + (result as { error: Error }).error.message, + /does not have enough independent Expensify employee approvals/, + ); + }); + + it("passes when an independent employee approves", () => { + const result = evaluatePeerReview({ + ...baseInput, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "pass"); + }); + + it("fails when independent approver count is below required", () => { + const result = evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "fail"); + }); + + it("passes when independent approver count meets required", () => { + const result = evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "rafecolton"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "pass"); + }); + + it("fails when all authors are bots", () => { + const result = evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], + }); + + assert.equal(result.status, "fail"); + assert.match( + (result as { error: Error }).error.message, + /no human commit authors or co-authors/, + ); + }); + + it("fails on unresolved expensify co-author emails", () => { + const result = evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], + unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], + }); + + assert.equal(result.status, "fail"); + assert.match( + (result as { error: Error }).error.message, + /Unable to resolve Expensify co-author emails/, + ); + }); +}); diff --git a/tests/peerReview/workflowOutput.test.ts b/tests/peerReview/workflowOutput.test.ts new file mode 100644 index 0000000..67d967d --- /dev/null +++ b/tests/peerReview/workflowOutput.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + escapeWorkflowCommandProperty, + escapeWorkflowCommandValue, + formatUsers, + getFailureTitle, +} from "../../libs/peerReview/workflowOutput"; + +describe("workflowOutput helpers", () => { + it("formats empty user lists", () => { + assert.equal(formatUsers([]), "(none)"); + }); + + it("escapes workflow command values", () => { + assert.equal(escapeWorkflowCommandValue("a\nb%"), "a%0Ab%25"); + }); + + it("escapes workflow command properties", () => { + assert.equal( + escapeWorkflowCommandProperty("title:one,two"), + "title%3Aone%2Ctwo", + ); + }); + + it("maps failure titles for known messages", () => { + assert.equal( + getFailureTitle( + "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", + ), + "Missing independent peer review", + ); + assert.equal( + getFailureTitle( + "Unable to read branch protection rules for Expensify/Auth@main.", + ), + "Branch protection lookup failed", + ); + }); +}); From 0a8b0f8b03833a1d2c662c6dadabcaeeedf9af41 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:09:06 -0700 Subject: [PATCH 25/63] Use Peer Review Checker bot and fix workflow triggers Co-authored-by: Cursor --- .github/workflows/verifyPeerReview.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 6390bb6..fea8780 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -6,7 +6,10 @@ name: Verify peer review run-name: Verify peer review for ${{ github.repository }}#${{ github.event.pull_request.number }} -on: pull_request +on: + pull_request: + pull_request_review: + types: [submitted, dismissed] permissions: contents: read @@ -22,12 +25,13 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 with: - app-id: '179547' - private-key: ${{ secrets.MELVIN_APP_PRIVATE_KEY }} + app-id: "3877737" + private-key: ${{ secrets.PEER_REVIEW_CHECKER_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | ${{ github.event.repository.name }} GitHub-Actions + permission-administration: read permission-contents: read permission-members: read permission-metadata: read From 11c368d3c7a1dbcc47547c01919562c113797154 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:09:40 -0700 Subject: [PATCH 26/63] Add test and typecheck CI workflows and local dev docs Co-authored-by: Cursor --- .github/workflows/test.yml | 39 +++++++++ .github/workflows/typecheck.yml | 39 +++++++++ README.md | 52 ++++++++++-- package-lock.json | 135 ++++++++++++++++++++++++++++---- package.json | 7 +- 5 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/typecheck.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9ae3292 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test + +on: + pull_request: + types: [opened, synchronize] + paths: + - "**.ts" + - "tsconfig.json" + - "package.json" + - "package-lock.json" + - ".nvmrc" + - ".github/workflows/test.yml" + +jobs: + test: + if: ${{ github.actor != 'OSBotify' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + # 6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + # v6.1.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install npm packages + run: npm ci + + - name: Run unit tests + run: npm test + + - name: Tell people how to run the failing check + if: failure() + run: echo "::error::The test check failed! To run it locally, run \`npm ci && npm test\` from the GitHub-Actions repo root." diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..bc848b4 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,39 @@ +name: Typecheck + +on: + pull_request: + types: [opened, synchronize] + paths: + - "**.ts" + - "tsconfig.json" + - "package.json" + - "package-lock.json" + - ".nvmrc" + - ".github/workflows/typecheck.yml" + +jobs: + typecheck: + if: ${{ github.actor != 'OSBotify' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + # 6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + # v6.1.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install npm packages + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Tell people how to run the failing check + if: failure() + run: echo "::error::The typecheck check failed! To run it locally, run \`npm ci && npm run typecheck\` from the GitHub-Actions repo root." diff --git a/README.md b/README.md index 9f9e75a..62edb7d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Expensify Shared GitHub Actions workflows 🔄 +# Expensify Shared GitHub Actions workflows 🔄 ## What is the repository used for? @@ -18,7 +18,7 @@ jobs: with: # Repository name with owner. For example, Expensify/eslint-config-expensify # Required, String, default: ${{ github.repository }} - repository: '' + repository: "" # True if we should run npm run build for the package # Optional, Boolean, default: false @@ -37,20 +37,58 @@ jobs: secrets: inherit ``` +### `typecheck.yml` + +Runs on pull requests that touch TypeScript or related config in this repository. Typechecking uses the TypeScript 7 beta native compiler (`tsgo`) via `@typescript/native-preview`. + +```bash +cd GitHub-Actions +nvm use +npm ci +npm run typecheck +``` + +### `test.yml` + +Runs unit tests on pull requests that touch TypeScript or related config in this repository. + +```bash +cd GitHub-Actions +nvm use +npm ci +npm test +``` + ### `verifyPeerReview.yml` Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. -This workflow requires a GitHub App token with read access for repository metadata, pull requests, and organization members. It uses the Melvin Bot app ID `179547` and `MELVIN_APP_PRIVATE_KEY` to generate that token. If GitHub does not return a branch-protection review count, the workflow defaults to requiring one independent approval, so the ruleset should target only the intended protected branches. +This workflow requires a GitHub App token with read access for repository metadata, pull requests, organization members, and branch administration. It uses the Peer Review Checker app ID `3877737` and the org secret `PEER_REVIEW_CHECKER_PRIVATE_KEY` to generate that token. + +The workflow runs on `pull_request` and `pull_request_review` (`submitted`, `dismissed`) so the check re-evaluates when reviews change without a new push. Org rulesets may only trigger default `pull_request` activity types; `pull_request_review` support under rulesets depends on GitHub's ruleset event coverage. + +If branch protection cannot be read due to missing permissions, the check fails. If the target branch has no branch-protection review requirement, the check skips. If branch protection cannot be queried due to a transient API error, the script defaults to requiring one independent approval. + +#### Local development + +```bash +cd GitHub-Actions +nvm use +npm ci +npm test +``` + +Smoke-test against a real pull request by pointing `GITHUB_EVENT_PATH` at a `pull_request` or `pull_request_review` webhook payload JSON file and setting `GITHUB_TOKEN` (or `GH_TOKEN`) to a token with the same read permissions as the Peer Review Checker app. ## Rulesets + GitHub [org-level rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-workflows-to-pass-before-merging) can be configured to run a workflow check against pull requests in all repos in the org. This is a very powerful feature, but there are some caveats and best practices to be aware of when enabling a ruleset. - Supported Event Triggers are documented [here](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#supported-event-triggers). However: - - When a workflow runs in response to a ruleset, some configs such as `branches`, `paths`, `paths-ignore`, that would normally be valid in a workflow are ignored. - - The default activity types for each event will be used. This means that something like `pull_request:comment` will not work - the `pull_request` event will always be triggered for the default activity types listed in the documentation. - - If you need to target or exclude specific branches, that can be configured in the ruleset settings. - - If you need to target or exclude specific paths, that must be implemented manually in the workflow itself. + - When a workflow runs in response to a ruleset, some configs such as `branches`, `paths`, `paths-ignore`, that would normally be valid in a workflow are ignored. + - The default activity types for each event will be used. This means that something like `pull_request:comment` will not work - the `pull_request` event will always be triggered for the default activity types listed in the documentation. + - If you need to target or exclude specific branches, that can be configured in the ruleset settings. + - If you need to target or exclude specific paths, that must be implemented manually in the workflow itself. - Due to a GitHub :bug:, PRs that are open when the rule is enabled will get stuck with a pending check that will never get picked up. The easiest way to fix that is to close and reopen the PR. Consider writing a script to close and reopen all open PRs across the org after the check is enabled. - It is less disruptive to [configure the ruleset to `Evaluate` first](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#using-evaluate-mode-for-ruleset-workflows), then `Active` once the kinks are worked out. - For `verifyPeerReview.yml`, start with a ruleset targeting only a test branch, then test the workflow from a GitHub-Actions branch, then from `main`, and only then enable it for the intended repositories and branches. diff --git a/package-lock.json b/package-lock.json index add6c47..78293c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,8 @@ "devDependencies": { "@octokit/webhooks-types": "^7.6.1", "@types/node": "^25.7.0", - "tsx": "^4.21.0", - "typescript": "^6.0.3" + "@typescript/native-preview": "beta", + "tsx": "^4.21.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -650,6 +650,123 @@ "undici-types": "~7.21.0" } }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -797,20 +914,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/undici-types": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", diff --git a/package.json b/package.json index c9ba667..e205238 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "scripts": { "verify-peer-review": "tsx scripts/verifyPeerReview.ts", "test": "tsx --test tests/**/*.test.ts", - "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts" + "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", + "typecheck": "tsgo --noEmit -p tsconfig.json" }, "devDependencies": { "@octokit/webhooks-types": "^7.6.1", "@types/node": "^25.7.0", - "tsx": "^4.21.0", - "typescript": "^6.0.3" + "@typescript/native-preview": "beta", + "tsx": "^4.21.0" }, "dependencies": { "@octokit/graphql": "^9.0.3", From 82015d85aed36909eadcc9d18b7f9fbcce786287 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:13:50 -0700 Subject: [PATCH 27/63] Remove OSBotify guard from test and typecheck jobs OSBotify does not open PRs in GitHub-Actions, so the actor check is unnecessary. Co-authored-by: Cursor --- .github/workflows/test.yml | 1 - .github/workflows/typecheck.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ae3292..bd23f02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,6 @@ on: jobs: test: - if: ${{ github.actor != 'OSBotify' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index bc848b4..01faa0d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -13,7 +13,6 @@ on: jobs: typecheck: - if: ${{ github.actor != 'OSBotify' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout From 0ba1286cd30dd3c141b3e1af1dd03d177b7af933 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:20:44 -0700 Subject: [PATCH 28/63] Rename GithubUtils to GitHubAPI and standardize module exports Use default-export objects with PascalCase import names so callers access utilities through the module namespace (e.g. EventContext.getPullRequestContext). Co-authored-by: Cursor --- libs/github/CONST.ts | 12 ++++-- libs/github/{GithubUtils.ts => GitHubAPI.ts} | 16 ++++---- libs/peerReview/coAuthors.ts | 28 +++++++++---- libs/peerReview/eventContext.ts | 6 ++- libs/peerReview/githubApi.ts | 43 ++++++++++++-------- libs/peerReview/policy.ts | 26 +++++++----- libs/peerReview/types.ts | 8 ++-- libs/peerReview/workflowOutput.ts | 24 +++++++---- scripts/verifyPeerReview.ts | 40 +++++++++--------- tests/peerReview/coAuthors.test.ts | 25 +++++------- tests/peerReview/githubApi.test.ts | 20 ++++----- tests/peerReview/policy.test.ts | 23 +++++------ tests/peerReview/workflowOutput.test.ts | 20 ++++----- 13 files changed, 164 insertions(+), 127 deletions(-) rename libs/github/{GithubUtils.ts => GitHubAPI.ts} (89%) diff --git a/libs/github/CONST.ts b/libs/github/CONST.ts index d473210..7d6bf46 100644 --- a/libs/github/CONST.ts +++ b/libs/github/CONST.ts @@ -1,5 +1,11 @@ -export const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); +const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); -export const EXPENSIFY_ORG = "Expensify"; +const EXPENSIFY_ORG = "Expensify"; -export const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; +const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; + +export default { + BOT_USERS, + EXPENSIFY_ORG, + EXPENSIFY_EMPLOYEE_TEAM_SLUG, +}; diff --git a/libs/github/GithubUtils.ts b/libs/github/GitHubAPI.ts similarity index 89% rename from libs/github/GithubUtils.ts rename to libs/github/GitHubAPI.ts index 9349821..00e3041 100644 --- a/libs/github/GithubUtils.ts +++ b/libs/github/GitHubAPI.ts @@ -13,12 +13,12 @@ type InternalOctokit = InstanceType; const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); -class GithubUtils { +class GitHubAPI { static internalOctokit: InternalOctokit | undefined; static graphqlClient: GraphqlQuery | undefined; - static initOctokitWithToken(token: string): void { + static initWithToken(token: string): void { this.internalOctokit = new OctokitWithPlugins({ auth: token, throttle: { @@ -50,18 +50,18 @@ class GithubUtils { }) as GraphqlQuery; } - static initOctokit(): void { + static init(): void { const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!token) { throw new Error("GITHUB_TOKEN or GH_TOKEN is required"); } - this.initOctokitWithToken(token); + this.initWithToken(token); } static get octokit(): InternalOctokit["rest"] { if (!this.internalOctokit) { - this.initOctokit(); + this.init(); } return (this.internalOctokit as InternalOctokit).rest; @@ -69,7 +69,7 @@ class GithubUtils { static get graphql(): GraphqlQuery { if (!this.graphqlClient) { - this.initOctokit(); + this.init(); } return this.graphqlClient as GraphqlQuery; @@ -77,11 +77,11 @@ class GithubUtils { static get paginate(): PaginateInterface { if (!this.internalOctokit) { - this.initOctokit(); + this.init(); } return (this.internalOctokit as InternalOctokit).paginate; } } -export default GithubUtils; +export default GitHubAPI; diff --git a/libs/peerReview/coAuthors.ts b/libs/peerReview/coAuthors.ts index 92bdf1d..2756bf1 100644 --- a/libs/peerReview/coAuthors.ts +++ b/libs/peerReview/coAuthors.ts @@ -1,17 +1,17 @@ import type { RestEndpointMethodTypes } from "@octokit/rest"; -import { BOT_USERS } from "../github/CONST"; -import { unique } from "./workflowOutput"; +import CONST from "../github/CONST"; +import WorkflowOutput from "./workflowOutput"; -export type Commit = +type Commit = RestEndpointMethodTypes["pulls"]["listCommits"]["response"]["data"][number]; -export function coAuthorEmails(message: string): string[] { +function coAuthorEmails(message: string): string[] { return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim(), ); } -export function resolveCoAuthorLogin(email: string): string | null { +function resolveCoAuthorLogin(email: string): string | null { const normalizedEmail = email.trim(); return ( normalizedEmail.match( @@ -34,7 +34,7 @@ function getCanonicalAuthorLogin(commit: Commit): string { return commit.commit.author?.name?.trim() ?? ""; } -export function getCommitAuthors(commits: Commit[]): { +function getCommitAuthors(commits: Commit[]): { authors: string[]; unresolvedExpensifyCoAuthors: string[]; } { @@ -49,7 +49,7 @@ export function getCommitAuthors(commits: Commit[]): { // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { + if (canonicalAuthor && !CONST.BOT_USERS.has(canonicalAuthor)) { continue; } @@ -65,7 +65,17 @@ export function getCommitAuthors(commits: Commit[]): { } return { - authors: unique([...authors]), - unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + authors: WorkflowOutput.unique([...authors]), + unresolvedExpensifyCoAuthors: WorkflowOutput.unique([ + ...unresolvedExpensifyCoAuthors, + ]), }; } + +export type { Commit }; + +export default { + coAuthorEmails, + getCommitAuthors, + resolveCoAuthorLogin, +}; diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts index c09a057..9e6cd9f 100644 --- a/libs/peerReview/eventContext.ts +++ b/libs/peerReview/eventContext.ts @@ -7,7 +7,7 @@ import type { PullRequestContext } from "./types"; type PullRequestWebhookEvent = PullRequestEvent | PullRequestReviewEvent; -export function getPullRequestContext(): PullRequestContext { +function getPullRequestContext(): PullRequestContext { const eventPath = process.env.GITHUB_EVENT_PATH; if (!eventPath) { throw new Error("GITHUB_EVENT_PATH is required"); @@ -23,3 +23,7 @@ export function getPullRequestContext(): PullRequestContext { baseRef: event.pull_request.base.ref, }; } + +export default { + getPullRequestContext, +}; diff --git a/libs/peerReview/githubApi.ts b/libs/peerReview/githubApi.ts index e020b3e..f1adf24 100644 --- a/libs/peerReview/githubApi.ts +++ b/libs/peerReview/githubApi.ts @@ -1,9 +1,9 @@ import { RequestError } from "@octokit/request-error"; -import { EXPENSIFY_EMPLOYEE_TEAM_SLUG, EXPENSIFY_ORG } from "../github/CONST"; -import GithubUtils from "../github/GithubUtils"; -import { DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT } from "./policy"; +import CONST from "../github/CONST"; +import GitHubAPI from "../github/GitHubAPI"; +import Policy from "./policy"; import type { PullRequestContext } from "./types"; -import { unique } from "./workflowOutput"; +import WorkflowOutput from "./workflowOutput"; type BranchProtectionResponse = { repository: { @@ -77,13 +77,13 @@ function isPermissionError(error: unknown): boolean { ); } -export async function getRequiredApprovingReviewCount({ +async function getRequiredApprovingReviewCount({ owner, repo, baseRef, }: PullRequestContext): Promise { try { - const response = await GithubUtils.graphql( + const response = await GitHubAPI.graphql( ` query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { repository(owner: $owner, name: $repo) { @@ -115,18 +115,18 @@ export async function getRequiredApprovingReviewCount({ const message = error instanceof Error ? error.message : String(error); console.log( - `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, ); - return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; + return Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; } } -export async function getLatestApprovers({ +async function getLatestApprovers({ owner, repo, number, }: PullRequestContext): Promise { - const response = await GithubUtils.graphql( + const response = await GitHubAPI.graphql( ` query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { @@ -152,7 +152,7 @@ export async function getLatestApprovers({ const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return unique( + return WorkflowOutput.unique( reviews .filter((review) => review.state === "APPROVED") .map((review) => review.author?.login ?? "") @@ -160,13 +160,13 @@ export async function getLatestApprovers({ ); } -export async function getEmployeeLogins(): Promise> { +async function getEmployeeLogins(): Promise> { const employeeLogins = new Set(); let cursor: string | null = null; do { const response: TeamMembersResponse = - await GithubUtils.graphql( + await GitHubAPI.graphql( ` query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { organization(login: $organization) { @@ -185,8 +185,8 @@ export async function getEmployeeLogins(): Promise> { } `, { - organization: EXPENSIFY_ORG, - teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, + organization: CONST.EXPENSIFY_ORG, + teamSlug: CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG, cursor, }, ); @@ -194,7 +194,7 @@ export async function getEmployeeLogins(): Promise> { const members = response.organization?.team?.members; if (!members) { throw new Error( - `${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, + `${CONST.EXPENSIFY_ORG}/${CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, ); } @@ -208,11 +208,18 @@ export async function getEmployeeLogins(): Promise> { return employeeLogins; } -export async function listPullRequestCommits(context: PullRequestContext) { - return GithubUtils.paginate(GithubUtils.octokit.pulls.listCommits, { +async function listPullRequestCommits(context: PullRequestContext) { + return GitHubAPI.paginate(GitHubAPI.octokit.pulls.listCommits, { owner: context.owner, repo: context.repo, pull_number: context.number, per_page: 100, }); } + +export default { + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, +}; diff --git a/libs/peerReview/policy.ts b/libs/peerReview/policy.ts index 5d2dd1b..46ef3a2 100644 --- a/libs/peerReview/policy.ts +++ b/libs/peerReview/policy.ts @@ -1,10 +1,10 @@ -import { BOT_USERS } from "../github/CONST"; -import { formatUsers } from "./workflowOutput"; +import CONST from "../github/CONST"; +import WorkflowOutput from "./workflowOutput"; import type { PeerReviewInput, PeerReviewResult } from "./types"; -export const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; +const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; -export function getIndependentEmployeeApprovers( +function getIndependentEmployeeApprovers( approvers: string[], authors: string[], employeeLogins: Set, @@ -15,7 +15,7 @@ export function getIndependentEmployeeApprovers( ); } -export function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { +function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { const { owner, repo, @@ -46,12 +46,12 @@ export function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { return { status: "fail", error: new Error( - `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, + `Unable to resolve Expensify co-author emails to GitHub users: ${WorkflowOutput.formatUsers(unresolvedExpensifyCoAuthors)}`, ), }; } - if (authors.every((author) => BOT_USERS.has(author))) { + if (authors.every((author) => CONST.BOT_USERS.has(author))) { return { status: "fail", error: new Error( @@ -72,9 +72,9 @@ export function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { [ `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + `Commit authors/co-authors: ${WorkflowOutput.formatUsers(authors)}`, + `Approvers: ${WorkflowOutput.formatUsers(approvers)}`, + `Independent employee approvers: ${WorkflowOutput.formatUsers(independentEmployeeApprovers)}`, ].join("\n"), ), }; @@ -85,3 +85,9 @@ export function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, }; } + +export default { + DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT, + evaluatePeerReview, + getIndependentEmployeeApprovers, +}; diff --git a/libs/peerReview/types.ts b/libs/peerReview/types.ts index 92f5e79..9b6597e 100644 --- a/libs/peerReview/types.ts +++ b/libs/peerReview/types.ts @@ -1,11 +1,11 @@ -export type PullRequestContext = { +type PullRequestContext = { owner: string; repo: string; number: number; baseRef: string; }; -export type PeerReviewInput = { +type PeerReviewInput = { owner: string; repo: string; number: number; @@ -17,7 +17,9 @@ export type PeerReviewInput = { employeeLogins: Set; }; -export type PeerReviewResult = +type PeerReviewResult = | { status: "pass"; reason: string } | { status: "skip"; reason: string } | { status: "fail"; error: Error }; + +export type { PeerReviewInput, PeerReviewResult, PullRequestContext }; diff --git a/libs/peerReview/workflowOutput.ts b/libs/peerReview/workflowOutput.ts index 49a4da4..6a843d2 100644 --- a/libs/peerReview/workflowOutput.ts +++ b/libs/peerReview/workflowOutput.ts @@ -1,24 +1,24 @@ import { appendFileSync } from "node:fs"; -export function formatUsers(users: string[]): string { +function formatUsers(users: string[]): string { return users.length > 0 ? users.join(", ") : "(none)"; } -export function unique(values: string[]): string[] { +function unique(values: string[]): string[] { return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } -export function escapeWorkflowCommandValue(value: string): string { +function escapeWorkflowCommandValue(value: string): string { return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); } -export function escapeWorkflowCommandProperty(value: string): string { +function escapeWorkflowCommandProperty(value: string): string { return escapeWorkflowCommandValue(value) .replace(/:/g, "%3A") .replace(/,/g, "%2C"); } -export function getFailureTitle(message: string): string { +function getFailureTitle(message: string): string { if ( message.includes( "does not have enough independent Expensify employee approvals", @@ -38,7 +38,7 @@ export function getFailureTitle(message: string): string { return "Peer review verification failed"; } -export function writeStepSummary(title: string, message: string): void { +function writeStepSummary(title: string, message: string): void { const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; if (!stepSummaryPath) { return; @@ -49,7 +49,7 @@ export function writeStepSummary(title: string, message: string): void { ); } -export function emitFailure(error: unknown): never { +function emitFailure(error: unknown): never { const message = error instanceof Error ? error.message : String(error); const title = getFailureTitle(message); writeStepSummary(title, message); @@ -58,3 +58,13 @@ export function emitFailure(error: unknown): never { ); process.exit(1); } + +export default { + emitFailure, + escapeWorkflowCommandProperty, + escapeWorkflowCommandValue, + formatUsers, + getFailureTitle, + unique, + writeStepSummary, +}; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index a748d77..7a5fdbe 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,32 +1,28 @@ -import { getCommitAuthors } from "../libs/peerReview/coAuthors"; -import { getPullRequestContext } from "../libs/peerReview/eventContext"; -import { - getEmployeeLogins, - getLatestApprovers, - getRequiredApprovingReviewCount, - listPullRequestCommits, -} from "../libs/peerReview/githubApi"; -import { evaluatePeerReview } from "../libs/peerReview/policy"; -import { emitFailure } from "../libs/peerReview/workflowOutput"; - -export async function main(): Promise { - const context = getPullRequestContext(); +import CoAuthors from "../libs/peerReview/coAuthors"; +import EventContext from "../libs/peerReview/eventContext"; +import PeerReviewGitHubApi from "../libs/peerReview/githubApi"; +import Policy from "../libs/peerReview/policy"; +import WorkflowOutput from "../libs/peerReview/workflowOutput"; + +async function main(): Promise { + const context = EventContext.getPullRequestContext(); const { owner, repo, number, baseRef } = context; const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - getRequiredApprovingReviewCount(context), - getLatestApprovers(context), - listPullRequestCommits(context), + PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), + PeerReviewGitHubApi.getLatestApprovers(context), + PeerReviewGitHubApi.listPullRequestCommits(context), ]); - const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors(commits); + const { authors, unresolvedExpensifyCoAuthors } = + CoAuthors.getCommitAuthors(commits); const employeeLogins = approvers.length > 0 && requiredApprovingReviewCount > 0 - ? await getEmployeeLogins() + ? await PeerReviewGitHubApi.getEmployeeLogins() : new Set(); - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ owner, repo, number, @@ -46,6 +42,10 @@ export async function main(): Promise { throw result.error; } +export default { + main, +}; + if (import.meta.main) { - main().catch(emitFailure); + main().catch(WorkflowOutput.emitFailure); } diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts index 13db4d5..2a1b75c 100644 --- a/tests/peerReview/coAuthors.test.ts +++ b/tests/peerReview/coAuthors.test.ts @@ -1,11 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import type { Commit } from "../../libs/peerReview/coAuthors"; -import { - coAuthorEmails, - getCommitAuthors, - resolveCoAuthorLogin, -} from "../../libs/peerReview/coAuthors"; +import CoAuthors, { type Commit } from "../../libs/peerReview/coAuthors"; function makeCommit( authorLogin: string | undefined, @@ -24,14 +19,16 @@ function makeCommit( describe("resolveCoAuthorLogin", () => { it("parses standard noreply addresses", () => { assert.equal( - resolveCoAuthorLogin("AndrewGable@users.noreply.github.com"), + CoAuthors.resolveCoAuthorLogin("AndrewGable@users.noreply.github.com"), "AndrewGable", ); }); it("parses numeric noreply prefixes", () => { assert.equal( - resolveCoAuthorLogin("2838819+AndrewGable@users.noreply.github.com"), + CoAuthors.resolveCoAuthorLogin( + "2838819+AndrewGable@users.noreply.github.com", + ), "AndrewGable", ); }); @@ -46,7 +43,7 @@ describe("coAuthorEmails", () => { "Co-authored-by: Monil Bhavsar ", ].join("\n"); - assert.deepEqual(coAuthorEmails(message), [ + assert.deepEqual(CoAuthors.coAuthorEmails(message), [ "AndrewGable@users.noreply.github.com", "MonilBhavsar@users.noreply.github.com", ]); @@ -55,7 +52,7 @@ describe("coAuthorEmails", () => { describe("getCommitAuthors", () => { it("counts co-authors for bot-authored commits", () => { - const result = getCommitAuthors([ + const result = CoAuthors.getCommitAuthors([ makeCommit( "MelvinBot", undefined, @@ -68,7 +65,7 @@ describe("getCommitAuthors", () => { }); it("ignores co-authors when canonical author is human", () => { - const result = getCommitAuthors([ + const result = CoAuthors.getCommitAuthors([ makeCommit( "rafecolton", undefined, @@ -80,7 +77,7 @@ describe("getCommitAuthors", () => { }); it("falls back to commit author name when github login is missing", () => { - const result = getCommitAuthors([ + const result = CoAuthors.getCommitAuthors([ makeCommit(undefined, "AndrewGable", "Change"), ]); @@ -88,7 +85,7 @@ describe("getCommitAuthors", () => { }); it("normalizes expensify email casing and whitespace for unresolved detection", () => { - const result = getCommitAuthors([ + const result = CoAuthors.getCommitAuthors([ makeCommit( "MelvinBot", undefined, @@ -102,7 +99,7 @@ describe("getCommitAuthors", () => { }); it("collects unresolved expensify co-author emails", () => { - const result = getCommitAuthors([ + const result = CoAuthors.getCommitAuthors([ makeCommit( "MelvinBot", undefined, diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index e612659..c393ac8 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, afterEach } from "node:test"; import { RequestError } from "@octokit/request-error"; -import GithubUtils from "../../libs/github/GithubUtils"; -import { getRequiredApprovingReviewCount } from "../../libs/peerReview/githubApi"; +import GitHubAPI from "../../libs/github/GitHubAPI"; +import PeerReviewGitHubApi from "../../libs/peerReview/githubApi"; const context = { owner: "Expensify", @@ -13,20 +13,20 @@ const context = { describe("getRequiredApprovingReviewCount", () => { afterEach(() => { - GithubUtils.internalOctokit = undefined; - GithubUtils.graphqlClient = undefined; + GitHubAPI.internalOctokit = undefined; + GitHubAPI.graphqlClient = undefined; }); it("returns 0 when branch protection rule is missing", async () => { - GithubUtils.graphqlClient = (async () => ({ + GitHubAPI.graphqlClient = (async () => ({ repository: { ref: { branchProtectionRule: null, }, }, - })) as NonNullable; + })) as NonNullable; - const count = await getRequiredApprovingReviewCount({ + const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ ...context, baseRef: "staging", }); @@ -34,7 +34,7 @@ describe("getRequiredApprovingReviewCount", () => { }); it("throws on permission errors", async () => { - GithubUtils.graphqlClient = (async () => { + GitHubAPI.graphqlClient = (async () => { throw new RequestError("Resource not accessible by integration", 403, { request: { method: "POST", @@ -42,10 +42,10 @@ describe("getRequiredApprovingReviewCount", () => { headers: {}, }, }); - }) as typeof GithubUtils.graphqlClient; + }) as typeof GitHubAPI.graphqlClient; await assert.rejects( - () => getRequiredApprovingReviewCount(context), + () => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), /Unable to read branch protection rules/, ); }); diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts index c12fca9..fd4873f 100644 --- a/tests/peerReview/policy.test.ts +++ b/tests/peerReview/policy.test.ts @@ -1,9 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { - evaluatePeerReview, - getIndependentEmployeeApprovers, -} from "../../libs/peerReview/policy"; +import Policy from "../../libs/peerReview/policy"; import type { PeerReviewInput } from "../../libs/peerReview/types"; const baseInput: PeerReviewInput = { @@ -20,7 +17,7 @@ const baseInput: PeerReviewInput = { describe("getIndependentEmployeeApprovers", () => { it("excludes commit authors and non-employees", () => { - const independent = getIndependentEmployeeApprovers( + const independent = Policy.getIndependentEmployeeApprovers( ["AndrewGable", "MonilBhavsar", "externalCollab"], ["AndrewGable"], new Set(["AndrewGable", "MonilBhavsar"]), @@ -32,7 +29,7 @@ describe("getIndependentEmployeeApprovers", () => { describe("evaluatePeerReview", () => { it("skips when branch requires no approving reviews", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, requiredApprovingReviewCount: 0, approvers: ["AndrewGable"], @@ -43,7 +40,7 @@ describe("evaluatePeerReview", () => { }); it("skips when there are no approving reviews yet", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, approvers: [], authors: ["MelvinBot", "AndrewGable"], @@ -53,7 +50,7 @@ describe("evaluatePeerReview", () => { }); it("fails on self-review from commit co-author", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, approvers: ["AndrewGable"], authors: ["MelvinBot", "AndrewGable"], @@ -67,7 +64,7 @@ describe("evaluatePeerReview", () => { }); it("passes when an independent employee approves", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, approvers: ["MonilBhavsar", "AndrewGable"], authors: ["MelvinBot", "AndrewGable"], @@ -77,7 +74,7 @@ describe("evaluatePeerReview", () => { }); it("fails when independent approver count is below required", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, requiredApprovingReviewCount: 2, approvers: ["MonilBhavsar", "AndrewGable"], @@ -88,7 +85,7 @@ describe("evaluatePeerReview", () => { }); it("passes when independent approver count meets required", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, requiredApprovingReviewCount: 2, approvers: ["MonilBhavsar", "rafecolton"], @@ -99,7 +96,7 @@ describe("evaluatePeerReview", () => { }); it("fails when all authors are bots", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, approvers: ["AndrewGable"], authors: ["MelvinBot"], @@ -113,7 +110,7 @@ describe("evaluatePeerReview", () => { }); it("fails on unresolved expensify co-author emails", () => { - const result = evaluatePeerReview({ + const result = Policy.evaluatePeerReview({ ...baseInput, approvers: ["AndrewGable"], authors: ["MelvinBot"], diff --git a/tests/peerReview/workflowOutput.test.ts b/tests/peerReview/workflowOutput.test.ts index 67d967d..5e00bcd 100644 --- a/tests/peerReview/workflowOutput.test.ts +++ b/tests/peerReview/workflowOutput.test.ts @@ -1,37 +1,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { - escapeWorkflowCommandProperty, - escapeWorkflowCommandValue, - formatUsers, - getFailureTitle, -} from "../../libs/peerReview/workflowOutput"; +import WorkflowOutput from "../../libs/peerReview/workflowOutput"; describe("workflowOutput helpers", () => { it("formats empty user lists", () => { - assert.equal(formatUsers([]), "(none)"); + assert.equal(WorkflowOutput.formatUsers([]), "(none)"); }); it("escapes workflow command values", () => { - assert.equal(escapeWorkflowCommandValue("a\nb%"), "a%0Ab%25"); + assert.equal( + WorkflowOutput.escapeWorkflowCommandValue("a\nb%"), + "a%0Ab%25", + ); }); it("escapes workflow command properties", () => { assert.equal( - escapeWorkflowCommandProperty("title:one,two"), + WorkflowOutput.escapeWorkflowCommandProperty("title:one,two"), "title%3Aone%2Ctwo", ); }); it("maps failure titles for known messages", () => { assert.equal( - getFailureTitle( + WorkflowOutput.getFailureTitle( "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", ), "Missing independent peer review", ); assert.equal( - getFailureTitle( + WorkflowOutput.getFailureTitle( "Unable to read branch protection rules for Expensify/Auth@main.", ), "Branch protection lookup failed", From c61abf0674a2cdac832a581d81137dba894ccadf Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 16:21:23 -0700 Subject: [PATCH 29/63] Rename GitHubAPI class to GitHubAPIClient Distinguishes the Octokit client from peer-review GitHub API helpers. Co-authored-by: Cursor --- .../{GitHubAPI.ts => GitHubAPIClient.ts} | 4 +-- libs/peerReview/githubApi.ts | 25 ++++++++++--------- tests/peerReview/githubApi.test.ts | 14 +++++------ 3 files changed, 22 insertions(+), 21 deletions(-) rename libs/github/{GitHubAPI.ts => GitHubAPIClient.ts} (97%) diff --git a/libs/github/GitHubAPI.ts b/libs/github/GitHubAPIClient.ts similarity index 97% rename from libs/github/GitHubAPI.ts rename to libs/github/GitHubAPIClient.ts index 00e3041..b4a9209 100644 --- a/libs/github/GitHubAPI.ts +++ b/libs/github/GitHubAPIClient.ts @@ -13,7 +13,7 @@ type InternalOctokit = InstanceType; const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); -class GitHubAPI { +class GitHubAPIClient { static internalOctokit: InternalOctokit | undefined; static graphqlClient: GraphqlQuery | undefined; @@ -84,4 +84,4 @@ class GitHubAPI { } } -export default GitHubAPI; +export default GitHubAPIClient; diff --git a/libs/peerReview/githubApi.ts b/libs/peerReview/githubApi.ts index f1adf24..a59dd18 100644 --- a/libs/peerReview/githubApi.ts +++ b/libs/peerReview/githubApi.ts @@ -1,6 +1,6 @@ import { RequestError } from "@octokit/request-error"; import CONST from "../github/CONST"; -import GitHubAPI from "../github/GitHubAPI"; +import GitHubAPIClient from "../github/GitHubAPIClient"; import Policy from "./policy"; import type { PullRequestContext } from "./types"; import WorkflowOutput from "./workflowOutput"; @@ -83,7 +83,7 @@ async function getRequiredApprovingReviewCount({ baseRef, }: PullRequestContext): Promise { try { - const response = await GitHubAPI.graphql( + const response = await GitHubAPIClient.graphql( ` query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { repository(owner: $owner, name: $repo) { @@ -126,8 +126,9 @@ async function getLatestApprovers({ repo, number, }: PullRequestContext): Promise { - const response = await GitHubAPI.graphql( - ` + const response = + await GitHubAPIClient.graphql( + ` query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { @@ -143,12 +144,12 @@ async function getLatestApprovers({ } } `, - { - owner, - repo, - prNumber: number, - }, - ); + { + owner, + repo, + prNumber: number, + }, + ); const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; @@ -166,7 +167,7 @@ async function getEmployeeLogins(): Promise> { do { const response: TeamMembersResponse = - await GitHubAPI.graphql( + await GitHubAPIClient.graphql( ` query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { organization(login: $organization) { @@ -209,7 +210,7 @@ async function getEmployeeLogins(): Promise> { } async function listPullRequestCommits(context: PullRequestContext) { - return GitHubAPI.paginate(GitHubAPI.octokit.pulls.listCommits, { + return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { owner: context.owner, repo: context.repo, pull_number: context.number, diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index c393ac8..571e07e 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it, afterEach } from "node:test"; import { RequestError } from "@octokit/request-error"; -import GitHubAPI from "../../libs/github/GitHubAPI"; +import GitHubAPIClient from "../../libs/github/GitHubAPIClient"; import PeerReviewGitHubApi from "../../libs/peerReview/githubApi"; const context = { @@ -13,18 +13,18 @@ const context = { describe("getRequiredApprovingReviewCount", () => { afterEach(() => { - GitHubAPI.internalOctokit = undefined; - GitHubAPI.graphqlClient = undefined; + GitHubAPIClient.internalOctokit = undefined; + GitHubAPIClient.graphqlClient = undefined; }); it("returns 0 when branch protection rule is missing", async () => { - GitHubAPI.graphqlClient = (async () => ({ + GitHubAPIClient.graphqlClient = (async () => ({ repository: { ref: { branchProtectionRule: null, }, }, - })) as NonNullable; + })) as NonNullable; const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ ...context, @@ -34,7 +34,7 @@ describe("getRequiredApprovingReviewCount", () => { }); it("throws on permission errors", async () => { - GitHubAPI.graphqlClient = (async () => { + GitHubAPIClient.graphqlClient = (async () => { throw new RequestError("Resource not accessible by integration", 403, { request: { method: "POST", @@ -42,7 +42,7 @@ describe("getRequiredApprovingReviewCount", () => { headers: {}, }, }); - }) as typeof GitHubAPI.graphqlClient; + }) as typeof GitHubAPIClient.graphqlClient; await assert.rejects( () => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), From a20f5bdbb13746724c0f39abe688649660c2b65c Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 10 Jun 2026 17:12:46 -0700 Subject: [PATCH 30/63] Switch peer review verification to CLI-based arguments Parse pull request metadata with expensify-common's CLI utility instead of GITHUB_EVENT_PATH, pass args from the workflow, and add a git dependency with postinstall build until expensify-common#918 is published. Co-authored-by: Cursor --- .github/workflows/verifyPeerReview.yml | 7 +- README.md | 13 +- libs/peerReview/eventContext.ts | 50 +++-- package-lock.json | 284 ++++++++++++++++++++++++- package.json | 7 +- scripts/prepareExpensifyCommon.sh | 39 ++++ tests/peerReview/eventContext.test.ts | 46 ++++ tsconfig.json | 3 +- 8 files changed, 417 insertions(+), 32 deletions(-) create mode 100755 scripts/prepareExpensifyCommon.sh create mode 100644 tests/peerReview/eventContext.test.ts diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index fea8780..b76785d 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -54,7 +54,12 @@ jobs: working-directory: GitHub-Actions - name: Verify peer review - run: npm run verify-peer-review + run: >- + npm run verify-peer-review -- + --owner ${{ github.repository_owner }} + --repo ${{ github.event.repository.name }} + --number ${{ github.event.pull_request.number }} + --base-ref ${{ github.event.pull_request.base.ref }} working-directory: GitHub-Actions env: GITHUB_TOKEN: ${{ steps.generateAppToken.outputs.token }} diff --git a/README.md b/README.md index 62edb7d..a0a4c08 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,25 @@ If branch protection cannot be read due to missing permissions, the check fails. #### Local development +This script depends on the shared `CLI` utility from [expensify-common#918](https://github.com/Expensify/expensify-common/pull/918), declared in `package.json` as a git dependency until that PR is published to npm. `postinstall` clones and builds `expensify-common` because git installs do not include the compiled `dist/` output. + ```bash cd GitHub-Actions nvm use npm ci npm test +npm run verify-peer-review -- --help ``` -Smoke-test against a real pull request by pointing `GITHUB_EVENT_PATH` at a `pull_request` or `pull_request_review` webhook payload JSON file and setting `GITHUB_TOKEN` (or `GH_TOKEN`) to a token with the same read permissions as the Peer Review Checker app. +Smoke-test against a real pull request by passing the pull request metadata as CLI arguments and setting `GITHUB_TOKEN` (or `GH_TOKEN`) to a token with the same read permissions as the Peer Review Checker app: + +```bash +GITHUB_TOKEN=... npm run verify-peer-review -- \ + --owner Expensify \ + --repo Auth \ + --number 12345 \ + --base-ref main +``` ## Rulesets diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts index 9e6cd9f..cf570ab 100644 --- a/libs/peerReview/eventContext.ts +++ b/libs/peerReview/eventContext.ts @@ -1,26 +1,42 @@ -import { readFileSync } from "node:fs"; -import type { - PullRequestEvent, - PullRequestReviewEvent, -} from "@octokit/webhooks-types"; +import { createRequire } from "node:module"; import type { PullRequestContext } from "./types"; -type PullRequestWebhookEvent = PullRequestEvent | PullRequestReviewEvent; +const require = createRequire(import.meta.url); +const CLI = require("expensify-common/dist/CLI.js") + .default as typeof import("expensify-common/dist/CLI.js").default; -function getPullRequestContext(): PullRequestContext { - const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) { - throw new Error("GITHUB_EVENT_PATH is required"); +function parsePullRequestNumber(value: string): number { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("Must be a positive integer"); } + return number; +} + +function getPullRequestContext(): PullRequestContext { + const cli = new CLI({ + namedArgs: { + owner: { + description: "Repository owner organization or user login", + }, + repo: { + description: "Repository name", + }, + number: { + description: "Pull request number", + parse: parsePullRequestNumber, + }, + "base-ref": { + description: "Target branch ref for the pull request", + }, + }, + }); - const event = JSON.parse( - readFileSync(eventPath, "utf8"), - ) as PullRequestWebhookEvent; return { - owner: event.repository.owner.login, - repo: event.repository.name, - number: event.pull_request.number, - baseRef: event.pull_request.base.ref, + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs["base-ref"], }; } diff --git a/package-lock.json b/package-lock.json index 78293c7..dd19c9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,15 +5,16 @@ "packages": { "": { "name": "@expensify/github-actions", + "hasInstallScript": true, "dependencies": { "@octokit/graphql": "^9.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-throttling": "^11.0.0", "@octokit/request-error": "^7.0.0", - "@octokit/rest": "^22.0.1" + "@octokit/rest": "^22.0.1", + "expensify-common": "github:Expensify/expensify-common#e1d0216" }, "devDependencies": { - "@octokit/webhooks-types": "^7.6.1", "@types/node": "^25.7.0", "@typescript/native-preview": "beta", "tsx": "^4.21.0" @@ -633,13 +634,6 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@octokit/webhooks-types": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz", - "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", @@ -767,6 +761,20 @@ "win32" ] }, + "node_modules/awesome-phonenumber": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.11.0.tgz", + "integrity": "sha512-25GfikMIo6CBQIqvjoewo4uiu5Ai7WqEC8gxesH3LDwCY43oEdkLaT15a+8adC7uWIJCGh+YQiBY5bjmDpoQcg==", + "license": "MIT", + "workspaces": [ + "webpack", + "cjs-test", + "esm-test" + ], + "engines": { + "node": ">=14" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -779,6 +787,26 @@ "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", + "license": "MIT", + "workspaces": [ + "benchmarks" + ] + }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, "node_modules/content-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", @@ -792,6 +820,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -834,6 +868,27 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/expensify-common": { + "version": "2.0.182", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#e1d0216b37ee2ec45ffadce81be2e2b38770ff06", + "license": "MIT", + "dependencies": { + "awesome-phonenumber": "^5.4.0", + "classnames": "2.5.0", + "clipboard": "2.0.11", + "html-entities": "^2.5.2", + "jquery": "3.6.0", + "localforage": "^1.10.0", + "lodash": "4.18.1", + "prop-types": "15.8.1", + "punycode": "^2.3.1", + "react": "16.12.0", + "react-dom": "16.12.0", + "semver": "^7.6.3", + "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "ua-parser-js": "^1.0.38" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -878,12 +933,155 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "dependencies": { + "delegate": "^3.1.2" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/json-with-bigint": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.18.0" + }, + "peerDependencies": { + "react": "^16.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -894,6 +1092,48 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/scheduler": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", + "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simply-deferred": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "integrity": "sha512-Nn5fsm554ecJunvy1ypMmGb7KksTi8W3ET3lKKnH/V1elXJck0OH6f9E/9Cu19n7DMuGvHOcrLmIAImR9WYAGg==", + "engines": { + "node": "*" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -914,6 +1154,32 @@ "fsevents": "~2.3.3" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", diff --git a/package.json b/package.json index e205238..89b0b28 100644 --- a/package.json +++ b/package.json @@ -2,23 +2,24 @@ "name": "@expensify/github-actions", "private": true, "scripts": { + "postinstall": "./scripts/prepareExpensifyCommon.sh", "verify-peer-review": "tsx scripts/verifyPeerReview.ts", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", "typecheck": "tsgo --noEmit -p tsconfig.json" }, "devDependencies": { - "@octokit/webhooks-types": "^7.6.1", "@types/node": "^25.7.0", "@typescript/native-preview": "beta", "tsx": "^4.21.0" }, "dependencies": { "@octokit/graphql": "^9.0.3", - "@octokit/rest": "^22.0.1", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-throttling": "^11.0.0", - "@octokit/request-error": "^7.0.0" + "@octokit/request-error": "^7.0.0", + "@octokit/rest": "^22.0.1", + "expensify-common": "github:Expensify/expensify-common#e1d0216" }, "packageManager": "npm@11.13.0", "type": "module" diff --git a/scripts/prepareExpensifyCommon.sh b/scripts/prepareExpensifyCommon.sh new file mode 100755 index 0000000..b249676 --- /dev/null +++ b/scripts/prepareExpensifyCommon.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Builds expensify-common when installed from git. npm git dependencies only pack +# the published `files` list (dist/), which is not committed — so we clone and build. +# Remove this script once expensify-common#918 is published to npm. +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly INSTALL_DIR="$REPO_ROOT/node_modules/expensify-common" + +if [[ -f "$INSTALL_DIR/dist/CLI.js" ]]; then + exit 0 +fi + +if [[ ! -d "$INSTALL_DIR" ]]; then + echo "::error::expensify-common is not installed. Run npm ci first." >&2 + exit 1 +fi + +readonly SPEC="$(node -e "console.log(require('$REPO_ROOT/package.json').dependencies['expensify-common'])")" +readonly REF="${SPEC##*#}" +if [[ "$REF" == "$SPEC" ]]; then + echo "::error::expensify-common dependency must pin a git ref (e.g. github:Expensify/expensify-common#e1d0216)." >&2 + exit 1 +fi + +readonly BUILD_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building expensify-common from ref $REF..." +git clone --filter=blob:none --no-checkout "https://github.com/Expensify/expensify-common.git" "$BUILD_DIR" +git -C "$BUILD_DIR" checkout "$REF" +(cd "$BUILD_DIR" && npm ci && npm run build) + +mkdir -p "$INSTALL_DIR/dist" +cp -R "$BUILD_DIR/dist/." "$INSTALL_DIR/dist/" diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts new file mode 100644 index 0000000..c41266d --- /dev/null +++ b/tests/peerReview/eventContext.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import EventContext from "../../libs/peerReview/eventContext"; + +const ORIGINAL_ARGV = process.argv; + +describe("getPullRequestContext", () => { + let originalExit: typeof process.exit; + + beforeEach(() => { + process.argv = ["tsx", "scripts/verifyPeerReview.ts"]; + originalExit = process.exit; + process.exit = ((code?: string | number | null) => { + throw new Error(`exit ${code ?? 0}`); + }) as typeof process.exit; + }); + + afterEach(() => { + process.argv = ORIGINAL_ARGV; + process.exit = originalExit; + }); + + it("parses required pull request CLI arguments", () => { + process.argv.push( + "--owner", + "Expensify", + "--repo", + "Auth", + "--number", + "21136", + "--base-ref", + "main", + ); + + assert.deepEqual(EventContext.getPullRequestContext(), { + owner: "Expensify", + repo: "Auth", + number: 21136, + baseRef: "main", + }); + }); + + it("fails when required arguments are missing", () => { + assert.throws(() => EventContext.getPullRequestContext(), /exit 1/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a1935f0..79d2704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "types": ["node"], "strict": true, "noEmit": true, - "skipLibCheck": true + "skipLibCheck": true, + "esModuleInterop": true }, "include": ["libs/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] } From 6a0d11ff3f9340aee602b5dffb3773e2eac3aafa Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 18 Jun 2026 14:48:35 -0700 Subject: [PATCH 31/63] Use published expensify-common 2.0.184 for CLI parsing Replace the git dependency and postinstall build workaround now that expensify-common#918 is on npm. Co-authored-by: Cursor --- README.md | 2 +- package-lock.json | 8 +++---- package.json | 3 +-- scripts/prepareExpensifyCommon.sh | 39 ------------------------------- 4 files changed, 6 insertions(+), 46 deletions(-) delete mode 100755 scripts/prepareExpensifyCommon.sh diff --git a/README.md b/README.md index a0a4c08..7fef77c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ If branch protection cannot be read due to missing permissions, the check fails. #### Local development -This script depends on the shared `CLI` utility from [expensify-common#918](https://github.com/Expensify/expensify-common/pull/918), declared in `package.json` as a git dependency until that PR is published to npm. `postinstall` clones and builds `expensify-common` because git installs do not include the compiled `dist/` output. +This script depends on the shared `CLI` utility from `expensify-common`. ```bash cd GitHub-Actions diff --git a/package-lock.json b/package-lock.json index dd19c9b..1bf7b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,14 +5,13 @@ "packages": { "": { "name": "@expensify/github-actions", - "hasInstallScript": true, "dependencies": { "@octokit/graphql": "^9.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-throttling": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.1", - "expensify-common": "github:Expensify/expensify-common#e1d0216" + "expensify-common": "2.0.184" }, "devDependencies": { "@types/node": "^25.7.0", @@ -869,8 +868,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.182", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#e1d0216b37ee2ec45ffadce81be2e2b38770ff06", + "version": "2.0.184", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.184.tgz", + "integrity": "sha512-TgoOCXGb7KVIq9ehsR9WajRn0DEcrbHJWLsvP8yUkm2GOKomZd6RxQfHPzC4HVVrWk9opSAutIlerVj0yBED3A==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", diff --git a/package.json b/package.json index 89b0b28..3dfb955 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "@expensify/github-actions", "private": true, "scripts": { - "postinstall": "./scripts/prepareExpensifyCommon.sh", "verify-peer-review": "tsx scripts/verifyPeerReview.ts", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", @@ -19,7 +18,7 @@ "@octokit/plugin-throttling": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.1", - "expensify-common": "github:Expensify/expensify-common#e1d0216" + "expensify-common": "2.0.184" }, "packageManager": "npm@11.13.0", "type": "module" diff --git a/scripts/prepareExpensifyCommon.sh b/scripts/prepareExpensifyCommon.sh deleted file mode 100755 index b249676..0000000 --- a/scripts/prepareExpensifyCommon.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# Builds expensify-common when installed from git. npm git dependencies only pack -# the published `files` list (dist/), which is not committed — so we clone and build. -# Remove this script once expensify-common#918 is published to npm. -set -euo pipefail - -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -readonly INSTALL_DIR="$REPO_ROOT/node_modules/expensify-common" - -if [[ -f "$INSTALL_DIR/dist/CLI.js" ]]; then - exit 0 -fi - -if [[ ! -d "$INSTALL_DIR" ]]; then - echo "::error::expensify-common is not installed. Run npm ci first." >&2 - exit 1 -fi - -readonly SPEC="$(node -e "console.log(require('$REPO_ROOT/package.json').dependencies['expensify-common'])")" -readonly REF="${SPEC##*#}" -if [[ "$REF" == "$SPEC" ]]; then - echo "::error::expensify-common dependency must pin a git ref (e.g. github:Expensify/expensify-common#e1d0216)." >&2 - exit 1 -fi - -readonly BUILD_DIR="$(mktemp -d)" -cleanup() { - rm -rf "$BUILD_DIR" -} -trap cleanup EXIT - -echo "Building expensify-common from ref $REF..." -git clone --filter=blob:none --no-checkout "https://github.com/Expensify/expensify-common.git" "$BUILD_DIR" -git -C "$BUILD_DIR" checkout "$REF" -(cd "$BUILD_DIR" && npm ci && npm run build) - -mkdir -p "$INSTALL_DIR/dist" -cp -R "$BUILD_DIR/dist/." "$INSTALL_DIR/dist/" From 7a72ee97d8ee7842da3f036c4eb46377a69190a2 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 18 Jun 2026 16:45:43 -0700 Subject: [PATCH 32/63] Add ESLint with eslint-config-expensify and lint CI workflow Lint TypeScript in libs, scripts, and tests on pull requests, with targeted rule overrides and small code fixes to satisfy the new checks. Co-authored-by: Cursor --- .github/workflows/lint.yml | 41 + README.md | 11 + eslint.config.mjs | 70 + libs/github/GitHubAPIClient.ts | 34 +- libs/peerReview/eventContext.ts | 1 + libs/peerReview/githubApi.ts | 12 +- libs/peerReview/workflowOutput.ts | 8 +- package-lock.json | 5743 +++++++++++++++++++++++-- package.json | 5 +- tests/peerReview/eventContext.test.ts | 2 +- tests/peerReview/githubApi.test.ts | 2 +- 11 files changed, 5501 insertions(+), 428 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 eslint.config.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a23e92c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,41 @@ +name: Lint + +on: + pull_request: + types: [opened, synchronize] + paths: + - "**.ts" + - "eslint.config.mjs" + - "tsconfig.json" + - "package.json" + - "package-lock.json" + - ".nvmrc" + - ".github/workflows/lint.yml" + +jobs: + lint: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + # 6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + # v6.1.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install npm packages + run: npm ci + + - name: Lint + run: npm run lint + env: + CI: true + + - name: Tell people how to run the failing check + if: failure() + run: echo "::error::The lint check failed! To run it locally, run \`npm ci && npm run lint\` from the GitHub-Actions repo root." diff --git a/README.md b/README.md index 7fef77c..7cfcaa1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,17 @@ npm ci npm run typecheck ``` +### `lint.yml` + +Runs ESLint on pull requests that touch TypeScript or related config in this repository. Linting uses `eslint-config-expensify`. + +```bash +cd GitHub-Actions +nvm use +npm ci +npm run lint +``` + ### `test.yml` Runs unit tests on pull requests that touch TypeScript or related config in this repository. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..214483d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,70 @@ +import jestConfig from "eslint-config-expensify/jest"; +import nodeConfig from "eslint-config-expensify/node"; +import scriptsConfig from "eslint-config-expensify/scripts"; +import tsConfig from "eslint-config-expensify/typescript"; +import { defineConfig, globalIgnores } from "eslint/config"; +import rulesdir from "eslint-plugin-rulesdir"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const expensifyConfigDirectory = path.dirname( + require.resolve("eslint-config-expensify"), +); + +rulesdir.RULES_DIR = path.resolve( + expensifyConfigDirectory, + "eslint-plugin-expensify", +); + +export default defineConfig([ + globalIgnores(["node_modules"]), + { + plugins: { + rulesdir, + }, + }, + ...nodeConfig, + ...tsConfig, + ...scriptsConfig, + ...jestConfig, + { + files: ["**/*.ts"], + languageOptions: { + parserOptions: { + project: path.resolve(projectRoot, "tsconfig.json"), + projectService: false, + }, + }, + rules: { + // node:test is used instead of Jest in this repo. + "jest/no-jest-import": "off", + }, + }, + { + files: ["libs/**/*.ts"], + rules: { + "no-console": ["error", { allow: ["warn", "error"] }], + "no-await-in-loop": "off", + }, + }, + { + files: ["libs/peerReview/eventContext.ts"], + rules: { + "import/extensions": "off", + "@typescript-eslint/no-unsafe-type-assertion": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/consistent-type-imports": "off", + }, + }, + { + files: ["tests/**/*.ts"], + rules: { + "import/no-extraneous-dependencies": "off", + "@typescript-eslint/no-unsafe-type-assertion": "off", + "@typescript-eslint/unbound-method": "off", + }, + }, +]); diff --git a/libs/github/GitHubAPIClient.ts b/libs/github/GitHubAPIClient.ts index b4a9209..87a89d6 100644 --- a/libs/github/GitHubAPIClient.ts +++ b/libs/github/GitHubAPIClient.ts @@ -29,7 +29,7 @@ class GitHubAPIClient { ); if (options.request.retryCount <= 5) { - console.log(`Retrying after ${retryAfter} seconds!`); + console.warn(`Retrying after ${retryAfter} seconds!`); return true; } @@ -47,7 +47,7 @@ class GitHubAPIClient { headers: { authorization: `token ${token}`, }, - }) as GraphqlQuery; + }); } static init(): void { @@ -59,28 +59,40 @@ class GitHubAPIClient { this.initWithToken(token); } - static get octokit(): InternalOctokit["rest"] { + private static ensureOctokit(): InternalOctokit { if (!this.internalOctokit) { this.init(); } - return (this.internalOctokit as InternalOctokit).rest; + if (!this.internalOctokit) { + throw new Error("Failed to initialize GitHub API client"); + } + + return this.internalOctokit; } - static get graphql(): GraphqlQuery { + private static ensureGraphqlClient(): GraphqlQuery { if (!this.graphqlClient) { this.init(); } - return this.graphqlClient as GraphqlQuery; + if (!this.graphqlClient) { + throw new Error("Failed to initialize GitHub GraphQL client"); + } + + return this.graphqlClient; } - static get paginate(): PaginateInterface { - if (!this.internalOctokit) { - this.init(); - } + static get octokit(): InternalOctokit["rest"] { + return this.ensureOctokit().rest; + } - return (this.internalOctokit as InternalOctokit).paginate; + static get graphql(): GraphqlQuery { + return this.ensureGraphqlClient(); + } + + static get paginate(): PaginateInterface { + return this.ensureOctokit().paginate; } } diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts index cf570ab..d78ad53 100644 --- a/libs/peerReview/eventContext.ts +++ b/libs/peerReview/eventContext.ts @@ -26,6 +26,7 @@ function getPullRequestContext(): PullRequestContext { description: "Pull request number", parse: parsePullRequestNumber, }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names "base-ref": { description: "Target branch ref for the pull request", }, diff --git a/libs/peerReview/githubApi.ts b/libs/peerReview/githubApi.ts index a59dd18..4c1d232 100644 --- a/libs/peerReview/githubApi.ts +++ b/libs/peerReview/githubApi.ts @@ -55,13 +55,17 @@ type GraphQLErrorResponse = { }>; }; +function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { + return typeof error === "object" && error !== null && "errors" in error; +} + function isPermissionError(error: unknown): boolean { if (error instanceof RequestError) { return error.status === 401 || error.status === 403; } - if (typeof error === "object" && error !== null && "errors" in error) { - const { errors } = error as GraphQLErrorResponse; + if (isGraphQLErrorResponse(error)) { + const { errors } = error; return ( errors?.some( (graphQLError) => @@ -114,7 +118,7 @@ async function getRequiredApprovingReviewCount({ } const message = error instanceof Error ? error.message : String(error); - console.log( + console.warn( `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, ); return Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; @@ -213,7 +217,9 @@ async function listPullRequestCommits(context: PullRequestContext) { return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { owner: context.owner, repo: context.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters pull_number: context.number, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters per_page: 100, }); } diff --git a/libs/peerReview/workflowOutput.ts b/libs/peerReview/workflowOutput.ts index 6a843d2..0218b3c 100644 --- a/libs/peerReview/workflowOutput.ts +++ b/libs/peerReview/workflowOutput.ts @@ -9,13 +9,13 @@ function unique(values: string[]): string[] { } function escapeWorkflowCommandValue(value: string): string { - return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + return value.replaceAll('%', "%25").replaceAll('\r', "%0D").replaceAll('\n', "%0A"); } function escapeWorkflowCommandProperty(value: string): string { return escapeWorkflowCommandValue(value) - .replace(/:/g, "%3A") - .replace(/,/g, "%2C"); + .replaceAll(':', "%3A") + .replaceAll(',', "%2C"); } function getFailureTitle(message: string): string { @@ -45,7 +45,7 @@ function writeStepSummary(title: string, message: string): void { } appendFileSync( stepSummaryPath, - `## ${title}\n\n${message.replace(/\n/g, "\n\n")}\n`, + `## ${title}\n\n${message.replaceAll('\n', "\n\n")}\n`, ); } diff --git a/package-lock.json b/package-lock.json index 1bf7b98..0f65937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,327 @@ "devDependencies": { "@types/node": "^25.7.0", "@typescript/native-preview": "beta", + "eslint": "^9.39.4", + "eslint-config-expensify": "^4.0.5", "tsx": "^4.21.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.7.tgz", + "integrity": "sha512-zxt+UJTOMKvUt3yOg+D58MLuz334pHp93qifMFcjIIO+9hN6t+ufw2gi7vDPMpxvfnHRR+3VVXvIjineCcgyXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.71.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.71.0.tgz", + "integrity": "sha512-2p9+dXWNQnp5Kq/V0XVWZiVAabzlX6rUW8vXXvtX8Yc1CkKgD93IPDEnv1sYZFkkS6HMvg6H0RMZfob/Co0YXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.46.0", + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~6.6.0" + }, + "engines": { + "node": ">=20.11.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -461,116 +779,410 @@ "node": ">=18" } }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">= 20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" }, "engines": { - "node": ">= 20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", - "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", - "license": "MIT", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">= 20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "license": "MIT", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">= 20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^16.0.0" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">= 20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@octokit/core": ">=6" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@octokit/plugin-request-log": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", - "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@octokit/core": ">=6" + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", - "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@octokit/plugin-throttling": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", - "license": "MIT", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" }, "engines": { "node": ">= 20" @@ -633,6 +1245,34 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", @@ -643,60 +1283,342 @@ "undici-types": "~7.21.0" } }, - "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260421.2", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260421.2.tgz", - "integrity": "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsgo": "bin/tsgo.js" + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, - "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260421.2", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260421.2.tgz", - "integrity": "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260421.2", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260421.2.tgz", - "integrity": "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260421.2", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260421.2.tgz", - "integrity": "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260421.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260421.2.tgz", + "integrity": "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==", + "cpu": [ + "arm" + ], "dev": true, "license": "Apache-2.0", "optional": true, @@ -760,398 +1682,4180 @@ "win32" ] }, - "node_modules/awesome-phonenumber": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.11.0.tgz", - "integrity": "sha512-25GfikMIo6CBQIqvjoewo4uiu5Ai7WqEC8gxesH3LDwCY43oEdkLaT15a+8adC7uWIJCGh+YQiBY5bjmDpoQcg==", + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", - "workspaces": [ - "webpack", - "cjs-test", - "esm-test" - ], + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">=14" + "node": ">=0.4.0" } }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0" - }, - "node_modules/bottleneck": { - "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", - "license": "MIT" - }, - "node_modules/classnames": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", - "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", - "workspaces": [ - "benchmarks" - ] + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } }, - "node_modules/clipboard": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", - "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, "license": "MIT", "dependencies": { - "good-listener": "^1.2.2", - "select": "^1.1.2", - "tiny-emitter": "^2.0.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "node": ">=14" } }, - "node_modules/expensify-common": { - "version": "2.0.184", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.184.tgz", - "integrity": "sha512-TgoOCXGb7KVIq9ehsR9WajRn0DEcrbHJWLsvP8yUkm2GOKomZd6RxQfHPzC4HVVrWk9opSAutIlerVj0yBED3A==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "license": "MIT", "dependencies": { - "awesome-phonenumber": "^5.4.0", - "classnames": "2.5.0", - "clipboard": "2.0.11", - "html-entities": "^2.5.2", - "jquery": "3.6.0", - "localforage": "^1.10.0", - "lodash": "4.18.1", - "prop-types": "15.8.1", - "punycode": "^2.3.1", - "react": "16.12.0", - "react-dom": "16.12.0", - "semver": "^7.6.3", - "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", - "ua-parser-js": "^1.0.38" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/good-listener": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", - "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, "license": "MIT", "dependencies": { - "delegate": "^3.1.2" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, "license": "MIT" }, - "node_modules/jquery": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", - "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/awesome-phonenumber": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.11.0.tgz", + "integrity": "sha512-25GfikMIo6CBQIqvjoewo4uiu5Ai7WqEC8gxesH3LDwCY43oEdkLaT15a+8adC7uWIJCGh+YQiBY5bjmDpoQcg==", + "license": "MIT", + "workspaces": [ + "webpack", + "cjs-test", + "esm-test" + ], + "engines": { + "node": ">=14" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/js-tokens": { + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/before-after-hook": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" }, - "node_modules/json-with-bigint": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", - "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "license": "MIT" }, - "node_modules/lie": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", - "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "dependencies": { - "immediate": "~3.0.5" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/localforage": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", - "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", - "license": "Apache-2.0", + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "lie": "3.1.1" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" + "node_modules/builtin-modules": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.2.0.tgz", + "integrity": "sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/classnames": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", + "license": "MIT", + "workspaces": [ + "benchmarks" + ] + }, + "node_modules/clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/clean-regexp/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz", + "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.5.tgz", + "integrity": "sha512-vFozo1iwu0elMxwZgh/Jkrsi/iXz4q9Js/E4Geee7TaeuaGeywnQLWdy7PAW7s1K+tEF+cEww0D29OeaCT95gQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/eslint-parser": "^7.28.4", + "@typescript-eslint/parser": "^8.44.1", + "@typescript-eslint/utils": "^8.44.1", + "confusing-browser-globals": "^1.0.11", + "eslint": "^9.36.0", + "eslint-plugin-es": "^4.1.0", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jest": "^29.0.1", + "eslint-plugin-jsdoc": "^60.2.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-rulesdir": "^0.2.2", + "eslint-plugin-unicorn": "^61.0.2", + "globals": "^15.14.0", + "lodash": "^4.17.21", + "typescript-eslint": "^8.61.0", + "underscore": "^1.13.6" + } + }, + "node_modules/eslint-config-expensify/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", + "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.15.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.2.tgz", + "integrity": "sha512-kEN4r9RZl1xcsb4arGq89LrcVdOUFII/JSCwtTPJyv16mDwmPrcuEQwpxqZHeINvcsd7oK5O/rhdGlxFRaZwvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <7.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "60.8.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-60.8.3.tgz", + "integrity": "sha512-4191bTMvnd5WUtopCdzNhQchvv/MxtPD86ZGl3vem8Ibm22xJhKuIyClmgSxw+YERtorVc/NhG+bGjfFVa6+VQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.71.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^10.4.0", + "esquery": "^1.6.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^1.0.5", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=20.11.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-rulesdir": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.2.tgz", + "integrity": "sha512-qhBtmrWgehAIQeMDJ+Q+PnOz1DWUZMPeVrI0wE9NZtnpIMFUfh3aPKFYt2saeMSemZRrvUtjWfYwepsC8X+mjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "61.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-61.0.2.tgz", + "integrity": "sha512-zLihukvneYT7f74GNbVJXfWIiNQmkc/a9vYBTE4qPkQZswolWNdu+Wsp9sIXno1JOzdn6OUwLPd19ekXVkahRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "@eslint-community/eslint-utils": "^4.7.0", + "@eslint/plugin-kit": "^0.3.3", + "change-case": "^5.4.4", + "ci-info": "^4.3.0", + "clean-regexp": "^1.0.0", + "core-js-compat": "^3.44.0", + "esquery": "^1.6.0", + "find-up-simple": "^1.0.1", + "globals": "^16.3.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^5.0.0", + "jsesc": "^3.1.0", + "pluralize": "^8.0.0", + "regexp-tree": "^0.1.27", + "regjsparser": "^0.12.0", + "semver": "^7.7.2", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.10.0 || >=21.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=9.29.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expensify-common": { + "version": "2.0.184", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.184.tgz", + "integrity": "sha512-TgoOCXGb7KVIq9ehsR9WajRn0DEcrbHJWLsvP8yUkm2GOKomZd6RxQfHPzC4HVVrWk9opSAutIlerVj0yBED3A==", + "license": "MIT", + "dependencies": { + "awesome-phonenumber": "^5.4.0", + "classnames": "2.5.0", + "clipboard": "2.0.11", + "html-entities": "^2.5.2", + "jquery": "3.6.0", + "localforage": "^1.10.0", + "lodash": "4.18.1", + "prop-types": "15.8.1", + "punycode": "^2.3.1", + "react": "16.12.0", + "react-dom": "16.12.0", + "semver": "^7.6.3", + "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "ua-parser-js": "^1.0.38" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "dependencies": { + "delegate": "^3.1.2" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-builtin-module": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", + "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^5.0.0" + }, + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-6.6.0.tgz", + "integrity": "sha512-3hSD14nXx66Rspx1RMnz1Pj4JacrMBAsC0CrF9lZYO/Qsp5/oIr6KqujVUNhQu94B6mMip2ukki8MpEWZwyhKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-deep-merge": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-1.0.5.tgz", + "integrity": "sha512-3DioFgOzetbxbeUq8pB2NunXo8V0n4EvqsWM/cJoI6IA9zghd7cl/2pBOuWRf4dlvA+fcg5ugFMZaN2/RuoaGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "4.2.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.18.0" + }, + "peerDependencies": { + "react": "^16.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", + "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simply-deferred": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "integrity": "sha512-Nn5fsm554ecJunvy1ypMmGb7KksTi8W3ET3lKKnH/V1elXJck0OH6f9E/9Cu19n7DMuGvHOcrLmIAImR9WYAGg==", + "engines": { + "node": "*" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "minimist": "^1.2.0" }, "bin": { - "loose-envify": "cli.js" + "json5": "lib/cli.js" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", + "node_modules/type-fest": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.2.0.tgz", + "integrity": "sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=6" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", - "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/react-dom": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", - "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.18.0" + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, - "peerDependencies": { - "react": "^16.0.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/scheduler": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", - "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/select": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", - "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simply-deferred": { - "version": "3.0.0", - "resolved": "git+ssh://git@github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", - "integrity": "sha512-Nn5fsm554ecJunvy1ypMmGb7KksTi8W3ET3lKKnH/V1elXJck0OH6f9E/9Cu19n7DMuGvHOcrLmIAImR9WYAGg==", + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": "*" + "node": ">=14.17" } }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "license": "MIT" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/ua-parser-js": { @@ -1180,6 +5884,32 @@ "node": "*" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", @@ -1192,6 +5922,205 @@ "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "license": "ISC" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 3dfb955..4c14a34 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,14 @@ "verify-peer-review": "tsx scripts/verifyPeerReview.ts", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", - "typecheck": "tsgo --noEmit -p tsconfig.json" + "typecheck": "tsgo --noEmit -p tsconfig.json", + "lint": "eslint libs scripts tests" }, "devDependencies": { "@types/node": "^25.7.0", "@typescript/native-preview": "beta", + "eslint": "^9.39.4", + "eslint-config-expensify": "^4.0.5", "tsx": "^4.21.0" }, "dependencies": { diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts index c41266d..67a1f5a 100644 --- a/tests/peerReview/eventContext.test.ts +++ b/tests/peerReview/eventContext.test.ts @@ -12,7 +12,7 @@ describe("getPullRequestContext", () => { originalExit = process.exit; process.exit = ((code?: string | number | null) => { throw new Error(`exit ${code ?? 0}`); - }) as typeof process.exit; + }); }); afterEach(() => { diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index 571e07e..b2aa390 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -42,7 +42,7 @@ describe("getRequiredApprovingReviewCount", () => { headers: {}, }, }); - }) as typeof GitHubAPIClient.graphqlClient; + }); await assert.rejects( () => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), From f817ca1dcb47746a7d7ae919238e097ef70a605b Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:33:46 -0700 Subject: [PATCH 33/63] Upgrade actions/create-github-app-token to 3.2.0 --- .github/workflows/verifyPeerReview.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index b76785d..2e67877 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -20,10 +20,10 @@ jobs: name: Check independent approval runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - # v3.1.1 + # v3.2.0 - name: Generate a GitHub App token id: generateAppToken - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: app-id: "3877737" private-key: ${{ secrets.PEER_REVIEW_CHECKER_PRIVATE_KEY }} From 3e0858863c6a93ae6b370bcd7b19fa8e57be222a Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:35:07 -0700 Subject: [PATCH 34/63] app-id -> client-id --- .github/workflows/verifyPeerReview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 2e67877..2231531 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -25,7 +25,7 @@ jobs: id: generateAppToken uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: - app-id: "3877737" + client-id: "3877737" private-key: ${{ secrets.PEER_REVIEW_CHECKER_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | From f4bdf043464056961bfc137d18a9fca5efe7057c Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:37:22 -0700 Subject: [PATCH 35/63] Alphabetize scripts --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4c14a34..ed3a63d 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,11 @@ "name": "@expensify/github-actions", "private": true, "scripts": { - "verify-peer-review": "tsx scripts/verifyPeerReview.ts", + "lint": "eslint libs scripts tests", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", "typecheck": "tsgo --noEmit -p tsconfig.json", - "lint": "eslint libs scripts tests" + "verify-peer-review": "tsx scripts/verifyPeerReview.ts" }, "devDependencies": { "@types/node": "^25.7.0", From cf5b09d6ad7170b08293cbdb3ee545144bfc91e4 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:47:22 -0700 Subject: [PATCH 36/63] refactor: move libs to scripts/libs Peer review modules are script-only; nesting under scripts/ lets eslint-config-expensify/scripts rules apply without overrides. Co-authored-by: Cursor --- eslint.config.mjs | 9 +--- libs/peerReview/eventContext.ts | 46 ------------------- package.json | 2 +- {libs => scripts/libs}/github/CONST.ts | 0 .../libs}/github/GitHubAPIClient.ts | 0 .../libs}/peerReview/coAuthors.ts | 0 scripts/libs/peerReview/eventContext.ts | 45 ++++++++++++++++++ .../libs}/peerReview/githubApi.ts | 10 ++-- {libs => scripts/libs}/peerReview/policy.ts | 0 {libs => scripts/libs}/peerReview/types.ts | 0 .../libs}/peerReview/workflowOutput.ts | 0 scripts/verifyPeerReview.ts | 10 ++-- tests/peerReview/coAuthors.test.ts | 4 +- tests/peerReview/eventContext.test.ts | 6 +-- tests/peerReview/githubApi.test.ts | 8 ++-- tests/peerReview/policy.test.ts | 4 +- tests/peerReview/workflowOutput.test.ts | 2 +- tsconfig.json | 2 +- 18 files changed, 72 insertions(+), 76 deletions(-) delete mode 100644 libs/peerReview/eventContext.ts rename {libs => scripts/libs}/github/CONST.ts (100%) rename {libs => scripts/libs}/github/GitHubAPIClient.ts (100%) rename {libs => scripts/libs}/peerReview/coAuthors.ts (100%) create mode 100644 scripts/libs/peerReview/eventContext.ts rename {libs => scripts/libs}/peerReview/githubApi.ts (96%) rename {libs => scripts/libs}/peerReview/policy.ts (100%) rename {libs => scripts/libs}/peerReview/types.ts (100%) rename {libs => scripts/libs}/peerReview/workflowOutput.ts (100%) diff --git a/eslint.config.mjs b/eslint.config.mjs index 214483d..f060d72 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,14 +44,7 @@ export default defineConfig([ }, }, { - files: ["libs/**/*.ts"], - rules: { - "no-console": ["error", { allow: ["warn", "error"] }], - "no-await-in-loop": "off", - }, - }, - { - files: ["libs/peerReview/eventContext.ts"], + files: ["scripts/libs/peerReview/eventContext.ts"], rules: { "import/extensions": "off", "@typescript-eslint/no-unsafe-type-assertion": "off", diff --git a/libs/peerReview/eventContext.ts b/libs/peerReview/eventContext.ts deleted file mode 100644 index d78ad53..0000000 --- a/libs/peerReview/eventContext.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createRequire } from "node:module"; -import type { PullRequestContext } from "./types"; - -const require = createRequire(import.meta.url); -const CLI = require("expensify-common/dist/CLI.js") - .default as typeof import("expensify-common/dist/CLI.js").default; - -function parsePullRequestNumber(value: string): number { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error("Must be a positive integer"); - } - return number; -} - -function getPullRequestContext(): PullRequestContext { - const cli = new CLI({ - namedArgs: { - owner: { - description: "Repository owner organization or user login", - }, - repo: { - description: "Repository name", - }, - number: { - description: "Pull request number", - parse: parsePullRequestNumber, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names - "base-ref": { - description: "Target branch ref for the pull request", - }, - }, - }); - - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs.number, - baseRef: cli.namedArgs["base-ref"], - }; -} - -export default { - getPullRequestContext, -}; diff --git a/package.json b/package.json index ed3a63d..d79b42c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@expensify/github-actions", "private": true, "scripts": { - "lint": "eslint libs scripts tests", + "lint": "eslint scripts tests", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", "typecheck": "tsgo --noEmit -p tsconfig.json", diff --git a/libs/github/CONST.ts b/scripts/libs/github/CONST.ts similarity index 100% rename from libs/github/CONST.ts rename to scripts/libs/github/CONST.ts diff --git a/libs/github/GitHubAPIClient.ts b/scripts/libs/github/GitHubAPIClient.ts similarity index 100% rename from libs/github/GitHubAPIClient.ts rename to scripts/libs/github/GitHubAPIClient.ts diff --git a/libs/peerReview/coAuthors.ts b/scripts/libs/peerReview/coAuthors.ts similarity index 100% rename from libs/peerReview/coAuthors.ts rename to scripts/libs/peerReview/coAuthors.ts diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts new file mode 100644 index 0000000..1bacdcc --- /dev/null +++ b/scripts/libs/peerReview/eventContext.ts @@ -0,0 +1,45 @@ +import {createRequire} from 'node:module'; +import type {PullRequestContext} from './types'; + +const require = createRequire(import.meta.url); +const CLI = require('expensify-common/dist/CLI.js').default as typeof import('expensify-common/dist/CLI.js').default; + +function parsePullRequestNumber(value: string): number { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error('Must be a positive integer'); + } + return number; +} + +function getPullRequestContext(): PullRequestContext { + const cli = new CLI({ + namedArgs: { + owner: { + description: 'Repository owner organization or user login', + }, + repo: { + description: 'Repository name', + }, + number: { + description: 'Pull request number', + parse: parsePullRequestNumber, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names + 'base-ref': { + description: 'Target branch ref for the pull request', + }, + }, + }); + + return { + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs['base-ref'], + }; +} + +export default { + getPullRequestContext, +}; diff --git a/libs/peerReview/githubApi.ts b/scripts/libs/peerReview/githubApi.ts similarity index 96% rename from libs/peerReview/githubApi.ts rename to scripts/libs/peerReview/githubApi.ts index 4c1d232..9828e1f 100644 --- a/libs/peerReview/githubApi.ts +++ b/scripts/libs/peerReview/githubApi.ts @@ -167,9 +167,8 @@ async function getLatestApprovers({ async function getEmployeeLogins(): Promise> { const employeeLogins = new Set(); - let cursor: string | null = null; - do { + async function collectPage(cursor: string | null): Promise { const response: TeamMembersResponse = await GitHubAPIClient.graphql( ` @@ -207,9 +206,12 @@ async function getEmployeeLogins(): Promise> { employeeLogins.add(member.login); } - cursor = members.pageInfo.hasNextPage ? members.pageInfo.endCursor : null; - } while (cursor); + if (members.pageInfo.hasNextPage) { + await collectPage(members.pageInfo.endCursor); + } + } + await collectPage(null); return employeeLogins; } diff --git a/libs/peerReview/policy.ts b/scripts/libs/peerReview/policy.ts similarity index 100% rename from libs/peerReview/policy.ts rename to scripts/libs/peerReview/policy.ts diff --git a/libs/peerReview/types.ts b/scripts/libs/peerReview/types.ts similarity index 100% rename from libs/peerReview/types.ts rename to scripts/libs/peerReview/types.ts diff --git a/libs/peerReview/workflowOutput.ts b/scripts/libs/peerReview/workflowOutput.ts similarity index 100% rename from libs/peerReview/workflowOutput.ts rename to scripts/libs/peerReview/workflowOutput.ts diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 7a5fdbe..2d341bb 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,8 +1,8 @@ -import CoAuthors from "../libs/peerReview/coAuthors"; -import EventContext from "../libs/peerReview/eventContext"; -import PeerReviewGitHubApi from "../libs/peerReview/githubApi"; -import Policy from "../libs/peerReview/policy"; -import WorkflowOutput from "../libs/peerReview/workflowOutput"; +import CoAuthors from "./libs/peerReview/coAuthors"; +import EventContext from "./libs/peerReview/eventContext"; +import PeerReviewGitHubApi from "./libs/peerReview/githubApi"; +import Policy from "./libs/peerReview/policy"; +import WorkflowOutput from "./libs/peerReview/workflowOutput"; async function main(): Promise { const context = EventContext.getPullRequestContext(); diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts index 2a1b75c..a8c6781 100644 --- a/tests/peerReview/coAuthors.test.ts +++ b/tests/peerReview/coAuthors.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import CoAuthors, { type Commit } from "../../libs/peerReview/coAuthors"; +import CoAuthors, { + type Commit, +} from "../../scripts/libs/peerReview/coAuthors"; function makeCommit( authorLogin: string | undefined, diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts index 67a1f5a..6fba2a2 100644 --- a/tests/peerReview/eventContext.test.ts +++ b/tests/peerReview/eventContext.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { afterEach, beforeEach, describe, it } from "node:test"; -import EventContext from "../../libs/peerReview/eventContext"; +import EventContext from "../../scripts/libs/peerReview/eventContext"; const ORIGINAL_ARGV = process.argv; @@ -10,9 +10,9 @@ describe("getPullRequestContext", () => { beforeEach(() => { process.argv = ["tsx", "scripts/verifyPeerReview.ts"]; originalExit = process.exit; - process.exit = ((code?: string | number | null) => { + process.exit = (code?: string | number | null) => { throw new Error(`exit ${code ?? 0}`); - }); + }; }); afterEach(() => { diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index b2aa390..3b703f9 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, afterEach } from "node:test"; import { RequestError } from "@octokit/request-error"; -import GitHubAPIClient from "../../libs/github/GitHubAPIClient"; -import PeerReviewGitHubApi from "../../libs/peerReview/githubApi"; +import GitHubAPIClient from "../../scripts/libs/github/GitHubAPIClient"; +import PeerReviewGitHubApi from "../../scripts/libs/peerReview/githubApi"; const context = { owner: "Expensify", @@ -34,7 +34,7 @@ describe("getRequiredApprovingReviewCount", () => { }); it("throws on permission errors", async () => { - GitHubAPIClient.graphqlClient = (async () => { + GitHubAPIClient.graphqlClient = async () => { throw new RequestError("Resource not accessible by integration", 403, { request: { method: "POST", @@ -42,7 +42,7 @@ describe("getRequiredApprovingReviewCount", () => { headers: {}, }, }); - }); + }; await assert.rejects( () => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts index fd4873f..ab5a6f6 100644 --- a/tests/peerReview/policy.test.ts +++ b/tests/peerReview/policy.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import Policy from "../../libs/peerReview/policy"; -import type { PeerReviewInput } from "../../libs/peerReview/types"; +import Policy from "../../scripts/libs/peerReview/policy"; +import type { PeerReviewInput } from "../../scripts/libs/peerReview/types"; const baseInput: PeerReviewInput = { owner: "Expensify", diff --git a/tests/peerReview/workflowOutput.test.ts b/tests/peerReview/workflowOutput.test.ts index 5e00bcd..020155f 100644 --- a/tests/peerReview/workflowOutput.test.ts +++ b/tests/peerReview/workflowOutput.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import WorkflowOutput from "../../libs/peerReview/workflowOutput"; +import WorkflowOutput from "../../scripts/libs/peerReview/workflowOutput"; describe("workflowOutput helpers", () => { it("formats empty user lists", () => { diff --git a/tsconfig.json b/tsconfig.json index 79d2704..4fe0bc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "esModuleInterop": true }, - "include": ["libs/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] + "include": ["scripts/**/*.ts", "tests/**/*.ts"] } From 4236743b3f85ad63cf5bfd20abad7cc23febb750 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:52:15 -0700 Subject: [PATCH 37/63] Add Prettier with CI check Mirror App's Prettier setup so TypeScript sources stay consistently formatted, and add a PR workflow that fails on unformatted diffs. Co-authored-by: Cursor --- .github/workflows/prettier.yml | 41 ++ .prettierignore | 7 + .prettierrc.mjs | 20 + eslint.config.mjs | 99 ++--- package-lock.json | 507 ++++++++++++++++++++++ package.json | 4 + scripts/libs/github/CONST.ts | 12 +- scripts/libs/github/GitHubAPIClient.ts | 145 +++---- scripts/libs/peerReview/coAuthors.ts | 99 ++--- scripts/libs/peerReview/githubApi.ts | 266 +++++------- scripts/libs/peerReview/policy.ts | 128 +++--- scripts/libs/peerReview/types.ts | 33 +- scripts/libs/peerReview/workflowOutput.ts | 81 ++-- scripts/verifyPeerReview.ts | 80 ++-- tests/peerReview/coAuthors.test.ts | 147 +++---- tests/peerReview/eventContext.test.ts | 63 ++- tests/peerReview/githubApi.test.ts | 81 ++-- tests/peerReview/policy.test.ts | 183 ++++---- tests/peerReview/workflowOutput.test.ts | 50 +-- tsconfig.json | 22 +- 20 files changed, 1235 insertions(+), 833 deletions(-) create mode 100644 .github/workflows/prettier.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.mjs diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 0000000..1e6e086 --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,41 @@ +name: Prettier + +on: + pull_request: + types: [opened, synchronize] + paths: + - "**.ts" + - "**.mjs" + - ".prettierrc.mjs" + - ".prettierignore" + - "package.json" + - "package-lock.json" + - ".nvmrc" + - ".github/workflows/prettier.yml" + +jobs: + prettier: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + # 6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + # v6.1.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install npm packages + run: npm ci + + - name: Verify there's no Prettier diff + run: | + npm run prettier -- --log-level error + if ! git diff --name-only --exit-code; then + echo "::error::Prettier diff detected! Run \`npm run prettier\` from the GitHub-Actions repo root and commit the changes." + exit 1 + fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0e7e931 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +package.json +package-lock.json +*.yml +*.yaml +*.md +*.markdown +dist/ diff --git a/.prettierrc.mjs b/.prettierrc.mjs new file mode 100644 index 0000000..83ee480 --- /dev/null +++ b/.prettierrc.mjs @@ -0,0 +1,20 @@ +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); + +/** @type {import('prettier').Config} */ +export default { + tabWidth: 4, + singleQuote: true, + trailingComma: 'all', + bracketSpacing: false, + arrowParens: 'always', + printWidth: 190, + singleAttributePerLine: true, + plugins: [require.resolve('@prettier/plugin-oxc'), require.resolve('@trivago/prettier-plugin-sort-imports')], + importOrderParserPlugins: ['typescript'], + importOrderImportAttributesKeyword: 'with', + importOrder: ['^[./]'], + importOrderSortSpecifiers: true, + importOrderCaseInsensitive: true, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index f060d72..5832570 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,63 +1,58 @@ -import jestConfig from "eslint-config-expensify/jest"; -import nodeConfig from "eslint-config-expensify/node"; -import scriptsConfig from "eslint-config-expensify/scripts"; -import tsConfig from "eslint-config-expensify/typescript"; -import { defineConfig, globalIgnores } from "eslint/config"; -import rulesdir from "eslint-plugin-rulesdir"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import jestConfig from 'eslint-config-expensify/jest'; +import nodeConfig from 'eslint-config-expensify/node'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import tsConfig from 'eslint-config-expensify/typescript'; +import {defineConfig, globalIgnores} from 'eslint/config'; +import rulesdir from 'eslint-plugin-rulesdir'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -const expensifyConfigDirectory = path.dirname( - require.resolve("eslint-config-expensify"), -); +const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); -rulesdir.RULES_DIR = path.resolve( - expensifyConfigDirectory, - "eslint-plugin-expensify", -); +rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); export default defineConfig([ - globalIgnores(["node_modules"]), - { - plugins: { - rulesdir, + globalIgnores(['node_modules']), + { + plugins: { + rulesdir, + }, }, - }, - ...nodeConfig, - ...tsConfig, - ...scriptsConfig, - ...jestConfig, - { - files: ["**/*.ts"], - languageOptions: { - parserOptions: { - project: path.resolve(projectRoot, "tsconfig.json"), - projectService: false, - }, + ...nodeConfig, + ...tsConfig, + ...scriptsConfig, + ...jestConfig, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + project: path.resolve(projectRoot, 'tsconfig.json'), + projectService: false, + }, + }, + rules: { + // node:test is used instead of Jest in this repo. + 'jest/no-jest-import': 'off', + }, }, - rules: { - // node:test is used instead of Jest in this repo. - "jest/no-jest-import": "off", + { + files: ['scripts/libs/peerReview/eventContext.ts'], + rules: { + 'import/extensions': 'off', + '@typescript-eslint/no-unsafe-type-assertion': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + }, }, - }, - { - files: ["scripts/libs/peerReview/eventContext.ts"], - rules: { - "import/extensions": "off", - "@typescript-eslint/no-unsafe-type-assertion": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/consistent-type-imports": "off", + { + files: ['tests/**/*.ts'], + rules: { + 'import/no-extraneous-dependencies': 'off', + '@typescript-eslint/no-unsafe-type-assertion': 'off', + '@typescript-eslint/unbound-method': 'off', + }, }, - }, - { - files: ["tests/**/*.ts"], - rules: { - "import/no-extraneous-dependencies": "off", - "@typescript-eslint/no-unsafe-type-assertion": "off", - "@typescript-eslint/unbound-method": "off", - }, - }, ]); diff --git a/package-lock.json b/package-lock.json index 0f65937..2532dad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,13 @@ "expensify-common": "2.0.184" }, "devDependencies": { + "@prettier/plugin-oxc": "0.1.3", + "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/node": "^25.7.0", "@typescript/native-preview": "beta", "eslint": "^9.39.4", "eslint-config-expensify": "^4.0.5", + "prettier": "3.7.4", "tsx": "^4.21.0" } }, @@ -320,6 +323,43 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.71.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.71.0.tgz", @@ -1039,6 +1079,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -1245,6 +1304,305 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.99.0.tgz", + "integrity": "sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.99.0.tgz", + "integrity": "sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.99.0.tgz", + "integrity": "sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.99.0.tgz", + "integrity": "sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.99.0.tgz", + "integrity": "sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.99.0.tgz", + "integrity": "sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.99.0.tgz", + "integrity": "sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.99.0.tgz", + "integrity": "sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.99.0.tgz", + "integrity": "sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.99.0.tgz", + "integrity": "sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.99.0.tgz", + "integrity": "sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.99.0.tgz", + "integrity": "sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.99.0.tgz", + "integrity": "sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.99.0.tgz", + "integrity": "sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.99.0.tgz", + "integrity": "sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.99.0.tgz", + "integrity": "sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@prettier/plugin-oxc": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@prettier/plugin-oxc/-/plugin-oxc-0.1.3.tgz", + "integrity": "sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "oxc-parser": "0.99.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1252,6 +1610,84 @@ "dev": true, "license": "MIT" }, + "node_modules/@trivago/prettier-plugin-sort-imports": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz", + "integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/generator": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "javascript-natural-sort": "^0.7.1", + "lodash-es": "^4.17.21", + "minimatch": "^9.0.0", + "parse-imports-exports": "^0.2.4" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@vue/compiler-sfc": "3.x", + "prettier": "2.x - 3.x", + "prettier-plugin-ember-template-tag": ">= 2.0.0", + "prettier-plugin-svelte": "3.x", + "svelte": "4.x || 5.x" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + }, + "prettier-plugin-ember-template-tag": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + }, + "svelte": { + "optional": true + } + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4367,6 +4803,13 @@ "node": ">= 0.4" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true, + "license": "MIT" + }, "node_modules/jquery": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", @@ -4565,6 +5008,13 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4848,6 +5298,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.99.0.tgz", + "integrity": "sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.99.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm64": "0.99.0", + "@oxc-parser/binding-darwin-arm64": "0.99.0", + "@oxc-parser/binding-darwin-x64": "0.99.0", + "@oxc-parser/binding-freebsd-x64": "0.99.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.99.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.99.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.99.0", + "@oxc-parser/binding-linux-arm64-musl": "0.99.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.99.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.99.0", + "@oxc-parser/binding-linux-x64-gnu": "0.99.0", + "@oxc-parser/binding-linux-x64-musl": "0.99.0", + "@oxc-parser/binding-wasm32-wasi": "0.99.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.99.0", + "@oxc-parser/binding-win32-x64-msvc": "0.99.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4987,6 +5470,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5695,6 +6194,14 @@ "json5": "lib/cli.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index d79b42c..ae3e462 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,20 @@ "private": true, "scripts": { "lint": "eslint scripts tests", + "prettier": "prettier --experimental-cli --no-cache --write .", "test": "tsx --test tests/**/*.test.ts", "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", "typecheck": "tsgo --noEmit -p tsconfig.json", "verify-peer-review": "tsx scripts/verifyPeerReview.ts" }, "devDependencies": { + "@prettier/plugin-oxc": "0.1.3", + "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/node": "^25.7.0", "@typescript/native-preview": "beta", "eslint": "^9.39.4", "eslint-config-expensify": "^4.0.5", + "prettier": "3.7.4", "tsx": "^4.21.0" }, "dependencies": { diff --git a/scripts/libs/github/CONST.ts b/scripts/libs/github/CONST.ts index 7d6bf46..3009fe7 100644 --- a/scripts/libs/github/CONST.ts +++ b/scripts/libs/github/CONST.ts @@ -1,11 +1,11 @@ -const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); +const BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); -const EXPENSIFY_ORG = "Expensify"; +const EXPENSIFY_ORG = 'Expensify'; -const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; +const EXPENSIFY_EMPLOYEE_TEAM_SLUG = 'expensify-expensify'; export default { - BOT_USERS, - EXPENSIFY_ORG, - EXPENSIFY_EMPLOYEE_TEAM_SLUG, + BOT_USERS, + EXPENSIFY_ORG, + EXPENSIFY_EMPLOYEE_TEAM_SLUG, }; diff --git a/scripts/libs/github/GitHubAPIClient.ts b/scripts/libs/github/GitHubAPIClient.ts index 87a89d6..2e4ea21 100644 --- a/scripts/libs/github/GitHubAPIClient.ts +++ b/scripts/libs/github/GitHubAPIClient.ts @@ -1,99 +1,92 @@ -import { graphql as createGraphql } from "@octokit/graphql"; -import { Octokit } from "@octokit/rest"; -import { paginateRest } from "@octokit/plugin-paginate-rest"; -import type { PaginateInterface } from "@octokit/plugin-paginate-rest"; -import { throttling } from "@octokit/plugin-throttling"; +import {graphql as createGraphql} from '@octokit/graphql'; +import {Octokit} from '@octokit/rest'; +import {paginateRest} from '@octokit/plugin-paginate-rest'; +import type {PaginateInterface} from '@octokit/plugin-paginate-rest'; +import {throttling} from '@octokit/plugin-throttling'; -type GraphqlQuery = ( - query: string, - variables?: Record, -) => Promise; +type GraphqlQuery = (query: string, variables?: Record) => Promise; type InternalOctokit = InstanceType; const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); class GitHubAPIClient { - static internalOctokit: InternalOctokit | undefined; - - static graphqlClient: GraphqlQuery | undefined; - - static initWithToken(token: string): void { - this.internalOctokit = new OctokitWithPlugins({ - auth: token, - throttle: { - retryAfterBaseValue: 2000, - onRateLimit: (retryAfter, options) => { - console.warn( - `Request quota exhausted for request ${options.method} ${options.url}`, - ); - - if (options.request.retryCount <= 5) { - console.warn(`Retrying after ${retryAfter} seconds!`); - return true; - } - - return false; - }, - onSecondaryRateLimit: (retryAfter, options) => { - console.warn( - `Abuse detected for request ${options.method} ${options.url}`, - ); - }, - }, - }); - - this.graphqlClient = createGraphql.defaults({ - headers: { - authorization: `token ${token}`, - }, - }); - } - - static init(): void { - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (!token) { - throw new Error("GITHUB_TOKEN or GH_TOKEN is required"); + static internalOctokit: InternalOctokit | undefined; + + static graphqlClient: GraphqlQuery | undefined; + + static initWithToken(token: string): void { + this.internalOctokit = new OctokitWithPlugins({ + auth: token, + throttle: { + retryAfterBaseValue: 2000, + onRateLimit: (retryAfter, options) => { + console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + if (options.request.retryCount <= 5) { + console.warn(`Retrying after ${retryAfter} seconds!`); + return true; + } + + return false; + }, + onSecondaryRateLimit: (retryAfter, options) => { + console.warn(`Abuse detected for request ${options.method} ${options.url}`); + }, + }, + }); + + this.graphqlClient = createGraphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); } - this.initWithToken(token); - } + static init(): void { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); + } - private static ensureOctokit(): InternalOctokit { - if (!this.internalOctokit) { - this.init(); + this.initWithToken(token); } - if (!this.internalOctokit) { - throw new Error("Failed to initialize GitHub API client"); - } + private static ensureOctokit(): InternalOctokit { + if (!this.internalOctokit) { + this.init(); + } - return this.internalOctokit; - } + if (!this.internalOctokit) { + throw new Error('Failed to initialize GitHub API client'); + } - private static ensureGraphqlClient(): GraphqlQuery { - if (!this.graphqlClient) { - this.init(); + return this.internalOctokit; } - if (!this.graphqlClient) { - throw new Error("Failed to initialize GitHub GraphQL client"); - } + private static ensureGraphqlClient(): GraphqlQuery { + if (!this.graphqlClient) { + this.init(); + } - return this.graphqlClient; - } + if (!this.graphqlClient) { + throw new Error('Failed to initialize GitHub GraphQL client'); + } - static get octokit(): InternalOctokit["rest"] { - return this.ensureOctokit().rest; - } + return this.graphqlClient; + } - static get graphql(): GraphqlQuery { - return this.ensureGraphqlClient(); - } + static get octokit(): InternalOctokit['rest'] { + return this.ensureOctokit().rest; + } - static get paginate(): PaginateInterface { - return this.ensureOctokit().paginate; - } + static get graphql(): GraphqlQuery { + return this.ensureGraphqlClient(); + } + + static get paginate(): PaginateInterface { + return this.ensureOctokit().paginate; + } } export default GitHubAPIClient; diff --git a/scripts/libs/peerReview/coAuthors.ts b/scripts/libs/peerReview/coAuthors.ts index 2756bf1..0b2168b 100644 --- a/scripts/libs/peerReview/coAuthors.ts +++ b/scripts/libs/peerReview/coAuthors.ts @@ -1,81 +1,72 @@ -import type { RestEndpointMethodTypes } from "@octokit/rest"; -import CONST from "../github/CONST"; -import WorkflowOutput from "./workflowOutput"; +import type {RestEndpointMethodTypes} from '@octokit/rest'; +import CONST from '../github/CONST'; +import WorkflowOutput from './workflowOutput'; -type Commit = - RestEndpointMethodTypes["pulls"]["listCommits"]["response"]["data"][number]; +type Commit = RestEndpointMethodTypes['pulls']['listCommits']['response']['data'][number]; function coAuthorEmails(message: string): string[] { - return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => - match[1].trim(), - ); + return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); } function resolveCoAuthorLogin(email: string): string | null { - const normalizedEmail = email.trim(); - return ( - normalizedEmail.match( - /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, - )?.[1] ?? null - ); + const normalizedEmail = email.trim(); + return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; } function isExpensifyEmail(email: string): boolean { - return email.trim().toLowerCase().endsWith("@expensify.com"); + return email.trim().toLowerCase().endsWith('@expensify.com'); } function getCanonicalAuthorLogin(commit: Commit): string { - const authorLogin = commit.author?.login ?? ""; - if (authorLogin) { - return authorLogin; - } + const authorLogin = commit.author?.login ?? ''; + if (authorLogin) { + return authorLogin; + } - // If the author's profile is private, author.login may be missing. Fall back to the commit author name. - return commit.commit.author?.name?.trim() ?? ""; + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + return commit.commit.author?.name?.trim() ?? ''; } function getCommitAuthors(commits: Commit[]): { - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; } { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); - for (const commit of commits) { - const canonicalAuthor = getCanonicalAuthorLogin(commit); - if (canonicalAuthor) { - authors.add(canonicalAuthor); - } + for (const commit of commits) { + const canonicalAuthor = getCanonicalAuthorLogin(commit); + if (canonicalAuthor) { + authors.add(canonicalAuthor); + } - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !CONST.BOT_USERS.has(canonicalAuthor)) { - continue; - } + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is missing or is a bot. + if (canonicalAuthor && !CONST.BOT_USERS.has(canonicalAuthor)) { + continue; + } - for (const email of coAuthorEmails(commit.commit.message)) { - const login = resolveCoAuthorLogin(email); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(email)) { - // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. - unresolvedExpensifyCoAuthors.add(email.trim()); - } + for (const email of coAuthorEmails(commit.commit.message)) { + const login = resolveCoAuthorLogin(email); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(email)) { + // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. + unresolvedExpensifyCoAuthors.add(email.trim()); + } + } } - } - return { - authors: WorkflowOutput.unique([...authors]), - unresolvedExpensifyCoAuthors: WorkflowOutput.unique([ - ...unresolvedExpensifyCoAuthors, - ]), - }; + return { + authors: WorkflowOutput.unique([...authors]), + unresolvedExpensifyCoAuthors: WorkflowOutput.unique([...unresolvedExpensifyCoAuthors]), + }; } -export type { Commit }; +export type {Commit}; export default { - coAuthorEmails, - getCommitAuthors, - resolveCoAuthorLogin, + coAuthorEmails, + getCommitAuthors, + resolveCoAuthorLogin, }; diff --git a/scripts/libs/peerReview/githubApi.ts b/scripts/libs/peerReview/githubApi.ts index 9828e1f..25738f8 100644 --- a/scripts/libs/peerReview/githubApi.ts +++ b/scripts/libs/peerReview/githubApi.ts @@ -1,94 +1,82 @@ -import { RequestError } from "@octokit/request-error"; -import CONST from "../github/CONST"; -import GitHubAPIClient from "../github/GitHubAPIClient"; -import Policy from "./policy"; -import type { PullRequestContext } from "./types"; -import WorkflowOutput from "./workflowOutput"; +import {RequestError} from '@octokit/request-error'; +import CONST from '../github/CONST'; +import GitHubAPIClient from '../github/GitHubAPIClient'; +import Policy from './policy'; +import type {PullRequestContext} from './types'; +import WorkflowOutput from './workflowOutput'; type BranchProtectionResponse = { - repository: { - ref: { - branchProtectionRule: { - requiredApprovingReviewCount: number; - } | null; + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; } | null; - } | null; }; type OpinionatedReviewNode = { - state: string; - author: { - login: string; - } | null; + state: string; + author: { + login: string; + } | null; }; type LatestOpinionatedReviewsResponse = { - repository: { - pullRequest: { - latestOpinionatedReviews: { - nodes: OpinionatedReviewNode[]; - }; + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: OpinionatedReviewNode[]; + }; + } | null; } | null; - } | null; }; type TeamMembersResponse = { - organization: { - team: { - members: { - pageInfo: { - hasNextPage: boolean; - endCursor: string | null; - }; - nodes: Array<{ - login: string; - }>; - }; + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; } | null; - } | null; }; type GraphQLErrorResponse = { - errors?: Array<{ - type?: string; - message?: string; - }>; + errors?: Array<{ + type?: string; + message?: string; + }>; }; function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { - return typeof error === "object" && error !== null && "errors" in error; + return typeof error === 'object' && error !== null && 'errors' in error; } function isPermissionError(error: unknown): boolean { - if (error instanceof RequestError) { - return error.status === 401 || error.status === 403; - } - - if (isGraphQLErrorResponse(error)) { - const { errors } = error; - return ( - errors?.some( - (graphQLError) => - graphQLError.type === "FORBIDDEN" || - graphQLError.type === "INSUFFICIENT_SCOPES", - ) ?? false - ); - } + if (error instanceof RequestError) { + return error.status === 401 || error.status === 403; + } - const message = error instanceof Error ? error.message : String(error); - return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test( - message, - ); + if (isGraphQLErrorResponse(error)) { + const {errors} = error; + return errors?.some((graphQLError) => graphQLError.type === 'FORBIDDEN' || graphQLError.type === 'INSUFFICIENT_SCOPES') ?? false; + } + + const message = error instanceof Error ? error.message : String(error); + return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test(message); } -async function getRequiredApprovingReviewCount({ - owner, - repo, - baseRef, -}: PullRequestContext): Promise { - try { - const response = await GitHubAPIClient.graphql( - ` +async function getRequiredApprovingReviewCount({owner, repo, baseRef}: PullRequestContext): Promise { + try { + const response = await GitHubAPIClient.graphql( + ` query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { repository(owner: $owner, name: $repo) { ref(qualifiedName: $branchRef) { @@ -99,40 +87,30 @@ async function getRequiredApprovingReviewCount({ } } `, - { - owner, - repo, - branchRef: `refs/heads/${baseRef}`, - }, - ); + { + owner, + repo, + branchRef: `refs/heads/${baseRef}`, + }, + ); + + return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; + } catch (error: unknown) { + if (isPermissionError(error)) { + throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`); + } - return ( - response.repository?.ref?.branchProtectionRule - ?.requiredApprovingReviewCount ?? 0 - ); - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new Error( - `Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`, - ); + const message = error instanceof Error ? error.message : String(error); + console.warn( + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + ); + return Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; } - - const message = error instanceof Error ? error.message : String(error); - console.warn( - `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, - ); - return Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; - } } -async function getLatestApprovers({ - owner, - repo, - number, -}: PullRequestContext): Promise { - const response = - await GitHubAPIClient.graphql( - ` +async function getLatestApprovers({owner, repo, number}: PullRequestContext): Promise { + const response = await GitHubAPIClient.graphql( + ` query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { @@ -148,30 +126,28 @@ async function getLatestApprovers({ } } `, - { - owner, - repo, - prNumber: number, - }, + { + owner, + repo, + prNumber: number, + }, ); - const reviews: OpinionatedReviewNode[] = - response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return WorkflowOutput.unique( - reviews - .filter((review) => review.state === "APPROVED") - .map((review) => review.author?.login ?? "") - .filter((login) => login !== ""), - ); + const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; + return WorkflowOutput.unique( + reviews + .filter((review) => review.state === 'APPROVED') + .map((review) => review.author?.login ?? '') + .filter((login) => login !== ''), + ); } async function getEmployeeLogins(): Promise> { - const employeeLogins = new Set(); + const employeeLogins = new Set(); - async function collectPage(cursor: string | null): Promise { - const response: TeamMembersResponse = - await GitHubAPIClient.graphql( - ` + async function collectPage(cursor: string | null): Promise { + const response: TeamMembersResponse = await GitHubAPIClient.graphql( + ` query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { organization(login: $organization) { team(slug: $teamSlug) { @@ -188,47 +164,45 @@ async function getEmployeeLogins(): Promise> { } } `, - { - organization: CONST.EXPENSIFY_ORG, - teamSlug: CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG, - cursor, - }, - ); - - const members = response.organization?.team?.members; - if (!members) { - throw new Error( - `${CONST.EXPENSIFY_ORG}/${CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, - ); - } + { + organization: CONST.EXPENSIFY_ORG, + teamSlug: CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG, + cursor, + }, + ); + + const members = response.organization?.team?.members; + if (!members) { + throw new Error(`${CONST.EXPENSIFY_ORG}/${CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`); + } - for (const member of members.nodes) { - employeeLogins.add(member.login); - } + for (const member of members.nodes) { + employeeLogins.add(member.login); + } - if (members.pageInfo.hasNextPage) { - await collectPage(members.pageInfo.endCursor); + if (members.pageInfo.hasNextPage) { + await collectPage(members.pageInfo.endCursor); + } } - } - await collectPage(null); - return employeeLogins; + await collectPage(null); + return employeeLogins; } async function listPullRequestCommits(context: PullRequestContext) { - return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { - owner: context.owner, - repo: context.repo, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - pull_number: context.number, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - per_page: 100, - }); + return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { + owner: context.owner, + repo: context.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + pull_number: context.number, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + per_page: 100, + }); } export default { - getEmployeeLogins, - getLatestApprovers, - getRequiredApprovingReviewCount, - listPullRequestCommits, + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, }; diff --git a/scripts/libs/peerReview/policy.ts b/scripts/libs/peerReview/policy.ts index 46ef3a2..e3d7655 100644 --- a/scripts/libs/peerReview/policy.ts +++ b/scripts/libs/peerReview/policy.ts @@ -1,93 +1,69 @@ -import CONST from "../github/CONST"; -import WorkflowOutput from "./workflowOutput"; -import type { PeerReviewInput, PeerReviewResult } from "./types"; +import CONST from '../github/CONST'; +import WorkflowOutput from './workflowOutput'; +import type {PeerReviewInput, PeerReviewResult} from './types'; const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; -function getIndependentEmployeeApprovers( - approvers: string[], - authors: string[], - employeeLogins: Set, -): string[] { - const authorSet = new Set(authors); - return approvers.filter( - (approver) => !authorSet.has(approver) && employeeLogins.has(approver), - ); +function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { + const authorSet = new Set(authors); + return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); } function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { - const { - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - } = input; + const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; - if (requiredApprovingReviewCount === 0) { - return { - status: "skip", - reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, - }; - } + if (requiredApprovingReviewCount === 0) { + return { + status: 'skip', + reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + }; + } - if (approvers.length === 0) { - return { - status: "skip", - reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - }; - } + if (approvers.length === 0) { + return { + status: 'skip', + reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } - if (unresolvedExpensifyCoAuthors.length > 0) { - return { - status: "fail", - error: new Error( - `Unable to resolve Expensify co-author emails to GitHub users: ${WorkflowOutput.formatUsers(unresolvedExpensifyCoAuthors)}`, - ), - }; - } + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: 'fail', + error: new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${WorkflowOutput.formatUsers(unresolvedExpensifyCoAuthors)}`), + }; + } - if (authors.every((author) => CONST.BOT_USERS.has(author))) { - return { - status: "fail", - error: new Error( - `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, - ), - }; - } + if (authors.every((author) => CONST.BOT_USERS.has(author))) { + return { + status: 'fail', + error: new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`), + }; + } + + const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + return { + status: 'fail', + error: new Error( + [ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${WorkflowOutput.formatUsers(authors)}`, + `Approvers: ${WorkflowOutput.formatUsers(approvers)}`, + `Independent employee approvers: ${WorkflowOutput.formatUsers(independentEmployeeApprovers)}`, + ].join('\n'), + ), + }; + } - const independentEmployeeApprovers = getIndependentEmployeeApprovers( - approvers, - authors, - employeeLogins, - ); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { return { - status: "fail", - error: new Error( - [ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${WorkflowOutput.formatUsers(authors)}`, - `Approvers: ${WorkflowOutput.formatUsers(approvers)}`, - `Independent employee approvers: ${WorkflowOutput.formatUsers(independentEmployeeApprovers)}`, - ].join("\n"), - ), + status: 'pass', + reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, }; - } - - return { - status: "pass", - reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, - }; } export default { - DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT, - evaluatePeerReview, - getIndependentEmployeeApprovers, + DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT, + evaluatePeerReview, + getIndependentEmployeeApprovers, }; diff --git a/scripts/libs/peerReview/types.ts b/scripts/libs/peerReview/types.ts index 9b6597e..0d7b6c4 100644 --- a/scripts/libs/peerReview/types.ts +++ b/scripts/libs/peerReview/types.ts @@ -1,25 +1,22 @@ type PullRequestContext = { - owner: string; - repo: string; - number: number; - baseRef: string; + owner: string; + repo: string; + number: number; + baseRef: string; }; type PeerReviewInput = { - owner: string; - repo: string; - number: number; - baseRef: string; - requiredApprovingReviewCount: number; - approvers: string[]; - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; - employeeLogins: Set; + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; }; -type PeerReviewResult = - | { status: "pass"; reason: string } - | { status: "skip"; reason: string } - | { status: "fail"; error: Error }; +type PeerReviewResult = {status: 'pass'; reason: string} | {status: 'skip'; reason: string} | {status: 'fail'; error: Error}; -export type { PeerReviewInput, PeerReviewResult, PullRequestContext }; +export type {PeerReviewInput, PeerReviewResult, PullRequestContext}; diff --git a/scripts/libs/peerReview/workflowOutput.ts b/scripts/libs/peerReview/workflowOutput.ts index 0218b3c..770f7d6 100644 --- a/scripts/libs/peerReview/workflowOutput.ts +++ b/scripts/libs/peerReview/workflowOutput.ts @@ -1,70 +1,59 @@ -import { appendFileSync } from "node:fs"; +import {appendFileSync} from 'node:fs'; function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(", ") : "(none)"; + return users.length > 0 ? users.join(', ') : '(none)'; } function unique(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } function escapeWorkflowCommandValue(value: string): string { - return value.replaceAll('%', "%25").replaceAll('\r', "%0D").replaceAll('\n', "%0A"); + return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); } function escapeWorkflowCommandProperty(value: string): string { - return escapeWorkflowCommandValue(value) - .replaceAll(':', "%3A") - .replaceAll(',', "%2C"); + return escapeWorkflowCommandValue(value).replaceAll(':', '%3A').replaceAll(',', '%2C'); } function getFailureTitle(message: string): string { - if ( - message.includes( - "does not have enough independent Expensify employee approvals", - ) - ) { - return "Missing independent peer review"; - } - if (message.includes("Unable to resolve Expensify co-author emails")) { - return "Unresolved Expensify co-author"; - } - if (message.includes("has no human commit authors or co-authors")) { - return "No human commit author"; - } - if (message.includes("Unable to read branch protection rules")) { - return "Branch protection lookup failed"; - } - return "Peer review verification failed"; + if (message.includes('does not have enough independent Expensify employee approvals')) { + return 'Missing independent peer review'; + } + if (message.includes('Unable to resolve Expensify co-author emails')) { + return 'Unresolved Expensify co-author'; + } + if (message.includes('has no human commit authors or co-authors')) { + return 'No human commit author'; + } + if (message.includes('Unable to read branch protection rules')) { + return 'Branch protection lookup failed'; + } + return 'Peer review verification failed'; } function writeStepSummary(title: string, message: string): void { - const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!stepSummaryPath) { - return; - } - appendFileSync( - stepSummaryPath, - `## ${title}\n\n${message.replaceAll('\n', "\n\n")}\n`, - ); + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!stepSummaryPath) { + return; + } + appendFileSync(stepSummaryPath, `## ${title}\n\n${message.replaceAll('\n', '\n\n')}\n`); } function emitFailure(error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - const title = getFailureTitle(message); - writeStepSummary(title, message); - console.error( - `::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`, - ); - process.exit(1); + const message = error instanceof Error ? error.message : String(error); + const title = getFailureTitle(message); + writeStepSummary(title, message); + console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); + process.exit(1); } export default { - emitFailure, - escapeWorkflowCommandProperty, - escapeWorkflowCommandValue, - formatUsers, - getFailureTitle, - unique, - writeStepSummary, + emitFailure, + escapeWorkflowCommandProperty, + escapeWorkflowCommandValue, + formatUsers, + getFailureTitle, + unique, + writeStepSummary, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 2d341bb..130a5f5 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,51 +1,47 @@ -import CoAuthors from "./libs/peerReview/coAuthors"; -import EventContext from "./libs/peerReview/eventContext"; -import PeerReviewGitHubApi from "./libs/peerReview/githubApi"; -import Policy from "./libs/peerReview/policy"; -import WorkflowOutput from "./libs/peerReview/workflowOutput"; +import CoAuthors from './libs/peerReview/coAuthors'; +import EventContext from './libs/peerReview/eventContext'; +import PeerReviewGitHubApi from './libs/peerReview/githubApi'; +import Policy from './libs/peerReview/policy'; +import WorkflowOutput from './libs/peerReview/workflowOutput'; async function main(): Promise { - const context = EventContext.getPullRequestContext(); - const { owner, repo, number, baseRef } = context; - - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), - PeerReviewGitHubApi.getLatestApprovers(context), - PeerReviewGitHubApi.listPullRequestCommits(context), - ]); - - const { authors, unresolvedExpensifyCoAuthors } = - CoAuthors.getCommitAuthors(commits); - - const employeeLogins = - approvers.length > 0 && requiredApprovingReviewCount > 0 - ? await PeerReviewGitHubApi.getEmployeeLogins() - : new Set(); - - const result = Policy.evaluatePeerReview({ - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - }); - - if (result.status === "skip" || result.status === "pass") { - console.log(result.reason); - return; - } - - throw result.error; + const context = EventContext.getPullRequestContext(); + const {owner, repo, number, baseRef} = context; + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), + PeerReviewGitHubApi.getLatestApprovers(context), + PeerReviewGitHubApi.listPullRequestCommits(context), + ]); + + const {authors, unresolvedExpensifyCoAuthors} = CoAuthors.getCommitAuthors(commits); + + const employeeLogins = approvers.length > 0 && requiredApprovingReviewCount > 0 ? await PeerReviewGitHubApi.getEmployeeLogins() : new Set(); + + const result = Policy.evaluatePeerReview({ + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + }); + + if (result.status === 'skip' || result.status === 'pass') { + console.log(result.reason); + return; + } + + throw result.error; } export default { - main, + main, }; if (import.meta.main) { - main().catch(WorkflowOutput.emitFailure); + main().catch(WorkflowOutput.emitFailure); } diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts index a8c6781..eb57d91 100644 --- a/tests/peerReview/coAuthors.test.ts +++ b/tests/peerReview/coAuthors.test.ts @@ -1,116 +1,69 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import CoAuthors, { - type Commit, -} from "../../scripts/libs/peerReview/coAuthors"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import CoAuthors, {type Commit} from '../../scripts/libs/peerReview/coAuthors'; -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, -): Commit { - return { - author: authorLogin ? { login: authorLogin } : null, - commit: { - message, - author: authorName ? { name: authorName } : {}, - }, - } as Commit; +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): Commit { + return { + author: authorLogin ? {login: authorLogin} : null, + commit: { + message, + author: authorName ? {name: authorName} : {}, + }, + } as Commit; } -describe("resolveCoAuthorLogin", () => { - it("parses standard noreply addresses", () => { - assert.equal( - CoAuthors.resolveCoAuthorLogin("AndrewGable@users.noreply.github.com"), - "AndrewGable", - ); - }); +describe('resolveCoAuthorLogin', () => { + it('parses standard noreply addresses', () => { + assert.equal(CoAuthors.resolveCoAuthorLogin('AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); - it("parses numeric noreply prefixes", () => { - assert.equal( - CoAuthors.resolveCoAuthorLogin( - "2838819+AndrewGable@users.noreply.github.com", - ), - "AndrewGable", - ); - }); + it('parses numeric noreply prefixes', () => { + assert.equal(CoAuthors.resolveCoAuthorLogin('2838819+AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); }); -describe("coAuthorEmails", () => { - it("extracts multiple co-author emails", () => { - const message = [ - "Some change", - "", - "Co-authored-by: Andrew Gable ", - "Co-authored-by: Monil Bhavsar ", - ].join("\n"); +describe('coAuthorEmails', () => { + it('extracts multiple co-author emails', () => { + const message = [ + 'Some change', + '', + 'Co-authored-by: Andrew Gable ', + 'Co-authored-by: Monil Bhavsar ', + ].join('\n'); - assert.deepEqual(CoAuthors.coAuthorEmails(message), [ - "AndrewGable@users.noreply.github.com", - "MonilBhavsar@users.noreply.github.com", - ]); - }); + assert.deepEqual(CoAuthors.coAuthorEmails(message), ['AndrewGable@users.noreply.github.com', 'MonilBhavsar@users.noreply.github.com']); + }); }); -describe("getCommitAuthors", () => { - it("counts co-authors for bot-authored commits", () => { - const result = CoAuthors.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); +describe('getCommitAuthors', () => { + it('counts co-authors for bot-authored commits', () => { + const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - it("ignores co-authors when canonical author is human", () => { - const result = CoAuthors.getCommitAuthors([ - makeCommit( - "rafecolton", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); + it('ignores co-authors when canonical author is human', () => { + const result = CoAuthors.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.authors, ["rafecolton"]); - }); + assert.deepEqual(result.authors, ['rafecolton']); + }); - it("falls back to commit author name when github login is missing", () => { - const result = CoAuthors.getCommitAuthors([ - makeCommit(undefined, "AndrewGable", "Change"), - ]); + it('falls back to commit author name when github login is missing', () => { + const result = CoAuthors.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); - assert.deepEqual(result.authors, ["AndrewGable"]); - }); + assert.deepEqual(result.authors, ['AndrewGable']); + }); - it("normalizes expensify email casing and whitespace for unresolved detection", () => { - const result = CoAuthors.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >", - ), - ]); + it('normalizes expensify email casing and whitespace for unresolved detection', () => { + const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >')]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "Andrew@Expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); + }); - it("collects unresolved expensify co-author emails", () => { - const result = CoAuthors.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); + it('collects unresolved expensify co-author emails', () => { + const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "andrew@expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); + }); }); diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts index 6fba2a2..afe369e 100644 --- a/tests/peerReview/eventContext.test.ts +++ b/tests/peerReview/eventContext.test.ts @@ -1,46 +1,37 @@ -import assert from "node:assert/strict"; -import { afterEach, beforeEach, describe, it } from "node:test"; -import EventContext from "../../scripts/libs/peerReview/eventContext"; +import assert from 'node:assert/strict'; +import {afterEach, beforeEach, describe, it} from 'node:test'; +import EventContext from '../../scripts/libs/peerReview/eventContext'; const ORIGINAL_ARGV = process.argv; -describe("getPullRequestContext", () => { - let originalExit: typeof process.exit; +describe('getPullRequestContext', () => { + let originalExit: typeof process.exit; - beforeEach(() => { - process.argv = ["tsx", "scripts/verifyPeerReview.ts"]; - originalExit = process.exit; - process.exit = (code?: string | number | null) => { - throw new Error(`exit ${code ?? 0}`); - }; - }); + beforeEach(() => { + process.argv = ['tsx', 'scripts/verifyPeerReview.ts']; + originalExit = process.exit; + process.exit = (code?: string | number | null) => { + throw new Error(`exit ${code ?? 0}`); + }; + }); - afterEach(() => { - process.argv = ORIGINAL_ARGV; - process.exit = originalExit; - }); + afterEach(() => { + process.argv = ORIGINAL_ARGV; + process.exit = originalExit; + }); - it("parses required pull request CLI arguments", () => { - process.argv.push( - "--owner", - "Expensify", - "--repo", - "Auth", - "--number", - "21136", - "--base-ref", - "main", - ); + it('parses required pull request CLI arguments', () => { + process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--number', '21136', '--base-ref', 'main'); - assert.deepEqual(EventContext.getPullRequestContext(), { - owner: "Expensify", - repo: "Auth", - number: 21136, - baseRef: "main", + assert.deepEqual(EventContext.getPullRequestContext(), { + owner: 'Expensify', + repo: 'Auth', + number: 21136, + baseRef: 'main', + }); }); - }); - it("fails when required arguments are missing", () => { - assert.throws(() => EventContext.getPullRequestContext(), /exit 1/); - }); + it('fails when required arguments are missing', () => { + assert.throws(() => EventContext.getPullRequestContext(), /exit 1/); + }); }); diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index 3b703f9..e989b1c 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -1,52 +1,49 @@ -import assert from "node:assert/strict"; -import { describe, it, afterEach } from "node:test"; -import { RequestError } from "@octokit/request-error"; -import GitHubAPIClient from "../../scripts/libs/github/GitHubAPIClient"; -import PeerReviewGitHubApi from "../../scripts/libs/peerReview/githubApi"; +import assert from 'node:assert/strict'; +import {describe, it, afterEach} from 'node:test'; +import {RequestError} from '@octokit/request-error'; +import GitHubAPIClient from '../../scripts/libs/github/GitHubAPIClient'; +import PeerReviewGitHubApi from '../../scripts/libs/peerReview/githubApi'; const context = { - owner: "Expensify", - repo: "Auth", - number: 1, - baseRef: "main", + owner: 'Expensify', + repo: 'Auth', + number: 1, + baseRef: 'main', }; -describe("getRequiredApprovingReviewCount", () => { - afterEach(() => { - GitHubAPIClient.internalOctokit = undefined; - GitHubAPIClient.graphqlClient = undefined; - }); +describe('getRequiredApprovingReviewCount', () => { + afterEach(() => { + GitHubAPIClient.internalOctokit = undefined; + GitHubAPIClient.graphqlClient = undefined; + }); - it("returns 0 when branch protection rule is missing", async () => { - GitHubAPIClient.graphqlClient = (async () => ({ - repository: { - ref: { - branchProtectionRule: null, - }, - }, - })) as NonNullable; + it('returns 0 when branch protection rule is missing', async () => { + GitHubAPIClient.graphqlClient = (async () => ({ + repository: { + ref: { + branchProtectionRule: null, + }, + }, + })) as NonNullable; - const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ - ...context, - baseRef: "staging", + const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ + ...context, + baseRef: 'staging', + }); + assert.equal(count, 0); }); - assert.equal(count, 0); - }); - it("throws on permission errors", async () => { - GitHubAPIClient.graphqlClient = async () => { - throw new RequestError("Resource not accessible by integration", 403, { - request: { - method: "POST", - url: "https://api.github.com/graphql", - headers: {}, - }, - }); - }; + it('throws on permission errors', async () => { + GitHubAPIClient.graphqlClient = async () => { + throw new RequestError('Resource not accessible by integration', 403, { + request: { + method: 'POST', + url: 'https://api.github.com/graphql', + headers: {}, + }, + }); + }; - await assert.rejects( - () => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), - /Unable to read branch protection rules/, - ); - }); + await assert.rejects(() => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), /Unable to read branch protection rules/); + }); }); diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts index ab5a6f6..4ebc318 100644 --- a/tests/peerReview/policy.test.ts +++ b/tests/peerReview/policy.test.ts @@ -1,126 +1,113 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import Policy from "../../scripts/libs/peerReview/policy"; -import type { PeerReviewInput } from "../../scripts/libs/peerReview/types"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import Policy from '../../scripts/libs/peerReview/policy'; +import type {PeerReviewInput} from '../../scripts/libs/peerReview/types'; const baseInput: PeerReviewInput = { - owner: "Expensify", - repo: "Auth", - number: 21136, - baseRef: "main", - requiredApprovingReviewCount: 1, - approvers: [], - authors: [], - unresolvedExpensifyCoAuthors: [], - employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), + owner: 'Expensify', + repo: 'Auth', + number: 21136, + baseRef: 'main', + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(['MonilBhavsar', 'AndrewGable', 'rafecolton']), }; -describe("getIndependentEmployeeApprovers", () => { - it("excludes commit authors and non-employees", () => { - const independent = Policy.getIndependentEmployeeApprovers( - ["AndrewGable", "MonilBhavsar", "externalCollab"], - ["AndrewGable"], - new Set(["AndrewGable", "MonilBhavsar"]), - ); +describe('getIndependentEmployeeApprovers', () => { + it('excludes commit authors and non-employees', () => { + const independent = Policy.getIndependentEmployeeApprovers(['AndrewGable', 'MonilBhavsar', 'externalCollab'], ['AndrewGable'], new Set(['AndrewGable', 'MonilBhavsar'])); - assert.deepEqual(independent, ["MonilBhavsar"]); - }); + assert.deepEqual(independent, ['MonilBhavsar']); + }); }); -describe("evaluatePeerReview", () => { - it("skips when branch requires no approving reviews", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 0, - approvers: ["AndrewGable"], - authors: ["AndrewGable"], +describe('evaluatePeerReview', () => { + it('skips when branch requires no approving reviews', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ['AndrewGable'], + authors: ['AndrewGable'], + }); + + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "skip"); - }); + it('skips when there are no approving reviews yet', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("skips when there are no approving reviews yet", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: [], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "skip"); - }); + it('fails on self-review from commit co-author', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails on self-review from commit co-author", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'fail'); + assert.match((result as {error: Error}).error.message, /does not have enough independent Expensify employee approvals/); }); - assert.equal(result.status, "fail"); - assert.match( - (result as { error: Error }).error.message, - /does not have enough independent Expensify employee approvals/, - ); - }); - - it("passes when an independent employee approves", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + it('passes when an independent employee approves', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); + + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "pass"); - }); + it('fails when independent approver count is below required', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails when independent approver count is below required", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'fail'); }); - assert.equal(result.status, "fail"); - }); + it('passes when independent approver count meets required', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'rafecolton'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("passes when independent approver count meets required", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "rafecolton"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "pass"); - }); + it('fails when all authors are bots', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + }); - it("fails when all authors are bots", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], + assert.equal(result.status, 'fail'); + assert.match((result as {error: Error}).error.message, /no human commit authors or co-authors/); }); - assert.equal(result.status, "fail"); - assert.match( - (result as { error: Error }).error.message, - /no human commit authors or co-authors/, - ); - }); - - it("fails on unresolved expensify co-author emails", () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], - unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], - }); + it('fails on unresolved expensify co-author emails', () => { + const result = Policy.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + unresolvedExpensifyCoAuthors: ['andrew@expensify.com'], + }); - assert.equal(result.status, "fail"); - assert.match( - (result as { error: Error }).error.message, - /Unable to resolve Expensify co-author emails/, - ); - }); + assert.equal(result.status, 'fail'); + assert.match((result as {error: Error}).error.message, /Unable to resolve Expensify co-author emails/); + }); }); diff --git a/tests/peerReview/workflowOutput.test.ts b/tests/peerReview/workflowOutput.test.ts index 020155f..af31214 100644 --- a/tests/peerReview/workflowOutput.test.ts +++ b/tests/peerReview/workflowOutput.test.ts @@ -1,38 +1,22 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import WorkflowOutput from "../../scripts/libs/peerReview/workflowOutput"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import WorkflowOutput from '../../scripts/libs/peerReview/workflowOutput'; -describe("workflowOutput helpers", () => { - it("formats empty user lists", () => { - assert.equal(WorkflowOutput.formatUsers([]), "(none)"); - }); +describe('workflowOutput helpers', () => { + it('formats empty user lists', () => { + assert.equal(WorkflowOutput.formatUsers([]), '(none)'); + }); - it("escapes workflow command values", () => { - assert.equal( - WorkflowOutput.escapeWorkflowCommandValue("a\nb%"), - "a%0Ab%25", - ); - }); + it('escapes workflow command values', () => { + assert.equal(WorkflowOutput.escapeWorkflowCommandValue('a\nb%'), 'a%0Ab%25'); + }); - it("escapes workflow command properties", () => { - assert.equal( - WorkflowOutput.escapeWorkflowCommandProperty("title:one,two"), - "title%3Aone%2Ctwo", - ); - }); + it('escapes workflow command properties', () => { + assert.equal(WorkflowOutput.escapeWorkflowCommandProperty('title:one,two'), 'title%3Aone%2Ctwo'); + }); - it("maps failure titles for known messages", () => { - assert.equal( - WorkflowOutput.getFailureTitle( - "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", - ), - "Missing independent peer review", - ); - assert.equal( - WorkflowOutput.getFailureTitle( - "Unable to read branch protection rules for Expensify/Auth@main.", - ), - "Branch protection lookup failed", - ); - }); + it('maps failure titles for known messages', () => { + assert.equal(WorkflowOutput.getFailureTitle('Expensify/Auth#1 does not have enough independent Expensify employee approvals.'), 'Missing independent peer review'); + assert.equal(WorkflowOutput.getFailureTitle('Unable to read branch protection rules for Expensify/Auth@main.'), 'Branch protection lookup failed'); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 4fe0bc8..1ddd95b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ { - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "target": "ES2022", - "types": ["node"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "esModuleInterop": true - }, - "include": ["scripts/**/*.ts", "tests/**/*.ts"] + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["scripts/**/*.ts", "tests/**/*.ts"] } From fa4e331a6330393f62a97651813b5b9703c764ac Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 16:56:54 -0700 Subject: [PATCH 38/63] refactor(peer-review): drop eventContext eslint overrides Load expensify-common CLI via typed guards instead of unsafe require assertions so the file complies with standard scripting rules. Co-authored-by: Cursor --- eslint.config.mjs | 9 --------- scripts/libs/peerReview/eventContext.ts | 27 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5832570..8810666 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,15 +38,6 @@ export default defineConfig([ 'jest/no-jest-import': 'off', }, }, - { - files: ['scripts/libs/peerReview/eventContext.ts'], - rules: { - 'import/extensions': 'off', - '@typescript-eslint/no-unsafe-type-assertion': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/consistent-type-imports': 'off', - }, - }, { files: ['tests/**/*.ts'], rules: { diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts index 1bacdcc..1b85d5f 100644 --- a/scripts/libs/peerReview/eventContext.ts +++ b/scripts/libs/peerReview/eventContext.ts @@ -1,8 +1,33 @@ import {createRequire} from 'node:module'; +import type CLIClass from 'expensify-common/dist/CLI'; import type {PullRequestContext} from './types'; const require = createRequire(import.meta.url); -const CLI = require('expensify-common/dist/CLI.js').default as typeof import('expensify-common/dist/CLI.js').default; + +function isCLIConstructor(value: unknown): value is typeof CLIClass { + return typeof value === 'function'; +} + +function isCLIModule(value: unknown): value is {default: typeof CLIClass} { + if (typeof value !== 'object' || value === null || !('default' in value)) { + return false; + } + + return isCLIConstructor(value.default); +} + +function loadCLI(): typeof CLIClass { + const moduleExports: unknown = require('expensify-common/dist/CLI'); + if (isCLIConstructor(moduleExports)) { + return moduleExports; + } + if (isCLIModule(moduleExports)) { + return moduleExports.default; + } + throw new Error('Failed to load expensify-common CLI'); +} + +const CLI = loadCLI(); function parsePullRequestNumber(value: string): number { const number = Number(value); From d082b36b9d3befae109bf559bf1362b17e847b06 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 17:00:42 -0700 Subject: [PATCH 39/63] refactor(tests): drop test eslint overrides Use PeerReviewCommit, status narrowing, and injectable GraphQL handlers so tests comply with standard lint rules. Co-authored-by: Cursor --- eslint.config.mjs | 8 -------- scripts/libs/github/GitHubAPIClient.ts | 11 ++++++++--- scripts/libs/peerReview/coAuthors.ts | 19 ++++++++++++++----- tests/peerReview/coAuthors.test.ts | 6 +++--- tests/peerReview/eventContext.test.ts | 2 +- tests/peerReview/githubApi.test.ts | 4 ++-- tests/peerReview/policy.test.ts | 12 +++++++++--- 7 files changed, 37 insertions(+), 25 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8810666..1850e97 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,12 +38,4 @@ export default defineConfig([ 'jest/no-jest-import': 'off', }, }, - { - files: ['tests/**/*.ts'], - rules: { - 'import/no-extraneous-dependencies': 'off', - '@typescript-eslint/no-unsafe-type-assertion': 'off', - '@typescript-eslint/unbound-method': 'off', - }, - }, ]); diff --git a/scripts/libs/github/GitHubAPIClient.ts b/scripts/libs/github/GitHubAPIClient.ts index 2e4ea21..76bc1b2 100644 --- a/scripts/libs/github/GitHubAPIClient.ts +++ b/scripts/libs/github/GitHubAPIClient.ts @@ -5,6 +5,7 @@ import type {PaginateInterface} from '@octokit/plugin-paginate-rest'; import {throttling} from '@octokit/plugin-throttling'; type GraphqlQuery = (query: string, variables?: Record) => Promise; +type GraphqlHandler = (query: string, variables?: Record) => Promise; type InternalOctokit = InstanceType; @@ -13,7 +14,7 @@ const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); class GitHubAPIClient { static internalOctokit: InternalOctokit | undefined; - static graphqlClient: GraphqlQuery | undefined; + static graphqlClient: GraphqlHandler | undefined; static initWithToken(token: string): void { this.internalOctokit = new OctokitWithPlugins({ @@ -64,7 +65,7 @@ class GitHubAPIClient { return this.internalOctokit; } - private static ensureGraphqlClient(): GraphqlQuery { + private static ensureGraphqlClient(): GraphqlHandler { if (!this.graphqlClient) { this.init(); } @@ -81,7 +82,11 @@ class GitHubAPIClient { } static get graphql(): GraphqlQuery { - return this.ensureGraphqlClient(); + const handler = this.ensureGraphqlClient(); + return (query: string, variables?: Record): Promise => + // GraphQL responses are typed at the call site; the handler returns untyped JSON. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic graphql boundary + handler(query, variables) as Promise; } static get paginate(): PaginateInterface { diff --git a/scripts/libs/peerReview/coAuthors.ts b/scripts/libs/peerReview/coAuthors.ts index 0b2168b..6736fa9 100644 --- a/scripts/libs/peerReview/coAuthors.ts +++ b/scripts/libs/peerReview/coAuthors.ts @@ -1,8 +1,17 @@ -import type {RestEndpointMethodTypes} from '@octokit/rest'; import CONST from '../github/CONST'; import WorkflowOutput from './workflowOutput'; -type Commit = RestEndpointMethodTypes['pulls']['listCommits']['response']['data'][number]; +type PeerReviewCommit = { + author: { + login?: string; + } | null; + commit: { + message: string; + author?: { + name?: string | null; + } | null; + }; +}; function coAuthorEmails(message: string): string[] { return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); @@ -17,7 +26,7 @@ function isExpensifyEmail(email: string): boolean { return email.trim().toLowerCase().endsWith('@expensify.com'); } -function getCanonicalAuthorLogin(commit: Commit): string { +function getCanonicalAuthorLogin(commit: PeerReviewCommit): string { const authorLogin = commit.author?.login ?? ''; if (authorLogin) { return authorLogin; @@ -27,7 +36,7 @@ function getCanonicalAuthorLogin(commit: Commit): string { return commit.commit.author?.name?.trim() ?? ''; } -function getCommitAuthors(commits: Commit[]): { +function getCommitAuthors(commits: PeerReviewCommit[]): { authors: string[]; unresolvedExpensifyCoAuthors: string[]; } { @@ -63,7 +72,7 @@ function getCommitAuthors(commits: Commit[]): { }; } -export type {Commit}; +export type {PeerReviewCommit}; export default { coAuthorEmails, diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts index eb57d91..e97f132 100644 --- a/tests/peerReview/coAuthors.test.ts +++ b/tests/peerReview/coAuthors.test.ts @@ -1,15 +1,15 @@ import assert from 'node:assert/strict'; import {describe, it} from 'node:test'; -import CoAuthors, {type Commit} from '../../scripts/libs/peerReview/coAuthors'; +import CoAuthors, {type PeerReviewCommit} from '../../scripts/libs/peerReview/coAuthors'; -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): Commit { +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): PeerReviewCommit { return { author: authorLogin ? {login: authorLogin} : null, commit: { message, author: authorName ? {name: authorName} : {}, }, - } as Commit; + }; } describe('resolveCoAuthorLogin', () => { diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts index afe369e..4c499b3 100644 --- a/tests/peerReview/eventContext.test.ts +++ b/tests/peerReview/eventContext.test.ts @@ -9,7 +9,7 @@ describe('getPullRequestContext', () => { beforeEach(() => { process.argv = ['tsx', 'scripts/verifyPeerReview.ts']; - originalExit = process.exit; + originalExit = process.exit.bind(process); process.exit = (code?: string | number | null) => { throw new Error(`exit ${code ?? 0}`); }; diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts index e989b1c..1df45db 100644 --- a/tests/peerReview/githubApi.test.ts +++ b/tests/peerReview/githubApi.test.ts @@ -18,13 +18,13 @@ describe('getRequiredApprovingReviewCount', () => { }); it('returns 0 when branch protection rule is missing', async () => { - GitHubAPIClient.graphqlClient = (async () => ({ + GitHubAPIClient.graphqlClient = async () => ({ repository: { ref: { branchProtectionRule: null, }, }, - })) as NonNullable; + }); const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ ...context, diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts index 4ebc318..6b48ccd 100644 --- a/tests/peerReview/policy.test.ts +++ b/tests/peerReview/policy.test.ts @@ -53,7 +53,9 @@ describe('evaluatePeerReview', () => { }); assert.equal(result.status, 'fail'); - assert.match((result as {error: Error}).error.message, /does not have enough independent Expensify employee approvals/); + if (result.status === 'fail') { + assert.match(result.error.message, /does not have enough independent Expensify employee approvals/); + } }); it('passes when an independent employee approves', () => { @@ -96,7 +98,9 @@ describe('evaluatePeerReview', () => { }); assert.equal(result.status, 'fail'); - assert.match((result as {error: Error}).error.message, /no human commit authors or co-authors/); + if (result.status === 'fail') { + assert.match(result.error.message, /no human commit authors or co-authors/); + } }); it('fails on unresolved expensify co-author emails', () => { @@ -108,6 +112,8 @@ describe('evaluatePeerReview', () => { }); assert.equal(result.status, 'fail'); - assert.match((result as {error: Error}).error.message, /Unable to resolve Expensify co-author emails/); + if (result.status === 'fail') { + assert.match(result.error.message, /Unable to resolve Expensify co-author emails/); + } }); }); From 41b95cbf9a027faa4a5be44a30b0d2c2726ac930 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 17:05:48 -0700 Subject: [PATCH 40/63] refactor: extract CLI wrapper to scripts/libs/CLI.ts Co-authored-by: Cursor --- scripts/libs/CLI.ts | 29 ++++++++++++++++++++++++ scripts/libs/peerReview/eventContext.ts | 30 +------------------------ 2 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 scripts/libs/CLI.ts diff --git a/scripts/libs/CLI.ts b/scripts/libs/CLI.ts new file mode 100644 index 0000000..59098ea --- /dev/null +++ b/scripts/libs/CLI.ts @@ -0,0 +1,29 @@ +import {createRequire} from 'node:module'; +import type CLIClass from 'expensify-common/dist/CLI'; + +const require = createRequire(import.meta.url); + +function isCLIConstructor(value: unknown): value is typeof CLIClass { + return typeof value === 'function'; +} + +function isCLIModule(value: unknown): value is {default: typeof CLIClass} { + if (typeof value !== 'object' || value === null || !('default' in value)) { + return false; + } + + return isCLIConstructor(value.default); +} + +function loadCLI(): typeof CLIClass { + const moduleExports: unknown = require('expensify-common/dist/CLI'); + if (isCLIConstructor(moduleExports)) { + return moduleExports; + } + if (isCLIModule(moduleExports)) { + return moduleExports.default; + } + throw new Error('Failed to load expensify-common CLI'); +} + +export default loadCLI(); diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts index 1b85d5f..b4921ea 100644 --- a/scripts/libs/peerReview/eventContext.ts +++ b/scripts/libs/peerReview/eventContext.ts @@ -1,34 +1,6 @@ -import {createRequire} from 'node:module'; -import type CLIClass from 'expensify-common/dist/CLI'; +import CLI from '../CLI'; import type {PullRequestContext} from './types'; -const require = createRequire(import.meta.url); - -function isCLIConstructor(value: unknown): value is typeof CLIClass { - return typeof value === 'function'; -} - -function isCLIModule(value: unknown): value is {default: typeof CLIClass} { - if (typeof value !== 'object' || value === null || !('default' in value)) { - return false; - } - - return isCLIConstructor(value.default); -} - -function loadCLI(): typeof CLIClass { - const moduleExports: unknown = require('expensify-common/dist/CLI'); - if (isCLIConstructor(moduleExports)) { - return moduleExports; - } - if (isCLIModule(moduleExports)) { - return moduleExports.default; - } - throw new Error('Failed to load expensify-common CLI'); -} - -const CLI = loadCLI(); - function parsePullRequestNumber(value: string): number { const number = Number(value); if (!Number.isInteger(number) || number <= 0) { From 334a6d626bafccce11a9e37f01a7cf35cf11a92f Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:06:11 -0700 Subject: [PATCH 41/63] chore: upgrade expensify-common to 2.0.188 for CLI subpath Use compiled dist/CLI.js export from expensify-common/CLI directly and remove the local CLI wrapper and type-fest devDependency. Co-authored-by: Cursor --- package-lock.json | 52 +++++++------------- package.json | 2 +- scripts/libs/CLI.ts | 29 ----------- scripts/libs/peerReview/eventContext.ts | 64 ++++++++++++------------- 4 files changed, 50 insertions(+), 97 deletions(-) delete mode 100644 scripts/libs/CLI.ts diff --git a/package-lock.json b/package-lock.json index 2532dad..67b8923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@octokit/plugin-throttling": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.1", - "expensify-common": "2.0.184" + "expensify-common": "2.0.188" }, "devDependencies": { "@prettier/plugin-oxc": "0.1.3", @@ -1414,9 +1414,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1434,9 +1431,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1454,9 +1448,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1474,9 +1465,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1482,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1514,9 +1499,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3789,9 +3771,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.184", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.184.tgz", - "integrity": "sha512-TgoOCXGb7KVIq9ehsR9WajRn0DEcrbHJWLsvP8yUkm2GOKomZd6RxQfHPzC4HVVrWk9opSAutIlerVj0yBED3A==", + "version": "2.0.188", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.188.tgz", + "integrity": "sha512-FQaqUyqdl++UyO2AfLwxJbJMvLk6bL7yjd/rQvfVfXsU+vfyEbw+Z5eeoz7/ygIQVDsu+J272kbQM4jknFwbjg==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -5149,6 +5131,19 @@ "type-fest": "4.2.0" } }, + "node_modules/object-deep-merge/node_modules/type-fest": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.2.0.tgz", + "integrity": "sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6235,19 +6230,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.2.0.tgz", - "integrity": "sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", diff --git a/package.json b/package.json index ae3e462..d30d34a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@octokit/plugin-throttling": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/rest": "^22.0.1", - "expensify-common": "2.0.184" + "expensify-common": "2.0.188" }, "packageManager": "npm@11.13.0", "type": "module" diff --git a/scripts/libs/CLI.ts b/scripts/libs/CLI.ts deleted file mode 100644 index 59098ea..0000000 --- a/scripts/libs/CLI.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {createRequire} from 'node:module'; -import type CLIClass from 'expensify-common/dist/CLI'; - -const require = createRequire(import.meta.url); - -function isCLIConstructor(value: unknown): value is typeof CLIClass { - return typeof value === 'function'; -} - -function isCLIModule(value: unknown): value is {default: typeof CLIClass} { - if (typeof value !== 'object' || value === null || !('default' in value)) { - return false; - } - - return isCLIConstructor(value.default); -} - -function loadCLI(): typeof CLIClass { - const moduleExports: unknown = require('expensify-common/dist/CLI'); - if (isCLIConstructor(moduleExports)) { - return moduleExports; - } - if (isCLIModule(moduleExports)) { - return moduleExports.default; - } - throw new Error('Failed to load expensify-common CLI'); -} - -export default loadCLI(); diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts index b4921ea..50dd629 100644 --- a/scripts/libs/peerReview/eventContext.ts +++ b/scripts/libs/peerReview/eventContext.ts @@ -1,42 +1,42 @@ -import CLI from '../CLI'; -import type {PullRequestContext} from './types'; +import CLI from "expensify-common/CLI"; +import type { PullRequestContext } from "./types"; function parsePullRequestNumber(value: string): number { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error('Must be a positive integer'); - } - return number; + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("Must be a positive integer"); + } + return number; } function getPullRequestContext(): PullRequestContext { - const cli = new CLI({ - namedArgs: { - owner: { - description: 'Repository owner organization or user login', - }, - repo: { - description: 'Repository name', - }, - number: { - description: 'Pull request number', - parse: parsePullRequestNumber, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names - 'base-ref': { - description: 'Target branch ref for the pull request', - }, - }, - }); + const cli = new CLI({ + namedArgs: { + owner: { + description: "Repository owner organization or user login", + }, + repo: { + description: "Repository name", + }, + number: { + description: "Pull request number", + parse: parsePullRequestNumber, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names + "base-ref": { + description: "Target branch ref for the pull request", + }, + }, + }); - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs.number, - baseRef: cli.namedArgs['base-ref'], - }; + return { + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs["base-ref"], + }; } export default { - getPullRequestContext, + getPullRequestContext, }; From 1537ef7598eaabfe01174951710983e120d4723b Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:08:10 -0700 Subject: [PATCH 42/63] style: fix prettier formatting in eventContext.ts Co-authored-by: Cursor --- scripts/libs/peerReview/eventContext.ts | 64 ++++++++++++------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts index 50dd629..cc7bf36 100644 --- a/scripts/libs/peerReview/eventContext.ts +++ b/scripts/libs/peerReview/eventContext.ts @@ -1,42 +1,42 @@ -import CLI from "expensify-common/CLI"; -import type { PullRequestContext } from "./types"; +import CLI from 'expensify-common/CLI'; +import type {PullRequestContext} from './types'; function parsePullRequestNumber(value: string): number { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error("Must be a positive integer"); - } - return number; + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error('Must be a positive integer'); + } + return number; } function getPullRequestContext(): PullRequestContext { - const cli = new CLI({ - namedArgs: { - owner: { - description: "Repository owner organization or user login", - }, - repo: { - description: "Repository name", - }, - number: { - description: "Pull request number", - parse: parsePullRequestNumber, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names - "base-ref": { - description: "Target branch ref for the pull request", - }, - }, - }); + const cli = new CLI({ + namedArgs: { + owner: { + description: 'Repository owner organization or user login', + }, + repo: { + description: 'Repository name', + }, + number: { + description: 'Pull request number', + parse: parsePullRequestNumber, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names + 'base-ref': { + description: 'Target branch ref for the pull request', + }, + }, + }); - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs.number, - baseRef: cli.namedArgs["base-ref"], - }; + return { + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs['base-ref'], + }; } export default { - getPullRequestContext, + getPullRequestContext, }; From b3d954a42507195580215ad5077ff01738c207a6 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:09:03 -0700 Subject: [PATCH 43/63] Remove unhelpful docs --- README.md | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/README.md b/README.md index 7cfcaa1..3df63d1 100644 --- a/README.md +++ b/README.md @@ -37,39 +37,6 @@ jobs: secrets: inherit ``` -### `typecheck.yml` - -Runs on pull requests that touch TypeScript or related config in this repository. Typechecking uses the TypeScript 7 beta native compiler (`tsgo`) via `@typescript/native-preview`. - -```bash -cd GitHub-Actions -nvm use -npm ci -npm run typecheck -``` - -### `lint.yml` - -Runs ESLint on pull requests that touch TypeScript or related config in this repository. Linting uses `eslint-config-expensify`. - -```bash -cd GitHub-Actions -nvm use -npm ci -npm run lint -``` - -### `test.yml` - -Runs unit tests on pull requests that touch TypeScript or related config in this repository. - -```bash -cd GitHub-Actions -nvm use -npm ci -npm test -``` - ### `verifyPeerReview.yml` Used as an org-level ruleset workflow to block pull requests that do not have enough independent Expensify employee approvals. The check only reads GitHub pull request metadata; it does not checkout or execute code from the pull request branch. From c3b65eaeae971eb1069bc58c41692240cb5fb348 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:13:21 -0700 Subject: [PATCH 44/63] ci: broaden workflow path filters for JS/TS extensions Use concise globs for lint, test, and prettier (.ts/.js/.mts/.mjs/.cts/.cjs) and typecheck-only patterns for .ts/.mts/.cts. Co-authored-by: Cursor --- .github/workflows/lint.yml | 3 ++- .github/workflows/prettier.yml | 4 ++-- .github/workflows/test.yml | 3 ++- .github/workflows/typecheck.yml | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a23e92c..392a3be 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,7 +4,8 @@ on: pull_request: types: [opened, synchronize] paths: - - "**.ts" + - "**/*.[tj]s" + - "**/*.?[tj]s" - "eslint.config.mjs" - "tsconfig.json" - "package.json" diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 1e6e086..ce549b6 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -4,8 +4,8 @@ on: pull_request: types: [opened, synchronize] paths: - - "**.ts" - - "**.mjs" + - "**/*.[tj]s" + - "**/*.?[tj]s" - ".prettierrc.mjs" - ".prettierignore" - "package.json" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd23f02..33833f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,8 @@ on: pull_request: types: [opened, synchronize] paths: - - "**.ts" + - "**/*.[tj]s" + - "**/*.?[tj]s" - "tsconfig.json" - "package.json" - "package-lock.json" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 01faa0d..61922d9 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,7 +4,8 @@ on: pull_request: types: [opened, synchronize] paths: - - "**.ts" + - "**/*.ts" + - "**/*.[mc]ts" - "tsconfig.json" - "package.json" - "package-lock.json" From 3b1ce0df9d92644e4d68738ba38f850eae6f1ba6 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:17:51 -0700 Subject: [PATCH 45/63] ci: remove redundant CI=true from workflow env GitHub Actions sets CI=true by default on runners. Co-authored-by: Cursor --- .github/workflows/lint.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 392a3be..bd410cc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,8 +34,6 @@ jobs: - name: Lint run: npm run lint - env: - CI: true - name: Tell people how to run the failing check if: failure() From af679b5997a1195a362e9d7cd6553811d43f552e Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:42:24 -0700 Subject: [PATCH 46/63] refactor: flatten peer review libs into reusable modules Move generic GitHub, workflow, and git commit helpers into flat libs/ and consolidate peer-review domain logic in verifyPeerReview.ts. Co-authored-by: Cursor --- package.json | 4 +- scripts/libs/GitCommitUtils.ts | 44 ++++ scripts/libs/GitHubAPIClient.ts | 110 ++++++++ scripts/libs/GitHubUtils.ts | 254 ++++++++++++++++++ scripts/libs/GitHubWorkflowUtils.ts | 41 +++ scripts/libs/github/CONST.ts | 11 - scripts/libs/github/GitHubAPIClient.ts | 97 ------- scripts/libs/peerReview/coAuthors.ts | 81 ------ scripts/libs/peerReview/eventContext.ts | 42 --- scripts/libs/peerReview/githubApi.ts | 208 --------------- scripts/libs/peerReview/policy.ts | 69 ----- scripts/libs/peerReview/types.ts | 22 -- scripts/libs/peerReview/workflowOutput.ts | 59 ----- scripts/verifyPeerReview.ts | 299 +++++++++++++++++++--- tests/GitCommitUtils.test.ts | 66 +++++ tests/GitHubUtils.test.ts | 52 ++++ tests/GitHubWorkflowUtils.test.ts | 19 ++ tests/peerReview/coAuthors.test.ts | 69 ----- tests/peerReview/eventContext.test.ts | 37 --- tests/peerReview/githubApi.test.ts | 49 ---- tests/peerReview/policy.test.ts | 119 --------- tests/peerReview/workflowOutput.test.ts | 22 -- tests/verifyPeerReviewCli.test.ts | 46 ++++ tests/verifyPeerReviewCoAuthors.test.ts | 81 ++++++ tests/verifyPeerReviewPolicy.test.ts | 150 +++++++++++ 25 files changed, 1127 insertions(+), 924 deletions(-) create mode 100644 scripts/libs/GitCommitUtils.ts create mode 100644 scripts/libs/GitHubAPIClient.ts create mode 100644 scripts/libs/GitHubUtils.ts create mode 100644 scripts/libs/GitHubWorkflowUtils.ts delete mode 100644 scripts/libs/github/CONST.ts delete mode 100644 scripts/libs/github/GitHubAPIClient.ts delete mode 100644 scripts/libs/peerReview/coAuthors.ts delete mode 100644 scripts/libs/peerReview/eventContext.ts delete mode 100644 scripts/libs/peerReview/githubApi.ts delete mode 100644 scripts/libs/peerReview/policy.ts delete mode 100644 scripts/libs/peerReview/types.ts delete mode 100644 scripts/libs/peerReview/workflowOutput.ts create mode 100644 tests/GitCommitUtils.test.ts create mode 100644 tests/GitHubUtils.test.ts create mode 100644 tests/GitHubWorkflowUtils.test.ts delete mode 100644 tests/peerReview/coAuthors.test.ts delete mode 100644 tests/peerReview/eventContext.test.ts delete mode 100644 tests/peerReview/githubApi.test.ts delete mode 100644 tests/peerReview/policy.test.ts delete mode 100644 tests/peerReview/workflowOutput.test.ts create mode 100644 tests/verifyPeerReviewCli.test.ts create mode 100644 tests/verifyPeerReviewCoAuthors.test.ts create mode 100644 tests/verifyPeerReviewPolicy.test.ts diff --git a/package.json b/package.json index d30d34a..a0f21fd 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "scripts": { "lint": "eslint scripts tests", "prettier": "prettier --experimental-cli --no-cache --write .", - "test": "tsx --test tests/**/*.test.ts", - "test:peer-review": "tsx --test tests/peerReview/**/*.test.ts", + "test": "tsx --test tests/*.test.ts", + "test:peer-review": "tsx --test tests/*.test.ts", "typecheck": "tsgo --noEmit -p tsconfig.json", "verify-peer-review": "tsx scripts/verifyPeerReview.ts" }, diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts new file mode 100644 index 0000000..531518f --- /dev/null +++ b/scripts/libs/GitCommitUtils.ts @@ -0,0 +1,44 @@ +type GitHubPullRequestCommit = { + author: { + login?: string; + } | null; + commit: { + message: string; + author?: { + name?: string | null; + } | null; + }; +}; + +function coAuthorEmails(message: string): string[] { + return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => + match[1].trim(), + ); +} + +function resolveNoreplyEmailToLogin(email: string): string | null { + const normalizedEmail = email.trim(); + return ( + normalizedEmail.match( + /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, + )?.[1] ?? null + ); +} + +function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { + const authorLogin = commit.author?.login ?? ""; + if (authorLogin) { + return authorLogin; + } + + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + return commit.commit.author?.name?.trim() ?? ""; +} + +export type { GitHubPullRequestCommit }; + +export default { + coAuthorEmails, + getCanonicalAuthorLogin, + resolveNoreplyEmailToLogin, +}; diff --git a/scripts/libs/GitHubAPIClient.ts b/scripts/libs/GitHubAPIClient.ts new file mode 100644 index 0000000..a2f71a9 --- /dev/null +++ b/scripts/libs/GitHubAPIClient.ts @@ -0,0 +1,110 @@ +import { graphql as createGraphql } from "@octokit/graphql"; +import { Octokit } from "@octokit/rest"; +import { paginateRest } from "@octokit/plugin-paginate-rest"; +import type { PaginateInterface } from "@octokit/plugin-paginate-rest"; +import { throttling } from "@octokit/plugin-throttling"; + +type GraphqlQuery = ( + query: string, + variables?: Record, +) => Promise; +type GraphqlHandler = ( + query: string, + variables?: Record, +) => Promise; + +type InternalOctokit = InstanceType; + +const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); + +class GitHubAPIClient { + static internalOctokit: InternalOctokit | undefined; + + static graphqlClient: GraphqlHandler | undefined; + + static initWithToken(token: string): void { + this.internalOctokit = new OctokitWithPlugins({ + auth: token, + throttle: { + retryAfterBaseValue: 2000, + onRateLimit: (retryAfter, options) => { + console.warn( + `Request quota exhausted for request ${options.method} ${options.url}`, + ); + + if (options.request.retryCount <= 5) { + console.warn(`Retrying after ${retryAfter} seconds!`); + return true; + } + + return false; + }, + onSecondaryRateLimit: (retryAfter, options) => { + console.warn( + `Abuse detected for request ${options.method} ${options.url}`, + ); + }, + }, + }); + + this.graphqlClient = createGraphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); + } + + static init(): void { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN or GH_TOKEN is required"); + } + + this.initWithToken(token); + } + + private static ensureOctokit(): InternalOctokit { + if (!this.internalOctokit) { + this.init(); + } + + if (!this.internalOctokit) { + throw new Error("Failed to initialize GitHub API client"); + } + + return this.internalOctokit; + } + + private static ensureGraphqlClient(): GraphqlHandler { + if (!this.graphqlClient) { + this.init(); + } + + if (!this.graphqlClient) { + throw new Error("Failed to initialize GitHub GraphQL client"); + } + + return this.graphqlClient; + } + + static get octokit(): InternalOctokit["rest"] { + return this.ensureOctokit().rest; + } + + static get graphql(): GraphqlQuery { + const handler = this.ensureGraphqlClient(); + return ( + query: string, + variables?: Record, + ): Promise => + // GraphQL responses are typed at the call site; the handler returns untyped JSON. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic graphql boundary + handler(query, variables) as Promise; + } + + static get paginate(): PaginateInterface { + return this.ensureOctokit().paginate; + } +} + +export default GitHubAPIClient; diff --git a/scripts/libs/GitHubUtils.ts b/scripts/libs/GitHubUtils.ts new file mode 100644 index 0000000..0841257 --- /dev/null +++ b/scripts/libs/GitHubUtils.ts @@ -0,0 +1,254 @@ +import { RequestError } from "@octokit/request-error"; +import GitHubAPIClient from "./GitHubAPIClient"; + +const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; +const EXPENSIFY_ORG = "Expensify"; +const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; + +type BranchProtectionResponse = { + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; + } | null; +}; + +type OpinionatedReviewNode = { + state: string; + author: { + login: string; + } | null; +}; + +type LatestOpinionatedReviewsResponse = { + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: OpinionatedReviewNode[]; + }; + } | null; + } | null; +}; + +type TeamMembersResponse = { + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; + } | null; +}; + +type GraphQLErrorResponse = { + errors?: Array<{ + type?: string; + message?: string; + }>; +}; + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { + return typeof error === "object" && error !== null && "errors" in error; +} + +function isPermissionError(error: unknown): boolean { + if (error instanceof RequestError) { + return error.status === 401 || error.status === 403; + } + + if (isGraphQLErrorResponse(error)) { + const { errors } = error; + return ( + errors?.some( + (graphQLError) => + graphQLError.type === "FORBIDDEN" || + graphQLError.type === "INSUFFICIENT_SCOPES", + ) ?? false + ); + } + + const message = error instanceof Error ? error.message : String(error); + return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test( + message, + ); +} + +async function getRequiredApprovingReviewCount({ + owner, + repo, + baseRef, +}: { + owner: string; + repo: string; + baseRef: string; +}): Promise { + try { + const response = await GitHubAPIClient.graphql( + ` + query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branchRef) { + branchProtectionRule { + requiredApprovingReviewCount + } + } + } + } + `, + { + owner, + repo, + branchRef: `refs/heads/${baseRef}`, + }, + ); + + return ( + response.repository?.ref?.branchProtectionRule + ?.requiredApprovingReviewCount ?? 0 + ); + } catch (error: unknown) { + if (isPermissionError(error)) { + throw new Error( + `Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`, + ); + } + + const message = error instanceof Error ? error.message : String(error); + console.warn( + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + ); + return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; + } +} + +async function getLatestApprovers({ + owner, + repo, + number, +}: { + owner: string; + repo: string; + number: number; +}): Promise { + const response = + await GitHubAPIClient.graphql( + ` + query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + latestOpinionatedReviews(last: 100, writersOnly: true) { + nodes { + state + author { + login + } + } + } + } + } + } + `, + { + owner, + repo, + prNumber: number, + }, + ); + + const reviews: OpinionatedReviewNode[] = + response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; + return uniqueSorted( + reviews + .filter((review) => review.state === "APPROVED") + .map((review) => review.author?.login ?? "") + .filter((login) => login !== ""), + ); +} + +async function getEmployeeLogins(): Promise> { + const employeeLogins = new Set(); + + async function collectPage(cursor: string | null): Promise { + const response: TeamMembersResponse = + await GitHubAPIClient.graphql( + ` + query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { + organization(login: $organization) { + team(slug: $teamSlug) { + members(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + login + } + } + } + } + } + `, + { + organization: EXPENSIFY_ORG, + teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, + cursor, + }, + ); + + const members = response.organization?.team?.members; + if (!members) { + throw new Error( + `${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, + ); + } + + for (const member of members.nodes) { + employeeLogins.add(member.login); + } + + if (members.pageInfo.hasNextPage) { + await collectPage(members.pageInfo.endCursor); + } + } + + await collectPage(null); + return employeeLogins; +} + +async function listPullRequestCommits({ + owner, + repo, + number, +}: { + owner: string; + repo: string; + number: number; +}) { + return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { + owner, + repo, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + pull_number: number, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + per_page: 100, + }); +} + +export default { + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, +}; diff --git a/scripts/libs/GitHubWorkflowUtils.ts b/scripts/libs/GitHubWorkflowUtils.ts new file mode 100644 index 0000000..593a029 --- /dev/null +++ b/scripts/libs/GitHubWorkflowUtils.ts @@ -0,0 +1,41 @@ +import { appendFileSync } from "node:fs"; + +function escapeWorkflowCommandValue(value: string): string { + return value + .replaceAll("%", "%25") + .replaceAll("\r", "%0D") + .replaceAll("\n", "%0A"); +} + +function escapeWorkflowCommandProperty(value: string): string { + return escapeWorkflowCommandValue(value) + .replaceAll(":", "%3A") + .replaceAll(",", "%2C"); +} + +function writeStepSummary(title: string, message: string): void { + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!stepSummaryPath) { + return; + } + appendFileSync( + stepSummaryPath, + `## ${title}\n\n${message.replaceAll("\n", "\n\n")}\n`, + ); +} + +function emitFailure(error: unknown, title = "Workflow step failed"): never { + const message = error instanceof Error ? error.message : String(error); + writeStepSummary(title, message); + console.error( + `::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`, + ); + process.exit(1); +} + +export default { + emitFailure, + escapeWorkflowCommandProperty, + escapeWorkflowCommandValue, + writeStepSummary, +}; diff --git a/scripts/libs/github/CONST.ts b/scripts/libs/github/CONST.ts deleted file mode 100644 index 3009fe7..0000000 --- a/scripts/libs/github/CONST.ts +++ /dev/null @@ -1,11 +0,0 @@ -const BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); - -const EXPENSIFY_ORG = 'Expensify'; - -const EXPENSIFY_EMPLOYEE_TEAM_SLUG = 'expensify-expensify'; - -export default { - BOT_USERS, - EXPENSIFY_ORG, - EXPENSIFY_EMPLOYEE_TEAM_SLUG, -}; diff --git a/scripts/libs/github/GitHubAPIClient.ts b/scripts/libs/github/GitHubAPIClient.ts deleted file mode 100644 index 76bc1b2..0000000 --- a/scripts/libs/github/GitHubAPIClient.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {graphql as createGraphql} from '@octokit/graphql'; -import {Octokit} from '@octokit/rest'; -import {paginateRest} from '@octokit/plugin-paginate-rest'; -import type {PaginateInterface} from '@octokit/plugin-paginate-rest'; -import {throttling} from '@octokit/plugin-throttling'; - -type GraphqlQuery = (query: string, variables?: Record) => Promise; -type GraphqlHandler = (query: string, variables?: Record) => Promise; - -type InternalOctokit = InstanceType; - -const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); - -class GitHubAPIClient { - static internalOctokit: InternalOctokit | undefined; - - static graphqlClient: GraphqlHandler | undefined; - - static initWithToken(token: string): void { - this.internalOctokit = new OctokitWithPlugins({ - auth: token, - throttle: { - retryAfterBaseValue: 2000, - onRateLimit: (retryAfter, options) => { - console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); - - if (options.request.retryCount <= 5) { - console.warn(`Retrying after ${retryAfter} seconds!`); - return true; - } - - return false; - }, - onSecondaryRateLimit: (retryAfter, options) => { - console.warn(`Abuse detected for request ${options.method} ${options.url}`); - }, - }, - }); - - this.graphqlClient = createGraphql.defaults({ - headers: { - authorization: `token ${token}`, - }, - }); - } - - static init(): void { - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (!token) { - throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); - } - - this.initWithToken(token); - } - - private static ensureOctokit(): InternalOctokit { - if (!this.internalOctokit) { - this.init(); - } - - if (!this.internalOctokit) { - throw new Error('Failed to initialize GitHub API client'); - } - - return this.internalOctokit; - } - - private static ensureGraphqlClient(): GraphqlHandler { - if (!this.graphqlClient) { - this.init(); - } - - if (!this.graphqlClient) { - throw new Error('Failed to initialize GitHub GraphQL client'); - } - - return this.graphqlClient; - } - - static get octokit(): InternalOctokit['rest'] { - return this.ensureOctokit().rest; - } - - static get graphql(): GraphqlQuery { - const handler = this.ensureGraphqlClient(); - return (query: string, variables?: Record): Promise => - // GraphQL responses are typed at the call site; the handler returns untyped JSON. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic graphql boundary - handler(query, variables) as Promise; - } - - static get paginate(): PaginateInterface { - return this.ensureOctokit().paginate; - } -} - -export default GitHubAPIClient; diff --git a/scripts/libs/peerReview/coAuthors.ts b/scripts/libs/peerReview/coAuthors.ts deleted file mode 100644 index 6736fa9..0000000 --- a/scripts/libs/peerReview/coAuthors.ts +++ /dev/null @@ -1,81 +0,0 @@ -import CONST from '../github/CONST'; -import WorkflowOutput from './workflowOutput'; - -type PeerReviewCommit = { - author: { - login?: string; - } | null; - commit: { - message: string; - author?: { - name?: string | null; - } | null; - }; -}; - -function coAuthorEmails(message: string): string[] { - return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); -} - -function resolveCoAuthorLogin(email: string): string | null { - const normalizedEmail = email.trim(); - return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; -} - -function isExpensifyEmail(email: string): boolean { - return email.trim().toLowerCase().endsWith('@expensify.com'); -} - -function getCanonicalAuthorLogin(commit: PeerReviewCommit): string { - const authorLogin = commit.author?.login ?? ''; - if (authorLogin) { - return authorLogin; - } - - // If the author's profile is private, author.login may be missing. Fall back to the commit author name. - return commit.commit.author?.name?.trim() ?? ''; -} - -function getCommitAuthors(commits: PeerReviewCommit[]): { - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; -} { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); - - for (const commit of commits) { - const canonicalAuthor = getCanonicalAuthorLogin(commit); - if (canonicalAuthor) { - authors.add(canonicalAuthor); - } - - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !CONST.BOT_USERS.has(canonicalAuthor)) { - continue; - } - - for (const email of coAuthorEmails(commit.commit.message)) { - const login = resolveCoAuthorLogin(email); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(email)) { - // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. - unresolvedExpensifyCoAuthors.add(email.trim()); - } - } - } - - return { - authors: WorkflowOutput.unique([...authors]), - unresolvedExpensifyCoAuthors: WorkflowOutput.unique([...unresolvedExpensifyCoAuthors]), - }; -} - -export type {PeerReviewCommit}; - -export default { - coAuthorEmails, - getCommitAuthors, - resolveCoAuthorLogin, -}; diff --git a/scripts/libs/peerReview/eventContext.ts b/scripts/libs/peerReview/eventContext.ts deleted file mode 100644 index cc7bf36..0000000 --- a/scripts/libs/peerReview/eventContext.ts +++ /dev/null @@ -1,42 +0,0 @@ -import CLI from 'expensify-common/CLI'; -import type {PullRequestContext} from './types'; - -function parsePullRequestNumber(value: string): number { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error('Must be a positive integer'); - } - return number; -} - -function getPullRequestContext(): PullRequestContext { - const cli = new CLI({ - namedArgs: { - owner: { - description: 'Repository owner organization or user login', - }, - repo: { - description: 'Repository name', - }, - number: { - description: 'Pull request number', - parse: parsePullRequestNumber, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names - 'base-ref': { - description: 'Target branch ref for the pull request', - }, - }, - }); - - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs.number, - baseRef: cli.namedArgs['base-ref'], - }; -} - -export default { - getPullRequestContext, -}; diff --git a/scripts/libs/peerReview/githubApi.ts b/scripts/libs/peerReview/githubApi.ts deleted file mode 100644 index 25738f8..0000000 --- a/scripts/libs/peerReview/githubApi.ts +++ /dev/null @@ -1,208 +0,0 @@ -import {RequestError} from '@octokit/request-error'; -import CONST from '../github/CONST'; -import GitHubAPIClient from '../github/GitHubAPIClient'; -import Policy from './policy'; -import type {PullRequestContext} from './types'; -import WorkflowOutput from './workflowOutput'; - -type BranchProtectionResponse = { - repository: { - ref: { - branchProtectionRule: { - requiredApprovingReviewCount: number; - } | null; - } | null; - } | null; -}; - -type OpinionatedReviewNode = { - state: string; - author: { - login: string; - } | null; -}; - -type LatestOpinionatedReviewsResponse = { - repository: { - pullRequest: { - latestOpinionatedReviews: { - nodes: OpinionatedReviewNode[]; - }; - } | null; - } | null; -}; - -type TeamMembersResponse = { - organization: { - team: { - members: { - pageInfo: { - hasNextPage: boolean; - endCursor: string | null; - }; - nodes: Array<{ - login: string; - }>; - }; - } | null; - } | null; -}; - -type GraphQLErrorResponse = { - errors?: Array<{ - type?: string; - message?: string; - }>; -}; - -function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { - return typeof error === 'object' && error !== null && 'errors' in error; -} - -function isPermissionError(error: unknown): boolean { - if (error instanceof RequestError) { - return error.status === 401 || error.status === 403; - } - - if (isGraphQLErrorResponse(error)) { - const {errors} = error; - return errors?.some((graphQLError) => graphQLError.type === 'FORBIDDEN' || graphQLError.type === 'INSUFFICIENT_SCOPES') ?? false; - } - - const message = error instanceof Error ? error.message : String(error); - return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test(message); -} - -async function getRequiredApprovingReviewCount({owner, repo, baseRef}: PullRequestContext): Promise { - try { - const response = await GitHubAPIClient.graphql( - ` - query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $branchRef) { - branchProtectionRule { - requiredApprovingReviewCount - } - } - } - } - `, - { - owner, - repo, - branchRef: `refs/heads/${baseRef}`, - }, - ); - - return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`); - } - - const message = error instanceof Error ? error.message : String(error); - console.warn( - `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, - ); - return Policy.DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; - } -} - -async function getLatestApprovers({owner, repo, number}: PullRequestContext): Promise { - const response = await GitHubAPIClient.graphql( - ` - query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - latestOpinionatedReviews(last: 100, writersOnly: true) { - nodes { - state - author { - login - } - } - } - } - } - } - `, - { - owner, - repo, - prNumber: number, - }, - ); - - const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return WorkflowOutput.unique( - reviews - .filter((review) => review.state === 'APPROVED') - .map((review) => review.author?.login ?? '') - .filter((login) => login !== ''), - ); -} - -async function getEmployeeLogins(): Promise> { - const employeeLogins = new Set(); - - async function collectPage(cursor: string | null): Promise { - const response: TeamMembersResponse = await GitHubAPIClient.graphql( - ` - query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { - organization(login: $organization) { - team(slug: $teamSlug) { - members(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - login - } - } - } - } - } - `, - { - organization: CONST.EXPENSIFY_ORG, - teamSlug: CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG, - cursor, - }, - ); - - const members = response.organization?.team?.members; - if (!members) { - throw new Error(`${CONST.EXPENSIFY_ORG}/${CONST.EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`); - } - - for (const member of members.nodes) { - employeeLogins.add(member.login); - } - - if (members.pageInfo.hasNextPage) { - await collectPage(members.pageInfo.endCursor); - } - } - - await collectPage(null); - return employeeLogins; -} - -async function listPullRequestCommits(context: PullRequestContext) { - return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { - owner: context.owner, - repo: context.repo, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - pull_number: context.number, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - per_page: 100, - }); -} - -export default { - getEmployeeLogins, - getLatestApprovers, - getRequiredApprovingReviewCount, - listPullRequestCommits, -}; diff --git a/scripts/libs/peerReview/policy.ts b/scripts/libs/peerReview/policy.ts deleted file mode 100644 index e3d7655..0000000 --- a/scripts/libs/peerReview/policy.ts +++ /dev/null @@ -1,69 +0,0 @@ -import CONST from '../github/CONST'; -import WorkflowOutput from './workflowOutput'; -import type {PeerReviewInput, PeerReviewResult} from './types'; - -const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; - -function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { - const authorSet = new Set(authors); - return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); -} - -function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { - const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; - - if (requiredApprovingReviewCount === 0) { - return { - status: 'skip', - reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, - }; - } - - if (approvers.length === 0) { - return { - status: 'skip', - reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - }; - } - - if (unresolvedExpensifyCoAuthors.length > 0) { - return { - status: 'fail', - error: new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${WorkflowOutput.formatUsers(unresolvedExpensifyCoAuthors)}`), - }; - } - - if (authors.every((author) => CONST.BOT_USERS.has(author))) { - return { - status: 'fail', - error: new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`), - }; - } - - const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { - return { - status: 'fail', - error: new Error( - [ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${WorkflowOutput.formatUsers(authors)}`, - `Approvers: ${WorkflowOutput.formatUsers(approvers)}`, - `Independent employee approvers: ${WorkflowOutput.formatUsers(independentEmployeeApprovers)}`, - ].join('\n'), - ), - }; - } - - return { - status: 'pass', - reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, - }; -} - -export default { - DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT, - evaluatePeerReview, - getIndependentEmployeeApprovers, -}; diff --git a/scripts/libs/peerReview/types.ts b/scripts/libs/peerReview/types.ts deleted file mode 100644 index 0d7b6c4..0000000 --- a/scripts/libs/peerReview/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -type PullRequestContext = { - owner: string; - repo: string; - number: number; - baseRef: string; -}; - -type PeerReviewInput = { - owner: string; - repo: string; - number: number; - baseRef: string; - requiredApprovingReviewCount: number; - approvers: string[]; - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; - employeeLogins: Set; -}; - -type PeerReviewResult = {status: 'pass'; reason: string} | {status: 'skip'; reason: string} | {status: 'fail'; error: Error}; - -export type {PeerReviewInput, PeerReviewResult, PullRequestContext}; diff --git a/scripts/libs/peerReview/workflowOutput.ts b/scripts/libs/peerReview/workflowOutput.ts deleted file mode 100644 index 770f7d6..0000000 --- a/scripts/libs/peerReview/workflowOutput.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {appendFileSync} from 'node:fs'; - -function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(', ') : '(none)'; -} - -function unique(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); -} - -function escapeWorkflowCommandValue(value: string): string { - return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); -} - -function escapeWorkflowCommandProperty(value: string): string { - return escapeWorkflowCommandValue(value).replaceAll(':', '%3A').replaceAll(',', '%2C'); -} - -function getFailureTitle(message: string): string { - if (message.includes('does not have enough independent Expensify employee approvals')) { - return 'Missing independent peer review'; - } - if (message.includes('Unable to resolve Expensify co-author emails')) { - return 'Unresolved Expensify co-author'; - } - if (message.includes('has no human commit authors or co-authors')) { - return 'No human commit author'; - } - if (message.includes('Unable to read branch protection rules')) { - return 'Branch protection lookup failed'; - } - return 'Peer review verification failed'; -} - -function writeStepSummary(title: string, message: string): void { - const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!stepSummaryPath) { - return; - } - appendFileSync(stepSummaryPath, `## ${title}\n\n${message.replaceAll('\n', '\n\n')}\n`); -} - -function emitFailure(error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - const title = getFailureTitle(message); - writeStepSummary(title, message); - console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); - process.exit(1); -} - -export default { - emitFailure, - escapeWorkflowCommandProperty, - escapeWorkflowCommandValue, - formatUsers, - getFailureTitle, - unique, - writeStepSummary, -}; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 130a5f5..d38b4fe 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,47 +1,272 @@ -import CoAuthors from './libs/peerReview/coAuthors'; -import EventContext from './libs/peerReview/eventContext'; -import PeerReviewGitHubApi from './libs/peerReview/githubApi'; -import Policy from './libs/peerReview/policy'; -import WorkflowOutput from './libs/peerReview/workflowOutput'; +import CLI from "expensify-common/CLI"; +import GitCommitUtils, { + type GitHubPullRequestCommit, +} from "./libs/GitCommitUtils"; +import GitHubUtils from "./libs/GitHubUtils"; +import GitHubWorkflowUtils from "./libs/GitHubWorkflowUtils"; -async function main(): Promise { - const context = EventContext.getPullRequestContext(); - const {owner, repo, number, baseRef} = context; - - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), - PeerReviewGitHubApi.getLatestApprovers(context), - PeerReviewGitHubApi.listPullRequestCommits(context), - ]); - - const {authors, unresolvedExpensifyCoAuthors} = CoAuthors.getCommitAuthors(commits); - - const employeeLogins = approvers.length > 0 && requiredApprovingReviewCount > 0 ? await PeerReviewGitHubApi.getEmployeeLogins() : new Set(); - - const result = Policy.evaluatePeerReview({ - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - }); - - if (result.status === 'skip' || result.status === 'pass') { - console.log(result.reason); - return; +const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); + +type PullRequestContext = { + owner: string; + repo: string; + number: number; + baseRef: string; +}; + +type PeerReviewInput = { + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; +}; + +type PeerReviewResult = + | { status: "pass"; reason: string } + | { status: "skip"; reason: string } + | { status: "fail"; error: Error }; + +function formatUsers(users: string[]): string { + return users.length > 0 ? users.join(", ") : "(none)"; +} + +function unique(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +function getPullRequestContext(): PullRequestContext { + const cli = new CLI({ + namedArgs: { + owner: { + description: "Repository owner organization or user login", + }, + repo: { + description: "Repository name", + }, + number: { + description: "Pull request number", + parse: (value: string) => { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("Must be a positive integer"); + } + return number; + }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names + "base-ref": { + description: "Target branch ref for the pull request", + }, + }, + }); + + return { + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs["base-ref"], + }; +} + +function isExpensifyEmail(email: string): boolean { + return email.trim().toLowerCase().endsWith("@expensify.com"); +} + +function getCommitAuthors(commits: GitHubPullRequestCommit[]): { + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; +} { + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + if (canonicalAuthor) { + authors.add(canonicalAuthor); } - throw result.error; + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is missing or is a bot. + if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { + continue; + } + + for (const email of GitCommitUtils.coAuthorEmails(commit.commit.message)) { + const login = GitCommitUtils.resolveNoreplyEmailToLogin(email); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(email)) { + // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. + unresolvedExpensifyCoAuthors.add(email.trim()); + } + } + } + + return { + authors: unique([...authors]), + unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + }; +} + +function getIndependentEmployeeApprovers( + approvers: string[], + authors: string[], + employeeLogins: Set, +): string[] { + const authorSet = new Set(authors); + return approvers.filter( + (approver) => !authorSet.has(approver) && employeeLogins.has(approver), + ); } +function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { + const { + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + } = input; + + if (requiredApprovingReviewCount === 0) { + return { + status: "skip", + reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + }; + } + + if (approvers.length === 0) { + return { + status: "skip", + reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } + + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: "fail", + error: new Error( + `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, + ), + }; + } + + if (authors.every((author) => BOT_USERS.has(author))) { + return { + status: "fail", + error: new Error( + `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, + ), + }; + } + + const independentEmployeeApprovers = getIndependentEmployeeApprovers( + approvers, + authors, + employeeLogins, + ); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + return { + status: "fail", + error: new Error( + [ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join("\n"), + ), + }; + } + + return { + status: "pass", + reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + }; +} + +function getFailureTitle(message: string): string { + if ( + message.includes( + "does not have enough independent Expensify employee approvals", + ) + ) { + return "Missing independent peer review"; + } + if (message.includes("Unable to resolve Expensify co-author emails")) { + return "Unresolved Expensify co-author"; + } + if (message.includes("has no human commit authors or co-authors")) { + return "No human commit author"; + } + if (message.includes("Unable to read branch protection rules")) { + return "Branch protection lookup failed"; + } + return "Peer review verification failed"; +} + +function emitPeerReviewFailure(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + return GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); +} + +async function main(): Promise { + const { owner, repo, number, baseRef } = getPullRequestContext(); + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + GitHubUtils.getRequiredApprovingReviewCount({ owner, repo, baseRef }), + GitHubUtils.getLatestApprovers({ owner, repo, number }), + GitHubUtils.listPullRequestCommits({ owner, repo, number }), + ]); + + const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors(commits); + + const employeeLogins = + approvers.length > 0 && requiredApprovingReviewCount > 0 + ? await GitHubUtils.getEmployeeLogins() + : new Set(); + + const result = evaluatePeerReview({ + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + }); + + if (result.status === "skip" || result.status === "pass") { + console.log(result.reason); + return; + } + + throw result.error; +} + +export type { PeerReviewInput, PeerReviewResult, PullRequestContext }; + export default { - main, + main, + getPullRequestContext, + evaluatePeerReview, + getIndependentEmployeeApprovers, + getCommitAuthors, + getFailureTitle, }; if (import.meta.main) { - main().catch(WorkflowOutput.emitFailure); + main().catch(emitPeerReviewFailure); } diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts new file mode 100644 index 0000000..c9520bf --- /dev/null +++ b/tests/GitCommitUtils.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import GitCommitUtils, { + type GitHubPullRequestCommit, +} from "../scripts/libs/GitCommitUtils"; + +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, +): GitHubPullRequestCommit { + return { + author: authorLogin ? { login: authorLogin } : null, + commit: { + message, + author: authorName ? { name: authorName } : {}, + }, + }; +} + +describe("resolveNoreplyEmailToLogin", () => { + it("parses standard noreply addresses", () => { + assert.equal( + GitCommitUtils.resolveNoreplyEmailToLogin( + "AndrewGable@users.noreply.github.com", + ), + "AndrewGable", + ); + }); + + it("parses numeric noreply prefixes", () => { + assert.equal( + GitCommitUtils.resolveNoreplyEmailToLogin( + "2838819+AndrewGable@users.noreply.github.com", + ), + "AndrewGable", + ); + }); +}); + +describe("coAuthorEmails", () => { + it("extracts multiple co-author emails", () => { + const message = [ + "Some change", + "", + "Co-authored-by: Andrew Gable ", + "Co-authored-by: Monil Bhavsar ", + ].join("\n"); + + assert.deepEqual(GitCommitUtils.coAuthorEmails(message), [ + "AndrewGable@users.noreply.github.com", + "MonilBhavsar@users.noreply.github.com", + ]); + }); +}); + +describe("getCanonicalAuthorLogin", () => { + it("falls back to commit author name when github login is missing", () => { + assert.equal( + GitCommitUtils.getCanonicalAuthorLogin( + makeCommit(undefined, "AndrewGable", "Change"), + ), + "AndrewGable", + ); + }); +}); diff --git a/tests/GitHubUtils.test.ts b/tests/GitHubUtils.test.ts new file mode 100644 index 0000000..d3575a3 --- /dev/null +++ b/tests/GitHubUtils.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { RequestError } from "@octokit/request-error"; +import GitHubAPIClient from "../scripts/libs/GitHubAPIClient"; +import GitHubUtils from "../scripts/libs/GitHubUtils"; + +const context = { + owner: "Expensify", + repo: "Auth", + number: 1, + baseRef: "main", +}; + +describe("getRequiredApprovingReviewCount", () => { + afterEach(() => { + GitHubAPIClient.internalOctokit = undefined; + GitHubAPIClient.graphqlClient = undefined; + }); + + it("returns 0 when branch protection rule is missing", async () => { + GitHubAPIClient.graphqlClient = async () => ({ + repository: { + ref: { + branchProtectionRule: null, + }, + }, + }); + + const count = await GitHubUtils.getRequiredApprovingReviewCount({ + ...context, + baseRef: "staging", + }); + assert.equal(count, 0); + }); + + it("throws on permission errors", async () => { + GitHubAPIClient.graphqlClient = async () => { + throw new RequestError("Resource not accessible by integration", 403, { + request: { + method: "POST", + url: "https://api.github.com/graphql", + headers: {}, + }, + }); + }; + + await assert.rejects( + () => GitHubUtils.getRequiredApprovingReviewCount(context), + /Unable to read branch protection rules/, + ); + }); +}); diff --git a/tests/GitHubWorkflowUtils.test.ts b/tests/GitHubWorkflowUtils.test.ts new file mode 100644 index 0000000..6c255ee --- /dev/null +++ b/tests/GitHubWorkflowUtils.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import GitHubWorkflowUtils from "../scripts/libs/GitHubWorkflowUtils"; + +describe("GitHubWorkflowUtils helpers", () => { + it("escapes workflow command values", () => { + assert.equal( + GitHubWorkflowUtils.escapeWorkflowCommandValue("a\nb%"), + "a%0Ab%25", + ); + }); + + it("escapes workflow command properties", () => { + assert.equal( + GitHubWorkflowUtils.escapeWorkflowCommandProperty("title:one,two"), + "title%3Aone%2Ctwo", + ); + }); +}); diff --git a/tests/peerReview/coAuthors.test.ts b/tests/peerReview/coAuthors.test.ts deleted file mode 100644 index e97f132..0000000 --- a/tests/peerReview/coAuthors.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import CoAuthors, {type PeerReviewCommit} from '../../scripts/libs/peerReview/coAuthors'; - -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): PeerReviewCommit { - return { - author: authorLogin ? {login: authorLogin} : null, - commit: { - message, - author: authorName ? {name: authorName} : {}, - }, - }; -} - -describe('resolveCoAuthorLogin', () => { - it('parses standard noreply addresses', () => { - assert.equal(CoAuthors.resolveCoAuthorLogin('AndrewGable@users.noreply.github.com'), 'AndrewGable'); - }); - - it('parses numeric noreply prefixes', () => { - assert.equal(CoAuthors.resolveCoAuthorLogin('2838819+AndrewGable@users.noreply.github.com'), 'AndrewGable'); - }); -}); - -describe('coAuthorEmails', () => { - it('extracts multiple co-author emails', () => { - const message = [ - 'Some change', - '', - 'Co-authored-by: Andrew Gable ', - 'Co-authored-by: Monil Bhavsar ', - ].join('\n'); - - assert.deepEqual(CoAuthors.coAuthorEmails(message), ['AndrewGable@users.noreply.github.com', 'MonilBhavsar@users.noreply.github.com']); - }); -}); - -describe('getCommitAuthors', () => { - it('counts co-authors for bot-authored commits', () => { - const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - - assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); - - it('ignores co-authors when canonical author is human', () => { - const result = CoAuthors.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - - assert.deepEqual(result.authors, ['rafecolton']); - }); - - it('falls back to commit author name when github login is missing', () => { - const result = CoAuthors.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); - - assert.deepEqual(result.authors, ['AndrewGable']); - }); - - it('normalizes expensify email casing and whitespace for unresolved detection', () => { - const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >')]); - - assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); - }); - - it('collects unresolved expensify co-author emails', () => { - const result = CoAuthors.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - - assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); - }); -}); diff --git a/tests/peerReview/eventContext.test.ts b/tests/peerReview/eventContext.test.ts deleted file mode 100644 index 4c499b3..0000000 --- a/tests/peerReview/eventContext.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'node:assert/strict'; -import {afterEach, beforeEach, describe, it} from 'node:test'; -import EventContext from '../../scripts/libs/peerReview/eventContext'; - -const ORIGINAL_ARGV = process.argv; - -describe('getPullRequestContext', () => { - let originalExit: typeof process.exit; - - beforeEach(() => { - process.argv = ['tsx', 'scripts/verifyPeerReview.ts']; - originalExit = process.exit.bind(process); - process.exit = (code?: string | number | null) => { - throw new Error(`exit ${code ?? 0}`); - }; - }); - - afterEach(() => { - process.argv = ORIGINAL_ARGV; - process.exit = originalExit; - }); - - it('parses required pull request CLI arguments', () => { - process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--number', '21136', '--base-ref', 'main'); - - assert.deepEqual(EventContext.getPullRequestContext(), { - owner: 'Expensify', - repo: 'Auth', - number: 21136, - baseRef: 'main', - }); - }); - - it('fails when required arguments are missing', () => { - assert.throws(() => EventContext.getPullRequestContext(), /exit 1/); - }); -}); diff --git a/tests/peerReview/githubApi.test.ts b/tests/peerReview/githubApi.test.ts deleted file mode 100644 index 1df45db..0000000 --- a/tests/peerReview/githubApi.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import {describe, it, afterEach} from 'node:test'; -import {RequestError} from '@octokit/request-error'; -import GitHubAPIClient from '../../scripts/libs/github/GitHubAPIClient'; -import PeerReviewGitHubApi from '../../scripts/libs/peerReview/githubApi'; - -const context = { - owner: 'Expensify', - repo: 'Auth', - number: 1, - baseRef: 'main', -}; - -describe('getRequiredApprovingReviewCount', () => { - afterEach(() => { - GitHubAPIClient.internalOctokit = undefined; - GitHubAPIClient.graphqlClient = undefined; - }); - - it('returns 0 when branch protection rule is missing', async () => { - GitHubAPIClient.graphqlClient = async () => ({ - repository: { - ref: { - branchProtectionRule: null, - }, - }, - }); - - const count = await PeerReviewGitHubApi.getRequiredApprovingReviewCount({ - ...context, - baseRef: 'staging', - }); - assert.equal(count, 0); - }); - - it('throws on permission errors', async () => { - GitHubAPIClient.graphqlClient = async () => { - throw new RequestError('Resource not accessible by integration', 403, { - request: { - method: 'POST', - url: 'https://api.github.com/graphql', - headers: {}, - }, - }); - }; - - await assert.rejects(() => PeerReviewGitHubApi.getRequiredApprovingReviewCount(context), /Unable to read branch protection rules/); - }); -}); diff --git a/tests/peerReview/policy.test.ts b/tests/peerReview/policy.test.ts deleted file mode 100644 index 6b48ccd..0000000 --- a/tests/peerReview/policy.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import Policy from '../../scripts/libs/peerReview/policy'; -import type {PeerReviewInput} from '../../scripts/libs/peerReview/types'; - -const baseInput: PeerReviewInput = { - owner: 'Expensify', - repo: 'Auth', - number: 21136, - baseRef: 'main', - requiredApprovingReviewCount: 1, - approvers: [], - authors: [], - unresolvedExpensifyCoAuthors: [], - employeeLogins: new Set(['MonilBhavsar', 'AndrewGable', 'rafecolton']), -}; - -describe('getIndependentEmployeeApprovers', () => { - it('excludes commit authors and non-employees', () => { - const independent = Policy.getIndependentEmployeeApprovers(['AndrewGable', 'MonilBhavsar', 'externalCollab'], ['AndrewGable'], new Set(['AndrewGable', 'MonilBhavsar'])); - - assert.deepEqual(independent, ['MonilBhavsar']); - }); -}); - -describe('evaluatePeerReview', () => { - it('skips when branch requires no approving reviews', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 0, - approvers: ['AndrewGable'], - authors: ['AndrewGable'], - }); - - assert.equal(result.status, 'skip'); - }); - - it('skips when there are no approving reviews yet', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: [], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'skip'); - }); - - it('fails on self-review from commit co-author', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /does not have enough independent Expensify employee approvals/); - } - }); - - it('passes when an independent employee approves', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ['MonilBhavsar', 'AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'pass'); - }); - - it('fails when independent approver count is below required', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ['MonilBhavsar', 'AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'fail'); - }); - - it('passes when independent approver count meets required', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ['MonilBhavsar', 'rafecolton'], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'pass'); - }); - - it('fails when all authors are bots', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /no human commit authors or co-authors/); - } - }); - - it('fails on unresolved expensify co-author emails', () => { - const result = Policy.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot'], - unresolvedExpensifyCoAuthors: ['andrew@expensify.com'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /Unable to resolve Expensify co-author emails/); - } - }); -}); diff --git a/tests/peerReview/workflowOutput.test.ts b/tests/peerReview/workflowOutput.test.ts deleted file mode 100644 index af31214..0000000 --- a/tests/peerReview/workflowOutput.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import WorkflowOutput from '../../scripts/libs/peerReview/workflowOutput'; - -describe('workflowOutput helpers', () => { - it('formats empty user lists', () => { - assert.equal(WorkflowOutput.formatUsers([]), '(none)'); - }); - - it('escapes workflow command values', () => { - assert.equal(WorkflowOutput.escapeWorkflowCommandValue('a\nb%'), 'a%0Ab%25'); - }); - - it('escapes workflow command properties', () => { - assert.equal(WorkflowOutput.escapeWorkflowCommandProperty('title:one,two'), 'title%3Aone%2Ctwo'); - }); - - it('maps failure titles for known messages', () => { - assert.equal(WorkflowOutput.getFailureTitle('Expensify/Auth#1 does not have enough independent Expensify employee approvals.'), 'Missing independent peer review'); - assert.equal(WorkflowOutput.getFailureTitle('Unable to read branch protection rules for Expensify/Auth@main.'), 'Branch protection lookup failed'); - }); -}); diff --git a/tests/verifyPeerReviewCli.test.ts b/tests/verifyPeerReviewCli.test.ts new file mode 100644 index 0000000..4e35ecb --- /dev/null +++ b/tests/verifyPeerReviewCli.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import VerifyPeerReview from "../scripts/verifyPeerReview"; + +const ORIGINAL_ARGV = process.argv; + +describe("getPullRequestContext", () => { + let originalExit: typeof process.exit; + + beforeEach(() => { + process.argv = ["tsx", "scripts/verifyPeerReview.ts"]; + originalExit = process.exit.bind(process); + process.exit = (code?: string | number | null) => { + throw new Error(`exit ${code ?? 0}`); + }; + }); + + afterEach(() => { + process.argv = ORIGINAL_ARGV; + process.exit = originalExit; + }); + + it("parses required pull request CLI arguments", () => { + process.argv.push( + "--owner", + "Expensify", + "--repo", + "Auth", + "--number", + "21136", + "--base-ref", + "main", + ); + + assert.deepEqual(VerifyPeerReview.getPullRequestContext(), { + owner: "Expensify", + repo: "Auth", + number: 21136, + baseRef: "main", + }); + }); + + it("fails when required arguments are missing", () => { + assert.throws(() => VerifyPeerReview.getPullRequestContext(), /exit 1/); + }); +}); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts new file mode 100644 index 0000000..ca00433 --- /dev/null +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GitHubPullRequestCommit } from "../scripts/libs/GitCommitUtils"; +import VerifyPeerReview from "../scripts/verifyPeerReview"; + +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, +): GitHubPullRequestCommit { + return { + author: authorLogin ? { login: authorLogin } : null, + commit: { + message, + author: authorName ? { name: authorName } : {}, + }, + }; +} + +describe("getCommitAuthors", () => { + it("counts co-authors for bot-authored commits", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); + + it("ignores co-authors when canonical author is human", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit( + "rafecolton", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.authors, ["rafecolton"]); + }); + + it("falls back to commit author name when github login is missing", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit(undefined, "AndrewGable", "Change"), + ]); + + assert.deepEqual(result.authors, ["AndrewGable"]); + }); + + it("normalizes expensify email casing and whitespace for unresolved detection", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >", + ), + ]); + + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "Andrew@Expensify.com", + ]); + }); + + it("collects unresolved expensify co-author emails", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); + + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "andrew@expensify.com", + ]); + }); +}); diff --git a/tests/verifyPeerReviewPolicy.test.ts b/tests/verifyPeerReviewPolicy.test.ts new file mode 100644 index 0000000..33f3dad --- /dev/null +++ b/tests/verifyPeerReviewPolicy.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import VerifyPeerReview, { + type PeerReviewInput, +} from "../scripts/verifyPeerReview"; + +const baseInput: PeerReviewInput = { + owner: "Expensify", + repo: "Auth", + number: 21136, + baseRef: "main", + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), +}; + +describe("getIndependentEmployeeApprovers", () => { + it("excludes commit authors and non-employees", () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers( + ["AndrewGable", "MonilBhavsar", "externalCollab"], + ["AndrewGable"], + new Set(["AndrewGable", "MonilBhavsar"]), + ); + + assert.deepEqual(independent, ["MonilBhavsar"]); + }); +}); + +describe("evaluatePeerReview", () => { + it("skips when branch requires no approving reviews", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ["AndrewGable"], + authors: ["AndrewGable"], + }); + + assert.equal(result.status, "skip"); + }); + + it("skips when there are no approving reviews yet", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "skip"); + }); + + it("fails on self-review from commit co-author", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /does not have enough independent Expensify employee approvals/, + ); + } + }); + + it("passes when an independent employee approves", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "pass"); + }); + + it("fails when independent approver count is below required", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "fail"); + }); + + it("passes when independent approver count meets required", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "rafecolton"], + authors: ["MelvinBot", "AndrewGable"], + }); + + assert.equal(result.status, "pass"); + }); + + it("fails when all authors are bots", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], + }); + + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /no human commit authors or co-authors/, + ); + } + }); + + it("fails on unresolved expensify co-author emails", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], + unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], + }); + + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /Unable to resolve Expensify co-author emails/, + ); + } + }); +}); + +describe("getFailureTitle", () => { + it("maps failure titles for known messages", () => { + assert.equal( + VerifyPeerReview.getFailureTitle( + "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", + ), + "Missing independent peer review", + ); + assert.equal( + VerifyPeerReview.getFailureTitle( + "Unable to read branch protection rules for Expensify/Auth@main.", + ), + "Branch protection lookup failed", + ); + }); +}); From e243313132fd17d345c96681d64d207ace527598 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:46:42 -0700 Subject: [PATCH 47/63] refactor: rename coAuthorEmails to parseCoAuthorEmails Use a verb in the method name to match naming conventions. Co-authored-by: Cursor --- scripts/libs/GitCommitUtils.ts | 4 ++-- scripts/verifyPeerReview.ts | 2 +- tests/GitCommitUtils.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 531518f..7b3be78 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -10,7 +10,7 @@ type GitHubPullRequestCommit = { }; }; -function coAuthorEmails(message: string): string[] { +function parseCoAuthorEmails(message: string): string[] { return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim(), ); @@ -38,7 +38,7 @@ function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { export type { GitHubPullRequestCommit }; export default { - coAuthorEmails, + parseCoAuthorEmails, getCanonicalAuthorLogin, resolveNoreplyEmailToLogin, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index d38b4fe..2bd99a8 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -96,7 +96,7 @@ function getCommitAuthors(commits: GitHubPullRequestCommit[]): { continue; } - for (const email of GitCommitUtils.coAuthorEmails(commit.commit.message)) { + for (const email of GitCommitUtils.parseCoAuthorEmails(commit.commit.message)) { const login = GitCommitUtils.resolveNoreplyEmailToLogin(email); if (login) { authors.add(login); diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index c9520bf..a8dbc92 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -38,7 +38,7 @@ describe("resolveNoreplyEmailToLogin", () => { }); }); -describe("coAuthorEmails", () => { +describe("parseCoAuthorEmails", () => { it("extracts multiple co-author emails", () => { const message = [ "Some change", @@ -47,7 +47,7 @@ describe("coAuthorEmails", () => { "Co-authored-by: Monil Bhavsar ", ].join("\n"); - assert.deepEqual(GitCommitUtils.coAuthorEmails(message), [ + assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), [ "AndrewGable@users.noreply.github.com", "MonilBhavsar@users.noreply.github.com", ]); From 22968219fb18d5ec7e27bfbd8dccd4774c673f25 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:47:08 -0700 Subject: [PATCH 48/63] Run prettier --- scripts/libs/GitCommitUtils.ts | 48 ++- scripts/libs/GitHubAPIClient.ts | 166 +++++----- scripts/libs/GitHubUtils.ts | 282 +++++++---------- scripts/libs/GitHubWorkflowUtils.ts | 44 +-- scripts/verifyPeerReview.ts | 402 +++++++++++------------- tests/GitCommitUtils.test.ts | 88 ++---- tests/GitHubUtils.test.ts | 81 +++-- tests/GitHubWorkflowUtils.test.ts | 26 +- tests/verifyPeerReviewCli.test.ts | 63 ++-- tests/verifyPeerReviewCoAuthors.test.ts | 104 +++--- tests/verifyPeerReviewPolicy.test.ts | 217 ++++++------- 11 files changed, 657 insertions(+), 864 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 7b3be78..e52bb61 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -1,44 +1,38 @@ type GitHubPullRequestCommit = { - author: { - login?: string; - } | null; - commit: { - message: string; - author?: { - name?: string | null; + author: { + login?: string; } | null; - }; + commit: { + message: string; + author?: { + name?: string | null; + } | null; + }; }; function parseCoAuthorEmails(message: string): string[] { - return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => - match[1].trim(), - ); + return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); } function resolveNoreplyEmailToLogin(email: string): string | null { - const normalizedEmail = email.trim(); - return ( - normalizedEmail.match( - /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, - )?.[1] ?? null - ); + const normalizedEmail = email.trim(); + return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; } function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { - const authorLogin = commit.author?.login ?? ""; - if (authorLogin) { - return authorLogin; - } + const authorLogin = commit.author?.login ?? ''; + if (authorLogin) { + return authorLogin; + } - // If the author's profile is private, author.login may be missing. Fall back to the commit author name. - return commit.commit.author?.name?.trim() ?? ""; + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + return commit.commit.author?.name?.trim() ?? ''; } -export type { GitHubPullRequestCommit }; +export type {GitHubPullRequestCommit}; export default { - parseCoAuthorEmails, - getCanonicalAuthorLogin, - resolveNoreplyEmailToLogin, + parseCoAuthorEmails, + getCanonicalAuthorLogin, + resolveNoreplyEmailToLogin, }; diff --git a/scripts/libs/GitHubAPIClient.ts b/scripts/libs/GitHubAPIClient.ts index a2f71a9..735c9eb 100644 --- a/scripts/libs/GitHubAPIClient.ts +++ b/scripts/libs/GitHubAPIClient.ts @@ -1,110 +1,98 @@ -import { graphql as createGraphql } from "@octokit/graphql"; -import { Octokit } from "@octokit/rest"; -import { paginateRest } from "@octokit/plugin-paginate-rest"; -import type { PaginateInterface } from "@octokit/plugin-paginate-rest"; -import { throttling } from "@octokit/plugin-throttling"; - -type GraphqlQuery = ( - query: string, - variables?: Record, -) => Promise; -type GraphqlHandler = ( - query: string, - variables?: Record, -) => Promise; +import {graphql as createGraphql} from '@octokit/graphql'; +import {Octokit} from '@octokit/rest'; +import {paginateRest} from '@octokit/plugin-paginate-rest'; +import type {PaginateInterface} from '@octokit/plugin-paginate-rest'; +import {throttling} from '@octokit/plugin-throttling'; + +type GraphqlQuery = (query: string, variables?: Record) => Promise; + +type GraphqlHandler = (query: string, variables?: Record) => Promise; type InternalOctokit = InstanceType; const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest); class GitHubAPIClient { - static internalOctokit: InternalOctokit | undefined; - - static graphqlClient: GraphqlHandler | undefined; - - static initWithToken(token: string): void { - this.internalOctokit = new OctokitWithPlugins({ - auth: token, - throttle: { - retryAfterBaseValue: 2000, - onRateLimit: (retryAfter, options) => { - console.warn( - `Request quota exhausted for request ${options.method} ${options.url}`, - ); - - if (options.request.retryCount <= 5) { - console.warn(`Retrying after ${retryAfter} seconds!`); - return true; - } - - return false; - }, - onSecondaryRateLimit: (retryAfter, options) => { - console.warn( - `Abuse detected for request ${options.method} ${options.url}`, - ); - }, - }, - }); - - this.graphqlClient = createGraphql.defaults({ - headers: { - authorization: `token ${token}`, - }, - }); - } - - static init(): void { - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (!token) { - throw new Error("GITHUB_TOKEN or GH_TOKEN is required"); + static internalOctokit: InternalOctokit | undefined; + + static graphqlClient: GraphqlHandler | undefined; + + static initWithToken(token: string): void { + this.internalOctokit = new OctokitWithPlugins({ + auth: token, + throttle: { + retryAfterBaseValue: 2000, + onRateLimit: (retryAfter, options) => { + console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + if (options.request.retryCount <= 5) { + console.warn(`Retrying after ${retryAfter} seconds!`); + return true; + } + + return false; + }, + onSecondaryRateLimit: (retryAfter, options) => { + console.warn(`Abuse detected for request ${options.method} ${options.url}`); + }, + }, + }); + + this.graphqlClient = createGraphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); } - this.initWithToken(token); - } + static init(): void { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN or GH_TOKEN is required'); + } - private static ensureOctokit(): InternalOctokit { - if (!this.internalOctokit) { - this.init(); + this.initWithToken(token); } - if (!this.internalOctokit) { - throw new Error("Failed to initialize GitHub API client"); + private static ensureOctokit(): InternalOctokit { + if (!this.internalOctokit) { + this.init(); + } + + if (!this.internalOctokit) { + throw new Error('Failed to initialize GitHub API client'); + } + + return this.internalOctokit; } - return this.internalOctokit; - } + private static ensureGraphqlClient(): GraphqlHandler { + if (!this.graphqlClient) { + this.init(); + } - private static ensureGraphqlClient(): GraphqlHandler { - if (!this.graphqlClient) { - this.init(); + if (!this.graphqlClient) { + throw new Error('Failed to initialize GitHub GraphQL client'); + } + + return this.graphqlClient; + } + + static get octokit(): InternalOctokit['rest'] { + return this.ensureOctokit().rest; } - if (!this.graphqlClient) { - throw new Error("Failed to initialize GitHub GraphQL client"); + static get graphql(): GraphqlQuery { + const handler = this.ensureGraphqlClient(); + return (query: string, variables?: Record): Promise => + // GraphQL responses are typed at the call site; the handler returns untyped JSON. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic graphql boundary + handler(query, variables) as Promise; } - return this.graphqlClient; - } - - static get octokit(): InternalOctokit["rest"] { - return this.ensureOctokit().rest; - } - - static get graphql(): GraphqlQuery { - const handler = this.ensureGraphqlClient(); - return ( - query: string, - variables?: Record, - ): Promise => - // GraphQL responses are typed at the call site; the handler returns untyped JSON. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic graphql boundary - handler(query, variables) as Promise; - } - - static get paginate(): PaginateInterface { - return this.ensureOctokit().paginate; - } + static get paginate(): PaginateInterface { + return this.ensureOctokit().paginate; + } } export default GitHubAPIClient; diff --git a/scripts/libs/GitHubUtils.ts b/scripts/libs/GitHubUtils.ts index 0841257..bc03e86 100644 --- a/scripts/libs/GitHubUtils.ts +++ b/scripts/libs/GitHubUtils.ts @@ -1,102 +1,86 @@ -import { RequestError } from "@octokit/request-error"; -import GitHubAPIClient from "./GitHubAPIClient"; +import {RequestError} from '@octokit/request-error'; +import GitHubAPIClient from './GitHubAPIClient'; const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; -const EXPENSIFY_ORG = "Expensify"; -const EXPENSIFY_EMPLOYEE_TEAM_SLUG = "expensify-expensify"; +const EXPENSIFY_ORG = 'Expensify'; +const EXPENSIFY_EMPLOYEE_TEAM_SLUG = 'expensify-expensify'; type BranchProtectionResponse = { - repository: { - ref: { - branchProtectionRule: { - requiredApprovingReviewCount: number; - } | null; + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; } | null; - } | null; }; type OpinionatedReviewNode = { - state: string; - author: { - login: string; - } | null; + state: string; + author: { + login: string; + } | null; }; type LatestOpinionatedReviewsResponse = { - repository: { - pullRequest: { - latestOpinionatedReviews: { - nodes: OpinionatedReviewNode[]; - }; + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: OpinionatedReviewNode[]; + }; + } | null; } | null; - } | null; }; type TeamMembersResponse = { - organization: { - team: { - members: { - pageInfo: { - hasNextPage: boolean; - endCursor: string | null; - }; - nodes: Array<{ - login: string; - }>; - }; + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; } | null; - } | null; }; type GraphQLErrorResponse = { - errors?: Array<{ - type?: string; - message?: string; - }>; + errors?: Array<{ + type?: string; + message?: string; + }>; }; function uniqueSorted(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { - return typeof error === "object" && error !== null && "errors" in error; + return typeof error === 'object' && error !== null && 'errors' in error; } function isPermissionError(error: unknown): boolean { - if (error instanceof RequestError) { - return error.status === 401 || error.status === 403; - } - - if (isGraphQLErrorResponse(error)) { - const { errors } = error; - return ( - errors?.some( - (graphQLError) => - graphQLError.type === "FORBIDDEN" || - graphQLError.type === "INSUFFICIENT_SCOPES", - ) ?? false - ); - } + if (error instanceof RequestError) { + return error.status === 401 || error.status === 403; + } + + if (isGraphQLErrorResponse(error)) { + const {errors} = error; + return errors?.some((graphQLError) => graphQLError.type === 'FORBIDDEN' || graphQLError.type === 'INSUFFICIENT_SCOPES') ?? false; + } - const message = error instanceof Error ? error.message : String(error); - return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test( - message, - ); + const message = error instanceof Error ? error.message : String(error); + return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test(message); } -async function getRequiredApprovingReviewCount({ - owner, - repo, - baseRef, -}: { - owner: string; - repo: string; - baseRef: string; -}): Promise { - try { - const response = await GitHubAPIClient.graphql( - ` +async function getRequiredApprovingReviewCount({owner, repo, baseRef}: {owner: string; repo: string; baseRef: string}): Promise { + try { + const response = await GitHubAPIClient.graphql( + ` query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { repository(owner: $owner, name: $repo) { ref(qualifiedName: $branchRef) { @@ -107,44 +91,30 @@ async function getRequiredApprovingReviewCount({ } } `, - { - owner, - repo, - branchRef: `refs/heads/${baseRef}`, - }, - ); + { + owner, + repo, + branchRef: `refs/heads/${baseRef}`, + }, + ); + + return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; + } catch (error: unknown) { + if (isPermissionError(error)) { + throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`); + } - return ( - response.repository?.ref?.branchProtectionRule - ?.requiredApprovingReviewCount ?? 0 - ); - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new Error( - `Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`, - ); + const message = error instanceof Error ? error.message : String(error); + console.warn( + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + ); + return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; } - - const message = error instanceof Error ? error.message : String(error); - console.warn( - `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, - ); - return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; - } } -async function getLatestApprovers({ - owner, - repo, - number, -}: { - owner: string; - repo: string; - number: number; -}): Promise { - const response = - await GitHubAPIClient.graphql( - ` +async function getLatestApprovers({owner, repo, number}: {owner: string; repo: string; number: number}): Promise { + const response = await GitHubAPIClient.graphql( + ` query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { @@ -160,30 +130,28 @@ async function getLatestApprovers({ } } `, - { - owner, - repo, - prNumber: number, - }, + { + owner, + repo, + prNumber: number, + }, ); - const reviews: OpinionatedReviewNode[] = - response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return uniqueSorted( - reviews - .filter((review) => review.state === "APPROVED") - .map((review) => review.author?.login ?? "") - .filter((login) => login !== ""), - ); + const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; + return uniqueSorted( + reviews + .filter((review) => review.state === 'APPROVED') + .map((review) => review.author?.login ?? '') + .filter((login) => login !== ''), + ); } async function getEmployeeLogins(): Promise> { - const employeeLogins = new Set(); + const employeeLogins = new Set(); - async function collectPage(cursor: string | null): Promise { - const response: TeamMembersResponse = - await GitHubAPIClient.graphql( - ` + async function collectPage(cursor: string | null): Promise { + const response: TeamMembersResponse = await GitHubAPIClient.graphql( + ` query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { organization(login: $organization) { team(slug: $teamSlug) { @@ -200,55 +168,45 @@ async function getEmployeeLogins(): Promise> { } } `, - { - organization: EXPENSIFY_ORG, - teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, - cursor, - }, - ); - - const members = response.organization?.team?.members; - if (!members) { - throw new Error( - `${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`, - ); - } + { + organization: EXPENSIFY_ORG, + teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, + cursor, + }, + ); + + const members = response.organization?.team?.members; + if (!members) { + throw new Error(`${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`); + } - for (const member of members.nodes) { - employeeLogins.add(member.login); - } + for (const member of members.nodes) { + employeeLogins.add(member.login); + } - if (members.pageInfo.hasNextPage) { - await collectPage(members.pageInfo.endCursor); + if (members.pageInfo.hasNextPage) { + await collectPage(members.pageInfo.endCursor); + } } - } - await collectPage(null); - return employeeLogins; + await collectPage(null); + return employeeLogins; } -async function listPullRequestCommits({ - owner, - repo, - number, -}: { - owner: string; - repo: string; - number: number; -}) { - return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { - owner, - repo, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - pull_number: number, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - per_page: 100, - }); +async function listPullRequestCommits({owner, repo, number}: {owner: string; repo: string; number: number}) { + return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { + owner, + repo, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + pull_number: number, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + per_page: 100, + }); } export default { - getEmployeeLogins, - getLatestApprovers, - getRequiredApprovingReviewCount, - listPullRequestCommits, + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, }; diff --git a/scripts/libs/GitHubWorkflowUtils.ts b/scripts/libs/GitHubWorkflowUtils.ts index 593a029..f5f55fa 100644 --- a/scripts/libs/GitHubWorkflowUtils.ts +++ b/scripts/libs/GitHubWorkflowUtils.ts @@ -1,41 +1,31 @@ -import { appendFileSync } from "node:fs"; +import {appendFileSync} from 'node:fs'; function escapeWorkflowCommandValue(value: string): string { - return value - .replaceAll("%", "%25") - .replaceAll("\r", "%0D") - .replaceAll("\n", "%0A"); + return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); } function escapeWorkflowCommandProperty(value: string): string { - return escapeWorkflowCommandValue(value) - .replaceAll(":", "%3A") - .replaceAll(",", "%2C"); + return escapeWorkflowCommandValue(value).replaceAll(':', '%3A').replaceAll(',', '%2C'); } function writeStepSummary(title: string, message: string): void { - const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!stepSummaryPath) { - return; - } - appendFileSync( - stepSummaryPath, - `## ${title}\n\n${message.replaceAll("\n", "\n\n")}\n`, - ); + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!stepSummaryPath) { + return; + } + appendFileSync(stepSummaryPath, `## ${title}\n\n${message.replaceAll('\n', '\n\n')}\n`); } -function emitFailure(error: unknown, title = "Workflow step failed"): never { - const message = error instanceof Error ? error.message : String(error); - writeStepSummary(title, message); - console.error( - `::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`, - ); - process.exit(1); +function emitFailure(error: unknown, title = 'Workflow step failed'): never { + const message = error instanceof Error ? error.message : String(error); + writeStepSummary(title, message); + console.error(`::error title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandValue(message)}`); + process.exit(1); } export default { - emitFailure, - escapeWorkflowCommandProperty, - escapeWorkflowCommandValue, - writeStepSummary, + emitFailure, + escapeWorkflowCommandProperty, + escapeWorkflowCommandValue, + writeStepSummary, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 2bd99a8..ad9b98b 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,272 +1,236 @@ -import CLI from "expensify-common/CLI"; -import GitCommitUtils, { - type GitHubPullRequestCommit, -} from "./libs/GitCommitUtils"; -import GitHubUtils from "./libs/GitHubUtils"; -import GitHubWorkflowUtils from "./libs/GitHubWorkflowUtils"; +import CLI from 'expensify-common/CLI'; +import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtils'; +import GitHubUtils from './libs/GitHubUtils'; +import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; -const BOT_USERS = new Set(["botify", "MelvinBot", "exfy-zapier"]); +const BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); type PullRequestContext = { - owner: string; - repo: string; - number: number; - baseRef: string; + owner: string; + repo: string; + number: number; + baseRef: string; }; type PeerReviewInput = { - owner: string; - repo: string; - number: number; - baseRef: string; - requiredApprovingReviewCount: number; - approvers: string[]; - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; - employeeLogins: Set; + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; }; -type PeerReviewResult = - | { status: "pass"; reason: string } - | { status: "skip"; reason: string } - | { status: "fail"; error: Error }; +type PeerReviewResult = {status: 'pass'; reason: string} | {status: 'skip'; reason: string} | {status: 'fail'; error: Error}; function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(", ") : "(none)"; + return users.length > 0 ? users.join(', ') : '(none)'; } function unique(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } function getPullRequestContext(): PullRequestContext { - const cli = new CLI({ - namedArgs: { - owner: { - description: "Repository owner organization or user login", - }, - repo: { - description: "Repository name", - }, - number: { - description: "Pull request number", - parse: (value: string) => { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error("Must be a positive integer"); - } - return number; + const cli = new CLI({ + namedArgs: { + owner: { + description: 'Repository owner organization or user login', + }, + repo: { + description: 'Repository name', + }, + number: { + description: 'Pull request number', + parse: (value: string) => { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error('Must be a positive integer'); + } + return number; + }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names + 'base-ref': { + description: 'Target branch ref for the pull request', + }, }, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names - "base-ref": { - description: "Target branch ref for the pull request", - }, - }, - }); - - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs.number, - baseRef: cli.namedArgs["base-ref"], - }; + }); + + return { + owner: cli.namedArgs.owner, + repo: cli.namedArgs.repo, + number: cli.namedArgs.number, + baseRef: cli.namedArgs['base-ref'], + }; } function isExpensifyEmail(email: string): boolean { - return email.trim().toLowerCase().endsWith("@expensify.com"); + return email.trim().toLowerCase().endsWith('@expensify.com'); } function getCommitAuthors(commits: GitHubPullRequestCommit[]): { - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; } { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); - - for (const commit of commits) { - const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); - if (canonicalAuthor) { - authors.add(canonicalAuthor); + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + if (canonicalAuthor) { + authors.add(canonicalAuthor); + } + + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is missing or is a bot. + if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { + continue; + } + + for (const email of GitCommitUtils.parseCoAuthorEmails(commit.commit.message)) { + const login = GitCommitUtils.resolveNoreplyEmailToLogin(email); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(email)) { + // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. + unresolvedExpensifyCoAuthors.add(email.trim()); + } + } } - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { - continue; - } - - for (const email of GitCommitUtils.parseCoAuthorEmails(commit.commit.message)) { - const login = GitCommitUtils.resolveNoreplyEmailToLogin(email); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(email)) { - // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. - unresolvedExpensifyCoAuthors.add(email.trim()); - } - } - } - - return { - authors: unique([...authors]), - unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), - }; + return { + authors: unique([...authors]), + unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + }; } -function getIndependentEmployeeApprovers( - approvers: string[], - authors: string[], - employeeLogins: Set, -): string[] { - const authorSet = new Set(authors); - return approvers.filter( - (approver) => !authorSet.has(approver) && employeeLogins.has(approver), - ); +function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { + const authorSet = new Set(authors); + return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); } function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { - const { - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - } = input; - - if (requiredApprovingReviewCount === 0) { - return { - status: "skip", - reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, - }; - } + const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; - if (approvers.length === 0) { - return { - status: "skip", - reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - }; - } + if (requiredApprovingReviewCount === 0) { + return { + status: 'skip', + reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + }; + } - if (unresolvedExpensifyCoAuthors.length > 0) { - return { - status: "fail", - error: new Error( - `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, - ), - }; - } + if (approvers.length === 0) { + return { + status: 'skip', + reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } + + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: 'fail', + error: new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`), + }; + } + + if (authors.every((author) => BOT_USERS.has(author))) { + return { + status: 'fail', + error: new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`), + }; + } + + const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + return { + status: 'fail', + error: new Error( + [ + `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join('\n'), + ), + }; + } - if (authors.every((author) => BOT_USERS.has(author))) { - return { - status: "fail", - error: new Error( - `Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`, - ), - }; - } - - const independentEmployeeApprovers = getIndependentEmployeeApprovers( - approvers, - authors, - employeeLogins, - ); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { return { - status: "fail", - error: new Error( - [ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, - ].join("\n"), - ), + status: 'pass', + reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, }; - } - - return { - status: "pass", - reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, - }; } function getFailureTitle(message: string): string { - if ( - message.includes( - "does not have enough independent Expensify employee approvals", - ) - ) { - return "Missing independent peer review"; - } - if (message.includes("Unable to resolve Expensify co-author emails")) { - return "Unresolved Expensify co-author"; - } - if (message.includes("has no human commit authors or co-authors")) { - return "No human commit author"; - } - if (message.includes("Unable to read branch protection rules")) { - return "Branch protection lookup failed"; - } - return "Peer review verification failed"; + if (message.includes('does not have enough independent Expensify employee approvals')) { + return 'Missing independent peer review'; + } + if (message.includes('Unable to resolve Expensify co-author emails')) { + return 'Unresolved Expensify co-author'; + } + if (message.includes('has no human commit authors or co-authors')) { + return 'No human commit author'; + } + if (message.includes('Unable to read branch protection rules')) { + return 'Branch protection lookup failed'; + } + return 'Peer review verification failed'; } function emitPeerReviewFailure(error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - return GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); + const message = error instanceof Error ? error.message : String(error); + return GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); } async function main(): Promise { - const { owner, repo, number, baseRef } = getPullRequestContext(); - - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - GitHubUtils.getRequiredApprovingReviewCount({ owner, repo, baseRef }), - GitHubUtils.getLatestApprovers({ owner, repo, number }), - GitHubUtils.listPullRequestCommits({ owner, repo, number }), - ]); - - const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors(commits); - - const employeeLogins = - approvers.length > 0 && requiredApprovingReviewCount > 0 - ? await GitHubUtils.getEmployeeLogins() - : new Set(); - - const result = evaluatePeerReview({ - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - }); - - if (result.status === "skip" || result.status === "pass") { - console.log(result.reason); - return; - } - - throw result.error; + const {owner, repo, number, baseRef} = getPullRequestContext(); + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + GitHubUtils.getRequiredApprovingReviewCount({owner, repo, baseRef}), + GitHubUtils.getLatestApprovers({owner, repo, number}), + GitHubUtils.listPullRequestCommits({owner, repo, number}), + ]); + + const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); + + const employeeLogins = approvers.length > 0 && requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); + + const result = evaluatePeerReview({ + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + }); + + if (result.status === 'skip' || result.status === 'pass') { + console.log(result.reason); + return; + } + + throw result.error; } -export type { PeerReviewInput, PeerReviewResult, PullRequestContext }; +export type {PeerReviewInput, PeerReviewResult, PullRequestContext}; export default { - main, - getPullRequestContext, - evaluatePeerReview, - getIndependentEmployeeApprovers, - getCommitAuthors, - getFailureTitle, + main, + getPullRequestContext, + evaluatePeerReview, + getIndependentEmployeeApprovers, + getCommitAuthors, + getFailureTitle, }; if (import.meta.main) { - main().catch(emitPeerReviewFailure); + main().catch(emitPeerReviewFailure); } diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index a8dbc92..bde4d07 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -1,66 +1,42 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import GitCommitUtils, { - type GitHubPullRequestCommit, -} from "../scripts/libs/GitCommitUtils"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import GitCommitUtils, {type GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, -): GitHubPullRequestCommit { - return { - author: authorLogin ? { login: authorLogin } : null, - commit: { - message, - author: authorName ? { name: authorName } : {}, - }, - }; +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { + return { + author: authorLogin ? {login: authorLogin} : null, + commit: { + message, + author: authorName ? {name: authorName} : {}, + }, + }; } -describe("resolveNoreplyEmailToLogin", () => { - it("parses standard noreply addresses", () => { - assert.equal( - GitCommitUtils.resolveNoreplyEmailToLogin( - "AndrewGable@users.noreply.github.com", - ), - "AndrewGable", - ); - }); +describe('resolveNoreplyEmailToLogin', () => { + it('parses standard noreply addresses', () => { + assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); - it("parses numeric noreply prefixes", () => { - assert.equal( - GitCommitUtils.resolveNoreplyEmailToLogin( - "2838819+AndrewGable@users.noreply.github.com", - ), - "AndrewGable", - ); - }); + it('parses numeric noreply prefixes', () => { + assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('2838819+AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); }); -describe("parseCoAuthorEmails", () => { - it("extracts multiple co-author emails", () => { - const message = [ - "Some change", - "", - "Co-authored-by: Andrew Gable ", - "Co-authored-by: Monil Bhavsar ", - ].join("\n"); +describe('parseCoAuthorEmails', () => { + it('extracts multiple co-author emails', () => { + const message = [ + 'Some change', + '', + 'Co-authored-by: Andrew Gable ', + 'Co-authored-by: Monil Bhavsar ', + ].join('\n'); - assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), [ - "AndrewGable@users.noreply.github.com", - "MonilBhavsar@users.noreply.github.com", - ]); - }); + assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), ['AndrewGable@users.noreply.github.com', 'MonilBhavsar@users.noreply.github.com']); + }); }); -describe("getCanonicalAuthorLogin", () => { - it("falls back to commit author name when github login is missing", () => { - assert.equal( - GitCommitUtils.getCanonicalAuthorLogin( - makeCommit(undefined, "AndrewGable", "Change"), - ), - "AndrewGable", - ); - }); +describe('getCanonicalAuthorLogin', () => { + it('falls back to commit author name when github login is missing', () => { + assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit(undefined, 'AndrewGable', 'Change')), 'AndrewGable'); + }); }); diff --git a/tests/GitHubUtils.test.ts b/tests/GitHubUtils.test.ts index d3575a3..9e221c4 100644 --- a/tests/GitHubUtils.test.ts +++ b/tests/GitHubUtils.test.ts @@ -1,52 +1,49 @@ -import assert from "node:assert/strict"; -import { describe, it, afterEach } from "node:test"; -import { RequestError } from "@octokit/request-error"; -import GitHubAPIClient from "../scripts/libs/GitHubAPIClient"; -import GitHubUtils from "../scripts/libs/GitHubUtils"; +import assert from 'node:assert/strict'; +import {describe, it, afterEach} from 'node:test'; +import {RequestError} from '@octokit/request-error'; +import GitHubAPIClient from '../scripts/libs/GitHubAPIClient'; +import GitHubUtils from '../scripts/libs/GitHubUtils'; const context = { - owner: "Expensify", - repo: "Auth", - number: 1, - baseRef: "main", + owner: 'Expensify', + repo: 'Auth', + number: 1, + baseRef: 'main', }; -describe("getRequiredApprovingReviewCount", () => { - afterEach(() => { - GitHubAPIClient.internalOctokit = undefined; - GitHubAPIClient.graphqlClient = undefined; - }); - - it("returns 0 when branch protection rule is missing", async () => { - GitHubAPIClient.graphqlClient = async () => ({ - repository: { - ref: { - branchProtectionRule: null, - }, - }, +describe('getRequiredApprovingReviewCount', () => { + afterEach(() => { + GitHubAPIClient.internalOctokit = undefined; + GitHubAPIClient.graphqlClient = undefined; }); - const count = await GitHubUtils.getRequiredApprovingReviewCount({ - ...context, - baseRef: "staging", + it('returns 0 when branch protection rule is missing', async () => { + GitHubAPIClient.graphqlClient = async () => ({ + repository: { + ref: { + branchProtectionRule: null, + }, + }, + }); + + const count = await GitHubUtils.getRequiredApprovingReviewCount({ + ...context, + baseRef: 'staging', + }); + assert.equal(count, 0); }); - assert.equal(count, 0); - }); - it("throws on permission errors", async () => { - GitHubAPIClient.graphqlClient = async () => { - throw new RequestError("Resource not accessible by integration", 403, { - request: { - method: "POST", - url: "https://api.github.com/graphql", - headers: {}, - }, - }); - }; + it('throws on permission errors', async () => { + GitHubAPIClient.graphqlClient = async () => { + throw new RequestError('Resource not accessible by integration', 403, { + request: { + method: 'POST', + url: 'https://api.github.com/graphql', + headers: {}, + }, + }); + }; - await assert.rejects( - () => GitHubUtils.getRequiredApprovingReviewCount(context), - /Unable to read branch protection rules/, - ); - }); + await assert.rejects(() => GitHubUtils.getRequiredApprovingReviewCount(context), /Unable to read branch protection rules/); + }); }); diff --git a/tests/GitHubWorkflowUtils.test.ts b/tests/GitHubWorkflowUtils.test.ts index 6c255ee..f470407 100644 --- a/tests/GitHubWorkflowUtils.test.ts +++ b/tests/GitHubWorkflowUtils.test.ts @@ -1,19 +1,13 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import GitHubWorkflowUtils from "../scripts/libs/GitHubWorkflowUtils"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import GitHubWorkflowUtils from '../scripts/libs/GitHubWorkflowUtils'; -describe("GitHubWorkflowUtils helpers", () => { - it("escapes workflow command values", () => { - assert.equal( - GitHubWorkflowUtils.escapeWorkflowCommandValue("a\nb%"), - "a%0Ab%25", - ); - }); +describe('GitHubWorkflowUtils helpers', () => { + it('escapes workflow command values', () => { + assert.equal(GitHubWorkflowUtils.escapeWorkflowCommandValue('a\nb%'), 'a%0Ab%25'); + }); - it("escapes workflow command properties", () => { - assert.equal( - GitHubWorkflowUtils.escapeWorkflowCommandProperty("title:one,two"), - "title%3Aone%2Ctwo", - ); - }); + it('escapes workflow command properties', () => { + assert.equal(GitHubWorkflowUtils.escapeWorkflowCommandProperty('title:one,two'), 'title%3Aone%2Ctwo'); + }); }); diff --git a/tests/verifyPeerReviewCli.test.ts b/tests/verifyPeerReviewCli.test.ts index 4e35ecb..8479ffc 100644 --- a/tests/verifyPeerReviewCli.test.ts +++ b/tests/verifyPeerReviewCli.test.ts @@ -1,46 +1,37 @@ -import assert from "node:assert/strict"; -import { afterEach, beforeEach, describe, it } from "node:test"; -import VerifyPeerReview from "../scripts/verifyPeerReview"; +import assert from 'node:assert/strict'; +import {afterEach, beforeEach, describe, it} from 'node:test'; +import VerifyPeerReview from '../scripts/verifyPeerReview'; const ORIGINAL_ARGV = process.argv; -describe("getPullRequestContext", () => { - let originalExit: typeof process.exit; +describe('getPullRequestContext', () => { + let originalExit: typeof process.exit; - beforeEach(() => { - process.argv = ["tsx", "scripts/verifyPeerReview.ts"]; - originalExit = process.exit.bind(process); - process.exit = (code?: string | number | null) => { - throw new Error(`exit ${code ?? 0}`); - }; - }); + beforeEach(() => { + process.argv = ['tsx', 'scripts/verifyPeerReview.ts']; + originalExit = process.exit.bind(process); + process.exit = (code?: string | number | null) => { + throw new Error(`exit ${code ?? 0}`); + }; + }); - afterEach(() => { - process.argv = ORIGINAL_ARGV; - process.exit = originalExit; - }); + afterEach(() => { + process.argv = ORIGINAL_ARGV; + process.exit = originalExit; + }); - it("parses required pull request CLI arguments", () => { - process.argv.push( - "--owner", - "Expensify", - "--repo", - "Auth", - "--number", - "21136", - "--base-ref", - "main", - ); + it('parses required pull request CLI arguments', () => { + process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--number', '21136', '--base-ref', 'main'); - assert.deepEqual(VerifyPeerReview.getPullRequestContext(), { - owner: "Expensify", - repo: "Auth", - number: 21136, - baseRef: "main", + assert.deepEqual(VerifyPeerReview.getPullRequestContext(), { + owner: 'Expensify', + repo: 'Auth', + number: 21136, + baseRef: 'main', + }); }); - }); - it("fails when required arguments are missing", () => { - assert.throws(() => VerifyPeerReview.getPullRequestContext(), /exit 1/); - }); + it('fails when required arguments are missing', () => { + assert.throws(() => VerifyPeerReview.getPullRequestContext(), /exit 1/); + }); }); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index ca00433..0a5198c 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -1,81 +1,47 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { GitHubPullRequestCommit } from "../scripts/libs/GitCommitUtils"; -import VerifyPeerReview from "../scripts/verifyPeerReview"; - -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, -): GitHubPullRequestCommit { - return { - author: authorLogin ? { login: authorLogin } : null, - commit: { - message, - author: authorName ? { name: authorName } : {}, - }, - }; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import type {GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; +import VerifyPeerReview from '../scripts/verifyPeerReview'; + +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { + return { + author: authorLogin ? {login: authorLogin} : null, + commit: { + message, + author: authorName ? {name: authorName} : {}, + }, + }; } -describe("getCommitAuthors", () => { - it("counts co-authors for bot-authored commits", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); +describe('getCommitAuthors', () => { + it('counts co-authors for bot-authored commits', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - it("ignores co-authors when canonical author is human", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit( - "rafecolton", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); + it('ignores co-authors when canonical author is human', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.authors, ["rafecolton"]); - }); + assert.deepEqual(result.authors, ['rafecolton']); + }); - it("falls back to commit author name when github login is missing", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit(undefined, "AndrewGable", "Change"), - ]); + it('falls back to commit author name when github login is missing', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); - assert.deepEqual(result.authors, ["AndrewGable"]); - }); + assert.deepEqual(result.authors, ['AndrewGable']); + }); - it("normalizes expensify email casing and whitespace for unresolved detection", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >", - ), - ]); + it('normalizes expensify email casing and whitespace for unresolved detection', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >')]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "Andrew@Expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); + }); - it("collects unresolved expensify co-author emails", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); + it('collects unresolved expensify co-author emails', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "andrew@expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); + }); }); diff --git a/tests/verifyPeerReviewPolicy.test.ts b/tests/verifyPeerReviewPolicy.test.ts index 33f3dad..e8ba251 100644 --- a/tests/verifyPeerReviewPolicy.test.ts +++ b/tests/verifyPeerReviewPolicy.test.ts @@ -1,150 +1,125 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import VerifyPeerReview, { - type PeerReviewInput, -} from "../scripts/verifyPeerReview"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import VerifyPeerReview, {type PeerReviewInput} from '../scripts/verifyPeerReview'; const baseInput: PeerReviewInput = { - owner: "Expensify", - repo: "Auth", - number: 21136, - baseRef: "main", - requiredApprovingReviewCount: 1, - approvers: [], - authors: [], - unresolvedExpensifyCoAuthors: [], - employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), + owner: 'Expensify', + repo: 'Auth', + number: 21136, + baseRef: 'main', + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(['MonilBhavsar', 'AndrewGable', 'rafecolton']), }; -describe("getIndependentEmployeeApprovers", () => { - it("excludes commit authors and non-employees", () => { - const independent = VerifyPeerReview.getIndependentEmployeeApprovers( - ["AndrewGable", "MonilBhavsar", "externalCollab"], - ["AndrewGable"], - new Set(["AndrewGable", "MonilBhavsar"]), - ); +describe('getIndependentEmployeeApprovers', () => { + it('excludes commit authors and non-employees', () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers(['AndrewGable', 'MonilBhavsar', 'externalCollab'], ['AndrewGable'], new Set(['AndrewGable', 'MonilBhavsar'])); - assert.deepEqual(independent, ["MonilBhavsar"]); - }); -}); - -describe("evaluatePeerReview", () => { - it("skips when branch requires no approving reviews", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 0, - approvers: ["AndrewGable"], - authors: ["AndrewGable"], + assert.deepEqual(independent, ['MonilBhavsar']); }); +}); - assert.equal(result.status, "skip"); - }); +describe('evaluatePeerReview', () => { + it('skips when branch requires no approving reviews', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ['AndrewGable'], + authors: ['AndrewGable'], + }); - it("skips when there are no approving reviews yet", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: [], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "skip"); - }); + it('skips when there are no approving reviews yet', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails on self-review from commit co-author", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /does not have enough independent Expensify employee approvals/, - ); - } - }); - - it("passes when an independent employee approves", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + it('fails on self-review from commit co-author', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /does not have enough independent Expensify employee approvals/); + } }); - assert.equal(result.status, "pass"); - }); + it('passes when an independent employee approves', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails when independent approver count is below required", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "fail"); - }); + it('fails when independent approver count is below required', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("passes when independent approver count meets required", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "rafecolton"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'fail'); }); - assert.equal(result.status, "pass"); - }); + it('passes when independent approver count meets required', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'rafecolton'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails when all authors are bots", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /no human commit authors or co-authors/, - ); - } - }); - - it("fails on unresolved expensify co-author emails", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], - unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], + it('fails when all authors are bots', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /no human commit authors or co-authors/); + } }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /Unable to resolve Expensify co-author emails/, - ); - } - }); + it('fails on unresolved expensify co-author emails', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + unresolvedExpensifyCoAuthors: ['andrew@expensify.com'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /Unable to resolve Expensify co-author emails/); + } + }); }); -describe("getFailureTitle", () => { - it("maps failure titles for known messages", () => { - assert.equal( - VerifyPeerReview.getFailureTitle( - "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", - ), - "Missing independent peer review", - ); - assert.equal( - VerifyPeerReview.getFailureTitle( - "Unable to read branch protection rules for Expensify/Auth@main.", - ), - "Branch protection lookup failed", - ); - }); +describe('getFailureTitle', () => { + it('maps failure titles for known messages', () => { + assert.equal(VerifyPeerReview.getFailureTitle('Expensify/Auth#1 does not have enough independent Expensify employee approvals.'), 'Missing independent peer review'); + assert.equal(VerifyPeerReview.getFailureTitle('Unable to read branch protection rules for Expensify/Auth@main.'), 'Branch protection lookup failed'); + }); }); From 87af15aec13289931284861393e7aa06fbe8de44 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:50:27 -0700 Subject: [PATCH 49/63] refactor: extract uniqueSorted into CollectionUtils Share dedupe-and-sort logic between GitHubUtils and verifyPeerReview. Co-authored-by: Cursor --- scripts/libs/CollectionUtils.ts | 7 +++++++ scripts/libs/GitHubUtils.ts | 7 ++----- scripts/verifyPeerReview.ts | 9 +++------ tests/CollectionUtils.test.ts | 9 +++++++++ 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 scripts/libs/CollectionUtils.ts create mode 100644 tests/CollectionUtils.test.ts diff --git a/scripts/libs/CollectionUtils.ts b/scripts/libs/CollectionUtils.ts new file mode 100644 index 0000000..a704e51 --- /dev/null +++ b/scripts/libs/CollectionUtils.ts @@ -0,0 +1,7 @@ +function uniqueSorted(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +export default { + uniqueSorted, +}; diff --git a/scripts/libs/GitHubUtils.ts b/scripts/libs/GitHubUtils.ts index bc03e86..0378dd3 100644 --- a/scripts/libs/GitHubUtils.ts +++ b/scripts/libs/GitHubUtils.ts @@ -1,4 +1,5 @@ import {RequestError} from '@octokit/request-error'; +import CollectionUtils from './CollectionUtils'; import GitHubAPIClient from './GitHubAPIClient'; const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; @@ -55,10 +56,6 @@ type GraphQLErrorResponse = { }>; }; -function uniqueSorted(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); -} - function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { return typeof error === 'object' && error !== null && 'errors' in error; } @@ -138,7 +135,7 @@ async function getLatestApprovers({owner, repo, number}: {owner: string; repo: s ); const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return uniqueSorted( + return CollectionUtils.uniqueSorted( reviews .filter((review) => review.state === 'APPROVED') .map((review) => review.author?.login ?? '') diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index ad9b98b..1090290 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,4 +1,5 @@ import CLI from 'expensify-common/CLI'; +import CollectionUtils from './libs/CollectionUtils'; import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtils'; import GitHubUtils from './libs/GitHubUtils'; import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; @@ -30,10 +31,6 @@ function formatUsers(users: string[]): string { return users.length > 0 ? users.join(', ') : '(none)'; } -function unique(values: string[]): string[] { - return [...new Set(values)].sort((a, b) => a.localeCompare(b)); -} - function getPullRequestContext(): PullRequestContext { const cli = new CLI({ namedArgs: { @@ -103,8 +100,8 @@ function getCommitAuthors(commits: GitHubPullRequestCommit[]): { } return { - authors: unique([...authors]), - unresolvedExpensifyCoAuthors: unique([...unresolvedExpensifyCoAuthors]), + authors: CollectionUtils.uniqueSorted([...authors]), + unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([...unresolvedExpensifyCoAuthors]), }; } diff --git a/tests/CollectionUtils.test.ts b/tests/CollectionUtils.test.ts new file mode 100644 index 0000000..bfdb052 --- /dev/null +++ b/tests/CollectionUtils.test.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import CollectionUtils from '../scripts/libs/CollectionUtils'; + +describe('uniqueSorted', () => { + it('deduplicates and sorts values', () => { + assert.deepEqual(CollectionUtils.uniqueSorted(['b', 'a', 'b', 'c']), ['a', 'b', 'c']); + }); +}); From 38a4caf7b6939fe121ff7fae178f2d0078de1139 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 21:56:48 -0700 Subject: [PATCH 50/63] refactor: split GitHubUtils into focused submodules Move each API helper into its own file under libs/GitHubUtils/. Co-authored-by: Cursor --- scripts/libs/GitHubUtils.ts | 209 ------------------ scripts/libs/GitHubUtils/getEmployeeLogins.ts | 69 ++++++ .../libs/GitHubUtils/getLatestApprovers.ts | 55 +++++ .../getRequiredApprovingReviewCount.ts | 51 +++++ scripts/libs/GitHubUtils/index.ts | 11 + scripts/libs/GitHubUtils/isPermissionError.ts | 28 +++ .../GitHubUtils/listPullRequestCommits.ts | 14 ++ 7 files changed, 228 insertions(+), 209 deletions(-) delete mode 100644 scripts/libs/GitHubUtils.ts create mode 100644 scripts/libs/GitHubUtils/getEmployeeLogins.ts create mode 100644 scripts/libs/GitHubUtils/getLatestApprovers.ts create mode 100644 scripts/libs/GitHubUtils/getRequiredApprovingReviewCount.ts create mode 100644 scripts/libs/GitHubUtils/index.ts create mode 100644 scripts/libs/GitHubUtils/isPermissionError.ts create mode 100644 scripts/libs/GitHubUtils/listPullRequestCommits.ts diff --git a/scripts/libs/GitHubUtils.ts b/scripts/libs/GitHubUtils.ts deleted file mode 100644 index 0378dd3..0000000 --- a/scripts/libs/GitHubUtils.ts +++ /dev/null @@ -1,209 +0,0 @@ -import {RequestError} from '@octokit/request-error'; -import CollectionUtils from './CollectionUtils'; -import GitHubAPIClient from './GitHubAPIClient'; - -const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; -const EXPENSIFY_ORG = 'Expensify'; -const EXPENSIFY_EMPLOYEE_TEAM_SLUG = 'expensify-expensify'; - -type BranchProtectionResponse = { - repository: { - ref: { - branchProtectionRule: { - requiredApprovingReviewCount: number; - } | null; - } | null; - } | null; -}; - -type OpinionatedReviewNode = { - state: string; - author: { - login: string; - } | null; -}; - -type LatestOpinionatedReviewsResponse = { - repository: { - pullRequest: { - latestOpinionatedReviews: { - nodes: OpinionatedReviewNode[]; - }; - } | null; - } | null; -}; - -type TeamMembersResponse = { - organization: { - team: { - members: { - pageInfo: { - hasNextPage: boolean; - endCursor: string | null; - }; - nodes: Array<{ - login: string; - }>; - }; - } | null; - } | null; -}; - -type GraphQLErrorResponse = { - errors?: Array<{ - type?: string; - message?: string; - }>; -}; - -function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { - return typeof error === 'object' && error !== null && 'errors' in error; -} - -function isPermissionError(error: unknown): boolean { - if (error instanceof RequestError) { - return error.status === 401 || error.status === 403; - } - - if (isGraphQLErrorResponse(error)) { - const {errors} = error; - return errors?.some((graphQLError) => graphQLError.type === 'FORBIDDEN' || graphQLError.type === 'INSUFFICIENT_SCOPES') ?? false; - } - - const message = error instanceof Error ? error.message : String(error); - return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test(message); -} - -async function getRequiredApprovingReviewCount({owner, repo, baseRef}: {owner: string; repo: string; baseRef: string}): Promise { - try { - const response = await GitHubAPIClient.graphql( - ` - query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $branchRef) { - branchProtectionRule { - requiredApprovingReviewCount - } - } - } - } - `, - { - owner, - repo, - branchRef: `refs/heads/${baseRef}`, - }, - ); - - return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`); - } - - const message = error instanceof Error ? error.message : String(error); - console.warn( - `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, - ); - return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; - } -} - -async function getLatestApprovers({owner, repo, number}: {owner: string; repo: string; number: number}): Promise { - const response = await GitHubAPIClient.graphql( - ` - query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - latestOpinionatedReviews(last: 100, writersOnly: true) { - nodes { - state - author { - login - } - } - } - } - } - } - `, - { - owner, - repo, - prNumber: number, - }, - ); - - const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; - return CollectionUtils.uniqueSorted( - reviews - .filter((review) => review.state === 'APPROVED') - .map((review) => review.author?.login ?? '') - .filter((login) => login !== ''), - ); -} - -async function getEmployeeLogins(): Promise> { - const employeeLogins = new Set(); - - async function collectPage(cursor: string | null): Promise { - const response: TeamMembersResponse = await GitHubAPIClient.graphql( - ` - query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { - organization(login: $organization) { - team(slug: $teamSlug) { - members(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - login - } - } - } - } - } - `, - { - organization: EXPENSIFY_ORG, - teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, - cursor, - }, - ); - - const members = response.organization?.team?.members; - if (!members) { - throw new Error(`${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`); - } - - for (const member of members.nodes) { - employeeLogins.add(member.login); - } - - if (members.pageInfo.hasNextPage) { - await collectPage(members.pageInfo.endCursor); - } - } - - await collectPage(null); - return employeeLogins; -} - -async function listPullRequestCommits({owner, repo, number}: {owner: string; repo: string; number: number}) { - return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { - owner, - repo, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - pull_number: number, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters - per_page: 100, - }); -} - -export default { - getEmployeeLogins, - getLatestApprovers, - getRequiredApprovingReviewCount, - listPullRequestCommits, -}; diff --git a/scripts/libs/GitHubUtils/getEmployeeLogins.ts b/scripts/libs/GitHubUtils/getEmployeeLogins.ts new file mode 100644 index 0000000..c5f405b --- /dev/null +++ b/scripts/libs/GitHubUtils/getEmployeeLogins.ts @@ -0,0 +1,69 @@ +import GitHubAPIClient from '../GitHubAPIClient'; + +const EXPENSIFY_ORG = 'Expensify'; +const EXPENSIFY_EMPLOYEE_TEAM_SLUG = 'expensify-expensify'; + +type TeamMembersResponse = { + organization: { + team: { + members: { + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: Array<{ + login: string; + }>; + }; + } | null; + } | null; +}; + +async function getEmployeeLogins(): Promise> { + const employeeLogins = new Set(); + + async function collectPage(cursor: string | null): Promise { + const response: TeamMembersResponse = await GitHubAPIClient.graphql( + ` + query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) { + organization(login: $organization) { + team(slug: $teamSlug) { + members(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + login + } + } + } + } + } + `, + { + organization: EXPENSIFY_ORG, + teamSlug: EXPENSIFY_EMPLOYEE_TEAM_SLUG, + cursor, + }, + ); + + const members = response.organization?.team?.members; + if (!members) { + throw new Error(`${EXPENSIFY_ORG}/${EXPENSIFY_EMPLOYEE_TEAM_SLUG} team could not be found.`); + } + + for (const member of members.nodes) { + employeeLogins.add(member.login); + } + + if (members.pageInfo.hasNextPage) { + await collectPage(members.pageInfo.endCursor); + } + } + + await collectPage(null); + return employeeLogins; +} + +export default getEmployeeLogins; diff --git a/scripts/libs/GitHubUtils/getLatestApprovers.ts b/scripts/libs/GitHubUtils/getLatestApprovers.ts new file mode 100644 index 0000000..b974321 --- /dev/null +++ b/scripts/libs/GitHubUtils/getLatestApprovers.ts @@ -0,0 +1,55 @@ +import CollectionUtils from '../CollectionUtils'; +import GitHubAPIClient from '../GitHubAPIClient'; + +type OpinionatedReviewNode = { + state: string; + author: { + login: string; + } | null; +}; + +type LatestOpinionatedReviewsResponse = { + repository: { + pullRequest: { + latestOpinionatedReviews: { + nodes: OpinionatedReviewNode[]; + }; + } | null; + } | null; +}; + +async function getLatestApprovers({owner, repo, number}: {owner: string; repo: string; number: number}): Promise { + const response = await GitHubAPIClient.graphql( + ` + query LatestOpinionatedReviews($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + latestOpinionatedReviews(last: 100, writersOnly: true) { + nodes { + state + author { + login + } + } + } + } + } + } + `, + { + owner, + repo, + prNumber: number, + }, + ); + + const reviews: OpinionatedReviewNode[] = response.repository?.pullRequest?.latestOpinionatedReviews.nodes ?? []; + return CollectionUtils.uniqueSorted( + reviews + .filter((review) => review.state === 'APPROVED') + .map((review) => review.author?.login ?? '') + .filter((login) => login !== ''), + ); +} + +export default getLatestApprovers; diff --git a/scripts/libs/GitHubUtils/getRequiredApprovingReviewCount.ts b/scripts/libs/GitHubUtils/getRequiredApprovingReviewCount.ts new file mode 100644 index 0000000..24fafee --- /dev/null +++ b/scripts/libs/GitHubUtils/getRequiredApprovingReviewCount.ts @@ -0,0 +1,51 @@ +import GitHubAPIClient from '../GitHubAPIClient'; +import isPermissionError from './isPermissionError'; + +const DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT = 1; + +type BranchProtectionResponse = { + repository: { + ref: { + branchProtectionRule: { + requiredApprovingReviewCount: number; + } | null; + } | null; + } | null; +}; + +async function getRequiredApprovingReviewCount({owner, repo, baseRef}: {owner: string; repo: string; baseRef: string}): Promise { + try { + const response = await GitHubAPIClient.graphql( + ` + query RequiredApprovingReviewCount($owner: String!, $repo: String!, $branchRef: String!) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branchRef) { + branchProtectionRule { + requiredApprovingReviewCount + } + } + } + } + `, + { + owner, + repo, + branchRef: `refs/heads/${baseRef}`, + }, + ); + + return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0; + } catch (error: unknown) { + if (isPermissionError(error)) { + throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`); + } + + const message = error instanceof Error ? error.message : String(error); + console.warn( + `${owner}/${repo}@${baseRef} did not return a branch protection review count (${message}); requiring ${DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT} independent approval(s).`, + ); + return DEFAULT_REQUIRED_APPROVING_REVIEW_COUNT; + } +} + +export default getRequiredApprovingReviewCount; diff --git a/scripts/libs/GitHubUtils/index.ts b/scripts/libs/GitHubUtils/index.ts new file mode 100644 index 0000000..62aa95f --- /dev/null +++ b/scripts/libs/GitHubUtils/index.ts @@ -0,0 +1,11 @@ +import getEmployeeLogins from './getEmployeeLogins'; +import getLatestApprovers from './getLatestApprovers'; +import getRequiredApprovingReviewCount from './getRequiredApprovingReviewCount'; +import listPullRequestCommits from './listPullRequestCommits'; + +export default { + getEmployeeLogins, + getLatestApprovers, + getRequiredApprovingReviewCount, + listPullRequestCommits, +}; diff --git a/scripts/libs/GitHubUtils/isPermissionError.ts b/scripts/libs/GitHubUtils/isPermissionError.ts new file mode 100644 index 0000000..5efea65 --- /dev/null +++ b/scripts/libs/GitHubUtils/isPermissionError.ts @@ -0,0 +1,28 @@ +import {RequestError} from '@octokit/request-error'; + +type GraphQLErrorResponse = { + errors?: Array<{ + type?: string; + message?: string; + }>; +}; + +function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { + return typeof error === 'object' && error !== null && 'errors' in error; +} + +function isPermissionError(error: unknown): boolean { + if (error instanceof RequestError) { + return error.status === 401 || error.status === 403; + } + + if (isGraphQLErrorResponse(error)) { + const {errors} = error; + return errors?.some((graphQLError) => graphQLError.type === 'FORBIDDEN' || graphQLError.type === 'INSUFFICIENT_SCOPES') ?? false; + } + + const message = error instanceof Error ? error.message : String(error); + return /resource not accessible by integration|must have admin access|insufficient scopes|permission denied/i.test(message); +} + +export default isPermissionError; diff --git a/scripts/libs/GitHubUtils/listPullRequestCommits.ts b/scripts/libs/GitHubUtils/listPullRequestCommits.ts new file mode 100644 index 0000000..e143cdb --- /dev/null +++ b/scripts/libs/GitHubUtils/listPullRequestCommits.ts @@ -0,0 +1,14 @@ +import GitHubAPIClient from '../GitHubAPIClient'; + +async function listPullRequestCommits({owner, repo, number}: {owner: string; repo: string; number: number}) { + return GitHubAPIClient.paginate(GitHubAPIClient.octokit.pulls.listCommits, { + owner, + repo, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + pull_number: number, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit REST API uses snake_case parameters + per_page: 100, + }); +} + +export default listPullRequestCommits; From 4a6aa5514a073127fa00e139ebfe2ff0f7be9cc4 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 22:02:16 -0700 Subject: [PATCH 51/63] feat: detect GitHub App bots via isBotUser helper Check [bot] suffix before known Expensify bot logins. Co-authored-by: Cursor --- scripts/libs/GitHubUtils/index.ts | 2 ++ scripts/libs/GitHubUtils/isBotUser.ts | 11 +++++++++++ scripts/verifyPeerReview.ts | 6 ++---- tests/GitHubUtils.test.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 scripts/libs/GitHubUtils/isBotUser.ts diff --git a/scripts/libs/GitHubUtils/index.ts b/scripts/libs/GitHubUtils/index.ts index 62aa95f..6696758 100644 --- a/scripts/libs/GitHubUtils/index.ts +++ b/scripts/libs/GitHubUtils/index.ts @@ -1,11 +1,13 @@ import getEmployeeLogins from './getEmployeeLogins'; import getLatestApprovers from './getLatestApprovers'; import getRequiredApprovingReviewCount from './getRequiredApprovingReviewCount'; +import isBotUser from './isBotUser'; import listPullRequestCommits from './listPullRequestCommits'; export default { getEmployeeLogins, getLatestApprovers, getRequiredApprovingReviewCount, + isBotUser, listPullRequestCommits, }; diff --git a/scripts/libs/GitHubUtils/isBotUser.ts b/scripts/libs/GitHubUtils/isBotUser.ts new file mode 100644 index 0000000..92b98b9 --- /dev/null +++ b/scripts/libs/GitHubUtils/isBotUser.ts @@ -0,0 +1,11 @@ +const KNOWN_BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); + +function isBotUser(login: string): boolean { + if (login.endsWith('[bot]')) { + return true; + } + + return KNOWN_BOT_USERS.has(login); +} + +export default isBotUser; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 1090290..d57d42c 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -4,8 +4,6 @@ import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtil import GitHubUtils from './libs/GitHubUtils'; import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; -const BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); - type PullRequestContext = { owner: string; repo: string; @@ -84,7 +82,7 @@ function getCommitAuthors(commits: GitHubPullRequestCommit[]): { // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !BOT_USERS.has(canonicalAuthor)) { + if (canonicalAuthor && !GitHubUtils.isBotUser(canonicalAuthor)) { continue; } @@ -134,7 +132,7 @@ function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { }; } - if (authors.every((author) => BOT_USERS.has(author))) { + if (authors.every((author) => GitHubUtils.isBotUser(author))) { return { status: 'fail', error: new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`), diff --git a/tests/GitHubUtils.test.ts b/tests/GitHubUtils.test.ts index 9e221c4..25397bb 100644 --- a/tests/GitHubUtils.test.ts +++ b/tests/GitHubUtils.test.ts @@ -47,3 +47,17 @@ describe('getRequiredApprovingReviewCount', () => { await assert.rejects(() => GitHubUtils.getRequiredApprovingReviewCount(context), /Unable to read branch protection rules/); }); }); + +describe('isBotUser', () => { + it('returns true for GitHub App bot accounts', () => { + assert.equal(GitHubUtils.isBotUser('dependabot[bot]'), true); + }); + + it('returns true for known Expensify bot accounts', () => { + assert.equal(GitHubUtils.isBotUser('MelvinBot'), true); + }); + + it('returns false for human accounts', () => { + assert.equal(GitHubUtils.isBotUser('AndrewGable'), false); + }); +}); From 3630fe4ac89d9bc53c98fab43c4e8ff5c1536404 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 22:06:26 -0700 Subject: [PATCH 52/63] refactor: rename CLI arg to --pull-request-number Wrap kebab-case CLI named args in a block eslint disable. Co-authored-by: Cursor --- .github/workflows/verifyPeerReview.yml | 2 +- README.md | 2 +- scripts/verifyPeerReview.ts | 7 ++++--- tests/verifyPeerReviewCli.test.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/verifyPeerReview.yml b/.github/workflows/verifyPeerReview.yml index 2231531..a0dd391 100644 --- a/.github/workflows/verifyPeerReview.yml +++ b/.github/workflows/verifyPeerReview.yml @@ -58,7 +58,7 @@ jobs: npm run verify-peer-review -- --owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} - --number ${{ github.event.pull_request.number }} + --pull-request-number ${{ github.event.pull_request.number }} --base-ref ${{ github.event.pull_request.base.ref }} working-directory: GitHub-Actions env: diff --git a/README.md b/README.md index 3df63d1..3a2e32a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Smoke-test against a real pull request by passing the pull request metadata as C GITHUB_TOKEN=... npm run verify-peer-review -- \ --owner Expensify \ --repo Auth \ - --number 12345 \ + --pull-request-number 12345 \ --base-ref main ``` diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index d57d42c..61f4298 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -30,6 +30,7 @@ function formatUsers(users: string[]): string { } function getPullRequestContext(): PullRequestContext { + /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ const cli = new CLI({ namedArgs: { owner: { @@ -38,7 +39,7 @@ function getPullRequestContext(): PullRequestContext { repo: { description: 'Repository name', }, - number: { + 'pull-request-number': { description: 'Pull request number', parse: (value: string) => { const number = Number(value); @@ -48,17 +49,17 @@ function getPullRequestContext(): PullRequestContext { return number; }, }, - // eslint-disable-next-line @typescript-eslint/naming-convention -- expensify-common CLI uses kebab-case argument names 'base-ref': { description: 'Target branch ref for the pull request', }, }, }); + /* eslint-enable @typescript-eslint/naming-convention */ return { owner: cli.namedArgs.owner, repo: cli.namedArgs.repo, - number: cli.namedArgs.number, + number: cli.namedArgs['pull-request-number'], baseRef: cli.namedArgs['base-ref'], }; } diff --git a/tests/verifyPeerReviewCli.test.ts b/tests/verifyPeerReviewCli.test.ts index 8479ffc..0a25511 100644 --- a/tests/verifyPeerReviewCli.test.ts +++ b/tests/verifyPeerReviewCli.test.ts @@ -21,7 +21,7 @@ describe('getPullRequestContext', () => { }); it('parses required pull request CLI arguments', () => { - process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--number', '21136', '--base-ref', 'main'); + process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--pull-request-number', '21136', '--base-ref', 'main'); assert.deepEqual(VerifyPeerReview.getPullRequestContext(), { owner: 'Expensify', From e38935deb127cf776bba7b4739a7407758a176d6 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 22:09:58 -0700 Subject: [PATCH 53/63] refactor: inline CLI parsing in verifyPeerReview main Remove getPullRequestContext and use pullRequestNumber locally. Co-authored-by: Cursor --- scripts/verifyPeerReview.ts | 82 +++++++++++++------------------ tests/verifyPeerReviewCli.test.ts | 28 +++++++---- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 61f4298..0333e7d 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -4,13 +4,6 @@ import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtil import GitHubUtils from './libs/GitHubUtils'; import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; -type PullRequestContext = { - owner: string; - repo: string; - number: number; - baseRef: string; -}; - type PeerReviewInput = { owner: string; repo: string; @@ -29,41 +22,6 @@ function formatUsers(users: string[]): string { return users.length > 0 ? users.join(', ') : '(none)'; } -function getPullRequestContext(): PullRequestContext { - /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ - const cli = new CLI({ - namedArgs: { - owner: { - description: 'Repository owner organization or user login', - }, - repo: { - description: 'Repository name', - }, - 'pull-request-number': { - description: 'Pull request number', - parse: (value: string) => { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error('Must be a positive integer'); - } - return number; - }, - }, - 'base-ref': { - description: 'Target branch ref for the pull request', - }, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - return { - owner: cli.namedArgs.owner, - repo: cli.namedArgs.repo, - number: cli.namedArgs['pull-request-number'], - baseRef: cli.namedArgs['base-ref'], - }; -} - function isExpensifyEmail(email: string): boolean { return email.trim().toLowerCase().endsWith('@expensify.com'); } @@ -184,12 +142,41 @@ function emitPeerReviewFailure(error: unknown): never { } async function main(): Promise { - const {owner, repo, number, baseRef} = getPullRequestContext(); + /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ + const cli = new CLI({ + namedArgs: { + owner: { + description: 'Repository owner organization or user login', + }, + repo: { + description: 'Repository name', + }, + 'pull-request-number': { + description: 'Pull request number', + parse: (value: string) => { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error('Must be a positive integer'); + } + return number; + }, + }, + 'base-ref': { + description: 'Target branch ref for the pull request', + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const owner = cli.namedArgs.owner; + const repo = cli.namedArgs.repo; + const pullRequestNumber = cli.namedArgs['pull-request-number']; + const baseRef = cli.namedArgs['base-ref']; const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ GitHubUtils.getRequiredApprovingReviewCount({owner, repo, baseRef}), - GitHubUtils.getLatestApprovers({owner, repo, number}), - GitHubUtils.listPullRequestCommits({owner, repo, number}), + GitHubUtils.getLatestApprovers({owner, repo, number: pullRequestNumber}), + GitHubUtils.listPullRequestCommits({owner, repo, number: pullRequestNumber}), ]); const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); @@ -199,7 +186,7 @@ async function main(): Promise { const result = evaluatePeerReview({ owner, repo, - number, + number: pullRequestNumber, baseRef, requiredApprovingReviewCount, approvers, @@ -216,11 +203,10 @@ async function main(): Promise { throw result.error; } -export type {PeerReviewInput, PeerReviewResult, PullRequestContext}; +export type {PeerReviewInput, PeerReviewResult}; export default { main, - getPullRequestContext, evaluatePeerReview, getIndependentEmployeeApprovers, getCommitAuthors, diff --git a/tests/verifyPeerReviewCli.test.ts b/tests/verifyPeerReviewCli.test.ts index 0a25511..4fe8f89 100644 --- a/tests/verifyPeerReviewCli.test.ts +++ b/tests/verifyPeerReviewCli.test.ts @@ -1,11 +1,15 @@ import assert from 'node:assert/strict'; import {afterEach, beforeEach, describe, it} from 'node:test'; +import GitHubUtils from '../scripts/libs/GitHubUtils'; import VerifyPeerReview from '../scripts/verifyPeerReview'; const ORIGINAL_ARGV = process.argv; -describe('getPullRequestContext', () => { +describe('main CLI parsing', () => { let originalExit: typeof process.exit; + let originalGetRequiredApprovingReviewCount: typeof GitHubUtils.getRequiredApprovingReviewCount; + let originalGetLatestApprovers: typeof GitHubUtils.getLatestApprovers; + let originalListPullRequestCommits: typeof GitHubUtils.listPullRequestCommits; beforeEach(() => { process.argv = ['tsx', 'scripts/verifyPeerReview.ts']; @@ -13,25 +17,29 @@ describe('getPullRequestContext', () => { process.exit = (code?: string | number | null) => { throw new Error(`exit ${code ?? 0}`); }; + originalGetRequiredApprovingReviewCount = GitHubUtils.getRequiredApprovingReviewCount; + originalGetLatestApprovers = GitHubUtils.getLatestApprovers; + originalListPullRequestCommits = GitHubUtils.listPullRequestCommits; + GitHubUtils.getRequiredApprovingReviewCount = async () => 0; + GitHubUtils.getLatestApprovers = async () => []; + GitHubUtils.listPullRequestCommits = async () => []; }); afterEach(() => { process.argv = ORIGINAL_ARGV; process.exit = originalExit; + GitHubUtils.getRequiredApprovingReviewCount = originalGetRequiredApprovingReviewCount; + GitHubUtils.getLatestApprovers = originalGetLatestApprovers; + GitHubUtils.listPullRequestCommits = originalListPullRequestCommits; }); - it('parses required pull request CLI arguments', () => { + it('parses required pull request CLI arguments', async () => { process.argv.push('--owner', 'Expensify', '--repo', 'Auth', '--pull-request-number', '21136', '--base-ref', 'main'); - assert.deepEqual(VerifyPeerReview.getPullRequestContext(), { - owner: 'Expensify', - repo: 'Auth', - number: 21136, - baseRef: 'main', - }); + await assert.doesNotReject(() => VerifyPeerReview.main()); }); - it('fails when required arguments are missing', () => { - assert.throws(() => VerifyPeerReview.getPullRequestContext(), /exit 1/); + it('fails when required arguments are missing', async () => { + await assert.rejects(() => VerifyPeerReview.main(), /exit 1/); }); }); From 40831be052e1941a0f947674293106b9e39940fc Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 22:20:22 -0700 Subject: [PATCH 54/63] refactor: inline peer review failure handler in main catch Co-authored-by: Cursor --- scripts/verifyPeerReview.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 0333e7d..bb64d77 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -136,11 +136,6 @@ function getFailureTitle(message: string): string { return 'Peer review verification failed'; } -function emitPeerReviewFailure(error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - return GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); -} - async function main(): Promise { /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ const cli = new CLI({ @@ -214,5 +209,8 @@ export default { }; if (import.meta.main) { - main().catch(emitPeerReviewFailure); + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); + }); } From a94477434016357be438bee7ed170cd1e5007fbb Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 22:22:03 -0700 Subject: [PATCH 55/63] refactor: extract prSlug in evaluatePeerReview Co-authored-by: Cursor --- scripts/verifyPeerReview.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index bb64d77..170027c 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -69,18 +69,19 @@ function getIndependentEmployeeApprovers(approvers: string[], authors: string[], function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; + const prSlug = `${owner}/${repo}#${number}`; if (requiredApprovingReviewCount === 0) { return { status: 'skip', - reason: `${owner}/${repo}#${number} targets ${baseRef}, which does not require approving reviews.`, + reason: `${prSlug} targets ${baseRef}, which does not require approving reviews.`, }; } if (approvers.length === 0) { return { status: 'skip', - reason: `${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + reason: `${prSlug} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, }; } @@ -94,7 +95,7 @@ function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { if (authors.every((author) => GitHubUtils.isBotUser(author))) { return { status: 'fail', - error: new Error(`Unable to verify independent peer review because ${owner}/${repo}#${number} has no human commit authors or co-authors.`), + error: new Error(`Unable to verify independent peer review because ${prSlug} has no human commit authors or co-authors.`), }; } @@ -104,7 +105,7 @@ function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { status: 'fail', error: new Error( [ - `${owner}/${repo}#${number} does not have enough independent Expensify employee approvals.`, + `${prSlug} does not have enough independent Expensify employee approvals.`, `Required independent approvals: ${requiredApprovingReviewCount}`, `Commit authors/co-authors: ${formatUsers(authors)}`, `Approvers: ${formatUsers(approvers)}`, @@ -116,7 +117,7 @@ function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { return { status: 'pass', - reason: `${owner}/${repo}#${number} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + reason: `${prSlug} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, }; } From ec927c2a45e78d6f90413bf0b7492763c43ef261 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 23:06:24 -0700 Subject: [PATCH 56/63] feat: resolve co-authors from display name fallback When noreply email parsing fails, map co-author display names to employee logins so @expensify.com trailers do not block merge. Co-authored-by: Cursor --- scripts/libs/GitCommitUtils.ts | 65 ++++++++++++++++++++++++- scripts/verifyPeerReview.ts | 19 ++++---- tests/GitCommitUtils.test.ts | 58 ++++++++++++++++++++++ tests/verifyPeerReviewCoAuthors.test.ts | 26 ++++++++-- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index e52bb61..7714878 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -10,8 +10,22 @@ type GitHubPullRequestCommit = { }; }; +type GitHubCoAuthor = { + displayName: string; + email: string; +}; + +const GITHUB_LOGIN_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; + +function parseCoAuthors(message: string): GitHubCoAuthor[] { + return [...message.matchAll(/^Co-authored-by:\s+(.+?)<([^>]+)>$/gim)].map((match) => ({ + displayName: match[1].trim(), + email: match[2].trim(), + })); +} + function parseCoAuthorEmails(message: string): string[] { - return [...message.matchAll(/^Co-authored-by:\s+.+<(.+)>$/gim)].map((match) => match[1].trim()); + return parseCoAuthors(message).map((coAuthor) => coAuthor.email); } function resolveNoreplyEmailToLogin(email: string): string | null { @@ -19,6 +33,50 @@ function resolveNoreplyEmailToLogin(email: string): string | null { return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; } +function resolveDisplayNameToLogin(displayName: string): string | null { + const trimmedDisplayName = displayName.trim(); + if (!trimmedDisplayName) { + return null; + } + + const candidates = [trimmedDisplayName, trimmedDisplayName.replaceAll(/\s+/g, '')]; + for (const candidate of candidates) { + if (GITHUB_LOGIN_PATTERN.test(candidate)) { + return candidate; + } + } + + return null; +} + +function findAllowedLogin(login: string, allowedLogins: Set): string | null { + for (const allowedLogin of allowedLogins) { + if (allowedLogin.toLowerCase() === login.toLowerCase()) { + return allowedLogin; + } + } + + return null; +} + +function resolveCoAuthorToLogin(coAuthor: GitHubCoAuthor, allowedLogins?: Set): string | null { + const loginFromNoreply = resolveNoreplyEmailToLogin(coAuthor.email); + if (loginFromNoreply) { + return loginFromNoreply; + } + + const loginFromDisplayName = resolveDisplayNameToLogin(coAuthor.displayName); + if (!loginFromDisplayName) { + return null; + } + + if (!allowedLogins) { + return loginFromDisplayName; + } + + return findAllowedLogin(loginFromDisplayName, allowedLogins); +} + function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { const authorLogin = commit.author?.login ?? ''; if (authorLogin) { @@ -29,10 +87,13 @@ function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { return commit.commit.author?.name?.trim() ?? ''; } -export type {GitHubPullRequestCommit}; +export type {GitHubCoAuthor, GitHubPullRequestCommit}; export default { parseCoAuthorEmails, + parseCoAuthors, getCanonicalAuthorLogin, + resolveCoAuthorToLogin, + resolveDisplayNameToLogin, resolveNoreplyEmailToLogin, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 170027c..09a4d56 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -26,7 +26,10 @@ function isExpensifyEmail(email: string): boolean { return email.trim().toLowerCase().endsWith('@expensify.com'); } -function getCommitAuthors(commits: GitHubPullRequestCommit[]): { +function getCommitAuthors( + commits: GitHubPullRequestCommit[], + employeeLogins?: Set, +): { authors: string[]; unresolvedExpensifyCoAuthors: string[]; } { @@ -45,13 +48,12 @@ function getCommitAuthors(commits: GitHubPullRequestCommit[]): { continue; } - for (const email of GitCommitUtils.parseCoAuthorEmails(commit.commit.message)) { - const login = GitCommitUtils.resolveNoreplyEmailToLogin(email); + for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { + const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); if (login) { authors.add(login); - } else if (isExpensifyEmail(email)) { - // Open-source action cannot resolve @expensify.com emails via internal whitelist; fail-hard instead. - unresolvedExpensifyCoAuthors.add(email.trim()); + } else if (isExpensifyEmail(coAuthor.email)) { + unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); } } } @@ -175,9 +177,8 @@ async function main(): Promise { GitHubUtils.listPullRequestCommits({owner, repo, number: pullRequestNumber}), ]); - const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits); - - const employeeLogins = approvers.length > 0 && requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); + const employeeLogins = requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); + const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits, employeeLogins); const result = evaluatePeerReview({ owner, diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index bde4d07..d1f71dc 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -35,6 +35,64 @@ describe('parseCoAuthorEmails', () => { }); }); +describe('parseCoAuthors', () => { + it('extracts display names and emails', () => { + const message = 'Change\n\nCo-authored-by: Andrew Gable '; + + assert.deepEqual(GitCommitUtils.parseCoAuthors(message), [{displayName: 'Andrew Gable', email: 'andrew@expensify.com'}]); + }); +}); + +describe('resolveDisplayNameToLogin', () => { + it('parses github usernames from display names', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin('AndrewGable'), 'AndrewGable'); + }); + + it('removes spaces from display names', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin('Andrew Gable'), 'AndrewGable'); + }); + + it('returns null when display name cannot map to a github username', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin("John O'Brien"), null); + }); +}); + +describe('resolveCoAuthorToLogin', () => { + it('prefers noreply email resolution over display name', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: 'Wrong Name', + email: 'AndrewGable@users.noreply.github.com', + }), + 'AndrewGable', + ); + }); + + it('falls back to display name when email cannot be resolved', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: 'Andrew Gable', + email: 'andrew@expensify.com', + }), + 'AndrewGable', + ); + }); + + it('rejects display names that do not match allowed logins', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({displayName: 'John Smith', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), + null, + ); + }); + + it('uses canonical allowed login casing', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({displayName: 'andrew gable', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), + 'AndrewGable', + ); + }); +}); + describe('getCanonicalAuthorLogin', () => { it('falls back to commit author name when github login is missing', () => { assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit(undefined, 'AndrewGable', 'Change')), 'AndrewGable'); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index 0a5198c..82494a2 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -13,9 +13,11 @@ function makeCommit(authorLogin: string | undefined, authorName: string | undefi }; } +const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); + describe('getCommitAuthors', () => { it('counts co-authors for bot-authored commits', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); @@ -34,13 +36,29 @@ describe('getCommitAuthors', () => { }); it('normalizes expensify email casing and whitespace for unresolved detection', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable < Andrew@Expensify.com >')]); + const result = VerifyPeerReview.getCommitAuthors( + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); }); - it('collects unresolved expensify co-author emails', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); + it('resolves expensify co-authors from display name when email cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors( + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], + EMPLOYEE_LOGINS, + ); + + assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); + + it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors( + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); }); From 62b825ac575a51cbc3354eb193f436b4384f07dc Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 25 Jun 2026 23:16:01 -0700 Subject: [PATCH 57/63] Fix prettier --- tests/GitCommitUtils.test.ts | 10 ++-------- tests/verifyPeerReviewCoAuthors.test.ts | 20 +++++++------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index d1f71dc..ebb63e8 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -79,17 +79,11 @@ describe('resolveCoAuthorToLogin', () => { }); it('rejects display names that do not match allowed logins', () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({displayName: 'John Smith', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), - null, - ); + assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'John Smith', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), null); }); it('uses canonical allowed login casing', () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({displayName: 'andrew gable', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), - 'AndrewGable', - ); + assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'andrew gable', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), 'AndrewGable'); }); }); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index 82494a2..6f548b0 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -17,7 +17,10 @@ const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); describe('getCommitAuthors', () => { it('counts co-authors for bot-authored commits', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); + const result = VerifyPeerReview.getCommitAuthors( + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); @@ -36,29 +39,20 @@ describe('getCommitAuthors', () => { }); it('normalizes expensify email casing and whitespace for unresolved detection', () => { - const result = VerifyPeerReview.getCommitAuthors( - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], - EMPLOYEE_LOGINS, - ); + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], EMPLOYEE_LOGINS); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); }); it('resolves expensify co-authors from display name when email cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors( - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], - EMPLOYEE_LOGINS, - ); + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); }); it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors( - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], - EMPLOYEE_LOGINS, - ); + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], EMPLOYEE_LOGINS); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); }); From 817d8664ab9f97377c4cc817463e8cbc225306fa Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 29 Jun 2026 10:54:30 -0700 Subject: [PATCH 58/63] fix: throw when canonical commit author cannot be resolved Fail loudly when GitHub author login and commit author name are both missing instead of silently continuing with an empty author. Co-authored-by: Cursor --- scripts/libs/GitCommitUtils.ts | 146 +++++---- scripts/verifyPeerReview.ts | 397 +++++++++++++----------- tests/GitCommitUtils.test.ts | 221 ++++++++----- tests/verifyPeerReviewCoAuthors.test.ts | 147 ++++++--- tests/verifyPeerReviewPolicy.test.ts | 223 +++++++------ 5 files changed, 682 insertions(+), 452 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 7714878..db55fcc 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -1,99 +1,121 @@ type GitHubPullRequestCommit = { - author: { - login?: string; + author: { + login?: string; + } | null; + commit: { + message: string; + author?: { + name?: string | null; } | null; - commit: { - message: string; - author?: { - name?: string | null; - } | null; - }; + }; }; type GitHubCoAuthor = { - displayName: string; - email: string; + displayName: string; + email: string; }; const GITHUB_LOGIN_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; function parseCoAuthors(message: string): GitHubCoAuthor[] { - return [...message.matchAll(/^Co-authored-by:\s+(.+?)<([^>]+)>$/gim)].map((match) => ({ - displayName: match[1].trim(), - email: match[2].trim(), - })); + return [...message.matchAll(/^Co-authored-by:\s+(.+?)<([^>]+)>$/gim)].map( + (match) => ({ + displayName: match[1].trim(), + email: match[2].trim(), + }), + ); } function parseCoAuthorEmails(message: string): string[] { - return parseCoAuthors(message).map((coAuthor) => coAuthor.email); + return parseCoAuthors(message).map((coAuthor) => coAuthor.email); } function resolveNoreplyEmailToLogin(email: string): string | null { - const normalizedEmail = email.trim(); - return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; + const normalizedEmail = email.trim(); + return ( + normalizedEmail.match( + /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, + )?.[1] ?? null + ); } function resolveDisplayNameToLogin(displayName: string): string | null { - const trimmedDisplayName = displayName.trim(); - if (!trimmedDisplayName) { - return null; - } - - const candidates = [trimmedDisplayName, trimmedDisplayName.replaceAll(/\s+/g, '')]; - for (const candidate of candidates) { - if (GITHUB_LOGIN_PATTERN.test(candidate)) { - return candidate; - } + const trimmedDisplayName = displayName.trim(); + if (!trimmedDisplayName) { + return null; + } + + const candidates = [ + trimmedDisplayName, + trimmedDisplayName.replaceAll(/\s+/g, ""), + ]; + for (const candidate of candidates) { + if (GITHUB_LOGIN_PATTERN.test(candidate)) { + return candidate; } + } - return null; + return null; } -function findAllowedLogin(login: string, allowedLogins: Set): string | null { - for (const allowedLogin of allowedLogins) { - if (allowedLogin.toLowerCase() === login.toLowerCase()) { - return allowedLogin; - } +function findAllowedLogin( + login: string, + allowedLogins: Set, +): string | null { + for (const allowedLogin of allowedLogins) { + if (allowedLogin.toLowerCase() === login.toLowerCase()) { + return allowedLogin; } + } - return null; + return null; } -function resolveCoAuthorToLogin(coAuthor: GitHubCoAuthor, allowedLogins?: Set): string | null { - const loginFromNoreply = resolveNoreplyEmailToLogin(coAuthor.email); - if (loginFromNoreply) { - return loginFromNoreply; - } - - const loginFromDisplayName = resolveDisplayNameToLogin(coAuthor.displayName); - if (!loginFromDisplayName) { - return null; - } +function resolveCoAuthorToLogin( + coAuthor: GitHubCoAuthor, + allowedLogins?: Set, +): string | null { + const loginFromNoreply = resolveNoreplyEmailToLogin(coAuthor.email); + if (loginFromNoreply) { + return loginFromNoreply; + } + + const loginFromDisplayName = resolveDisplayNameToLogin(coAuthor.displayName); + if (!loginFromDisplayName) { + return null; + } - if (!allowedLogins) { - return loginFromDisplayName; - } + if (!allowedLogins) { + return loginFromDisplayName; + } - return findAllowedLogin(loginFromDisplayName, allowedLogins); + return findAllowedLogin(loginFromDisplayName, allowedLogins); } function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { - const authorLogin = commit.author?.login ?? ''; - if (authorLogin) { - return authorLogin; - } - - // If the author's profile is private, author.login may be missing. Fall back to the commit author name. - return commit.commit.author?.name?.trim() ?? ''; + const authorLogin = commit.author?.login?.trim() ?? ""; + if (authorLogin) { + return authorLogin; + } + + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + const authorName = commit.commit.author?.name?.trim() ?? ""; + if (authorName) { + return authorName; + } + + throw new Error( + "Unable to resolve canonical commit author: missing GitHub author login and commit author name.", + ); } -export type {GitHubCoAuthor, GitHubPullRequestCommit}; +export type { GitHubCoAuthor, GitHubPullRequestCommit }; export default { - parseCoAuthorEmails, - parseCoAuthors, - getCanonicalAuthorLogin, - resolveCoAuthorToLogin, - resolveDisplayNameToLogin, - resolveNoreplyEmailToLogin, + parseCoAuthorEmails, + parseCoAuthors, + getCanonicalAuthorLogin, + resolveCoAuthorToLogin, + resolveDisplayNameToLogin, + resolveNoreplyEmailToLogin, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 09a4d56..1c3a670 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,218 +1,269 @@ -import CLI from 'expensify-common/CLI'; -import CollectionUtils from './libs/CollectionUtils'; -import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtils'; -import GitHubUtils from './libs/GitHubUtils'; -import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; +import CLI from "expensify-common/CLI"; +import CollectionUtils from "./libs/CollectionUtils"; +import GitCommitUtils, { + type GitHubPullRequestCommit, +} from "./libs/GitCommitUtils"; +import GitHubUtils from "./libs/GitHubUtils"; +import GitHubWorkflowUtils from "./libs/GitHubWorkflowUtils"; type PeerReviewInput = { - owner: string; - repo: string; - number: number; - baseRef: string; - requiredApprovingReviewCount: number; - approvers: string[]; - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; - employeeLogins: Set; + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; }; -type PeerReviewResult = {status: 'pass'; reason: string} | {status: 'skip'; reason: string} | {status: 'fail'; error: Error}; +type PeerReviewResult = + | { status: "pass"; reason: string } + | { status: "skip"; reason: string } + | { status: "fail"; error: Error }; function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(', ') : '(none)'; + return users.length > 0 ? users.join(", ") : "(none)"; } function isExpensifyEmail(email: string): boolean { - return email.trim().toLowerCase().endsWith('@expensify.com'); + return email.trim().toLowerCase().endsWith("@expensify.com"); } function getCommitAuthors( - commits: GitHubPullRequestCommit[], - employeeLogins?: Set, + commits: GitHubPullRequestCommit[], + employeeLogins?: Set, ): { - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; } { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); - - for (const commit of commits) { - const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); - if (canonicalAuthor) { - authors.add(canonicalAuthor); - } - - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is missing or is a bot. - if (canonicalAuthor && !GitHubUtils.isBotUser(canonicalAuthor)) { - continue; - } - - for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { - const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(coAuthor.email)) { - unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); - } - } + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + authors.add(canonicalAuthor); + + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is a bot. + if (!GitHubUtils.isBotUser(canonicalAuthor)) { + continue; } - return { - authors: CollectionUtils.uniqueSorted([...authors]), - unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([...unresolvedExpensifyCoAuthors]), - }; + for (const coAuthor of GitCommitUtils.parseCoAuthors( + commit.commit.message, + )) { + const login = GitCommitUtils.resolveCoAuthorToLogin( + coAuthor, + employeeLogins, + ); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(coAuthor.email)) { + unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); + } + } + } + + return { + authors: CollectionUtils.uniqueSorted([...authors]), + unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([ + ...unresolvedExpensifyCoAuthors, + ]), + }; } -function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { - const authorSet = new Set(authors); - return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); +function getIndependentEmployeeApprovers( + approvers: string[], + authors: string[], + employeeLogins: Set, +): string[] { + const authorSet = new Set(authors); + return approvers.filter( + (approver) => !authorSet.has(approver) && employeeLogins.has(approver), + ); } function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { - const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; - const prSlug = `${owner}/${repo}#${number}`; - - if (requiredApprovingReviewCount === 0) { - return { - status: 'skip', - reason: `${prSlug} targets ${baseRef}, which does not require approving reviews.`, - }; - } + const { + owner, + repo, + number, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + } = input; + const prSlug = `${owner}/${repo}#${number}`; - if (approvers.length === 0) { - return { - status: 'skip', - reason: `${prSlug} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - }; - } + if (requiredApprovingReviewCount === 0) { + return { + status: "skip", + reason: `${prSlug} targets ${baseRef}, which does not require approving reviews.`, + }; + } - if (unresolvedExpensifyCoAuthors.length > 0) { - return { - status: 'fail', - error: new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`), - }; - } + if (approvers.length === 0) { + return { + status: "skip", + reason: `${prSlug} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } - if (authors.every((author) => GitHubUtils.isBotUser(author))) { - return { - status: 'fail', - error: new Error(`Unable to verify independent peer review because ${prSlug} has no human commit authors or co-authors.`), - }; - } + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: "fail", + error: new Error( + `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, + ), + }; + } - const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { - return { - status: 'fail', - error: new Error( - [ - `${prSlug} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, - ].join('\n'), - ), - }; - } + if (authors.every((author) => GitHubUtils.isBotUser(author))) { + return { + status: "fail", + error: new Error( + `Unable to verify independent peer review because ${prSlug} has no human commit authors or co-authors.`, + ), + }; + } + const independentEmployeeApprovers = getIndependentEmployeeApprovers( + approvers, + authors, + employeeLogins, + ); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { return { - status: 'pass', - reason: `${prSlug} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + status: "fail", + error: new Error( + [ + `${prSlug} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join("\n"), + ), }; + } + + return { + status: "pass", + reason: `${prSlug} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, + }; } function getFailureTitle(message: string): string { - if (message.includes('does not have enough independent Expensify employee approvals')) { - return 'Missing independent peer review'; - } - if (message.includes('Unable to resolve Expensify co-author emails')) { - return 'Unresolved Expensify co-author'; - } - if (message.includes('has no human commit authors or co-authors')) { - return 'No human commit author'; - } - if (message.includes('Unable to read branch protection rules')) { - return 'Branch protection lookup failed'; - } - return 'Peer review verification failed'; + if ( + message.includes( + "does not have enough independent Expensify employee approvals", + ) + ) { + return "Missing independent peer review"; + } + if (message.includes("Unable to resolve Expensify co-author emails")) { + return "Unresolved Expensify co-author"; + } + if (message.includes("Unable to resolve canonical commit author")) { + return "Missing commit author"; + } + if (message.includes("has no human commit authors or co-authors")) { + return "No human commit author"; + } + if (message.includes("Unable to read branch protection rules")) { + return "Branch protection lookup failed"; + } + return "Peer review verification failed"; } async function main(): Promise { - /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ - const cli = new CLI({ - namedArgs: { - owner: { - description: 'Repository owner organization or user login', - }, - repo: { - description: 'Repository name', - }, - 'pull-request-number': { - description: 'Pull request number', - parse: (value: string) => { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error('Must be a positive integer'); - } - return number; - }, - }, - 'base-ref': { - description: 'Target branch ref for the pull request', - }, + /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ + const cli = new CLI({ + namedArgs: { + owner: { + description: "Repository owner organization or user login", + }, + repo: { + description: "Repository name", + }, + "pull-request-number": { + description: "Pull request number", + parse: (value: string) => { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("Must be a positive integer"); + } + return number; }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - const owner = cli.namedArgs.owner; - const repo = cli.namedArgs.repo; - const pullRequestNumber = cli.namedArgs['pull-request-number']; - const baseRef = cli.namedArgs['base-ref']; - - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - GitHubUtils.getRequiredApprovingReviewCount({owner, repo, baseRef}), - GitHubUtils.getLatestApprovers({owner, repo, number: pullRequestNumber}), - GitHubUtils.listPullRequestCommits({owner, repo, number: pullRequestNumber}), - ]); - - const employeeLogins = requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); - const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits, employeeLogins); - - const result = evaluatePeerReview({ - owner, - repo, - number: pullRequestNumber, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - }); + }, + "base-ref": { + description: "Target branch ref for the pull request", + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ - if (result.status === 'skip' || result.status === 'pass') { - console.log(result.reason); - return; - } + const owner = cli.namedArgs.owner; + const repo = cli.namedArgs.repo; + const pullRequestNumber = cli.namedArgs["pull-request-number"]; + const baseRef = cli.namedArgs["base-ref"]; + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + GitHubUtils.getRequiredApprovingReviewCount({ owner, repo, baseRef }), + GitHubUtils.getLatestApprovers({ owner, repo, number: pullRequestNumber }), + GitHubUtils.listPullRequestCommits({ + owner, + repo, + number: pullRequestNumber, + }), + ]); + + const employeeLogins = + requiredApprovingReviewCount > 0 + ? await GitHubUtils.getEmployeeLogins() + : new Set(); + const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors( + commits, + employeeLogins, + ); + + const result = evaluatePeerReview({ + owner, + repo, + number: pullRequestNumber, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + }); + + if (result.status === "skip" || result.status === "pass") { + console.log(result.reason); + return; + } - throw result.error; + throw result.error; } -export type {PeerReviewInput, PeerReviewResult}; +export type { PeerReviewInput, PeerReviewResult }; export default { - main, - evaluatePeerReview, - getIndependentEmployeeApprovers, - getCommitAuthors, - getFailureTitle, + main, + evaluatePeerReview, + getIndependentEmployeeApprovers, + getCommitAuthors, + getFailureTitle, }; if (import.meta.main) { - main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); - }); + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); + }); } diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index ebb63e8..54b2920 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -1,94 +1,161 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import GitCommitUtils, {type GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; - -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { - return { - author: authorLogin ? {login: authorLogin} : null, - commit: { - message, - author: authorName ? {name: authorName} : {}, - }, - }; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import GitCommitUtils, { + type GitHubPullRequestCommit, +} from "../scripts/libs/GitCommitUtils"; + +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, +): GitHubPullRequestCommit { + return { + author: authorLogin ? { login: authorLogin } : null, + commit: { + message, + author: authorName ? { name: authorName } : {}, + }, + }; } -describe('resolveNoreplyEmailToLogin', () => { - it('parses standard noreply addresses', () => { - assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('AndrewGable@users.noreply.github.com'), 'AndrewGable'); - }); +describe("resolveNoreplyEmailToLogin", () => { + it("parses standard noreply addresses", () => { + assert.equal( + GitCommitUtils.resolveNoreplyEmailToLogin( + "AndrewGable@users.noreply.github.com", + ), + "AndrewGable", + ); + }); - it('parses numeric noreply prefixes', () => { - assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('2838819+AndrewGable@users.noreply.github.com'), 'AndrewGable'); - }); + it("parses numeric noreply prefixes", () => { + assert.equal( + GitCommitUtils.resolveNoreplyEmailToLogin( + "2838819+AndrewGable@users.noreply.github.com", + ), + "AndrewGable", + ); + }); }); -describe('parseCoAuthorEmails', () => { - it('extracts multiple co-author emails', () => { - const message = [ - 'Some change', - '', - 'Co-authored-by: Andrew Gable ', - 'Co-authored-by: Monil Bhavsar ', - ].join('\n'); - - assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), ['AndrewGable@users.noreply.github.com', 'MonilBhavsar@users.noreply.github.com']); - }); +describe("parseCoAuthorEmails", () => { + it("extracts multiple co-author emails", () => { + const message = [ + "Some change", + "", + "Co-authored-by: Andrew Gable ", + "Co-authored-by: Monil Bhavsar ", + ].join("\n"); + + assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), [ + "AndrewGable@users.noreply.github.com", + "MonilBhavsar@users.noreply.github.com", + ]); + }); }); -describe('parseCoAuthors', () => { - it('extracts display names and emails', () => { - const message = 'Change\n\nCo-authored-by: Andrew Gable '; +describe("parseCoAuthors", () => { + it("extracts display names and emails", () => { + const message = + "Change\n\nCo-authored-by: Andrew Gable "; - assert.deepEqual(GitCommitUtils.parseCoAuthors(message), [{displayName: 'Andrew Gable', email: 'andrew@expensify.com'}]); - }); + assert.deepEqual(GitCommitUtils.parseCoAuthors(message), [ + { displayName: "Andrew Gable", email: "andrew@expensify.com" }, + ]); + }); }); -describe('resolveDisplayNameToLogin', () => { - it('parses github usernames from display names', () => { - assert.equal(GitCommitUtils.resolveDisplayNameToLogin('AndrewGable'), 'AndrewGable'); - }); +describe("resolveDisplayNameToLogin", () => { + it("parses github usernames from display names", () => { + assert.equal( + GitCommitUtils.resolveDisplayNameToLogin("AndrewGable"), + "AndrewGable", + ); + }); - it('removes spaces from display names', () => { - assert.equal(GitCommitUtils.resolveDisplayNameToLogin('Andrew Gable'), 'AndrewGable'); - }); + it("removes spaces from display names", () => { + assert.equal( + GitCommitUtils.resolveDisplayNameToLogin("Andrew Gable"), + "AndrewGable", + ); + }); - it('returns null when display name cannot map to a github username', () => { - assert.equal(GitCommitUtils.resolveDisplayNameToLogin("John O'Brien"), null); - }); + it("returns null when display name cannot map to a github username", () => { + assert.equal( + GitCommitUtils.resolveDisplayNameToLogin("John O'Brien"), + null, + ); + }); }); -describe('resolveCoAuthorToLogin', () => { - it('prefers noreply email resolution over display name', () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({ - displayName: 'Wrong Name', - email: 'AndrewGable@users.noreply.github.com', - }), - 'AndrewGable', - ); - }); - - it('falls back to display name when email cannot be resolved', () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({ - displayName: 'Andrew Gable', - email: 'andrew@expensify.com', - }), - 'AndrewGable', - ); - }); - - it('rejects display names that do not match allowed logins', () => { - assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'John Smith', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), null); - }); - - it('uses canonical allowed login casing', () => { - assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'andrew gable', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), 'AndrewGable'); - }); +describe("resolveCoAuthorToLogin", () => { + it("prefers noreply email resolution over display name", () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: "Wrong Name", + email: "AndrewGable@users.noreply.github.com", + }), + "AndrewGable", + ); + }); + + it("falls back to display name when email cannot be resolved", () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: "Andrew Gable", + email: "andrew@expensify.com", + }), + "AndrewGable", + ); + }); + + it("rejects display names that do not match allowed logins", () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin( + { displayName: "John Smith", email: "andrew@expensify.com" }, + new Set(["AndrewGable"]), + ), + null, + ); + }); + + it("uses canonical allowed login casing", () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin( + { displayName: "andrew gable", email: "andrew@expensify.com" }, + new Set(["AndrewGable"]), + ), + "AndrewGable", + ); + }); }); -describe('getCanonicalAuthorLogin', () => { - it('falls back to commit author name when github login is missing', () => { - assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit(undefined, 'AndrewGable', 'Change')), 'AndrewGable'); - }); +describe("getCanonicalAuthorLogin", () => { + it("returns github author login when present", () => { + assert.equal( + GitCommitUtils.getCanonicalAuthorLogin( + makeCommit("AndrewGable", undefined, "Change"), + ), + "AndrewGable", + ); + }); + + it("falls back to commit author name when github login is missing", () => { + assert.equal( + GitCommitUtils.getCanonicalAuthorLogin( + makeCommit(undefined, "AndrewGable", "Change"), + ), + "AndrewGable", + ); + }); + + it("throws when canonical author cannot be resolved", () => { + assert.throws( + () => + GitCommitUtils.getCanonicalAuthorLogin( + makeCommit(undefined, undefined, "Change"), + ), + /Unable to resolve canonical commit author/, + ); + }); }); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index 6f548b0..71ff041 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -1,59 +1,118 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import type {GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; -import VerifyPeerReview from '../scripts/verifyPeerReview'; - -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { - return { - author: authorLogin ? {login: authorLogin} : null, - commit: { - message, - author: authorName ? {name: authorName} : {}, - }, - }; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GitHubPullRequestCommit } from "../scripts/libs/GitCommitUtils"; +import VerifyPeerReview from "../scripts/verifyPeerReview"; + +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, +): GitHubPullRequestCommit { + return { + author: authorLogin ? { login: authorLogin } : null, + commit: { + message, + author: authorName ? { name: authorName } : {}, + }, + }; } -const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); +const EMPLOYEE_LOGINS = new Set(["AndrewGable", "MelvinBot", "rafecolton"]); + +describe("getCommitAuthors", () => { + it("counts co-authors for bot-authored commits", () => { + const result = VerifyPeerReview.getCommitAuthors( + [ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ], + EMPLOYEE_LOGINS, + ); -describe('getCommitAuthors', () => { - it('counts co-authors for bot-authored commits', () => { - const result = VerifyPeerReview.getCommitAuthors( - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], - EMPLOYEE_LOGINS, - ); + assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + it("ignores co-authors when canonical author is human", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit( + "rafecolton", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ]); - it('ignores co-authors when canonical author is human', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); + assert.deepEqual(result.authors, ["rafecolton"]); + }); - assert.deepEqual(result.authors, ['rafecolton']); - }); + it("falls back to commit author name when github login is missing", () => { + const result = VerifyPeerReview.getCommitAuthors([ + makeCommit(undefined, "AndrewGable", "Change"), + ]); - it('falls back to commit author name when github login is missing', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); + assert.deepEqual(result.authors, ["AndrewGable"]); + }); - assert.deepEqual(result.authors, ['AndrewGable']); - }); + it("normalizes expensify email casing and whitespace for unresolved detection", () => { + const result = VerifyPeerReview.getCommitAuthors( + [ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >", + ), + ], + EMPLOYEE_LOGINS, + ); - it('normalizes expensify email casing and whitespace for unresolved detection', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], EMPLOYEE_LOGINS); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "Andrew@Expensify.com", + ]); + }); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); - }); + it("resolves expensify co-authors from display name when email cannot be mapped", () => { + const result = VerifyPeerReview.getCommitAuthors( + [ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: Andrew Gable ", + ), + ], + EMPLOYEE_LOGINS, + ); - it('resolves expensify co-authors from display name when email cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); + assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + it("collects unresolved expensify co-author emails when display name cannot be mapped", () => { + const result = VerifyPeerReview.getCommitAuthors( + [ + makeCommit( + "MelvinBot", + undefined, + "Change\n\nCo-authored-by: John Smith ", + ), + ], + EMPLOYEE_LOGINS, + ); - it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], EMPLOYEE_LOGINS); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ + "andrew@expensify.com", + ]); + }); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); - }); + it("throws when canonical author cannot be resolved", () => { + assert.throws( + () => + VerifyPeerReview.getCommitAuthors([ + makeCommit(undefined, undefined, "Change"), + ]), + /Unable to resolve canonical commit author/, + ); + }); }); diff --git a/tests/verifyPeerReviewPolicy.test.ts b/tests/verifyPeerReviewPolicy.test.ts index e8ba251..7881627 100644 --- a/tests/verifyPeerReviewPolicy.test.ts +++ b/tests/verifyPeerReviewPolicy.test.ts @@ -1,125 +1,156 @@ -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; -import VerifyPeerReview, {type PeerReviewInput} from '../scripts/verifyPeerReview'; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import VerifyPeerReview, { + type PeerReviewInput, +} from "../scripts/verifyPeerReview"; const baseInput: PeerReviewInput = { - owner: 'Expensify', - repo: 'Auth', - number: 21136, - baseRef: 'main', - requiredApprovingReviewCount: 1, - approvers: [], - authors: [], - unresolvedExpensifyCoAuthors: [], - employeeLogins: new Set(['MonilBhavsar', 'AndrewGable', 'rafecolton']), + owner: "Expensify", + repo: "Auth", + number: 21136, + baseRef: "main", + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), }; -describe('getIndependentEmployeeApprovers', () => { - it('excludes commit authors and non-employees', () => { - const independent = VerifyPeerReview.getIndependentEmployeeApprovers(['AndrewGable', 'MonilBhavsar', 'externalCollab'], ['AndrewGable'], new Set(['AndrewGable', 'MonilBhavsar'])); +describe("getIndependentEmployeeApprovers", () => { + it("excludes commit authors and non-employees", () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers( + ["AndrewGable", "MonilBhavsar", "externalCollab"], + ["AndrewGable"], + new Set(["AndrewGable", "MonilBhavsar"]), + ); - assert.deepEqual(independent, ['MonilBhavsar']); - }); + assert.deepEqual(independent, ["MonilBhavsar"]); + }); }); -describe('evaluatePeerReview', () => { - it('skips when branch requires no approving reviews', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 0, - approvers: ['AndrewGable'], - authors: ['AndrewGable'], - }); +describe("evaluatePeerReview", () => { + it("skips when branch requires no approving reviews", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ["AndrewGable"], + authors: ["AndrewGable"], + }); + + assert.equal(result.status, "skip"); + }); - assert.equal(result.status, 'skip'); + it("skips when there are no approving reviews yet", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ["MelvinBot", "AndrewGable"], }); - it('skips when there are no approving reviews yet', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: [], - authors: ['MelvinBot', 'AndrewGable'], - }); + assert.equal(result.status, "skip"); + }); - assert.equal(result.status, 'skip'); + it("fails on self-review from commit co-author", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], }); - it('fails on self-review from commit co-author', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /does not have enough independent Expensify employee approvals/); - } + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /does not have enough independent Expensify employee approvals/, + ); + } + }); + + it("passes when an independent employee approves", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], }); - it('passes when an independent employee approves', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ['MonilBhavsar', 'AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); + assert.equal(result.status, "pass"); + }); - assert.equal(result.status, 'pass'); + it("fails when independent approver count is below required", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "AndrewGable"], + authors: ["MelvinBot", "AndrewGable"], }); - it('fails when independent approver count is below required', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ['MonilBhavsar', 'AndrewGable'], - authors: ['MelvinBot', 'AndrewGable'], - }); + assert.equal(result.status, "fail"); + }); - assert.equal(result.status, 'fail'); + it("passes when independent approver count meets required", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ["MonilBhavsar", "rafecolton"], + authors: ["MelvinBot", "AndrewGable"], }); - it('passes when independent approver count meets required', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ['MonilBhavsar', 'rafecolton'], - authors: ['MelvinBot', 'AndrewGable'], - }); + assert.equal(result.status, "pass"); + }); - assert.equal(result.status, 'pass'); + it("fails when all authors are bots", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], }); - it('fails when all authors are bots', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /no human commit authors or co-authors/); - } + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /no human commit authors or co-authors/, + ); + } + }); + + it("fails on unresolved expensify co-author emails", () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ["AndrewGable"], + authors: ["MelvinBot"], + unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], }); - it('fails on unresolved expensify co-author emails', () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ['AndrewGable'], - authors: ['MelvinBot'], - unresolvedExpensifyCoAuthors: ['andrew@expensify.com'], - }); - - assert.equal(result.status, 'fail'); - if (result.status === 'fail') { - assert.match(result.error.message, /Unable to resolve Expensify co-author emails/); - } - }); + assert.equal(result.status, "fail"); + if (result.status === "fail") { + assert.match( + result.error.message, + /Unable to resolve Expensify co-author emails/, + ); + } + }); }); -describe('getFailureTitle', () => { - it('maps failure titles for known messages', () => { - assert.equal(VerifyPeerReview.getFailureTitle('Expensify/Auth#1 does not have enough independent Expensify employee approvals.'), 'Missing independent peer review'); - assert.equal(VerifyPeerReview.getFailureTitle('Unable to read branch protection rules for Expensify/Auth@main.'), 'Branch protection lookup failed'); - }); +describe("getFailureTitle", () => { + it("maps failure titles for known messages", () => { + assert.equal( + VerifyPeerReview.getFailureTitle( + "Unable to resolve canonical commit author: missing GitHub author login and commit author name.", + ), + "Missing commit author", + ); + assert.equal( + VerifyPeerReview.getFailureTitle( + "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", + ), + "Missing independent peer review", + ); + assert.equal( + VerifyPeerReview.getFailureTitle( + "Unable to read branch protection rules for Expensify/Auth@main.", + ), + "Branch protection lookup failed", + ); + }); }); From 7c496a8eaf074b924db2fc0c994686e48758b49a Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 29 Jun 2026 11:25:59 -0700 Subject: [PATCH 59/63] Fix prettier --- scripts/libs/GitCommitUtils.ts | 151 ++++----- scripts/verifyPeerReview.ts | 402 +++++++++++------------- tests/GitCommitUtils.test.ts | 235 ++++++-------- tests/verifyPeerReviewCoAuthors.test.ts | 149 +++------ tests/verifyPeerReviewPolicy.test.ts | 224 ++++++------- 5 files changed, 477 insertions(+), 684 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index db55fcc..532b826 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -1,121 +1,104 @@ type GitHubPullRequestCommit = { - author: { - login?: string; - } | null; - commit: { - message: string; - author?: { - name?: string | null; + author: { + login?: string; } | null; - }; + commit: { + message: string; + author?: { + name?: string | null; + } | null; + }; }; type GitHubCoAuthor = { - displayName: string; - email: string; + displayName: string; + email: string; }; const GITHUB_LOGIN_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; function parseCoAuthors(message: string): GitHubCoAuthor[] { - return [...message.matchAll(/^Co-authored-by:\s+(.+?)<([^>]+)>$/gim)].map( - (match) => ({ - displayName: match[1].trim(), - email: match[2].trim(), - }), - ); + return [...message.matchAll(/^Co-authored-by:\s+(.+?)<([^>]+)>$/gim)].map((match) => ({ + displayName: match[1].trim(), + email: match[2].trim(), + })); } function parseCoAuthorEmails(message: string): string[] { - return parseCoAuthors(message).map((coAuthor) => coAuthor.email); + return parseCoAuthors(message).map((coAuthor) => coAuthor.email); } function resolveNoreplyEmailToLogin(email: string): string | null { - const normalizedEmail = email.trim(); - return ( - normalizedEmail.match( - /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i, - )?.[1] ?? null - ); + const normalizedEmail = email.trim(); + return normalizedEmail.match(/^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/i)?.[1] ?? null; } function resolveDisplayNameToLogin(displayName: string): string | null { - const trimmedDisplayName = displayName.trim(); - if (!trimmedDisplayName) { - return null; - } - - const candidates = [ - trimmedDisplayName, - trimmedDisplayName.replaceAll(/\s+/g, ""), - ]; - for (const candidate of candidates) { - if (GITHUB_LOGIN_PATTERN.test(candidate)) { - return candidate; + const trimmedDisplayName = displayName.trim(); + if (!trimmedDisplayName) { + return null; + } + + const candidates = [trimmedDisplayName, trimmedDisplayName.replaceAll(/\s+/g, '')]; + for (const candidate of candidates) { + if (GITHUB_LOGIN_PATTERN.test(candidate)) { + return candidate; + } } - } - return null; + return null; } -function findAllowedLogin( - login: string, - allowedLogins: Set, -): string | null { - for (const allowedLogin of allowedLogins) { - if (allowedLogin.toLowerCase() === login.toLowerCase()) { - return allowedLogin; +function findAllowedLogin(login: string, allowedLogins: Set): string | null { + for (const allowedLogin of allowedLogins) { + if (allowedLogin.toLowerCase() === login.toLowerCase()) { + return allowedLogin; + } } - } - return null; + return null; } -function resolveCoAuthorToLogin( - coAuthor: GitHubCoAuthor, - allowedLogins?: Set, -): string | null { - const loginFromNoreply = resolveNoreplyEmailToLogin(coAuthor.email); - if (loginFromNoreply) { - return loginFromNoreply; - } - - const loginFromDisplayName = resolveDisplayNameToLogin(coAuthor.displayName); - if (!loginFromDisplayName) { - return null; - } +function resolveCoAuthorToLogin(coAuthor: GitHubCoAuthor, allowedLogins?: Set): string | null { + const loginFromNoreply = resolveNoreplyEmailToLogin(coAuthor.email); + if (loginFromNoreply) { + return loginFromNoreply; + } - if (!allowedLogins) { - return loginFromDisplayName; - } + const loginFromDisplayName = resolveDisplayNameToLogin(coAuthor.displayName); + if (!loginFromDisplayName) { + return null; + } + + if (!allowedLogins) { + return loginFromDisplayName; + } - return findAllowedLogin(loginFromDisplayName, allowedLogins); + return findAllowedLogin(loginFromDisplayName, allowedLogins); } function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { - const authorLogin = commit.author?.login?.trim() ?? ""; - if (authorLogin) { - return authorLogin; - } - - // If the author's profile is private, author.login may be missing. Fall back to the commit author name. - const authorName = commit.commit.author?.name?.trim() ?? ""; - if (authorName) { - return authorName; - } - - throw new Error( - "Unable to resolve canonical commit author: missing GitHub author login and commit author name.", - ); + const authorLogin = commit.author?.login?.trim() ?? ''; + if (authorLogin) { + return authorLogin; + } + + // If the author's profile is private, author.login may be missing. Fall back to the commit author name. + const authorName = commit.commit.author?.name?.trim() ?? ''; + if (authorName) { + return authorName; + } + + throw new Error('Unable to resolve canonical commit author: missing GitHub author login and commit author name.'); } -export type { GitHubCoAuthor, GitHubPullRequestCommit }; +export type {GitHubCoAuthor, GitHubPullRequestCommit}; export default { - parseCoAuthorEmails, - parseCoAuthors, - getCanonicalAuthorLogin, - resolveCoAuthorToLogin, - resolveDisplayNameToLogin, - resolveNoreplyEmailToLogin, + parseCoAuthorEmails, + parseCoAuthors, + getCanonicalAuthorLogin, + resolveCoAuthorToLogin, + resolveDisplayNameToLogin, + resolveNoreplyEmailToLogin, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 1c3a670..842f80c 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -1,269 +1,223 @@ -import CLI from "expensify-common/CLI"; -import CollectionUtils from "./libs/CollectionUtils"; -import GitCommitUtils, { - type GitHubPullRequestCommit, -} from "./libs/GitCommitUtils"; -import GitHubUtils from "./libs/GitHubUtils"; -import GitHubWorkflowUtils from "./libs/GitHubWorkflowUtils"; +import CLI from 'expensify-common/CLI'; +import CollectionUtils from './libs/CollectionUtils'; +import GitCommitUtils, {type GitHubPullRequestCommit} from './libs/GitCommitUtils'; +import GitHubUtils from './libs/GitHubUtils'; +import GitHubWorkflowUtils from './libs/GitHubWorkflowUtils'; type PeerReviewInput = { - owner: string; - repo: string; - number: number; - baseRef: string; - requiredApprovingReviewCount: number; - approvers: string[]; - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; - employeeLogins: Set; + owner: string; + repo: string; + number: number; + baseRef: string; + requiredApprovingReviewCount: number; + approvers: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; + employeeLogins: Set; }; -type PeerReviewResult = - | { status: "pass"; reason: string } - | { status: "skip"; reason: string } - | { status: "fail"; error: Error }; +type PeerReviewResult = {status: 'pass'; reason: string} | {status: 'skip'; reason: string} | {status: 'fail'; error: Error}; function formatUsers(users: string[]): string { - return users.length > 0 ? users.join(", ") : "(none)"; + return users.length > 0 ? users.join(', ') : '(none)'; } function isExpensifyEmail(email: string): boolean { - return email.trim().toLowerCase().endsWith("@expensify.com"); + return email.trim().toLowerCase().endsWith('@expensify.com'); } function getCommitAuthors( - commits: GitHubPullRequestCommit[], - employeeLogins?: Set, + commits: GitHubPullRequestCommit[], + employeeLogins?: Set, ): { - authors: string[]; - unresolvedExpensifyCoAuthors: string[]; + authors: string[]; + unresolvedExpensifyCoAuthors: string[]; } { - const authors = new Set(); - const unresolvedExpensifyCoAuthors = new Set(); - - for (const commit of commits) { - const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); - authors.add(canonicalAuthor); - - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is a bot. - if (!GitHubUtils.isBotUser(canonicalAuthor)) { - continue; - } - - for (const coAuthor of GitCommitUtils.parseCoAuthors( - commit.commit.message, - )) { - const login = GitCommitUtils.resolveCoAuthorToLogin( - coAuthor, - employeeLogins, - ); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(coAuthor.email)) { - unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); - } + const authors = new Set(); + const unresolvedExpensifyCoAuthors = new Set(); + + for (const commit of commits) { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + authors.add(canonicalAuthor); + + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is a bot. + if (!GitHubUtils.isBotUser(canonicalAuthor)) { + continue; + } + + for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { + const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(coAuthor.email)) { + unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); + } + } } - } - return { - authors: CollectionUtils.uniqueSorted([...authors]), - unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([ - ...unresolvedExpensifyCoAuthors, - ]), - }; + return { + authors: CollectionUtils.uniqueSorted([...authors]), + unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([...unresolvedExpensifyCoAuthors]), + }; } -function getIndependentEmployeeApprovers( - approvers: string[], - authors: string[], - employeeLogins: Set, -): string[] { - const authorSet = new Set(authors); - return approvers.filter( - (approver) => !authorSet.has(approver) && employeeLogins.has(approver), - ); +function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { + const authorSet = new Set(authors); + return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); } function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { - const { - owner, - repo, - number, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - } = input; - const prSlug = `${owner}/${repo}#${number}`; + const {owner, repo, number, baseRef, requiredApprovingReviewCount, approvers, authors, unresolvedExpensifyCoAuthors, employeeLogins} = input; + const prSlug = `${owner}/${repo}#${number}`; + + if (requiredApprovingReviewCount === 0) { + return { + status: 'skip', + reason: `${prSlug} targets ${baseRef}, which does not require approving reviews.`, + }; + } - if (requiredApprovingReviewCount === 0) { - return { - status: "skip", - reason: `${prSlug} targets ${baseRef}, which does not require approving reviews.`, - }; - } + if (approvers.length === 0) { + return { + status: 'skip', + reason: `${prSlug} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, + }; + } - if (approvers.length === 0) { - return { - status: "skip", - reason: `${prSlug} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`, - }; - } + if (unresolvedExpensifyCoAuthors.length > 0) { + return { + status: 'fail', + error: new Error(`Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`), + }; + } - if (unresolvedExpensifyCoAuthors.length > 0) { - return { - status: "fail", - error: new Error( - `Unable to resolve Expensify co-author emails to GitHub users: ${formatUsers(unresolvedExpensifyCoAuthors)}`, - ), - }; - } + if (authors.every((author) => GitHubUtils.isBotUser(author))) { + return { + status: 'fail', + error: new Error(`Unable to verify independent peer review because ${prSlug} has no human commit authors or co-authors.`), + }; + } - if (authors.every((author) => GitHubUtils.isBotUser(author))) { - return { - status: "fail", - error: new Error( - `Unable to verify independent peer review because ${prSlug} has no human commit authors or co-authors.`, - ), - }; - } + const independentEmployeeApprovers = getIndependentEmployeeApprovers(approvers, authors, employeeLogins); + if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { + return { + status: 'fail', + error: new Error( + [ + `${prSlug} does not have enough independent Expensify employee approvals.`, + `Required independent approvals: ${requiredApprovingReviewCount}`, + `Commit authors/co-authors: ${formatUsers(authors)}`, + `Approvers: ${formatUsers(approvers)}`, + `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, + ].join('\n'), + ), + }; + } - const independentEmployeeApprovers = getIndependentEmployeeApprovers( - approvers, - authors, - employeeLogins, - ); - if (independentEmployeeApprovers.length < requiredApprovingReviewCount) { return { - status: "fail", - error: new Error( - [ - `${prSlug} does not have enough independent Expensify employee approvals.`, - `Required independent approvals: ${requiredApprovingReviewCount}`, - `Commit authors/co-authors: ${formatUsers(authors)}`, - `Approvers: ${formatUsers(approvers)}`, - `Independent employee approvers: ${formatUsers(independentEmployeeApprovers)}`, - ].join("\n"), - ), + status: 'pass', + reason: `${prSlug} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, }; - } - - return { - status: "pass", - reason: `${prSlug} has ${independentEmployeeApprovers.length} independent Expensify employee approval(s).`, - }; } function getFailureTitle(message: string): string { - if ( - message.includes( - "does not have enough independent Expensify employee approvals", - ) - ) { - return "Missing independent peer review"; - } - if (message.includes("Unable to resolve Expensify co-author emails")) { - return "Unresolved Expensify co-author"; - } - if (message.includes("Unable to resolve canonical commit author")) { - return "Missing commit author"; - } - if (message.includes("has no human commit authors or co-authors")) { - return "No human commit author"; - } - if (message.includes("Unable to read branch protection rules")) { - return "Branch protection lookup failed"; - } - return "Peer review verification failed"; + if (message.includes('does not have enough independent Expensify employee approvals')) { + return 'Missing independent peer review'; + } + if (message.includes('Unable to resolve Expensify co-author emails')) { + return 'Unresolved Expensify co-author'; + } + if (message.includes('Unable to resolve canonical commit author')) { + return 'Missing commit author'; + } + if (message.includes('has no human commit authors or co-authors')) { + return 'No human commit author'; + } + if (message.includes('Unable to read branch protection rules')) { + return 'Branch protection lookup failed'; + } + return 'Peer review verification failed'; } async function main(): Promise { - /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ - const cli = new CLI({ - namedArgs: { - owner: { - description: "Repository owner organization or user login", - }, - repo: { - description: "Repository name", - }, - "pull-request-number": { - description: "Pull request number", - parse: (value: string) => { - const number = Number(value); - if (!Number.isInteger(number) || number <= 0) { - throw new Error("Must be a positive integer"); - } - return number; + /* eslint-disable @typescript-eslint/naming-convention -- CLI uses kebab-case argument names */ + const cli = new CLI({ + namedArgs: { + owner: { + description: 'Repository owner organization or user login', + }, + repo: { + description: 'Repository name', + }, + 'pull-request-number': { + description: 'Pull request number', + parse: (value: string) => { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error('Must be a positive integer'); + } + return number; + }, + }, + 'base-ref': { + description: 'Target branch ref for the pull request', + }, }, - }, - "base-ref": { - description: "Target branch ref for the pull request", - }, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - const owner = cli.namedArgs.owner; - const repo = cli.namedArgs.repo; - const pullRequestNumber = cli.namedArgs["pull-request-number"]; - const baseRef = cli.namedArgs["base-ref"]; - - const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ - GitHubUtils.getRequiredApprovingReviewCount({ owner, repo, baseRef }), - GitHubUtils.getLatestApprovers({ owner, repo, number: pullRequestNumber }), - GitHubUtils.listPullRequestCommits({ - owner, - repo, - number: pullRequestNumber, - }), - ]); - - const employeeLogins = - requiredApprovingReviewCount > 0 - ? await GitHubUtils.getEmployeeLogins() - : new Set(); - const { authors, unresolvedExpensifyCoAuthors } = getCommitAuthors( - commits, - employeeLogins, - ); - - const result = evaluatePeerReview({ - owner, - repo, - number: pullRequestNumber, - baseRef, - requiredApprovingReviewCount, - approvers, - authors, - unresolvedExpensifyCoAuthors, - employeeLogins, - }); + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const owner = cli.namedArgs.owner; + const repo = cli.namedArgs.repo; + const pullRequestNumber = cli.namedArgs['pull-request-number']; + const baseRef = cli.namedArgs['base-ref']; + + const [requiredApprovingReviewCount, approvers, commits] = await Promise.all([ + GitHubUtils.getRequiredApprovingReviewCount({owner, repo, baseRef}), + GitHubUtils.getLatestApprovers({owner, repo, number: pullRequestNumber}), + GitHubUtils.listPullRequestCommits({ + owner, + repo, + number: pullRequestNumber, + }), + ]); + + const employeeLogins = requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); + const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits, employeeLogins); + + const result = evaluatePeerReview({ + owner, + repo, + number: pullRequestNumber, + baseRef, + requiredApprovingReviewCount, + approvers, + authors, + unresolvedExpensifyCoAuthors, + employeeLogins, + }); - if (result.status === "skip" || result.status === "pass") { - console.log(result.reason); - return; - } + if (result.status === 'skip' || result.status === 'pass') { + console.log(result.reason); + return; + } - throw result.error; + throw result.error; } -export type { PeerReviewInput, PeerReviewResult }; +export type {PeerReviewInput, PeerReviewResult}; export default { - main, - evaluatePeerReview, - getIndependentEmployeeApprovers, - getCommitAuthors, - getFailureTitle, + main, + evaluatePeerReview, + getIndependentEmployeeApprovers, + getCommitAuthors, + getFailureTitle, }; if (import.meta.main) { - main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); - }); + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + GitHubWorkflowUtils.emitFailure(error, getFailureTitle(message)); + }); } diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index 54b2920..0e42993 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -1,161 +1,102 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import GitCommitUtils, { - type GitHubPullRequestCommit, -} from "../scripts/libs/GitCommitUtils"; - -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, -): GitHubPullRequestCommit { - return { - author: authorLogin ? { login: authorLogin } : null, - commit: { - message, - author: authorName ? { name: authorName } : {}, - }, - }; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import GitCommitUtils, {type GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; + +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { + return { + author: authorLogin ? {login: authorLogin} : null, + commit: { + message, + author: authorName ? {name: authorName} : {}, + }, + }; } -describe("resolveNoreplyEmailToLogin", () => { - it("parses standard noreply addresses", () => { - assert.equal( - GitCommitUtils.resolveNoreplyEmailToLogin( - "AndrewGable@users.noreply.github.com", - ), - "AndrewGable", - ); - }); - - it("parses numeric noreply prefixes", () => { - assert.equal( - GitCommitUtils.resolveNoreplyEmailToLogin( - "2838819+AndrewGable@users.noreply.github.com", - ), - "AndrewGable", - ); - }); +describe('resolveNoreplyEmailToLogin', () => { + it('parses standard noreply addresses', () => { + assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); + + it('parses numeric noreply prefixes', () => { + assert.equal(GitCommitUtils.resolveNoreplyEmailToLogin('2838819+AndrewGable@users.noreply.github.com'), 'AndrewGable'); + }); }); -describe("parseCoAuthorEmails", () => { - it("extracts multiple co-author emails", () => { - const message = [ - "Some change", - "", - "Co-authored-by: Andrew Gable ", - "Co-authored-by: Monil Bhavsar ", - ].join("\n"); - - assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), [ - "AndrewGable@users.noreply.github.com", - "MonilBhavsar@users.noreply.github.com", - ]); - }); +describe('parseCoAuthorEmails', () => { + it('extracts multiple co-author emails', () => { + const message = [ + 'Some change', + '', + 'Co-authored-by: Andrew Gable ', + 'Co-authored-by: Monil Bhavsar ', + ].join('\n'); + + assert.deepEqual(GitCommitUtils.parseCoAuthorEmails(message), ['AndrewGable@users.noreply.github.com', 'MonilBhavsar@users.noreply.github.com']); + }); }); -describe("parseCoAuthors", () => { - it("extracts display names and emails", () => { - const message = - "Change\n\nCo-authored-by: Andrew Gable "; +describe('parseCoAuthors', () => { + it('extracts display names and emails', () => { + const message = 'Change\n\nCo-authored-by: Andrew Gable '; - assert.deepEqual(GitCommitUtils.parseCoAuthors(message), [ - { displayName: "Andrew Gable", email: "andrew@expensify.com" }, - ]); - }); + assert.deepEqual(GitCommitUtils.parseCoAuthors(message), [{displayName: 'Andrew Gable', email: 'andrew@expensify.com'}]); + }); }); -describe("resolveDisplayNameToLogin", () => { - it("parses github usernames from display names", () => { - assert.equal( - GitCommitUtils.resolveDisplayNameToLogin("AndrewGable"), - "AndrewGable", - ); - }); - - it("removes spaces from display names", () => { - assert.equal( - GitCommitUtils.resolveDisplayNameToLogin("Andrew Gable"), - "AndrewGable", - ); - }); - - it("returns null when display name cannot map to a github username", () => { - assert.equal( - GitCommitUtils.resolveDisplayNameToLogin("John O'Brien"), - null, - ); - }); +describe('resolveDisplayNameToLogin', () => { + it('parses github usernames from display names', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin('AndrewGable'), 'AndrewGable'); + }); + + it('removes spaces from display names', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin('Andrew Gable'), 'AndrewGable'); + }); + + it('returns null when display name cannot map to a github username', () => { + assert.equal(GitCommitUtils.resolveDisplayNameToLogin("John O'Brien"), null); + }); }); -describe("resolveCoAuthorToLogin", () => { - it("prefers noreply email resolution over display name", () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({ - displayName: "Wrong Name", - email: "AndrewGable@users.noreply.github.com", - }), - "AndrewGable", - ); - }); - - it("falls back to display name when email cannot be resolved", () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin({ - displayName: "Andrew Gable", - email: "andrew@expensify.com", - }), - "AndrewGable", - ); - }); - - it("rejects display names that do not match allowed logins", () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin( - { displayName: "John Smith", email: "andrew@expensify.com" }, - new Set(["AndrewGable"]), - ), - null, - ); - }); - - it("uses canonical allowed login casing", () => { - assert.equal( - GitCommitUtils.resolveCoAuthorToLogin( - { displayName: "andrew gable", email: "andrew@expensify.com" }, - new Set(["AndrewGable"]), - ), - "AndrewGable", - ); - }); +describe('resolveCoAuthorToLogin', () => { + it('prefers noreply email resolution over display name', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: 'Wrong Name', + email: 'AndrewGable@users.noreply.github.com', + }), + 'AndrewGable', + ); + }); + + it('falls back to display name when email cannot be resolved', () => { + assert.equal( + GitCommitUtils.resolveCoAuthorToLogin({ + displayName: 'Andrew Gable', + email: 'andrew@expensify.com', + }), + 'AndrewGable', + ); + }); + + it('rejects display names that do not match allowed logins', () => { + assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'John Smith', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), null); + }); + + it('uses canonical allowed login casing', () => { + assert.equal(GitCommitUtils.resolveCoAuthorToLogin({displayName: 'andrew gable', email: 'andrew@expensify.com'}, new Set(['AndrewGable'])), 'AndrewGable'); + }); }); -describe("getCanonicalAuthorLogin", () => { - it("returns github author login when present", () => { - assert.equal( - GitCommitUtils.getCanonicalAuthorLogin( - makeCommit("AndrewGable", undefined, "Change"), - ), - "AndrewGable", - ); - }); - - it("falls back to commit author name when github login is missing", () => { - assert.equal( - GitCommitUtils.getCanonicalAuthorLogin( - makeCommit(undefined, "AndrewGable", "Change"), - ), - "AndrewGable", - ); - }); - - it("throws when canonical author cannot be resolved", () => { - assert.throws( - () => - GitCommitUtils.getCanonicalAuthorLogin( - makeCommit(undefined, undefined, "Change"), - ), - /Unable to resolve canonical commit author/, - ); - }); +describe('getCanonicalAuthorLogin', () => { + it('returns github author login when present', () => { + assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit('AndrewGable', undefined, 'Change')), 'AndrewGable'); + }); + + it('falls back to commit author name when github login is missing', () => { + assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit(undefined, 'AndrewGable', 'Change')), 'AndrewGable'); + }); + + it('throws when canonical author cannot be resolved', () => { + assert.throws(() => GitCommitUtils.getCanonicalAuthorLogin(makeCommit(undefined, undefined, 'Change')), /Unable to resolve canonical commit author/); + }); }); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index 71ff041..b4d93c5 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -1,118 +1,63 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { GitHubPullRequestCommit } from "../scripts/libs/GitCommitUtils"; -import VerifyPeerReview from "../scripts/verifyPeerReview"; - -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, -): GitHubPullRequestCommit { - return { - author: authorLogin ? { login: authorLogin } : null, - commit: { - message, - author: authorName ? { name: authorName } : {}, - }, - }; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import type {GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; +import VerifyPeerReview from '../scripts/verifyPeerReview'; + +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { + return { + author: authorLogin ? {login: authorLogin} : null, + commit: { + message, + author: authorName ? {name: authorName} : {}, + }, + }; } -const EMPLOYEE_LOGINS = new Set(["AndrewGable", "MelvinBot", "rafecolton"]); +const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); -describe("getCommitAuthors", () => { - it("counts co-authors for bot-authored commits", () => { - const result = VerifyPeerReview.getCommitAuthors( - [ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ], - EMPLOYEE_LOGINS, - ); +describe('getCommitAuthors', () => { + it('counts co-authors for bot-authored commits', () => { + const result = VerifyPeerReview.getCommitAuthors( + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], + EMPLOYEE_LOGINS, + ); - assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - it("ignores co-authors when canonical author is human", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit( - "rafecolton", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ]); + it('ignores co-authors when canonical author is human', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); - assert.deepEqual(result.authors, ["rafecolton"]); - }); + assert.deepEqual(result.authors, ['rafecolton']); + }); - it("falls back to commit author name when github login is missing", () => { - const result = VerifyPeerReview.getCommitAuthors([ - makeCommit(undefined, "AndrewGable", "Change"), - ]); + it('falls back to commit author name when github login is missing', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); - assert.deepEqual(result.authors, ["AndrewGable"]); - }); + assert.deepEqual(result.authors, ['AndrewGable']); + }); - it("normalizes expensify email casing and whitespace for unresolved detection", () => { - const result = VerifyPeerReview.getCommitAuthors( - [ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >", - ), - ], - EMPLOYEE_LOGINS, - ); + it('normalizes expensify email casing and whitespace for unresolved detection', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], EMPLOYEE_LOGINS); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "Andrew@Expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); + }); - it("resolves expensify co-authors from display name when email cannot be mapped", () => { - const result = VerifyPeerReview.getCommitAuthors( - [ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: Andrew Gable ", - ), - ], - EMPLOYEE_LOGINS, - ); + it('resolves expensify co-authors from display name when email cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); - assert.deepEqual(result.authors, ["AndrewGable", "MelvinBot"]); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); - }); + assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); + }); - it("collects unresolved expensify co-author emails when display name cannot be mapped", () => { - const result = VerifyPeerReview.getCommitAuthors( - [ - makeCommit( - "MelvinBot", - undefined, - "Change\n\nCo-authored-by: John Smith ", - ), - ], - EMPLOYEE_LOGINS, - ); + it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], EMPLOYEE_LOGINS); - assert.deepEqual(result.unresolvedExpensifyCoAuthors, [ - "andrew@expensify.com", - ]); - }); + assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); + }); - it("throws when canonical author cannot be resolved", () => { - assert.throws( - () => - VerifyPeerReview.getCommitAuthors([ - makeCommit(undefined, undefined, "Change"), - ]), - /Unable to resolve canonical commit author/, - ); - }); + it('throws when canonical author cannot be resolved', () => { + assert.throws(() => VerifyPeerReview.getCommitAuthors([makeCommit(undefined, undefined, 'Change')]), /Unable to resolve canonical commit author/); + }); }); diff --git a/tests/verifyPeerReviewPolicy.test.ts b/tests/verifyPeerReviewPolicy.test.ts index 7881627..dca092d 100644 --- a/tests/verifyPeerReviewPolicy.test.ts +++ b/tests/verifyPeerReviewPolicy.test.ts @@ -1,156 +1,126 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import VerifyPeerReview, { - type PeerReviewInput, -} from "../scripts/verifyPeerReview"; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import VerifyPeerReview, {type PeerReviewInput} from '../scripts/verifyPeerReview'; const baseInput: PeerReviewInput = { - owner: "Expensify", - repo: "Auth", - number: 21136, - baseRef: "main", - requiredApprovingReviewCount: 1, - approvers: [], - authors: [], - unresolvedExpensifyCoAuthors: [], - employeeLogins: new Set(["MonilBhavsar", "AndrewGable", "rafecolton"]), + owner: 'Expensify', + repo: 'Auth', + number: 21136, + baseRef: 'main', + requiredApprovingReviewCount: 1, + approvers: [], + authors: [], + unresolvedExpensifyCoAuthors: [], + employeeLogins: new Set(['MonilBhavsar', 'AndrewGable', 'rafecolton']), }; -describe("getIndependentEmployeeApprovers", () => { - it("excludes commit authors and non-employees", () => { - const independent = VerifyPeerReview.getIndependentEmployeeApprovers( - ["AndrewGable", "MonilBhavsar", "externalCollab"], - ["AndrewGable"], - new Set(["AndrewGable", "MonilBhavsar"]), - ); +describe('getIndependentEmployeeApprovers', () => { + it('excludes commit authors and non-employees', () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers(['AndrewGable', 'MonilBhavsar', 'externalCollab'], ['AndrewGable'], new Set(['AndrewGable', 'MonilBhavsar'])); - assert.deepEqual(independent, ["MonilBhavsar"]); - }); -}); - -describe("evaluatePeerReview", () => { - it("skips when branch requires no approving reviews", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 0, - approvers: ["AndrewGable"], - authors: ["AndrewGable"], + assert.deepEqual(independent, ['MonilBhavsar']); }); +}); - assert.equal(result.status, "skip"); - }); +describe('evaluatePeerReview', () => { + it('skips when branch requires no approving reviews', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 0, + approvers: ['AndrewGable'], + authors: ['AndrewGable'], + }); - it("skips when there are no approving reviews yet", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: [], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "skip"); - }); + it('skips when there are no approving reviews yet', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: [], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails on self-review from commit co-author", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'skip'); }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /does not have enough independent Expensify employee approvals/, - ); - } - }); - - it("passes when an independent employee approves", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + it('fails on self-review from commit co-author', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /does not have enough independent Expensify employee approvals/); + } }); - assert.equal(result.status, "pass"); - }); + it('passes when an independent employee approves', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails when independent approver count is below required", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "AndrewGable"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "fail"); - }); + it('fails when independent approver count is below required', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'AndrewGable'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("passes when independent approver count meets required", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - requiredApprovingReviewCount: 2, - approvers: ["MonilBhavsar", "rafecolton"], - authors: ["MelvinBot", "AndrewGable"], + assert.equal(result.status, 'fail'); }); - assert.equal(result.status, "pass"); - }); + it('passes when independent approver count meets required', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + requiredApprovingReviewCount: 2, + approvers: ['MonilBhavsar', 'rafecolton'], + authors: ['MelvinBot', 'AndrewGable'], + }); - it("fails when all authors are bots", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], + assert.equal(result.status, 'pass'); }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /no human commit authors or co-authors/, - ); - } - }); - - it("fails on unresolved expensify co-author emails", () => { - const result = VerifyPeerReview.evaluatePeerReview({ - ...baseInput, - approvers: ["AndrewGable"], - authors: ["MelvinBot"], - unresolvedExpensifyCoAuthors: ["andrew@expensify.com"], + it('fails when all authors are bots', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /no human commit authors or co-authors/); + } }); - assert.equal(result.status, "fail"); - if (result.status === "fail") { - assert.match( - result.error.message, - /Unable to resolve Expensify co-author emails/, - ); - } - }); + it('fails on unresolved expensify co-author emails', () => { + const result = VerifyPeerReview.evaluatePeerReview({ + ...baseInput, + approvers: ['AndrewGable'], + authors: ['MelvinBot'], + unresolvedExpensifyCoAuthors: ['andrew@expensify.com'], + }); + + assert.equal(result.status, 'fail'); + if (result.status === 'fail') { + assert.match(result.error.message, /Unable to resolve Expensify co-author emails/); + } + }); }); -describe("getFailureTitle", () => { - it("maps failure titles for known messages", () => { - assert.equal( - VerifyPeerReview.getFailureTitle( - "Unable to resolve canonical commit author: missing GitHub author login and commit author name.", - ), - "Missing commit author", - ); - assert.equal( - VerifyPeerReview.getFailureTitle( - "Expensify/Auth#1 does not have enough independent Expensify employee approvals.", - ), - "Missing independent peer review", - ); - assert.equal( - VerifyPeerReview.getFailureTitle( - "Unable to read branch protection rules for Expensify/Auth@main.", - ), - "Branch protection lookup failed", - ); - }); +describe('getFailureTitle', () => { + it('maps failure titles for known messages', () => { + assert.equal(VerifyPeerReview.getFailureTitle('Unable to resolve canonical commit author: missing GitHub author login and commit author name.'), 'Missing commit author'); + assert.equal(VerifyPeerReview.getFailureTitle('Expensify/Auth#1 does not have enough independent Expensify employee approvals.'), 'Missing independent peer review'); + assert.equal(VerifyPeerReview.getFailureTitle('Unable to read branch protection rules for Expensify/Auth@main.'), 'Branch protection lookup failed'); + }); }); From 3a3b3e80fd152d4e0deda5fd9f2329596898b475 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 29 Jun 2026 13:30:03 -0700 Subject: [PATCH 60/63] fix: attribute authors from referenced PR on merge commits Cherry-picked merge commits keep the original merger as git author, which caused false peer-review failures when that person also approved. Expand Merge pull request #N commits to source PR authors and skip merge clickers. Co-authored-by: Cursor --- scripts/libs/GitCommitUtils.ts | 20 +++++ scripts/verifyPeerReview.ts | 80 +++++++++++++---- tests/GitCommitUtils.test.ts | 36 +++++++- tests/verifyPeerReviewCoAuthors.test.ts | 114 ++++++++++++++++++++---- 4 files changed, 215 insertions(+), 35 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 532b826..22aff3c 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -8,6 +8,10 @@ type GitHubPullRequestCommit = { name?: string | null; } | null; }; + parents?: Array<{sha: string}>; + committer?: { + login?: string; + } | null; }; type GitHubCoAuthor = { @@ -77,6 +81,20 @@ function resolveCoAuthorToLogin(coAuthor: GitHubCoAuthor, allowedLogins?: Set= 2; +} + function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { const authorLogin = commit.author?.login?.trim() ?? ''; if (authorLogin) { @@ -98,6 +116,8 @@ export default { parseCoAuthorEmails, parseCoAuthors, getCanonicalAuthorLogin, + isGitMergeCommit, + parseMergePullRequestNumber, resolveCoAuthorToLogin, resolveDisplayNameToLogin, resolveNoreplyEmailToLogin, diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 842f80c..22cf670 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -26,36 +26,78 @@ function isExpensifyEmail(email: string): boolean { return email.trim().toLowerCase().endsWith('@expensify.com'); } -function getCommitAuthors( +type FetchPullRequestCommits = (params: {owner: string; repo: string; number: number}) => Promise; + +function addCommitAuthor( + commit: GitHubPullRequestCommit, + authors: Set, + unresolvedExpensifyCoAuthors: Set, + employeeLogins?: Set, +): void { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + authors.add(canonicalAuthor); + + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is a bot. + if (!GitHubUtils.isBotUser(canonicalAuthor)) { + return; + } + + for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { + const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(coAuthor.email)) { + unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); + } + } +} + +async function getCommitAuthors( + owner: string, + repo: string, commits: GitHubPullRequestCommit[], employeeLogins?: Set, -): { + visitedPullRequests: Set = new Set(), + fetchPullRequestCommits: FetchPullRequestCommits = GitHubUtils.listPullRequestCommits, +): Promise<{ authors: string[]; unresolvedExpensifyCoAuthors: string[]; -} { +}> { const authors = new Set(); const unresolvedExpensifyCoAuthors = new Set(); - for (const commit of commits) { - const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); - authors.add(canonicalAuthor); + async function collectAuthorsFromCommits(commitsToProcess: GitHubPullRequestCommit[]): Promise { + const referencedPullRequestFetches: Array> = []; - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is a bot. - if (!GitHubUtils.isBotUser(canonicalAuthor)) { - continue; - } + for (const commit of commitsToProcess) { + const mergePullRequestNumber = GitCommitUtils.parseMergePullRequestNumber(commit.commit.message); + if (mergePullRequestNumber !== null) { + if (!visitedPullRequests.has(mergePullRequestNumber)) { + visitedPullRequests.add(mergePullRequestNumber); + referencedPullRequestFetches.push( + fetchPullRequestCommits({ + owner, + repo, + number: mergePullRequestNumber, + }).then((referencedCommits) => collectAuthorsFromCommits(referencedCommits)), + ); + } + continue; + } - for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { - const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(coAuthor.email)) { - unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); + if (GitCommitUtils.isGitMergeCommit(commit)) { + continue; } + + addCommitAuthor(commit, authors, unresolvedExpensifyCoAuthors, employeeLogins); } + + await Promise.all(referencedPullRequestFetches); } + await collectAuthorsFromCommits(commits); + return { authors: CollectionUtils.uniqueSorted([...authors]), unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([...unresolvedExpensifyCoAuthors]), @@ -183,7 +225,7 @@ async function main(): Promise { ]); const employeeLogins = requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); - const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits, employeeLogins); + const {authors, unresolvedExpensifyCoAuthors} = await getCommitAuthors(owner, repo, commits, employeeLogins); const result = evaluatePeerReview({ owner, @@ -205,7 +247,7 @@ async function main(): Promise { throw result.error; } -export type {PeerReviewInput, PeerReviewResult}; +export type {FetchPullRequestCommits, PeerReviewInput, PeerReviewResult}; export default { main, diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index 0e42993..81a5f69 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -2,13 +2,19 @@ import assert from 'node:assert/strict'; import {describe, it} from 'node:test'; import GitCommitUtils, {type GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, + parents?: Array<{sha: string}>, +): GitHubPullRequestCommit { return { author: authorLogin ? {login: authorLogin} : null, commit: { message, author: authorName ? {name: authorName} : {}, }, + parents, }; } @@ -87,6 +93,34 @@ describe('resolveCoAuthorToLogin', () => { }); }); +describe('parseMergePullRequestNumber', () => { + it('parses merge pull request messages', () => { + assert.equal( + GitCommitUtils.parseMergePullRequestNumber('Merge pull request #94881 from Expensify/rory-94619-some-branch'), + 94881, + ); + }); + + it('returns null for normal commit messages', () => { + assert.equal(GitCommitUtils.parseMergePullRequestNumber('Fix something'), null); + assert.equal(GitCommitUtils.parseMergePullRequestNumber('Merge branch main into feature'), null); + }); +}); + +describe('isGitMergeCommit', () => { + it('returns true when commit has two or more parents', () => { + assert.equal( + GitCommitUtils.isGitMergeCommit(makeCommit('luacmartins', undefined, 'Merge pull request #1', [{sha: 'a'}, {sha: 'b'}])), + true, + ); + }); + + it('returns false for single-parent or missing parents', () => { + assert.equal(GitCommitUtils.isGitMergeCommit(makeCommit('roryabraham', undefined, 'Change', [{sha: 'a'}])), false); + assert.equal(GitCommitUtils.isGitMergeCommit(makeCommit('roryabraham', undefined, 'Change')), false); + }); +}); + describe('getCanonicalAuthorLogin', () => { it('returns github author login when present', () => { assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit('AndrewGable', undefined, 'Change')), 'AndrewGable'); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index b4d93c5..dad3ecc 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -3,21 +3,29 @@ import {describe, it} from 'node:test'; import type {GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; import VerifyPeerReview from '../scripts/verifyPeerReview'; -function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { +function makeCommit( + authorLogin: string | undefined, + authorName: string | undefined, + message: string, + parents?: Array<{sha: string}>, +): GitHubPullRequestCommit { return { author: authorLogin ? {login: authorLogin} : null, commit: { message, author: authorName ? {name: authorName} : {}, }, + parents, }; } const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); describe('getCommitAuthors', () => { - it('counts co-authors for bot-authored commits', () => { - const result = VerifyPeerReview.getCommitAuthors( + it('counts co-authors for bot-authored commits', async () => { + const result = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'Auth', [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS, ); @@ -26,38 +34,114 @@ describe('getCommitAuthors', () => { assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); }); - it('ignores co-authors when canonical author is human', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); + it('ignores co-authors when canonical author is human', async () => { + const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [ + makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable '), + ]); assert.deepEqual(result.authors, ['rafecolton']); }); - it('falls back to commit author name when github login is missing', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); + it('falls back to commit author name when github login is missing', async () => { + const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [makeCommit(undefined, 'AndrewGable', 'Change')]); assert.deepEqual(result.authors, ['AndrewGable']); }); - it('normalizes expensify email casing and whitespace for unresolved detection', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], EMPLOYEE_LOGINS); + it('normalizes expensify email casing and whitespace for unresolved detection', async () => { + const result = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'Auth', + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); }); - it('resolves expensify co-authors from display name when email cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); + it('resolves expensify co-authors from display name when email cannot be mapped', async () => { + const result = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'Auth', + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); }); - it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { - const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], EMPLOYEE_LOGINS); + it('collects unresolved expensify co-author emails when display name cannot be mapped', async () => { + const result = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'Auth', + [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], + EMPLOYEE_LOGINS, + ); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); }); - it('throws when canonical author cannot be resolved', () => { - assert.throws(() => VerifyPeerReview.getCommitAuthors([makeCommit(undefined, undefined, 'Change')]), /Unable to resolve canonical commit author/); + it('throws when canonical author cannot be resolved', async () => { + await assert.rejects(() => VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [makeCommit(undefined, undefined, 'Change')]), /Unable to resolve canonical commit author/); + }); + + it('excludes merger from true git merge commits', async () => { + const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'App', [ + makeCommit('luacmartins', undefined, 'Merge branch main into feature', [{sha: 'a'}, {sha: 'b'}]), + makeCommit('roryabraham', undefined, 'Actual change'), + ]); + + assert.deepEqual(result.authors, ['roryabraham']); + }); + + it('expands authors from referenced PR for merge-message commits', async () => { + const fetchPullRequestCommits = async () => [ + makeCommit('roryabraham', undefined, 'First change'), + makeCommit('roryabraham', undefined, 'Second change'), + ]; + + const result = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'App', + [ + makeCommit('OSBotify', undefined, 'Bump version 1'), + makeCommit('OSBotify', undefined, 'Bump version 2'), + makeCommit('luacmartins', undefined, 'Merge pull request #94881 from Expensify/rory-94619-some-branch'), + ], + EMPLOYEE_LOGINS, + undefined, + fetchPullRequestCommits, + ); + + assert.deepEqual(result.authors, ['OSBotify', 'roryabraham']); + assert.ok(!result.authors.includes('luacmartins')); + }); + + it('passes peer review when approver only merged cherry-picked PR', async () => { + const fetchPullRequestCommits = async () => [makeCommit('roryabraham', undefined, 'Feature change')]; + + const {authors, unresolvedExpensifyCoAuthors} = await VerifyPeerReview.getCommitAuthors( + 'Expensify', + 'App', + [makeCommit('luacmartins', undefined, 'Merge pull request #94881 from Expensify/rory-branch')], + EMPLOYEE_LOGINS, + undefined, + fetchPullRequestCommits, + ); + + const result = VerifyPeerReview.evaluatePeerReview({ + owner: 'Expensify', + repo: 'App', + number: 94887, + baseRef: 'production', + requiredApprovingReviewCount: 1, + approvers: ['luacmartins'], + authors, + unresolvedExpensifyCoAuthors, + employeeLogins: new Set(['luacmartins', 'roryabraham']), + }); + + assert.equal(result.status, 'pass'); }); }); From de60ef370ab3ba576d214f1d7162554e703eb54c Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 29 Jun 2026 13:35:30 -0700 Subject: [PATCH 61/63] Revert "fix: attribute authors from referenced PR on merge commits" This reverts commit 3a3b3e80fd152d4e0deda5fd9f2329596898b475. --- scripts/libs/GitCommitUtils.ts | 20 ----- scripts/verifyPeerReview.ts | 80 ++++------------- tests/GitCommitUtils.test.ts | 36 +------- tests/verifyPeerReviewCoAuthors.test.ts | 114 ++++-------------------- 4 files changed, 35 insertions(+), 215 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 22aff3c..532b826 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -8,10 +8,6 @@ type GitHubPullRequestCommit = { name?: string | null; } | null; }; - parents?: Array<{sha: string}>; - committer?: { - login?: string; - } | null; }; type GitHubCoAuthor = { @@ -81,20 +77,6 @@ function resolveCoAuthorToLogin(coAuthor: GitHubCoAuthor, allowedLogins?: Set= 2; -} - function getCanonicalAuthorLogin(commit: GitHubPullRequestCommit): string { const authorLogin = commit.author?.login?.trim() ?? ''; if (authorLogin) { @@ -116,8 +98,6 @@ export default { parseCoAuthorEmails, parseCoAuthors, getCanonicalAuthorLogin, - isGitMergeCommit, - parseMergePullRequestNumber, resolveCoAuthorToLogin, resolveDisplayNameToLogin, resolveNoreplyEmailToLogin, diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index 22cf670..842f80c 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -26,78 +26,36 @@ function isExpensifyEmail(email: string): boolean { return email.trim().toLowerCase().endsWith('@expensify.com'); } -type FetchPullRequestCommits = (params: {owner: string; repo: string; number: number}) => Promise; - -function addCommitAuthor( - commit: GitHubPullRequestCommit, - authors: Set, - unresolvedExpensifyCoAuthors: Set, - employeeLogins?: Set, -): void { - const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); - authors.add(canonicalAuthor); - - // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. - // Only parse co-authors when the canonical commit author is a bot. - if (!GitHubUtils.isBotUser(canonicalAuthor)) { - return; - } - - for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { - const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); - if (login) { - authors.add(login); - } else if (isExpensifyEmail(coAuthor.email)) { - unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); - } - } -} - -async function getCommitAuthors( - owner: string, - repo: string, +function getCommitAuthors( commits: GitHubPullRequestCommit[], employeeLogins?: Set, - visitedPullRequests: Set = new Set(), - fetchPullRequestCommits: FetchPullRequestCommits = GitHubUtils.listPullRequestCommits, -): Promise<{ +): { authors: string[]; unresolvedExpensifyCoAuthors: string[]; -}> { +} { const authors = new Set(); const unresolvedExpensifyCoAuthors = new Set(); - async function collectAuthorsFromCommits(commitsToProcess: GitHubPullRequestCommit[]): Promise { - const referencedPullRequestFetches: Array> = []; + for (const commit of commits) { + const canonicalAuthor = GitCommitUtils.getCanonicalAuthorLogin(commit); + authors.add(canonicalAuthor); - for (const commit of commitsToProcess) { - const mergePullRequestNumber = GitCommitUtils.parseMergePullRequestNumber(commit.commit.message); - if (mergePullRequestNumber !== null) { - if (!visitedPullRequests.has(mergePullRequestNumber)) { - visitedPullRequests.add(mergePullRequestNumber); - referencedPullRequestFetches.push( - fetchPullRequestCommits({ - owner, - repo, - number: mergePullRequestNumber, - }).then((referencedCommits) => collectAuthorsFromCommits(referencedCommits)), - ); - } - continue; - } + // Co-authorship between two humans from making and accepting a suggestion does not violate peer review. + // Only parse co-authors when the canonical commit author is a bot. + if (!GitHubUtils.isBotUser(canonicalAuthor)) { + continue; + } - if (GitCommitUtils.isGitMergeCommit(commit)) { - continue; + for (const coAuthor of GitCommitUtils.parseCoAuthors(commit.commit.message)) { + const login = GitCommitUtils.resolveCoAuthorToLogin(coAuthor, employeeLogins); + if (login) { + authors.add(login); + } else if (isExpensifyEmail(coAuthor.email)) { + unresolvedExpensifyCoAuthors.add(coAuthor.email.trim()); } - - addCommitAuthor(commit, authors, unresolvedExpensifyCoAuthors, employeeLogins); } - - await Promise.all(referencedPullRequestFetches); } - await collectAuthorsFromCommits(commits); - return { authors: CollectionUtils.uniqueSorted([...authors]), unresolvedExpensifyCoAuthors: CollectionUtils.uniqueSorted([...unresolvedExpensifyCoAuthors]), @@ -225,7 +183,7 @@ async function main(): Promise { ]); const employeeLogins = requiredApprovingReviewCount > 0 ? await GitHubUtils.getEmployeeLogins() : new Set(); - const {authors, unresolvedExpensifyCoAuthors} = await getCommitAuthors(owner, repo, commits, employeeLogins); + const {authors, unresolvedExpensifyCoAuthors} = getCommitAuthors(commits, employeeLogins); const result = evaluatePeerReview({ owner, @@ -247,7 +205,7 @@ async function main(): Promise { throw result.error; } -export type {FetchPullRequestCommits, PeerReviewInput, PeerReviewResult}; +export type {PeerReviewInput, PeerReviewResult}; export default { main, diff --git a/tests/GitCommitUtils.test.ts b/tests/GitCommitUtils.test.ts index 81a5f69..0e42993 100644 --- a/tests/GitCommitUtils.test.ts +++ b/tests/GitCommitUtils.test.ts @@ -2,19 +2,13 @@ import assert from 'node:assert/strict'; import {describe, it} from 'node:test'; import GitCommitUtils, {type GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, - parents?: Array<{sha: string}>, -): GitHubPullRequestCommit { +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { return { author: authorLogin ? {login: authorLogin} : null, commit: { message, author: authorName ? {name: authorName} : {}, }, - parents, }; } @@ -93,34 +87,6 @@ describe('resolveCoAuthorToLogin', () => { }); }); -describe('parseMergePullRequestNumber', () => { - it('parses merge pull request messages', () => { - assert.equal( - GitCommitUtils.parseMergePullRequestNumber('Merge pull request #94881 from Expensify/rory-94619-some-branch'), - 94881, - ); - }); - - it('returns null for normal commit messages', () => { - assert.equal(GitCommitUtils.parseMergePullRequestNumber('Fix something'), null); - assert.equal(GitCommitUtils.parseMergePullRequestNumber('Merge branch main into feature'), null); - }); -}); - -describe('isGitMergeCommit', () => { - it('returns true when commit has two or more parents', () => { - assert.equal( - GitCommitUtils.isGitMergeCommit(makeCommit('luacmartins', undefined, 'Merge pull request #1', [{sha: 'a'}, {sha: 'b'}])), - true, - ); - }); - - it('returns false for single-parent or missing parents', () => { - assert.equal(GitCommitUtils.isGitMergeCommit(makeCommit('roryabraham', undefined, 'Change', [{sha: 'a'}])), false); - assert.equal(GitCommitUtils.isGitMergeCommit(makeCommit('roryabraham', undefined, 'Change')), false); - }); -}); - describe('getCanonicalAuthorLogin', () => { it('returns github author login when present', () => { assert.equal(GitCommitUtils.getCanonicalAuthorLogin(makeCommit('AndrewGable', undefined, 'Change')), 'AndrewGable'); diff --git a/tests/verifyPeerReviewCoAuthors.test.ts b/tests/verifyPeerReviewCoAuthors.test.ts index dad3ecc..b4d93c5 100644 --- a/tests/verifyPeerReviewCoAuthors.test.ts +++ b/tests/verifyPeerReviewCoAuthors.test.ts @@ -3,29 +3,21 @@ import {describe, it} from 'node:test'; import type {GitHubPullRequestCommit} from '../scripts/libs/GitCommitUtils'; import VerifyPeerReview from '../scripts/verifyPeerReview'; -function makeCommit( - authorLogin: string | undefined, - authorName: string | undefined, - message: string, - parents?: Array<{sha: string}>, -): GitHubPullRequestCommit { +function makeCommit(authorLogin: string | undefined, authorName: string | undefined, message: string): GitHubPullRequestCommit { return { author: authorLogin ? {login: authorLogin} : null, commit: { message, author: authorName ? {name: authorName} : {}, }, - parents, }; } const EMPLOYEE_LOGINS = new Set(['AndrewGable', 'MelvinBot', 'rafecolton']); describe('getCommitAuthors', () => { - it('counts co-authors for bot-authored commits', async () => { - const result = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'Auth', + it('counts co-authors for bot-authored commits', () => { + const result = VerifyPeerReview.getCommitAuthors( [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS, ); @@ -34,114 +26,38 @@ describe('getCommitAuthors', () => { assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); }); - it('ignores co-authors when canonical author is human', async () => { - const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [ - makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable '), - ]); + it('ignores co-authors when canonical author is human', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('rafecolton', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')]); assert.deepEqual(result.authors, ['rafecolton']); }); - it('falls back to commit author name when github login is missing', async () => { - const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [makeCommit(undefined, 'AndrewGable', 'Change')]); + it('falls back to commit author name when github login is missing', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit(undefined, 'AndrewGable', 'Change')]); assert.deepEqual(result.authors, ['AndrewGable']); }); - it('normalizes expensify email casing and whitespace for unresolved detection', async () => { - const result = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'Auth', - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], - EMPLOYEE_LOGINS, - ); + it('normalizes expensify email casing and whitespace for unresolved detection', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith < Andrew@Expensify.com >')], EMPLOYEE_LOGINS); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['Andrew@Expensify.com']); }); - it('resolves expensify co-authors from display name when email cannot be mapped', async () => { - const result = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'Auth', - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], - EMPLOYEE_LOGINS, - ); + it('resolves expensify co-authors from display name when email cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: Andrew Gable ')], EMPLOYEE_LOGINS); assert.deepEqual(result.authors, ['AndrewGable', 'MelvinBot']); assert.deepEqual(result.unresolvedExpensifyCoAuthors, []); }); - it('collects unresolved expensify co-author emails when display name cannot be mapped', async () => { - const result = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'Auth', - [makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], - EMPLOYEE_LOGINS, - ); + it('collects unresolved expensify co-author emails when display name cannot be mapped', () => { + const result = VerifyPeerReview.getCommitAuthors([makeCommit('MelvinBot', undefined, 'Change\n\nCo-authored-by: John Smith ')], EMPLOYEE_LOGINS); assert.deepEqual(result.unresolvedExpensifyCoAuthors, ['andrew@expensify.com']); }); - it('throws when canonical author cannot be resolved', async () => { - await assert.rejects(() => VerifyPeerReview.getCommitAuthors('Expensify', 'Auth', [makeCommit(undefined, undefined, 'Change')]), /Unable to resolve canonical commit author/); - }); - - it('excludes merger from true git merge commits', async () => { - const result = await VerifyPeerReview.getCommitAuthors('Expensify', 'App', [ - makeCommit('luacmartins', undefined, 'Merge branch main into feature', [{sha: 'a'}, {sha: 'b'}]), - makeCommit('roryabraham', undefined, 'Actual change'), - ]); - - assert.deepEqual(result.authors, ['roryabraham']); - }); - - it('expands authors from referenced PR for merge-message commits', async () => { - const fetchPullRequestCommits = async () => [ - makeCommit('roryabraham', undefined, 'First change'), - makeCommit('roryabraham', undefined, 'Second change'), - ]; - - const result = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'App', - [ - makeCommit('OSBotify', undefined, 'Bump version 1'), - makeCommit('OSBotify', undefined, 'Bump version 2'), - makeCommit('luacmartins', undefined, 'Merge pull request #94881 from Expensify/rory-94619-some-branch'), - ], - EMPLOYEE_LOGINS, - undefined, - fetchPullRequestCommits, - ); - - assert.deepEqual(result.authors, ['OSBotify', 'roryabraham']); - assert.ok(!result.authors.includes('luacmartins')); - }); - - it('passes peer review when approver only merged cherry-picked PR', async () => { - const fetchPullRequestCommits = async () => [makeCommit('roryabraham', undefined, 'Feature change')]; - - const {authors, unresolvedExpensifyCoAuthors} = await VerifyPeerReview.getCommitAuthors( - 'Expensify', - 'App', - [makeCommit('luacmartins', undefined, 'Merge pull request #94881 from Expensify/rory-branch')], - EMPLOYEE_LOGINS, - undefined, - fetchPullRequestCommits, - ); - - const result = VerifyPeerReview.evaluatePeerReview({ - owner: 'Expensify', - repo: 'App', - number: 94887, - baseRef: 'production', - requiredApprovingReviewCount: 1, - approvers: ['luacmartins'], - authors, - unresolvedExpensifyCoAuthors, - employeeLogins: new Set(['luacmartins', 'roryabraham']), - }); - - assert.equal(result.status, 'pass'); + it('throws when canonical author cannot be resolved', () => { + assert.throws(() => VerifyPeerReview.getCommitAuthors([makeCommit(undefined, undefined, 'Change')]), /Unable to resolve canonical commit author/); }); }); From d7c2da9ff409180e3edc2cc12d22fdba709c5e58 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 8 Jul 2026 23:14:25 -0700 Subject: [PATCH 62/63] Add OSBotify and CLABotify to known bot users Treat these Expensify GitHub App accounts as bots during peer review author resolution. Co-authored-by: Cursor --- scripts/libs/GitHubUtils/isBotUser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/libs/GitHubUtils/isBotUser.ts b/scripts/libs/GitHubUtils/isBotUser.ts index 92b98b9..77f8036 100644 --- a/scripts/libs/GitHubUtils/isBotUser.ts +++ b/scripts/libs/GitHubUtils/isBotUser.ts @@ -1,4 +1,4 @@ -const KNOWN_BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier']); +const KNOWN_BOT_USERS = new Set(['botify', 'MelvinBot', 'exfy-zapier', 'OSBotify', 'CLABotify']); function isBotUser(login: string): boolean { if (login.endsWith('[bot]')) { From 6f10b596d2649358a2a7b41c7c110514e67abfcb Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 9 Jul 2026 13:28:34 -0700 Subject: [PATCH 63/63] Match employee approvers case-insensitively Reuse findAllowedLogin when checking independent employee approvals so login casing mismatches do not skip valid reviewers or authors. Co-authored-by: Cursor --- scripts/libs/GitCommitUtils.ts | 1 + scripts/verifyPeerReview.ts | 12 ++++++++++-- tests/verifyPeerReviewPolicy.test.ts | 12 ++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/libs/GitCommitUtils.ts b/scripts/libs/GitCommitUtils.ts index 532b826..1147e79 100644 --- a/scripts/libs/GitCommitUtils.ts +++ b/scripts/libs/GitCommitUtils.ts @@ -101,4 +101,5 @@ export default { resolveCoAuthorToLogin, resolveDisplayNameToLogin, resolveNoreplyEmailToLogin, + findAllowedLogin, }; diff --git a/scripts/verifyPeerReview.ts b/scripts/verifyPeerReview.ts index db3c758..98c7e67 100644 --- a/scripts/verifyPeerReview.ts +++ b/scripts/verifyPeerReview.ts @@ -66,8 +66,16 @@ function getCommitAuthors( } function getIndependentEmployeeApprovers(approvers: string[], authors: string[], employeeLogins: Set): string[] { - const authorSet = new Set(authors); - return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver)); + const authorsLowerCase = new Set(authors.map((author) => author.toLowerCase())); + + return approvers.flatMap((approver) => { + if (authorsLowerCase.has(approver.toLowerCase())) { + return []; + } + + const canonicalEmployeeLogin = GitCommitUtils.findAllowedLogin(approver, employeeLogins); + return canonicalEmployeeLogin ? [canonicalEmployeeLogin] : []; + }); } function evaluatePeerReview(input: PeerReviewInput): PeerReviewResult { diff --git a/tests/verifyPeerReviewPolicy.test.ts b/tests/verifyPeerReviewPolicy.test.ts index db7d60a..365ea6f 100644 --- a/tests/verifyPeerReviewPolicy.test.ts +++ b/tests/verifyPeerReviewPolicy.test.ts @@ -21,6 +21,18 @@ describe('getIndependentEmployeeApprovers', () => { assert.deepEqual(independent, ['MonilBhavsar']); }); + + it('matches employee logins case-insensitively', () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers(['monilbhavsar'], ['AndrewGable'], new Set(['MonilBhavsar'])); + + assert.deepEqual(independent, ['MonilBhavsar']); + }); + + it('excludes commit authors case-insensitively', () => { + const independent = VerifyPeerReview.getIndependentEmployeeApprovers(['andrewgable'], ['AndrewGable'], new Set(['AndrewGable'])); + + assert.deepEqual(independent, []); + }); }); describe('evaluatePeerReview', () => {