Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test Files #208

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
342 changes: 342 additions & 0 deletions github-comment-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
import { Value } from "@sinclair/typebox/value";
import Decimal from "decimal.js";
import * as fs from "fs";
import { JSDOM } from "jsdom";
import { stringify } from "yaml";
import { CommentAssociation, CommentKind } from "../configuration/comment-types";
import configuration from "../configuration/config-reader";
import { GithubCommentConfiguration, githubCommentConfigurationType } from "../configuration/github-comment-config";
import { getGithubWorkflowRunUrl } from "../helpers/github";
import logger from "../helpers/logger";
import { createStructuredMetadata } from "../helpers/metadata";
import { removeKeyFromObject, typeReplacer } from "../helpers/result-replacer";
import { getErc20TokenSymbol } from "../helpers/web3";
import { IssueActivity } from "../issue-activity";
import { getOctokitInstance } from "../octokit";
import program from "./command-line";
import { GithubCommentScore, Module, Result } from "./processor";
import { GITHUB_COMMENT_PAYLOAD_LIMIT } from "../helpers/constants";
import { generateFeeString } from "../helpers/fee";

interface SortedTasks {
issues: { specification: GithubCommentScore | null; comments: GithubCommentScore[] };
reviews: GithubCommentScore[];
}

/**
* Posts a GitHub comment according to the given results.
*/
export class GithubCommentModule implements Module {
private readonly _configuration: GithubCommentConfiguration | null = configuration.incentives.githubComment;
private readonly _debugFilePath = "./output.html";
/**
* COMMENT_ID can be set in the environment to reference the id of the last comment created during this workflow.
* See also compute.yml to understand how it is set.
*/
private _lastCommentId: number | null = process.env.COMMENT_ID ? Number(process.env.COMMENT_ID) : null;

/**
* Ensures that a string containing special characters get HTML encoded.
*/
_encodeHTML(str: string) {
const dom = new JSDOM();
const div = dom.window.document.createElement("div");
div.appendChild(dom.window.document.createTextNode(str));
return div.innerHTML;
}

async getBodyContent(result: Result, stripContent = false): Promise<string> {
const keysToRemove: string[] = [];
const bodyArray: (string | undefined)[] = [];

if (stripContent) {
logger.info("Stripping content due to excessive length.");
bodyArray.push("> [!NOTE]\n");
bodyArray.push("> This output has been truncated due to the comment length limit.\n\n");
for (const [key, value] of Object.entries(result)) {
// Remove result with 0 total from being displayed
if (result[key].total <= 0) continue;
result[key].evaluationCommentHtml = await this._generateHtml(key, value, true);
bodyArray.push(result[key].evaluationCommentHtml);
}
bodyArray.push(
createStructuredMetadata("GithubCommentModule", {
workflowUrl: this._encodeHTML(getGithubWorkflowRunUrl()),
})
);
return bodyArray.join("");
}

for (const [key, value] of Object.entries(result)) {
// Remove result with 0 total from being displayed
if (result[key].total <= 0) {
keysToRemove.push(key);
continue;
}
result[key].evaluationCommentHtml = await this._generateHtml(key, value);
bodyArray.push(result[key].evaluationCommentHtml);
}
// Remove evaluationCommentHtml because it is superfluous
let metadataResult = removeKeyFromObject(result, "evaluationCommentHtml");
// Remove user with 0 result from metadataResult
for (const key of keysToRemove) {
metadataResult = removeKeyFromObject(metadataResult, key);
}
// Add the workflow run url and the metadata in the GitHub's comment
bodyArray.push(
createStructuredMetadata("GithubCommentModule", {
workflowUrl: this._encodeHTML(getGithubWorkflowRunUrl()),
output: JSON.parse(JSON.stringify(metadataResult, typeReplacer, 2)),
})
);

const body = bodyArray.join("");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask explain this pr?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request (#208) introduces the GithubCommentModule, a new TypeScript class designed to automate the posting of GitHub comments based on specific results. Key functionalities include:

  1. Configuration Management: The module loads its configuration from GithubCommentConfiguration, allowing customization of comment behaviors through the ubiquibot-config.
  2. HTML Encoding: Utilizes jsdom to safely encode special characters in comment strings, ensuring that comments render correctly on GitHub.
  3. Content Generation: The getBodyContent method constructs the comment body, supporting both full and truncated content based on length constraints. It intelligently includes or excludes specific data points to adhere to GitHub's comment size limits.
  4. Metadata Integration: Adds structured metadata to comments using helper functions, embedding information like workflow URLs to provide context and traceability.
  5. GitHub API Interaction: Leverages the @octokit/rest package to interact with GitHub APIs, enabling actions like posting comments and handling workflow events.
  6. Logging and Error Handling: Incorporates custom logging via @ubiquity-dao/ubiquibot-logger to track operations and potential issues, enhancing maintainability and debugging capabilities.
  7. Environment Management: Manages environment variables securely with dotenv, ensuring sensitive information like COMMENT_ID is handled appropriately.

Overall, this module enhances the repository's ability to programmatically manage GitHub comments, streamlining processes like notifying contributors, reporting statuses, and integrating AI-driven functionalities.

// We check this length because GitHub has a comment length limit
if (body.length > GITHUB_COMMENT_PAYLOAD_LIMIT) {
// First, we try to diminish the metadata content to only contain the URL
bodyArray[bodyArray.length - 1] = `${createStructuredMetadata("GithubCommentModule", {
workflowUrl: this._encodeHTML(getGithubWorkflowRunUrl()),
})}`;
const newBody = bodyArray.join("");
if (newBody.length <= GITHUB_COMMENT_PAYLOAD_LIMIT) {
return newBody;
} else {
return this.getBodyContent(result, true);
}
}
return body;
}

async transform(data: Readonly<IssueActivity>, result: Result): Promise<Result> {
const body = await this.getBodyContent(result);
if (this._configuration?.debug) {
fs.writeFileSync(this._debugFilePath, body);
}
if (this._configuration?.post) {
try {
await this.postComment(body);
} catch (e) {
logger.error(`Could not post GitHub comment: ${e}`);
}
}
return result;
}

get enabled(): boolean {
if (!Value.Check(githubCommentConfigurationType, this._configuration)) {
logger.error("Invalid / missing configuration detected for GithubContentModule, disabling.");
return false;
}
return true;
}

async postComment(body: string, updateLastComment = true) {
const { eventPayload } = program;
if (!this._configuration?.post) {
logger.debug("Won't post a comment since posting is disabled.", { body });
return;
}
if (updateLastComment && this._lastCommentId !== null) {
await getOctokitInstance().issues.updateComment({
body,
repo: eventPayload.repository.name,
owner: eventPayload.repository.owner.login,
issue_number: eventPayload.issue.number,
comment_id: this._lastCommentId,
});
} else {
const comment = await getOctokitInstance().issues.createComment({
body,
repo: eventPayload.repository.name,
owner: eventPayload.repository.owner.login,
issue_number: eventPayload.issue.number,
});
this._lastCommentId = comment.data.id;
}
}

_createContributionRows(result: Result[0], sortedTasks: SortedTasks | undefined) {
const content: string[] = [];

if (result.task?.reward) {
content.push(buildContributionRow("Issue", "Task", result.task.multiplier, result.task.reward));
}

if (!sortedTasks) {
return content.join("");
}

function buildContributionRow(
view: string,
contribution: string,
count: number,
reward: number | Decimal | undefined
) {
const fee = generateFeeString(reward, result.feeRate);
return `
<tr>
<td>${view}</td>
<td>${contribution}</td>
<td>${count}</td>
<td>${reward || "-"}</td>
<td>${fee}</td>
</tr>`;
}

if (sortedTasks.issues.specification) {
content.push(buildContributionRow("Issue", "Specification", 1, sortedTasks.issues.specification.score?.reward));
}
if (sortedTasks.issues.comments.length) {
content.push(
buildContributionRow(
"Issue",
"Comment",
sortedTasks.issues.comments.length,
sortedTasks.issues.comments.reduce((acc, curr) => acc.add(curr.score?.reward ?? 0), new Decimal(0))
)
);
}
if (sortedTasks.reviews.length) {
content.push(
buildContributionRow(
"Review",
"Comment",
sortedTasks.reviews.length,
sortedTasks.reviews.reduce((acc, curr) => acc.add(curr.score?.reward ?? 0), new Decimal(0))
)
);
}
return content.join("");
}

_createIncentiveRows(sortedTasks: SortedTasks | undefined, feeRate: number | Decimal | undefined = undefined) {
const content: string[] = [];

if (!sortedTasks) {
return content.join("");
}

function buildIncentiveRow(commentScore: GithubCommentScore) {
// Properly escape carriage returns for HTML rendering
const formatting = stringify({
content: commentScore.score?.formatting,
regex: commentScore.score?.words,
}).replace(/[\n\r]/g, "&#13;");
// Makes sure any HTML injected in the templated is not rendered itself
const sanitizedContent = commentScore.content
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("`", "&#96;")
.replace(/([\s\S]{64}).[\s\S]+/, "$1&hellip;");
const fee = generateFeeString(commentScore.score?.reward, feeRate);
return `
<tr>
<td>
<h6>
<a href="${commentScore.url}" target="_blank" rel="noopener">${sanitizedContent}</a>
</h6>
</td>
<td>
<details>
<summary>
${new Decimal(commentScore.score?.words?.result ?? 0).add(new Decimal(commentScore.score?.formatting?.result ?? 0))}
</summary>
<pre>${formatting}</pre>
</details>
</td>
<td>${commentScore.score?.relevance ?? "-"}</td>
<td>${commentScore.score?.reward ?? "-"}</td>
<td>${fee}</td>
</tr>`;
}

if (sortedTasks.issues.specification) {
content.push(buildIncentiveRow(sortedTasks.issues.specification));
}
for (const issueComment of sortedTasks.issues.comments) {
content.push(buildIncentiveRow(issueComment));
}
for (const reviewComment of sortedTasks.reviews) {
content.push(buildIncentiveRow(reviewComment));
}
return content.join("");
}

async _generateHtml(username: string, result: Result[0], stripComments = false) {
const sortedTasks = result.comments?.reduce<SortedTasks>(
(acc, curr) => {
if (curr.type & CommentKind.ISSUE) {
if (curr.type & CommentAssociation.SPECIFICATION) {
acc.issues.specification = curr;
} else {
acc.issues.comments.push(curr);
}
} else if (curr.type & CommentKind.PULL) {
acc.reviews.push(curr);
}
return acc;
},
{ issues: { specification: null, comments: [] }, reviews: [] }
);

const tokenSymbol = await getErc20TokenSymbol(configuration.evmNetworkId, configuration.erc20RewardToken);

return `
<details>
<summary>
<b>
<h3>
&nbsp;
<a href="${result.permitUrl}" target="_blank" rel="noopener">
[ ${result.total} ${tokenSymbol} ]
</a>
&nbsp;
</h3>
<h6>
@${username}
</h6>
</b>
</summary>
${result.feeRate !== undefined ? `<h6>⚠️ ${result.feeRate}% fee rate has been applied. Consider using the&nbsp;<a href="https://dao.ubq.fi/dollar" target="_blank" rel="noopener">Ubiquity Dollar</a>&nbsp;for no fees.</h6>` : ""}
<h6>Contributions Overview</h6>
<table>
<thead>
<tr>
<th>View</th>
<th>Contribution</th>
<th>Count</th>
<th>Reward</th>
<th>Fee</th>
</tr>
</thead>
<tbody>
${this._createContributionRows(result, sortedTasks)}
</tbody>
</table>
${
!stripComments
? `<h6>Conversation Incentives</h6>
<table>
<thead>
<tr>
<th>Comment</th>
<th>Formatting</th>
<th>Relevance</th>
<th>Reward</th>
<th>Fee</th>
</tr>
</thead>
<tbody>
${this._createIncentiveRows(sortedTasks, result.feeRate)}
</tbody>
</table>`
: ""
}
</details>
`
.replace(/(\r?\n|\r)\s*/g, "") // Remove newlines and leading spaces/tabs after them
.replace(/\s*(<\/?[^>]+>)\s*/g, "$1") // Trim spaces around HTML tags
.trim();
}
}
291 changes: 291 additions & 0 deletions index.js

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions resr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Important Details about the project:

1. We would use C11 for general language support
2. If someone is facing issues with CMAKE then should try switching their LLVM compilers especially on Mac Silicon
3. I think we should start using Qt6 for UI, 4.5 is not good
4. GDCOM has not been added to vsproj fix that
1 change: 1 addition & 0 deletions static/dist/test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
randomtest