Unverified Commit 5d55e2cd authored by Shawn Tabrizi's avatar Shawn Tabrizi Committed by GitHub
Browse files

Merge pull request #14 from joao-paulo-parity/opstooling-js

Apply opstooling-js and opstooling-js-style
parents 0a0e1db4 92384925
dist
node_modules
const {
getConfiguration,
} = require("opstooling-js-style/src/eslint/configuration")
module.exports = getConfiguration({ typescript: { rootDir: __dirname } })
{
"extends": ["eslint:recommended", "prettier"],
"parserOptions": {
"ecmaVersion": "latest"
},
"env": {
"node": true,
"commonjs": true,
"es6": true
},
"overrides": [
{
"files": ["**/*.test.js"],
"env": {
"mocha": true
},
"plugins": ["mocha"],
"rules": {
"mocha/no-exclusive-tests": "error",
"mocha/no-pending-tests": "error"
}
},
{
"files": ["**/*.ts"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"]
}
],
"rules": {
"prefer-const": "warn"
}
}
name: pre-commit
on:
pull_request:
push:
branches: [master]
env:
CI: true
PYTHON_VERSION: 3.8.12
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use cached node_modules
uses: actions/cache@v1
with:
path: node_modules
key: node_modules+${{ hashFiles('**/yarn.lock') }}
- name: Install
run: yarn install --immutable
- name: Set up pre-commit environment
uses: actions/setup-python@v3
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Get pip cache dir
id: get-pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: Use cached pre-commit environment
uses: actions/cache@v3
with:
path: |
~/.cache/pre-commit
${{ steps.get-pip-cache.outputs.dir }}
key: py-lints+${{ env.PYTHON_VERSION }}+${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure --color=always
'use strict';
module.exports = {
require: 'ts-node/register',
spec: ['test/**/*.test.ts'],
sort: true,
};
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: local
hooks:
- id: eslint
name: eslint
entry: node_modules/.bin/eslint
language: node
files: \.[^\.]*(t|j)s$
args: ["--quiet"]
- id: prettier
name: prettier
entry: node_modules/.bin/prettier
language: node
files: \.json$
args: ["--check"]
- id: typecheck
name: typecheck
entry: node_modules/.bin/tsc-files
language: node
files: \.ts$
dist
node_modules
{
"trailingComma": "es5",
"tabWidth": 2,
"singleQuote": true,
"semi": true,
"bracketSpacing": true
}
module.exports = require("opstooling-js-style/src/prettier/configuration")
FROM node:14-alpine
FROM node:16-alpine
RUN apk -U upgrade --no-cache
......
......@@ -12,17 +12,17 @@
"probot-app"
],
"scripts": {
"typecheck": "tsc --noEmit",
"fix:eslint": "eslint --fix",
"fix:prettier": "prettier --write",
"fix": "yarn fix:eslint '{*,**/*}.{js,ts}' && yarn fix:prettier '{*,**/*}.json'",
"start": "nodemon",
"test": "mocha",
"lint": "eslint src/**/* --cache",
"lint:fix": "eslint src/**/* --cache --fix",
"build": "run-s build:*",
"build:clean": "rimraf dist/",
"build:js": "babel src/ --extensions '.ts,.js' --out-dir dist/"
"build": "rimraf dist; babel src/ --extensions '.ts,.js' --out-dir dist/"
},
"dependencies": {
"@polkadot/api": "^8.1.1",
"@polkadot/util-crypto": "^9.0.1",
"opstooling-js": "https://github.com/paritytech/opstooling-js#v0.0.3",
"probot": "^11.0.1"
},
"devDependencies": {
......@@ -30,20 +30,11 @@
"@babel/core": "^7.15.8",
"@babel/preset-env": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@types/chai": "^4.2.22",
"@types/mocha": "^9.0.0",
"@resolritter/tsc-files": "^1.1.4",
"@types/node": "^16.10.3",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"chai": "^4.3.4",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^9.1.2",
"nock": "^13.0.5",
"dotenv": "^16.0.1",
"nodemon": "^2.0.13",
"npm-run-all": "^4.1.5",
"prettier": "^2.4.1",
"opstooling-js-style": "https://github.com/paritytech/opstooling-js-style#master",
"rimraf": "^3.0.2",
"smee-client": "^1.2.2",
"ts-node": "^10.3.0",
......
import { Probot, run } from 'probot';
import { ApiPromise, WsProvider, Keyring } from '@polkadot/api';
import { cryptoWaitReady } from '@polkadot/util-crypto';
import { postComment } from './helpers/github';
// TODO add some kind of timeout then return an error
// TODO Unit tests
export async function tipUser(
address,
contributor,
network,
pullRequestNumber,
pullRequestRepo,
size
) {
await cryptoWaitReady();
const keyring = new Keyring({ type: 'sr25519' });
// Connect to the appropriate network.
let provider, account;
if (network == 'localtest') {
provider = new WsProvider('ws://localhost:9944');
account = keyring.addFromUri('//Alice', { name: 'Alice default' });
} else if (network == 'polkadot') {
provider = new WsProvider('wss://rpc.polkadot.io/');
account = keyring.addFromUri(process.env.ACCOUNT_SEED);
} else if (network == 'kusama') {
provider = new WsProvider('wss://kusama-rpc.polkadot.io/');
account = keyring.addFromUri(process.env.ACCOUNT_SEED);
} else {
return;
}
import { HandlerFunction } from "@octokit/webhooks/dist-types/types"
import { ApiPromise, Keyring, WsProvider } from "@polkadot/api"
import { cryptoWaitReady } from "@polkadot/util-crypto"
import assert from "assert"
import { displayError, envVar } from "opstooling-js"
import { Probot, run } from "probot"
import { State } from "./types"
/* TODO add some kind of timeout then return an error
TODO Unit tests */
const tipUser = async (
{ seedOfTipperAccount, bot }: State,
{
contributor,
pullRequestNumber,
pullRequestRepo,
tipSize,
}: {
contributor: {
githubUsername: string
account: {
address: string
network: "localtest" | "kusama" | "polkadot"
}
}
pullRequestNumber: number
pullRequestRepo: string
tipSize: string
},
) => {
await cryptoWaitReady()
const keyring = new Keyring({ type: "sr25519" })
const { provider, botTipAccount, tipUrl } = (() => {
switch (contributor.account.network) {
case "localtest": {
return {
provider: new WsProvider("ws://localhost:9944"),
botTipAccount: keyring.addFromUri("//Alice", {
name: "Alice default",
}),
tipUrl:
"https://polkadot.js.org/apps/?rpc=ws%3A%2F%2F127.0.0.1%3A9944#/treasury/tips",
}
}
case "polkadot": {
return {
provider: new WsProvider("wss://rpc.polkadot.io"),
botTipAccount: keyring.addFromUri(seedOfTipperAccount),
tipUrl:
"https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc.polkadot.io#/treasury/tips",
}
}
case "kusama": {
return {
provider: new WsProvider(
`wss://${contributor.account.network}-rpc.polkadot.io`,
),
botTipAccount: keyring.addFromUri(seedOfTipperAccount),
tipUrl: `https://polkadot.js.org/apps/?rpc=wss%3A%2F%${contributor.account.network}-rpc.polkadot.io#/treasury/tips`,
}
}
default: {
const exhaustivenessCheck: never = contributor.account.network
throw new Error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Network is not handled properly in tipUser: ${exhaustivenessCheck}`,
)
}
}
})()
const api = await ApiPromise.create({ provider });
const api = await ApiPromise.create({ provider })
// Get general information about the node we are connected to
const [chain, nodeName, nodeVersion] = await Promise.all([
api.rpc.system.chain(),
api.rpc.system.name(),
api.rpc.system.version(),
]);
console.log(
`You are connected to chain ${chain} using ${nodeName} v${nodeVersion}`
);
const reason = `TO: ${contributor} FOR: ${pullRequestRepo}#${pullRequestNumber} (${size})`;
// TODO before submitting, check tip does not already exist via a storage query.
// TODO potentially prevent duplicates by also checking for reasons with the other sizes.
])
bot.log(
`You are connected to chain ${chain.toString()} using ${nodeName.toString()} v${nodeVersion.toString()}`,
)
const reason = `TO: ${contributor.githubUsername} FOR: ${pullRequestRepo}#${pullRequestNumber} (${tipSize})`
/* TODO before submitting, check tip does not already exist via a storage query.
TODO potentially prevent duplicates by also checking for reasons with the other sizes. */
const unsub = await api.tx.tips
.reportAwesome(reason, address)
.signAndSend(account, (result) => {
console.log(`Current status is ${result.status}`);
.reportAwesome(reason, botTipAccount.address)
.signAndSend(botTipAccount, (result) => {
bot.log(`Current status is ${result.status.toString()}`)
if (result.status.isInBlock) {
console.log(`Tip included at blockHash ${result.status.asInBlock}`);
bot.log(
`Tip included at blockHash ${result.status.asInBlock.toString()}`,
)
} else if (result.status.isFinalized) {
console.log(`Tip finalized at blockHash ${result.status.asFinalized}`);
unsub();
bot.log(
`Tip finalized at blockHash ${result.status.asFinalized.toString()}`,
)
unsub()
}
});
})
return true;
return { success: true, tipUrl }
}
export default function bot(bot: Probot) {
bot.log.info('Tip bot was loaded!');
bot.on('issue_comment', async (context) => {
// Get all the relevant contextual information.
const commentText = context.payload.comment.body;
const pullRequestBody = context.payload.issue.body;
const pullRequestUrl = context.payload.issue.html_url;
const tipper = context.payload.comment.user.login;
const contributor = context.payload.issue.user.login;
const pullRequestNumber = context.payload.issue.number;
const pullRequestRepo = context.payload.repository.name;
// The bot only triggers on creation of a new comment on a pull request.
if (
!Object.prototype.hasOwnProperty.call(
context.payload.issue,
'pull_request'
) ||
context.payload.action !== 'created' ||
!commentText.startsWith('/tip')
) {
return;
}
const onIssueComment = async (
state: State,
context: Parameters<HandlerFunction<"issue_comment.created", unknown>>[0],
tipRequester: string,
) => {
const { allowedTipRequesters, bot } = state
// Any problems along the way will be stored here, and used to return an error if needed.
const problemsText = [];
const commentText = context.payload.comment.body
const pullRequestBody = context.payload.issue.body
const pullRequestUrl = context.payload.issue.html_url
const contributor = context.payload.issue.user.login
const pullRequestNumber = context.payload.issue.number
const pullRequestRepo = context.payload.repository.name
if (tipper === contributor) {
problemsText.push(`Contributor and tipper cannot be the same person!`)
}
const [botMention, tipSizeInput] = commentText.split(" ") as (
| string
| undefined
)[]
// The bot only triggers on creation of a new comment on a pull request.
if (
!Object.prototype.hasOwnProperty.call(
context.payload.issue,
"pull_request",
) ||
context.payload.action !== "created" ||
!botMention?.startsWith("/tip")
) {
return
}
// TODO check contributor is NOT member of parity org (or better, not a member of the org where the repo lives)
// if (contributor is in github org) {
// problemsText.push(`Contributor can't be a member of Parity!`)
// }
if (tipRequester === contributor) {
return "Contributor and tipper cannot be the same person!"
}
if (!process.env.ALLOWED_USERS.includes(tipper)) {
problemsText.push(
`You are not allowed to access the tip bot. Only ${process.env.ALLOWED_USERS} are allowed.`
);
if (!allowedTipRequesters.includes(tipRequester)) {
return `You are not allowed to request a tip. Only ${allowedTipRequesters.join(
", ",
)} are allowed.`
}
const contributorAccount = (() => {
const matches = pullRequestBody.match(
// match "polkadot address: <ADDRESS>"
/(\S+)\s*address:\s*([a-z0-9]+)/i,
)
if (!matches || matches.length != 3) {
throw new Error(
`Contributor did not properly post their account address.\n\nMake sure the pull request description has: "{network} address: {address}".`,
)
}
// We will populate this information by processing the pull request and tip comment.
let network, address, size;
// match "polkadot address: <ADDRESS>"
const addressRegex = /(polkadot|kusama|localtest) address:\s?([a-z0-9]+)/i;
const maybeMatch = pullRequestBody.match(addressRegex);
if (!maybeMatch || maybeMatch.length != 3) {
problemsText.push(
`Contributor did not properly post their Polkadot or Kusama address. \n\n Make sure the pull request description has: "{network} address: {address}".`
);
} else {
network = maybeMatch[1].toLowerCase();
if (!['polkadot', 'kusama', 'localtest'].includes(network)) {
problemsText.push(
`Invalid network: ${maybeMatch[1]}. Please select "polkadot" or "kusama".`
);
}
address = maybeMatch[2];
const [matched, networkInput, address] = matches
assert(networkInput, `networkInput could not be parsed from "${matched}"`)
assert(address, `address could not be parsed from "${matched}"`)
const validNetworks = {
polkadot: "polkadot",
kusama: "kusama",
localtest: "localtest",
} as const
const validNetwork =
networkInput in validNetworks
? validNetworks[networkInput as keyof typeof validNetworks]
: undefined
if (!validNetwork) {
throw new Error(
`Invalid network: "${networkInput}". Please select one of: ${Object.keys(
validNetworks,
).join(", ")}.`,
)
}
// Tip initiation comment should be: "/tip { small / medium / large }"
const textParts = commentText.split(' ');
if (textParts.length !== 2) {
problemsText.push(
`Invalid command! Payload should be: "/tip { small / medium / large }".`
);
} else {
// We already match `/tip` at the top of this program, so just check size.
size = textParts[1].toLowerCase();
if (size == 's') {
size = 'small';
} else if (size == 'm') {
size = 'medium';
} else if (size == 'l') {
size = 'large';
}
if (!['small', 'medium', 'large'].includes(size)) {
problemsText.push(
`Invalid tip size. Please specify one of small, medium, or large.`
);
}
return { network: validNetwork, address }
})()
const tipSize = (() => {
const validTipSizes = {
small: "small",
medium: "medium",
large: "large",
} as const
const validTipSize =
tipSizeInput && tipSizeInput in validTipSizes
? validTipSizes[tipSizeInput as keyof typeof validTipSizes]
: undefined
if (!validTipSize) {
throw new Error(
`Invalid tip size. Please specify one of ${Object.keys(
validTipSizes,
).join(", ")}.`,
)
}
if (problemsText.length > 0) {
// there was some error to get to this point, lets list them.
let comment =
'Please fix the following problems before calling the tip bot again:';
for (const problem of problemsText) {
comment += `\n * ${problem}`;
}
postComment(context, comment);
} else {
console.log(
`Valid command! \n ${tipper} wants to tip ${contributor} (${address} on ${network}) a ${size} tip for pull request ${pullRequestUrl}.`
);
// Send the transaction to the network.
const result = await tipUser(
address,
contributor,
network,
pullRequestNumber,
pullRequestRepo,
size
);
let tipUrl;
if (network == 'polkadot') {
tipUrl = "https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc.polkadot.io#/treasury/tips";
} else if (network == 'kusama') {
tipUrl = "https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fkusama-rpc.polkadot.io#/treasury/tips";
} else {
tipUrl = "https://polkadot.js.org/apps/#/treasury/tips";
}
return validTipSize
})()
bot.log(
`Valid command!\n${tipRequester} wants to tip ${contributor} (${contributorAccount.address} on ${contributorAccount.network}) a ${tipSize} tip for pull request ${pullRequestUrl}.`,
)
const tipResult = await tipUser(state, {
contributor: { githubUsername: contributor, account: contributorAccount },
pullRequestNumber,
pullRequestRepo,
tipSize,
})
// TODO actually check for problems with submitting the tip. Maybe even query storage to ensure the tip is there.
if (result) {
postComment(
context,
`A ${size} tip was successfully submitted for ${contributor} (${address} on ${network}). \n\n ${tipUrl}`
);
} else {
postComment(
context,
`Could not submit tip :( Notify someone at Parity.`
);
// TODO actually check for problems with submitting the tip. Maybe even query storage to ensure the tip is there.
return tipResult.success
? `A ${tipSize} tip was successfully submitted for ${contributor} (${contributorAccount.address} on ${contributorAccount.network}). \n\n ${tipResult.tipUrl}`
: "Could not submit tip :( Notify someone at Parity."
}
const main = (bot: Probot) => {
const allowedTipRequesters = JSON.parse(envVar("ALLOWED_USERS")) as unknown[]
if (!Array.isArray(allowedTipRequesters)) {
throw new Error("$ALLOWED_USERS needs to be an array")
}
const seedOfTipperAccount = envVar("ACCOUNT_SEED")
const state = { allowedTipRequesters, seedOfTipperAccount, bot }
bot.log.info("Tip bot was loaded!")
bot.on("issue_comment", (context) => {
const tipRequester = context.payload.comment.user.login
const onIssueCommentResult = async (result: string | Error | undefined) => {
if (result === undefined) {