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/package.json b/package.json index 918e9042c..28e67bd76 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@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", 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/payout/post.ts b/src/handlers/payout/post.ts index dd6a4880c..1bd18ada0 100644 --- a/src/handlers/payout/post.ts +++ b/src/handlers/payout/post.ts @@ -5,10 +5,13 @@ import { generatePermit2Signature, getAllIssueComments, getIssueDescription, + getAllPullRequestReviews, getOrgMembershipOfUser, getTokenSymbol, parseComments, } from "../../helpers"; +import { gitLinkedPrParser } from "../../helpers/parser"; + import { Incentives, MarkdownItem, Payload, StateReason, UserType } from "../../types"; import { commentParser } from "../comment"; import Decimal from "decimal.js"; @@ -150,6 +153,134 @@ 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 }); + + if (!linkedPullRequest) { + 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, linkedPullRequest.number, "full"); + const prComments = await getAllIssueComments(linkedPullRequest.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 { diff --git a/src/handlers/processors.ts b/src/handlers/processors.ts index 0c8d6f5d7..68e366063 100644 --- a/src/handlers/processors.ts +++ b/src/handlers/processors.ts @@ -6,8 +6,8 @@ import { nullHandler } from "./shared"; import { handleComment, issueClosedCallback, issueCreatedCallback, issueReopenedCallback } from "./comment"; import { checkPullRequests } from "./assign/auto"; import { createDevPoolPR } from "./pull-request"; +import { incentivizeComments, incentivizeCreatorComment, incentivizePullRequestReviews } from "./payout"; import { runOnPush, validateConfigChange } from "./push"; -import { incentivizeComments, incentivizeCreatorComment } from "./payout"; import { findDuplicateOne } from "./issue"; export const processors: Record = { @@ -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], diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index 6f8d7cb1b..b8b487f35 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -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) { diff --git a/src/helpers/parser.ts b/src/helpers/parser.ts index b05411fee..93d249404 100644 --- a/src/helpers/parser.ts +++ b/src/helpers/parser.ts @@ -1,10 +1,14 @@ import axios from "axios"; import { HTMLElement, parse } from "node-html-parser"; +import { getPullByNumber } from "./issue"; +import { getBotContext, getLogger } from "../bindings"; +import { Payload } from "../types"; 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 => { @@ -25,20 +29,58 @@ export const gitIssueParser = async ({ owner, repo, issue_number }: GitParser): } }; -export const gitLinkedIssueParser = async ({ owner, repo, issue_number }: GitParser): Promise => { +export const gitLinkedIssueParser = async ({ owner, repo, pull_number }: GitParser) => { + const logger = getLogger(); try { - const { data } = await axios.get(`https://github.com/${owner}/${repo}/pull/${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"); + const linkedIssues = devForm.querySelectorAll(".my-1"); - if (linkedPRs.length === 0) { - return ""; + if (linkedIssues.length === 0) { + return null; } - const prUrl = linkedPRs[0].querySelector("a")?.attrs?.href || ""; - return prUrl; + const issueUrl = linkedIssues[0].querySelector("a")?.attrs?.href || ""; + return issueUrl; + } catch (error) { + logger.error(`${JSON.stringify(error)}`); + return null; + } +}; + +export const gitLinkedPrParser = async ({ owner, repo, issue_number }: GitParser) => { + const logger = getLogger(); + try { + const { data } = await axios.get(`https://github.com/${owner}/${repo}/issues/${issue_number}`); + const context = getBotContext(); + const payload = context.payload as Payload; + 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 null; + let linkedPullRequest = null; + for (const linkedPr of linkedPRs) { + const prHref = linkedPr.querySelector("a")?.attrs?.href || ""; + const parts = prHref.split("/"); + // extract the organization name and repo name from the link:(e.g. "https://github.com/wannacfuture/ubiquibot/pull/5";) + const organization = parts[parts.length - 4]; + const repository = parts[parts.length - 3]; + + if (`${organization}/${repository}` !== payload.repository.full_name) continue; + const prNumber = parts[parts.length - 1]; + if (Number.isNaN(Number(prNumber))) return null; + const pr = await getPullByNumber(context, Number(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; } catch (error) { - return ""; + logger.error(`${JSON.stringify(error)}`); + return null; } }; diff --git a/src/helpers/shared.ts b/src/helpers/shared.ts index f851c5220..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"; @@ -35,13 +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("minute")) return number * 60; - 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; };