Skip to content

Commit

Permalink
feat(ci): create PR bot
Browse files Browse the repository at this point in the history
Introduces code for the PR bot. PR bot will leave comments on things and
will automatically add/remove labels when necessary.
  • Loading branch information
smartcontracts committed Sep 30, 2022
1 parent 5ad320d commit 52219ce
Show file tree
Hide file tree
Showing 11 changed files with 1,889 additions and 35 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TOKEN_LIST_BOT__SECRET=
TOKEN_LIST_BOT__PAT=
42 changes: 42 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Publish Packages

on:
push:
branches: [master]

jobs:
publish-bot:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v29
with:
files: |
bin/**
src/bot.ts
package.json
yarn.lock
Dockerfile.bot
- name: Set up QEMU
uses: docker/setup-qemu-action@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v3
with:
push: true
tags: ethereumoptimism/tokenlist-bot:latest
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules
err.out
std.out
dist/
tmp/
14 changes: 14 additions & 0 deletions Dockerfile.bot
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:16-alpine3.14

WORKDIR /app

COPY package*.json /app
COPY yarn.lock /app

RUN yarn install

COPY . /app

CMD [ "yarn", "start-bot" ]

EXPOSE 7300
8 changes: 7 additions & 1 deletion bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ program
const errs = results.filter((r) => r.type === 'error')
if (errs.length > 0) {
for (const err of errs) {
console.error(`error: ${err.message}`)
if (err.message.startsWith('final token list is invalid')) {
// Message generated here is super long and doesn't really give more information than the
// rest of the errors, so just print a short version of it instead.
console.error(`error: final token list is invalid`)
} else {
console.error(`error: ${err.message}`)
}
}

// Exit with error code so CI fails
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
}
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@
"test": "jest --detectOpenHandles",
"lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint --fix .",
"lint": "yarn lint:fix && yarn lint:check"
"lint": "yarn lint:fix && yarn lint:check",
"start-bot": "ts-node ./src/bot.ts"
},
"devDependencies": {
"@actions/core": "^1.4.0",
"@babel/eslint-parser": "^7.18.2",
"@eth-optimism/common-ts": "^0.6.5",
"@eth-optimism/contracts": "^0.5.7",
"@eth-optimism/core-utils": "^0.9.3",
"@types/glob": "^8.0.0",
"@types/jest": "^29.0.3",
"@types/node": "^12.0.0",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.26.0",
"@typescript-eslint/parser": "^4.26.0",
"@uniswap/token-lists": "^1.0.0-beta.30",
Expand All @@ -45,15 +48,18 @@
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-unicorn": "^42.0.0",
"ethers": "^5.4.1",
"extract-zip": "^2.0.1",
"glob": "^8.0.3",
"jest": "^28.1.3",
"jsonschema": "^1.4.1",
"mocha": "^8.4.0",
"node-fetch": "2.6.7",
"octokit": "^2.0.7",
"prettier": "^2.3.1",
"ts-jest": "^29.0.1",
"ts-mocha": "^10.0.0",
"ts-node": "^10.8.2",
"typescript": "^4.6.2"
"typescript": "^4.6.2",
"uuid": "^9.0.0"
}
}
236 changes: 236 additions & 0 deletions src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import crypto from 'crypto'
import fs from 'fs'
import path from 'path'

import {
BaseServiceV2,
ExpressRouter,
validators,
} from '@eth-optimism/common-ts'
import { Octokit } from 'octokit'
import extract from 'extract-zip'
import uuid from 'uuid'

import { version } from '../package.json'

type TOptions = {
secret: string
pat: string
tempdir: string
}

type TMetrics = {}

type TState = {
gh: Octokit
}

export class Bot extends BaseServiceV2<TOptions, TMetrics, TState> {
constructor(options?: Partial<TOptions>) {
super({
name: 'token-list-bot',
version,
options,
optionsSpec: {
secret: {
secret: true,
desc: 'secret used to check webhook validity',
validator: validators.str,
},
pat: {
secret: true,
desc: 'personal access token for github',
validator: validators.str,
},
tempdir: {
desc: 'temporary directory for storing files',
validator: validators.str,
default: './tmp',
},
},
metricsSpec: {
// ...
},
})
}

async init(): Promise<void> {
this.state.gh = new Octokit({
auth: this.options.pat,
})

// Create the temporary directory if it doesn't exist.
if (!fs.existsSync(this.options.tempdir)) {
fs.mkdirSync(this.options.tempdir)
}
}

async routes(router: ExpressRouter): Promise<void> {
router.post('/webhook', async (req: any, res: any) => {
const id = uuid.v4()
try {
// We'll need this later
const owner = 'ethereum-optimism'
const repo = 'ethereum-optimism.github.io'

// Compute the HMAC of the request body
const sig = Buffer.from(req.get('X-Hub-Signature-256') || '', 'utf8')
const hmac = crypto.createHmac('sha256', this.options.secret)
const digest = Buffer.from(
`sha256=${hmac.update(req.rawBody).digest('hex')}`,
'utf8'
)

// Check that the HMAC is valid
if (!crypto.timingSafeEqual(digest, sig)) {
return res
.status(200)
.json({ ok: false, message: 'invalid signature on workflow' })
}

// Make sure we're only looking at "Validate PR" workflows
if (req.body.workflow.name !== 'Validate PR') {
return res
.status(200)
.json({ ok: false, message: 'incorrect workflow' })
}

const artifactQueryResponse = await this.state.gh.request(
'GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts',
{
owner,
repo,
run_id: req.body.workflow_run.id,
}
)

if (artifactQueryResponse.data.total_count !== 1) {
return res
.status(200)
.json({ ok: false, message: 'incorrect number of artifacts' })
}

const artifact = artifactQueryResponse.data.artifacts[0]
if (artifact.name !== 'logs-artifact') {
return res
.status(200)
.json({ ok: false, message: 'incorrect artifact name' })
}

const artifactDownloadResponse = await this.state.gh.request(
'GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}',
{
owner,
repo,
artifact_id: artifact.id,
archive_format: 'zip',
}
)

const tempdir = path.resolve(this.options.tempdir, id)
if (!fs.existsSync(tempdir)) {
fs.mkdirSync(tempdir)
}

const temploc = path.resolve(tempdir, `${artifact.id}.zip`)
const tempout = path.resolve(tempdir, `${artifact.id}-out`)

fs.writeFileSync(
temploc,
Buffer.from(
new Uint8Array(artifactDownloadResponse.data as ArrayBuffer)
)
)

await extract(temploc, {
dir: path.resolve(tempout),
})

const pr = fs.readFileSync(path.resolve(tempout, 'pr.txt'), 'utf8')
const err = fs.readFileSync(path.resolve(tempout, 'err.txt'), 'utf8')
const std = fs.readFileSync(path.resolve(tempout, 'std.txt'), 'utf8')

if (err.length > 0) {
await this.state.gh.request(
'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
{
owner,
repo,
issue_number: parseInt(pr, 10),
body: `Got some errors while validating this PR. You will need to fix these errors before this PR can be reviewed.\n\`\`\`\n${err}\`\`\``,
}
)

return res
.status(200)
.json({ ok: true, message: 'ok with error comment on PR' })
}

const warns = std.split('\n').filter((line) => {
return line.startsWith('warning')
})

if (warns.length > 0) {
await this.state.gh.request(
'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
{
owner,
repo,
issue_number: parseInt(pr, 10),
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(
'\n'
)}\n\`\`\``,
}
)

await this.state.gh.request(
'POST /repos/{owner}/{repo}/issues/{issue_number}/labels',
{
owner,
repo,
issue_number: parseInt(pr, 10),
labels: ['requires-manual-review'],
}
)

return res
.status(200)
.json({ ok: true, message: 'ok with warning comment on PR' })
}

// Can safely remove requires-manual-review if we got here without warnings! Sometimes the
// label will be on the PR because a previous iteration of the workflow had warnings but the
// warnings were cleared up.
await this.state.gh.request(
'DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}',
{
owner,
repo,
issue_number: parseInt(pr, 10),
name: 'requires-manual-review',
}
)

return res.status(200).json({ ok: true, message: 'noice!' })
} catch (err) {
this.logger.error(err)
return res.status(500).json({ ok: false, message: 'unexpected error' })
} finally {
// Always clean up the tempdir.
const tempdir = path.resolve(this.options.tempdir, id)
if (fs.existsSync(tempdir)) {
fs.rmdirSync(tempdir, { recursive: true })
}
}
})
}

async main(): Promise<void> {
// nothing to do here
}
}

if (require.main === module) {
const bot = new Bot()
bot.run()
}
2 changes: 1 addition & 1 deletion tests/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ test("'generate' script parse data dir and compile correct token list", async ()
.mockReturnValueOnce(path.resolve(__dirname, 'data'))

const mockDate = new Date(1660755600000)
jest.spyOn(global, 'Date').mockImplementation(() => (mockDate as any))
jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any)

const tokenList = generate(path.resolve(__dirname, 'data'))
expect(tokenList).toMatchSnapshot({
Expand Down
Loading

0 comments on commit 52219ce

Please sign in to comment.