diff --git a/.env.example b/.env.example index 819c46da0..b6102ad3d 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,9 @@ CHATGPT_USER_PROMPT_FOR_MEASURE_SIMILARITY='I have two github issues and I need SIMILARITY_THRESHOLD=80 MEASURE_SIMILARITY_AI_TEMPERATURE=0 IMPORTANT_WORDS_AI_TEMPERATURE=0 + +# Telegram Log Notification Envs +LOG_WEBHOOK_BOT_URL= # URL of cloudflare worker without trailing / +LOG_WEBHOOK_SECRET= # Random Secret, Shared between the telegram bot and the sender +LOG_WEBHOOK_GROUP_ID= # Group Id, ex: -100124234325 +LOG_WEBHOOK_TOPIC_ID= # Topic Id (Optional), Only provide if group is a topic and you're not using General \ No newline at end of file diff --git a/.github/ubiquibot-config.yml b/.github/ubiquibot-config.yml index 3658a5818..5b48b5f30 100644 --- a/.github/ubiquibot-config.yml +++ b/.github/ubiquibot-config.yml @@ -1,6 +1,6 @@ -priceMultiplier: 1.5 +price-multiplier: 1.5 # newContributorGreeting: # enabled: true # header: "Thank you for contributing to UbiquiBot! Please be sure to set your wallet address before completing your first bounty so that the automatic payout upon task completion will work for you." # helpMenu: true -# footer: "###### Also please star this repository and [@ubiquity/devpool-directory](https://github.com/ubiquity/devpool-directory/) to show your support. It helps a lot!" \ No newline at end of file +# footer: "###### Also please star this repository and [@ubiquity/devpool-directory](https://github.com/ubiquity/devpool-directory/) to show your support. It helps a lot!" diff --git a/package.json b/package.json index 2eb6a78d6..807d2253b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "husky": "^8.0.2", "jimp": "^0.22.4", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", "libsodium-wrappers": "^0.7.11", "lint-staged": "^13.1.0", "lodash": "^4.17.21", diff --git a/src/adapters/supabase/helpers/log.ts b/src/adapters/supabase/helpers/log.ts index 51a16f5b2..81bbf9d6b 100644 --- a/src/adapters/supabase/helpers/log.ts +++ b/src/adapters/supabase/helpers/log.ts @@ -1,7 +1,8 @@ +import axios from "axios"; import { getAdapters, getBotContext, Logger } from "../../../bindings"; -import { Payload, LogLevel } from "../../../types"; +import { Payload, LogLevel, LogNotification } from "../../../types"; import { getOrgAndRepoFromPath } from "../../../utils/private"; - +import jwt from "jsonwebtoken"; interface Log { repo: string | null; org: string | null; @@ -43,13 +44,15 @@ export class GitHubLogger implements Logger { private retryDelay = 1000; // Delay between retries in milliseconds private throttleCount = 0; private retryLimit = 0; // Retries disabled by default + private logNotification; - constructor(app: string, logEnvironment: string, maxLevel: LogLevel, retryLimit: number) { + constructor(app: string, logEnvironment: string, maxLevel: LogLevel, retryLimit: number, logNotification: LogNotification) { this.app = app; this.logEnvironment = logEnvironment; this.maxLevel = getNumericLevel(maxLevel); this.retryLimit = retryLimit; this.supabase = getAdapters().supabase; + this.logNotification = logNotification; } async sendLogsToSupabase({ repo, org, commentId, issueNumber, logMessage, level, timestamp }: Log) { @@ -80,6 +83,66 @@ export class GitHubLogger implements Logger { } } + private sendDataWithJwt(message: string | object, errorPayload?: string | object) { + const context = getBotContext(); + const payload = context.payload as Payload; + + const { comment, issue, repository } = payload; + const commentId = comment?.id; + const issueNumber = issue?.number; + const repoFullName = repository?.full_name; + + const { org, repo } = getOrgAndRepoFromPath(repoFullName); + + const issueLink = `https://github.com/${org}/${repo}/issues/${issueNumber}${commentId ? `#issuecomment-${commentId}` : ""}`; + + return new Promise((resolve, reject) => { + try { + if (!this.logNotification?.enabled) { + reject("Telegram Log Notification is disabled, please check that url, secret and group is provided"); + } + + if (typeof message === "object") { + message = JSON.stringify(message); + } + + if (errorPayload && typeof errorPayload === "object") { + errorPayload = JSON.stringify(errorPayload); + } + + const errorMessage = `\`${message}${errorPayload ? " - " + errorPayload : ""}\`\n\nContext: ${issueLink}`; + + // Step 1: Sign a JWT with the provided parameter + const jwtToken = jwt.sign( + { + group: this.logNotification.groupId, + topic: this.logNotification.topicId, + msg: errorMessage, + }, + this.logNotification.secret, + { noTimestamp: true } + ); + + const apiUrl = `${this.logNotification.url}/sendLogs`; + const headers = { + Authorization: `${jwtToken}`, + }; + + axios + .get(apiUrl, { headers }) + .then((response) => { + resolve(response.data); + }) + .catch((error) => { + reject(error); + }); + } catch (error) { + // Reject the promise with the error + reject(error); + } + }); + } + async retryLog(log: Log, retryCount = 0) { if (retryCount >= this.retryLimit) { console.error("Max retry limit reached for log:", log); @@ -169,6 +232,13 @@ export class GitHubLogger implements Logger { warn(message: string | object, errorPayload?: string | object) { this.save(message, LogLevel.WARN, errorPayload); + this.sendDataWithJwt(message, errorPayload) + .then((response) => { + this.save(`Log Notification Success: ${response}`, LogLevel.DEBUG, ""); + }) + .catch((error) => { + this.save(`Log Notification Error: ${error}`, LogLevel.DEBUG, ""); + }); } debug(message: string | object, errorPayload?: string | object) { @@ -177,6 +247,13 @@ export class GitHubLogger implements Logger { error(message: string | object, errorPayload?: string | object) { this.save(message, LogLevel.ERROR, errorPayload); + this.sendDataWithJwt(message, errorPayload) + .then((response) => { + this.save(`Log Notification Success: ${response}`, LogLevel.DEBUG, ""); + }) + .catch((error) => { + this.save(`Log Notification Error: ${error}`, LogLevel.DEBUG, ""); + }); } async get() { diff --git a/src/bindings/config.ts b/src/bindings/config.ts index 49a3e4fc3..43b5718fa 100644 --- a/src/bindings/config.ts +++ b/src/bindings/config.ts @@ -1,14 +1,6 @@ import ms from "ms"; import { BotConfig, BotConfigSchema, LogLevel } from "../types"; -import { - DEFAULT_BOT_DELAY, - DEFAULT_DISQUALIFY_TIME, - DEFAULT_FOLLOWUP_TIME, - DEFAULT_PERMIT_BASE_URL, - DEFAULT_TIME_RANGE_FOR_MAX_ISSUE, - DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED, -} from "../configs"; import { getPayoutConfigByNetworkId } from "../helpers"; import { ajv } from "../utils"; import { Context } from "probot"; @@ -37,6 +29,12 @@ export const loadConfig = async (context: Context): Promise => { openAIKey, openAITokenLimit, newContributorGreeting, + timeRangeForMaxIssueEnabled, + timeRangeForMaxIssue, + permitBaseUrl, + botDelay, + followUpTime, + disqualifyTime, } = await getWideConfig(context); const publicKey = await getScalarKey(process.env.X25519_PRIVATE_KEY); @@ -64,17 +62,15 @@ export const loadConfig = async (context: Context): Promise => { rpc: rpc, privateKey: privateKey, paymentToken: paymentToken, - permitBaseUrl: process.env.PERMIT_BASE_URL || DEFAULT_PERMIT_BASE_URL, + permitBaseUrl: process.env.PERMIT_BASE_URL || permitBaseUrl, }, unassign: { - timeRangeForMaxIssue: process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE - ? Number(process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE) - : DEFAULT_TIME_RANGE_FOR_MAX_ISSUE, + timeRangeForMaxIssue: process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE ? Number(process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE) : timeRangeForMaxIssue, timeRangeForMaxIssueEnabled: process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED ? process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED == "true" - : DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED, - followUpTime: ms(process.env.FOLLOW_UP_TIME || DEFAULT_FOLLOWUP_TIME), - disqualifyTime: ms(process.env.DISQUALIFY_TIME || DEFAULT_DISQUALIFY_TIME), + : timeRangeForMaxIssueEnabled, + followUpTime: ms(process.env.FOLLOW_UP_TIME || followUpTime), + disqualifyTime: ms(process.env.DISQUALIFY_TIME || disqualifyTime), }, supabase: { url: process.env.SUPABASE_URL ?? "", @@ -82,7 +78,14 @@ export const loadConfig = async (context: Context): Promise => { }, telegram: { token: process.env.TELEGRAM_BOT_TOKEN ?? "", - delay: process.env.TELEGRAM_BOT_DELAY ? Number(process.env.TELEGRAM_BOT_DELAY) : DEFAULT_BOT_DELAY, + delay: process.env.TELEGRAM_BOT_DELAY ? Number(process.env.TELEGRAM_BOT_DELAY) : botDelay, + }, + logNotification: { + url: process.env.LOG_WEBHOOK_BOT_URL || "", + secret: process.env.LOG_WEBHOOK_SECRET || "", + groupId: Number(process.env.LOG_WEBHOOK_GROUP_ID) || 0, + topicId: Number(process.env.LOG_WEBHOOK_TOPIC_ID) || 0, + enabled: true, }, mode: { paymentPermitMaxPrice: paymentPermitMaxPrice, @@ -114,6 +117,10 @@ export const loadConfig = async (context: Context): Promise => { botConfig.mode.paymentPermitMaxPrice = 0; } + if (botConfig.logNotification.secret == "" || botConfig.logNotification.groupId == 0 || botConfig.logNotification.url == "") { + botConfig.logNotification.enabled = false; + } + const validate = ajv.compile(BotConfigSchema); const valid = validate(botConfig); if (!valid) { diff --git a/src/bindings/event.ts b/src/bindings/event.ts index f36641565..e3895541a 100644 --- a/src/bindings/event.ts +++ b/src/bindings/event.ts @@ -55,7 +55,8 @@ export const bindEvents = async (context: Context): Promise => { options.app, botConfig?.log?.logEnvironment ?? "development", botConfig?.log?.level ?? LogLevel.DEBUG, - botConfig?.log?.retryLimit ?? 0 + botConfig?.log?.retryLimit ?? 0, + botConfig.logNotification ); // contributors will see logs in console while on development env if (!logger) { return; diff --git a/src/configs/index.ts b/src/configs/index.ts index a449ff0d2..6d4bef9de 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -1,4 +1,3 @@ -export * from "./shared"; export * from "./strings"; export * from "./abis"; export * from "./ubiquibot-config-default"; diff --git a/src/configs/shared.ts b/src/configs/shared.ts deleted file mode 100644 index 297fdff33..000000000 --- a/src/configs/shared.ts +++ /dev/null @@ -1,31 +0,0 @@ -// cspell:disable -export const COLORS = { - default: "ededed", - price: "1f883d", -}; -// cspell:enable -export const DEFAULT_BOT_DELAY = 100; // 100ms -export const DEFAULT_TIME_RANGE_FOR_MAX_ISSUE = 24; -export const DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED = true; - -export const ASSIGN_COMMAND_ENABLED = true; -/** - * ms('2 days') // 172800000 - * ms('1d') // 86400000 - * ms('10h') // 36000000 - * ms('2.5 hrs') // 9000000 - * ms('2h') // 7200000 - * ms('1m') // 60000 - * ms('5s') // 5000 - * ms('1y') // 31557600000 - * ms('100') // 100 - * ms('-3 days') // -259200000 - * ms('-1h') // -3600000 - * ms('-200') // -200 - */ -export const DEFAULT_FOLLOWUP_TIME = "4 days"; // 4 days -export const DEFAULT_DISQUALIFY_TIME = "7 days"; // 7 days - -export const DEFAULT_NETWORK_ID = 1; // ethereum -export const DEFAULT_RPC_ENDPOINT = "https://rpc-bot.ubq.fi/v1/mainnet"; -export const DEFAULT_PERMIT_BASE_URL = "https://pay.ubq.fi"; diff --git a/src/configs/ubiquibot-config-default.ts b/src/configs/ubiquibot-config-default.ts index 5a5e29e5d..5fce38d64 100644 --- a/src/configs/ubiquibot-config-default.ts +++ b/src/configs/ubiquibot-config-default.ts @@ -122,6 +122,12 @@ export const DefaultConfig: MergedConfig = { organization: true, }, staleBountyTime: "0d", + timeRangeForMaxIssue: 24, //24 + timeRangeForMaxIssueEnabled: false, + permitBaseUrl: "https://pay.ubq.fi", + botDelay: 100, // 100ms + followUpTime: "4 days", + disqualifyTime: "7 days", newContributorGreeting: { enabled: true, header: diff --git a/src/handlers/comment/handlers/assign.ts b/src/handlers/comment/handlers/assign.ts index 6a8fabfe4..d4ab030ef 100644 --- a/src/handlers/comment/handlers/assign.ts +++ b/src/handlers/comment/handlers/assign.ts @@ -5,7 +5,7 @@ import { deadLinePrefix } from "../../shared"; import { getWalletAddress, getWalletMultiplier } from "../../../adapters/supabase"; import { tableComment } from "./table"; import { bountyInfo } from "../../wildcard"; -import { ASSIGN_COMMAND_ENABLED, GLOBAL_STRINGS } from "../../../configs"; +import { GLOBAL_STRINGS } from "../../../configs"; import { isParentIssue } from "../../pricing"; export const assign = async (body: string) => { @@ -19,6 +19,7 @@ export const assign = async (body: string) => { const id = organization?.id || repository?.id; // repository?.id as fallback const staleBounty = config.assign.staleBountyTime; + const startEnabled = config.command.find((command) => command.name === "start"); logger.info(`Received '/start' command from user: ${payload.sender.login}, body: ${body}`); const issue = (_payload as Payload).issue; @@ -28,7 +29,7 @@ export const assign = async (body: string) => { return "Skipping '/start' because of no issue instance"; } - if (!ASSIGN_COMMAND_ENABLED) { + if (!startEnabled?.enabled) { logger.info(`Ignore '/start' command from user: ASSIGN_COMMAND_ENABLED config is set false`); return GLOBAL_STRINGS.assignCommandDisabledComment; } diff --git a/src/handlers/comment/handlers/help.ts b/src/handlers/comment/handlers/help.ts index 7e2c11bd4..c2f545049 100644 --- a/src/handlers/comment/handlers/help.ts +++ b/src/handlers/comment/handlers/help.ts @@ -1,6 +1,5 @@ import { userCommands } from "."; -import { getBotContext, getLogger } from "../../../bindings"; -import { ASSIGN_COMMAND_ENABLED } from "../../../configs"; +import { getBotConfig, getBotContext, getLogger } from "../../../bindings"; import { IssueType, Payload } from "../../../types"; import { IssueCommentCommands } from "../commands"; @@ -28,13 +27,15 @@ export const listAvailableCommands = async (body: string) => { }; export const generateHelpMenu = () => { + const config = getBotConfig(); + const startEnabled = config.command.find((command) => command.name === "start"); let helpMenu = "### Available commands\n```"; const commands = userCommands(); commands.map((command) => { // if first command, add a new line if (command.id === commands[0].id) { helpMenu += `\n`; - if (!ASSIGN_COMMAND_ENABLED) return; + if (!startEnabled) return; } helpMenu += `- ${command.id}: ${command.description}`; // if not last command, add a new line (fixes too much space below) @@ -43,6 +44,6 @@ export const generateHelpMenu = () => { } }); - if (!ASSIGN_COMMAND_ENABLED) helpMenu += "```\n***_To assign yourself to an issue, please open a draft pull request that is linked to it._***"; + if (!startEnabled) helpMenu += "```\n***_To assign yourself to an issue, please open a draft pull request that is linked to it._***"; return helpMenu; }; diff --git a/src/handlers/comment/handlers/index.ts b/src/handlers/comment/handlers/index.ts index 4182b20ec..43d62946a 100644 --- a/src/handlers/comment/handlers/index.ts +++ b/src/handlers/comment/handlers/index.ts @@ -52,14 +52,15 @@ export * from "./authorize"; export interface RewardsResponse { error: string | null; title?: string; - userId?: string; + userId?: number; username?: string; reward?: { account: string; priceInEth: Decimal; penaltyAmount: BigNumber; user: string; - userId: string; + userId: number; + debug: Record; }[]; fallbackReward?: Record; } diff --git a/src/handlers/payout/action.ts b/src/handlers/payout/action.ts index e0c8aa1be..5b47f87b9 100644 --- a/src/handlers/payout/action.ts +++ b/src/handlers/payout/action.ts @@ -9,21 +9,21 @@ import { generatePermit2Signature, getAllIssueComments, getTokenSymbol, - savePermitToDB, wasIssueReopened, getAllIssueAssignEvents, addCommentToIssue, + createDetailsTable, + savePermitToDB, } from "../../helpers"; import { UserType, Payload, StateReason, Comment, User, Incentives, Issue } from "../../types"; -import { shortenEthAddress } from "../../utils"; import { bountyInfo } from "../wildcard"; import Decimal from "decimal.js"; import { GLOBAL_STRINGS } from "../../configs"; import { isParentIssue } from "../pricing"; import { RewardsResponse } from "../comment"; -import { isEmpty } from "lodash"; export interface IncentivesCalculationResult { + id: number; paymentToken: string; rpc: string; networkId: number; @@ -51,10 +51,12 @@ export interface IncentivesCalculationResult { export interface RewardByUser { account: string; priceInEth: Decimal; - userId: string | undefined; + userId: number | undefined; issueId: string; - type: string | undefined; + type: (string | undefined)[]; user: string | undefined; + priceArray: string[]; + debug: Record; } /** @@ -231,9 +233,17 @@ export const incentivesCalculation = async (): Promise content.body.includes("https://pay.ubq.fi?claim=") && content.user.type == UserType.Bot); + + if (permitComments.length > 0) { + logger.info(`skip to generate a permit url because it has been already posted`); + throw new Error(`skip to generate a permit url because it has been already posted`); + } + const tokenSymbol = await getTokenSymbol(paymentToken, rpc); return { + id, paymentToken, rpc, networkId, @@ -304,8 +314,9 @@ export const calculateIssueAssigneeReward = async (incentivesCalculation: Incent const account = await getWalletAddress(assigneeLogin); return { + title: "Issue-Assignee", error: "", - userId: incentivesCalculation.assignee.node_id, + userId: incentivesCalculation.assignee.id, username: assigneeLogin, reward: [ { @@ -313,7 +324,8 @@ export const calculateIssueAssigneeReward = async (incentivesCalculation: Incent penaltyAmount, account: account || "0x", user: "", - userId: "", + userId: incentivesCalculation.assignee.id, + debug: {}, }, ], }; @@ -330,23 +342,17 @@ export const handleIssueClosed = async ( const { comments } = getBotConfig(); const issueNumber = incentivesCalculation.issue.number; - let commentersComment = "", - title = "Task Assignee", - assigneeComment = "", - creatorComment = "", - mergedComment = "", - pullRequestReviewerComment = ""; - // The mapping between gh handle and comment with a permit url - const commentersReward: Record = {}; - const prReviewersReward: Record = {}; + let permitComment = ""; + const title = ["Issue-Assignee"]; // Rewards by user const rewardByUser: RewardByUser[] = []; // ASSIGNEE REWARD PRICE PROCESSOR - let priceInEth = new Decimal(incentivesCalculation.issueDetailed.priceLabel.substring(7, incentivesCalculation.issueDetailed.priceLabel.length - 4)).mul( + const priceInEth = new Decimal(incentivesCalculation.issueDetailed.priceLabel.substring(7, incentivesCalculation.issueDetailed.priceLabel.length - 4)).mul( incentivesCalculation.multiplier ); + if (priceInEth.gt(incentivesCalculation.paymentPermitMaxPrice)) { logger.info("Skipping to proceed the payment because bounty payout is higher than paymentPermitMaxPrice"); return { error: `Permit generation skipped since issue's bounty is higher than ${incentivesCalculation.paymentPermitMaxPrice}` }; @@ -354,8 +360,6 @@ export const handleIssueClosed = async ( // COMMENTERS REWARD HANDLER if (conversationRewards.reward && conversationRewards.reward.length > 0) { - commentersComment = `#### ${conversationRewards.title} Rewards \n`; - conversationRewards.reward.map(async (permit) => { // Exclude issue creator from commenter rewards if (permit.userId !== creatorReward.userId) { @@ -364,8 +368,10 @@ export const handleIssueClosed = async ( priceInEth: permit.priceInEth, userId: permit.userId, issueId: incentivesCalculation.issue.node_id, - type: conversationRewards.title, + type: [conversationRewards.title], user: permit.user, + priceArray: [permit.priceInEth.toString()], + debug: permit.debug, }); } }); @@ -373,8 +379,6 @@ export const handleIssueClosed = async ( // PULL REQUEST REVIEWERS REWARD HANDLER if (pullRequestReviewersReward.reward && pullRequestReviewersReward.reward.length > 0) { - pullRequestReviewerComment = `#### ${pullRequestReviewersReward.title} Rewards \n`; - pullRequestReviewersReward.reward.map(async (permit) => { // Exclude issue creator from commenter rewards if (permit.userId !== creatorReward.userId) { @@ -383,8 +387,10 @@ export const handleIssueClosed = async ( priceInEth: permit.priceInEth, userId: permit.userId, issueId: incentivesCalculation.issue.node_id, - type: pullRequestReviewersReward.title, + type: [pullRequestReviewersReward.title], user: permit.user, + priceArray: [permit.priceInEth.toString()], + debug: permit.debug, }); } }); @@ -392,52 +398,40 @@ export const handleIssueClosed = async ( // CREATOR REWARD HANDLER // Generate permit for user if its not the same id as assignee - if (creatorReward && creatorReward.reward && creatorReward.reward[0].account !== "0x" && creatorReward.userId !== incentivesCalculation.assignee.node_id) { - const { payoutUrl } = await generatePermit2Signature( - creatorReward.reward[0].account, - creatorReward.reward[0].priceInEth, - incentivesCalculation.issue.node_id, - creatorReward.userId - ); - - creatorComment = `#### ${creatorReward.title} Reward \n### [ **${creatorReward.username}: [ CLAIM ${ - creatorReward.reward[0].priceInEth - } ${incentivesCalculation.tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; - if (payoutUrl) { - logger.info(`Permit url generated for creator. reward: ${payoutUrl}`); - } - // Add amount to assignee if assignee is the creator - } else if ( - creatorReward && - creatorReward.reward && - creatorReward.reward[0].account !== "0x" && - creatorReward.userId === incentivesCalculation.assignee.node_id - ) { - priceInEth = priceInEth.add(creatorReward.reward[0].priceInEth); - title += " and Creator"; + if (creatorReward && creatorReward.reward && creatorReward.reward[0].account !== "0x") { + rewardByUser.push({ + account: creatorReward.reward[0].account, + priceInEth: creatorReward.reward[0].priceInEth, + userId: creatorReward.userId, + issueId: incentivesCalculation.issue.node_id, + type: [creatorReward.title], + user: creatorReward.username, + priceArray: [creatorReward.reward[0].priceInEth.toString()], + debug: creatorReward.reward[0].debug, + }); } else if (creatorReward && creatorReward.reward && creatorReward.reward[0].account === "0x") { logger.info(`Skipping to generate a permit url for missing account. fallback: ${creatorReward.fallbackReward}`); } // ASSIGNEE REWARD HANDLER if (assigneeReward && assigneeReward.reward && assigneeReward.reward[0].account !== "0x") { - const { txData, payoutUrl } = await generatePermit2Signature( - assigneeReward.reward[0].account, - assigneeReward.reward[0].priceInEth, - incentivesCalculation.issue.node_id, - incentivesCalculation.assignee.node_id - ); - const tokenSymbol = await getTokenSymbol(incentivesCalculation.paymentToken, incentivesCalculation.rpc); - const shortenRecipient = shortenEthAddress(assigneeReward.reward[0].account, `[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]`.length); - logger.info(`Posting a payout url to the issue, url: ${payoutUrl}`); - assigneeComment = - `#### ${title} Reward \n### [ **[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n` + "```" + shortenRecipient + "```"; const permitComments = incentivesCalculation.comments.filter((content) => { const permitUrlMatches = content.body.match(incentivesCalculation.claimUrlRegex); if (!permitUrlMatches || permitUrlMatches.length < 2) return false; else return true; }); + rewardByUser.push({ + account: assigneeReward.reward[0].account, + priceInEth: assigneeReward.reward[0].priceInEth, + userId: assigneeReward.userId, + issueId: incentivesCalculation.issue.node_id, + type: title, + user: assigneeReward.username, + priceArray: [assigneeReward.reward[0].priceInEth.toString()], + debug: assigneeReward.reward[0].debug, + }); + if (permitComments.length > 0) { logger.info(`Skip to generate a permit url because it has been already posted.`); return { error: `Permit generation disabled because it was already posted to this issue.` }; @@ -452,8 +446,6 @@ export const handleIssueClosed = async ( assigneeReward.reward[0].penaltyAmount ); } - - await savePermitToDB(incentivesCalculation.assignee.id, txData); } // MERGE ALL REWARDS @@ -461,59 +453,66 @@ export const handleIssueClosed = async ( const existing = acc.find((item) => item.userId === curr.userId); if (existing) { existing.priceInEth = existing.priceInEth.add(curr.priceInEth); - // merge type by adding comma and - existing.type = `${existing.type} and ${curr.type}`; + existing.priceArray = existing.priceArray.concat(curr.priceArray); + existing.type = existing.type.concat(curr.type); } else { acc.push(curr); } return acc; }, [] as RewardByUser[]); + // sort rewards by price + rewards.sort((a, b) => { + return new Decimal(b.priceInEth).cmp(new Decimal(a.priceInEth)); + }); + // CREATE PERMIT URL FOR EACH USER for (const reward of rewards) { - const { payoutUrl } = await generatePermit2Signature(reward.account, reward.priceInEth, reward.issueId, reward.userId); - - if (!reward.user) { + if (!reward.user || !reward.userId) { logger.info(`Skipping to generate a permit url for missing user. fallback: ${reward.user}`); continue; } - switch (reward.type) { - case "Conversation and Reviewer": - case "Reviewer and Conversation": - if (mergedComment === "") mergedComment = `#### ${reward.type} Rewards `; - mergedComment = `${mergedComment}\n### [ **${reward.user}: [ CLAIM ${ - reward.priceInEth - } ${incentivesCalculation.tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; - break; - case "Conversation": - commentersComment = `${commentersComment}\n### [ **${reward.user}: [ CLAIM ${ - reward.priceInEth - } ${incentivesCalculation.tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; - commentersReward[reward.user] = payoutUrl; - break; - case "Reviewer": - pullRequestReviewerComment = `${pullRequestReviewerComment}\n### [ **${reward.user}: [ CLAIM ${ - reward.priceInEth - } ${incentivesCalculation.tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`; - prReviewersReward[reward.user] = payoutUrl; - break; - default: - break; + const detailsValue = reward.priceArray + .map((price, i) => { + const separateTitle = reward.type[i]?.split("-"); + if (!separateTitle) return { title: "", subtitle: "", value: "" }; + return { title: separateTitle[0], subtitle: separateTitle[1], value: price }; + }) + // remove title if it's the same as the first one + .map((item, i, arr) => { + if (i === 0) return item; + if (item.title === arr[0].title) return { ...item, title: "" }; + return item; + }); + + const { reason, value } = await getWalletMultiplier(reward.user, incentivesCalculation.id?.toString()); + + // if reason is not "", then add multiplier to detailsValue and multiply the price + if (reason) { + detailsValue.push({ title: "Multiplier", subtitle: "Amount", value: value.toString() }); + detailsValue.push({ title: "", subtitle: "Reason", value: reason }); + + const multiplier = new Decimal(value); + const price = new Decimal(reward.priceInEth); + // add multiplier to the price + reward.priceInEth = price.mul(multiplier); } - logger.info(`Permit url generated for contributors. reward: ${JSON.stringify(commentersReward)}`); - logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(conversationRewards.fallbackReward)}`); + const { payoutUrl, txData } = await generatePermit2Signature(reward.account, reward.priceInEth, reward.issueId, reward.userId?.toString()); + + const price = `${reward.priceInEth} ${incentivesCalculation.tokenSymbol.toUpperCase()}`; - logger.info(`Permit url generated for pull request reviewers. reward: ${JSON.stringify(prReviewersReward)}`); + const comment = createDetailsTable(price, payoutUrl, reward.user, detailsValue, reward.debug); + + await savePermitToDB(Number(reward.userId), txData); + permitComment += comment; + + logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(conversationRewards.fallbackReward)}`); logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(pullRequestReviewersReward.fallbackReward)}`); } - if (commentersComment && !isEmpty(commentersReward)) await addCommentToIssue(commentersComment, issueNumber); - if (creatorComment) await addCommentToIssue(creatorComment, issueNumber); - if (pullRequestReviewerComment && !isEmpty(prReviewersReward)) await addCommentToIssue(pullRequestReviewerComment, issueNumber); - if (mergedComment) await addCommentToIssue(mergedComment, issueNumber); - if (assigneeComment) await addCommentToIssue(assigneeComment + comments.promotionComment, issueNumber); + if (permitComment) await addCommentToIssue(permitComment.trim() + comments.promotionComment, issueNumber); await deleteLabel(incentivesCalculation.issueDetailed.priceLabel); await addLabelToIssue("Permitted"); diff --git a/src/handlers/payout/post.ts b/src/handlers/payout/post.ts index a4ef8f8b4..74d32d440 100644 --- a/src/handlers/payout/post.ts +++ b/src/handlers/payout/post.ts @@ -1,5 +1,5 @@ import { getWalletAddress } from "../../adapters/supabase"; -import { getBotConfig, getBotContext, getLogger } from "../../bindings"; +import { getBotContext, getLogger } from "../../bindings"; import { getAllIssueComments, getAllPullRequestReviews, getIssueDescription, parseComments } from "../../helpers"; import { getLatestPullRequest, gitLinkedPrParser } from "../../helpers/parser"; import { Incentives, MarkdownItem, Payload, UserType } from "../../types"; @@ -25,24 +25,13 @@ const ItemsToExclude: string[] = [MarkdownItem.BlockQuote]; * The default formula has been defined in https://github.com/ubiquity/ubiquibot/issues/272 */ export const calculateIssueConversationReward = async (calculateIncentives: IncentivesCalculationResult): Promise => { - const title = `Conversation`; + const title = `Issue-Comments`; const logger = getLogger(); const context = getBotContext(); - const { - payout: { permitBaseUrl }, - } = getBotConfig(); const payload = context.payload as Payload; const issue = payload.issue; - const permitComments = calculateIncentives.comments.filter( - (content) => content.body.includes(title) && content.body.includes(permitBaseUrl) && content.user.type == UserType.Bot - ); - if (permitComments.length > 0) { - logger.info(`incentivizeComments: skip to generate a permit url because it has been already posted`); - return { error: `incentivizeComments: skip to generate a permit url because it has been already posted` }; - } - const assignees = issue?.assignees ?? []; const assignee = assignees.length > 0 ? assignees[0] : undefined; if (!assignee) { @@ -52,7 +41,7 @@ export const calculateIssueConversationReward = async (calculateIncentives: Ince const issueComments = await getAllIssueComments(calculateIncentives.issue.number, "full"); 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.login) continue; @@ -68,7 +57,7 @@ export const calculateIssueConversationReward = async (calculateIncentives: Ince // Store the comment along with user's login and node_id if (!issueCommentsByUser[user.login]) { - issueCommentsByUser[user.login] = { id: user.node_id, comments: [] }; + issueCommentsByUser[user.login] = { id: user.id, comments: [] }; } issueCommentsByUser[user.login].comments.push(issueComment.body_html); } @@ -78,25 +67,32 @@ export const calculateIssueConversationReward = async (calculateIncentives: Ince const fallbackReward: Record = {}; // array of awaiting permits to generate - const reward: { account: string; priceInEth: Decimal; userId: string; user: string; penaltyAmount: BigNumber }[] = []; + const reward: { + account: string; + priceInEth: Decimal; + userId: number; + user: string; + penaltyAmount: BigNumber; + debug: Record; + }[] = []; for (const user of Object.keys(issueCommentsByUser)) { const commentsByUser = issueCommentsByUser[user]; const commentsByNode = await parseComments(commentsByUser.comments, ItemsToExclude); const rewardValue = calculateRewardValue(commentsByNode, calculateIncentives.incentives); - if (rewardValue.equals(0)) { + if (rewardValue.sum.equals(0)) { logger.info(`Skipping to generate a permit url because the reward value is 0. user: ${user}`); continue; } - logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`); + logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}`); const account = await getWalletAddress(user); - const priceInEth = rewardValue.mul(calculateIncentives.baseMultiplier); + const priceInEth = rewardValue.sum.mul(calculateIncentives.baseMultiplier); if (priceInEth.gt(calculateIncentives.paymentPermitMaxPrice)) { logger.info(`Skipping comment reward for user ${user} because reward is higher than payment permit max price`); continue; } if (account) { - reward.push({ account, priceInEth, userId: commentsByUser.id, user, penaltyAmount: BigNumber.from(0) }); + reward.push({ account, priceInEth, userId: commentsByUser.id, user, penaltyAmount: BigNumber.from(0), debug: rewardValue.sumByType }); } else { fallbackReward[user] = priceInEth; } @@ -106,11 +102,8 @@ export const calculateIssueConversationReward = async (calculateIncentives: Ince }; export const calculateIssueCreatorReward = async (incentivesCalculation: IncentivesCalculationResult): Promise => { - const title = `Task Creator`; + const title = `Issue-Creation`; const logger = getLogger(); - const { - payout: { permitBaseUrl }, - } = getBotConfig(); const issueDetailed = bountyInfo(incentivesCalculation.issue); if (!issueDetailed.isBounty) { @@ -118,15 +111,6 @@ export const calculateIssueCreatorReward = async (incentivesCalculation: Incenti return { error: `incentivizeCreatorComment: its not a bounty` }; } - const comments = await getAllIssueComments(incentivesCalculation.issue.number); - const permitComments = comments.filter( - (content) => content.body.includes(title) && content.body.includes(permitBaseUrl) && content.user.type == UserType.Bot - ); - if (permitComments.length > 0) { - logger.info(`incentivizeCreatorComment: skip to generate a permit url because it has been already posted`); - return { error: `incentivizeCreatorComment: skip to generate a permit url because it has been already posted` }; - } - const assignees = incentivesCalculation.issue.assignees ?? []; const assignee = assignees.length > 0 ? assignees[0] : undefined; if (!assignee) { @@ -161,15 +145,16 @@ export const calculateIssueCreatorReward = async (incentivesCalculation: Incenti return { error: "", title, - userId: creator.node_id, + userId: creator.id, username: creator.login, reward: [ { priceInEth: result?.rewardInTokens ?? new Decimal(0), account: result?.account, - userId: "", + userId: creator.id, user: "", penaltyAmount: BigNumber.from(0), + debug: {}, }, ], }; @@ -178,10 +163,7 @@ export const calculateIssueCreatorReward = async (incentivesCalculation: Incenti export const calculatePullRequestReviewsReward = async (incentivesCalculation: IncentivesCalculationResult): Promise => { const logger = getLogger(); const context = getBotContext(); - const { - payout: { permitBaseUrl }, - } = getBotConfig(); - const title = "Reviewer"; + const title = "Review-Reviewer"; const linkedPullRequest = await gitLinkedPrParser({ owner: incentivesCalculation.payload.repository.owner.login, @@ -196,15 +178,6 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I return { error: `calculatePullRequestReviewsReward: No linked pull requests found` }; } - const comments = await getAllIssueComments(incentivesCalculation.issue.number); - const permitComments = comments.filter( - (content) => content.body.includes(title) && content.body.includes(permitBaseUrl) && content.user.type == UserType.Bot - ); - if (permitComments.length > 0) { - logger.info(`calculatePullRequestReviewsReward: skip to generate a permit url because it has been already posted`); - return { error: `calculatePullRequestReviewsReward: skip to generate a permit url because it has been already posted` }; - } - const assignees = incentivesCalculation.issue?.assignees ?? []; const assignee = assignees.length > 0 ? assignees[0] : undefined; if (!assignee) { @@ -215,7 +188,7 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I 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 = {}; + const prReviewsByUser: Record = {}; for (const review of prReviews) { const user = review.user; if (!user) continue; @@ -225,7 +198,7 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I continue; } if (!prReviewsByUser[user.login]) { - prReviewsByUser[user.login] = { id: user.node_id, comments: [] }; + prReviewsByUser[user.login] = { id: user.id, comments: [] }; } prReviewsByUser[user.login].comments.push(review.body_html); } @@ -239,7 +212,7 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I continue; } if (!prReviewsByUser[user.login]) { - prReviewsByUser[user.login] = { id: user.node_id, comments: [] }; + prReviewsByUser[user.login] = { id: user.id, comments: [] }; } prReviewsByUser[user.login].comments.push(comment.body_html); } @@ -247,7 +220,14 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I logger.info(`calculatePullRequestReviewsReward: Filtering by the user type done. commentsByUser: ${JSON.stringify(prReviewsByUser)}`); // array of awaiting permits to generate - const reward: { account: string; priceInEth: Decimal; userId: string; user: string; penaltyAmount: BigNumber }[] = []; + const reward: { + account: string; + priceInEth: Decimal; + userId: number; + user: string; + penaltyAmount: BigNumber; + debug: Record; + }[] = []; // The mapping between gh handle and amount in ETH const fallbackReward: Record = {}; @@ -256,20 +236,22 @@ export const calculatePullRequestReviewsReward = async (incentivesCalculation: I const commentByUser = prReviewsByUser[user]; const commentsByNode = await parseComments(commentByUser.comments, ItemsToExclude); const rewardValue = calculateRewardValue(commentsByNode, incentivesCalculation.incentives); - if (rewardValue.equals(0)) { + if (rewardValue.sum.equals(0)) { logger.info(`calculatePullRequestReviewsReward: Skipping to generate a permit url because the reward value is 0. user: ${user}`); continue; } - logger.info(`calculatePullRequestReviewsReward: Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`); + logger.info( + `calculatePullRequestReviewsReward: Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}` + ); const account = await getWalletAddress(user); - const priceInEth = rewardValue.mul(incentivesCalculation.baseMultiplier); + const priceInEth = rewardValue.sum.mul(incentivesCalculation.baseMultiplier); if (priceInEth.gt(incentivesCalculation.paymentPermitMaxPrice)) { logger.info(`calculatePullRequestReviewsReward: Skipping comment reward for user ${user} because reward is higher than payment permit max price`); continue; } if (account) { - reward.push({ account, priceInEth, userId: commentByUser.id, user, penaltyAmount: BigNumber.from(0) }); + reward.push({ account, priceInEth, userId: commentByUser.id, user, penaltyAmount: BigNumber.from(0), debug: rewardValue.sumByType }); } else { fallbackReward[user] = priceInEth; } @@ -291,13 +273,13 @@ const generatePermitForComments = async ( const logger = getLogger(); const commentsByNode = await parseComments(comments, ItemsToExclude); const rewardValue = calculateRewardValue(commentsByNode, incentives); - if (rewardValue.equals(0)) { + if (rewardValue.sum.equals(0)) { logger.info(`No reward for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`); return; } - logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`); + logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}`); const account = await getWalletAddress(user); - const rewardInTokens = rewardValue.mul(multiplier); + const rewardInTokens = rewardValue.sum.mul(multiplier); if (rewardInTokens.gt(paymentPermitMaxPrice)) { logger.info(`Skipping issue creator reward for user ${user} because reward is higher than payment permit max price`); return; @@ -315,18 +297,34 @@ const generatePermitForComments = async ( * @param incentives - The basic price table for reward calculation * @returns - The reward value */ -const calculateRewardValue = (comments: Record, incentives: Incentives): Decimal => { +const calculateRewardValue = ( + comments: Record, + incentives: Incentives +): { sum: Decimal; sumByType: Record } => { let sum = new Decimal(0); + const sumByType: Record = {}; + for (const key of Object.keys(comments)) { const value = comments[key]; + // Initialize the sum for this key if it doesn't exist + if (!sumByType[key]) { + sumByType[key] = { + count: 0, + reward: new Decimal(0), + }; + } + // if it's a text node calculate word count and multiply with the reward value if (key == "#text") { if (!incentives.comment.totals.word) { continue; } const wordReward = new Decimal(incentives.comment.totals.word); - const reward = wordReward.mul(value.map((str) => str.trim().split(" ").length).reduce((totalWords, wordCount) => totalWords + wordCount, 0)); + const wordCount = value.map((str) => str.trim().split(" ").length).reduce((totalWords, wordCount) => totalWords + wordCount, 0); + const reward = wordReward.mul(wordCount); + sumByType[key].count += wordCount; + sumByType[key].reward = wordReward; sum = sum.add(reward); } else { if (!incentives.comment.elements[key]) { @@ -334,9 +332,11 @@ const calculateRewardValue = (comments: Record, incentives: In } const rewardValue = new Decimal(incentives.comment.elements[key]); const reward = rewardValue.mul(value.length); + sumByType[key].count += value.length; + sumByType[key].reward = rewardValue; sum = sum.add(reward); } } - return sum; + return { sum, sumByType }; }; diff --git a/src/handlers/pricing/action.ts b/src/handlers/pricing/action.ts index 0b8982eaa..7e9673c6f 100644 --- a/src/handlers/pricing/action.ts +++ b/src/handlers/pricing/action.ts @@ -1,7 +1,7 @@ import { getBotConfig, getBotContext, getLogger } from "../../bindings"; import { GLOBAL_STRINGS } from "../../configs"; -import { addCommentToIssue, addLabelToIssue, clearAllPriceLabelsOnIssue, createLabel, getLabel, calculateWeight } from "../../helpers"; -import { Payload } from "../../types"; +import { addCommentToIssue, addLabelToIssue, clearAllPriceLabelsOnIssue, createLabel, getLabel, calculateWeight, getAllLabeledEvents } from "../../helpers"; +import { Payload, UserType } from "../../types"; import { handleLabelsAccess } from "../access"; import { getTargetPriceLabel } from "../shared"; @@ -12,7 +12,7 @@ export const pricingLabelLogic = async (): Promise => { const payload = context.payload as Payload; if (!payload.issue) return; const labels = payload.issue.labels; - + const labelNames = labels.map((i) => i.name); logger.info(`Checking if the issue is a parent issue.`); if (payload.issue.body && isParentIssue(payload.issue.body)) { logger.error("Identified as parent issue. Disabling price label."); @@ -37,10 +37,36 @@ export const pricingLabelLogic = async (): Promise => { const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined; const targetPriceLabel = getTargetPriceLabel(minTimeLabel, minPriorityLabel); + if (targetPriceLabel) { - if (labels.map((i) => i.name).includes(targetPriceLabel)) { - logger.info(`Skipping... already exists`); + const _targetPriceLabel = labelNames.find((name) => name.includes("Price") && name.includes(targetPriceLabel)); + + if (_targetPriceLabel) { + // get all issue events of type "labeled" and the event label includes Price + let labeledEvents = await getAllLabeledEvents(); + if (!labeledEvents) return; + + labeledEvents = labeledEvents.filter((event) => event.label?.name.includes("Price")); + if (!labeledEvents.length) return; + + // check if the latest price label has been added by a user + if (labeledEvents[labeledEvents.length - 1].actor?.type == UserType.User) { + logger.info(`Skipping... already exists`); + } else { + // add price label to issue becuase wrong price has been added by bot + logger.info(`Adding price label to issue`); + await clearAllPriceLabelsOnIssue(); + + const exist = await getLabel(targetPriceLabel); + + if (assistivePricing && !exist) { + logger.info(`${targetPriceLabel} doesn't exist on the repo, creating...`); + await createLabel(targetPriceLabel, "price"); + } + await addLabelToIssue(targetPriceLabel); + } } else { + // add price if there is none logger.info(`Adding price label to issue`); await clearAllPriceLabelsOnIssue(); diff --git a/src/handlers/wildcard/analytics.ts b/src/handlers/wildcard/analytics.ts index 62f24edf9..df877f070 100644 --- a/src/handlers/wildcard/analytics.ts +++ b/src/handlers/wildcard/analytics.ts @@ -2,7 +2,6 @@ import { getMaxIssueNumber, upsertIssue, upsertUser } from "../../adapters/supab import { getBotConfig, getLogger } from "../../bindings"; import { listIssuesForRepo, getUser, calculateWeight } from "../../helpers"; import { Issue, IssueType, User, UserProfile } from "../../types"; -import { getTargetPriceLabel } from "../shared"; /** * Checks the issue whether it's a bounty for hunters or an issue for not @@ -19,6 +18,7 @@ export const bountyInfo = ( } => { const config = getBotConfig(); const labels = issue.labels; + const timeLabels = config.price.timeLabels.filter((item) => labels.map((i) => i.name).includes(item.name)); const priorityLabels = config.price.priorityLabels.filter((item) => labels.map((i) => i.name).includes(item.name)); @@ -27,7 +27,7 @@ export const bountyInfo = ( const minTimeLabel = timeLabels.length > 0 ? timeLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined; const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined; - const priceLabel = getTargetPriceLabel(minTimeLabel, minPriorityLabel); + const priceLabel = labels.find((label) => label.name.includes("Price"))?.name; return { isBounty, diff --git a/src/helpers/comment.ts b/src/helpers/comment.ts index cba6c165f..7be39376c 100644 --- a/src/helpers/comment.ts +++ b/src/helpers/comment.ts @@ -1,3 +1,5 @@ +import Decimal from "decimal.js"; +import { isEmpty } from "lodash"; import * as parse5 from "parse5"; type Node = { @@ -40,3 +42,99 @@ export const parseComments = (comments: string[], itemsToExclude: string[]): Rec return result; }; + +export const generateCollapsibleTable = (data: { element: string; units: number; reward: Decimal }[]) => { + // Check if the data array is empty + if (data.length === 0) { + return "No data to display."; + } + + // Create the table header row + const headerRow = "| element | units | reward |\n| --- | --- | --- |"; + + // Create the table rows from the data array + const tableRows = data.map((item) => `| ${item.element} | ${item.units} | ${item.reward} |`).join("\n"); + + // Create the complete Markdown table + const tableMarkdown = ` +
+ Details + +${headerRow} +${tableRows} + +
+ `; + + return tableMarkdown; +}; + +export const createDetailsTable = ( + amount: string, + paymentURL: string, + username: string, + values: { title: string; subtitle: string; value: string }[], + debug: Record< + string, + { + count: number; + reward: Decimal; + } + > +): string => { + let collapsibleTable = null; + // Generate the table rows based on the values array + const tableRows = values + .map(({ title, value, subtitle }) => { + if (!subtitle || !value) { + return ""; + } + return ` + ${title || ""} + ${subtitle} + ${value} + `; + }) + .join(""); + + if (!isEmpty(debug)) { + const data = Object.entries(debug) + .filter(([_, value]) => value.count > 0) + .map(([key, value]) => { + const element = key === "#text" ? "words" : key; + const units = value.count; + const reward = value.reward; + return { element, units, reward }; + }); + + collapsibleTable = generateCollapsibleTable(data); + } + + // Construct the complete HTML structure + const html = ` +
+ + +

+ [ ${amount} ] +

+
 @${username}
+
+
+ + + ${tableRows} + +
+ ${collapsibleTable ? "COLLAPSIBLE_TABLE_PLACEHOLDER" : ""} +
+ `; + + // Remove spaces and line breaks from the HTML, ignoring the attributes like and [ ... ] + const cleanedHtml = html.replace(/>\s+<").replace(/[\r\n]+/g, ""); + + // Add collapsible table here to avoid compression + const finalHtml = cleanedHtml.replace("COLLAPSIBLE_TABLE_PLACEHOLDER", collapsibleTable || ""); + + return finalHtml; +}; diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index 27ab9562d..c3cc89972 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -3,6 +3,50 @@ import { getBotConfig, getBotContext, getLogger } from "../bindings"; import { AssignEvent, Comment, IssueType, Payload, StreamlinedComment, UserType } from "../types"; import { checkRateLimitGit } from "../utils"; +export const getAllIssueEvents = async () => { + const context = getBotContext(); + const logger = getLogger(); + const payload = context.payload as Payload; + if (!payload.issue) return; + + let shouldFetch = true; + let page_number = 1; + const events = []; + + try { + while (shouldFetch) { + // Fetch issue events + const response = await context.octokit.issues.listEvents({ + owner: payload.repository.owner.login, + repo: payload.repository.full_name, + issue_number: payload.issue.number, + per_page: 100, + page: page_number, + }); + + await checkRateLimitGit(response?.headers); + + if (response?.data?.length > 0) { + events.push(...response.data); + page_number++; + } else { + shouldFetch = false; + } + } + } catch (e: unknown) { + shouldFetch = false; + logger.error(`Getting all issue events failed, reason: ${e}`); + return null; + } + return events; +}; + +export const getAllLabeledEvents = async () => { + const events = await getAllIssueEvents(); + if (!events) return null; + return events.filter((event) => event.event === "labeled"); +}; + export const clearAllPriceLabelsOnIssue = async (): Promise => { const context = getBotContext(); const logger = getLogger(); diff --git a/src/helpers/label.ts b/src/helpers/label.ts index a02d6c5eb..7bc3744ec 100644 --- a/src/helpers/label.ts +++ b/src/helpers/label.ts @@ -1,11 +1,17 @@ import { Context } from "probot"; import { getBotConfig, getBotContext, getLogger } from "../bindings"; -import { COLORS } from "../configs"; import { calculateBountyPrice } from "../handlers"; import { Label, Payload } from "../types"; import { deleteLabel } from "./issue"; import { calculateWeight } from "../helpers"; +// cspell:disable +export const COLORS = { + default: "ededed", + price: "1f883d", +}; +// cspell:enable + export const listLabelsForRepo = async (per_page?: number, page?: number): Promise => { const context = getBotContext(); const payload = context.payload as Payload; diff --git a/src/helpers/payout.ts b/src/helpers/payout.ts index 21a8f7c8f..c6f4a06f2 100644 --- a/src/helpers/payout.ts +++ b/src/helpers/payout.ts @@ -12,7 +12,6 @@ */ import { Static } from "@sinclair/typebox"; -import { DEFAULT_RPC_ENDPOINT } from "../configs"; import { PayoutConfigSchema } from "../types"; import { getUserPermission } from "./issue"; import { getBotContext, getLogger } from "../bindings"; @@ -21,7 +20,7 @@ import { getAccessLevel } from "../adapters/supabase"; // available tokens for payouts const PAYMENT_TOKEN_PER_NETWORK: Record = { "1": { - rpc: DEFAULT_RPC_ENDPOINT, + rpc: "https://rpc-bot.ubq.fi/v1/mainnet", token: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI }, "100": { diff --git a/src/types/config.ts b/src/types/config.ts index 8614a7416..eeec20253 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -63,6 +63,16 @@ export const TelegramBotConfigSchema = Type.Object({ delay: Type.Number(), }); +export const LogNotificationSchema = Type.Object({ + url: Type.String(), + secret: Type.String(), + groupId: Type.Number(), + topicId: Type.Number(), + enabled: Type.Boolean(), +}); + +export type LogNotification = Static; + export const PayoutConfigSchema = Type.Object({ networkId: Type.Number(), rpc: Type.String(), @@ -139,6 +149,7 @@ export const BotConfigSchema = Type.Object({ unassign: UnassignConfigSchema, supabase: SupabaseConfigSchema, telegram: TelegramBotConfigSchema, + logNotification: LogNotificationSchema, mode: ModeSchema, assign: AssignSchema, sodium: SodiumSchema, @@ -223,6 +234,12 @@ export const MergedConfigSchema = Type.Object({ openAITokenLimit: Type.Optional(Type.Number()), staleBountyTime: Type.String(), newContributorGreeting: NewContributorGreetingSchema, + timeRangeForMaxIssue: Type.Number(), + timeRangeForMaxIssueEnabled: Type.Boolean(), + permitBaseUrl: Type.String(), + botDelay: Type.Number(), + followUpTime: Type.String(), + disqualifyTime: Type.String(), }); export type MergedConfig = Static; diff --git a/src/utils/private.ts b/src/utils/private.ts index a92d73b02..90b2e577a 100644 --- a/src/utils/private.ts +++ b/src/utils/private.ts @@ -162,6 +162,12 @@ export const getWideConfig = async (context: Context) => { openAITokenLimit: mergedConfigData.openAITokenLimit, staleBountyTime: mergedConfigData.staleBountyTime, newContributorGreeting: mergedConfigData.newContributorGreeting, + timeRangeForMaxIssue: mergedConfigData.timeRangeForMaxIssue, + timeRangeForMaxIssueEnabled: mergedConfigData.timeRangeForMaxIssueEnabled, + permitBaseUrl: mergedConfigData.permitBaseUrl, + botDelay: mergedConfigData.botDelay, + followUpTime: mergedConfigData.followUpTime, + disqualifyTime: mergedConfigData.disqualifyTime, }; return configData; diff --git a/yarn.lock b/yarn.lock index 60036ca49..65e3ead1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6481,7 +6481,7 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== -jsonwebtoken@^9.0.0: +jsonwebtoken@^9.0.0, jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==