mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[ci] Add ghstack /land bot
Adds a new `/land` command that can be written as a comment on a pull request. The workflow will first check if the commenter is a collaborator or member, and additionally also check if the commenter is a maintainer via the MAINTAINERS file. The workflow will then attempt to validate the pull request, checking that CI has completed successfully and that it has received at least one approval before landing. The land is performed via `ghstack land`, which does mean that the PR itself isn't merged directly via github but it is pushed to main by a synthetic user (@facebook-github-bot for now).
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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==
|
||||
@@ -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 && contains(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 <<EOF > ~/.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 }}
|
||||
Reference in New Issue
Block a user