Verified Commit 1aa426eb authored by Maksym Hlukhovtsov's avatar Maksym Hlukhovtsov
Browse files

moved few tip-related methods to its own module; added more details into documentation

parent 2abb4789
Pipeline #201377 passed with stages
in 1 minute and 23 seconds
...@@ -25,7 +25,7 @@ COPY babel.config.json ./ ...@@ -25,7 +25,7 @@ COPY babel.config.json ./
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src/ ./src COPY src/ ./src
RUN yarn install --frozen-lockfile RUN yarn install --immutable
RUN yarn build RUN yarn build
......
...@@ -23,6 +23,8 @@ Followed by a _comment_ on said pull request ...@@ -23,6 +23,8 @@ Followed by a _comment_ on said pull request
/tip {small | medium | large} /tip {small | medium | large}
``` ```
## Local development 🔧 ## Local development 🔧
To use this bot, you'll need to have an `.env` file. Most of the options will 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 ...@@ -35,6 +37,42 @@ A reference env file is placed at `.env.example` to copy over
$ cp .env.example .env $ 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 After registering and configuring the bot environment, we can run it. We use
[Nodemon](https://nodemon.io/) for hot-reloading, the `probot` package [Nodemon](https://nodemon.io/) for hot-reloading, the `probot` package
automatically parses the relevant `.env` values. automatically parses the relevant `.env` values.
...@@ -43,6 +81,12 @@ automatically parses the relevant `.env` values. ...@@ -43,6 +81,12 @@ automatically parses the relevant `.env` values.
$ yarn start $ yarn start
``` ```
### Create a PR and test it
You'll need to gh users: contributor and maintainer
- 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 ### Docker
To run the bot via Docker, we need to build and then run it like so 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 { displayError, envVar } from "opstooling-js"
import { Probot, run } from "probot" import { Probot, run } from "probot"
import { State } from "./types" import { isPullRequest } from "./github"
import { getTipSize, parseContributorAccount, tipUser } from "./tip"
/* TODO add some kind of timeout then return an error import { IssueCommentCreatedContext, State } from "./types"
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 }
}
const onIssueComment = async ( const onIssueComment = async (
state: State, state: State,
context: Parameters<HandlerFunction<"issue_comment.created", unknown>>[0], context: IssueCommentCreatedContext,
tipRequester: string, tipRequester: string,
) => { ) => {
const { allowedTipRequesters, bot } = state const { allowedTipRequesters, bot } = state
...@@ -115,7 +15,7 @@ const onIssueComment = async ( ...@@ -115,7 +15,7 @@ const onIssueComment = async (
const commentText = context.payload.comment.body const commentText = context.payload.comment.body
const pullRequestBody = context.payload.issue.body const pullRequestBody = context.payload.issue.body
const pullRequestUrl = context.payload.issue.html_url 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 pullRequestNumber = context.payload.issue.number
const pullRequestRepo = context.payload.repository.name const pullRequestRepo = context.payload.repository.name
...@@ -126,17 +26,14 @@ const onIssueComment = async ( ...@@ -126,17 +26,14 @@ const onIssueComment = async (
// The bot only triggers on creation of a new comment on a pull request. // The bot only triggers on creation of a new comment on a pull request.
if ( if (
!Object.prototype.hasOwnProperty.call( !isPullRequest(context) ||
context.payload.issue,
"pull_request",
) ||
context.payload.action !== "created" || context.payload.action !== "created" ||
!botMention?.startsWith("/tip") !botMention?.startsWith("/tip")
) { ) {
return return
} }
if (tipRequester === contributor) { if (tipRequester === contributorLogin) {
return "Contributor and tipper cannot be the same person!" return "Contributor and tipper cannot be the same person!"
} }
...@@ -146,70 +43,18 @@ const onIssueComment = async ( ...@@ -146,70 +43,18 @@ const onIssueComment = async (
)} are allowed.` )} are allowed.`
} }
const contributorAccount = (() => { const contributorAccount = parseContributorAccount(pullRequestBody)
const matches = pullRequestBody.match( const tipSize = getTipSize(tipSizeInput)
// 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
})()
bot.log( 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, { const tipResult = await tipUser(state, {
contributor: { githubUsername: contributor, account: contributorAccount }, contributor: {
githubUsername: contributorLogin,
account: contributorAccount,
},
pullRequestNumber, pullRequestNumber,
pullRequestRepo, pullRequestRepo,
tipSize, tipSize,
...@@ -217,7 +62,7 @@ const onIssueComment = async ( ...@@ -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. // TODO actually check for problems with submitting the tip. Maybe even query storage to ensure the tip is there.
return tipResult.success 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." : "Could not submit tip :( Notify someone at Parity."
} }
...@@ -236,10 +81,11 @@ const main = (bot: Probot) => { ...@@ -236,10 +81,11 @@ const main = (bot: Probot) => {
bot.on("issue_comment", (context) => { bot.on("issue_comment", (context) => {
const tipRequester = context.payload.comment.user.login const tipRequester = context.payload.comment.user.login
const onIssueCommentResult = async (result: string | Error | undefined) => { const respondOnResult = async (result: string | Error | undefined) => {
if (result === undefined) { if (result === undefined) {
return return
} }
await context.octokit.issues.createComment({ await context.octokit.issues.createComment({
owner: context.payload.repository.owner.login, owner: context.payload.repository.owner.login,
repo: context.payload.repository.name, repo: context.payload.repository.name,
...@@ -251,8 +97,8 @@ const main = (bot: Probot) => { ...@@ -251,8 +97,8 @@ const main = (bot: Probot) => {
} }
void onIssueComment(state, context, tipRequester) void onIssueComment(state, context, tipRequester)
.then(onIssueCommentResult) .then(respondOnResult)
.catch(onIssueCommentResult) .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 { HandlerFunction } from "@octokit/webhooks/dist-types/types"
import { WsProvider } from "@polkadot/api"
import { KeyringPair } from "@polkadot/keyring/types"
import { Probot } from "probot" 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< export type IssueCommentCreatedContext = Parameters<
HandlerFunction<"issue_comment.created", unknown> HandlerFunction<"issue_comment.created", unknown>
>[0] >[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