Skip to content

Commit 4b8883e

Browse files
committed
[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 means PRs landed with `/land` will have an additional co-author @facebook-github-bot, but the original committer will not be lost.
1 parent 3366146 commit 4b8883e

File tree

4 files changed

+466
-0
lines changed

4 files changed

+466
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env node
2+
// JS rewrite of https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/scripts/ghstack-perm-check.py
3+
'use strict';
4+
5+
const {spawnSync} = require('child_process');
6+
const process = require('process');
7+
const {Octokit} = require('@octokit/rest');
8+
9+
const OWNER = 'facebook';
10+
const REPO = 'react';
11+
12+
async function must(cond, msg, octokit, issue_number) {
13+
if (!cond) {
14+
console.error(msg);
15+
try {
16+
await octokit.issues.createComment({
17+
owner: OWNER,
18+
repo: REPO,
19+
issue_number,
20+
body: `ghstack bot failed: ${msg}`,
21+
});
22+
} catch (error) {
23+
console.error('Failed to post comment:', error);
24+
}
25+
process.exit(1);
26+
}
27+
}
28+
29+
async function main() {
30+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
31+
if (!GITHUB_TOKEN) {
32+
console.error('GITHUB_TOKEN environment variable is not set.');
33+
process.exit(1);
34+
}
35+
36+
const octokit = new Octokit({auth: GITHUB_TOKEN});
37+
const prNumber = parseInt(process.argv[2]);
38+
const headRef = process.argv[3];
39+
40+
console.log(headRef);
41+
await must(
42+
headRef && /^gh\/[A-Za-z0-9-]+\/[0-9]+\/head$/.test(headRef),
43+
'Not a ghstack PR',
44+
octokit,
45+
OWNER,
46+
REPO,
47+
prNumber
48+
);
49+
50+
const origRef = headRef.replace('/head', '/orig');
51+
52+
console.log(':: Fetching newest main...');
53+
let result = spawnSync('git', ['fetch', 'origin', 'main'], {
54+
stdio: 'inherit',
55+
});
56+
await must(
57+
result.status === 0,
58+
"Can't fetch main",
59+
octokit,
60+
OWNER,
61+
REPO,
62+
prNumber
63+
);
64+
65+
console.log(':: Fetching orig branch...');
66+
result = spawnSync('git', ['fetch', 'origin', origRef], {stdio: 'inherit'});
67+
await must(
68+
result.status === 0,
69+
"Can't fetch orig branch",
70+
octokit,
71+
OWNER,
72+
REPO,
73+
prNumber
74+
);
75+
76+
result = spawnSync(
77+
'git',
78+
['log', 'FETCH_HEAD...$(git merge-base FETCH_HEAD origin/main)'],
79+
{shell: true}
80+
);
81+
const out = result.stdout.toString();
82+
await must(
83+
result.status === 0,
84+
'`git log` command failed!',
85+
octokit,
86+
OWNER,
87+
REPO,
88+
prNumber
89+
);
90+
91+
const regex =
92+
/Pull Request resolved: https:\/\/github\.com\/.*?\/pull\/([0-9]+)/g;
93+
const prNumbers = [];
94+
let match;
95+
while ((match = regex.exec(out)) !== null) {
96+
prNumbers.push(parseInt(match[1], 10));
97+
}
98+
console.log(prNumbers);
99+
await must(
100+
prNumbers.length && prNumbers[0] === prNumber,
101+
'Extracted PR numbers not seems right!',
102+
octokit,
103+
OWNER,
104+
REPO,
105+
prNumber
106+
);
107+
108+
for (const n of prNumbers) {
109+
process.stdout.write(`:: Checking PR status #${n}... `);
110+
111+
let prObj;
112+
try {
113+
const {data} = await octokit.pulls.get({
114+
owner: OWNER,
115+
repo: REPO,
116+
pull_number: n,
117+
});
118+
prObj = data;
119+
} catch (error) {
120+
await must(
121+
false,
122+
'Error Getting PR Object!',
123+
octokit,
124+
OWNER,
125+
REPO,
126+
prNumber
127+
);
128+
}
129+
130+
let reviews;
131+
try {
132+
const {data} = await octokit.request(
133+
'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
134+
{
135+
owner: OWNER,
136+
repo: REPO,
137+
pull_number: prNumber,
138+
headers: {
139+
'X-GitHub-Api-Version': '2022-11-28',
140+
},
141+
}
142+
);
143+
reviews = data;
144+
} catch (error) {
145+
await must(
146+
false,
147+
'Error Getting PR Reviews!',
148+
octokit,
149+
OWNER,
150+
REPO,
151+
prNumber
152+
);
153+
}
154+
155+
let approved = false;
156+
for (const review of reviews) {
157+
if (review.state === 'COMMENTED') continue;
158+
159+
await must(
160+
['APPROVED', 'DISMISSED'].includes(review.state),
161+
`@${review.user.login} has stamped PR #${n} \`${review.state}\`, please resolve it first!`,
162+
octokit,
163+
OWNER,
164+
REPO,
165+
prNumber
166+
);
167+
if (review.state === 'APPROVED') {
168+
approved = true;
169+
}
170+
}
171+
await must(
172+
approved,
173+
`PR #${n} is not approved yet!`,
174+
octokit,
175+
OWNER,
176+
REPO,
177+
prNumber
178+
);
179+
180+
let checkruns;
181+
try {
182+
const {data} = await octokit.checks.listForRef({
183+
owner: OWNER,
184+
repo: REPO,
185+
ref: prObj.head.sha,
186+
});
187+
checkruns = data;
188+
} catch (error) {
189+
await must(
190+
false,
191+
'Error getting check runs status!',
192+
octokit,
193+
OWNER,
194+
REPO,
195+
prNumber
196+
);
197+
}
198+
199+
for (const cr of checkruns.check_runs) {
200+
const status = cr.conclusion ? cr.conclusion : cr.status;
201+
const name = cr.name;
202+
if (name === 'Copilot for PRs') continue;
203+
await must(
204+
['success', 'neutral'].includes(status),
205+
`PR #${n} check-run \`${name}\`'s status \`${status}\` is not success!`,
206+
octokit,
207+
OWNER,
208+
REPO,
209+
prNumber
210+
);
211+
}
212+
console.log('SUCCESS!');
213+
}
214+
215+
console.log(':: All PRs are ready to be landed!');
216+
}
217+
218+
main().catch(err => {
219+
console.error('Unexpected error:', err);
220+
process.exit(1);
221+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "ghstack-perm-check",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"check-permissions": "node ./check_permissions.js"
7+
},
8+
"license": "MIT",
9+
"dependencies": {
10+
"@octokit/rest": "^21.1.1"
11+
}
12+
}
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
"@octokit/auth-token@^5.0.0":
6+
version "5.1.2"
7+
resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.2.tgz#68a486714d7a7fd1df56cb9bc89a860a0de866de"
8+
integrity sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==
9+
10+
"@octokit/core@^6.1.4":
11+
version "6.1.4"
12+
resolved "https://registry.yarnpkg.com/@octokit/core/-/core-6.1.4.tgz#f5ccf911cc95b1ce9daf6de425d1664392f867db"
13+
integrity sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg==
14+
dependencies:
15+
"@octokit/auth-token" "^5.0.0"
16+
"@octokit/graphql" "^8.1.2"
17+
"@octokit/request" "^9.2.1"
18+
"@octokit/request-error" "^6.1.7"
19+
"@octokit/types" "^13.6.2"
20+
before-after-hook "^3.0.2"
21+
universal-user-agent "^7.0.0"
22+
23+
"@octokit/endpoint@^10.1.3":
24+
version "10.1.3"
25+
resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.3.tgz#bfe8ff2ec213eb4216065e77654bfbba0fc6d4de"
26+
integrity sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==
27+
dependencies:
28+
"@octokit/types" "^13.6.2"
29+
universal-user-agent "^7.0.2"
30+
31+
"@octokit/graphql@^8.1.2":
32+
version "8.2.1"
33+
resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-8.2.1.tgz#0cb83600e6b4009805acc1c56ae8e07e6c991b78"
34+
integrity sha512-n57hXtOoHrhwTWdvhVkdJHdhTv0JstjDbDRhJfwIRNfFqmSo1DaK/mD2syoNUoLCyqSjBpGAKOG0BuwF392slw==
35+
dependencies:
36+
"@octokit/request" "^9.2.2"
37+
"@octokit/types" "^13.8.0"
38+
universal-user-agent "^7.0.0"
39+
40+
"@octokit/openapi-types@^24.2.0":
41+
version "24.2.0"
42+
resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-24.2.0.tgz#3d55c32eac0d38da1a7083a9c3b0cca77924f7d3"
43+
integrity sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==
44+
45+
"@octokit/plugin-paginate-rest@^11.4.2":
46+
version "11.6.0"
47+
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz#e5e9ff3530e867c3837fdbff94ce15a2468a1f37"
48+
integrity sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==
49+
dependencies:
50+
"@octokit/types" "^13.10.0"
51+
52+
"@octokit/plugin-request-log@^5.3.1":
53+
version "5.3.1"
54+
resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz#ccb75d9705de769b2aa82bcd105cc96eb0c00f69"
55+
integrity sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==
56+
57+
"@octokit/plugin-rest-endpoint-methods@^13.3.0":
58+
version "13.5.0"
59+
resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz#d8c8ca2123b305596c959a9134dfa8b0495b0ba6"
60+
integrity sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==
61+
dependencies:
62+
"@octokit/types" "^13.10.0"
63+
64+
"@octokit/request-error@^6.1.7":
65+
version "6.1.7"
66+
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-6.1.7.tgz#44fc598f5cdf4593e0e58b5155fe2e77230ff6da"
67+
integrity sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==
68+
dependencies:
69+
"@octokit/types" "^13.6.2"
70+
71+
"@octokit/request@^9.2.1", "@octokit/request@^9.2.2":
72+
version "9.2.2"
73+
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-9.2.2.tgz#754452ec4692d7fdc32438a14e028eba0e6b2c09"
74+
integrity sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==
75+
dependencies:
76+
"@octokit/endpoint" "^10.1.3"
77+
"@octokit/request-error" "^6.1.7"
78+
"@octokit/types" "^13.6.2"
79+
fast-content-type-parse "^2.0.0"
80+
universal-user-agent "^7.0.2"
81+
82+
"@octokit/rest@^21.1.1":
83+
version "21.1.1"
84+
resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-21.1.1.tgz#7a70455ca451b1d253e5b706f35178ceefb74de2"
85+
integrity sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==
86+
dependencies:
87+
"@octokit/core" "^6.1.4"
88+
"@octokit/plugin-paginate-rest" "^11.4.2"
89+
"@octokit/plugin-request-log" "^5.3.1"
90+
"@octokit/plugin-rest-endpoint-methods" "^13.3.0"
91+
92+
"@octokit/types@^13.10.0", "@octokit/types@^13.6.2", "@octokit/types@^13.8.0":
93+
version "13.10.0"
94+
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.10.0.tgz#3e7c6b19c0236c270656e4ea666148c2b51fd1a3"
95+
integrity sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==
96+
dependencies:
97+
"@octokit/openapi-types" "^24.2.0"
98+
99+
before-after-hook@^3.0.2:
100+
version "3.0.2"
101+
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d"
102+
integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==
103+
104+
fast-content-type-parse@^2.0.0:
105+
version "2.0.1"
106+
resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz#c236124534ee2cb427c8d8e5ba35a4856947847b"
107+
integrity sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==
108+
109+
universal-user-agent@^7.0.0, universal-user-agent@^7.0.2:
110+
version "7.0.2"
111+
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.2.tgz#52e7d0e9b3dc4df06cc33cb2b9fd79041a54827e"
112+
integrity sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==

0 commit comments

Comments
 (0)