Unverified Commit c139277a authored by Mak's avatar Mak Committed by GitHub
Browse files

Merge pull request #23 from paritytech/mak/slight-refactoring

moved few tip-related methods to its own module; added more details i…
parents 2abb4789 0a3d256a
......@@ -25,7 +25,7 @@ COPY babel.config.json ./
COPY tsconfig.json ./
COPY src/ ./src
RUN yarn install --frozen-lockfile
RUN yarn install --immutable
RUN yarn build
......
......@@ -23,6 +23,8 @@ Followed by a _comment_ on said pull request
/tip {small | medium | large}
```
## Local development 🔧
To use this bot, you'll need to have an `.env` file. Most of the options will
......@@ -35,6 +37,42 @@ A reference env file is placed at `.env.example` to copy over
$ cp .env.example .env
```
### Run polkadot or substrate `localtest` network locally
- Follow readme in https://github.com/paritytech/polkadot#development to run local network.
- Among all dependencies, main steps are (from repo):
- Compile `cargo b -r`
- Run `./target/release/polkadot --dev`
- [Create 2 accounts: for "bot" & for "contributor"](https://polkadot.js.org/apps/?rpc=ws%3A%2F%2F127.0.0.1%3A9944#/accounts)
- Save the seeds & passwords somewhere
- Set `ACCOUNT_SEED` as bot's seed in `.env` file
- Transfer some meaningful amount from test accounts (like Alice) to a new bot account (from which bot will be send tip to the contributor)
### Create GitHub application for testing
- Note: During app creation save according env variables to `.env` file
- Read [Getting-started](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Getting-started) doc to get a sense of how to work with bots
- Follow [creating app](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Create-a-new-GitHub-App)
and [installing app](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Installing-the-GitHub-App)
guidance
- `WEBHOOK_PROXY_URL` you can generate via https://smee.io/new
#### Github app permissions
##### Repository permissions:
- **Issues**: Read-only
- Allows for interacting with the comments API
- **Pull Requests**: Read & write
- Allows for posting comments on pull requests
##### Organization permissions
- **Members**: Read-only
- Related to $ALLOWED_ORGANIZATIONS: this permission enables the bot to request the organization membership of the command's requester even if their membership is private
##### Event subscriptions
- **Issue comment**
- Allows for receiving events for pull request comments
### Start a bot
After registering and configuring the bot environment, we can run it. We use
[Nodemon](https://nodemon.io/) for hot-reloading, the `probot` package
automatically parses the relevant `.env` values.
......@@ -43,6 +81,12 @@ automatically parses the relevant `.env` values.
$ yarn start
```
### Create a PR and test it
You'll need 2 gh users: contributor and maintainer (since it's not allowed for contributors to send a tip to themselves)
- From contributor GH account: create a PR and add into PR description `localtest address: <contributor polkadot address>`
- From maintainer GH account: write `/tip small` in comments so the bot sends funds to <contributor polkadot address>
### Docker
To run the bot via Docker, we need to build and then run it like so
......
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 })
// 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(),
])
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, botTipAccount.address)
.signAndSend(botTipAccount, (result) => {
bot.log(`Current status is ${result.status.toString()}`)
if (result.status.isInBlock) {
bot.log(
`Tip included at blockHash ${result.status.asInBlock.toString()}`,
)
} else if (result.status.isFinalized) {
bot.log(
`Tip finalized at blockHash ${result.status.asFinalized.toString()}`,
)
unsub()
}
})
return { success: true, tipUrl }
}
import { isPullRequest } from "./github"
import { getTipSize, parseContributorAccount, tipUser } from "./tip"
import { IssueCommentCreatedContext, State } from "./types"
const onIssueComment = async (
state: State,
context: Parameters<HandlerFunction<"issue_comment.created", unknown>>[0],
context: IssueCommentCreatedContext,
tipRequester: string,
) => {
const { allowedTipRequesters, bot } = state
......@@ -115,7 +15,7 @@ const onIssueComment = async (
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 contributorLogin = context.payload.issue.user.login
const pullRequestNumber = context.payload.issue.number
const pullRequestRepo = context.payload.repository.name
......@@ -126,17 +26,14 @@ const onIssueComment = async (
// The bot only triggers on creation of a new comment on a pull request.
if (
!Object.prototype.hasOwnProperty.call(
context.payload.issue,
"pull_request",
) ||
!isPullRequest(context) ||
context.payload.action !== "created" ||
!botMention?.startsWith("/tip")
) {
return
}
if (tipRequester === contributor) {
if (tipRequester === contributorLogin) {
return "Contributor and tipper cannot be the same person!"
}
......@@ -146,70 +43,18 @@ const onIssueComment = async (
)} 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}".`,
)
}
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(", ")}.`,
)
}
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(", ")}.`,
)
}
return validTipSize
})()
const contributorAccount = parseContributorAccount(pullRequestBody)
const tipSize = getTipSize(tipSizeInput)
bot.log(
`Valid command!\n${tipRequester} wants to tip ${contributor} (${contributorAccount.address} on ${contributorAccount.network}) a ${tipSize} tip for pull request ${pullRequestUrl}.`,
`Valid command!\n${tipRequester} wants to tip ${contributorLogin} (${contributorAccount.address} on ${contributorAccount.network}) a ${tipSize} tip for pull request ${pullRequestUrl}.`,
)
const tipResult = await tipUser(state, {
contributor: { githubUsername: contributor, account: contributorAccount },
contributor: {
githubUsername: contributorLogin,
account: contributorAccount,
},
pullRequestNumber,
pullRequestRepo,
tipSize,
......@@ -217,7 +62,7 @@ const onIssueComment = async (
// 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}`
? `A ${tipSize} tip was successfully submitted for ${contributorLogin} (${contributorAccount.address} on ${contributorAccount.network}). \n\n ${tipResult.tipUrl}`
: "Could not submit tip :( Notify someone at Parity."
}
......@@ -236,10 +81,11 @@ const main = (bot: Probot) => {
bot.on("issue_comment", (context) => {
const tipRequester = context.payload.comment.user.login
const onIssueCommentResult = async (result: string | Error | undefined) => {
const respondOnResult = async (result: string | Error | undefined) => {
if (result === undefined) {
return
}
await context.octokit.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
......@@ -251,8 +97,8 @@ const main = (bot: Probot) => {
}
void onIssueComment(state, context, tipRequester)
.then(onIssueCommentResult)
.catch(onIssueCommentResult)
.then(respondOnResult)
.catch(respondOnResult)
})
}
......
import { IssueCommentCreatedContext } from "./types"
export function isPullRequest(context: IssueCommentCreatedContext): boolean {
return Object.prototype.hasOwnProperty.call(
context.payload.issue,
"pull_request",
)
}
import {
ApiPromise,
Keyring,
SubmittableResult,
WsProvider,
} from "@polkadot/api"
import { cryptoWaitReady } from "@polkadot/util-crypto"
import assert from "assert"
import {
Contributor,
ContributorAccount,
State,
TipMetadata,
TipNetwork,
TipSize,
} from "./types"
export function getTipSize(tipSizeInput: string | undefined): TipSize {
const validTipSizes: { [key: string]: TipSize } = {
small: "small",
medium: "medium",
large: "large",
} as const
if (!tipSizeInput || !(tipSizeInput in validTipSizes)) {
throw new Error(
`Invalid tip size. Please specify one of ${Object.keys(
validTipSizes,
).join(", ")}.`,
)
}
return validTipSizes[tipSizeInput]
}
export function parseContributorAccount(
pullRequestBody: string,
): 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}".`,
)
}
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: { [key: string]: TipNetwork } = {
polkadot: "polkadot",
kusama: "kusama",
localtest: "localtest",
}
const network =
networkInput in validNetworks
? validNetworks[networkInput as keyof typeof validNetworks]
: undefined
if (!network) {
throw new Error(
`Invalid network: "${networkInput}". Please select one of: ${Object.keys(
validNetworks,
).join(", ")}.`,
)
}
return { network, address }
}
/* TODO add some kind of timeout then return an error
TODO Unit tests */
export async function tipUser(
state: State,
{
contributor,
pullRequestNumber,
pullRequestRepo,
tipSize,
}: {
contributor: Contributor
pullRequestNumber: number
pullRequestRepo: string
tipSize: string
},
): Promise<{ success: boolean; tipUrl: string }> {
const { bot } = state
const { provider, botTipAccount, tipUrl } = await getContributorMetadata(
state,
contributor,
)
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(),
])
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, botTipAccount.address)
.signAndSend(botTipAccount, (result: SubmittableResult) => {
bot.log(`Current status is ${result.status.toString()}`)
if (result.status.isInBlock) {
bot.log(
`Tip included at blockHash ${result.status.asInBlock.toString()}`,
)
} else if (result.status.isFinalized) {
bot.log(
`Tip finalized at blockHash ${result.status.asFinalized.toString()}`,
)
unsub()
}
})
return { success: true, tipUrl }
}
async function getContributorMetadata(
state: State,
contributor: Contributor,
): Promise<TipMetadata> {
await cryptoWaitReady()
const { seedOfTipperAccount } = state
const keyring = new Keyring({ type: "sr25519" })
const botTipAccount = keyring.addFromUri(seedOfTipperAccount)
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,
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,
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}`,
)
}
}
}
import { HandlerFunction } from "@octokit/webhooks/dist-types/types"
import { WsProvider } from "@polkadot/api"
import { KeyringPair } from "@polkadot/keyring/types"
import { Probot } from "probot"
export type TipNetwork = "localtest" | "kusama" | "polkadot"
export type TipSize = "small" | "medium" | "large"
export type TipMetadata = {
provider: WsProvider
botTipAccount: KeyringPair
tipUrl: string
}
export type ContributorAccount = {
address: string
network: TipNetwork
}
export type Contributor = {
githubUsername: string
account: ContributorAccount
}
export type IssueCommentCreatedContext = Parameters<
HandlerFunction<"issue_comment.created", unknown>
>[0]
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment