diff --git a/.github/workflows/scripts/ghstack/check_permissions.js b/.github/workflows/scripts/ghstack/check_permissions.js new file mode 100644 index 0000000000000..5d362996cd769 --- /dev/null +++ b/.github/workflows/scripts/ghstack/check_permissions.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// JS rewrite of https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/scripts/ghstack-perm-check.py +'use strict'; + +const {spawnSync} = require('child_process'); +const process = require('process'); +const {Octokit} = require('@octokit/rest'); + +const OWNER = 'facebook'; +const REPO = 'react'; + +async function must(cond, msg, octokit, issue_number) { + if (!cond) { + console.error(msg); + try { + await octokit.issues.createComment({ + owner: OWNER, + repo: REPO, + issue_number, + body: `ghstack bot failed: ${msg}`, + }); + } catch (error) { + console.error('Failed to post comment:', error); + } + process.exit(1); + } +} + +async function main() { + const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + if (!GITHUB_TOKEN) { + console.error('GITHUB_TOKEN environment variable is not set.'); + process.exit(1); + } + + const octokit = new Octokit({auth: GITHUB_TOKEN}); + const prNumber = parseInt(process.argv[2]); + const headRef = process.argv[3]; + + console.log(headRef); + await must( + headRef && /^gh\/[A-Za-z0-9-]+\/[0-9]+\/head$/.test(headRef), + 'Not a ghstack PR', + octokit, + OWNER, + REPO, + prNumber + ); + + const origRef = headRef.replace('/head', '/orig'); + + console.log(':: Fetching newest main...'); + let result = spawnSync('git', ['fetch', 'origin', 'main'], { + stdio: 'inherit', + }); + await must( + result.status === 0, + "Can't fetch main", + octokit, + OWNER, + REPO, + prNumber + ); + + console.log(':: Fetching orig branch...'); + result = spawnSync('git', ['fetch', 'origin', origRef], {stdio: 'inherit'}); + await must( + result.status === 0, + "Can't fetch orig branch", + octokit, + OWNER, + REPO, + prNumber + ); + + result = spawnSync( + 'git', + ['log', 'FETCH_HEAD...$(git merge-base FETCH_HEAD origin/main)'], + {shell: true} + ); + const out = result.stdout.toString(); + await must( + result.status === 0, + '`git log` command failed!', + octokit, + OWNER, + REPO, + prNumber + ); + + const regex = + /Pull Request resolved: https:\/\/github\.com\/.*?\/pull\/([0-9]+)/g; + const prNumbers = []; + let match; + while ((match = regex.exec(out)) !== null) { + prNumbers.push(parseInt(match[1], 10)); + } + console.log(prNumbers); + await must( + prNumbers.length && prNumbers[0] === prNumber, + 'Extracted PR numbers not seems right!', + octokit, + OWNER, + REPO, + prNumber + ); + + for (const n of prNumbers) { + process.stdout.write(`:: Checking PR status #${n}... `); + + let prObj; + try { + const {data} = await octokit.pulls.get({ + owner: OWNER, + repo: REPO, + pull_number: n, + }); + prObj = data; + } catch (error) { + await must( + false, + 'Error Getting PR Object!', + octokit, + OWNER, + REPO, + prNumber + ); + } + + let reviews; + try { + const {data} = await octokit.request( + 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', + { + owner: OWNER, + repo: REPO, + pull_number: prNumber, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + reviews = data; + } catch (error) { + await must( + false, + 'Error Getting PR Reviews!', + octokit, + OWNER, + REPO, + prNumber + ); + } + + let approved = false; + for (const review of reviews) { + if (review.state === 'COMMENTED') continue; + + await must( + ['APPROVED', 'DISMISSED'].includes(review.state), + `@${review.user.login} has stamped PR #${n} \`${review.state}\`, please resolve it first!`, + octokit, + OWNER, + REPO, + prNumber + ); + if (review.state === 'APPROVED') { + approved = true; + } + } + await must( + approved, + `PR #${n} is not approved yet!`, + octokit, + OWNER, + REPO, + prNumber + ); + + let checkruns; + try { + const {data} = await octokit.checks.listForRef({ + owner: OWNER, + repo: REPO, + ref: prObj.head.sha, + }); + checkruns = data; + } catch (error) { + await must( + false, + 'Error getting check runs status!', + octokit, + OWNER, + REPO, + prNumber + ); + } + + for (const cr of checkruns.check_runs) { + const status = cr.conclusion ? cr.conclusion : cr.status; + const name = cr.name; + if (name === 'Copilot for PRs') continue; + await must( + ['success', 'neutral'].includes(status), + `PR #${n} check-run \`${name}\`'s status \`${status}\` is not success!`, + octokit, + OWNER, + REPO, + prNumber + ); + } + console.log('SUCCESS!'); + } + + console.log(':: All PRs are ready to be landed!'); +} + +main().catch(err => { + console.error('Unexpected error:', err); + process.exit(1); +}); diff --git a/.github/workflows/scripts/ghstack/package.json b/.github/workflows/scripts/ghstack/package.json new file mode 100644 index 0000000000000..53849365eb401 --- /dev/null +++ b/.github/workflows/scripts/ghstack/package.json @@ -0,0 +1,12 @@ +{ + "name": "ghstack-perm-check", + "version": "0.0.0", + "private": true, + "scripts": { + "check-permissions": "node ./check_permissions.js" + }, + "license": "MIT", + "dependencies": { + "@octokit/rest": "^21.1.1" + } +} diff --git a/.github/workflows/scripts/ghstack/yarn.lock b/.github/workflows/scripts/ghstack/yarn.lock new file mode 100644 index 0000000000000..9f429cd26e6b9 --- /dev/null +++ b/.github/workflows/scripts/ghstack/yarn.lock @@ -0,0 +1,112 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@octokit/auth-token@^5.0.0": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.2.tgz#68a486714d7a7fd1df56cb9bc89a860a0de866de" + integrity sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw== + +"@octokit/core@^6.1.4": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-6.1.4.tgz#f5ccf911cc95b1ce9daf6de425d1664392f867db" + integrity sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg== + dependencies: + "@octokit/auth-token" "^5.0.0" + "@octokit/graphql" "^8.1.2" + "@octokit/request" "^9.2.1" + "@octokit/request-error" "^6.1.7" + "@octokit/types" "^13.6.2" + before-after-hook "^3.0.2" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^10.1.3": + version "10.1.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.3.tgz#bfe8ff2ec213eb4216065e77654bfbba0fc6d4de" + integrity sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA== + dependencies: + "@octokit/types" "^13.6.2" + universal-user-agent "^7.0.2" + +"@octokit/graphql@^8.1.2": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-8.2.1.tgz#0cb83600e6b4009805acc1c56ae8e07e6c991b78" + integrity sha512-n57hXtOoHrhwTWdvhVkdJHdhTv0JstjDbDRhJfwIRNfFqmSo1DaK/mD2syoNUoLCyqSjBpGAKOG0BuwF392slw== + dependencies: + "@octokit/request" "^9.2.2" + "@octokit/types" "^13.8.0" + universal-user-agent "^7.0.0" + +"@octokit/openapi-types@^24.2.0": + version "24.2.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-24.2.0.tgz#3d55c32eac0d38da1a7083a9c3b0cca77924f7d3" + integrity sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg== + +"@octokit/plugin-paginate-rest@^11.4.2": + version "11.6.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz#e5e9ff3530e867c3837fdbff94ce15a2468a1f37" + integrity sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw== + dependencies: + "@octokit/types" "^13.10.0" + +"@octokit/plugin-request-log@^5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz#ccb75d9705de769b2aa82bcd105cc96eb0c00f69" + integrity sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw== + +"@octokit/plugin-rest-endpoint-methods@^13.3.0": + version "13.5.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz#d8c8ca2123b305596c959a9134dfa8b0495b0ba6" + integrity sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw== + dependencies: + "@octokit/types" "^13.10.0" + +"@octokit/request-error@^6.1.7": + version "6.1.7" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-6.1.7.tgz#44fc598f5cdf4593e0e58b5155fe2e77230ff6da" + integrity sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g== + dependencies: + "@octokit/types" "^13.6.2" + +"@octokit/request@^9.2.1", "@octokit/request@^9.2.2": + version "9.2.2" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-9.2.2.tgz#754452ec4692d7fdc32438a14e028eba0e6b2c09" + integrity sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg== + dependencies: + "@octokit/endpoint" "^10.1.3" + "@octokit/request-error" "^6.1.7" + "@octokit/types" "^13.6.2" + fast-content-type-parse "^2.0.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@^21.1.1": + version "21.1.1" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-21.1.1.tgz#7a70455ca451b1d253e5b706f35178ceefb74de2" + integrity sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg== + dependencies: + "@octokit/core" "^6.1.4" + "@octokit/plugin-paginate-rest" "^11.4.2" + "@octokit/plugin-request-log" "^5.3.1" + "@octokit/plugin-rest-endpoint-methods" "^13.3.0" + +"@octokit/types@^13.10.0", "@octokit/types@^13.6.2", "@octokit/types@^13.8.0": + version "13.10.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.10.0.tgz#3e7c6b19c0236c270656e4ea666148c2b51fd1a3" + integrity sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA== + dependencies: + "@octokit/openapi-types" "^24.2.0" + +before-after-hook@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" + integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== + +fast-content-type-parse@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz#c236124534ee2cb427c8d8e5ba35a4856947847b" + integrity sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q== + +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.2.tgz#52e7d0e9b3dc4df06cc33cb2b9fd79041a54827e" + integrity sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q== diff --git a/.github/workflows/shared_ghstack_land.yml b/.github/workflows/shared_ghstack_land.yml new file mode 100644 index 0000000000000..5e483249defba --- /dev/null +++ b/.github/workflows/shared_ghstack_land.yml @@ -0,0 +1,121 @@ +name: (Shared) ghstack land + +on: + issue_comment: + types: [created] + +permissions: {} + +env: + TZ: /usr/share/zoneinfo/America/Los_Angeles + # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout + SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 + +jobs: + check_access: + runs-on: ubuntu-latest + outputs: + is_member_or_collaborator: ${{ steps.check_access.outputs.result }} + steps: + - name: Check access + id: check_access + if: ${{ github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' }} + run: echo "is_member_or_collaborator=true" >> "$GITHUB_OUTPUT" + + check_maintainer: + if: ${{ needs.check_access.outputs.is_member_or_collaborator == 'true' }} + needs: [check_access] + uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main + permissions: + # Used by check_maintainer + contents: read + with: + actor: ${{ github.event.comment.user.login }} + + ghstack_land: + if: ${{ needs.check_maintainer.outputs.is_core_team == 'true' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/land') }} + needs: [check_maintainer] + runs-on: ubuntu-latest + steps: + - name: Add reaction to comment + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const comment_id = "${{ github.event.comment.id }}" + + await github.rest.reactions.createForCommitComment({ + owner, + repo, + comment_id, + content: "rocket", + }); + - name: Get PR details + id: get-pr + run: | + PR_NUMBER=${{ github.event.issue.number }} + echo "PR number is $PR_NUMBER" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + + # Get PR details using GitHub API + PR_DATA=$(curl -s \ + -H "Authorization: token ${{ github.token }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "${{ github.api_url }}/repos/${{ github.repository }}/pulls/$PR_NUMBER") + + # Extract useful information + PR_HEAD_REF=$(echo "$PR_DATA" | jq -r .head.ref) + PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r .head.sha) + PR_URL="${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER" + + echo "pr_branch=$PR_HEAD_REF" >> $GITHUB_OUTPUT + echo "pr_sha=$PR_HEAD_SHA" >> $GITHUB_OUTPUT + echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT + echo "pr_branch=$PR_HEAD_REF" + echo "pr_sha=$PR_HEAD_SHA" + echo "pr_url=$PR_URL" + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ghstack-pip-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }} + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Install ghstack + run: pip install requests ghstack + - name: Restore cached node_modules + uses: actions/cache@v4 + id: node_modules + with: + path: | + **/node_modules + key: ghstack-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('.github/workflows/scripts/ghstack/yarn.lock') }} + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + cache-dependency-path: .github/workflows/scripts/ghstack/yarn.lock + - run: yarn install --cwd .github/workflows/scripts/ghstack --frozen-lockfile + if: steps.node_modules.outputs.cache-hit != 'true' + - name: Check Current CI Status + run: | + echo ${{ github.event.issue.number }} + yarn --cwd .github/workflows/scripts/ghstack check-permissions ${{ github.event.issue.number }} ${{steps.get-pr.outputs.pr_branch}} + env: + GITHUB_TOKEN: ${{ github.token }} + - name: Land It! + run: | + git config --global user.email "facebook-github-bot@users.noreply.github.com" + git config --global user.name "Facebook Community Bot" + cat < ~/.ghstackrc + [ghstack] + github_url = github.com + github_oauth = $GITHUB_TOKEN + github_username = facebook-github-bot + remote_name = origin + EOF + ghstack land "${{ steps.get-pr.outputs.pr_url }}" + env: + GITHUB_TOKEN: ${{ github.token }}