diff --git a/.github/ubiquibot-config.yml b/.github/ubiquibot-config.yml index 99e33594b..97d5a5cce 100644 --- a/.github/ubiquibot-config.yml +++ b/.github/ubiquibot-config.yml @@ -1,4 +1,4 @@ -priceMultiplier: 1.5 +basePriceMultiplier: 1.5 newContributorGreeting: enabled: true header: "Thank you for contributing to UbiquiBot! Please be sure to set your wallet address before completing your first task so that the automatic payout upon task completion will work for you." diff --git a/package.json b/package.json index e39323050..a8b41f6fa 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@octokit/rest": "^20.0.2", "@openzeppelin/contracts": "^5.0.0", "@probot/adapter-github-actions": "^3.1.3", - "@sinclair/typebox": "^0.31.5", + "@sinclair/typebox": "^0.31.22", "@supabase/supabase-js": "^2.4.0", "@types/ms": "^0.7.31", "@types/parse5": "^7.0.0", @@ -42,7 +42,7 @@ "@typescript-eslint/parser": "^5.59.11", "@uniswap/permit2-sdk": "^1.2.0", "@vercel/ncc": "^0.34.0", - "ajv": "^8.11.2", + "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "axios": "^1.3.2", "cspell": "^7.0.0", @@ -63,7 +63,8 @@ "prettier": "^2.7.1", "probot": "^12.2.4", "tsx": "^3.12.7", - "yaml": "^2.2.2" + "yaml": "^2.2.2", + "zod": "^3.22.4" }, "devDependencies": { "@types/dotenv": "^8.2.0", diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 6242fdfa8..bbf63c2e1 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,5 +1,5 @@ import { createClient } from "@supabase/supabase-js"; -import { Context } from "../types"; +import { Context as ProbotContext } from "probot"; import { Access } from "./supabase/helpers/tables/access"; import { Label } from "./supabase/helpers/tables/label"; import { Locations } from "./supabase/helpers/tables/locations"; @@ -9,25 +9,22 @@ import { Super } from "./supabase/helpers/tables/super"; import { User } from "./supabase/helpers/tables/user"; import { Wallet } from "./supabase/helpers/tables/wallet"; import { Database } from "./supabase/types"; +import { env } from "../bindings/env"; -export function createAdapters(context: Context) { - const client = generateSupabase(context.config.supabase.url, context.config.supabase.key); +const supabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_KEY, { auth: { persistSession: false } }); + +export function createAdapters(context: ProbotContext) { return { supabase: { - access: new Access(client, context), - wallet: new Wallet(client, context), - user: new User(client, context), - debit: new Settlement(client, context), - settlement: new Settlement(client, context), - label: new Label(client, context), - logs: new Logs(client, context), - locations: new Locations(client, context), - super: new Super(client, context), + access: new Access(supabaseClient, context), + wallet: new Wallet(supabaseClient, context), + user: new User(supabaseClient, context), + debit: new Settlement(supabaseClient, context), + settlement: new Settlement(supabaseClient, context), + label: new Label(supabaseClient, context), + logs: new Logs(supabaseClient, context, env.LOG_ENVIRONMENT, env.LOG_RETRY_LIMIT, env.LOG_LEVEL), + locations: new Locations(supabaseClient, context), + super: new Super(supabaseClient, context), }, }; } - -function generateSupabase(url: string | null, key: string | null) { - if (!url || !key) throw new Error("Supabase url or key is not defined"); - return createClient(url, key, { auth: { persistSession: false } }); -} diff --git a/src/adapters/supabase/helpers/tables/access.ts b/src/adapters/supabase/helpers/tables/access.ts index b64b93340..dc1ca7a98 100644 --- a/src/adapters/supabase/helpers/tables/access.ts +++ b/src/adapters/supabase/helpers/tables/access.ts @@ -4,7 +4,7 @@ import { Database } from "../../types/database"; import { GitHubNode } from "../client"; import { Super } from "./super"; import { UserRow } from "./user"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; type AccessRow = Database["public"]["Tables"]["access"]["Row"]; type AccessInsert = Database["public"]["Tables"]["access"]["Insert"]; type UserWithAccess = (UserRow & { access: AccessRow | null })[]; @@ -19,7 +19,7 @@ type _Access = { }; export class Access extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } diff --git a/src/adapters/supabase/helpers/tables/label.ts b/src/adapters/supabase/helpers/tables/label.ts index bf523c9b4..4f723f650 100644 --- a/src/adapters/supabase/helpers/tables/label.ts +++ b/src/adapters/supabase/helpers/tables/label.ts @@ -2,13 +2,13 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Repository } from "../../../../types/payload"; import { Database } from "../../types"; import { Super } from "./super"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; import Runtime from "../../../../bindings/bot-runtime"; type LabelRow = Database["public"]["Tables"]["labels"]["Row"]; export class Label extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } diff --git a/src/adapters/supabase/helpers/tables/locations.ts b/src/adapters/supabase/helpers/tables/locations.ts index bd09507df..7a372d653 100644 --- a/src/adapters/supabase/helpers/tables/locations.ts +++ b/src/adapters/supabase/helpers/tables/locations.ts @@ -1,7 +1,7 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Super } from "./super"; import { Database } from "../../types/database"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; // currently trying to save all of the location metadata of the event. // seems that focusing on the IssueComments will provide the most value @@ -17,7 +17,7 @@ export class Locations extends Super { node_id: string | undefined; node_type: string | undefined; - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } @@ -62,7 +62,7 @@ export class Locations extends Super { } `; - this.locationResponse = (await this.context.event.octokit.graphql(graphQlQuery)) as LocationResponse; + this.locationResponse = (await this.context.octokit.graphql(graphQlQuery)) as LocationResponse; console.trace(this.locationResponse); this.user_id = this.locationResponse.data.node.author.id; diff --git a/src/adapters/supabase/helpers/tables/logs.ts b/src/adapters/supabase/helpers/tables/logs.ts index 3203b6ff4..826fb6057 100644 --- a/src/adapters/supabase/helpers/tables/logs.ts +++ b/src/adapters/supabase/helpers/tables/logs.ts @@ -8,7 +8,8 @@ import { Database } from "../../types"; import { prettyLogs } from "../pretty-logs"; import { Super } from "./super"; import { execSync } from "child_process"; -import { Context } from "../../../../types"; +import { LogLevel } from "../../../../types/logs"; +import { Context as ProbotContext } from "probot"; import Runtime from "../../../../bindings/bot-runtime"; type LogFunction = (message: string, metadata?: any) => void; @@ -222,13 +223,18 @@ export class Logs extends Super { }); } - constructor(supabase: SupabaseClient, context: Context) { + constructor( + supabase: SupabaseClient, + context: ProbotContext, + environment: string, + retryLimit: number, + logLevel: LogLevel + ) { super(supabase, context); - const logConfig = this.context.config.log; - this.environment = logConfig.logEnvironment; - this.retryLimit = logConfig.retryLimit; - this.maxLevel = this._getNumericLevel(logConfig.level ?? LogLevel.DEBUG); + this.environment = environment; + this.retryLimit = retryLimit; + this.maxLevel = this._getNumericLevel(logLevel); } private async _sendLogsToSupabase(log: LogInsert) { @@ -363,11 +369,11 @@ export class Logs extends Super { } private _postComment(message: string) { - this.context.event.octokit.issues + this.context.octokit.issues .createComment({ - owner: this.context.event.issue().owner, - repo: this.context.event.issue().repo, - issue_number: this.context.event.issue().issue_number, + owner: this.context.issue().owner, + repo: this.context.issue().repo, + issue_number: this.context.issue().issue_number, body: message, }) // .then((x) => console.trace(x)) @@ -411,13 +417,3 @@ export class Logs extends Super { return obj; } } - -export enum LogLevel { - ERROR = "error", - WARN = "warn", - INFO = "info", - HTTP = "http", - VERBOSE = "verbose", - DEBUG = "debug", - SILLY = "silly", -} diff --git a/src/adapters/supabase/helpers/tables/settlement.ts b/src/adapters/supabase/helpers/tables/settlement.ts index cfe26b74d..e97dbeebe 100644 --- a/src/adapters/supabase/helpers/tables/settlement.ts +++ b/src/adapters/supabase/helpers/tables/settlement.ts @@ -3,7 +3,7 @@ import Decimal from "decimal.js"; import { Comment, Payload } from "../../../../types/payload"; import { Database } from "../../types/database"; import { Super } from "./super"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; type DebitInsert = Database["public"]["Tables"]["debits"]["Insert"]; type CreditInsert = Database["public"]["Tables"]["credits"]["Insert"]; @@ -26,7 +26,7 @@ type AddCreditWithPermit = { }; export class Settlement extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } diff --git a/src/adapters/supabase/helpers/tables/super.ts b/src/adapters/supabase/helpers/tables/super.ts index 409f02ed6..175620561 100644 --- a/src/adapters/supabase/helpers/tables/super.ts +++ b/src/adapters/supabase/helpers/tables/super.ts @@ -1,13 +1,13 @@ import { SupabaseClient } from "@supabase/supabase-js"; import Runtime from "../../../../bindings/bot-runtime"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; export class Super { protected supabase: SupabaseClient; protected runtime: Runtime; // convenience accessor - protected context: Context; + protected context: ProbotContext; - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { this.supabase = supabase; this.runtime = Runtime.getState(); this.context = context; diff --git a/src/adapters/supabase/helpers/tables/user.ts b/src/adapters/supabase/helpers/tables/user.ts index 7631d0862..857348a1c 100644 --- a/src/adapters/supabase/helpers/tables/user.ts +++ b/src/adapters/supabase/helpers/tables/user.ts @@ -1,16 +1,16 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Database } from "../../types/database"; import { Super } from "./super"; -import { Context } from "../../../../types"; +import { Context as ProbotContext } from "probot"; export type UserRow = Database["public"]["Tables"]["users"]["Row"]; export class User extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } public async getUserId(username: string): Promise { - const octokit = this.context.event.octokit; + const octokit = this.context.octokit; const { data } = await octokit.rest.users.getByUsername({ username }); return data.id; } diff --git a/src/adapters/supabase/helpers/tables/wallet.test.ts b/src/adapters/supabase/helpers/tables/wallet.test.ts index 2464e9ed9..72f3bbdc8 100644 --- a/src/adapters/supabase/helpers/tables/wallet.test.ts +++ b/src/adapters/supabase/helpers/tables/wallet.test.ts @@ -3,8 +3,7 @@ dotenv.config(); import { Context as ProbotContext } from "probot"; import { createAdapters } from "../../.."; -import { loadConfig } from "../../../../bindings/config"; -import { Context, User } from "../../../../types"; +import { User } from "../../../../types"; const SUPABASE_URL = process.env.SUPABASE_URL; if (!SUPABASE_URL) throw new Error("SUPABASE_URL is not defined"); const SUPABASE_KEY = process.env.SUPABASE_KEY; @@ -13,9 +12,7 @@ if (!SUPABASE_KEY) throw new Error("SUPABASE_KEY is not defined"); const mockContext = { supabase: { url: SUPABASE_URL, key: SUPABASE_KEY } } as unknown as ProbotContext; async function getWalletAddressAndUrlTest(eventContext: ProbotContext) { - const botConfig = await loadConfig(eventContext); - const context: Context = { event: eventContext, config: botConfig }; - const { wallet } = createAdapters(context).supabase; + const { wallet } = createAdapters(eventContext).supabase; const userId = 4975670 as User["id"]; const results = [] as unknown[]; try { diff --git a/src/adapters/supabase/helpers/tables/wallet.ts b/src/adapters/supabase/helpers/tables/wallet.ts index 2ac4ba512..871eb4acd 100644 --- a/src/adapters/supabase/helpers/tables/wallet.ts +++ b/src/adapters/supabase/helpers/tables/wallet.ts @@ -1,7 +1,7 @@ import { PostgrestError, SupabaseClient } from "@supabase/supabase-js"; -import { Context as ProbotContext } from "probot/lib/context"; +import { Context as ProbotContext } from "probot"; import Runtime from "../../../../bindings/bot-runtime"; -import { Context, User } from "../../../../types"; +import { User } from "../../../../types"; import { Database } from "../../types/database"; import { Super } from "./super"; import { UserRow } from "./user"; @@ -16,7 +16,7 @@ type IssueCommentPayload = | ProbotContext<"issue_comment.edited">["payload"]; export class Wallet extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: ProbotContext) { super(supabase, context); } @@ -26,7 +26,7 @@ export class Wallet extends Super { } public async upsertWalletAddress(address: string) { - const payload = this.context.event.payload as + const payload = this.context.payload as | ProbotContext<"issue_comment.created">["payload"] | ProbotContext<"issue_comment.edited">["payload"]; diff --git a/src/bindings/config.ts b/src/bindings/config.ts index 8e69c3fb7..de5302b63 100644 --- a/src/bindings/config.ts +++ b/src/bindings/config.ts @@ -1,89 +1,60 @@ -import ms from "ms"; -import { BotConfig, BotConfigSchema } from "../types"; -import { getPayoutConfigByNetworkId } from "../helpers"; -import { ajv } from "../utils"; -import { Context } from "probot"; -import { getConfig } from "../utils/get-config"; -import { LogLevel } from "../adapters/supabase/helpers/tables/logs"; -import Runtime from "./bot-runtime"; -export async function loadConfig(context: Context): Promise { - const { - assistivePricing, - priceMultiplier, - commandSettings, - defaultLabels, - evmNetworkId, - incentives, - issueCreatorMultiplier, - maxConcurrentTasks, - openAIKey, - openAITokenLimit, - maxPermitPrice, - priorityLabels, - keys, - promotionComment, - publicAccessControl, - registerWalletWithVerification, - staleTaskTime, - timeLabels, - newContributorGreeting, - reviewDelayTolerance, - permitBaseUrl, - taskFollowUpDuration, - taskDisqualifyDuration, - } = await getConfig(context); - const runtime = Runtime.getState(); - if (!keys.private) { - console.trace("X25519_PRIVATE_KEY not defined"); - } - const { rpc, paymentToken } = getPayoutConfigByNetworkId(evmNetworkId); - const botConfig: BotConfig = { - log: { - logEnvironment: process.env.LOG_ENVIRONMENT || "production", - level: (process.env.LOG_LEVEL as LogLevel) || LogLevel.SILLY, - retryLimit: Number(process.env.LOG_RETRY) || 0, - }, - price: { priceMultiplier, issueCreatorMultiplier, timeLabels, priorityLabels, incentives, defaultLabels }, - comments: { promotionComment: promotionComment }, - payout: { - evmNetworkId: evmNetworkId, - rpc: rpc, - privateKey: keys.private, - publicKey: keys.public, - paymentToken: paymentToken, - permitBaseUrl: permitBaseUrl, - }, - unassign: { - reviewDelayTolerance: ms(reviewDelayTolerance), - taskFollowUpDuration: ms(taskFollowUpDuration), - taskDisqualifyDuration: ms(taskDisqualifyDuration), - }, - supabase: { url: process.env.SUPABASE_URL ?? null, key: process.env.SUPABASE_KEY ?? null }, - mode: { maxPermitPrice, assistivePricing }, - command: commandSettings, - assign: { maxConcurrentTasks: maxConcurrentTasks, staleTaskTime: ms(staleTaskTime) }, - sodium: { privateKey: keys.private, publicKey: keys.public }, - wallet: { registerWalletWithVerification: registerWalletWithVerification }, - ask: { apiKey: process.env.OPENAI_API_KEY || openAIKey, tokenLimit: openAITokenLimit || 0 }, - publicAccessControl: publicAccessControl, - newContributorGreeting: newContributorGreeting, - }; - if (botConfig.payout.privateKey == null) { - botConfig.mode.maxPermitPrice = 0; - } - const validate = ajv.compile(BotConfigSchema); - const valid = validate(botConfig); - if (!valid) { - throw new Error(JSON.stringify(validate.errors, null, 2)); - } - if (botConfig.unassign.taskFollowUpDuration < 0 || botConfig.unassign.taskDisqualifyDuration < 0) { - throw runtime.logger.error( - "Invalid time interval, taskFollowUpDuration or taskDisqualifyDuration cannot be negative", - { - taskFollowUpDuration: botConfig.unassign.taskFollowUpDuration, - taskDisqualifyDuration: botConfig.unassign.taskDisqualifyDuration, - } - ); - } - return botConfig; +import { Context as ProbotContext } from "probot"; +import { generateConfiguration } from "../utils/generate-configuration"; +import { BotConfig } from "../types/configuration-types"; + +export async function loadConfiguration(context: ProbotContext): Promise { + // const runtime = Runtime.getState(); + const configuration = await generateConfiguration(context); + console.trace({ configuration }); + return configuration; + + // const { rpc, paymentToken } = getPayoutConfigByNetworkId(evmNetworkId); + // const botConfig: AllConfigurationTypes = { + // log: { + // logEnvironment: process.env.LOG_ENVIRONMENT || "production", + // level: (process.env.LOG_LEVEL as LogLevel) || LogLevel.SILLY, + // retryLimit: Number(process.env.LOG_RETRY) || 0, + // }, + // price: { basePriceMultiplier, issueCreatorMultiplier, timeLabels, priorityLabels, incentives, defaultLabels }, + // comments: { promotionComment: promotionComment }, + // payout: { + // evmNetworkId: evmNetworkId, + // rpc: rpc, + // privateKey: keys.private, + // publicKey: keys.public, + // paymentToken: paymentToken, + // }, + // unassign: { + // reviewDelayTolerance: reviewDelayTolerance, + // taskFollowUpDuration: taskFollowUpDuration, + // taskDisqualifyDuration: taskDisqualifyDuration, + // }, + // supabase: { url: process.env.SUPABASE_URL || null, key: process.env.SUPABASE_KEY || null }, + // mode: { maxPermitPrice, assistivePricing }, + // command: commandSettings, + // assign: { maxConcurrentTasks: maxConcurrentTasks, taskStaleTimeoutDuration: ms(taskStaleTimeoutDuration) }, + // sodium: { privateKey: keys.private, publicKey: keys.public }, + // wallet: { registerWalletWithVerification: registerWalletWithVerification }, + // ask: { apiKey: process.env.OPENAI_API_KEY || openAIKey }, + // publicAccessControl: publicAccessControl, + // newContributorGreeting: newContributorGreeting, + // }; + // if (botConfig.payout.privateKey == null) { + // botConfig.mode.maxPermitPrice = 0; + // } + // const validate = ajv.compile(BotConfigSchema); + // const valid = validate(botConfig); + // if (!valid) { + // throw new Error(JSON.stringify(validate.errors, null, 2)); + // } + // if (botConfig.unassign.taskFollowUpDuration < 0 || botConfig.unassign.taskDisqualifyDuration < 0) { + // throw runtime.logger.error( + // "Invalid time interval, taskFollowUpDuration or taskDisqualifyDuration cannot be negative", + // { + // taskFollowUpDuration: botConfig.unassign.taskFollowUpDuration, + // taskDisqualifyDuration: botConfig.unassign.taskDisqualifyDuration, + // } + // ); + // } + // return botConfig; } diff --git a/src/bindings/env.ts b/src/bindings/env.ts new file mode 100644 index 000000000..600cc604f --- /dev/null +++ b/src/bindings/env.ts @@ -0,0 +1,10 @@ +import { EnvConfig, validateEnvConfig } from "../types/configuration-types"; +import dotenv from "dotenv"; +dotenv.config(); + +export const env = { ...process.env } as unknown as EnvConfig; + +const valid = validateEnvConfig(env); +if (!valid) { + throw new Error("Invalid env configuration: " + JSON.stringify(validateEnvConfig.errors, null, 2)); +} diff --git a/src/bindings/event.ts b/src/bindings/event.ts index e77f18011..b7a5b7a72 100644 --- a/src/bindings/event.ts +++ b/src/bindings/event.ts @@ -5,19 +5,16 @@ import { LogMessage } from "../adapters/supabase/helpers/tables/logs"; import { processors, wildcardProcessors } from "../handlers/processors"; import { validateConfigChange } from "../handlers/push"; import { addCommentToIssue, shouldSkip } from "../helpers"; -import { - GitHubEvent, - MainActionHandler, - PayloadSchema, - PostActionHandler, - PreActionHandler, - WildCardHandler, -} from "../types"; -import { Payload } from "../types/payload"; -import { ajv } from "../utils"; +import { MainActionHandler, PostActionHandler, PreActionHandler, WildCardHandler } from "../types/handlers"; +import { Payload, PayloadSchema, GitHubEvent } from "../types/payload"; +import { ajv } from "../utils/ajv"; import Runtime from "./bot-runtime"; -import { loadConfig } from "./config"; -import { Context } from "../types"; +import { loadConfiguration } from "./config"; +import { Context } from "../types/context"; +import OpenAI from "openai"; +import { BotConfig } from "../types"; + +const allowedEvents = Object.values(GitHubEvent) as string[]; const NO_VALIDATION = [GitHubEvent.INSTALLATION_ADDED_EVENT, GitHubEvent.PUSH_EVENT] as string[]; type PreHandlerWithType = { type: string; actions: PreActionHandler[] }; @@ -27,34 +24,17 @@ type PostHandlerWithType = { type: string; actions: PostActionHandler[] }; type AllHandlersWithTypes = PreHandlerWithType | HandlerWithType | PostHandlerWithType; type AllHandlers = PreActionHandler | MainActionHandler | PostActionHandler; +const validatePayload = ajv.compile(PayloadSchema); + export async function bindEvents(eventContext: ProbotContext) { const runtime = Runtime.getState(); - - const botConfig = await loadConfig(eventContext); - const context: Context = { - event: eventContext, - config: botConfig, - }; - - runtime.adapters = createAdapters(context); + runtime.adapters = createAdapters(eventContext); runtime.logger = runtime.adapters.supabase.logs; - if (!context.config.payout.privateKey) { - runtime.logger.warn("No EVM private key found"); - } - const payload = eventContext.payload as Payload; - const allowedEvents = Object.values(GitHubEvent) as string[]; const eventName = payload?.action ? `${eventContext.name}.${payload?.action}` : eventContext.name; // some events wont have actions as this grows - if (eventName === GitHubEvent.PUSH_EVENT) { - await validateConfigChange(context); - } - - if (!runtime.logger) { - throw new Error("Failed to create logger"); - } - runtime.logger.info("Binding events", { id: eventContext.id, name: eventName, allowedEvents }); + runtime.logger.info("Event received", { id: eventContext.id, name: eventName }); if (!allowedEvents.includes(eventName)) { // just check if its on the watch list @@ -64,24 +44,42 @@ export async function bindEvents(eventContext: ProbotContext) { // Skip validation for installation event and push if (!NO_VALIDATION.includes(eventName)) { // Validate payload - // console.trace({ payload }); - const validate = ajv.compile(PayloadSchema); - const valid = validate(payload); - if (!valid) { - // runtime.logger.info("Payload schema validation failed!", payload); - if (validate.errors) { - return runtime.logger.error("validation errors", validate.errors); - } - // return; + const valid = validatePayload(payload); + if (!valid && validatePayload.errors) { + return runtime.logger.error("Payload schema validation failed!", validatePayload.errors); } // Check if we should skip the event - const should = shouldSkip(context); + const should = shouldSkip(eventContext); if (should.stop) { return runtime.logger.info("Skipping the event.", { reason: should.reason }); } } + if (eventName === GitHubEvent.PUSH_EVENT) { + await validateConfigChange(eventContext); + } + + let botConfig: BotConfig; + try { + botConfig = await loadConfiguration(eventContext); + } catch (error) { + return; + } + const context: Context = { + event: eventContext, + config: botConfig, + openAi: botConfig.keys.openAi ? new OpenAI({ apiKey: botConfig.keys.openAi }) : null, + }; + + if (!context.config.keys.evmPrivateEncrypted) { + runtime.logger.warn("No EVM private key found"); + } + + if (!runtime.logger) { + throw new Error("Failed to create logger"); + } + // Get the handlers for the action const handlers = processors[eventName]; diff --git a/src/handlers/access/labels-access.ts b/src/handlers/access/labels-access.ts index db2123935..46e7dab35 100644 --- a/src/handlers/access/labels-access.ts +++ b/src/handlers/access/labels-access.ts @@ -5,7 +5,9 @@ import { Context, Payload, UserType } from "../../types"; export async function labelAccessPermissionsCheck(context: Context) { const runtime = Runtime.getState(); const logger = runtime.logger; - const { publicAccessControl } = context.config; + const { + features: { publicAccessControl }, + } = context.config; if (!publicAccessControl.setLabel) return true; const payload = context.event.payload as Payload; diff --git a/src/handlers/assign/action.ts b/src/handlers/assign/action.ts index 8365e5583..78a4a8819 100644 --- a/src/handlers/assign/action.ts +++ b/src/handlers/assign/action.ts @@ -33,7 +33,7 @@ export async function startCommandHandler(context: Context) { // Filter out labels that match the time labels defined in the config const timeLabelsAssigned: Label[] = labels.filter((label) => typeof label === "string" || typeof label === "object" - ? config.price.timeLabels.some((item) => item.name === label.name) + ? config.labels.time.some((item) => item.name === label.name) : false ); diff --git a/src/handlers/comment/action.ts b/src/handlers/comment/action.ts index 56c8974eb..d939ccec7 100644 --- a/src/handlers/comment/action.ts +++ b/src/handlers/comment/action.ts @@ -12,7 +12,7 @@ export async function commentCreatedOrEdited(context: Context) { const comment = payload.comment as Comment; const body = comment.body; - const commentedCommand = commentParser(context, body); + const commentedCommand = commentParser(body); if (!comment) { logger.info(`Comment is null. Skipping`); @@ -26,14 +26,14 @@ export async function commentCreatedOrEdited(context: Context) { await verifyFirstCommentInRepository(context); } - const allCommands = userCommands(context); + const allCommands = userCommands(config.miscellaneous.registerWalletWithVerification); const userCommand = allCommands.find((i) => i.id == commentedCommand); if (userCommand) { const { id, handler } = userCommand; logger.info("Running a comment handler", { id, handler: handler.name }); - const feature = config.command.find((e) => e.name === id.split("/")[1]); + const feature = config.commands.find((e) => e.name === id.split("/")[1]); if (feature?.enabled === false && id !== "/help") { return logger.warn("Skipping because it is disabled on this repo.", { id }); diff --git a/src/handlers/comment/handlers/assign/get-time-labels-assigned.ts b/src/handlers/comment/handlers/assign/get-time-labels-assigned.ts index 500387152..c6de00a40 100644 --- a/src/handlers/comment/handlers/assign/get-time-labels-assigned.ts +++ b/src/handlers/comment/handlers/assign/get-time-labels-assigned.ts @@ -9,7 +9,7 @@ export function getTimeLabelsAssigned(payload: Payload, config: BotConfig) { logger.warn("Skipping '/start' since no labels are set to calculate the timeline", { labels }); return; } - const timeLabelsDefined = config.price.timeLabels; + const timeLabelsDefined = config.labels.time; const timeLabelsAssigned: Label[] = []; for (const _label of labels) { const _labelType = typeof _label; diff --git a/src/handlers/comment/handlers/assign/index.ts b/src/handlers/comment/handlers/assign/index.ts index 93034ea71..c690e21ab 100644 --- a/src/handlers/comment/handlers/assign/index.ts +++ b/src/handlers/comment/handlers/assign/index.ts @@ -19,9 +19,13 @@ export async function assign(context: Context, body: string) { const config = context.config; const payload = context.event.payload as Payload; const issue = payload.issue; + const { + miscellaneous: { maxConcurrentTasks }, + timers: { taskStaleTimeoutDuration }, + commands, + } = context.config; - const staleTask = config.assign.staleTaskTime; - const startEnabled = config.command.find((command) => command.name === "start"); + const startEnabled = commands.find((command) => command.name === "start"); logger.info("Received '/start' command", { sender: payload.sender.login, body }); @@ -47,12 +51,12 @@ export async function assign(context: Context, body: string) { ); const assignedIssues = await getAssignedIssues(context, payload.sender.login); - logger.info("Max issue allowed is", config.assign.maxConcurrentTasks); + logger.info("Max issue allowed is", maxConcurrentTasks); // check for max and enforce max - if (assignedIssues.length - openedPullRequests.length >= config.assign.maxConcurrentTasks) { + if (assignedIssues.length - openedPullRequests.length >= maxConcurrentTasks) { throw logger.warn("Too many assigned issues, you have reached your max limit", { - maxConcurrentTasks: config.assign.maxConcurrentTasks, + maxConcurrentTasks, }); } @@ -88,7 +92,7 @@ export async function assign(context: Context, body: string) { await addAssignees(context, issue.number, [payload.sender.login]); } - const isTaskStale = checkTaskStale(staleTask, issue); + const isTaskStale = checkTaskStale(taskStaleTimeoutDuration, issue); // double check whether the assign message has been already posted or not logger.info("Creating an issue comment", { comment }); diff --git a/src/handlers/comment/handlers/first.ts b/src/handlers/comment/handlers/first.ts index f266f4eec..2d35985dc 100644 --- a/src/handlers/comment/handlers/first.ts +++ b/src/handlers/comment/handlers/first.ts @@ -9,7 +9,9 @@ export async function verifyFirstCommentInRepository(context: Context) { throw runtime.logger.error("Issue is null. Skipping", { issue: payload.issue }, true); } const { - newContributorGreeting: { header, footer, enabled }, + features: { + newContributorGreeting: { header, footer, enabled }, + }, } = context.config; const response_issue = await context.event.octokit.rest.search.issuesAndPullRequests({ q: `is:issue repo:${payload.repository.owner.login}/${payload.repository.name} commenter:${payload.sender.login}`, diff --git a/src/handlers/comment/handlers/help.ts b/src/handlers/comment/handlers/help.ts index 57f1ed037..970db8f65 100644 --- a/src/handlers/comment/handlers/help.ts +++ b/src/handlers/comment/handlers/help.ts @@ -20,9 +20,9 @@ export async function listAvailableCommands(context: Context, body: string) { export function generateHelpMenu(context: Context) { const config = context.config; - const startEnabled = config.command.find((command) => command.name === "start"); + const startEnabled = config.commands.find((command) => command.name === "start"); let helpMenu = "### Available Commands\n\n| Command | Description | Example |\n| --- | --- | --- |\n"; - const commands = userCommands(context); + const commands = userCommands(config.miscellaneous.registerWalletWithVerification); commands.map( (command) => diff --git a/src/handlers/comment/handlers/index.ts b/src/handlers/comment/handlers/index.ts index 4dbedb445..2a2a299b3 100644 --- a/src/handlers/comment/handlers/index.ts +++ b/src/handlers/comment/handlers/index.ts @@ -1,4 +1,4 @@ -import { Context, UserCommands } from "../../../types"; +import { UserCommands } from "../../../types"; import { assign } from "./assign"; import { listAvailableCommands } from "./help"; // Commented out until Gnosis Safe is integrated (https://github.com/ubiquity/ubiquibot/issues/353) @@ -25,8 +25,8 @@ export * from "./unassign"; export * from "./wallet"; // Parses the comment body and figure out the command name a user wants -export function commentParser(context: Context, body: string): null | string { - const userCommandIds = userCommands(context).map((cmd) => cmd.id); +export function commentParser(body: string): null | string { + const userCommandIds = userCommands(false).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); @@ -40,8 +40,8 @@ export function commentParser(context: Context, body: string): null | string { return null; } -export function userCommands(context: Context): UserCommands[] { - const accountForWalletVerification = walletVerificationDetails(context); +export function userCommands(walletVerificationEnabled: boolean): UserCommands[] { + const accountForWalletVerification = walletVerificationDetails(walletVerificationEnabled); return [ { id: "/start", @@ -112,7 +112,7 @@ export function userCommands(context: Context): UserCommands[] { ]; } -function walletVerificationDetails(context: Context) { +function walletVerificationDetails(walletVerificationEnabled: boolean) { const base = { description: "Register your wallet address for payments.", example: "/wallet ubq.eth", @@ -125,7 +125,6 @@ function walletVerificationDetails(context: Context) { "0xe2a3e34a63f3def2c29605de82225b79e1398190b542be917ef88a8e93ff9dc91bdc3ef9b12ed711550f6d2cbbb50671aa3f14a665b709ec391f3e603d0899a41b", }; - const walletVerificationEnabled = context.config.wallet.registerWalletWithVerification; if (walletVerificationEnabled) { return { description: `${base.description} ${withVerification.description}`, diff --git a/src/handlers/comment/handlers/issue/calculate-quality-score.test.ts b/src/handlers/comment/handlers/issue/calculate-quality-score.test.ts index 6a1f39a03..e2b1f6376 100644 --- a/src/handlers/comment/handlers/issue/calculate-quality-score.test.ts +++ b/src/handlers/comment/handlers/issue/calculate-quality-score.test.ts @@ -5,6 +5,12 @@ import { gptRelevance, } from "./calculate-quality-score"; import { Comment, Issue, User, UserType } from "../../../../types/payload"; +import OpenAI from "openai"; +import { Context } from "../../../../types"; + +const context = { + openAi: new OpenAI(), +} as unknown as Context; // Do not run real API calls inside of VSCode because it keeps running the tests in the background if (process.env.NODE_ENV !== "test") { @@ -16,7 +22,7 @@ if (process.env.NODE_ENV !== "test") { { body: "it is juicy", user: { type: UserType.User } as User } as Comment, { body: "bananas are great", user: { type: UserType.User } as User } as Comment, ]; - const result = await calculateQualScore(issue, comments); + const result = await calculateQualScore(context, issue, comments); expect(result).toBeDefined(); expect(result.relevanceScores).toBeDefined(); expect(Array.isArray(result.relevanceScores)).toBe(true); @@ -27,7 +33,7 @@ if (process.env.NODE_ENV !== "test") { describe("*** Real OpenAI API Call *** gptRelevance", () => { it("should calculate gpt relevance", async () => { - const result = await gptRelevance("gpt-3.5-turbo", "my topic is about apples", [ + const result = await gptRelevance(context, "gpt-3.5-turbo", "my topic is about apples", [ "the apple is red", "it is juicy", "bananas are great", @@ -86,7 +92,7 @@ describe("calculateQualScore", () => { const issue = { body: "issue body" } as Issue; const comment = { body: "comment body", user: { type: "User" } } as Comment; const comments = [comment, comment, comment] as Comment[]; - const result = await calculateQualScore(issue, comments); + const result = await calculateQualScore(context, issue, comments); expect(result).toBeDefined(); expect(result.relevanceScores).toBeDefined(); expect(Array.isArray(result.relevanceScores)).toBe(true); @@ -106,7 +112,7 @@ describe("calculateQualScore", () => { describe("gptRelevance", () => { it("should calculate gpt relevance", async () => { - const result = await gptRelevance("gpt-3.5-turbo", "issue body", ["comment body"]); + const result = await gptRelevance(context, "gpt-3.5-turbo", "issue body", ["comment body"]); expect(result).toEqual([1, 1, 0]); }); }); diff --git a/src/handlers/comment/handlers/issue/calculate-quality-score.ts b/src/handlers/comment/handlers/issue/calculate-quality-score.ts index 080086ffe..c3621d6cd 100644 --- a/src/handlers/comment/handlers/issue/calculate-quality-score.ts +++ b/src/handlers/comment/handlers/issue/calculate-quality-score.ts @@ -3,13 +3,12 @@ import OpenAI from "openai"; import { encodingForModel } from "js-tiktoken"; import Decimal from "decimal.js"; import Runtime from "../../../../bindings/bot-runtime"; +import { Context } from "../../../../types"; -const openai = new OpenAI(); // apiKey: // defaults to process.env["OPENAI_API_KEY"] - -export async function calculateQualScore(issue: Issue, contributorComments: Comment[]) { +export async function calculateQualScore(context: Context, issue: Issue, contributorComments: Comment[]) { const sumOfConversationTokens = countTokensOfConversation(issue, contributorComments); const estimatedOptimalModel = estimateOptimalModel(sumOfConversationTokens); - const relevanceScores = await sampleQualityScores(contributorComments, estimatedOptimalModel, issue); + const relevanceScores = await sampleQualityScores(context, contributorComments, estimatedOptimalModel, issue); return { relevanceScores, sumOfConversationTokens, model: estimatedOptimalModel }; } @@ -51,15 +50,18 @@ export function countTokensOfConversation(issue: Issue, comments: Comment[]) { } export async function gptRelevance( + context: Context, model: string, ISSUE_SPECIFICATION_BODY: string, CONVERSATION_STRINGS: string[], ARRAY_LENGTH = CONVERSATION_STRINGS.length ) { + const openAi = context.openAi; + if (!openAi) throw new Error("OpenAI adapter is not defined"); const PROMPT = `I need to evaluate the relevance of GitHub contributors' comments to a specific issue specification. Specifically, I'm interested in how much each comment helps to further define the issue specification or contributes new information or research relevant to the issue. Please provide a float between 0 and 1 to represent the degree of relevance. A score of 1 indicates that the comment is entirely relevant and adds significant value to the issue, whereas a score of 0 indicates no relevance or added value. Each contributor's comment is on a new line.\n\nIssue Specification:\n\`\`\`\n${ISSUE_SPECIFICATION_BODY}\n\`\`\`\n\nConversation:\n\`\`\`\n${CONVERSATION_STRINGS.join( "\n" )}\n\`\`\`\n\n\nTo what degree are each of the comments in the conversation relevant and valuable to further defining the issue specification? Please reply with an array of float numbers between 0 and 1, corresponding to each comment in the order they appear. Each float should represent the degree of relevance and added value of the comment to the issue. The total length of the array in your response should equal exactly ${ARRAY_LENGTH} elements.`; - const response: OpenAI.Chat.ChatCompletion = await openai.chat.completions.create({ + const response: OpenAI.Chat.ChatCompletion = await openAi.chat.completions.create({ model: model, messages: [ { @@ -83,6 +85,7 @@ export async function gptRelevance( } async function sampleQualityScores( + context: Context, contributorComments: Comment[], estimatedOptimalModel: ReturnType, issue: Issue @@ -93,7 +96,7 @@ async function sampleQualityScores( const batchSamples = [] as Decimal[][]; for (let attempt = 0; attempt < BATCHES; attempt++) { - const fetchedSamples = await fetchSamples({ + const fetchedSamples = await fetchSamples(context, { contributorComments, estimatedOptimalModel, issue, @@ -108,16 +111,14 @@ async function sampleQualityScores( return average; } -async function fetchSamples({ - contributorComments, - estimatedOptimalModel, - issue, - maxConcurrency, -}: InEachRequestParams) { +async function fetchSamples( + context: Context, + { contributorComments, estimatedOptimalModel, issue, maxConcurrency }: InEachRequestParams +) { const commentsSerialized = contributorComments.map((comment) => comment.body); const batchPromises = []; for (let i = 0; i < maxConcurrency; i++) { - const requestPromise = gptRelevance(estimatedOptimalModel, issue.body, commentsSerialized); + const requestPromise = gptRelevance(context, estimatedOptimalModel, issue.body, commentsSerialized); batchPromises.push(requestPromise); } const batchResults = await Promise.all(batchPromises); diff --git a/src/handlers/comment/handlers/issue/generate-permit-2-signature.ts b/src/handlers/comment/handlers/issue/generate-permit-2-signature.ts index 3ac70e638..abf76184b 100644 --- a/src/handlers/comment/handlers/issue/generate-permit-2-signature.ts +++ b/src/handlers/comment/handlers/issue/generate-permit-2-signature.ts @@ -4,6 +4,8 @@ import { BigNumber, ethers } from "ethers"; import { keccak256, toUtf8Bytes } from "ethers/lib/utils"; import Runtime from "../../../../bindings/bot-runtime"; import { Context } from "../../../../types"; +import { getPayoutConfigByNetworkId } from "../../../../helpers"; +import { decryptKeys } from "../../../../utils/private"; export async function generatePermit2Signature( context: Context, @@ -11,8 +13,12 @@ export async function generatePermit2Signature( ) { const runtime = Runtime.getState(); const { - payout: { privateKey, paymentToken, rpc, evmNetworkId }, + payments: { evmNetworkId }, + keys: { evmPrivateEncrypted }, } = context.config; + if (!evmPrivateEncrypted) throw runtime.logger.warn("No bot wallet private key defined"); + const { rpc, paymentToken } = getPayoutConfigByNetworkId(evmNetworkId); + const { privateKey } = await decryptKeys(evmPrivateEncrypted); if (!rpc) throw runtime.logger.error("RPC is not defined"); if (!privateKey) throw runtime.logger.error("Private key is not defined"); diff --git a/src/handlers/comment/handlers/issue/generate-permits.ts b/src/handlers/comment/handlers/issue/generate-permits.ts index 009ddb2c1..12131fd4d 100644 --- a/src/handlers/comment/handlers/issue/generate-permits.ts +++ b/src/handlers/comment/handlers/issue/generate-permits.ts @@ -7,6 +7,7 @@ import structuredMetadata from "../../../shared/structured-metadata"; import { generatePermit2Signature } from "./generate-permit-2-signature"; import { FinalScores } from "./issue-closed"; import { IssueRole } from "./_calculate-all-comment-scores"; +import { getPayoutConfigByNetworkId } from "../../../../helpers"; export async function generatePermits(context: Context, totals: FinalScores, contributorComments: Comment[]) { const userIdToNameMap = mapIdsToNames(contributorComments); @@ -23,8 +24,10 @@ async function generateComment( ) { const runtime = Runtime.getState(); const { - payout: { paymentToken, rpc, privateKey }, + payments: { evmNetworkId }, + keys: { evmPrivateEncrypted }, } = context.config; + const { rpc, paymentToken } = getPayoutConfigByNetworkId(evmNetworkId); const detailsTable = generateDetailsTable(totals, contributorComments); const tokenSymbol = await getTokenSymbol(paymentToken, rpc); const HTMLs = [] as string[]; @@ -38,7 +41,7 @@ async function generateComment( const contributorName = userIdToNameMap[userId]; const issueRole = userTotals.role; - if (!privateKey) throw runtime.logger.warn("No bot wallet private key defined"); + if (!evmPrivateEncrypted) throw runtime.logger.warn("No bot wallet private key defined"); const beneficiaryAddress = await runtime.adapters.supabase.wallet.getAddress(parseInt(userId)); diff --git a/src/handlers/comment/handlers/issue/issue-closed.ts b/src/handlers/comment/handlers/issue/issue-closed.ts index 6e4e2afed..e78231d06 100644 --- a/src/handlers/comment/handlers/issue/issue-closed.ts +++ b/src/handlers/comment/handlers/issue/issue-closed.ts @@ -60,7 +60,7 @@ async function preflightChecks(context: Context, issue: Issue, logger: Logs, iss if (!issue) throw logger.error("Permit generation skipped because issue is undefined"); if (issue.state_reason !== StateReason.COMPLETED) throw logger.info("Issue was not closed as completed. Skipping.", { issue }); - if (config.publicAccessControl.fundExternalClosedIssue) { + if (config.features.publicAccessControl.fundExternalClosedIssue) { const userHasPermission = await checkUserPermissionForRepoAndOrg(context, payload.sender.login); if (!userHasPermission) throw logger.warn("Permit generation disabled because this issue has been closed by an external contributor."); @@ -89,7 +89,7 @@ function checkIfPermitsAlreadyPosted(botComments: Comment[], logger: Logs) { } async function calculateScores(context: Context, issue: Issue, contributorComments: Comment[]) { - const qualityScore = await calculateQualScore(issue, contributorComments); // the issue specification is not included in this array scoring, it is only for the other contributor comments + const qualityScore = await calculateQualScore(context, issue, contributorComments); // the issue specification is not included in this array scoring, it is only for the other contributor comments const qualityScoresWithMetaData = qualityScore.relevanceScores.map((score, index) => ({ commentId: contributorComments[index].id, userId: contributorComments[index].user.id, diff --git a/src/handlers/comment/handlers/wallet.ts b/src/handlers/comment/handlers/wallet.ts index fbbb0de0b..a92c1e47e 100644 --- a/src/handlers/comment/handlers/wallet.ts +++ b/src/handlers/comment/handlers/wallet.ts @@ -42,7 +42,7 @@ export async function registerWallet(context: Context, body: string) { return logger.info("Skipping to register a wallet address because both address/ens doesn't exist"); } - if (config.wallet.registerWalletWithVerification) { + if (config.miscellaneous.registerWalletWithVerification) { _registerWalletWithVerification(body, address, logger); } diff --git a/src/handlers/pricing/pre.ts b/src/handlers/pricing/pre.ts index ac99ba482..fc6ef513e 100644 --- a/src/handlers/pricing/pre.ts +++ b/src/handlers/pricing/pre.ts @@ -11,23 +11,26 @@ export async function syncPriceLabelsToConfig(context: Context) { const config = context.config; const logger = runtime.logger; - const { assistivePricing } = config.mode; + const { + features: { assistivePricing }, + labels, + } = config; if (!assistivePricing) { logger.info(`Assistive pricing is disabled`); return; } - const timeLabels = config.price.timeLabels.map((i) => i.name); - const priorityLabels = config.price.priorityLabels.map((i) => i.name); + const timeLabels = labels.time.map((i) => i.name); + const priorityLabels = labels.priority.map((i) => i.name); const aiLabels: string[] = []; - for (const timeLabel of config.price.timeLabels) { - for (const priorityLabel of config.price.priorityLabels) { + for (const timeLabel of config.labels.time) { + for (const priorityLabel of config.labels.priority) { const targetPrice = calculateTaskPrice( context, calculateLabelValue(timeLabel), calculateLabelValue(priorityLabel), - config.price.priceMultiplier + config.payments.basePriceMultiplier ); const targetPriceLabel = `Price: ${targetPrice} USD`; aiLabels.push(targetPriceLabel); diff --git a/src/handlers/pricing/pricing-label.ts b/src/handlers/pricing/pricing-label.ts index b25cd5fba..e641254ee 100644 --- a/src/handlers/pricing/pricing-label.ts +++ b/src/handlers/pricing/pricing-label.ts @@ -1,6 +1,6 @@ import Runtime from "../../bindings/bot-runtime"; import { addLabelToIssue, clearAllPriceLabelsOnIssue, createLabel, getAllLabeledEvents } from "../../helpers"; -import { BotConfig, Context, Label, LabelFromConfig, Payload, UserType } from "../../types"; +import { BotConfig, Context, Label, Payload, UserType } from "../../types"; import { labelAccessPermissionsCheck } from "../access"; import { setPrice } from "../shared/pricing"; import { handleParentIssue, isParentIssue, sortLabelsByValue } from "./action"; @@ -21,13 +21,13 @@ export async function onLabelChangeSetPricing(context: Context) { } const permission = await labelAccessPermissionsCheck(context); if (!permission) { - if (config.publicAccessControl.setLabel === false) { + if (config.features.publicAccessControl.setLabel === false) { throw logger.warn("No public access control to set labels"); } throw logger.warn("No permission to set labels"); } - const { assistivePricing } = config.mode; + const { assistivePricing } = config.features; if (!labels) throw logger.warn(`No labels to calculate price`); @@ -57,15 +57,13 @@ export async function onLabelChangeSetPricing(context: Context) { } function getRecognizedLabels(labels: Label[], config: BotConfig) { - const isRecognizedLabel = (label: Label, labelConfig: LabelFromConfig[]) => + const isRecognizedLabel = (label: Label, labelConfig: { name: string }[]) => (typeof label === "string" || typeof label === "object") && labelConfig.some((item) => item.name === label.name); - const recognizedTimeLabels: Label[] = labels.filter((label: Label) => - isRecognizedLabel(label, config.price.timeLabels) - ); + const recognizedTimeLabels: Label[] = labels.filter((label: Label) => isRecognizedLabel(label, config.labels.time)); const recognizedPriorityLabels: Label[] = labels.filter((label: Label) => - isRecognizedLabel(label, config.price.priorityLabels) + isRecognizedLabel(label, config.labels.priority) ); return { time: recognizedTimeLabels, priority: recognizedPriorityLabels }; diff --git a/src/handlers/processors.ts b/src/handlers/processors.ts index 50fe309f3..dc77a4944 100644 --- a/src/handlers/processors.ts +++ b/src/handlers/processors.ts @@ -8,7 +8,6 @@ import { commentCreatedOrEdited } from "./comment/action"; import { issueClosed } from "./comment/handlers/issue/issue-closed"; import { watchLabelChange } from "./label"; import { createDevPoolPR } from "./pull-request"; -import { validateConfigChange } from "./push"; import { checkModifiedBaseRate } from "./push/check-modified-base-rate"; import { onLabelChangeSetPricing } from "./pricing/pricing-label"; import Runtime from "../bindings/bot-runtime"; @@ -79,7 +78,7 @@ export const processors: Record = { post: [], }, [GitHubEvent.PUSH_EVENT]: { - pre: [validateConfigChange], + pre: [], action: [], post: [checkModifiedBaseRate], }, diff --git a/src/handlers/push/index.ts b/src/handlers/push/index.ts index 38c1b5b9a..f5de707c5 100644 --- a/src/handlers/push/index.ts +++ b/src/handlers/push/index.ts @@ -1,8 +1,9 @@ +import { Context as ProbotContext } from "probot"; import Runtime from "../../bindings/bot-runtime"; import { createCommitComment, getFileContent } from "../../helpers"; -import { CommitsPayload, PushPayload, ConfigSchema, Context } from "../../types"; -import { parseYamlConfig } from "../../utils/get-config"; -import { validate } from "../../utils/ajv"; +import { BotConfig, CommitsPayload, PushPayload, validateBotConfig } from "../../types"; +import { parseYaml, transformConfig } from "../../utils/generate-configuration"; +import { DefinedError } from "ajv"; export const ZERO_SHA = "0000000000000000000000000000000000000000"; export const BASE_RATE_FILE = ".github/ubiquibot-config.yml"; @@ -21,11 +22,11 @@ export function getCommitChanges(commits: CommitsPayload[]) { return changes; } -export async function validateConfigChange(context: Context) { +export async function validateConfigChange(context: ProbotContext) { const runtime = Runtime.getState(); const logger = runtime.logger; - const payload = context.event.payload as PushPayload; + const payload = context.payload as PushPayload; if (!payload.ref.startsWith("refs/heads/")) { logger.debug("Skipping push events, not a branch"); @@ -61,18 +62,54 @@ export async function validateConfigChange(context: Context) { if (configFileContent) { const decodedConfig = Buffer.from(configFileContent, "base64").toString(); - const config = parseYamlConfig(decodedConfig); - const { valid, error } = validate(ConfigSchema, config); + const config = parseYaml(decodedConfig); + const valid = validateBotConfig(config); + let errorMsg: string | undefined; + if (!valid) { - await createCommitComment( - context, - `@${payload.sender.login} Config validation failed! ${error}`, - commitSha, - BASE_RATE_FILE - ); - logger.info("Config validation failed!", error); + const errMsg = generateValidationError(validateBotConfig.errors as DefinedError[]); + errorMsg = `@${payload.sender.login} ${errMsg}`; + } + + try { + transformConfig(config as BotConfig); + } catch (err) { + if (errorMsg) { + errorMsg += `\nConfig tranformation failed:\n${err}`; + } else { + errorMsg = `@${payload.sender.login} Config tranformation failed:\n${err}`; + } + } + + if (errorMsg) { + logger.info("Config validation failed!", errorMsg); + await createCommitComment(context, errorMsg, commitSha, BASE_RATE_FILE); + } else { + logger.debug(`Config validation passed!`); } } + } else { + logger.debug(`Skipping push events, file change doesnt include config file: ${JSON.stringify(changes)}`); } - logger.debug("Skipping push events, file change empty 4"); +} + +function generateValidationError(errors: DefinedError[]) { + const errorsWithoutStrict = errors.filter((error) => error.keyword !== "additionalProperties"); + const errorsOnlyStrict = errors.filter((error) => error.keyword === "additionalProperties"); + const isValid = errorsWithoutStrict.length === 0; + const errorMsg = isValid + ? "" + : errorsWithoutStrict.map((error) => error.instancePath.replaceAll("/", ".") + " " + error.message).join("\n"); + const warningMsg = + errorsOnlyStrict.length > 0 + ? "Warning! Unneccesary properties: \n" + + errorsOnlyStrict + .map( + (error) => + error.keyword === "additionalProperties" && + error.instancePath.replaceAll("/", ".") + "." + error.params.additionalProperty + ) + .join("\n") + : ""; + return `${isValid ? "Valid" : "Invalid"} configuration. \n${errorMsg}\n${warningMsg}`; } diff --git a/src/handlers/push/update-base-rate.ts b/src/handlers/push/update-base-rate.ts index 798080206..a4e402df6 100644 --- a/src/handlers/push/update-base-rate.ts +++ b/src/handlers/push/update-base-rate.ts @@ -2,7 +2,7 @@ import Runtime from "../../bindings/bot-runtime"; import { getPreviousFileContent, listLabelsForRepo, updateLabelsFromBaseRate } from "../../helpers"; import { Label, PushPayload, Context } from "../../types"; -import { parseYamlConfig } from "../../utils/get-config"; +import { parseYaml } from "../../utils/generate-configuration"; export async function updateBaseRate(context: Context, filePath: string) { const runtime = Runtime.getState(); @@ -20,13 +20,13 @@ export async function updateBaseRate(context: Context, filePath: string) { throw logger.error("Getting previous file content failed"); } const previousConfigRaw = Buffer.from(previousFileContent, "base64").toString(); - const previousConfigParsed = parseYamlConfig(previousConfigRaw); + const previousConfigParsed = parseYaml(previousConfigRaw); - if (!previousConfigParsed || !previousConfigParsed.priceMultiplier) { + if (!previousConfigParsed || !previousConfigParsed.basePriceMultiplier) { throw logger.warn("No multiplier found in previous config"); } - const previousBaseRate = previousConfigParsed.priceMultiplier; + const previousBaseRate = previousConfigParsed.basePriceMultiplier; if (!previousBaseRate) { throw logger.warn("No base rate found in previous config"); diff --git a/src/handlers/shared/pricing.ts b/src/handlers/shared/pricing.ts index 80c7f32dc..325216a21 100644 --- a/src/handlers/shared/pricing.ts +++ b/src/handlers/shared/pricing.ts @@ -8,7 +8,7 @@ export function calculateTaskPrice( priorityValue: number, baseValue?: number ): number { - const base = baseValue ?? context.config.price.priceMultiplier; + const base = baseValue ?? context.config.payments.basePriceMultiplier; const priority = priorityValue / 10; // floats cause bad math const price = 1000 * base * timeValue * priority; return price; @@ -17,14 +17,14 @@ export function calculateTaskPrice( export function setPrice(context: Context, timeLabel: Label, priorityLabel: Label) { const runtime = Runtime.getState(); const logger = runtime.logger; - const { price } = context.config; + const { labels } = context.config; if (!timeLabel || !priorityLabel) throw logger.warn("Time or priority label is not defined"); - const recognizedTimeLabels = price.timeLabels.find((item) => item.name === timeLabel.name); + const recognizedTimeLabels = labels.time.find((item) => item.name === timeLabel.name); if (!recognizedTimeLabels) throw logger.warn("Time label is not recognized"); - const recognizedPriorityLabels = price.priorityLabels.find((item) => item.name === priorityLabel.name); + const recognizedPriorityLabels = labels.priority.find((item) => item.name === priorityLabel.name); if (!recognizedPriorityLabels) throw logger.warn("Priority label is not recognized"); const timeValue = calculateLabelValue(recognizedTimeLabels); diff --git a/src/handlers/wildcard/analytics.ts b/src/handlers/wildcard/analytics.ts index 5ab2254d7..003b242df 100644 --- a/src/handlers/wildcard/analytics.ts +++ b/src/handlers/wildcard/analytics.ts @@ -11,11 +11,10 @@ export function taskPaymentMetaData( priorityLabel: string | null; priceLabel: string | null; } { - const { price } = context.config; - const labels = issue.labels; + const { labels } = context.config; - const timeLabels = price.timeLabels.filter((item) => labels.map((i) => i.name).includes(item.name)); - const priorityLabels = price.priorityLabels.filter((item) => labels.map((i) => i.name).includes(item.name)); + const timeLabels = labels.time.filter((item) => issue.labels.map((i) => i.name).includes(item.name)); + const priorityLabels = labels.priority.filter((item) => issue.labels.map((i) => i.name).includes(item.name)); const isTask = timeLabels.length > 0 && priorityLabels.length > 0; @@ -29,7 +28,7 @@ export function taskPaymentMetaData( ? priorityLabels.reduce((a, b) => (calculateLabelValue(a) < calculateLabelValue(b) ? a : b)).name : null; - const priceLabel = labels.find((label) => label.name.includes("Price"))?.name || null; + const priceLabel = issue.labels.find((label) => label.name.includes("Price"))?.name || null; return { eligibleForPayment: isTask, diff --git a/src/handlers/wildcard/unassign/unassign.ts b/src/handlers/wildcard/unassign/unassign.ts index 340702987..753c3660c 100644 --- a/src/handlers/wildcard/unassign/unassign.ts +++ b/src/handlers/wildcard/unassign/unassign.ts @@ -23,8 +23,9 @@ async function checkTaskToUnassign(context: Context, assignedIssue: Issue) { const runtime = Runtime.getState(); const logger = runtime.logger; const payload = context.event.payload as Payload; - const unassign = context.config.unassign; - const { taskDisqualifyDuration, taskFollowUpDuration } = unassign; + const { + timers: { taskDisqualifyDuration, taskFollowUpDuration }, + } = context.config; logger.info("Checking for neglected tasks", { issueNumber: assignedIssue.number }); diff --git a/src/helpers/commit.ts b/src/helpers/commit.ts index 0c64945be..3406422ae 100644 --- a/src/helpers/commit.ts +++ b/src/helpers/commit.ts @@ -1,14 +1,15 @@ -import { Context, Payload } from "../types"; +import { Payload } from "../types"; +import { Context as ProbotContext } from "probot"; export async function createCommitComment( - context: Context, + context: ProbotContext, body: string, commitSha: string, path?: string, owner?: string, repo?: string ) { - const payload = context.event.payload as Payload; + const payload = context.payload as Payload; if (!owner) { owner = payload.repository.owner.login; } @@ -16,7 +17,7 @@ export async function createCommitComment( repo = payload.repository.name; } - await context.event.octokit.rest.repos.createCommitComment({ + await context.octokit.rest.repos.createCommitComment({ owner: owner, repo: repo, commit_sha: commitSha, diff --git a/src/helpers/file.ts b/src/helpers/file.ts index 5dfa7f0fc..5071e1db4 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -1,5 +1,6 @@ import Runtime from "../bindings/bot-runtime"; import { Context } from "../types"; +import { Context as ProbotContext } from "probot"; // Get the previous file content export async function getPreviousFileContent( @@ -68,7 +69,7 @@ export async function getPreviousFileContent( } export async function getFileContent( - context: Context, + context: ProbotContext, owner: string, repo: string, branch: string, @@ -81,7 +82,7 @@ export async function getFileContent( try { if (!commitSha) { // Get the latest commit of the branch - const branchData = await context.event.octokit.repos.getBranch({ + const branchData = await context.octokit.repos.getBranch({ owner, repo, branch, @@ -90,7 +91,7 @@ export async function getFileContent( } // Get the commit details - const commitData = await context.event.octokit.repos.getCommit({ + const commitData = await context.octokit.repos.getCommit({ owner, repo, ref: commitSha, @@ -100,7 +101,7 @@ export async function getFileContent( const file = commitData.data.files ? commitData.data.files.find((file) => file.filename === filePath) : undefined; if (file) { // Retrieve the file tree - const tree = await context.event.octokit.git.getTree({ + const tree = await context.octokit.git.getTree({ owner, repo, tree_sha: commitData.data.commit.tree.sha, @@ -111,7 +112,7 @@ export async function getFileContent( const file = tree.data.tree.find((item) => item.path === filePath); if (file && file.sha) { // Get the previous file content - const fileContent = await context.event.octokit.git.getBlob({ + const fileContent = await context.octokit.git.getBlob({ owner, repo, file_sha: file.sha, diff --git a/src/helpers/gpt.ts b/src/helpers/gpt.ts index bc1e3a6bb..ea06cf149 100644 --- a/src/helpers/gpt.ts +++ b/src/helpers/gpt.ts @@ -136,21 +136,22 @@ export async function askGPT(context: Context, chatHistory: CreateChatCompletion const runtime = Runtime.getState(); const logger = runtime.logger; const config = context.config; + const { keys } = config; - if (!config.ask.apiKey) { + if (!keys.openAi) { throw logger.error( "You must configure the `openai-api-key` property in the bot configuration in order to use AI powered features." ); } const openAI = new OpenAI({ - apiKey: config.ask.apiKey, + apiKey: keys.openAi, }); const res: OpenAI.Chat.Completions.ChatCompletion = await openAI.chat.completions.create({ messages: chatHistory, model: "gpt-3.5-turbo-16k", - max_tokens: config.ask.tokenLimit, + max_tokens: config.openai.tokenLimit, temperature: 0, }); diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index bde98f35b..acdd5c3c9 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -645,8 +645,7 @@ export async function getCommitsOnPullRequest(context: Context, pullNumber: numb } export async function getAvailableOpenedPullRequests(context: Context, username: string) { - const unassignConfig = context.config.unassign; - const { reviewDelayTolerance } = unassignConfig; + const { reviewDelayTolerance } = context.config.timers; if (!reviewDelayTolerance) return []; const openedPullRequests = await getOpenedPullRequests(context, username); diff --git a/src/helpers/label.ts b/src/helpers/label.ts index 43e655d52..a43650c2a 100644 --- a/src/helpers/label.ts +++ b/src/helpers/label.ts @@ -56,13 +56,13 @@ export async function updateLabelsFromBaseRate( const newLabels: string[] = []; const previousLabels: string[] = []; - for (const timeLabel of config.price.timeLabels) { - for (const priorityLabel of config.price.priorityLabels) { + for (const timeLabel of config.labels.time) { + for (const priorityLabel of config.labels.priority) { const targetPrice = calculateTaskPrice( context, calculateLabelValue(timeLabel), calculateLabelValue(priorityLabel), - config.price.priceMultiplier + config.payments.basePriceMultiplier ); const targetPriceLabel = `Price: ${targetPrice} USD`; newLabels.push(targetPriceLabel); diff --git a/src/helpers/shared.ts b/src/helpers/shared.ts index d2b37cad7..fce30fe1a 100644 --- a/src/helpers/shared.ts +++ b/src/helpers/shared.ts @@ -1,15 +1,16 @@ import ms from "ms"; -import { Label, LabelFromConfig, Payload, UserType, Context } from "../types"; +import { Label, Payload, UserType } from "../types"; +import { Context as ProbotContext } from "probot"; const contextNamesToSkip = ["workflow_run"]; -export function shouldSkip(context: Context) { - const payload = context.event.payload as Payload; +export function shouldSkip(context: ProbotContext) { + const payload = context.payload as Payload; const response = { stop: false, reason: null } as { stop: boolean; reason: string | null }; - if (contextNamesToSkip.includes(context.event.name)) { + if (contextNamesToSkip.includes(context.name)) { response.stop = true; - response.reason = `excluded context name: "${context.event.name}"`; + response.reason = `excluded context name: "${context.name}"`; } else if (payload.sender.type === UserType.Bot) { response.stop = true; response.reason = "sender is a bot"; @@ -18,7 +19,7 @@ export function shouldSkip(context: Context) { return response; } -export function calculateLabelValue(label: LabelFromConfig): number { +export function calculateLabelValue(label: { name: string }): number { const matches = label.name.match(/\d+/); const number = matches && matches.length > 0 ? parseInt(matches[0]) || 0 : 0; if (label.name.toLowerCase().includes("priority")) return number; diff --git a/src/tests/commands-test.ts b/src/tests/commands-test.ts index 010b67408..0ebec1e56 100644 --- a/src/tests/commands-test.ts +++ b/src/tests/commands-test.ts @@ -58,7 +58,7 @@ export function setServer(value: Server) { } export const orgConfig: Config = { - privateKeyEncrypted: + privateEncrypted: "YU-tFJFczN3JPVoJu0pQKSbWoeiCFPjKiTXMoFnJxDDxUNX-BBXc6ZHkcQcHVjdOd6ZcEnU1o2jU3F-i05mGJPmhF2rhQYXkNlxu5U5fZMMcgxJ9INhAmktzRBUxWncg4L1HOalZIoQ7gm3nk1a84g", }; diff --git a/src/tests/test-repo-config.ts b/src/tests/test-repo-config.ts index 2ba2f5290..8b333629a 100644 --- a/src/tests/test-repo-config.ts +++ b/src/tests/test-repo-config.ts @@ -1,8 +1,6 @@ -import { Config } from "../types"; - -export const repoConfig: Config = { +export const configuration = { evmNetworkId: 100, - priceMultiplier: 1, + basePriceMultiplier: 1, issueCreatorMultiplier: 1, timeLabels: [ { name: "Time: <1 Hour" }, @@ -54,10 +52,7 @@ export const repoConfig: Config = { }, }, }, - // openAIKey: null, - // privateKeyEncrypted: null, - openAITokenLimit: 100000, - staleTaskTime: "15 minutes", + taskStaleTimeoutDuration: "15 minutes", newContributorGreeting: { enabled: true, diff --git a/src/types/config.ts b/src/types/config.ts deleted file mode 100644 index 0377865ab..000000000 --- a/src/types/config.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { Static, Type } from "@sinclair/typebox"; -import { LogLevel } from "../adapters/supabase/helpers/tables/logs"; - -const LabelFromConfigSchema = Type.Object( - { - name: Type.String(), - }, - { - additionalProperties: false, - } -); -export type LabelFromConfig = Static; - -const CommentIncentivesSchema = Type.Object( - { - elements: Type.Record(Type.String(), Type.Number()), - totals: Type.Object( - { - word: Type.Number(), - }, - { additionalProperties: false } - ), - }, - { additionalProperties: false } -); -// type CommentIncentives= Static; - -const IncentivesSchema = Type.Object( - { - comment: CommentIncentivesSchema, - }, - { additionalProperties: false } -); - -export type Incentives = Static; - -const CommandItemSchema = Type.Object( - { - name: Type.String(), - enabled: Type.Boolean(), - }, - { additionalProperties: false } -); -// type CommandItem= Static; - -const PriceConfigSchema = Type.Object({ - priceMultiplier: Type.Number(), - issueCreatorMultiplier: Type.Number(), - timeLabels: Type.Array(LabelFromConfigSchema), - priorityLabels: Type.Array(LabelFromConfigSchema), - incentives: IncentivesSchema, - defaultLabels: Type.Array(Type.String()), -}); -// type PriceConfig= Static; - -const SupabaseConfigSchema = Type.Object({ - url: Type.Union([Type.String(), Type.Null()]), - key: Type.Union([Type.String(), Type.Null()]), -}); - -// const LogNotificationSchema = Type.Object({ -// url: Type.String(), -// secret: Type.String(), -// groupId: Type.Number(), -// topicId: Type.Number(), -// enabled: Type.Boolean(), -// }); - -// type LogNotification= Static; - -const PayoutConfigSchema = Type.Object({ - evmNetworkId: Type.Number(), - rpc: Type.String(), - privateKey: Type.Union([Type.String(), Type.Null()]), - publicKey: Type.Union([Type.String(), Type.Null()]), - paymentToken: Type.String(), - permitBaseUrl: Type.String(), -}); - -const UnassignConfigSchema = Type.Object({ - taskFollowUpDuration: Type.Number(), - taskDisqualifyDuration: Type.Number(), - reviewDelayTolerance: Type.Number(), -}); - -const ModeSchema = Type.Object({ - maxPermitPrice: Type.Number(), - assistivePricing: Type.Boolean(), -}); - -const AssignSchema = Type.Object({ - maxConcurrentTasks: Type.Number(), - staleTaskTime: Type.Number(), -}); - -const LogConfigSchema = Type.Object({ - logEnvironment: Type.String(), - level: Type.Enum(LogLevel), - retryLimit: Type.Number(), -}); - -const SodiumSchema = Type.Object({ - publicKey: Type.Union([Type.String(), Type.Null()]), - privateKey: Type.Union([Type.String(), Type.Null()]), -}); - -const CommentsSchema = Type.Object({ - promotionComment: Type.String(), -}); - -const AskSchema = Type.Object({ - apiKey: Type.Optional(Type.String()), - tokenLimit: Type.Number(), -}); - -const NewContributorGreetingSchema = Type.Object({ - enabled: Type.Boolean(), - header: Type.String(), - displayHelpMenu: Type.Boolean(), - footer: Type.String(), -}); -// type NewContributorGreeting= Static; - -const CommandConfigSchema = Type.Array(CommandItemSchema); - -// type CommandConfig= Static; -const WalletSchema = Type.Object({ - registerWalletWithVerification: Type.Boolean(), -}); - -const PublicAccessControlSchema = Type.Object({ - setLabel: Type.Boolean(), - fundExternalClosedIssue: Type.Boolean(), -}); - -// type AccessControl= Static; - -export const BotConfigSchema = Type.Object({ - log: LogConfigSchema, - price: PriceConfigSchema, - payout: PayoutConfigSchema, - unassign: UnassignConfigSchema, - supabase: SupabaseConfigSchema, - // logNotification: LogNotificationSchema, - mode: ModeSchema, - assign: AssignSchema, - sodium: SodiumSchema, - comments: CommentsSchema, - command: CommandConfigSchema, - wallet: WalletSchema, - ask: AskSchema, - publicAccessControl: PublicAccessControlSchema, - newContributorGreeting: NewContributorGreetingSchema, -}); - -export type BotConfig = Static; - -const StreamlinedCommentSchema = Type.Object({ - login: Type.Optional(Type.String()), - body: Type.Optional(Type.String()), -}); - -export type StreamlinedComment = Static; - -// const GPTResponseSchema = Type.Object({ -// answer: Type.Optional(Type.String()), -// tokenUsage: Type.Object({ -// output: Type.Optional(Type.Number()), -// input: Type.Optional(Type.Number()), -// total: Type.Optional(Type.Number()), -// }), -// }); - -// type GPTResponse= Static; - -export const ConfigSchema = Type.Object( - { - evmNetworkId: Type.Optional(Type.Number()), - priceMultiplier: Type.Optional(Type.Number()), - issueCreatorMultiplier: Type.Optional(Type.Number()), - timeLabels: Type.Optional(Type.Array(LabelFromConfigSchema)), - priorityLabels: Type.Optional(Type.Array(LabelFromConfigSchema)), - maxPermitPrice: Type.Optional(Type.Number()), - commandSettings: Type.Optional(Type.Array(CommandItemSchema)), - promotionComment: Type.Optional(Type.Union([Type.String(), Type.Null()])), - assistivePricing: Type.Optional(Type.Boolean()), - maxConcurrentTasks: Type.Optional(Type.Number()), - incentives: Type.Optional(IncentivesSchema), - defaultLabels: Type.Optional(Type.Array(Type.String())), - registerWalletWithVerification: Type.Optional(Type.Boolean()), - publicAccessControl: Type.Optional(PublicAccessControlSchema), - openAIKey: Type.Optional(Type.String()), - openAITokenLimit: Type.Optional(Type.Number()), - staleTaskTime: Type.Optional(Type.String()), - privateKeyEncrypted: Type.Optional(Type.String()), - newContributorGreeting: Type.Optional(NewContributorGreetingSchema), - }, - { - additionalProperties: false, - } -); - -export type Config = Static; - -// export type Config = Config; - -const MergedConfigSchema = Type.Object({ - assistivePricing: Type.Boolean(), - commandSettings: Type.Array(CommandItemSchema), - defaultLabels: Type.Array(Type.String()), - evmNetworkId: Type.Number(), - incentives: IncentivesSchema, - issueCreatorMultiplier: Type.Number(), - maxConcurrentTasks: Type.Number(), - newContributorGreeting: NewContributorGreetingSchema, - openAIKey: Type.Optional(Type.String()), - openAITokenLimit: Type.Optional(Type.Number()), - maxPermitPrice: Type.Number(), - priceMultiplier: Type.Number(), - priorityLabels: Type.Array(LabelFromConfigSchema), - privateKeyEncrypted: Type.Optional(Type.String()), - promotionComment: Type.String(), - publicAccessControl: PublicAccessControlSchema, - registerWalletWithVerification: Type.Boolean(), - staleTaskTime: Type.String(), - timeLabels: Type.Array(LabelFromConfigSchema), - reviewDelayTolerance: Type.String(), - permitBaseUrl: Type.String(), - taskFollowUpDuration: Type.String(), - taskDisqualifyDuration: Type.String(), -}); - -export type MergedConfig = Static; diff --git a/src/types/configuration-types.ts b/src/types/configuration-types.ts new file mode 100644 index 000000000..caa540285 --- /dev/null +++ b/src/types/configuration-types.ts @@ -0,0 +1,139 @@ +import { Type as T, Static, TProperties, ObjectOptions, StringOptions, StaticDecode } from "@sinclair/typebox"; +import { LogLevel } from "../types"; +import { validHTMLElements } from "../handlers/comment/handlers/issue/valid-html-elements"; +import { userCommands } from "../handlers"; +import { ajv } from "../utils/ajv"; +import ms from "ms"; + +const promotionComment = + "###### If you enjoy the DevPool experience, please follow [Ubiquity on GitHub](https://github.com/ubiquity) and star [this repo](https://github.com/ubiquity/devpool-directory) to show your support. It helps a lot!"; +const defaultGreetingHeader = + "Thank you for contributing! Please be sure to set your wallet address before completing your first task so that you can collect your reward."; + +const HtmlEntities = validHTMLElements.map((value) => T.Literal(value)); + +const allHtmlElementsSetToZero = validHTMLElements.reduce((accumulator, current) => { + accumulator[current] = 0; + return accumulator; +}, {} as Record); + +const allCommands = userCommands(false).map((cmd) => ({ name: cmd.id.replace("/", ""), enabled: false })); + +const defaultTimeLabels = [ + { name: "Time: <1 Hour" }, + { name: "Time: <2 Hours" }, + { name: "Time: <4 Hours" }, + { name: "Time: <1 Day" }, + { name: "Time: <1 Week" }, +]; + +const defaultPriorityLabels = [ + { name: "Priority: 1 (Normal)" }, + { name: "Priority: 2 (Medium)" }, + { name: "Priority: 3 (High)" }, + { name: "Priority: 4 (Urgent)" }, + { name: "Priority: 5 (Emergency)" }, +]; + +function StrictObject(obj: T, options?: ObjectOptions) { + return T.Object(obj, { additionalProperties: false, default: {}, ...options }); +} + +export function stringDuration(options?: StringOptions) { + return T.Transform(T.String(options)) + .Decode((value) => { + const decoded = ms(value); + if (decoded === undefined || isNaN(decoded)) { + throw new Error(`Invalid duration string: ${value}`); + } + return ms(value); + }) + .Encode((value) => { + return ms(value); + }); +} + +export const EnvConfigSchema = T.Object({ + WEBHOOK_PROXY_URL: T.String({ format: "uri" }), + LOG_ENVIRONMENT: T.String({ default: "production" }), + LOG_LEVEL: T.Enum(LogLevel, { default: LogLevel.SILLY }), + LOG_RETRY_LIMIT: T.Number({ default: 8 }), + SUPABASE_URL: T.String({ format: "uri" }), + SUPABASE_KEY: T.String(), + X25519_PRIVATE_KEY: T.String(), + PRIVATE_KEY: T.String(), + APP_ID: T.Number(), +}); + +export const validateEnvConfig = ajv.compile(EnvConfigSchema); +export type EnvConfig = Static; + +export const BotConfigSchema = StrictObject( + { + keys: StrictObject({ + evmPrivateEncrypted: T.Optional(T.String()), + openAi: T.Optional(T.String()), + }), + features: StrictObject({ + assistivePricing: T.Boolean({ default: false }), + defaultLabels: T.Array(T.String(), { default: [] }), + newContributorGreeting: StrictObject({ + enabled: T.Boolean({ default: false }), + header: T.String({ default: defaultGreetingHeader }), + displayHelpMenu: T.Boolean({ default: true }), + footer: T.String({ default: promotionComment }), + }), + publicAccessControl: StrictObject({ + setLabel: T.Boolean({ default: true }), + fundExternalClosedIssue: T.Boolean({ default: true }), + }), + }), + openai: StrictObject({ + tokenLimit: T.Number({ default: 100000 }), + }), + timers: StrictObject({ + reviewDelayTolerance: stringDuration({ default: "1 day" }), + taskStaleTimeoutDuration: stringDuration({ default: "4 weeks" }), + taskFollowUpDuration: stringDuration({ default: "0.5 weeks" }), + taskDisqualifyDuration: stringDuration({ default: "1 week" }), + }), + payments: StrictObject({ + maxPermitPrice: T.Number({ default: Number.MAX_SAFE_INTEGER }), + evmNetworkId: T.Number({ default: 1 }), + basePriceMultiplier: T.Number({ default: 1 }), + issueCreatorMultiplier: T.Number({ default: 1 }), + }), + commands: T.Array( + StrictObject({ + name: T.String(), + enabled: T.Boolean(), + }), + { default: allCommands } + ), + incentives: StrictObject({ + comment: StrictObject({ + elements: T.Record(T.Union(HtmlEntities), T.Number({ default: 0 }), { default: allHtmlElementsSetToZero }), + totals: StrictObject({ + character: T.Number({ default: 0, minimum: 0 }), + word: T.Number({ default: 0, minimum: 0 }), + sentence: T.Number({ default: 0, minimum: 0 }), + paragraph: T.Number({ default: 0, minimum: 0 }), + comment: T.Number({ default: 0, minimum: 0 }), + }), + }), + }), + labels: StrictObject({ + time: T.Array(StrictObject({ name: T.String() }), { default: defaultTimeLabels }), + priority: T.Array(StrictObject({ name: T.String() }), { default: defaultPriorityLabels }), + }), + miscellaneous: StrictObject({ + maxConcurrentTasks: T.Number({ default: Number.MAX_SAFE_INTEGER }), + promotionComment: T.String({ default: promotionComment }), + registerWalletWithVerification: T.Boolean({ default: false }), + }), + }, + { default: undefined } // top level object can't have default! +); +export const validateBotConfig = ajv.compile(BotConfigSchema); + +export type BotConfig = StaticDecode; diff --git a/src/types/context.ts b/src/types/context.ts index 73deb69dc..f2d6e4bb9 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,7 +1,9 @@ import { Context as ProbotContext } from "probot"; import { BotConfig } from "./"; +import OpenAI from "openai"; export interface Context { event: ProbotContext; config: BotConfig; + openAi: OpenAI | null; } diff --git a/src/types/index.ts b/src/types/index.ts index 617ce4e22..9fd5354e3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,8 @@ export * from "./payload"; export * from "./label"; export * from "./handlers"; -export * from "./config"; +export * from "./configuration-types"; export * from "./markdown"; export * from "./context"; +export * from "./openai"; +export * from "./logs"; diff --git a/src/types/logs.ts b/src/types/logs.ts new file mode 100644 index 000000000..8e3e1b913 --- /dev/null +++ b/src/types/logs.ts @@ -0,0 +1,9 @@ +export enum LogLevel { + ERROR = "error", + WARN = "warn", + INFO = "info", + HTTP = "http", + VERBOSE = "verbose", + DEBUG = "debug", + SILLY = "silly", +} diff --git a/src/types/openai.ts b/src/types/openai.ts new file mode 100644 index 000000000..e4b62d6bb --- /dev/null +++ b/src/types/openai.ts @@ -0,0 +1,19 @@ +import { Type as T, Static } from "@sinclair/typebox"; + +export const StreamlinedCommentSchema = T.Object({ + login: T.Optional(T.String()), + body: T.Optional(T.String()), +}); + +export type StreamlinedComment = Static; + +export const GPTResponseSchema = T.Object({ + answer: T.Optional(T.String()), + tokenUsage: T.Object({ + output: T.Optional(T.Number()), + input: T.Optional(T.Number()), + total: T.Optional(T.Number()), + }), +}); + +export type GPTResponse = Static; diff --git a/src/types/payload.ts b/src/types/payload.ts index 1cbd268f0..ce874492f 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -33,7 +33,7 @@ export enum GitHubEvent { export enum UserType { User = "User", Bot = "Bot", - // Organization = "Organization", + Organization = "Organization", } export enum IssueType { diff --git a/src/ubiquibot-config-default.ts b/src/ubiquibot-config-default.ts index 10b7cbd96..95952fa31 100644 --- a/src/ubiquibot-config-default.ts +++ b/src/ubiquibot-config-default.ts @@ -1,84 +1,95 @@ import fs from "fs"; import path from "path"; -import { MergedConfig } from "./types"; +import { validHTMLElements } from "./handlers/comment/handlers/issue/valid-html-elements"; +import { LogLevel } from "./types"; + const commandFiles = fs.readdirSync(path.resolve(__dirname, "../src/handlers/comment/handlers")); +const commands = commandFiles.map((file) => { + // dynamic mount based on file names + const commandName = path.basename(file, path.extname(file)); + return { name: commandName, enabled: false }; +}); const promotionComment = "###### If you enjoy the DevPool experience, please follow [Ubiquity on GitHub](https://github.com/ubiquity) and star [this repo](https://github.com/ubiquity/devpool-directory) to show your support. It helps a lot!"; +const allHtmlElementsSetToZero = validHTMLElements.reduce>( + (accumulator, current) => { + accumulator[current] = 0; + return accumulator; + }, + {} as Record +); + +export default { + logs: { + environment: "development", + level: LogLevel.SILLY, + retryLimit: 8, + }, + + features: { + assistivePricing: false, + defaultLabels: [], + newContributorGreeting: { + header: + "Thank you for contributing! \ + Please be sure to set your wallet address \ + before completing your first task so that you can \ + collect your reward.", + displayHelpMenu: true, + footer: promotionComment, + }, + publicAccessControl: { + setLabel: true, + fundExternalClosedIssue: true, + }, + }, + timers: { + taskStaleTimeoutDuration: "1 month", + taskDisqualifyDuration: "1 week", + taskFollowUpDuration: "0.5 weeks", + reviewDelayTolerance: "1 day", + }, + payments: { + evmNetworkId: 1, + basePriceMultiplier: 1, + issueCreatorMultiplier: 1, + maxPermitPrice: Number.MAX_SAFE_INTEGER, + }, + commands, -export const DefaultConfig: MergedConfig = { - evmNetworkId: 1, - priceMultiplier: 1, - issueCreatorMultiplier: 1, - maxPermitPrice: Number.MAX_SAFE_INTEGER, - maxConcurrentTasks: Number.MAX_SAFE_INTEGER, - assistivePricing: false, - registerWalletWithVerification: false, - promotionComment, - defaultLabels: [], - timeLabels: [ - { name: "Time: <1 Hour" }, - { name: "Time: <2 Hours" }, - { name: "Time: <4 Hours" }, - { name: "Time: <1 Day" }, - { name: "Time: <1 Week" }, - ], - priorityLabels: [ - { name: "Priority: 1 (Normal)" }, - { name: "Priority: 2 (Medium)" }, - { name: "Priority: 3 (High)" }, - { name: "Priority: 4 (Urgent)" }, - { name: "Priority: 5 (Emergency)" }, - ], - commandSettings: commandFiles.map((file) => { - const commandName = path.basename(file, path.extname(file)); - return { name: commandName, enabled: false }; - }), // dynamic mount based on file names incentives: { comment: { - elements: { - h1: 0, - h2: 0, - h3: 0, - h4: 0, - h5: 0, - h6: 0, - a: 0, - ul: 0, - li: 0, - p: 0, - img: 0, - code: 0, - table: 0, - td: 0, - tr: 0, - br: 0, - blockquote: 0, - em: 0, - strong: 0, - hr: 0, - del: 0, - pre: 0, - ol: 0, - }, + elements: allHtmlElementsSetToZero, totals: { + character: 0, word: 0, + sentence: 0, + paragraph: 0, + comment: 0, }, }, }, - publicAccessControl: { - setLabel: true, - fundExternalClosedIssue: true, + + miscellaneous: { + maxConcurrentTasks: Number.MAX_SAFE_INTEGER, + registerWalletWithVerification: false, + promotionComment, }, - staleTaskTime: "1 month", - reviewDelayTolerance: "1 day", - permitBaseUrl: "https://pay.ubq.fi", - taskFollowUpDuration: "0.5 weeks", - taskDisqualifyDuration: "1 week", - newContributorGreeting: { - enabled: true, - header: - "Thank you for contributing! Please be sure to set your wallet address before completing your first task so that you can collect your reward.", - displayHelpMenu: true, - footer: promotionComment, + + labels: { + time: [ + { name: "Time: <1 Hour" }, + { name: "Time: <2 Hours" }, + { name: "Time: <4 Hours" }, + { name: "Time: <1 Day" }, + { name: "Time: <1 Week" }, + ], + priority: [ + { name: "Priority: 1 (Normal)" }, + { name: "Priority: 2 (Medium)" }, + { name: "Priority: 3 (High)" }, + { name: "Priority: 4 (Urgent)" }, + { name: "Priority: 5 (Emergency)" }, + ], }, }; diff --git a/src/utils/ajv.ts b/src/utils/ajv.ts index 3159e69f9..b045088d4 100644 --- a/src/utils/ajv.ts +++ b/src/utils/ajv.ts @@ -1,32 +1,41 @@ -import Ajv, { Schema } from "ajv"; +import Ajv, { Schema, ValidateFunction } from "ajv"; import addFormats from "ajv-formats"; -export const ajv = addFormats(new Ajv(), { - formats: [ - "date", - "time", - "date-time", - "duration", - "uri", - "uri-reference", - "uri-template", - "email", - "hostname", - "ipv4", - "ipv6", - "regex", - "uuid", - "json-pointer", - "relative-json-pointer", - "byte", - "int32", - "int64", - "float", - "double", - "password", - "binary", - ], -}); +export const ajv = addFormats( + new Ajv({ + strict: true, + removeAdditional: false, + useDefaults: true, + allErrors: true, + coerceTypes: true, + }), + { + formats: [ + "date", + "time", + "date-time", + "duration", + "uri", + "uri-reference", + "uri-template", + "email", + "hostname", + "ipv4", + "ipv6", + "regex", + "uuid", + "json-pointer", + "relative-json-pointer", + "byte", + "int32", + "int64", + "float", + "double", + "password", + "binary", + ], + } +); function getAdditionalProperties() { return ajv.errors @@ -34,11 +43,16 @@ function getAdditionalProperties() { .map((error) => error.params.additionalProperty); } -export function validate( - scheme: string | Schema, - data: unknown -): { valid: true; error: undefined } | { valid: false; error: string } { - const valid = ajv.validate(scheme, data); +export function validateTypes(schema: Schema | ValidateFunction, data: unknown) { + // : { valid: true; error: undefined } | { valid: false; error: string } + // try { + let valid: boolean; + if (schema instanceof Function) { + valid = schema(data); + } else { + valid = ajv.validate(schema, data); + } + if (!valid) { const additionalProperties = getAdditionalProperties(); return { @@ -49,6 +63,15 @@ export function validate( : null }`, }; + // return { valid: false, error: ajv.errorsText() }; + // throw new Error(ajv.errorsText()); } - return { valid: true, error: undefined }; + + // return data; + + // } + return { valid: true, error: null }; + // } catch (error) { + // throw console.trace(error); + // } } diff --git a/src/utils/generate-configuration.ts b/src/utils/generate-configuration.ts new file mode 100644 index 000000000..f7d8b0b61 --- /dev/null +++ b/src/utils/generate-configuration.ts @@ -0,0 +1,183 @@ +import merge from "lodash/merge"; +import { Context as ProbotContext } from "probot"; +import YAML from "yaml"; +import Runtime from "../bindings/bot-runtime"; +import { Payload, BotConfig, validateBotConfig, stringDuration } from "../types"; +import { DefinedError } from "ajv"; +import { Value } from "@sinclair/typebox/value"; + +const UBIQUIBOT_CONFIG_REPOSITORY = "ubiquibot-config"; +const UBIQUIBOT_CONFIG_FULL_PATH = ".github/ubiquibot-config.yml"; + +export async function generateConfiguration(context: ProbotContext): Promise { + const payload = context.payload as Payload; + + const organizationConfiguration = parseYaml( + await download({ + context, + repository: UBIQUIBOT_CONFIG_REPOSITORY, + owner: payload.organization?.login || payload.repository.owner.login, + }) + ); + + const repositoryConfiguration = parseYaml( + await download({ + context, + repository: payload.repository.name, + owner: payload.repository.owner.login, + }) + ); + + let orgConfig: BotConfig | undefined; + if (organizationConfiguration) { + const valid = validateBotConfig(organizationConfiguration); + if (!valid) { + let errMsg = getErrorMsg(validateBotConfig.errors as DefinedError[]); + if (errMsg) { + errMsg = `Invalid org configuration! \n${errMsg}`; + if (payload.issue?.number) + await context.octokit.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue?.number, + body: errMsg, + }); + throw new Error(errMsg); + } + } + orgConfig = organizationConfiguration as BotConfig; + } + + let repoConfig: BotConfig | undefined; + if (repositoryConfiguration) { + const valid = validateBotConfig(repositoryConfiguration); + if (!valid) { + let errMsg = getErrorMsg(validateBotConfig.errors as DefinedError[]); + if (errMsg) { + errMsg = `Invalid repo configuration! \n${errMsg}`; + if (payload.issue?.number) + await context.octokit.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue?.number, + body: errMsg, + }); + throw new Error(errMsg); + } + } + repoConfig = repositoryConfiguration as BotConfig; + } + + const merged = merge({}, orgConfig, repoConfig); + const valid = validateBotConfig(merged); + if (!valid) { + let errMsg = getErrorMsg(validateBotConfig.errors as DefinedError[]); + if (errMsg) { + errMsg = `Invalid merged configuration! \n${errMsg}`; + if (payload.issue?.number) + await context.octokit.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue?.number, + body: errMsg, + }); + throw new Error(errMsg); + } + } + + // this will run transform functions + try { + transformConfig(merged); + } catch (err) { + if (err instanceof Error && payload.issue?.number) + await context.octokit.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue?.number, + body: `Config error!\n${err.toString()}`, + }); + throw err; + } + + console.dir(merged, { depth: null, colors: true }); + return merged as BotConfig; +} + +// Tranforming the config only works with Typebox and not Ajv +// When you use Decode() it not only transforms the values but also validates the whole config and Typebox doesn't return all errors so we can filter for correct ones +// That's why we have transform every field manually and catch errors +export function transformConfig(config: BotConfig) { + let errorMsg = ""; + try { + config.timers.reviewDelayTolerance = Value.Decode(stringDuration(), config.timers.reviewDelayTolerance); + } catch (err: any) { + if (err.value) { + errorMsg += `Invalid reviewDelayTolerance value: ${err.value}\n`; + } + } + try { + config.timers.taskStaleTimeoutDuration = Value.Decode(stringDuration(), config.timers.taskStaleTimeoutDuration); + } catch (err: any) { + if (err.value) { + errorMsg += `Invalid taskStaleTimeoutDuration value: ${err.value}\n`; + } + } + try { + config.timers.taskFollowUpDuration = Value.Decode(stringDuration(), config.timers.taskFollowUpDuration); + } catch (err: any) { + if (err.value) { + errorMsg += `Invalid taskFollowUpDuration value: ${err.value}\n`; + } + } + try { + config.timers.taskDisqualifyDuration = Value.Decode(stringDuration(), config.timers.taskDisqualifyDuration); + } catch (err: any) { + if (err.value) { + errorMsg += `Invalid taskDisqualifyDuration value: ${err.value}\n`; + } + } + if (errorMsg) throw new Error(errorMsg); +} + +function getErrorMsg(errors: DefinedError[]) { + const errorsWithoutStrict = errors.filter((error) => error.keyword !== "additionalProperties"); + return errorsWithoutStrict.length === 0 + ? null + : errorsWithoutStrict.map((error) => error.instancePath.replaceAll("/", ".") + " " + error.message).join("\n"); +} + +async function download({ + context, + repository, + owner, +}: { + context: ProbotContext; + repository: string; + owner: string; +}): Promise { + if (!repository || !owner) throw new Error("Repo or owner is not defined"); + try { + const { data } = await context.octokit.rest.repos.getContent({ + owner, + repo: repository, + path: UBIQUIBOT_CONFIG_FULL_PATH, + mediaType: { format: "raw" }, + }); + return data as unknown as string; // this will be a string if media format is raw + } catch (err) { + return null; + } +} + +export function parseYaml(data: null | string) { + try { + if (data) { + const parsedData = YAML.parse(data); + return parsedData ?? null; + } + } catch (error) { + const logger = Runtime.getState().logger; + logger.error("Failed to parse YAML", { error }); + } + return null; +} diff --git a/src/utils/get-config.ts b/src/utils/get-config.ts deleted file mode 100644 index 465dda1e0..000000000 --- a/src/utils/get-config.ts +++ /dev/null @@ -1,151 +0,0 @@ -import sodium from "libsodium-wrappers"; -import merge from "lodash/merge"; -import { Context } from "probot"; -import YAML from "yaml"; -import Runtime from "../bindings/bot-runtime"; -import { upsertLastCommentToIssue } from "../helpers/issue"; -import { ConfigSchema, MergedConfig, Payload } from "../types"; -import { BotConfig, Config } from "../types/config"; -import { validate } from "./ajv"; -import { DefaultConfig } from "../ubiquibot-config-default"; -const CONFIG_REPO = "ubiquibot-config"; -const CONFIG_PATH = ".github/ubiquibot-config.yml"; -const KEY_NAME = "privateKeyEncrypted"; -const KEY_PREFIX = "HSK_"; - -export async function getConfig(context: Context) { - const orgConfig = await downloadConfig(context, "org"); - const repoConfig = await downloadConfig(context, "repo"); - const payload = context.payload as Payload; - - let parsedOrg: Config | null; - if (typeof orgConfig === "string") { - parsedOrg = parseYamlConfig(orgConfig); - } else { - parsedOrg = null; - } - if (parsedOrg) { - const { valid, error } = validate(ConfigSchema, parsedOrg); - if (!valid) { - const err = new Error(`Invalid org config: ${error}`); - if (payload.issue) - await upsertLastCommentToIssue({ event: context, config: {} as BotConfig }, payload.issue.number, err.message); - throw err; - } - } - - let parsedRepo: Config | null; - if (typeof repoConfig === "string") { - parsedRepo = parseYamlConfig(repoConfig); - } else { - parsedRepo = null; - } - if (parsedRepo) { - const { valid, error } = validate(ConfigSchema, parsedRepo); - if (!valid) { - const err = new Error(`Invalid repo config: ${error}`); - if (payload.issue) - await upsertLastCommentToIssue({ event: context, config: {} as BotConfig }, payload.issue.number, err.message); - throw err; - } - } - - const parsedDefault: MergedConfig = DefaultConfig; - - const keys = { private: null, public: null } as { private: string | null; public: string | null }; - try { - if (parsedRepo && parsedRepo[KEY_NAME]) { - await getPrivateAndPublicKeys(parsedRepo[KEY_NAME], keys); - } else if (parsedOrg && parsedOrg[KEY_NAME]) { - await getPrivateAndPublicKeys(parsedOrg[KEY_NAME], keys); - } - } catch (error) { - console.warn("Failed to get keys", { error }); - } - const configs: MergedConfigs = { parsedDefault, parsedOrg, parsedRepo }; - const mergedConfigData: MergedConfig = mergeConfigs(configs); - const configData = { keys, ...mergedConfigData }; - return configData; -} - -async function downloadConfig(context: Context, type: "org" | "repo") { - const payload = context.payload as Payload; - let repo: string; - let owner: string; - if (type === "org") { - repo = CONFIG_REPO; - owner = payload.organization?.login || payload.repository.owner.login; - } else { - repo = payload.repository.name; - owner = payload.repository.owner.login; - } - if (!repo || !owner) return null; - - const { data } = await context.octokit.rest.repos.getContent({ - owner, - repo, - path: CONFIG_PATH, - mediaType: { format: "raw" }, - }); - - return data; -} - -interface MergedConfigs { - parsedRepo: Config | null; - parsedOrg: Config | null; - parsedDefault: MergedConfig; -} - -export function parseYamlConfig(data?: string) { - try { - if (data) { - const parsedData = YAML.parse(data) as Config; - return parsedData ?? null; - } - } catch (error) { - const logger = Runtime.getState().logger; - logger.error("Failed to parse YAML", { error }); - } - return null; -} - -async function getPrivateAndPublicKeys(cipherText: string, keys: { private: string | null; public: string | null }) { - await sodium.ready; - const X25519_PRIVATE_KEY = process.env.X25519_PRIVATE_KEY; - if (!X25519_PRIVATE_KEY) { - return console.warn("X25519_PRIVATE_KEY is not defined"); - } - keys.public = await getScalarKey(X25519_PRIVATE_KEY); - if (!keys.public) { - return console.warn("Public key is null"); - } - // console.trace(); - const binPub = sodium.from_base64(keys.public, sodium.base64_variants.URLSAFE_NO_PADDING); - const binPriv = sodium.from_base64(X25519_PRIVATE_KEY, sodium.base64_variants.URLSAFE_NO_PADDING); - const binCipher = sodium.from_base64(cipherText, sodium.base64_variants.URLSAFE_NO_PADDING); - - const walletPrivateKey: string | null = sodium.crypto_box_seal_open(binCipher, binPub, binPriv, "text"); - // console.trace({ walletPrivateKey }); - keys.private = walletPrivateKey?.replace(KEY_PREFIX, ""); - return keys; -} - -async function getScalarKey(X25519_PRIVATE_KEY: string) { - const logger = Runtime.getState().logger; - - if (X25519_PRIVATE_KEY !== null) { - await sodium.ready; - // console.trace(); - const binPriv = sodium.from_base64(X25519_PRIVATE_KEY, sodium.base64_variants.URLSAFE_NO_PADDING); - const scalerPub = sodium.crypto_scalarmult_base(binPriv, "base64"); - return scalerPub; - } else { - logger.warn("X25519_PRIVATE_KEY is null"); - return null; - } -} - -function mergeConfigs(configs: MergedConfigs) { - return merge({}, configs.parsedDefault, configs.parsedOrg, configs.parsedRepo); -} diff --git a/src/utils/private.ts b/src/utils/private.ts new file mode 100644 index 000000000..040e90f4b --- /dev/null +++ b/src/utils/private.ts @@ -0,0 +1,38 @@ +import sodium from "libsodium-wrappers"; +import { env } from "../bindings/env"; +const KEY_PREFIX = "HSK_"; + +export async function decryptKeys( + cipherText: string +): Promise<{ privateKey: string; publicKey: string } | { privateKey: null; publicKey: null }> { + await sodium.ready; + + let _public: null | string = null; + let _private: null | string = null; + + const { X25519_PRIVATE_KEY } = env; + + if (!X25519_PRIVATE_KEY) { + console.warn("X25519_PRIVATE_KEY is not defined"); + return { privateKey: null, publicKey: null }; + } + _public = await getScalarKey(X25519_PRIVATE_KEY); + if (!_public) { + console.warn("Public key is null"); + return { privateKey: null, publicKey: null }; + } + const binPub = sodium.from_base64(_public, sodium.base64_variants.URLSAFE_NO_PADDING); + const binPriv = sodium.from_base64(X25519_PRIVATE_KEY, sodium.base64_variants.URLSAFE_NO_PADDING); + const binCipher = sodium.from_base64(cipherText, sodium.base64_variants.URLSAFE_NO_PADDING); + + const walletPrivateKey: string | null = sodium.crypto_box_seal_open(binCipher, binPub, binPriv, "text"); + _private = walletPrivateKey?.replace(KEY_PREFIX, ""); + return { privateKey: _private, publicKey: _public }; +} + +async function getScalarKey(X25519_PRIVATE_KEY: string) { + await sodium.ready; + const binPriv = sodium.from_base64(X25519_PRIVATE_KEY, sodium.base64_variants.URLSAFE_NO_PADDING); + const scalerPub = sodium.crypto_scalarmult_base(binPriv, "base64"); + return scalerPub; +} diff --git a/yarn.lock b/yarn.lock index 63a18518f..a437cf3dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2399,10 +2399,10 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sinclair/typebox@^0.31.5": - version "0.31.15" - resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.15.tgz" - integrity sha512-gheE0Z2QWB/EuUwirniP+vq17N0MdQ+9bKyy2lPJzcBin6piBxOrazTYOB18N+oeBwVVepAmlqqo9KbpSl9DOA== +"@sinclair/typebox@^0.31.22": + version "0.31.22" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.31.22.tgz#f13fa4050a7e883d252365902e38186fa0dc8ab8" + integrity sha512-CKviMgpcXd8q8IsQQD8cCleswe4/EkQRcOqtVQcP1e+XUyszjJYjgL5Dtf3XunWZc2zEGmQPqJEsq08NiW9xfw== "@sinonjs/commons@^3.0.0": version "3.0.0" @@ -3062,7 +3062,7 @@ aggregate-error@^3.0.0, aggregate-error@^3.1.0: ajv-formats@^2.1.1: version "2.1.1" - resolved "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== dependencies: ajv "^8.0.0" @@ -3077,7 +3077,7 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.11.0, ajv@^8.11.2: +ajv@^8.0.0, ajv@^8.11.0, ajv@^8.12.0: version "8.12.0" resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== @@ -4686,7 +4686,7 @@ fast-copy@^3.0.0: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== fast-equals@^4.0.3: @@ -6032,12 +6032,12 @@ json-parse-even-better-errors@^3.0.0: json-schema-traverse@^0.4.1: version "0.4.1" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== json-schema-traverse@^1.0.0: version "1.0.0" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== json-stable-stringify-without-jsonify@^1.0.1: @@ -7418,7 +7418,7 @@ punycode@^1.3.2: punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: version "2.3.0" - resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== pure-rand@^6.0.0: @@ -7589,7 +7589,7 @@ require-directory@^2.1.1: require-from-string@^2.0.2: version "2.0.2" - resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== requires-port@^1.0.0: @@ -8465,7 +8465,7 @@ update-dotenv@^1.1.1: uri-js@^4.2.2: version "4.4.1" - resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== dependencies: punycode "^2.1.0" @@ -8797,7 +8797,7 @@ zod-validation-error@^1.5.0: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-1.5.0.tgz#2b355007a1c3b7fb04fa476bfad4e7b3fd5491e3" integrity sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw== -zod@3.22.4: +zod@3.22.4, zod@^3.22.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==