Skip to content

Commit 52219ce

Browse files
feat(ci): create PR bot
Introduces code for the PR bot. PR bot will leave comments on things and will automatically add/remove labels when necessary.
1 parent 5ad320d commit 52219ce

File tree

11 files changed

+1889
-35
lines changed

11 files changed

+1889
-35
lines changed

.dockerignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

.env.example

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
TOKEN_LIST_BOT__SECRET=
2+
TOKEN_LIST_BOT__PAT=

.github/workflows/publish.yml

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Publish Packages
2+
3+
on:
4+
push:
5+
branches: [master]
6+
7+
jobs:
8+
publish-bot:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v2
14+
15+
- name: Get changed files
16+
id: changed-files
17+
uses: tj-actions/changed-files@v29
18+
with:
19+
files: |
20+
bin/**
21+
src/bot.ts
22+
package.json
23+
yarn.lock
24+
Dockerfile.bot
25+
26+
- name: Set up QEMU
27+
uses: docker/setup-qemu-action@v2
28+
29+
- name: Set up Docker Buildx
30+
uses: docker/setup-buildx-action@v2
31+
32+
- name: Login to DockerHub
33+
uses: docker/login-action@v2
34+
with:
35+
username: ${{ secrets.DOCKERHUB_USERNAME }}
36+
password: ${{ secrets.DOCKERHUB_TOKEN }}
37+
38+
- name: Build and push
39+
uses: docker/build-push-action@v3
40+
with:
41+
push: true
42+
tags: ethereumoptimism/tokenlist-bot:latest

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ node_modules
44
err.out
55
std.out
66
dist/
7+
tmp/

Dockerfile.bot

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM node:16-alpine3.14
2+
3+
WORKDIR /app
4+
5+
COPY package*.json /app
6+
COPY yarn.lock /app
7+
8+
RUN yarn install
9+
10+
COPY . /app
11+
12+
CMD [ "yarn", "start-bot" ]
13+
14+
EXPOSE 7300

bin/cli.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ program
2727
const errs = results.filter((r) => r.type === 'error')
2828
if (errs.length > 0) {
2929
for (const err of errs) {
30-
console.error(`error: ${err.message}`)
30+
if (err.message.startsWith('final token list is invalid')) {
31+
// Message generated here is super long and doesn't really give more information than the
32+
// rest of the errors, so just print a short version of it instead.
33+
console.error(`error: final token list is invalid`)
34+
} else {
35+
console.error(`error: ${err.message}`)
36+
}
3137
}
3238

3339
// Exit with error code so CI fails

jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'node',
5-
};
5+
}

package.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@
1717
"test": "jest --detectOpenHandles",
1818
"lint:check": "eslint . --max-warnings=0",
1919
"lint:fix": "eslint --fix .",
20-
"lint": "yarn lint:fix && yarn lint:check"
20+
"lint": "yarn lint:fix && yarn lint:check",
21+
"start-bot": "ts-node ./src/bot.ts"
2122
},
2223
"devDependencies": {
2324
"@actions/core": "^1.4.0",
2425
"@babel/eslint-parser": "^7.18.2",
26+
"@eth-optimism/common-ts": "^0.6.5",
2527
"@eth-optimism/contracts": "^0.5.7",
2628
"@eth-optimism/core-utils": "^0.9.3",
2729
"@types/glob": "^8.0.0",
2830
"@types/jest": "^29.0.3",
2931
"@types/node": "^12.0.0",
32+
"@types/uuid": "^8.3.4",
3033
"@typescript-eslint/eslint-plugin": "^5.26.0",
3134
"@typescript-eslint/parser": "^4.26.0",
3235
"@uniswap/token-lists": "^1.0.0-beta.30",
@@ -45,15 +48,18 @@
4548
"eslint-plugin-react": "^7.24.0",
4649
"eslint-plugin-unicorn": "^42.0.0",
4750
"ethers": "^5.4.1",
51+
"extract-zip": "^2.0.1",
4852
"glob": "^8.0.3",
4953
"jest": "^28.1.3",
5054
"jsonschema": "^1.4.1",
5155
"mocha": "^8.4.0",
5256
"node-fetch": "2.6.7",
57+
"octokit": "^2.0.7",
5358
"prettier": "^2.3.1",
5459
"ts-jest": "^29.0.1",
5560
"ts-mocha": "^10.0.0",
5661
"ts-node": "^10.8.2",
57-
"typescript": "^4.6.2"
62+
"typescript": "^4.6.2",
63+
"uuid": "^9.0.0"
5864
}
5965
}

src/bot.ts

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import crypto from 'crypto'
2+
import fs from 'fs'
3+
import path from 'path'
4+
5+
import {
6+
BaseServiceV2,
7+
ExpressRouter,
8+
validators,
9+
} from '@eth-optimism/common-ts'
10+
import { Octokit } from 'octokit'
11+
import extract from 'extract-zip'
12+
import uuid from 'uuid'
13+
14+
import { version } from '../package.json'
15+
16+
type TOptions = {
17+
secret: string
18+
pat: string
19+
tempdir: string
20+
}
21+
22+
type TMetrics = {}
23+
24+
type TState = {
25+
gh: Octokit
26+
}
27+
28+
export class Bot extends BaseServiceV2<TOptions, TMetrics, TState> {
29+
constructor(options?: Partial<TOptions>) {
30+
super({
31+
name: 'token-list-bot',
32+
version,
33+
options,
34+
optionsSpec: {
35+
secret: {
36+
secret: true,
37+
desc: 'secret used to check webhook validity',
38+
validator: validators.str,
39+
},
40+
pat: {
41+
secret: true,
42+
desc: 'personal access token for github',
43+
validator: validators.str,
44+
},
45+
tempdir: {
46+
desc: 'temporary directory for storing files',
47+
validator: validators.str,
48+
default: './tmp',
49+
},
50+
},
51+
metricsSpec: {
52+
// ...
53+
},
54+
})
55+
}
56+
57+
async init(): Promise<void> {
58+
this.state.gh = new Octokit({
59+
auth: this.options.pat,
60+
})
61+
62+
// Create the temporary directory if it doesn't exist.
63+
if (!fs.existsSync(this.options.tempdir)) {
64+
fs.mkdirSync(this.options.tempdir)
65+
}
66+
}
67+
68+
async routes(router: ExpressRouter): Promise<void> {
69+
router.post('/webhook', async (req: any, res: any) => {
70+
const id = uuid.v4()
71+
try {
72+
// We'll need this later
73+
const owner = 'ethereum-optimism'
74+
const repo = 'ethereum-optimism.github.io'
75+
76+
// Compute the HMAC of the request body
77+
const sig = Buffer.from(req.get('X-Hub-Signature-256') || '', 'utf8')
78+
const hmac = crypto.createHmac('sha256', this.options.secret)
79+
const digest = Buffer.from(
80+
`sha256=${hmac.update(req.rawBody).digest('hex')}`,
81+
'utf8'
82+
)
83+
84+
// Check that the HMAC is valid
85+
if (!crypto.timingSafeEqual(digest, sig)) {
86+
return res
87+
.status(200)
88+
.json({ ok: false, message: 'invalid signature on workflow' })
89+
}
90+
91+
// Make sure we're only looking at "Validate PR" workflows
92+
if (req.body.workflow.name !== 'Validate PR') {
93+
return res
94+
.status(200)
95+
.json({ ok: false, message: 'incorrect workflow' })
96+
}
97+
98+
const artifactQueryResponse = await this.state.gh.request(
99+
'GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts',
100+
{
101+
owner,
102+
repo,
103+
run_id: req.body.workflow_run.id,
104+
}
105+
)
106+
107+
if (artifactQueryResponse.data.total_count !== 1) {
108+
return res
109+
.status(200)
110+
.json({ ok: false, message: 'incorrect number of artifacts' })
111+
}
112+
113+
const artifact = artifactQueryResponse.data.artifacts[0]
114+
if (artifact.name !== 'logs-artifact') {
115+
return res
116+
.status(200)
117+
.json({ ok: false, message: 'incorrect artifact name' })
118+
}
119+
120+
const artifactDownloadResponse = await this.state.gh.request(
121+
'GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}',
122+
{
123+
owner,
124+
repo,
125+
artifact_id: artifact.id,
126+
archive_format: 'zip',
127+
}
128+
)
129+
130+
const tempdir = path.resolve(this.options.tempdir, id)
131+
if (!fs.existsSync(tempdir)) {
132+
fs.mkdirSync(tempdir)
133+
}
134+
135+
const temploc = path.resolve(tempdir, `${artifact.id}.zip`)
136+
const tempout = path.resolve(tempdir, `${artifact.id}-out`)
137+
138+
fs.writeFileSync(
139+
temploc,
140+
Buffer.from(
141+
new Uint8Array(artifactDownloadResponse.data as ArrayBuffer)
142+
)
143+
)
144+
145+
await extract(temploc, {
146+
dir: path.resolve(tempout),
147+
})
148+
149+
const pr = fs.readFileSync(path.resolve(tempout, 'pr.txt'), 'utf8')
150+
const err = fs.readFileSync(path.resolve(tempout, 'err.txt'), 'utf8')
151+
const std = fs.readFileSync(path.resolve(tempout, 'std.txt'), 'utf8')
152+
153+
if (err.length > 0) {
154+
await this.state.gh.request(
155+
'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
156+
{
157+
owner,
158+
repo,
159+
issue_number: parseInt(pr, 10),
160+
body: `Got some errors while validating this PR. You will need to fix these errors before this PR can be reviewed.\n\`\`\`\n${err}\`\`\``,
161+
}
162+
)
163+
164+
return res
165+
.status(200)
166+
.json({ ok: true, message: 'ok with error comment on PR' })
167+
}
168+
169+
const warns = std.split('\n').filter((line) => {
170+
return line.startsWith('warning')
171+
})
172+
173+
if (warns.length > 0) {
174+
await this.state.gh.request(
175+
'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
176+
{
177+
owner,
178+
repo,
179+
issue_number: parseInt(pr, 10),
180+
body: `Got some warnings while validating this PR. This is usually OK but this PR will require manual review if you are unable to resolve these warnings.\n\`\`\`\n${warns.join(
181+
'\n'
182+
)}\n\`\`\``,
183+
}
184+
)
185+
186+
await this.state.gh.request(
187+
'POST /repos/{owner}/{repo}/issues/{issue_number}/labels',
188+
{
189+
owner,
190+
repo,
191+
issue_number: parseInt(pr, 10),
192+
labels: ['requires-manual-review'],
193+
}
194+
)
195+
196+
return res
197+
.status(200)
198+
.json({ ok: true, message: 'ok with warning comment on PR' })
199+
}
200+
201+
// Can safely remove requires-manual-review if we got here without warnings! Sometimes the
202+
// label will be on the PR because a previous iteration of the workflow had warnings but the
203+
// warnings were cleared up.
204+
await this.state.gh.request(
205+
'DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}',
206+
{
207+
owner,
208+
repo,
209+
issue_number: parseInt(pr, 10),
210+
name: 'requires-manual-review',
211+
}
212+
)
213+
214+
return res.status(200).json({ ok: true, message: 'noice!' })
215+
} catch (err) {
216+
this.logger.error(err)
217+
return res.status(500).json({ ok: false, message: 'unexpected error' })
218+
} finally {
219+
// Always clean up the tempdir.
220+
const tempdir = path.resolve(this.options.tempdir, id)
221+
if (fs.existsSync(tempdir)) {
222+
fs.rmdirSync(tempdir, { recursive: true })
223+
}
224+
}
225+
})
226+
}
227+
228+
async main(): Promise<void> {
229+
// nothing to do here
230+
}
231+
}
232+
233+
if (require.main === module) {
234+
const bot = new Bot()
235+
bot.run()
236+
}

tests/generate.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ test("'generate' script parse data dir and compile correct token list", async ()
88
.mockReturnValueOnce(path.resolve(__dirname, 'data'))
99

1010
const mockDate = new Date(1660755600000)
11-
jest.spyOn(global, 'Date').mockImplementation(() => (mockDate as any))
11+
jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any)
1212

1313
const tokenList = generate(path.resolve(__dirname, 'data'))
1414
expect(tokenList).toMatchSnapshot({

0 commit comments

Comments
 (0)