Skip to content

Commit

Permalink
chore: set up basic example of configuration loading from plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
0x4007 committed Feb 17, 2024
1 parent af8fe43 commit f0be88d
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 56 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@
"open-source"
],
"dependencies": {
"@octokit/rest": "^20.0.2",
"@octokit/webhooks": "^12.0.10",
"@octokit/webhooks-types": "^7.3.1",
"@sinclair/typebox": "^0.32.5",
"create-cloudflare": "^2.8.3",
"js-yaml": "^4.1.0",
"octokit": "^3.1.2",
"smee-client": "^2.0.0",
"universal-github-app-jwt": "^2.0.5"
Expand All @@ -44,6 +46,7 @@
"@cspell/dict-node": "^4.0.3",
"@cspell/dict-software-terms": "^3.3.17",
"@cspell/dict-typescript": "^3.1.2",
"@types/js-yaml": "^4.0.9",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"cspell": "^8.3.2",
Expand Down
6 changes: 3 additions & 3 deletions src/github/github-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/core";
import { RequestOptions } from "@octokit/types";
import { paginateRest } from "@octokit/plugin-paginate-rest";
import { legacyRestEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import { retry } from "@octokit/plugin-retry";
import { throttling } from "@octokit/plugin-throttling";
import { createAppAuth } from "@octokit/auth-app";
import { RequestOptions } from "@octokit/types";

const defaultOptions = {
authStrategy: createAppAuth,
Expand Down Expand Up @@ -46,6 +46,6 @@ function requestLogging(octokit: Octokit) {
});
}

export const customOctokit = Octokit.plugin(throttling, retry, paginateRest, legacyRestEndpointMethods, requestLogging).defaults((instanceOptions: object) => {
export const augmentedOctokit = Octokit.plugin(throttling, retry, paginateRest, legacyRestEndpointMethods, requestLogging).defaults((instanceOptions: object) => {
return Object.assign({}, defaultOptions, instanceOptions);
});
6 changes: 3 additions & 3 deletions src/github/github-context.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { customOctokit } from "./github-client";
import { augmentedOctokit } from "./github-client";

export class GitHubContext<T extends WebhookEventName = WebhookEventName> {
public key: WebhookEventName;
public name: WebhookEventName;
public id: string;
public payload: WebhookEvent<T>["payload"];
public octokit: InstanceType<typeof customOctokit>;
public octokit: InstanceType<typeof augmentedOctokit>;

constructor(event: WebhookEvent<T>, octokit: InstanceType<typeof customOctokit>) {
constructor(event: WebhookEvent<T>, octokit: InstanceType<typeof augmentedOctokit>) {
this.name = event.name;
this.id = event.id;
this.payload = event.payload;
Expand Down
15 changes: 4 additions & 11 deletions src/github/github-event-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Webhooks } from "@octokit/webhooks";
import { customOctokit } from "./github-client";
import { augmentedOctokit } from "./github-client";
import { GitHubContext, SimplifiedContext } from "./github-context";

export type Options = {
Expand All @@ -12,7 +12,7 @@ export class GitHubEventHandler {
public webhooks: Webhooks<SimplifiedContext>;
public on: Webhooks<SimplifiedContext>["on"];
public onAny: Webhooks<SimplifiedContext>["onAny"];
public onError: Webhooks<SimplifiedContext>["onError"];
// public onError: Webhooks<SimplifiedContext>["onError"];

private _webhookSecret: string;
private _privateKey: string;
Expand All @@ -30,7 +30,7 @@ export class GitHubEventHandler {
if ("installation" in event.payload) {
installationId = event.payload.installation?.id;
}
const octokit = new customOctokit({
const octokit = new augmentedOctokit({
auth: {
appId: this._appId,
privateKey: this._privateKey,
Expand All @@ -44,13 +44,6 @@ export class GitHubEventHandler {

this.on = this.webhooks.on;
this.onAny = this.webhooks.onAny;
this.onError = this.webhooks.onError;

this.onAny((event) => {
console.log(`Event ${event.name} received (id: ${event.id})`);
});
this.onError((error) => {
console.error(error);
});
// this.onError = this.webhooks.onError;
}
}
62 changes: 53 additions & 9 deletions src/github/handlers/issue-comment/created.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,59 @@
import { GitHubContext } from "../../github-context";
import { UbiquiBotConfig, getUbiquiBotConfig } from "../../ubiquibot-config";
import { generateHelpMenu } from "./help/help";

export const userCommands: IssueCommentCreatedCommand[] = [{ id: "/help", description: "List all available commands.", example: "/help", handler: generateHelpMenu }];
// fetch the ubiquibot-config.yml from the current repository, from the current organization, then merge (priority being the current repository.)
// ubiquibot-config.yml is always meant to live at .github/ubiquibot-config.yml
export async function issueCommentCreated(event: GitHubContext<"issue_comment.created">) {
if (event.payload.comment.user.type === "Bot") {
console.log("Skipping bot comment");
return null;
const configuration = await getUbiquiBotConfig(event);
const command = commentParser(event.payload.comment.body);
if (!command) {
return;
}
const commandHandler = userCommands.find((cmd) => cmd.id === command);
if (!commandHandler) {
return;
} else {
const result = await commandHandler.handler(event, configuration, event.payload.comment.body);
if (typeof result === "string") {
// Extract issue number and repository details from the event payload
const issueNumber = event.payload.issue.number;
const repo = event.payload.repository.name;
const owner = event.payload.repository.owner.login;

await event.octokit.issues.createComment({
owner: event.payload.repository.owner.login,
repo: event.payload.repository.name,
issue_number: event.payload.issue.number,
body: "Hello from the worker!",
});
// Create a new comment on the issue
await event.octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: result,
});
}
return result;
}
}

// Parses the comment body and figure out the command name a user wants
function commentParser(body: string): null | string {
const userCommandIds = userCommands.map((cmd) => cmd.id);
const regex = new RegExp(`^(${userCommandIds.join("|")})\\b`); // Regex pattern to match any command at the beginning of the body
const matches = regex.exec(body);
if (matches) {
const command = matches[0] as string;
if (userCommandIds.includes(command)) {
return command;
}
}

return null;
}

type IssueCommentCreatedCommand = {
id: string;
description: string;
example: string;
handler: IssueCommentCreatedHandler;
};

type IssueCommentCreatedHandler = (context: GitHubContext<"issue_comment.created">, configuration: UbiquiBotConfig, body: string) => Promise<string | null>;
45 changes: 45 additions & 0 deletions src/github/handlers/issue-comment/help/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { GitHubContext } from "../../../github-context";
import { UbiquiBotConfig } from "../../../ubiquibot-config";
import { userCommands } from "../created";

export async function generateHelpMenu(context: GitHubContext<"issue_comment.created">, configuration: UbiquiBotConfig) {
const disabledCommands = configuration.disabledCommands;
const isStartDisabled = configuration.disabledCommands.some((command) => command === "start");
let helpMenu = "### Available Commands\n\n| Command | Description | Example |\n| --- | --- | --- |\n";
// const commands = userCommands(configuration.miscellaneous.registerWalletWithVerification);

userCommands
.filter((command) => !disabledCommands.includes(command.id))
.map(
(command) =>
(helpMenu += `| \`${command.id}\` | ${breakSentences(command.description) || ""} | ${(command.example && breakLongString(command.example)) || ""} |\n`) // add to help menu
);

if (isStartDisabled) {
helpMenu += "\n\n**To assign yourself to an issue, please open a draft pull request that is linked to it.**";
}
return helpMenu;
}

function breakLongString(str: string, maxLen = 24) {
const newStr = [] as string[];
let spaceIndex = str.indexOf(" ", maxLen); // Find the first space after maxLen

while (str.length > maxLen && spaceIndex !== -1) {
newStr.push(str.slice(0, spaceIndex));
str = str.slice(spaceIndex + 1);
spaceIndex = str.indexOf(" ", maxLen);
}

newStr.push(str); // Push the remaining part of the string

return newStr.join("<br>");
}

function breakSentences(str: string) {
const sentences = str.endsWith(".") ? str.slice(0, -1).split(". ") : str.split(". ");
if (sentences.length <= 1) {
return str;
}
return sentences.join(".<br><br>");
}
76 changes: 76 additions & 0 deletions src/github/ubiquibot-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import yaml from "js-yaml";
import { GitHubContext } from "./github-context";

export type UbiquiBotConfig = {
keys: {
evmPrivateEncrypted: string;
openAi: string;
};
features: {
assistivePricing: boolean;
publicAccessControl: unknown;
};
payments: {
evmNetworkId: 1 | 100;
basePriceMultiplier: number;
issueCreatorMultiplier: number;
maxPermitPrice: number;
};
timers: {
reviewDelayTolerance: string;
taskStaleTimeoutDuration: string;
taskFollowUpDuration: string;
taskDisqualifyDuration: string;
};
miscellaneous: {
promotionComment: string;
maxConcurrentTasks: number;
registerWalletWithVerification: boolean;
};
disabledCommands: string[];
incentives: { comment: unknown };
labels: { time: string[]; priority: string[] };
};

export async function getUbiquiBotConfig(event: GitHubContext<"issue_comment.created">): Promise<UbiquiBotConfig> {
const responses = {
repositoryConfig: null as UbiquiBotConfig | null,
organizationConfig: null as UbiquiBotConfig | null,
};

try {
responses.repositoryConfig = await fetchConfig(event, event.payload.repository.name);
} catch (error) {
console.error(error);
}

try {
responses.organizationConfig = await fetchConfig(event, `.ubiquibot-config`);
} catch (error) {
console.error(error);
}

// Merge the two configs
return {
...(responses.organizationConfig || {}),
...(responses.repositoryConfig || {}),
} as UbiquiBotConfig;
}

async function fetchConfig(event: GitHubContext<"issue_comment.created">, repo: string): Promise<UbiquiBotConfig | null> {
const response = await event.octokit.rest.repos.getContent({
owner: event.payload.repository.owner.login,
repo,
path: ".github/ubiquibot-config.yml",
});

// Check if the response data is a file and has a content property
if ("content" in response.data && typeof response.data.content === "string") {
// Convert the content from Base64 to string and parse the YAML content
const content = atob(response.data.content).toString();
return yaml.load(content) as UbiquiBotConfig;
} else {
return null;
// throw new Error("Expected file content, but got something else");
}
}
36 changes: 10 additions & 26 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,21 @@ import { bindHandlers } from "./github/handlers";
import { Env, envSchema } from "./github/types/env";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
validateEnv(env);
const eventName = getEventName(request);
const signatureSHA256 = getSignature(request);
const id = getId(request);
const eventHandler = new GitHubEventHandler({ webhookSecret: env.WEBHOOK_SECRET, appId: env.APP_ID, privateKey: env.PRIVATE_KEY });
bindHandlers(eventHandler);
await eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSHA256 });
return new Response("ok\n", { status: 200, headers: { "content-type": "text/plain" } });
} catch (error) {
return handleUncaughtError(error);
}
validateEnv(env);
const eventName = getEventName(request);
const signatureSHA256 = getSignature(request);
const id = getId(request);
const eventHandler = new GitHubEventHandler({ webhookSecret: env.WEBHOOK_SECRET, appId: env.APP_ID, privateKey: env.PRIVATE_KEY });
bindHandlers(eventHandler);
await eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSHA256 });
return new Response("ok\n", { status: 200, headers: { "content-type": "text/plain" } });
},
};
function handleUncaughtError(error: unknown) {
console.error(error);
let status = 500;
let errorMessage = "An uncaught error occurred";
if (error instanceof AggregateError) {
const err = error.errors[0];
errorMessage = err.message ? `${err.name}: ${err.message}` : `Error: ${errorMessage}`;
status = typeof err.status !== "undefined" ? err.status : 500;
}
return new Response(JSON.stringify({ error: errorMessage }), { status: status, headers: { "content-type": "application/json" } });
}

function validateEnv(env: Env): void {
if (!Value.Check(envSchema, env)) {
const errors = [...Value.Errors(envSchema, env)];
console.error("Invalid environment variables", errors);
throw new Error("Invalid environment variables");
// console.error("Invalid environment variables", errors);
throw new Error(`Invalid environment variables: ${errors}`);
}
}
function getEventName(request: Request): WebhookEventName {
Expand Down
8 changes: 4 additions & 4 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */

/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["ESNext"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
Expand All @@ -24,7 +24,7 @@
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */

/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
"module": "ESNext" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
Expand All @@ -45,7 +45,7 @@
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
Expand Down

0 comments on commit f0be88d

Please sign in to comment.