diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8b1378917..4a5a1e837 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ - +* @0xcodercrane diff --git a/.github/ISSUE_TEMPLATE/bounty-template.yml b/.github/ISSUE_TEMPLATE/bounty-template.yml index eb78b826f..7e81ebf0c 100644 --- a/.github/ISSUE_TEMPLATE/bounty-template.yml +++ b/.github/ISSUE_TEMPLATE/bounty-template.yml @@ -1,18 +1,20 @@ name: "Bounty Proposal" description: Have a suggestion for how to improve UbiquiBot? Let us know! -title: "Bounty Proposal:" +title: "Bounty Proposal: " body: - type: markdown attributes: value: | ## Feature Request Form - Thank you for taking the time to file a feature request! Please let us know what you're trying to do, and how UbiquiBot can help. + Thank you for taking the time to file a feature request. + If you register your wallet address, you will be eligible for compensation if this is accepted! + Please let us know how we can improve the bot. - type: textarea attributes: label: Describe the background or context - description: Please let us know what inspired you to write this proposal. Backlinking to specific comments is usually sufficient context. + description: Please let us know what inspired you to write this proposal. Backlinking to specific comments on GitHub, and leaving a remark about how the bot should have interacted with it is usually sufficient context. validations: required: false @@ -22,3 +24,10 @@ body: description: A clear description of what you want to happen. Add any considered drawbacks. validations: required: true + + - type: textarea + attributes: + label: Remarks + description: Any closing remarks? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 86f3b5e6b..2403ad576 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - - name: Telegram Group Chat - url: https://t.me/UbiquityDAO/29891 - about: "Join us on Telegram!" + - name: UbiquiBot Development Group Chat + url: https://t.me/UbiquityDAO/31132 + about: "Live chat with us on Telegram!" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d7bbd50f..b1b688448 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,11 @@ + + Resolves # - +Quality Assurance: diff --git a/.github/ubiquibot-config.yml b/.github/ubiquibot-config.yml index 5d5a81c05..336f1f1a4 100644 --- a/.github/ubiquibot-config.yml +++ b/.github/ubiquibot-config.yml @@ -1,5 +1 @@ -price-multiplier: 1.5 - -default-labels: - - "Time: <1 Hour" - - "Priority: 1 (Normal)" +price-multiplier: 1.5 \ No newline at end of file diff --git a/README.md b/README.md index e846f7d3c..e8d09c845 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ Bounty bot is built using the [probot](https://probot.github.io/) framework so i ├── utils A set of utility functions -## Default Config Notes (`ubiquibot-config-default.json`) +## Default Config Notes (`ubiquibot-config-default.ts`) We can't use a `jsonc` file due to limitations with Netlify. Here is a snippet of some values with notes next to them. @@ -239,3 +239,27 @@ We can't use a `jsonc` file due to limitations with Netlify. Here is a snippet o } } ``` + +## Supabase Cron Job (`logs-cleaner`) + +##### Dashboard > Project > Database > Extensions + +> Search `PG_CRON` and Enable it. + + +##### Dashboard > Project > SQL Editor + +```sql +-- Runs everyday at 03:00 AM to cleanup logs that are older than a week +-- Use the cron time format to modify the trigger time if necessary +select + cron.schedule ( + 'logs-cleaner', -- Job name + '0 3 * * *', -- Everyday at 03:00 AM + $$DELETE FROM logs WHERE timestamp < now() - INTERVAL '1 week'$$ + ); + + +-- Cancel the cron job +select cron.unschedule('logs-cleaner'); +``` diff --git a/package.json b/package.json index 69449cc2b..5b3fc2c52 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,10 @@ "@netlify/functions": "^1.4.0", "@probot/adapter-aws-lambda-serverless": "^3.0.2", "@probot/adapter-github-actions": "^3.1.3", - "@sinclair/typebox": "^0.25.9", + "@sinclair/typebox": "^0.31.5", "@supabase/supabase-js": "^2.4.0", "@types/ms": "^0.7.31", + "@types/parse5": "^7.0.0", "@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/parser": "^5.59.11", "@uniswap/permit2-sdk": "^1.2.0", @@ -53,6 +54,7 @@ "mdast-util-from-markdown": "^0.8.5", "mdast-util-gfm": "^0.1.2", "micromark-extension-gfm": "^0.3.3", + "lodash": "^4.17.21", "ms": "^2.1.3", "node-html-parser": "^6.1.5", "node-html-to-image": "^3.3.0", @@ -67,6 +69,7 @@ "@types/eslint": "^8.40.2", "@types/jest": "^28.1.0", "@types/libsodium-wrappers": "^0.7.10", + "@types/lodash": "^4.14.197", "@types/node": "^14.18.37", "@types/source-map-support": "^0.5.6", "eslint": "^8.43.0", diff --git a/src/adapters/index.ts b/src/adapters/index.ts index c5d85b7eb..5279a354a 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -6,7 +6,7 @@ export * from "./telegram"; export const createAdapters = (config: BotConfig): Adapters => { return { - supabase: supabase(config.supabase.url, config.supabase.key), - telegram: new Telegraf(config.telegram.token).telegram, + supabase: supabase(config?.supabase?.url ?? process.env.SUPABASE_URL, config?.supabase?.key ?? process.env.SUPABASE_KEY), + telegram: new Telegraf(config?.telegram?.token ?? process.env.TELEGRAM_BOT_TOKEN).telegram, }; }; diff --git a/src/adapters/supabase/helpers/client.ts b/src/adapters/supabase/helpers/client.ts index cbd0629f7..480ecd0f9 100644 --- a/src/adapters/supabase/helpers/client.ts +++ b/src/adapters/supabase/helpers/client.ts @@ -5,6 +5,13 @@ import { Database } from "../types"; import { InsertPermit, Permit } from "../../../helpers"; import { BigNumber, BigNumberish } from "ethers"; +interface AccessLevels { + multiplier: boolean; + price: boolean; + priority: boolean; + time: boolean; +} + /** * @dev Creates a typescript client which will be used to interact with supabase platform * @@ -218,17 +225,17 @@ export const upsertWalletAddress = async (username: string, address: string): Pr } logger.info(`Upserting a wallet address done, { data: ${JSON.stringify(_data)} }`); } else { - const { data: _data, error: _error } = await supabase.from("wallets").insert({ + const { error } = await supabase.from("wallets").insert({ user_name: username, wallet_address: address, created_at: new Date().toUTCString(), updated_at: new Date().toUTCString(), }); - if (_error) { - logger.error(`Creating a new wallet_table record failed, error: ${JSON.stringify(_error)}`); - throw new Error(`Creating a new wallet_table record failed, error: ${JSON.stringify(_error)}`); + if (error) { + logger.error(`Creating a new wallet_table record failed, error: ${JSON.stringify(error)}`); + throw new Error(`Creating a new wallet_table record failed, error: ${JSON.stringify(error)}`); } - logger.info(`Creating a new wallet_table record done, { data: ${JSON.stringify(_data)} }`); + logger.info(`Creating a new wallet_table record done, { data: ${JSON.stringify(data)}, address: $address }`); } }; @@ -340,6 +347,20 @@ export const getAccessLevel = async (username: string, repository: string, label return accessValues; }; +export const getAllAccessLevels = async (username: string, repository: string): Promise => { + const logger = getLogger(); + const { supabase } = getAdapters(); + + const { data } = await supabase.from("access").select("*").eq("user_name", username).eq("repository", repository).single(); + + if (!data) { + logger.info(`Access not found on the database`); + // no access + return null; + } + return { multiplier: data.multiplier_access, time: data.time_access, priority: data.priority_access, price: data.price_access }; +}; + /** * Queries the wallet address registered previously * diff --git a/src/adapters/supabase/helpers/log.ts b/src/adapters/supabase/helpers/log.ts index e352339a8..51a16f5b2 100644 --- a/src/adapters/supabase/helpers/log.ts +++ b/src/adapters/supabase/helpers/log.ts @@ -1,26 +1,37 @@ import { getAdapters, getBotContext, Logger } from "../../../bindings"; -import { Payload } from "../../../types"; -import { getNumericLevel } from "../../../utils/helpers"; +import { Payload, LogLevel } from "../../../types"; import { getOrgAndRepoFromPath } from "../../../utils/private"; + interface Log { repo: string | null; org: string | null; commentId: number | undefined; issueNumber: number | undefined; logMessage: string; - level: Level; + level: LogLevel; timestamp: string; } -export enum Level { - ERROR = "error", - WARN = "warn", - INFO = "info", - HTTP = "http", - VERBOSE = "verbose", - DEBUG = "debug", - SILLY = "silly", -} +export const getNumericLevel = (level: LogLevel) => { + switch (level) { + case LogLevel.ERROR: + return 0; + case LogLevel.WARN: + return 1; + case LogLevel.INFO: + return 2; + case LogLevel.HTTP: + return 3; + case LogLevel.VERBOSE: + return 4; + case LogLevel.DEBUG: + return 5; + case LogLevel.SILLY: + return 6; + default: + return -1; // Invalid level + } +}; export class GitHubLogger implements Logger { private supabase; @@ -33,7 +44,7 @@ export class GitHubLogger implements Logger { private throttleCount = 0; private retryLimit = 0; // Retries disabled by default - constructor(app: string, logEnvironment: string, maxLevel: Level, retryLimit: number) { + constructor(app: string, logEnvironment: string, maxLevel: LogLevel, retryLimit: number) { this.app = app; this.logEnvironment = logEnvironment; this.maxLevel = getNumericLevel(maxLevel); @@ -118,7 +129,7 @@ export class GitHubLogger implements Logger { } } - private save(logMessage: string | object, level: Level, errorPayload?: string | object) { + private save(logMessage: string | object, level: LogLevel, errorPayload?: string | object) { if (getNumericLevel(level) > this.maxLevel) return; // only return errors lower than max level const context = getBotContext(); @@ -153,19 +164,19 @@ export class GitHubLogger implements Logger { } info(message: string | object, errorPayload?: string | object) { - this.save(message, Level.INFO, errorPayload); + this.save(message, LogLevel.INFO, errorPayload); } warn(message: string | object, errorPayload?: string | object) { - this.save(message, Level.WARN, errorPayload); + this.save(message, LogLevel.WARN, errorPayload); } debug(message: string | object, errorPayload?: string | object) { - this.save(message, Level.DEBUG, errorPayload); + this.save(message, LogLevel.DEBUG, errorPayload); } error(message: string | object, errorPayload?: string | object) { - this.save(message, Level.ERROR, errorPayload); + this.save(message, LogLevel.ERROR, errorPayload); } async get() { diff --git a/src/bindings/config.ts b/src/bindings/config.ts index 9fc6f4ed0..4fc29bf11 100644 --- a/src/bindings/config.ts +++ b/src/bindings/config.ts @@ -1,6 +1,6 @@ import ms from "ms"; -import { BotConfig, BotConfigSchema } from "../types"; +import { BotConfig, BotConfigSchema, LogLevel } from "../types"; import { DEFAULT_BOT_DELAY, DEFAULT_DISQUALIFY_TIME, @@ -13,7 +13,6 @@ import { getPayoutConfigByNetworkId } from "../helpers"; import { ajv } from "../utils"; import { Context } from "probot"; import { getScalarKey, getWideConfig } from "../utils/private"; -import { Level } from "../adapters/supabase"; export const loadConfig = async (context: Context): Promise => { const { @@ -33,6 +32,8 @@ export const loadConfig = async (context: Context): Promise => { commandSettings, assistivePricing, registerWalletWithVerification, + staleBountyTime, + enableAccessControl, } = await getWideConfig(context); const publicKey = await getScalarKey(process.env.X25519_PRIVATE_KEY); @@ -41,7 +42,7 @@ export const loadConfig = async (context: Context): Promise => { const botConfig: BotConfig = { log: { logEnvironment: process.env.LOG_ENVIRONMENT || "production", - level: (process.env.LOG_LEVEL as Level) || Level.DEBUG, + level: (process.env.LOG_LEVEL as LogLevel) || LogLevel.DEBUG, retryLimit: Number(process.env.LOG_RETRY) || 0, }, price: { @@ -89,6 +90,7 @@ export const loadConfig = async (context: Context): Promise => { command: commandSettings, assign: { bountyHunterMax: bountyHunterMax, + staleBountyTime: ms(staleBountyTime), }, sodium: { privateKey: process.env.X25519_PRIVATE_KEY ?? "", @@ -97,6 +99,7 @@ export const loadConfig = async (context: Context): Promise => { wallet: { registerWalletWithVerification: registerWalletWithVerification, }, + accessControl: enableAccessControl, }; if (botConfig.payout.privateKey == "") { diff --git a/src/bindings/event.ts b/src/bindings/event.ts index 3ea42f287..f36641565 100644 --- a/src/bindings/event.ts +++ b/src/bindings/event.ts @@ -2,11 +2,12 @@ import { Context } from "probot"; import { createAdapters } from "../adapters"; import { processors, wildcardProcessors } from "../handlers/processors"; import { shouldSkip } from "../helpers"; -import { BotConfig, GithubEvent, Payload, PayloadSchema } from "../types"; +import { BotConfig, GithubEvent, Payload, PayloadSchema, LogLevel } from "../types"; import { Adapters } from "../types/adapters"; import { ajv } from "../utils"; import { loadConfig } from "./config"; import { GitHubLogger } from "../adapters/supabase"; +import { validateConfigChange } from "../handlers/push"; let botContext: Context = {} as Context; export const getBotContext = () => botContext; @@ -33,8 +34,15 @@ export const bindEvents = async (context: Context): Promise => { const { id, name } = context; botContext = context; const payload = context.payload as Payload; + const allowedEvents = Object.values(GithubEvent) as string[]; + const eventName = payload.action ? `${name}.${payload.action}` : name; // some events wont have actions as this grows - botConfig = await loadConfig(context); + let botConfigError; + try { + botConfig = await loadConfig(context); + } catch (err) { + botConfigError = err; + } adapters = createAdapters(botConfig); @@ -43,11 +51,24 @@ export const bindEvents = async (context: Context): Promise => { // level: botConfig.log.level, }; - logger = new GitHubLogger(options.app, botConfig.log.logEnvironment, botConfig.log.level, botConfig.log.retryLimit); // contributors will see logs in console while on development env + logger = new GitHubLogger( + options.app, + botConfig?.log?.logEnvironment ?? "development", + botConfig?.log?.level ?? LogLevel.DEBUG, + botConfig?.log?.retryLimit ?? 0 + ); // contributors will see logs in console while on development env if (!logger) { return; } + if (botConfigError) { + logger.error(botConfigError.toString()); + if (eventName === GithubEvent.PUSH_EVENT) { + await validateConfigChange(); + } + return; + } + // Create adapters for telegram, supabase, twitter, discord, etc logger.info("Creating adapters for supabase, telegram, twitter, etc..."); @@ -60,8 +81,6 @@ export const bindEvents = async (context: Context): Promise => { wallet: botConfig.wallet, })}` ); - const allowedEvents = Object.values(GithubEvent) as string[]; - const eventName = payload.action ? `${name}.${payload.action}` : name; // some events wont have actions as this grows logger.info(`Started binding events... id: ${id}, name: ${eventName}, allowedEvents: ${allowedEvents}`); diff --git a/src/configs/index.ts b/src/configs/index.ts index 6addb9b75..a449ff0d2 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -1,3 +1,4 @@ export * from "./shared"; export * from "./strings"; export * from "./abis"; +export * from "./ubiquibot-config-default"; diff --git a/src/configs/ubiquibot-config-default.ts b/src/configs/ubiquibot-config-default.ts new file mode 100644 index 000000000..af8aa18bf --- /dev/null +++ b/src/configs/ubiquibot-config-default.ts @@ -0,0 +1,97 @@ +import { MergedConfig } from "../types"; + +export const DefaultConfig: MergedConfig = { + "evm-network-id": 100, + "price-multiplier": 1, + "issue-creator-multiplier": 2, + "payment-permit-max-price": 9007199254740991, + "max-concurrent-assigns": 9007199254740991, + "assistive-pricing": false, + "disable-analytics": false, + "comment-incentives": false, + "register-wallet-with-verification": false, + "promotion-comment": + "\n
If you enjoy the DevPool experience, please follow Ubiquity on GitHub and star this repo to show your support. It helps a lot!
", + "default-labels": [], + "time-labels": [ + { + name: "Time: <1 Hour", + }, + { + name: "Time: <1 Day", + }, + { + name: "Time: <1 Week", + }, + { + name: "Time: <2 Weeks", + }, + { + name: "Time: <1 Month", + }, + ], + "priority-labels": [ + { + name: "Priority: 1 (Normal)", + }, + { + name: "Priority: 2 (Medium)", + }, + { + name: "Priority: 3 (High)", + }, + { + name: "Priority: 4 (Urgent)", + }, + { + name: "Priority: 5 (Emergency)", + }, + ], + "command-settings": [ + { + name: "start", + enabled: false, + }, + { + name: "stop", + enabled: false, + }, + { + name: "wallet", + enabled: false, + }, + { + name: "payout", + enabled: false, + }, + { + name: "multiplier", + enabled: false, + }, + { + name: "query", + enabled: false, + }, + { + name: "allow", + enabled: false, + }, + { + name: "autopay", + enabled: false, + }, + ], + incentives: { + comment: { + elements: {}, + totals: { + word: 0, + }, + }, + }, + "enable-access-control": { + label: false, + organization: true, + }, + "stale-bounty-time": "0d", +}; diff --git a/src/handlers/access/labels-access.ts b/src/handlers/access/labels-access.ts index 184a10c0a..228aa5a38 100644 --- a/src/handlers/access/labels-access.ts +++ b/src/handlers/access/labels-access.ts @@ -1,9 +1,12 @@ import { getAccessLevel } from "../../adapters/supabase"; -import { getBotContext, getLogger } from "../../bindings"; +import { getBotConfig, getBotContext, getLogger } from "../../bindings"; import { addCommentToIssue, getUserPermission, removeLabel, addLabelToIssue } from "../../helpers"; import { Payload } from "../../types"; export const handleLabelsAccess = async () => { + const { accessControl } = getBotConfig(); + if (!accessControl.label) return true; + const context = getBotContext(); const logger = getLogger(); const payload = context.payload as Payload; diff --git a/src/handlers/assign/action.ts b/src/handlers/assign/action.ts index b42312a88..e0093de07 100644 --- a/src/handlers/assign/action.ts +++ b/src/handlers/assign/action.ts @@ -1,5 +1,6 @@ import { getBotConfig, getBotContext, getLogger } from "../../bindings"; -import { addCommentToIssue, closePullRequest, getOpenedPullRequestsForAnIssue, calculateWeight, calculateDuration } from "../../helpers"; +import { addCommentToIssue, closePullRequest, calculateWeight, calculateDuration } from "../../helpers"; +import { gitLinkedPrParser } from "../../helpers/parser"; import { Payload, LabelItem } from "../../types"; import { deadLinePrefix } from "../shared"; @@ -72,13 +73,19 @@ export const closePullRequestForAnIssue = async (): Promise => { const payload = context.payload as Payload; if (!payload.issue?.number) return; - const prs = await getOpenedPullRequestsForAnIssue(payload.issue.number, ""); + const prs = await gitLinkedPrParser({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue.number, + }); + if (!prs.length) return; + logger.info(`Opened prs for this issue: ${JSON.stringify(prs)}`); let comment = `These linked pull requests are closed: `; for (let i = 0; i < prs.length; i++) { - await closePullRequest(prs[i].number); - comment += ` #${prs[i].number} `; + await closePullRequest(prs[i].prNumber); + comment += ` #${prs[i].prNumber} `; } await addCommentToIssue(comment, payload.issue.number); }; diff --git a/src/handlers/assign/auto.ts b/src/handlers/assign/auto.ts index 7c648b5e7..380b59983 100644 --- a/src/handlers/assign/auto.ts +++ b/src/handlers/assign/auto.ts @@ -18,14 +18,14 @@ export const checkPullRequests = async () => { // Loop through the pull requests and assign them to their respective issues if needed for (const pull of pulls) { - const pullRequestLinked = await gitLinkedIssueParser({ + const linkedIssue = await gitLinkedIssueParser({ owner: payload.repository.owner.login, repo: payload.repository.name, - issue_number: pull.number, + pull_number: pull.number, }); // if pullRequestLinked is empty, continue - if (pullRequestLinked == "" || !pull.user) { + if (linkedIssue == "" || !pull.user || !linkedIssue) { continue; } @@ -37,7 +37,7 @@ export const checkPullRequests = async () => { continue; } - const linkedIssueNumber = pullRequestLinked.substring(pullRequestLinked.lastIndexOf("/") + 1); + const linkedIssueNumber = linkedIssue.substring(linkedIssue.lastIndexOf("/") + 1); // Check if the pull request opener is assigned to the issue const opener = pull.user.login; diff --git a/src/handlers/comment/action.ts b/src/handlers/comment/action.ts index 84c54e6d5..60ea5bbae 100644 --- a/src/handlers/comment/action.ts +++ b/src/handlers/comment/action.ts @@ -1,5 +1,6 @@ import { getBotConfig, getBotContext, getLogger } from "../../bindings"; import { Payload } from "../../types"; +import { ErrorDiff } from "../../utils/helpers"; import { IssueCommentCommands } from "./commands"; import { commentParser, userCommands } from "./handlers"; import { verifyFirstCheck } from "./handlers/first"; @@ -40,8 +41,8 @@ export const handleComment = async (): Promise => { const feature = config.command.find((e) => e.name === id.split("/")[1]); if (!feature?.enabled && id !== IssueCommentCommands.HELP) { - logger.info(`Skipping '${id}' because it is disabled on this repo`); - await callback(issue.number, `Skipping \`${id}\` because it is disabled on this repo`, payload.action, payload.comment); + logger.info(`Skipping '${id}' because it is disabled on this repo.`); + await callback(issue.number, `Skipping \`${id}\` because it is disabled on this repo.`, payload.action, payload.comment); continue; } @@ -54,7 +55,7 @@ export const handleComment = async (): Promise => { if (failureComment) { await callback(issue.number, failureComment, payload.action, payload.comment); } - await callback(issue.number, `Error: ${err}`, payload.action, payload.comment); + await callback(issue.number, ErrorDiff(err), payload.action, payload.comment); } } else { logger.info(`Skipping for a command: ${command}`); diff --git a/src/handlers/comment/handlers/allow.ts b/src/handlers/comment/handlers/allow.ts index 52d22e9bc..aa867985c 100644 --- a/src/handlers/comment/handlers/allow.ts +++ b/src/handlers/comment/handlers/allow.ts @@ -20,13 +20,20 @@ export const setAccess = async (body: string) => { return; } - const regex = /^\/allow set-(\S+)\s@(\w+)\s(true|false)$/; - + const regex = /\/allow\s+(\S+)\s+(\S+)\s+(\S+)/; const matches = body.match(regex); if (matches) { - const [, accessType, username, bool] = matches; - + let accessType, username, bool; + matches.slice(1).forEach((part) => { + if (part.startsWith("@")) username = part.slice(1); + else if (part.startsWith("set-")) accessType = part.slice(4); + else if (part === "true" || part === "false") bool = part; + }); + if (!accessType || !username || !bool) { + logger.error("Invalid body for allow command"); + return `Invalid syntax for allow \n usage: '/allow set-(access type) @user true|false' \n ex-1 /allow set-multiplier @user false`; + } // Check if access control demand is valid if (!validAccessString.includes(accessType)) { logger.info(`Access control setting for ${accessType} does not exist.`); diff --git a/src/handlers/comment/handlers/assign.ts b/src/handlers/comment/handlers/assign.ts index 5394b3404..e50831a64 100644 --- a/src/handlers/comment/handlers/assign.ts +++ b/src/handlers/comment/handlers/assign.ts @@ -18,8 +18,11 @@ export const assign = async (body: string) => { const id = organization?.id || repository?.id; // repository?.id as fallback + const staleBounty = config.assign.staleBountyTime; + logger.info(`Received '/start' command from user: ${payload.sender.login}, body: ${body}`); const issue = (_payload as Payload).issue; + if (!issue) { logger.info(`Skipping '/start' because of no issue instance`); return "Skipping '/start' because of no issue instance"; @@ -109,6 +112,16 @@ export const assign = async (body: string) => { await addAssignees(issue.number, [payload.sender.login]); } + let days: number | undefined; + let staleToDays: number | undefined; + let isBountyStale = false; + + if (staleBounty !== 0) { + days = Math.floor((new Date().getTime() - new Date(issue.created_at).getTime()) / (1000 * 60 * 60 * 24)); + staleToDays = Math.floor(staleBounty / (1000 * 60 * 60 * 24)); + isBountyStale = days >= staleToDays; + } + // double check whether the assign message has been already posted or not logger.info(`Creating an issue comment: ${comment.commit}`); const issueComments = await getAllIssueComments(issue.number); @@ -116,7 +129,7 @@ export const assign = async (body: string) => { const latestComment = comments.length > 0 ? comments[0].body : undefined; if (latestComment && comment.commit != latestComment) { const { multiplier, reason, bounty } = await getMultiplierInfoToDisplay(payload.sender.login, id?.toString(), issue); - return tableComment({ ...comment, multiplier, reason, bounty }) + comment.tips; + return tableComment({ ...comment, multiplier, reason, bounty, isBountyStale, days }) + comment.tips; } return; }; @@ -139,7 +152,7 @@ const getMultiplierInfoToDisplay = async (senderLogin: string, org_id: string, i } else { _multiplierToDisplay = multiplier; _reasonToDisplay = reason; - _bountyToDisplay = `Permit generation skipped since price label is not set`; + _bountyToDisplay = `Permit generation disabled because price label is not set.`; const issueDetailed = bountyInfo(issue); if (issueDetailed.priceLabel) { _bountyToDisplay = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * value).toString() + " USD"; diff --git a/src/handlers/comment/handlers/first.ts b/src/handlers/comment/handlers/first.ts index c14d50702..1e7400a35 100644 --- a/src/handlers/comment/handlers/first.ts +++ b/src/handlers/comment/handlers/first.ts @@ -10,14 +10,19 @@ export const verifyFirstCheck = async (): Promise => { if (!payload.issue) return; try { - const response = await context.octokit.rest.search.issuesAndPullRequests({ + const response_issue = await context.octokit.rest.search.issuesAndPullRequests({ q: `is:issue repo:${payload.repository.owner.login}/${payload.repository.name} commenter:${payload.sender.login}`, per_page: 2, }); - if (response.data.total_count === 1) { + const response_pr = await context.octokit.rest.search.issuesAndPullRequests({ + q: `is:pull-request repo:${payload.repository.owner.login}/${payload.repository.name} commenter:${payload.sender.login}`, + per_page: 2, + }); + if (response_issue.data.total_count + response_pr.data.total_count === 1) { //continue_first_search + const data = response_issue.data.total_count > 0 ? response_issue.data : response_pr.data; const resp = await context.octokit.rest.issues.listComments({ - issue_number: response.data.items[0].number, + issue_number: data.items[0].number, owner: payload.repository.owner.login, repo: payload.repository.name, per_page: 100, diff --git a/src/handlers/comment/handlers/index.ts b/src/handlers/comment/handlers/index.ts index 5470b3062..b8dfe8534 100644 --- a/src/handlers/comment/handlers/index.ts +++ b/src/handlers/comment/handlers/index.ts @@ -27,6 +27,7 @@ import { handleIssueClosed } from "../../payout"; import { query } from "./query"; import { autoPay } from "./payout"; import { getTargetPriceLabel } from "../../shared"; +import { ErrorDiff } from "../../../utils/helpers"; export * from "./assign"; export * from "./wallet"; @@ -71,7 +72,7 @@ export const issueClosedCallback = async (): Promise => { const comment = await handleIssueClosed(); if (comment) await addCommentToIssue(comment + comments.promotionComment, issue.number); } catch (err: unknown) { - return await addCommentToIssue(`Error: ${err}`, issue.number); + return await addCommentToIssue(ErrorDiff(err), issue.number); } }; @@ -90,7 +91,7 @@ export const issueCreatedCallback = async (): Promise => { const { assistivePricing } = config.mode; if (!assistivePricing) { - logger.info("Skipping adding label to issue because assistive pricing is disabled"); + logger.info("Skipping adding label to issue because assistive pricing is disabled."); return; } @@ -112,7 +113,7 @@ export const issueCreatedCallback = async (): Promise => { await addLabelToIssue(targetPriceLabel); } } catch (err: unknown) { - await addCommentToIssue(`Error: ${err}`, issue.number); + await addCommentToIssue(ErrorDiff(err), issue.number); } }; @@ -176,20 +177,24 @@ export const issueReopenedCallback = async (): Promise => { } const assignee = events[0].assignee.login; - // write penalty to db - try { - await addPenalty(assignee, repository.full_name, tokenAddress, networkId.toString(), amount); - } catch (err) { - logger.error(`Error writing penalty to db: ${err}`); - return; - } + if (parseFloat(formattedAmount) > 0) { + // write penalty to db + try { + await addPenalty(assignee, repository.full_name, tokenAddress, networkId.toString(), amount); + } catch (err) { + logger.error(`Error writing penalty to db: ${err}`); + return; + } - await addCommentToIssue( - `@${assignee} please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **${formattedAmount} ${tokenSymbol}** will be deducted from your next bounty.`, - issue.number - ); + await addCommentToIssue( + `@${assignee} please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **${formattedAmount} ${tokenSymbol}** will be deducted from your next bounty.`, + issue.number + ); + } else { + logger.info(`Skipped penalty because amount is 0`); + } } catch (err: unknown) { - await addCommentToIssue(`Error: ${err}`, issue.number); + await addCommentToIssue(ErrorDiff(err), issue.number); } }; diff --git a/src/handlers/comment/handlers/query.ts b/src/handlers/comment/handlers/query.ts index efee499d1..8f78c537f 100644 --- a/src/handlers/comment/handlers/query.ts +++ b/src/handlers/comment/handlers/query.ts @@ -1,4 +1,4 @@ -import { getWalletInfo } from "../../../adapters/supabase"; +import { getAllAccessLevels, getWalletInfo } from "../../../adapters/supabase"; import { getBotContext, getLogger } from "../../../bindings"; import { Payload } from "../../../types"; @@ -22,13 +22,26 @@ export const query = async (body: string) => { const regex = /^\/query\s+@([\w-]+)\s*$/; const matches = body.match(regex); const user = matches?.[1]; + const repo = payload.repository; if (user) { + const data = await getAllAccessLevels(user, repo.full_name); + if (!data) { + return `Error retrieving access for @${user}`; + } const walletInfo = await getWalletInfo(user, id?.toString()); if (!walletInfo?.address) { return `Error retrieving multiplier and wallet address for @${user}`; } else { - return `@${user}'s wallet address is ${walletInfo?.address} and multiplier is ${walletInfo?.multiplier}`; + return `@${user}'s wallet address is ${walletInfo?.address}, multiplier is ${walletInfo?.multiplier} and access levels are + +| access type | access level | +| ----------- | ------------------- | +| multiplier | ${data.multiplier} | +| priority | ${data.priority} | +| time | ${data.time} | +| price | ${data.price} | + `; } } else { logger.error("Invalid body for query command"); diff --git a/src/handlers/comment/handlers/table.ts b/src/handlers/comment/handlers/table.ts index 13bed9e88..97f154e0b 100644 --- a/src/handlers/comment/handlers/table.ts +++ b/src/handlers/comment/handlers/table.ts @@ -4,32 +4,35 @@ export const tableComment = ({ multiplier, reason, bounty, + isBountyStale, + days, }: { deadline: string; wallet: string; multiplier?: string; reason?: string; bounty?: string; + isBountyStale?: boolean; + days?: number; }) => { return ` - - - - - - - - - - - - - - - ${multiplier ? `` : ``} - ${reason ? `` : ``} - ${bounty ? `` : ``} -
Deadline${deadline}
Registered Wallet${wallet}
Payment Multiplier${multiplier}
Multiplier Reason${reason}
Total Bounty${bounty}
-
`; + +${ + isBountyStale + ? `` + : `` +} + + + + + + + + +${multiplier ? `` : ``} +${reason ? `` : ``} +${bounty ? `` : ``} +
Warning! This task was created over ${days} days ago. Please confirm that this issue specification is accurate before starting.
Deadline${deadline}
Registered Wallet${wallet}
Payment Multiplier${multiplier}
Multiplier Reason${reason}
Total Bounty${bounty}
`; }; diff --git a/src/handlers/payout/action.ts b/src/handlers/payout/action.ts index fd2aaaaac..49677a3cb 100644 --- a/src/handlers/payout/action.ts +++ b/src/handlers/payout/action.ts @@ -25,6 +25,7 @@ export const handleIssueClosed = async () => { const { payout: { paymentToken, rpc, permitBaseUrl, networkId, privateKey }, mode: { paymentPermitMaxPrice }, + accessControl, } = getBotConfig(); const logger = getLogger(); const payload = context.payload as Payload; @@ -35,9 +36,11 @@ export const handleIssueClosed = async () => { if (!issue) return; - const userHasPermission = await checkUserPermissionForRepoAndOrg(payload.sender.login, context); + if (accessControl.organization) { + const userHasPermission = await checkUserPermissionForRepoAndOrg(payload.sender.login, context); - if (!userHasPermission) return "Permit generation skipped because this issue has been closed by an external contributor."; + if (!userHasPermission) return "Permit generation disabled because this issue has been closed by an external contributor."; + } const comments = await getAllIssueComments(issue.number); @@ -91,19 +94,19 @@ export const handleIssueClosed = async () => { return; } if (privateKey == "") { - logger.info("Permit generation skipped because wallet private key is not set"); - return "Permit generation skipped because wallet private key is not set"; + logger.info("Permit generation disabled because wallet private key is not set."); + return "Permit generation disabled because wallet private key is not set."; } if (issue.state_reason !== StateReason.COMPLETED) { - logger.info("Permit generation skipped because the issue was not closed as completed"); - return "Permit generation skipped because the issue was not closed as completed"; + logger.info("Permit generation disabled because this is marked as unplanned."); + return "Permit generation disabled because this is marked as unplanned."; } logger.info(`Checking if the issue is a parent issue.`); if (issue.body && isParentIssue(issue.body)) { - logger.error("Permit generation skipped since the issue is identified as parent issue."); + logger.error("Permit generation disabled because this is a collection of issues."); await clearAllPriceLabelsOnIssue(); - return "Permit generation skipped since the issue is identified as parent issue."; + return "Permit generation disabled because this is a collection of issues."; } logger.info(`Handling issues.closed event, issue: ${issue.number}`); @@ -115,7 +118,7 @@ export const handleIssueClosed = async () => { if (res) { if (res[1] === "false") { logger.info(`Skipping to generate permit2 url, reason: autoPayMode for this issue: false`); - return `Permit generation skipped since automatic payment for this issue is disabled.`; + return `Permit generation disabled because automatic payment for this issue is disabled.`; } break; } @@ -124,25 +127,25 @@ export const handleIssueClosed = async () => { if (paymentPermitMaxPrice == 0 || !paymentPermitMaxPrice) { logger.info(`Skipping to generate permit2 url, reason: { paymentPermitMaxPrice: ${paymentPermitMaxPrice}}`); - return `Permit generation skipped since paymentPermitMaxPrice is 0`; + return `Permit generation disabled because paymentPermitMaxPrice is 0.`; } const issueDetailed = bountyInfo(issue); if (!issueDetailed.isBounty) { - logger.info(`Skipping... its not a bounty`); - return `Permit generation skipped since this issue didn't qualify as bounty`; + logger.info(`Skipping... its not a bounty.`); + return `Permit generation disabled because this issue didn't qualify as bounty.`; } const assignees = issue?.assignees ?? []; const assignee = assignees.length > 0 ? assignees[0] : undefined; if (!assignee) { - logger.info("Skipping to proceed the payment because `assignee` is undefined"); - return `Permit generation skipped since assignee is undefined`; + logger.info("Skipping to proceed the payment because `assignee` is undefined."); + return `Permit generation disabled because assignee is undefined.`; } if (!issueDetailed.priceLabel) { logger.info("Skipping to proceed the payment because price not set"); - return `Permit generation skipped since price label is not set`; + return `Permit generation disabled because price label is not set.`; } const recipient = await getWalletAddress(assignee.login); @@ -161,8 +164,8 @@ export const handleIssueClosed = async () => { let priceInEth = new Decimal(issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4)).mul(multiplier); if (priceInEth.gt(paymentPermitMaxPrice)) { - logger.info("Skipping to proceed the payment because bounty payout is higher than paymentPermitMaxPrice"); - return `Permit generation skipped since issue's bounty is higher than ${paymentPermitMaxPrice}`; + logger.info("Skipping to proceed the payment because bounty payout is higher than paymentPermitMaxPrice."); + return `Permit generation disabled because issue's bounty is higher than ${paymentPermitMaxPrice}.`; } // if bounty hunter has any penalty then deduct it from the bounty @@ -173,23 +176,28 @@ export const handleIssueClosed = async () => { const bountyAmountAfterPenalty = bountyAmount.sub(penaltyAmount); if (bountyAmountAfterPenalty.lte(0)) { await removePenalty(assignee.login, payload.repository.full_name, paymentToken, networkId.toString(), bountyAmount); - const msg = `Permit generation skipped because bounty amount after penalty is 0`; + const msg = `Permit generation disabled because bounty amount after penalty is 0.`; logger.info(msg); return msg; } priceInEth = new Decimal(ethers.utils.formatUnits(bountyAmountAfterPenalty, 18)); } - const { txData, payoutUrl } = await generatePermit2Signature(recipient, priceInEth, issue.node_id); + const { txData, payoutUrl } = await generatePermit2Signature(recipient, priceInEth, issue.node_id, assignee.node_id, "ISSUE_ASSIGNEE"); const tokenSymbol = await getTokenSymbol(paymentToken, rpc); const shortenRecipient = shortenEthAddress(recipient, `[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]`.length); logger.info(`Posting a payout url to the issue, url: ${payoutUrl}`); const comment = `#### Task Assignee Reward\n### [ **[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n` + "```" + shortenRecipient + "```"; - const permitComments = comments.filter((content) => content.body.includes("https://pay.ubq.fi?claim=") && content.user.type == UserType.Bot); + const permitComments = comments.filter((content) => { + claimUrlRegex; + const permitUrlMatches = content.body.match(claimUrlRegex); + if (!permitUrlMatches || permitUrlMatches.length < 2) return false; + else return true; + }); if (permitComments.length > 0) { - logger.info(`Skip to generate a permit url because it has been already posted`); - return `Permit generation skipped because it was already posted to this issue.`; + logger.info(`Skip to generate a permit url because it has been already posted.`); + return `Permit generation disabled because it was already posted to this issue.`; } await deleteLabel(issueDetailed.priceLabel); await addLabelToIssue("Permitted"); diff --git a/src/handlers/payout/post.ts b/src/handlers/payout/post.ts index fed1aaabe..4db4530ea 100644 --- a/src/handlers/payout/post.ts +++ b/src/handlers/payout/post.ts @@ -1,6 +1,15 @@ import { getWalletAddress } from "../../adapters/supabase"; import { getBotConfig, getBotContext, getLogger } from "../../bindings"; -import { addCommentToIssue, generatePermit2Signature, getAllIssueComments, getIssueDescription, getTokenSymbol, parseComments } from "../../helpers"; +import { + addCommentToIssue, + generatePermit2Signature, + getAllIssueComments, + getAllPullRequestReviews, + getIssueDescription, + getTokenSymbol, + parseComments, +} from "../../helpers"; +import { getLatestPullRequest, gitLinkedPrParser } from "../../helpers/parser"; import { Incentives, MarkdownItem, MarkdownItems, Payload, StateReason, UserType } from "../../types"; import { commentParser } from "../comment"; import Decimal from "decimal.js"; @@ -32,7 +41,7 @@ export const incentivizeComments = async () => { } if (issue.state_reason !== StateReason.COMPLETED) { - logger.info("incentivizeComments: comment incentives skipped because the issue was not closed as completed"); + logger.info("incentivizeComments: comment incentives disabled because the issue was not closed as completed."); return; } @@ -65,7 +74,7 @@ export const incentivizeComments = async () => { const issueComments = await getAllIssueComments(issue.number); logger.info(`Getting the issue comments done. comments: ${JSON.stringify(issueComments)}`); - const issueCommentsByUser: Record = {}; + const issueCommentsByUser: Record = {}; for (const issueComment of issueComments) { const user = issueComment.user; if (user.type == UserType.Bot || user.login == assignee) continue; @@ -78,10 +87,12 @@ export const incentivizeComments = async () => { logger.info(`Skipping to parse the comment because body is undefined. comment: ${JSON.stringify(issueComment)}`); continue; } + + // Store the comment along with user's login and node_id if (!issueCommentsByUser[user.login]) { - issueCommentsByUser[user.login] = []; + issueCommentsByUser[user.login] = { id: user.node_id, comments: [] }; } - issueCommentsByUser[user.login].push(issueComment.body); + issueCommentsByUser[user.login].comments.push(issueComment.body); } const tokenSymbol = await getTokenSymbol(paymentToken, rpc); logger.info(`Filtering by the user type done. commentsByUser: ${JSON.stringify(issueCommentsByUser)}`); @@ -93,8 +104,8 @@ export const incentivizeComments = async () => { const fallbackReward: Record = {}; let comment = `#### Conversation Rewards\n`; for (const user of Object.keys(issueCommentsByUser)) { - const comments = issueCommentsByUser[user]; - const commentsByNode = await parseComments(comments, ItemsToExclude); + const commentsByUser = issueCommentsByUser[user]; + const commentsByNode = await parseComments(commentsByUser.comments, ItemsToExclude); const rewardValue = calculateRewardValue(commentsByNode, incentives); if (rewardValue.equals(0)) { logger.info(`Skipping to generate a permit url because the reward value is 0. user: ${user}`); @@ -108,7 +119,7 @@ export const incentivizeComments = async () => { continue; } if (account) { - const { payoutUrl } = await generatePermit2Signature(account, amountInETH, issue.node_id); + const { payoutUrl } = await generatePermit2Signature(account, amountInETH, issue.node_id, commentsByUser.id, "ISSUE_COMMENTER"); comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; reward[user] = payoutUrl; } else { @@ -122,6 +133,139 @@ export const incentivizeComments = async () => { await addCommentToIssue(comment, issue.number); }; +export const incentivizePullRequestReviews = async () => { + const logger = getLogger(); + const { + mode: { incentiveMode, paymentPermitMaxPrice }, + price: { baseMultiplier, incentives }, + payout: { paymentToken, rpc }, + } = getBotConfig(); + if (!incentiveMode) { + logger.info(`No incentive mode. skipping to process`); + return; + } + const context = getBotContext(); + const payload = context.payload as Payload; + const issue = payload.issue; + if (!issue) { + logger.info(`Incomplete payload. issue: ${issue}`); + return; + } + + if (issue.state_reason !== StateReason.COMPLETED) { + logger.info("incentivizePullRequestReviews: comment incentives skipped because the issue was not closed as completed"); + return; + } + + if (paymentPermitMaxPrice == 0 || !paymentPermitMaxPrice) { + logger.info(`incentivizePullRequestReviews: skipping to generate permit2 url, reason: { paymentPermitMaxPrice: ${paymentPermitMaxPrice}}`); + return; + } + + const issueDetailed = bountyInfo(issue); + if (!issueDetailed.isBounty) { + logger.info(`incentivizePullRequestReviews: its not a bounty`); + return; + } + + const linkedPullRequest = await gitLinkedPrParser({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issue.number, + }); + const latestLinkedPullRequest = await getLatestPullRequest(linkedPullRequest); + + if (!latestLinkedPullRequest) { + logger.debug(`incentivizePullRequestReviews: No linked pull requests found`); + return; + } + + const comments = await getAllIssueComments(issue.number); + const permitComments = comments.filter( + (content) => content.body.includes("Reviewer Rewards") && content.body.includes("https://pay.ubq.fi?claim=") && content.user.type == UserType.Bot + ); + if (permitComments.length > 0) { + logger.info(`incentivizePullRequestReviews: skip to generate a permit url because it has been already posted`); + return; + } + + const assignees = issue?.assignees ?? []; + const assignee = assignees.length > 0 ? assignees[0] : undefined; + if (!assignee) { + logger.info("incentivizePullRequestReviews: skipping payment permit generation because `assignee` is `undefined`."); + return; + } + + const prReviews = await getAllPullRequestReviews(context, latestLinkedPullRequest.number, "full"); + const prComments = await getAllIssueComments(latestLinkedPullRequest.number, "full"); + logger.info(`Getting the PR reviews done. comments: ${JSON.stringify(prReviews)}`); + const prReviewsByUser: Record = {}; + for (const review of prReviews) { + const user = review.user; + if (!user) continue; + if (user.type == UserType.Bot || user.login == assignee) continue; + if (!review.body_html) { + logger.info(`incentivizePullRequestReviews: Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(review)}`); + continue; + } + if (!prReviewsByUser[user.login]) { + prReviewsByUser[user.login] = { id: user.node_id, comments: [] }; + } + prReviewsByUser[user.login].comments.push(review.body_html); + } + + for (const comment of prComments) { + const user = comment.user; + if (!user) continue; + if (user.type == UserType.Bot || user.login == assignee) continue; + if (!comment.body_html) { + logger.info(`incentivizePullRequestReviews: Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(comment)}`); + continue; + } + if (!prReviewsByUser[user.login]) { + prReviewsByUser[user.login] = { id: user.node_id, comments: [] }; + } + prReviewsByUser[user.login].comments.push(comment.body_html); + } + const tokenSymbol = await getTokenSymbol(paymentToken, rpc); + logger.info(`incentivizePullRequestReviews: Filtering by the user type done. commentsByUser: ${JSON.stringify(prReviewsByUser)}`); + + // The mapping between gh handle and comment with a permit url + const reward: Record = {}; + + // The mapping between gh handle and amount in ETH + const fallbackReward: Record = {}; + let comment = `#### Reviewer Rewards\n`; + for (const user of Object.keys(prReviewsByUser)) { + const commentByUser = prReviewsByUser[user]; + const commentsByNode = await parseComments(commentByUser.comments, ItemsToExclude); + const rewardValue = calculateRewardValue(commentsByNode, incentives); + if (rewardValue.equals(0)) { + logger.info(`incentivizePullRequestReviews: Skipping to generate a permit url because the reward value is 0. user: ${user}`); + continue; + } + logger.info(`incentivizePullRequestReviews: Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`); + const account = await getWalletAddress(user); + const amountInETH = rewardValue.mul(baseMultiplier); + if (amountInETH.gt(paymentPermitMaxPrice)) { + logger.info(`incentivizePullRequestReviews: Skipping comment reward for user ${user} because reward is higher than payment permit max price`); + continue; + } + if (account) { + const { payoutUrl } = await generatePermit2Signature(account, amountInETH, issue.node_id, commentByUser.id, "ISSUE_COMMENTER"); + comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; + reward[user] = payoutUrl; + } else { + fallbackReward[user] = amountInETH; + } + } + + logger.info(`incentivizePullRequestReviews: Permit url generated for pull request reviewers. reward: ${JSON.stringify(reward)}`); + logger.info(`incentivizePullRequestReviews: Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(fallbackReward)}`); + + await addCommentToIssue(comment, issue.number); +}; + export const incentivizeCreatorComment = async () => { const logger = getLogger(); const { @@ -142,7 +286,7 @@ export const incentivizeCreatorComment = async () => { } if (issue.state_reason !== StateReason.COMPLETED) { - logger.info("incentivizeCreatorComment: comment incentives skipped because the issue was not closed as completed"); + logger.info("incentivizeCreatorComment: comment incentives disabled because the issue was not closed as completed."); return; } @@ -188,6 +332,7 @@ export const incentivizeCreatorComment = async () => { const tokenSymbol = await getTokenSymbol(paymentToken, rpc); const result = await generatePermitForComments( creator.login, + creator.node_id, [description], issueCreatorMultiplier, incentives, @@ -207,6 +352,7 @@ export const incentivizeCreatorComment = async () => { const generatePermitForComments = async ( user: string, + userId: string, comments: string[], multiplier: number, incentives: Incentives, @@ -230,7 +376,7 @@ const generatePermitForComments = async ( } let comment = `#### Task Creator Reward\n`; if (account) { - const { payoutUrl } = await generatePermit2Signature(account, amountInETH, node_id); + const { payoutUrl } = await generatePermit2Signature(account, amountInETH, node_id, userId, "ISSUE_CREATOR"); comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; return { comment, payoutUrl }; } else { diff --git a/src/handlers/pricing/action.ts b/src/handlers/pricing/action.ts index cd9dfa8a4..0b8982eaa 100644 --- a/src/handlers/pricing/action.ts +++ b/src/handlers/pricing/action.ts @@ -25,7 +25,7 @@ export const pricingLabelLogic = async (): Promise => { } const valid = await handleLabelsAccess(); - if (!valid) { + if (!valid && config.accessControl.label) { return; } diff --git a/src/handlers/processors.ts b/src/handlers/processors.ts index 16f89a535..68e366063 100644 --- a/src/handlers/processors.ts +++ b/src/handlers/processors.ts @@ -3,17 +3,17 @@ import { closePullRequestForAnIssue, commentWithAssignMessage } from "./assign"; import { pricingLabelLogic, validatePriceLabels } from "./pricing"; import { checkBountiesToUnassign, collectAnalytics, checkWeeklyUpdate } from "./wildcard"; import { nullHandler } from "./shared"; -import { handleComment, issueClosedCallback, issueReopenedCallback } from "./comment"; +import { handleComment, issueClosedCallback, issueCreatedCallback, issueReopenedCallback } from "./comment"; import { checkPullRequests } from "./assign/auto"; import { createDevPoolPR } from "./pull-request"; -import { runOnPush } from "./push"; -import { incentivizeComments, incentivizeCreatorComment } from "./payout"; +import { incentivizeComments, incentivizeCreatorComment, incentivizePullRequestReviews } from "./payout"; +import { runOnPush, validateConfigChange } from "./push"; import { findDuplicateOne } from "./issue"; export const processors: Record = { [GithubEvent.ISSUES_OPENED]: { pre: [nullHandler], - action: [findDuplicateOne], // SHOULD not set `issueCreatedCallback` until the exploit issue resolved. https://github.com/ubiquity/ubiquibot/issues/535 + action: [findDuplicateOne, issueCreatedCallback], post: [nullHandler], }, [GithubEvent.ISSUES_REOPENED]: { @@ -54,7 +54,7 @@ export const processors: Record = { [GithubEvent.ISSUES_CLOSED]: { pre: [nullHandler], action: [issueClosedCallback], - post: [incentivizeCreatorComment, incentivizeComments], + post: [incentivizeCreatorComment, incentivizeComments, incentivizePullRequestReviews], }, [GithubEvent.PULL_REQUEST_OPENED]: { pre: [nullHandler], @@ -68,7 +68,7 @@ export const processors: Record = { }, [GithubEvent.PUSH_EVENT]: { pre: [nullHandler], - action: [runOnPush], + action: [validateConfigChange, runOnPush], post: [nullHandler], }, }; diff --git a/src/handlers/push/index.ts b/src/handlers/push/index.ts index 4cd4775ae..340143439 100644 --- a/src/handlers/push/index.ts +++ b/src/handlers/push/index.ts @@ -1,6 +1,9 @@ import { getBotContext, getLogger } from "../../bindings"; -import { CommitsPayload, PushPayload } from "../../types"; +import { createCommitComment, getFileContent } from "../../helpers"; +import { CommitsPayload, PushPayload, WideConfigSchema } from "../../types"; +import { parseYAML } from "../../utils/private"; import { updateBaseRate } from "./update-base"; +import { validate } from "../../utils/ajv"; const ZERO_SHA = "0000000000000000000000000000000000000000"; const BASE_RATE_FILE = ".github/ubiquibot-config.yml"; @@ -45,3 +48,49 @@ export const runOnPush = async () => { await updateBaseRate(context, payload, BASE_RATE_FILE); } }; + +export const validateConfigChange = async () => { + const logger = getLogger(); + + const context = getBotContext(); + const payload = context.payload as PushPayload; + + if (!payload.ref.startsWith("refs/heads/")) { + logger.debug("Skipping push events, not a branch"); + return; + } + + const changes = getCommitChanges(payload.commits); + + // skip if empty + if (changes && changes.length === 0) { + logger.debug("Skipping push events, file change empty"); + return; + } + + // check for modified or added files and check for specified file + if (changes.includes(BASE_RATE_FILE)) { + const commitSha = payload.commits.filter((commit) => commit.modified.includes(BASE_RATE_FILE) || commit.added.includes(BASE_RATE_FILE)).reverse()[0]?.id; + if (!commitSha) { + logger.debug("Skipping push events, commit sha not found"); + return; + } + + const configFileContent = await getFileContent( + payload.repository.owner.login, + payload.repository.name, + payload.ref.split("refs/heads/")[1], + BASE_RATE_FILE, + commitSha + ); + + if (configFileContent) { + const decodedConfig = Buffer.from(configFileContent, "base64").toString(); + const config = parseYAML(decodedConfig); + const { valid, error } = validate(WideConfigSchema, config); + if (!valid) { + await createCommitComment(`@${payload.sender.login} Config validation failed! ${error}`, commitSha, BASE_RATE_FILE); + } + } + } +}; diff --git a/src/handlers/wildcard/unassign.ts b/src/handlers/wildcard/unassign.ts index 8e06ecebd..ec057ce73 100644 --- a/src/handlers/wildcard/unassign.ts +++ b/src/handlers/wildcard/unassign.ts @@ -1,15 +1,16 @@ -import { closePullRequestForAnIssue } from "../assign"; -import { getBotConfig, getLogger } from "../../bindings"; +import { getBotConfig, getBotContext, getLogger } from "../../bindings"; import { GLOBAL_STRINGS } from "../../configs/strings"; import { addCommentToIssue, getAllIssueComments, getCommitsOnPullRequest, getOpenedPullRequestsForAnIssue, + getReviewRequests, listAllIssuesForRepo, removeAssignees, } from "../../helpers"; -import { Comment, Issue, IssueType } from "../../types"; +import { Comment, Issue, IssueType, Payload, UserType } from "../../types"; +import { deadLinePrefix } from "../shared"; /** * @dev Check out the bounties which haven't been completed within the initial timeline @@ -31,6 +32,8 @@ export const checkBountiesToUnassign = async () => { }; const checkBountyToUnassign = async (issue: Issue): Promise => { + const context = getBotContext(); + const payload = context.payload as Payload; const logger = getLogger(); const { unassign: { followUpTime, disqualifyTime }, @@ -49,13 +52,20 @@ const checkBountyToUnassign = async (issue: Issue): Promise => { const curTimestamp = new Date().getTime(); const lastActivity = await lastActivityTime(issue, comments); const passedDuration = curTimestamp - lastActivity.getTime(); + const pullRequest = await getOpenedPullRequestsForAnIssue(issue.number, issue.assignee.login); + + if (pullRequest.length > 0) { + const reviewRequests = await getReviewRequests(context, pullRequest[0].number, payload.repository.owner.login, payload.repository.name); + if (!reviewRequests || reviewRequests.users?.length > 0) { + return false; + } + } if (passedDuration >= disqualifyTime || passedDuration >= followUpTime) { if (passedDuration >= disqualifyTime) { logger.info( `Unassigning... lastActivityTime: ${lastActivity.getTime()}, curTime: ${curTimestamp}, passedDuration: ${passedDuration}, followUpTime: ${followUpTime}, disqualifyTime: ${disqualifyTime}` ); - await closePullRequestForAnIssue(); // remove assignees from the issue await removeAssignees(issue.number, assignees); await addCommentToIssue(`@${assignees[0]} - ${unassignComment} \nLast activity time: ${lastActivity}`, issue.number); @@ -70,11 +80,12 @@ const checkBountyToUnassign = async (issue: Issue): Promise => { logger.info( `Skipping posting an update message cause its been already asked, lastAskTime: ${lastAskTime}, lastActivityTime: ${lastActivity.getTime()}` ); - } else + } else { await addCommentToIssue( `${askUpdate} @${assignees[0]}? If you would like to release the bounty back to the DevPool, please comment \`/stop\` \nLast activity time: ${lastActivity}`, issue.number ); + } } } @@ -87,6 +98,11 @@ const lastActivityTime = async (issue: Issue, comments: Comment[]): Promise i.login); const activities: Date[] = [new Date(issue.created_at)]; + const lastAssignCommentOfHunter = comments + .filter((comment) => comment.user.type === UserType.Bot && comment.body.includes(assignees[0]) && comment.body.includes(deadLinePrefix)) + .sort((a: Comment, b: Comment) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + if (lastAssignCommentOfHunter.length > 0) activities.push(new Date(lastAssignCommentOfHunter[0].created_at)); + // get last comment on the issue const lastCommentsOfHunterForIssue = comments .filter((comment) => assignees.includes(comment.user.login)) diff --git a/src/helpers/commit.ts b/src/helpers/commit.ts new file mode 100644 index 000000000..678b4d774 --- /dev/null +++ b/src/helpers/commit.ts @@ -0,0 +1,21 @@ +import { getBotContext } from "../bindings"; +import { Payload } from "../types"; + +export async function createCommitComment(body: string, commitSha: string, path?: string, owner?: string, repo?: string) { + const context = getBotContext(); + const payload = context.payload as Payload; + if (!owner) { + owner = payload.repository.owner.login; + } + if (!repo) { + repo = payload.repository.name; + } + + await context.octokit.rest.repos.createCommitComment({ + owner: owner, + repo: repo, + commit_sha: commitSha, + body: body, + path: path, + }); +} diff --git a/src/helpers/file.ts b/src/helpers/file.ts index f32a64e31..b21905098 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -59,3 +59,55 @@ export async function getPreviousFileContent(owner: string, repo: string, branch return ""; } } + +export async function getFileContent(owner: string, repo: string, branch: string, filePath: string, commitSha?: string): Promise { + const logger = getLogger(); + const context = getBotContext(); + + try { + if (!commitSha) { + // Get the latest commit of the branch + const branchData = await context.octokit.repos.getBranch({ + owner, + repo, + branch, + }); + commitSha = branchData.data.commit.sha; + } + + // Get the commit details + const commitData = await context.octokit.repos.getCommit({ + owner, + repo, + ref: commitSha, + }); + + // Find the file in the commit tree + const file = commitData.data.files ? commitData.data.files.find((file) => file.filename === filePath) : undefined; + if (file) { + // Retrieve the file tree + const tree = await context.octokit.git.getTree({ + owner, + repo, + tree_sha: commitData.data.commit.tree.sha, + recursive: "true", + }); + + // Find the previous file content in the tree + const file = tree.data.tree.find((item) => item.path === filePath); + if (file && file.sha) { + // Get the previous file content + const fileContent = await context.octokit.git.getBlob({ + owner, + repo, + file_sha: file.sha, + }); + return fileContent.data.content; + } + } + return null; + } catch (error: unknown) { + logger.debug(`Error retrieving previous file content. error: ${error}`); + return null; + } +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index ad8ee0cb9..07cf66d74 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -9,3 +9,4 @@ export * from "./comment"; export * from "./payout"; export * from "./file"; export * from "./similarity"; +export * from "./commit"; diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index 00ccd0e93..b8b487f35 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -1,5 +1,5 @@ import { Context } from "probot"; -import { getBotContext, getLogger, loadConfig } from "../bindings"; +import { getBotConfig, getBotContext, getLogger } from "../bindings"; import { AssignEvent, Comment, IssueType, Payload } from "../types"; import { checkRateLimitGit } from "../utils"; @@ -515,13 +515,13 @@ export const closePullRequest = async (pull_number: number) => { } }; -export const getAllPullRequestReviews = async (context: Context, pull_number: number) => { +export const getAllPullRequestReviews = async (context: Context, pull_number: number, format: "raw" | "html" | "text" | "full" = "raw") => { const prArr = []; let fetchDone = false; const perPage = 30; let curPage = 1; while (!fetchDone) { - const prs = await getPullRequestReviews(context, pull_number, perPage, curPage); + const prs = await getPullRequestReviews(context, pull_number, perPage, curPage, format); // push the objects to array prArr.push(...prs); @@ -532,7 +532,13 @@ export const getAllPullRequestReviews = async (context: Context, pull_number: nu return prArr; }; -export const getPullRequestReviews = async (context: Context, pull_number: number, per_page: number, page: number) => { +export const getPullRequestReviews = async ( + context: Context, + pull_number: number, + per_page: number, + page: number, + format: "raw" | "html" | "text" | "full" = "raw" +) => { const logger = getLogger(); const payload = context.payload as Payload; try { @@ -542,6 +548,9 @@ export const getPullRequestReviews = async (context: Context, pull_number: numbe pull_number, per_page, page, + mediaType: { + format, + }, }); return reviews; } catch (e: unknown) { @@ -550,6 +559,20 @@ export const getPullRequestReviews = async (context: Context, pull_number: numbe } }; +export const getReviewRequests = async (context: Context, pull_number: number, owner: string, repo: string) => { + const logger = getLogger(); + try { + const response = await context.octokit.pulls.listRequestedReviewers({ + owner: owner, + repo: repo, + pull_number: pull_number, + }); + return response.data; + } catch (e: unknown) { + logger.error(`Error: could not get requested reviewers, reason: ${e}`); + return null; + } +}; // Get issues by issue number export const getIssueByNumber = async (context: Context, issue_number: number) => { const logger = getLogger(); @@ -641,8 +664,10 @@ export const getCommitsOnPullRequest = async (pullNumber: number) => { export const getAvailableOpenedPullRequests = async (username: string) => { const context = getBotContext(); - const botConfig = await loadConfig(context); - if (!botConfig.unassign.timeRangeForMaxIssueEnabled) return []; + const { + unassign: { timeRangeForMaxIssue, timeRangeForMaxIssueEnabled }, + } = await getBotConfig(); + if (!timeRangeForMaxIssueEnabled) return []; const opened_prs = await getOpenedPullRequests(username); @@ -657,7 +682,7 @@ export const getAvailableOpenedPullRequests = async (username: string) => { if (approvedReviews) result.push(pr); } - if (reviews.length === 0 && (new Date().getTime() - new Date(pr.created_at).getTime()) / (1000 * 60 * 60) >= botConfig.unassign.timeRangeForMaxIssue) { + if (reviews.length === 0 && (new Date().getTime() - new Date(pr.created_at).getTime()) / (1000 * 60 * 60) >= timeRangeForMaxIssue) { result.push(pr); } } diff --git a/src/helpers/parser.ts b/src/helpers/parser.ts index b05411fee..42821a5b5 100644 --- a/src/helpers/parser.ts +++ b/src/helpers/parser.ts @@ -1,44 +1,93 @@ import axios from "axios"; import { HTMLElement, parse } from "node-html-parser"; +import { getPullByNumber } from "./issue"; +import { getBotContext, getLogger } from "../bindings"; interface GitParser { owner: string; repo: string; - issue_number: number; + issue_number?: number; + pull_number?: number; } -export const gitIssueParser = async ({ owner, repo, issue_number }: GitParser): Promise => { +export interface LinkedPR { + prOrganization: string; + prRepository: string; + prNumber: number; + prHref: string; +} + +export const gitLinkedIssueParser = async ({ owner, repo, pull_number }: GitParser) => { + const logger = getLogger(); try { - const { data } = await axios.get(`https://github.com/${owner}/${repo}/issues/${issue_number}`); + const { data } = await axios.get(`https://github.com/${owner}/${repo}/pull/${pull_number}`); const dom = parse(data); const devForm = dom.querySelector("[data-target='create-branch.developmentForm']") as HTMLElement; - const linkedPRs = devForm.querySelectorAll(".my-1"); - if (linkedPRs.length > 0) { - //has LinkedPRs - return true; - } else { - //no LinkedPRs - return false; + const linkedIssues = devForm.querySelectorAll(".my-1"); + + if (linkedIssues.length === 0) { + return null; } + + const issueUrl = linkedIssues[0].querySelector("a")?.attrs?.href || ""; + return issueUrl; } catch (error) { - return true; + logger.error(`${JSON.stringify(error)}`); + return null; } }; -export const gitLinkedIssueParser = async ({ owner, repo, issue_number }: GitParser): Promise => { +export const gitLinkedPrParser = async ({ owner, repo, issue_number }: GitParser): Promise => { + const logger = getLogger(); try { - const { data } = await axios.get(`https://github.com/${owner}/${repo}/pull/${issue_number}`); + const prData = []; + const { data } = await axios.get(`https://github.com/${owner}/${repo}/issues/${issue_number}`); const dom = parse(data); const devForm = dom.querySelector("[data-target='create-branch.developmentForm']") as HTMLElement; const linkedPRs = devForm.querySelectorAll(".my-1"); + if (linkedPRs.length === 0) return []; + + for (const linkedPr of linkedPRs) { + const prUrl = linkedPr.querySelector("a")?.attrs?.href; + + if (!prUrl) continue; + + const parts = prUrl.split("/"); - if (linkedPRs.length === 0) { - return ""; + // check if array size is at least 4 + if (parts.length < 4) continue; + + // extract the organization name and repo name from the link:(e.g. " + const prOrganization = parts[parts.length - 4]; + const prRepository = parts[parts.length - 3]; + const prNumber = Number(parts[parts.length - 1]); + const prHref = `https://github.com${prUrl}`; + + if (`${prOrganization}/${prRepository}` !== `${owner}/${repo}`) continue; + + prData.push({ prOrganization, prRepository, prNumber, prHref }); } - const prUrl = linkedPRs[0].querySelector("a")?.attrs?.href || ""; - return prUrl; + return prData; } catch (error) { - return ""; + logger.error(`${JSON.stringify(error)}`); + return []; } }; + +export const getLatestPullRequest = async (prs: LinkedPR[]) => { + const context = getBotContext(); + let linkedPullRequest = null; + for (const _pr of prs) { + if (Number.isNaN(_pr.prNumber)) return null; + const pr = await getPullByNumber(context, _pr.prNumber); + if (!pr || !pr.merged) continue; + + if (!linkedPullRequest) linkedPullRequest = pr; + else if (linkedPullRequest.merged_at && pr.merged_at && new Date(linkedPullRequest.merged_at) < new Date(pr.merged_at)) { + linkedPullRequest = pr; + } + } + + return linkedPullRequest; +}; diff --git a/src/helpers/permit.ts b/src/helpers/permit.ts index ba1bcaaa0..3e6f21832 100644 --- a/src/helpers/permit.ts +++ b/src/helpers/permit.ts @@ -52,7 +52,13 @@ type TxData = { * * @returns Permit2 url including base64 encocded data */ -export const generatePermit2Signature = async (spender: string, amountInEth: Decimal, identifier: string): Promise<{ txData: TxData; payoutUrl: string }> => { +export const generatePermit2Signature = async ( + spender: string, + amountInEth: Decimal, + identifier: string, + userId = "", + type = "" +): Promise<{ txData: TxData; payoutUrl: string }> => { const { payout: { networkId, privateKey, permitBaseUrl, rpc, paymentToken }, } = getBotConfig(); @@ -69,7 +75,7 @@ export const generatePermit2Signature = async (spender: string, amountInEth: Dec }, // who can transfer the tokens spender: spender, - nonce: BigNumber.from(keccak256(toUtf8Bytes(identifier))), + nonce: BigNumber.from(keccak256(toUtf8Bytes(identifier + userId + type))), // signature deadline deadline: MaxUint256, }; diff --git a/src/helpers/shared.ts b/src/helpers/shared.ts index 646e0d8e9..86e1599eb 100644 --- a/src/helpers/shared.ts +++ b/src/helpers/shared.ts @@ -1,3 +1,4 @@ +import ms from "ms"; import { getBotContext } from "../bindings"; import { LabelItem, Payload, UserType } from "../types"; @@ -25,6 +26,7 @@ export const calculateWeight = (label: LabelItem | undefined): number => { const matches = label.name.match(/\d+/); const number = matches && matches.length > 0 ? parseInt(matches[0]) || 0 : 0; if (label.name.toLowerCase().includes("priority")) return number; + if (label.name.toLowerCase().includes("minute")) return number * 0.002; if (label.name.toLowerCase().includes("hour")) return number * 0.125; if (label.name.toLowerCase().includes("day")) return 1 + (number - 1) * 0.25; if (label.name.toLowerCase().includes("week")) return number + 1; @@ -34,12 +36,11 @@ export const calculateWeight = (label: LabelItem | undefined): number => { export const calculateDuration = (label: LabelItem): number => { if (!label) return 0; - const matches = label.name.match(/\d+/); if (label.name.toLowerCase().includes("priority")) return 0; - const number = matches && matches.length > 0 ? parseInt(matches[0]) || 0 : 0; - if (label.name.toLowerCase().includes("hour")) return number * 3600; - if (label.name.toLowerCase().includes("day")) return number * 86400; - if (label.name.toLowerCase().includes("week")) return number * 604800; - if (label.name.toLowerCase().includes("month")) return number * 2592000; - return 0; + + const pattern = /<(\d+\s\w+)/; + const result = label.name.match(pattern); + if (!result) return 0; + + return ms(result[1]) / 1000; }; diff --git a/src/types/config.ts b/src/types/config.ts index cda063811..b4dedf3d0 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,28 +1,46 @@ import { Static, Type } from "@sinclair/typebox"; -import { Level } from "../adapters/supabase"; - -const LabelItemSchema = Type.Object({ - name: Type.String(), -}); +import { LogLevel } from "./log"; + +const LabelItemSchema = Type.Object( + { + name: Type.String(), + }, + { + additionalProperties: false, + } +); export type LabelItem = Static; -const CommentIncentivesSchema = Type.Object({ - elements: Type.Record(Type.String(), Type.Number()), - totals: Type.Object({ - word: Type.Number(), - }), -}); +const CommentIncentivesSchema = Type.Object( + { + elements: Type.Record(Type.String(), Type.Number()), + totals: Type.Object( + { + word: Type.Number(), + }, + { additionalProperties: false } + ), + }, + { additionalProperties: false } +); export type CommentIncentives = Static; -const IncentivesSchema = Type.Object({ - comment: CommentIncentivesSchema, -}); +const IncentivesSchema = Type.Object( + { + comment: CommentIncentivesSchema, + }, + { additionalProperties: false } +); + export type Incentives = Static; -const CommandItemSchema = Type.Object({ - name: Type.String(), - enabled: Type.Boolean(), -}); +const CommandItemSchema = Type.Object( + { + name: Type.String(), + enabled: Type.Boolean(), + }, + { additionalProperties: false } +); export type CommandItem = Static; export const PriceConfigSchema = Type.Object({ @@ -69,11 +87,12 @@ export const ModeSchema = Type.Object({ export const AssignSchema = Type.Object({ bountyHunterMax: Type.Number(), + staleBountyTime: Type.Number(), }); export const LogConfigSchema = Type.Object({ logEnvironment: Type.String(), - level: Type.Enum(Level), + level: Type.Enum(LogLevel), retryLimit: Type.Number(), }); @@ -93,6 +112,13 @@ export const WalletSchema = Type.Object({ registerWalletWithVerification: Type.Boolean(), }); +export const AccessControlSchema = Type.Object({ + label: Type.Boolean(), + organization: Type.Boolean(), +}); + +export type AccessControl = Static; + export const BotConfigSchema = Type.Object({ log: LogConfigSchema, price: PriceConfigSchema, @@ -106,6 +132,60 @@ export const BotConfigSchema = Type.Object({ comments: CommentsSchema, command: CommandConfigSchema, wallet: WalletSchema, + accessControl: AccessControlSchema, }); export type BotConfig = Static; + +export const WideConfigSchema = Type.Object( + { + "evm-network-id": Type.Optional(Type.Number()), + "price-multiplier": Type.Optional(Type.Number()), + "issue-creator-multiplier": Type.Optional(Type.Number()), + "time-labels": Type.Optional(Type.Array(LabelItemSchema)), + "priority-labels": Type.Optional(Type.Array(LabelItemSchema)), + "payment-permit-max-price": Type.Optional(Type.Number()), + "command-settings": Type.Optional(Type.Array(CommandItemSchema)), + "promotion-comment": Type.Optional(Type.String()), + "disable-analytics": Type.Optional(Type.Boolean()), + "comment-incentives": Type.Optional(Type.Boolean()), + "assistive-pricing": Type.Optional(Type.Boolean()), + "max-concurrent-assigns": Type.Optional(Type.Number()), + incentives: Type.Optional(IncentivesSchema), + "default-labels": Type.Optional(Type.Array(Type.String())), + "register-wallet-with-verification": Type.Optional(Type.Boolean()), + "enable-access-control": Type.Optional(AccessControlSchema), + "stale-bounty-time": Type.Optional(Type.String()), + "private-key-encrypted": Type.Optional(Type.String()), + }, + { + additionalProperties: false, + } +); + +export type WideConfig = Static; + +export type WideRepoConfig = WideConfig; + +export const MergedConfigSchema = Type.Object({ + "evm-network-id": Type.Number(), + "price-multiplier": Type.Number(), + "private-key-encrypted": Type.Optional(Type.String()), + "issue-creator-multiplier": Type.Number(), + "time-labels": Type.Array(LabelItemSchema), + "priority-labels": Type.Array(LabelItemSchema), + "payment-permit-max-price": Type.Number(), + "command-settings": Type.Array(CommandItemSchema), + "promotion-comment": Type.String(), + "disable-analytics": Type.Boolean(), + "comment-incentives": Type.Boolean(), + "assistive-pricing": Type.Boolean(), + "max-concurrent-assigns": Type.Number(), + incentives: IncentivesSchema, + "default-labels": Type.Array(Type.String()), + "register-wallet-with-verification": Type.Boolean(), + "enable-access-control": AccessControlSchema, + "stale-bounty-time": Type.String(), +}); + +export type MergedConfig = Static; diff --git a/src/types/index.ts b/src/types/index.ts index d797feabf..83eac7e89 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from "./label"; export * from "./handlers"; export * from "./config"; export * from "./markdown"; +export * from "./log"; diff --git a/src/types/log.ts b/src/types/log.ts new file mode 100644 index 000000000..8e3e1b913 --- /dev/null +++ b/src/types/log.ts @@ -0,0 +1,9 @@ +export enum LogLevel { + ERROR = "error", + WARN = "warn", + INFO = "info", + HTTP = "http", + VERBOSE = "verbose", + DEBUG = "debug", + SILLY = "silly", +} diff --git a/src/utils/ajv.ts b/src/utils/ajv.ts index 3a5d59d1b..ce632101e 100644 --- a/src/utils/ajv.ts +++ b/src/utils/ajv.ts @@ -1,4 +1,4 @@ -import Ajv from "ajv"; +import Ajv, { Schema } from "ajv"; import addFormats from "ajv-formats"; export const ajv = addFormats(new Ajv(), { @@ -27,3 +27,21 @@ export const ajv = addFormats(new Ajv(), { "binary", ], }); + +export function getAdditionalProperties() { + return ajv.errors?.filter((error) => error.keyword === "additionalProperties").map((error) => error.params.additionalProperty); +} + +export function validate(scheme: string | Schema, data: unknown): { valid: true; error: undefined } | { valid: false; error: string } { + const valid = ajv.validate(scheme, data); + if (!valid) { + const additionalProperties = getAdditionalProperties(); + return { + valid: false, + error: `${ajv.errorsText()}. ${ + additionalProperties && additionalProperties.length > 0 ? `Unnecessary properties: ${additionalProperties.join(", ")}` : "" + }`, + }; + } + return { valid: true, error: undefined }; +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index e3965208b..c7bad1b2c 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,185 +1,3 @@ -import { Incentives } from "./private"; -import { Level } from "../adapters/supabase"; -import { CommandObj, WideLabel, WideOrgConfig, WideRepoConfig } from "./private"; - -interface Configs { - parsedRepo?: WideRepoConfig; - parsedOrg?: WideOrgConfig; - parsedDefault: WideRepoConfig; -} - -export const getNumericLevel = (level: Level) => { - switch (level) { - case Level.ERROR: - return 0; - case Level.WARN: - return 1; - case Level.INFO: - return 2; - case Level.HTTP: - return 3; - case Level.VERBOSE: - return 4; - case Level.DEBUG: - return 5; - case Level.SILLY: - return 6; - default: - return -1; // Invalid level - } -}; - -export const getNetworkId = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): number => { - if (parsedRepo && parsedRepo["evm-network-id"] !== undefined && !Number.isNaN(Number(parsedRepo["evm-network-id"]))) { - return Number(parsedRepo["evm-network-id"]); - } else if (parsedOrg && parsedOrg["evm-network-id"] !== undefined && !Number.isNaN(Number(parsedOrg["evm-network-id"]))) { - return Number(parsedOrg["evm-network-id"]); - } else { - return Number(parsedDefault["evm-network-id"]); - } -}; - -export const getBaseMultiplier = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): number => { - if (parsedRepo && parsedRepo["price-multiplier"] !== undefined && !Number.isNaN(Number(parsedRepo["price-multiplier"]))) { - return Number(parsedRepo["price-multiplier"]); - } else if (parsedOrg && parsedOrg["price-multiplier"] !== undefined && !Number.isNaN(Number(parsedOrg["price-multiplier"]))) { - return Number(parsedOrg["price-multiplier"]); - } else { - return Number(parsedDefault["price-multiplier"]); - } -}; - -export const getCreatorMultiplier = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): number => { - if (parsedRepo && parsedRepo["issue-creator-multiplier"] !== undefined && !Number.isNaN(Number(parsedRepo["issue-creator-multiplier"]))) { - return Number(parsedRepo["issue-creator-multiplier"]); - } else if (parsedOrg && parsedOrg["issue-creator-multiplier"] !== undefined && !Number.isNaN(Number(parsedOrg["issue-creator-multiplier"]))) { - return Number(parsedOrg["issue-creator-multiplier"]); - } else { - return Number(parsedDefault["issue-creator-multiplier"]); - } -}; - -export const getTimeLabels = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): WideLabel[] => { - if (parsedRepo && parsedRepo["time-labels"] !== undefined && Array.isArray(parsedRepo["time-labels"]) && parsedRepo["time-labels"].length > 0) { - return parsedRepo["time-labels"]; - } else if (parsedOrg && parsedOrg["time-labels"] !== undefined && Array.isArray(parsedOrg["time-labels"]) && parsedOrg["time-labels"].length > 0) { - return parsedOrg["time-labels"]; - } else { - return parsedDefault["time-labels"] as WideLabel[]; - } -}; - -export const getCommandSettings = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): CommandObj[] => { - if (parsedRepo && parsedRepo["command-settings"] && Array.isArray(parsedRepo["command-settings"]) && parsedRepo["command-settings"].length > 0) { - return parsedRepo["command-settings"]; - } else if (parsedOrg && parsedOrg["command-settings"] && Array.isArray(parsedOrg["command-settings"]) && parsedOrg["command-settings"].length > 0) { - return parsedOrg["command-settings"]; - } else { - return parsedDefault["command-settings"] as CommandObj[]; - } -}; - -export const getPriorityLabels = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): WideLabel[] => { - if (parsedRepo && parsedRepo["priority-labels"] !== undefined && Array.isArray(parsedRepo["priority-labels"]) && parsedRepo["priority-labels"].length > 0) { - return parsedRepo["priority-labels"]; - } else if ( - parsedOrg && - parsedOrg["priority-labels"] !== undefined && - Array.isArray(parsedOrg["priority-labels"]) && - parsedOrg["priority-labels"].length > 0 - ) { - return parsedOrg["priority-labels"]; - } else { - return parsedDefault["priority-labels"] as WideLabel[]; - } -}; - -export const getIncentives = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): Incentives => { - if (parsedRepo && parsedRepo["incentives"]) { - return parsedRepo["incentives"]; - } else if (parsedOrg && parsedOrg["incentives"]) { - return parsedOrg["incentives"]; - } else { - return parsedDefault["incentives"] as Incentives; - } -}; - -export const getPaymentPermitMaxPrice = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): number => { - if (parsedRepo && parsedRepo["payment-permit-max-price"] && typeof parsedRepo["payment-permit-max-price"] === "number") { - return Number(parsedRepo["payment-permit-max-price"]); - } else if (parsedOrg && parsedOrg["payment-permit-max-price"] && typeof parsedOrg["payment-permit-max-price"] === "number") { - return Number(parsedOrg["payment-permit-max-price"]); - } else { - return Number(parsedDefault["payment-permit-max-price"]); - } -}; - -export const getAssistivePricing = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): boolean => { - if (parsedRepo && parsedRepo["assistive-pricing"] && typeof parsedRepo["assistive-pricing"] === "boolean") { - return parsedRepo["assistive-pricing"]; - } else if (parsedOrg && parsedOrg["assistive-pricing"] && typeof parsedOrg["assistive-pricing"] === "boolean") { - return parsedOrg["assistive-pricing"]; - } else { - return parsedDefault["assistive-pricing"] as boolean; - } -}; - -export const getAnalyticsMode = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): boolean => { - if (parsedRepo && parsedRepo["disable-analytics"] !== undefined && typeof parsedRepo["disable-analytics"] === "boolean") { - return parsedRepo["disable-analytics"]; - } else if (parsedOrg && parsedOrg["disable-analytics"] !== undefined && typeof parsedOrg["disable-analytics"] === "boolean") { - return parsedOrg["disable-analytics"]; - } else { - return parsedDefault["disable-analytics"] as boolean; - } -}; - -export const getPromotionComment = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): string => { - if (parsedRepo && parsedRepo["promotion-comment"] !== undefined && typeof parsedRepo["promotion-comment"] === "string") { - return parsedRepo["promotion-comment"]; - } else if (parsedOrg && parsedOrg["promotion-comment"] !== undefined && typeof parsedOrg["promotion-comment"] === "string") { - return parsedOrg["promotion-comment"]; - } else { - return parsedDefault["promotion-comment"] as string; - } -}; - -export const getIncentiveMode = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): boolean => { - if (parsedRepo && parsedRepo["comment-incentives"] !== undefined && typeof parsedRepo["comment-incentives"] === "boolean") { - return parsedRepo["comment-incentives"]; - } else if (parsedOrg && parsedOrg["comment-incentives"] !== undefined && typeof parsedOrg["comment-incentives"] === "boolean") { - return parsedOrg["comment-incentives"]; - } else { - return parsedDefault["comment-incentives"] as boolean; - } -}; - -export const getBountyHunterMax = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): number => { - if (parsedRepo && parsedRepo["max-concurrent-assigns"] !== undefined && !Number.isNaN(Number(parsedRepo["max-concurrent-assigns"]))) { - return Number(parsedRepo["max-concurrent-assigns"]); - } else if (parsedOrg && parsedOrg["max-concurrent-assigns"] !== undefined && !Number.isNaN(Number(parsedOrg["max-concurrent-assigns"]))) { - return Number(parsedOrg["max-concurrent-assigns"]); - } else { - return Number(parsedDefault["max-concurrent-assigns"]); - } -}; - -export const getDefaultLabels = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): string[] => { - if (parsedRepo && parsedRepo["default-labels"] !== undefined) { - return parsedRepo["default-labels"]; - } else if (parsedOrg && parsedOrg["default-labels"] !== undefined) { - return parsedOrg["default-labels"]; - } else { - return parsedDefault["default-labels"] as string[]; - } -}; - -export const getRegisterWalletWithVerification = ({ parsedRepo, parsedOrg, parsedDefault }: Configs): boolean => { - if (parsedRepo && parsedRepo["register-wallet-with-verification"] !== undefined && typeof parsedRepo["register-wallet-with-verification"] === "boolean") { - return Boolean(parsedRepo["register-wallet-with-verification"]); - } else if (parsedOrg && parsedOrg["register-wallet-with-verification"] !== undefined && typeof parsedOrg["register-wallet-with-verification"] === "boolean") { - return Boolean(parsedOrg["register-wallet-with-verification"]); - } else { - return Boolean(parsedDefault["register-wallet-with-verification"]); - } +export const ErrorDiff = (message: unknown) => { + return "```diff\n- " + message + "\n```"; }; diff --git a/src/utils/private.ts b/src/utils/private.ts index 36ff92f8c..4953a6137 100644 --- a/src/utils/private.ts +++ b/src/utils/private.ts @@ -1,26 +1,12 @@ import _sodium from "libsodium-wrappers"; import YAML from "yaml"; -import { Payload } from "../types"; +import { MergedConfig, Payload } from "../types"; import { Context } from "probot"; -import { - getAnalyticsMode, - getPaymentPermitMaxPrice, - getBaseMultiplier, - getCreatorMultiplier, - getBountyHunterMax, - getIncentiveMode, - getNetworkId, - getPriorityLabels, - getTimeLabels, - getDefaultLabels, - getPromotionComment, - getIncentives, - getAssistivePricing, - getCommandSettings, - getRegisterWalletWithVerification, -} from "./helpers"; - -import DEFAULT_CONFIG_JSON from "../../ubiquibot-config-default.json"; +import merge from "lodash/merge"; + +import { DefaultConfig } from "../configs"; +import { validate } from "./ajv"; +import { WideConfig, WideRepoConfig, WideConfigSchema } from "../types"; const CONFIG_REPO = "ubiquibot-config"; const CONFIG_PATH = ".github/ubiquibot-config.yml"; @@ -47,48 +33,10 @@ export const getConfigSuperset = async (context: Context, type: "org" | "repo", } }; -export interface WideLabel { - name: string; -} - -export interface CommentIncentives { - elements: Record; - totals: { - word: number; - }; -} - -export interface Incentives { - comment: CommentIncentives; -} - -export interface CommandObj { - name: string; - enabled: boolean; -} - -export interface WideConfig { - "evm-network-id"?: number; - "price-multiplier"?: number; - "issue-creator-multiplier": number; - "time-labels"?: WideLabel[]; - "priority-labels"?: WideLabel[]; - "payment-permit-max-price"?: number; - "command-settings"?: CommandObj[]; - "promotion-comment"?: string; - "disable-analytics"?: boolean; - "comment-incentives"?: boolean; - "assistive-pricing"?: boolean; - "max-concurrent-assigns"?: number; - incentives?: Incentives; - "default-labels"?: string[]; - "register-wallet-with-verification"?: boolean; -} - -export type WideRepoConfig = WideConfig; - -export interface WideOrgConfig extends WideConfig { - "private-key-encrypted"?: string; +export interface MergedConfigs { + parsedRepo: WideRepoConfig | undefined; + parsedOrg: WideRepoConfig | undefined; + parsedDefault: MergedConfig; } export const parseYAML = (data?: string): WideConfig | undefined => { @@ -155,33 +103,62 @@ export const getScalarKey = async (X25519_PRIVATE_KEY: string | undefined): Prom } }; +const mergeConfigs = (configs: MergedConfigs) => { + return merge({}, configs.parsedDefault, configs.parsedOrg, configs.parsedRepo); +}; + export const getWideConfig = async (context: Context) => { const orgConfig = await getConfigSuperset(context, "org", CONFIG_PATH); const repoConfig = await getConfigSuperset(context, "repo", CONFIG_PATH); - const parsedOrg: WideOrgConfig | undefined = parseYAML(orgConfig); + const parsedOrg: WideRepoConfig | undefined = parseYAML(orgConfig); + + if (parsedOrg) { + const { valid, error } = validate(WideConfigSchema, parsedOrg); + if (!valid) { + throw new Error(`Invalid org config: ${error}`); + } + } const parsedRepo: WideRepoConfig | undefined = parseYAML(repoConfig); - const parsedDefault: WideRepoConfig = DEFAULT_CONFIG_JSON; - const privateKeyDecrypted = parsedOrg && parsedOrg[KEY_NAME] ? await getPrivateKey(parsedOrg[KEY_NAME]) : undefined; + if (parsedRepo) { + const { valid, error } = validate(WideConfigSchema, parsedRepo); + if (!valid) { + throw new Error(`Invalid repo config: ${error}`); + } + } + const parsedDefault: MergedConfig = DefaultConfig; + + let privateKeyDecrypted; + if (parsedRepo && parsedRepo[KEY_NAME]) { + privateKeyDecrypted = await getPrivateKey(parsedRepo[KEY_NAME]); + } else if (parsedOrg && parsedOrg[KEY_NAME]) { + privateKeyDecrypted = await getPrivateKey(parsedOrg[KEY_NAME]); + } else { + privateKeyDecrypted = undefined; + } + + const configs: MergedConfigs = { parsedDefault, parsedOrg, parsedRepo }; + const mergedConfigData: MergedConfig = mergeConfigs(configs); - const configs = { parsedRepo, parsedOrg, parsedDefault }; const configData = { - networkId: getNetworkId(configs), + networkId: mergedConfigData["evm-network-id"], privateKey: privateKeyDecrypted ?? "", - assistivePricing: getAssistivePricing(configs), - commandSettings: getCommandSettings(configs), - baseMultiplier: getBaseMultiplier(configs), - issueCreatorMultiplier: getCreatorMultiplier(configs), - timeLabels: getTimeLabels(configs), - priorityLabels: getPriorityLabels(configs), - paymentPermitMaxPrice: getPaymentPermitMaxPrice(configs), - disableAnalytics: getAnalyticsMode(configs), - bountyHunterMax: getBountyHunterMax(configs), - incentiveMode: getIncentiveMode(configs), - incentives: getIncentives(configs), - defaultLabels: getDefaultLabels(configs), - promotionComment: getPromotionComment(configs), - registerWalletWithVerification: getRegisterWalletWithVerification(configs), + assistivePricing: mergedConfigData["assistive-pricing"], + commandSettings: mergedConfigData["command-settings"], + baseMultiplier: mergedConfigData["price-multiplier"], + issueCreatorMultiplier: mergedConfigData["issue-creator-multiplier"], + timeLabels: mergedConfigData["time-labels"], + priorityLabels: mergedConfigData["priority-labels"], + paymentPermitMaxPrice: mergedConfigData["payment-permit-max-price"], + disableAnalytics: mergedConfigData["disable-analytics"], + bountyHunterMax: mergedConfigData["max-concurrent-assigns"], + incentiveMode: mergedConfigData["comment-incentives"], + incentives: mergedConfigData["incentives"], + defaultLabels: mergedConfigData["default-labels"], + promotionComment: mergedConfigData["promotion-comment"], + registerWalletWithVerification: mergedConfigData["register-wallet-with-verification"], + enableAccessControl: mergedConfigData["enable-access-control"], + staleBountyTime: mergedConfigData["stale-bounty-time"], }; return configData; diff --git a/supabase/migrations/20230829101134_setup_cron.sql b/supabase/migrations/20230829101134_setup_cron.sql new file mode 100644 index 000000000..883436a87 --- /dev/null +++ b/supabase/migrations/20230829101134_setup_cron.sql @@ -0,0 +1,11 @@ +-- Enable the pg_cron extension +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Runs everyday at 03:00 AM to cleanup logs that are older than a week +-- Use the cron time format to modify the trigger time if necessary +SELECT + cron.schedule( + 'logs-cleaner', -- Job name + '0 3 * * *', -- Everyday at 03:00 AM + $$DELETE FROM logs WHERE timestamp < now() - INTERVAL '1 week'$$ + ); diff --git a/ubiquibot-config-default.json b/ubiquibot-config-default.json deleted file mode 100644 index 2cf139e15..000000000 --- a/ubiquibot-config-default.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "evm-network-id": 100, - "price-multiplier": 1, - "issue-creator-multiplier": 2, - "payment-permit-max-price": 9007199254740991, - "max-concurrent-assigns": 9007199254740991, - "assistive-pricing": false, - "disable-analytics": false, - "comment-incentives": false, - "register-wallet-with-verification": false, - "promotion-comment": "\n
If you enjoy the DevPool experience, please follow Ubiquity on GitHub and star this repo to show your support. It helps a lot!
", - "default-labels": [], - "time-labels": [ - { - "name": "Time: <1 Hour" - }, - { - "name": "Time: <1 Day" - }, - { - "name": "Time: <1 Week" - }, - { - "name": "Time: <2 Weeks" - }, - { - "name": "Time: <1 Month" - } - ], - "priority-labels": [ - { - "name": "Priority: 1 (Normal)" - }, - { - "name": "Priority: 2 (Medium)" - }, - { - "name": "Priority: 3 (High)" - }, - { - "name": "Priority: 4 (Urgent)" - }, - { - "name": "Priority: 5 (Emergency)" - } - ], - "command-settings": [ - { - "name": "start", - "enabled": false - }, - { - "name": "stop", - "enabled": false - }, - { - "name": "wallet", - "enabled": false - }, - { - "name": "payout", - "enabled": false - }, - { - "name": "multiplier", - "enabled": false - }, - { - "name": "query", - "enabled": false - }, - { - "name": "allow", - "enabled": false - }, - { - "name": "autopay", - "enabled": false - } - ], - "incentives": { - "comment": { - "elements": {}, - "totals": { - "word": 0 - } - } - } -} diff --git a/yarn.lock b/yarn.lock index 9beb769c4..33fd5221f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1928,10 +1928,10 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== -"@sinclair/typebox@^0.25.9": - version "0.25.24" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" - integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== +"@sinclair/typebox@^0.31.5": + version "0.31.5" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.31.5.tgz#10ae6c60fc523d7d695a730df1ac3dd9725ce207" + integrity sha512-4fbqH1ONle98ULTQakJFVNwGwSx+rv90HEnjZGt1GoApMKooUw1WXw3ub+Ew7rInmyDcwsjIxiHt39bkWzeCBA== "@sinonjs/commons@^1.7.0": version "1.8.6" @@ -2190,6 +2190,11 @@ resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz#a6ebde70d3b4af960fd802af8d0e3c7cfe281eb2" integrity sha512-BqI9B92u+cM3ccp8mpHf+HzJ8fBlRwdmyd6+fz3p99m3V6ifT5O3zmOMi612PGkpeFeG/G6loxUnzlDNhfjPSA== +"@types/lodash@^4.14.197": + version "4.14.197" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b" + integrity sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g== + "@types/mdast@^3.0.0": version "3.0.12" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.12.tgz#beeb511b977c875a5b0cc92eab6fcac2f0895514" @@ -2237,6 +2242,13 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/parse5@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-7.0.0.tgz#8b412a0a4461c84d6280a372bfa8c57a418a06bd" + integrity sha512-f2SeAxumolBmhuR62vNGTsSAvdz/Oj0k682xNrcKJ4dmRnTPODB74j6CPoNPzBPTHsu7Y7W7u93Mgp8Ovo8vWw== + dependencies: + parse5 "*" + "@types/phoenix@^1.5.4": version "1.6.0" resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.0.tgz#eb7536259ee695646e75c4c7b0c9a857ea174781" @@ -3839,7 +3851,7 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -entities@^4.2.0: +entities@^4.2.0, entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -7102,6 +7114,13 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse5@*: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + parse5@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"