Skip to content
This repository has been archived by the owner on Sep 19, 2024. It is now read-only.

Html comments #545

Merged
merged 20 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/ubiquibot-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ auto-pay-mode: true
comment-incentives: true
max-concurrent-bounties: 2
promotion-comment: "\n<h6>If you enjoy the DevPool experience, please follow <a href='https://github.com/ubiquity'>Ubiquity on GitHub</a> and star <a href='https://github.com/ubiquity/devpool-directory'>this repo</a> to show your support. It helps a lot!</h6>"
incentives:
comment:
elements:
code: 5
img: 5
h1: 1
li: 0.5
a: 0.5
blockquote: 0
totals:
word: 0.1
register-wallet-with-verification: false
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"@probot/adapter-github-actions": "^3.1.3",
"@sinclair/typebox": "^0.25.9",
"@supabase/supabase-js": "^2.4.0",
"@types/mdast": "^3.0.11",
"@types/ms": "^0.7.31",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
Expand All @@ -49,13 +48,11 @@
"js-yaml": "^4.1.0",
"libsodium-wrappers": "^0.7.11",
"lint-staged": "^13.1.0",
"mdast-util-from-markdown": "^1.3.0",
"mdast-util-gfm": "^2.0.2",
"micromark-extension-gfm": "^2.0.3",
"ms": "^2.1.3",
"node-html-parser": "^6.1.5",
"node-html-to-image": "^3.3.0",
"nodemon": "^2.0.19",
"parse5": "^7.1.2",
"prettier": "^2.7.1",
"probot": "^12.2.4",
"telegraf": "^4.11.2",
Expand Down
23 changes: 18 additions & 5 deletions src/adapters/supabase/helpers/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,22 +159,35 @@ export const upsertWalletAddress = async (username: string, address: string): Pr
const logger = getLogger();
const { supabase } = getAdapters();

const { data, error } = await supabase.from("wallets").select("user_name").eq("user_name", username).single();
if (data) {
await supabase.from("wallets").upsert({
const { data, error } = await supabase.from("wallets").select("user_name").eq("user_name", username);
if (error) {
logger.error(`Checking wallet address failed, error: ${JSON.stringify(error)}`);
throw new Error(`Checking wallet address failed, error: ${JSON.stringify(error)}`);
}

if (data && data.length > 0) {
const { data: _data, error: _error } = await supabase.from("wallets").upsert({
user_name: username,
wallet_address: address,
updated_at: new Date().toUTCString(),
});
logger.info(`Upserting a wallet address done, { data: ${data}, error: ${error} }`);
if (_error) {
logger.error(`Upserting a wallet address failed, error: ${JSON.stringify(_error)}`);
throw new Error(`Upserting a wallet address failed, error: ${JSON.stringify(_error)}`);
}
logger.info(`Upserting a wallet address done, { data: ${JSON.stringify(_data)} }`);
} else {
const { data: _data, error: _error } = await supabase.from("wallets").insert({
user_name: username,
wallet_address: address,
created_at: new Date().toUTCString(),
updated_at: new Date().toUTCString(),
});
logger.info(`Creating a new wallet_table record done, { data: ${_data}, error: ${_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)} }`);
}
};

Expand Down
4 changes: 2 additions & 2 deletions src/bindings/config.ts
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const loadConfig = async (context: Context): Promise<BotConfig> => {
timeLabels,
privateKey,
priorityLabels,
commentElementPricing,
incentives,
autoPayMode,
disableAnalytics,
bountyHunterMax,
Expand All @@ -38,7 +38,7 @@ export const loadConfig = async (context: Context): Promise<BotConfig> => {
issueCreatorMultiplier,
timeLabels,
priorityLabels,
commentElementPricing,
incentives,
defaultLabels,
},
comments: {
Expand Down
11 changes: 6 additions & 5 deletions src/handlers/payout/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
addLabelToIssue,
deleteLabel,
generatePermit2Signature,
getAllIssueAssignEvents,
getAllIssueComments,
getTokenSymbol,
wasIssueReopened,
getAllIssueAssignEvents,
} from "../../helpers";
import { UserType, Payload, StateReason } from "../../types";
import { shortenEthAddress } from "../../utils";
Expand Down Expand Up @@ -110,6 +110,11 @@ export const handleIssueClosed = async () => {
}

const recipient = await getWalletAddress(assignee.login);
if (!recipient || recipient?.trim() === "") {
logger.info(`Recipient address is missing`);
return;
}

const { value } = await getWalletMultiplier(assignee.login, id?.toString());

if (value === 0) {
Expand All @@ -120,10 +125,6 @@ export const handleIssueClosed = async () => {

// TODO: add multiplier to the priceInEth
let priceInEth = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * value).toString();
if (!recipient || recipient?.trim() === "") {
logger.info(`Recipient address is missing`);
return;
}

// if bounty hunter has any penalty then deduct it from the bounty
const penaltyAmount = await getPenalty(assignee.login, payload.repository.full_name, paymentToken, networkId.toString());
Expand Down
86 changes: 60 additions & 26 deletions src/handlers/payout/post.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { getWalletAddress } from "../../adapters/supabase";
import { getBotConfig, getBotContext, getLogger } from "../../bindings";
import { addCommentToIssue, generatePermit2Signature, getAllIssueComments, getIssueDescription, getTokenSymbol, parseComments } from "../../helpers";
import { MarkdownItem, Payload, UserType, CommentElementPricing } from "../../types";
import { Incentives, Payload, UserType } from "../../types";
import { commentParser } from "../comment";

const ItemsToExclude: string[] = [MarkdownItem.BlockQuote];
const ItemsToExclude: string[] = ["blockquote"];
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
/**
* Incentivize the contributors based on their contribution.
* The default formula has been defined in https://github.com/ubiquity/ubiquibot/issues/272
Expand All @@ -12,7 +13,7 @@ export const incentivizeComments = async () => {
const logger = getLogger();
const {
mode: { incentiveMode },
price: { baseMultiplier, commentElementPricing },
price: { baseMultiplier, incentives },
payout: { paymentToken, rpc },
} = getBotConfig();
if (!incentiveMode) {
Expand All @@ -21,26 +22,38 @@ export const incentivizeComments = async () => {
}
const context = getBotContext();
const payload = context.payload as Payload;
const org = payload.organization?.login;
const issue = payload.issue;
if (!issue || !org) {
logger.info(`Incomplete payload. issue: ${issue}, org: ${org}`);
if (!issue) {
logger.info(`Incomplete payload. issue: ${issue}`);
return;
}

const assignees = issue?.assignees ?? [];
const assignee = assignees.length > 0 ? assignees[0] : undefined;
if (!assignee) {
logger.info("Skipping payment permit generation because `assignee` is `undefined`.");
return;
}

const issueComments = await getAllIssueComments(issue.number);
const issueComments = await getAllIssueComments(issue.number, "full");
logger.info(`Getting the issue comments done. comments: ${JSON.stringify(issueComments)}`);
const issueCommentsByUser: Record<string, string[]> = {};
for (const issueComment of issueComments) {
const user = issueComment.user;
if (user.type == UserType.Bot || user.login == assignee) continue;
issueCommentsByUser[user.login].push(issueComment.body);
const commands = commentParser(issueComment.body);
if (commands.length > 0) {
logger.info(`Skipping to parse the comment because it contains commands. comment: ${JSON.stringify(issueComment)}`);
continue;
}
if (!issueComment.body_html) {
logger.info(`Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(issueComment)}`);
continue;
}
if (!issueCommentsByUser[user.login]) {
issueCommentsByUser[user.login] = [];
}
issueCommentsByUser[user.login].push(issueComment.body_html);
}
const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
logger.info(`Filtering by the user type done. commentsByUser: ${JSON.stringify(issueCommentsByUser)}`);
Expand All @@ -54,10 +67,14 @@ export const incentivizeComments = async () => {
for (const user of Object.keys(issueCommentsByUser)) {
const comments = issueCommentsByUser[user];
const commentsByNode = await parseComments(comments, ItemsToExclude);
const rewardValue = calculateRewardValue(commentsByNode, commentElementPricing);
const rewardValue = calculateRewardValue(commentsByNode, incentives);
if (rewardValue === 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}`);
const account = await getWalletAddress(user);
const amountInETH = ((rewardValue * baseMultiplier) / 1000).toString();
const amountInETH = ((rewardValue * baseMultiplier) / 1000).toFixed(2);
if (account) {
const payoutUrl = await generatePermit2Signature(account, amountInETH, issue.node_id);
comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`;
Expand All @@ -77,7 +94,7 @@ export const incentivizeCreatorComment = async () => {
const logger = getLogger();
const {
mode: { incentiveMode },
price: { commentElementPricing, issueCreatorMultiplier },
price: { incentives, issueCreatorMultiplier },
payout: { paymentToken, rpc },
} = getBotConfig();
if (!incentiveMode) {
Expand All @@ -86,29 +103,33 @@ export const incentivizeCreatorComment = async () => {
}
const context = getBotContext();
const payload = context.payload as Payload;
const org = payload.organization?.login;
const issue = payload.issue;
if (!issue || !org) {
logger.info(`Incomplete payload. issue: ${issue}, org: ${org}`);
if (!issue) {
logger.info(`Incomplete payload. issue: ${issue}`);
return;
}
const assignees = issue?.assignees ?? [];

const assignees = issue.assignees ?? [];
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
const assignee = assignees.length > 0 ? assignees[0] : undefined;
if (!assignee) {
logger.info("Skipping payment permit generation because `assignee` is `undefined`.");
return;
}

const description = await getIssueDescription(issue.number);
const description = await getIssueDescription(issue.number, "html");
if (!description) {
logger.info(`Skipping to generate a permit url because issue description is empty. description: ${description}`);
return;
}
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
logger.info(`Getting the issue description done. description: ${description}`);
const creator = issue.user;
if (creator?.type === UserType.Bot || creator?.login === issue?.assignee) {
if (creator.type === UserType.Bot || creator.login === issue.assignee) {
logger.info("Issue creator assigneed himself or Bot created this issue.");
return;
}

const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
const result = await generatePermitForComments(creator?.login, [description], issueCreatorMultiplier, commentElementPricing, tokenSymbol, issue.node_id);
const result = await generatePermitForComments(creator.login, [description], issueCreatorMultiplier, incentives, tokenSymbol, issue.node_id);

if (result.payoutUrl) {
logger.info(`Permit url generated for creator. reward: ${result.payoutUrl}`);
Expand All @@ -123,16 +144,20 @@ const generatePermitForComments = async (
user: string,
comments: string[],
multiplier: number,
commentElementPricing: Record<string, number>,
incentives: Incentives,
tokenSymbol: string,
node_id: string
): Promise<{ comment: string; payoutUrl?: string; amountInETH?: string }> => {
const logger = getLogger();
const commentsByNode = await parseComments(comments, ItemsToExclude);
const rewardValue = calculateRewardValue(commentsByNode, commentElementPricing);
const rewardValue = calculateRewardValue(commentsByNode, incentives);
if (rewardValue === 0) {
logger.info(`No reward for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`);
return { comment: "" };
}
logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`);
const account = await getWalletAddress(user);
const amountInETH = ((rewardValue * multiplier) / 1000).toString();
const amountInETH = ((rewardValue * multiplier) / 1000).toFixed(2);
let comment = "";
if (account) {
const payoutUrl = await generatePermit2Signature(account, amountInETH, node_id);
Expand All @@ -146,17 +171,26 @@ const generatePermitForComments = async (
* @dev Calculates the reward values for a given comments. We'll improve the formula whenever we get the better one.
*
* @param comments - The comments to calculate the reward for
* @param commentElementPricing - The basic price table for reward calculation
* @param incentives - The basic price table for reward calculation
* @returns - The reward value
*/
const calculateRewardValue = (comments: Record<string, string[]>, commentElementPricing: CommentElementPricing): number => {
const calculateRewardValue = (comments: Record<string, string[]>, incentives: Incentives): number => {
let sum = 0;
for (const key of Object.keys(comments)) {
const rewardValue = commentElementPricing[key];
const value = comments[key];
if (key == MarkdownItem.Text || key == MarkdownItem.Paragraph) {
sum += value.length * rewardValue;

// if it's a text node calculate word count and multiply with the reward value
if (key == "#text") {
const wordReward = incentives.comment.totals.word;
if (!wordReward) {
continue;
}
sum += value.map((str) => str.trim().split(" ").length).reduce((totalWords, wordCount) => totalWords + wordCount, 0) * wordReward;
} else {
const rewardValue = incentives.comment.elements[key];
if (!rewardValue) {
continue;
}
sum += rewardValue;
}
}
Expand Down
61 changes: 29 additions & 32 deletions src/helpers/comment.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,41 @@
type MdastNode = {
type: string;
value: string;
children: MdastNode[];
import * as parse5 from "parse5";

type Node = {
nodeName: string;
tagName?: string;
value?: string;
childNodes?: Node[];
};
const cachedResult: Record<string, string[]> = {};
const traverse = (node: MdastNode, itemsToExclude: string[]): Record<string, string[]> => {
if (!cachedResult[node.type]) {
cachedResult[node.type] = [];

const traverse = (result: Record<string, string[]>, node: Node, itemsToExclude: string[]): Record<string, string[]> => {
if (itemsToExclude.includes(node.nodeName)) {
return result;
}

if (!itemsToExclude.includes(node.type)) {
// skip pushing if the node type has been excluded
cachedResult[node.type].push(node.value);
} else if (node.children.length > 0) {
node.children.forEach((child) => traverse(child, itemsToExclude));
if (!result[node.nodeName]) {
result[node.nodeName] = [];
}

return cachedResult;
};
result[node.nodeName].push(node.value?.trim() ?? "");

if (node.childNodes && node.childNodes.length > 0) {
node.childNodes.forEach((child) => traverse(result, child, itemsToExclude));
}

export const parseComments = async (comments: string[], itemsToExclude: string[]): Promise<Record<string, string[]>> => {
const { fromMarkdown } = await import("mdast-util-from-markdown");
const { gfmFromMarkdown } = await import("mdast-util-gfm");
const { gfm } = await import("micromark-extension-gfm");
return result;
};

export const parseComments = (comments: string[], itemsToExclude: string[]): Record<string, string[]> => {
const result: Record<string, string[]> = {};

for (const comment of comments) {
const tree = fromMarkdown(comment, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});

const parsedContent = traverse(tree as MdastNode, itemsToExclude);
for (const key of Object.keys(parsedContent)) {
if (Object.keys(result).includes(key)) {
result[key].push(...parsedContent[key]);
} else {
result[key] = parsedContent[key];
}
}
const fragment = parse5.parseFragment(comment);
traverse(result, fragment as Node, itemsToExclude);
}

// remove empty values
if (result["#text"]) {
result["#text"] = result["#text"].filter((str) => str.length > 0);
}

return result;
Expand Down
Loading