diff --git a/.cspell.json b/.cspell.json index 0af8db43..f635fbc3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -18,7 +18,9 @@ "ubiquibot", "signoff", "sonarjs", - "mswjs" + "mswjs", + "unassignment", + "unassignments" ], "dictionaries": ["typescript", "node", "software-terms"], "import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"], diff --git a/.github/workflows/worker-deploy.yml b/.github/workflows/worker-deploy.yml index 0a3942c1..6c2bb0f4 100644 --- a/.github/workflows/worker-deploy.yml +++ b/.github/workflows/worker-deploy.yml @@ -41,9 +41,11 @@ jobs: secrets: | SUPABASE_URL SUPABASE_KEY + APP_ID env: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} + APP_ID: ${{ secrets.APP_ID }} - name: Write Deployment URL to Summary run: | diff --git a/README.md b/README.md index 5f13e9ac..a39b428b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu taskStaleTimeoutDuration: "30 Days" maxConcurrentTasks: 3 startRequiresWallet: true # default is true + emptyWalletText: "Please set your wallet address with the /wallet command first and try again." ``` # Testing diff --git a/manifest.json b/manifest.json index a1a05724..1e2d19c2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Start | Stop", "description": "Assign or un-assign yourself from an issue.", - "ubiquity:listeners": ["issue_comment.created"], + "ubiquity:listeners": ["issue_comment.created", "issues.assigned"], "commands": { "start": { "ubiquity:example": "/start", diff --git a/src/adapters/supabase/helpers/user.ts b/src/adapters/supabase/helpers/user.ts index 635e4ff9..e1d313cd 100644 --- a/src/adapters/supabase/helpers/user.ts +++ b/src/adapters/supabase/helpers/user.ts @@ -17,10 +17,10 @@ export class User extends Super { if ((error && !data) || !data.wallets?.address) { this.context.logger.error("No wallet address found", { userId, issueNumber }); if (this.context.config.startRequiresWallet) { - await addCommentToIssue(this.context, "```diff\n! Please set your wallet address with the /wallet command first and try again.\n```"); + await addCommentToIssue(this.context, this.context.config.emptyWalletText); throw new Error("No wallet address found"); } else { - await addCommentToIssue(this.context, "```diff\n# Please set your wallet address with the /wallet command in order to be eligible for rewards.\n```"); + await addCommentToIssue(this.context, this.context.config.emptyWalletText); } } else { this.context.logger.info("Successfully fetched wallet", { userId, address: data.wallets?.address }); diff --git a/src/handlers/result-types.ts b/src/handlers/result-types.ts new file mode 100644 index 00000000..adbfe769 --- /dev/null +++ b/src/handlers/result-types.ts @@ -0,0 +1,10 @@ +export enum HttpStatusCode { + OK = 200, + NOT_MODIFIED = 304, +} + +export interface Result { + status: HttpStatusCode; + content?: string; + reason?: string; +} diff --git a/src/handlers/shared/check-assignments.ts b/src/handlers/shared/check-assignments.ts new file mode 100644 index 00000000..c969231d --- /dev/null +++ b/src/handlers/shared/check-assignments.ts @@ -0,0 +1,92 @@ +import { Context } from "../../types"; +import { getOwnerRepoFromHtmlUrl } from "../../utils/issue"; + +async function getUserStopComments(context: Context, username: string): Promise { + const { payload, octokit, logger } = context; + const { number, html_url } = payload.issue; + const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url); + + try { + const comments = await octokit.paginate(octokit.issues.listComments, { + owner, + repo, + issue_number: number, + }); + + return comments.filter((comment) => comment.body?.includes("/stop") && comment.user?.login.toLowerCase() === username.toLowerCase()).length; + } catch (error) { + throw new Error(logger.error("Error while getting user stop comments", { error: error as Error }).logMessage.raw); + } +} + +export async function hasUserBeenUnassigned(context: Context, username: string): Promise { + const { + env: { APP_ID }, + } = context; + const events = await getAssignmentEvents(context); + const userAssignments = events.filter((event) => event.assignee === username); + + if (userAssignments.length === 0) { + return false; + } + + const unassignedEvents = userAssignments.filter((event) => event.event === "unassigned"); + // all bot unassignments (/stop, disqualification, etc) + // TODO: task-xp-guard: will also prevent future assignments so we need to add a comment tracker we can use here + const botUnassigned = unassignedEvents.filter((event) => event.actorId === APP_ID); + // UI assignment + const adminUnassigned = unassignedEvents.filter((event) => event.actor !== username && event.actorId !== APP_ID); + // UI assignment + const userUnassigned = unassignedEvents.filter((event) => event.actor === username); + const userStopComments = await getUserStopComments(context, username); + /** + * Basically the bot will be the actor in most cases but if we + * remove the /stop usage which does not trigger future disqualification + * then any other bot unassignment will be considered valid + */ + + const botMinusUserStopCommands = Math.max(0, botUnassigned.length - userStopComments); + const userUiMinusUserStopCommands = Math.max(0, userUnassigned.length - userStopComments); + + return botMinusUserStopCommands > 0 || userUiMinusUserStopCommands > 0 || adminUnassigned.length > 0; +} + +async function getAssignmentEvents(context: Context) { + const { repository, issue } = context.payload; + try { + const data = await context.octokit.paginate(context.octokit.issues.listEventsForTimeline, { + owner: repository.owner.login, + repo: repository.name, + issue_number: issue.number, + }); + + const events = data + .filter((event) => event.event === "assigned" || event.event === "unassigned") + .map((event) => { + let actor, assignee, createdAt, actorId; + + if ((event.event === "unassigned" || event.event === "assigned") && "actor" in event && event.actor && "assignee" in event && event.assignee) { + actor = event.actor.login; + assignee = event.assignee.login; + createdAt = event.created_at; + actorId = event.actor.id; + } + + return { + event: event.event, + actor, + actorId, + assignee, + createdAt, + }; + }); + + return events + .filter((event) => event !== undefined) + .sort((a, b) => { + return new Date(a.createdAt || "").getTime() - new Date(b.createdAt || "").getTime(); + }); + } catch (error) { + throw new Error(context.logger.error("Error while getting assignment events", { error: error as Error }).logMessage.raw); + } +} diff --git a/src/handlers/shared/generate-assignment-comment.ts b/src/handlers/shared/generate-assignment-comment.ts index 7c35c6e9..c8c3317a 100644 --- a/src/handlers/shared/generate-assignment-comment.ts +++ b/src/handlers/shared/generate-assignment-comment.ts @@ -1,6 +1,7 @@ -import { Context } from "../../types/context"; +import { Context } from "../../types"; +import { calculateDurations } from "../../utils/shared"; -const options: Intl.DateTimeFormatOptions = { +export const options: Intl.DateTimeFormatOptions = { weekday: "short", month: "short", day: "numeric", @@ -10,16 +11,23 @@ const options: Intl.DateTimeFormatOptions = { timeZoneName: "short", }; -export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, duration: number) { +export function getDeadline(issue: Context["payload"]["issue"]): string | null { + if (!issue?.labels) { + throw new Error("No labels are set."); + } + const startTime = new Date().getTime(); + const duration: number = calculateDurations(issue.labels).shift() ?? 0; + if (!duration) return null; + const endTime = new Date(startTime + duration * 1000); + return endTime.toLocaleString("en-US", options); +} + +export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, deadline: string | null) { const startTime = new Date().getTime(); - let endTime: null | Date = null; - let deadline: null | string = null; - endTime = new Date(startTime + duration * 1000); - deadline = endTime.toLocaleString("en-US", options); return { daysElapsedSinceTaskCreation: Math.floor((startTime - new Date(issueCreatedAt).getTime()) / 1000 / 60 / 60 / 24), - deadline: duration > 0 ? deadline : null, + deadline: deadline ?? null, registeredWallet: (await context.adapters.supabase.user.getWalletByUserId(senderId, issueNumber)) || "Register your wallet address using the following slash command: `/wallet 0x0000...0000`", diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts index a7804889..19a66494 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -1,12 +1,13 @@ import { Context, ISSUE_TYPE, Label } from "../../types"; -import { isParentIssue, getAvailableOpenedPullRequests, getAssignedIssues, addAssignees, addCommentToIssue, getTimeValue } from "../../utils/issue"; -import { calculateDurations } from "../../utils/shared"; +import { addAssignees, addCommentToIssue, getAssignedIssues, getAvailableOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue"; +import { HttpStatusCode, Result } from "../result-types"; +import { hasUserBeenUnassigned } from "./check-assignments"; import { checkTaskStale } from "./check-task-stale"; -import { generateAssignmentComment } from "./generate-assignment-comment"; +import { generateAssignmentComment, getDeadline } from "./generate-assignment-comment"; import structuredMetadata from "./structured-metadata"; import { assignTableComment } from "./table"; -export async function start(context: Context, issue: Context["payload"]["issue"], sender: Context["payload"]["sender"], teammates: string[]) { +export async function start(context: Context, issue: Context["payload"]["issue"], sender: Context["payload"]["sender"], teammates: string[]): Promise { const { logger, config } = context; const { maxConcurrentTasks, taskStaleTimeoutDuration } = config; @@ -16,8 +17,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"] context, "```diff\n# Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.\n```" ); - logger.error(`Skipping '/start' since the issue is a parent issue`); - return { output: "Parent issue detected" }; + throw new Error(logger.error(`Skipping '/start' since the issue is a parent issue`).logMessage.raw); } let commitHash: string | null = null; @@ -36,7 +36,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"] // is it assignable? if (issue.state === ISSUE_TYPE.CLOSED) { - throw logger.error("This issue is closed, please choose another.", { issueNumber: issue.number }); + throw new Error(logger.error("This issue is closed, please choose another.", { issueNumber: issue.number }).logMessage.raw); } const assignees = issue?.assignees ?? []; @@ -44,43 +44,58 @@ export async function start(context: Context, issue: Context["payload"]["issue"] // find out if the issue is already assigned if (assignees.length !== 0) { const isCurrentUserAssigned = !!assignees.find((assignee) => assignee?.login === sender.login); - throw logger.error( - isCurrentUserAssigned ? "You are already assigned to this task." : "This issue is already assigned. Please choose another unassigned task.", - { issueNumber: issue.number } + throw new Error( + logger.error( + isCurrentUserAssigned ? "You are already assigned to this task." : "This issue is already assigned. Please choose another unassigned task.", + { issueNumber: issue.number } + ).logMessage.raw ); } teammates.push(sender.login); + const toAssign = []; // check max assigned issues for (const user of teammates) { - await handleTaskLimitChecks(user, context, maxConcurrentTasks, logger, sender.login); + if (await handleTaskLimitChecks(user, context, maxConcurrentTasks, logger, sender.login)) { + toAssign.push(user); + } + } + + let error: string | null = null; + + if (toAssign.length === 0 && teammates.length > 1) { + error = "All teammates have reached their max task limit. Please close out some tasks before assigning new ones."; + } else if (toAssign.length === 0) { + error = "You have reached your max task limit. Please close out some tasks before assigning new ones."; + } + + if (error) { + throw new Error(logger.error(error, { issueNumber: issue.number }).logMessage.raw); } // get labels - const labels = issue.labels; + const labels = issue.labels ?? []; const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); if (!priceLabel) { - throw logger.error("No price label is set to calculate the duration", { issueNumber: issue.number }); + throw new Error(logger.error("No price label is set to calculate the duration", { issueNumber: issue.number }).logMessage.raw); } - const duration: number = calculateDurations(labels).shift() ?? 0; + const deadline = getDeadline(issue); + const toAssignIds = await fetchUserIds(context, toAssign); - const { id } = sender; + const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, deadline); const logMessage = logger.info("Task assigned successfully", { - duration, + taskDeadline: assignmentComment.deadline, + taskAssignees: toAssignIds, priceLabel, revision: commitHash?.substring(0, 7), - assignees: teammates, - issue: issue.number, }); - - const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, id, duration); const metadata = structuredMetadata.create("Assignment", logMessage); - // assign the issue - await addAssignees(context, issue.number, teammates); + // add assignee + await addAssignees(context, issue.number, toAssign); const isTaskStale = checkTaskStale(getTimeValue(taskStaleTimeoutDuration), issue.created_at); @@ -98,7 +113,25 @@ export async function start(context: Context, issue: Context["payload"]["issue"] ].join("\n") as string ); - return { output: "Task assigned successfully" }; + return { content: "Task assigned successfully", status: HttpStatusCode.OK }; +} + +async function fetchUserIds(context: Context, username: string[]) { + const ids = []; + + for (const user of username) { + const { data } = await context.octokit.rest.users.getByUsername({ + username: user, + }); + + ids.push(data.id); + } + + if (ids.filter((id) => !id).length > 0) { + throw new Error("Error while fetching user ids"); + } + + return ids; } async function handleTaskLimitChecks(username: string, context: Context, maxConcurrentTasks: number, logger: Context["logger"], sender: string) { @@ -106,11 +139,20 @@ async function handleTaskLimitChecks(username: string, context: Context, maxConc const assignedIssues = await getAssignedIssues(context, username); // check for max and enforce max - if (assignedIssues.length - openedPullRequests.length >= maxConcurrentTasks) { - throw logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, { + + if (Math.abs(assignedIssues.length - openedPullRequests.length) >= maxConcurrentTasks) { + const log = logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, { assignedIssues: assignedIssues.length, openedPullRequests: openedPullRequests.length, maxConcurrentTasks, }); + await addCommentToIssue(context, log?.logMessage.diff as string); + return false; + } + + if (await hasUserBeenUnassigned(context, username)) { + throw new Error(logger.error(`${username} you were previously unassigned from this task. You cannot be reassigned.`, { username }).logMessage.raw); } + + return true; } diff --git a/src/handlers/shared/stop.ts b/src/handlers/shared/stop.ts index 3a2541e4..21161510 100644 --- a/src/handlers/shared/stop.ts +++ b/src/handlers/shared/stop.ts @@ -1,7 +1,8 @@ -import { Assignee, Context } from "../../types"; +import { Assignee, Context, Sender } from "../../types"; import { addCommentToIssue, closePullRequestForAnIssue } from "../../utils/issue"; +import { HttpStatusCode, Result } from "../result-types"; -export async function stop(context: Context, issue: Context["payload"]["issue"], sender: Context["payload"]["sender"], repo: Context["payload"]["repository"]) { +export async function stop(context: Context, issue: Context["payload"]["issue"], sender: Sender, repo: Context["payload"]["repository"]): Promise { const { logger } = context; const issueNumber = issue.number; @@ -11,7 +12,7 @@ export async function stop(context: Context, issue: Context["payload"]["issue"], const userToUnassign = assignees.find((assignee: Partial) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); if (!userToUnassign) { - throw new Error(logger.error("You are not assigned to this task", { issueNumber, user: sender.login })?.logMessage.diff as string); + throw new Error(logger.error("You are not assigned to this task", { issueNumber, user: sender.login })?.logMessage.raw as string); } // close PR @@ -32,11 +33,13 @@ export async function stop(context: Context, issue: Context["payload"]["issue"], assignees: [userToUnassign.login], }); } catch (err) { - throw logger.error(`Error while removing ${userToUnassign.login} from the issue: `, { - err, - issueNumber, - user: userToUnassign.login, - }); + throw new Error( + logger.error(`Error while removing ${userToUnassign.login} from the issue: `, { + err, + issueNumber, + user: userToUnassign.login, + }).logMessage.raw + ); } const unassignedLog = logger.info("You have been unassigned from the task", { @@ -45,5 +48,5 @@ export async function stop(context: Context, issue: Context["payload"]["issue"], }); await addCommentToIssue(context, unassignedLog?.logMessage.diff as string); - return { output: "Task unassigned successfully" }; + return { content: "Task unassigned successfully", status: HttpStatusCode.OK }; } diff --git a/src/handlers/user-start-stop.ts b/src/handlers/user-start-stop.ts index a7c8815b..143287d6 100644 --- a/src/handlers/user-start-stop.ts +++ b/src/handlers/user-start-stop.ts @@ -1,8 +1,14 @@ -import { Context } from "../types"; +import { Context, isContextCommentCreated } from "../types"; +import { addCommentToIssue } from "../utils/issue"; +import { HttpStatusCode, Result } from "./result-types"; +import { getDeadline } from "./shared/generate-assignment-comment"; import { start } from "./shared/start"; import { stop } from "./shared/stop"; -export async function userStartStop(context: Context): Promise<{ output: string | null }> { +export async function userStartStop(context: Context): Promise { + if (!isContextCommentCreated(context)) { + return { status: HttpStatusCode.NOT_MODIFIED }; + } const { payload } = context; const { issue, comment, sender, repository } = payload; const slashCommand = comment.body.split(" ")[0].replace("/", ""); @@ -17,5 +23,21 @@ export async function userStartStop(context: Context): Promise<{ output: string return await start(context, issue, sender, teamMates); } - return { output: null }; + return { status: HttpStatusCode.NOT_MODIFIED }; +} + +export async function userSelfAssign(context: Context): Promise { + const { payload } = context; + const { issue } = payload; + const deadline = getDeadline(issue); + + if (!deadline) { + context.logger.debug("Skipping deadline posting message because no deadline has been set."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + + const users = issue.assignees.map((user) => `@${user?.login}`).join(", "); + + await addCommentToIssue(context, `${users} the deadline is at ${deadline}`); + return { status: HttpStatusCode.OK }; } diff --git a/src/plugin.ts b/src/plugin.ts index 9511216e..84763253 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest"; import { createClient } from "@supabase/supabase-js"; import { LogReturn, Logs } from "@ubiquity-dao/ubiquibot-logger"; import { createAdapters } from "./adapters"; -import { userStartStop } from "./handlers/user-start-stop"; +import { userSelfAssign, userStartStop } from "./handlers/user-start-stop"; import { Context, Env, PluginInputs } from "./types"; import { addCommentToIssue } from "./utils/issue"; @@ -22,22 +22,25 @@ export async function startStopTask(inputs: PluginInputs, env: Env) { context.adapters = createAdapters(supabase, context); - if (context.eventName === "issue_comment.created") { - try { - return await userStartStop(context); - } catch (err) { - let errorMessage; - if (err instanceof LogReturn) { - errorMessage = err; - } else if (err instanceof Error) { - errorMessage = context.logger.error(err.message, { error: err }); - } else { - errorMessage = context.logger.error("An error occurred", { err }); - } - await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n`); + try { + switch (context.eventName) { + case "issue_comment.created": + return await userStartStop(context); + case "issues.assigned": + return await userSelfAssign(context); + default: + context.logger.error(`Unsupported event: ${context.eventName}`); } - } else { - context.logger.error(`Unsupported event: ${context.eventName}`); + } catch (err) { + let errorMessage; + if (err instanceof LogReturn) { + errorMessage = err; + } else if (err instanceof Error) { + errorMessage = context.logger.error(err.message, { error: err }); + } else { + errorMessage = context.logger.error("An error occurred", { err }); + } + await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n`); } } diff --git a/src/types/context.ts b/src/types/context.ts index 583759fc..85db1d23 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -5,12 +5,16 @@ import { createAdapters } from "../adapters"; import { Env } from "./env"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; -export type SupportedEventsU = "issue_comment.created"; +export type SupportedEventsU = "issue_comment.created" | "issues.assigned"; export type SupportedEvents = { [K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent : never; }; +export function isContextCommentCreated(context: Context): context is Context<"issue_comment.created"> { + return "comment" in context.payload; +} + export interface Context { eventName: T; payload: TU["payload"]; diff --git a/src/types/env.ts b/src/types/env.ts index 3d09c116..3c3722a3 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -1,9 +1,31 @@ import { Type as T } from "@sinclair/typebox"; import { StaticDecode } from "@sinclair/typebox"; +import { StandardValidator } from "typebox-validators"; +const ERROR_MSG = "Invalid APP_ID"; export const envSchema = T.Object({ SUPABASE_URL: T.String(), SUPABASE_KEY: T.String(), + APP_ID: T.Transform(T.Union([T.String(), T.Number()], { examples: 123456 })) + .Decode((value) => { + if (typeof value === "string" && !isNaN(Number(value))) { + return Number(value); + } + if (typeof value === "number") { + return value; + } + throw new Error(ERROR_MSG); + }) + .Encode((value) => { + if (typeof value === "number") { + return value.toString(); + } + if (typeof value === "string") { + return value; + } + throw new Error(ERROR_MSG); + }), }); export type Env = StaticDecode; +export const envConfigValidator = new StandardValidator(envSchema); diff --git a/src/types/payload.ts b/src/types/payload.ts index a3877d4d..c0a7787d 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -1,12 +1,13 @@ import { RestEndpointMethodTypes } from "@octokit/rest"; export type Issue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; -export type PullRequest = RestEndpointMethodTypes["pulls"]["list"]["response"]["data"][0]; export type Label = RestEndpointMethodTypes["issues"]["listLabelsOnIssue"]["response"]["data"][0]; export type Review = RestEndpointMethodTypes["pulls"]["listReviews"]["response"]["data"][0]; export type TimelineEventResponse = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]; export type TimelineEvents = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0]; export type Assignee = Issue["assignee"]; +export type GitHubIssueSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"]; +export type Sender = { login: string; id: number }; export const ISSUE_TYPE = { OPEN: "open", diff --git a/src/types/plugin-input.ts b/src/types/plugin-input.ts index a25b4510..01a1c955 100644 --- a/src/types/plugin-input.ts +++ b/src/types/plugin-input.ts @@ -17,6 +17,7 @@ export const startStopSchema = T.Object( taskStaleTimeoutDuration: T.String({ default: "30 Days" }), maxConcurrentTasks: T.Number({ default: 3 }), startRequiresWallet: T.Boolean({ default: true }), + emptyWalletText: T.String({ default: "Please set your wallet address with the /wallet command first and try again." }), }, { default: {}, diff --git a/src/utils/issue.ts b/src/utils/issue.ts index 9c4e1610..4133c5de 100644 --- a/src/utils/issue.ts +++ b/src/utils/issue.ts @@ -1,6 +1,6 @@ import ms from "ms"; import { Context } from "../types/context"; -import { Issue, PullRequest, Review } from "../types/payload"; +import { Issue, GitHubIssueSearch, Review } from "../types/payload"; import { getLinkedPullRequests, GetLinkedResults } from "./get-linked-prs"; export function isParentIssue(body: string) { @@ -16,25 +16,26 @@ export async function getAssignedIssues(context: Context, username: string): Pro q: `is:issue is:open assignee:${username} org:${payload.repository.owner.login}`, })) as Issue[]; } catch (err: unknown) { - throw context.logger.error("Fetching assigned issues failed!", { error: err as Error }); + throw new Error(context.logger.error("Fetching assigned issues failed!", { error: err as Error }).logMessage.raw); } } export async function addCommentToIssue(context: Context, message: string | null) { - const comment = message as string; - - const { payload } = context; + const { payload, logger } = context; + if (!message) { + logger.error("Message is not defined"); + return; + } - const issueNumber = payload.issue.number; try { await context.octokit.rest.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, - issue_number: issueNumber, - body: comment, + issue_number: payload.issue.number, + body: message, }); } catch (err: unknown) { - throw context.logger.error("Adding a comment failed!", { error: err as Error }); + throw new Error(context.logger.error("Adding a comment failed!", { error: err as Error }).logMessage.raw); } } @@ -50,17 +51,19 @@ export async function closePullRequest(context: Context, results: GetLinkedResul state: "closed", }); } catch (err: unknown) { - throw context.logger.error("Closing pull requests failed!", { error: err as Error }); + throw new Error(context.logger.error("Closing pull requests failed!", { error: err as Error }).logMessage.raw); } } export async function closePullRequestForAnIssue(context: Context, issueNumber: number, repository: Context["payload"]["repository"], author: string) { const { logger } = context; if (!issueNumber) { - throw logger.error("Issue is not defined", { - issueNumber, - repository: repository.name, - }); + throw new Error( + logger.error("Issue is not defined", { + issueNumber, + repository: repository.name, + }).logMessage.raw + ); } const linkedPullRequests = await getLinkedPullRequests(context, { @@ -125,7 +128,9 @@ async function confirmMultiAssignment(context: Context, issueNumber: number, use }); if (!assignees?.length) { - throw logger.error("We detected that this task was not assigned to anyone. Please report this to the maintainers.", { issueNumber, usernames }); + throw new Error( + logger.error("We detected that this task was not assigned to anyone. Please report this to the maintainers.", { issueNumber, usernames }).logMessage.raw + ); } if (isPrivate && assignees?.length <= 1) { @@ -147,46 +152,49 @@ export async function addAssignees(context: Context, issueNo: number, assignees: assignees, }); } catch (e: unknown) { - throw context.logger.error("Adding the assignee failed", { assignee: assignees, issueNo, error: e as Error }); + throw new Error(context.logger.error("Adding the assignee failed", { assignee: assignees, issueNo, error: e as Error }).logMessage.raw); } await confirmMultiAssignment(context, issueNo, assignees); } -export async function getAllPullRequests(context: Context, state: "open" | "closed" | "all" = "open") { - const payload = context.payload; +export async function getAllPullRequests(context: Context, state: "open" | "closed" | "all" = "open", username: string) { + const { payload } = context; try { - return (await context.octokit.paginate(context.octokit.rest.pulls.list, { - owner: payload.repository.owner.login, - repo: payload.repository.name, - state, + return (await context.octokit.paginate(context.octokit.search.issuesAndPullRequests, { + q: `org:${payload.repository.owner.login} author:${username} state:${state}`, per_page: 100, - })) as PullRequest[]; + order: "desc", + sort: "created", + })) as GitHubIssueSearch["items"]; } catch (err: unknown) { - throw context.logger.error("Fetching all pull requests failed!", { error: err as Error }); + throw new Error(context.logger.error("Fetching all pull requests failed!", { error: err as Error }).logMessage.raw); } } -export async function getAllPullRequestReviews(context: Context, pullNumber: number, format: "raw" | "html" | "text" | "full" = "raw") { - const payload = context.payload; - - const owner = payload.repository.owner.login; - const repo = payload.repository.name; - +export async function getAllPullRequestReviews(context: Context, pullNumber: number, owner: string, repo: string) { try { - return (await context.octokit.paginate(context.octokit.rest.pulls.listReviews, { + return (await context.octokit.paginate(context.octokit.pulls.listReviews, { owner, repo, pull_number: pullNumber, per_page: 100, - mediaType: { - format, - }, })) as Review[]; } catch (err: unknown) { - throw context.logger.error("Fetching all pull request reviews failed!", { error: err as Error }); + throw new Error(context.logger.error("Fetching all pull request reviews failed!", { error: err as Error }).logMessage.raw); + } +} + +export function getOwnerRepoFromHtmlUrl(url: string) { + const parts = url.split("/"); + if (parts.length < 5) { + throw new Error("Invalid URL"); } + return { + owner: parts[3], + repo: parts[4], + }; } export async function getAvailableOpenedPullRequests(context: Context, username: string) { @@ -198,7 +206,8 @@ export async function getAvailableOpenedPullRequests(context: Context, username: for (let i = 0; i < openedPullRequests.length; i++) { const openedPullRequest = openedPullRequests[i]; - const reviews = await getAllPullRequestReviews(context, openedPullRequest.number); + const { owner, repo } = getOwnerRepoFromHtmlUrl(openedPullRequest.html_url); + const reviews = await getAllPullRequestReviews(context, openedPullRequest.number, owner, repo); if (reviews.length > 0) { const approvedReviews = reviews.find((review) => review.state === "APPROVED"); @@ -225,8 +234,8 @@ export function getTimeValue(timeString: string): number { } async function getOpenedPullRequests(context: Context, username: string): Promise> { - const prs = await getAllPullRequests(context, "open"); - return prs.filter((pr) => !pr.draft && (pr.user?.login === username || !username)); + const prs = await getAllPullRequests(context, "open", username); + return prs.filter((pr) => pr.pull_request && pr.state === "open"); } /** diff --git a/src/worker.ts b/src/worker.ts index b572ebc9..d6c19548 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,7 @@ import { Value } from "@sinclair/typebox/value"; -import { startStopTask } from "./plugin"; -import { Env, startStopSchema, startStopSettingsValidator } from "./types"; import manifest from "../manifest.json"; +import { startStopTask } from "./plugin"; +import { Env, envConfigValidator, startStopSchema, startStopSettingsValidator } from "./types"; export default { async fetch(request: Request, env: Env): Promise { @@ -36,6 +36,19 @@ export default { throw new Error("Invalid settings provided"); } + if (!envConfigValidator.test(env)) { + const errorDetails: string[] = []; + for (const error of envConfigValidator.errors(env)) { + errorDetails.push(`${error.path}: ${error.message}`); + } + return new Response(JSON.stringify({ error: `Bad Request: the environment is invalid. ${errorDetails.join("; ")}` }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const decodedEnv = Value.Decode(envConfigValidator.schema, env); + webhookPayload.env = decodedEnv; webhookPayload.settings = settings; await startStopTask(webhookPayload, env); return new Response(JSON.stringify("OK"), { status: 200, headers: { "content-type": "application/json" } }); diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 19702099..70d2ccb8 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -61,6 +61,7 @@ export const db = factory({ id: primaryKey(Number), html_url: String, name: String, + full_name: String, owner: { login: String, id: Number, @@ -77,6 +78,7 @@ export const db = factory({ body: nullable(String), repo: String, owner: String, + pull_request: Object, author: nullable({ avatar_url: String, email: nullable(String), @@ -132,7 +134,12 @@ export const db = factory({ }, event: { id: primaryKey(Number), - actor: Object, + actor: { + id: Number, + type: String, + login: String, + name: nullable(String), + }, owner: String, repo: String, issue_number: Number, @@ -140,6 +147,9 @@ export const db = factory({ commit_id: nullable(String), commit_url: String, created_at: Date, + assignee: { + login: String, + }, source: nullable({ issue: { number: Number, @@ -162,4 +172,18 @@ export const db = factory({ }, }), }, + comments: { + id: primaryKey(Number), + body: String, + user: Object, + created_at: Date, + updated_at: Date, + author_association: String, + html_url: String, + issue_url: String, + owner: String, + repo: String, + issue_number: Number, + pull_request_url: String, + }, }); diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index f247b080..30131ba1 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -47,14 +47,6 @@ export const handlers = [ http.get("https://api.github.com/repos/:owner/:repo/pulls", ({ params: { owner, repo } }: { params: { owner: string; repo: string } }) => HttpResponse.json(db.pull.findMany({ where: { owner: { equals: owner }, repo: { equals: repo } } })) ), - // list reviews for a pull request - http.get("https://api.github.com/repos/:owner/:repo/pulls/:pull_number/reviews", ({ params: { owner, repo, pull_number: pullNumber } }) => - HttpResponse.json( - db.review.findMany({ - where: { owner: { equals: owner as string }, repo: { equals: repo as string }, pull_number: { equals: Number(pullNumber) } }, - }) - ) - ), // list events for an issue timeline http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/timeline", () => HttpResponse.json(db.event.getAll())), // update a pull request @@ -96,13 +88,7 @@ export const handlers = [ // get commit hash http.get("https://api.github.com/repos/:owner/:repo/commits", () => HttpResponse.json({ sha: "commitHash" })), // list all pull request reviews - http.get("https://api.github.com/repos/:owner/:repo/pulls/:pull_number/reviews", ({ params: { owner, repo, pull_number: pullNumber } }) => - HttpResponse.json( - db.review.findMany({ - where: { owner: { equals: owner as string }, repo: { equals: repo as string }, pull_number: { equals: Number(pullNumber) } }, - }) - ) - ), + http.get("https://api.github.com/repos/:owner/:repo/pulls/:pull_number/reviews", () => HttpResponse.json(db.review.getAll())), // remove assignee from an issue http.delete("https://api.github.com/repos/:owner/:repo/issues/:issue_number/assignees", ({ params: { owner, repo, issue_number: issueNumber } }) => HttpResponse.json({ owner, repo, issueNumber }) @@ -120,4 +106,14 @@ export const handlers = [ }) ) ), + // get user + http.get("https://api.github.com/users/:username", ({ params: { username } }) => { + const user = db.users.findFirst({ where: { login: { equals: username as string } } }); + if (!user) { + return new HttpResponse(null, { status: 404 }); + } + return HttpResponse.json(user); + }), + // get comments for an issue + http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/comments", () => HttpResponse.json(db.comments.getAll())), ]; diff --git a/tests/__mocks__/repo-template.json b/tests/__mocks__/repo-template.json index c86a9f65..b1f39af2 100644 --- a/tests/__mocks__/repo-template.json +++ b/tests/__mocks__/repo-template.json @@ -41,6 +41,7 @@ "updated_at": "", "url": "", "user": null, + "full_name": "ubiquity/test-repo", "owner": "ubiquity", "repo": "test-repo", "labels": [ diff --git a/tests/__mocks__/users-get.json b/tests/__mocks__/users-get.json index 8681c7be..0bb1a0e5 100644 --- a/tests/__mocks__/users-get.json +++ b/tests/__mocks__/users-get.json @@ -6,5 +6,9 @@ { "id": 2, "login": "user2" + }, + { + "id": 3, + "login": "user3" } ] diff --git a/tests/main.test.ts b/tests/main.test.ts index 62016dcd..a99e1c48 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,5 +1,5 @@ import { drop } from "@mswjs/data"; -import { Context, SupportedEventsU } from "../src/types"; +import { Context, envConfigValidator, Sender, SupportedEventsU } from "../src/types"; import { db } from "./__mocks__/db"; import { server } from "./__mocks__/node"; import usersGet from "./__mocks__/users-get.json"; @@ -13,7 +13,7 @@ import { Logs, cleanLogString } from "@ubiquity-dao/ubiquibot-logger"; dotenv.config(); type Issue = Context["payload"]["issue"]; -type Sender = Context["payload"]["sender"]; +type PayloadSender = Context["payload"]["sender"]; const octokit = jest.requireActual("@octokit/rest"); const TEST_REPO = "ubiquity/test-repo"; @@ -29,63 +29,65 @@ afterAll(() => server.close()); describe("User start/stop", () => { beforeEach(async () => { + jest.clearAllMocks(); + jest.resetModules(); await setupTests(); }); test("User can start an issue", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender); + const context = createContext(issue, sender) as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(context as unknown as Context); + const { content } = await userStartStop(context); - expect(output).toEqual("Task assigned successfully"); + expect(content).toEqual("Task assigned successfully"); }); test("User can start an issue with teammates", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; - const context = createContext(issue, sender, "/start @user2"); + const context = createContext(issue, sender, "/start @user3") as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(context as unknown as Context); + const { content } = await userStartStop(context); - expect(output).toEqual("Task assigned successfully"); + expect(content).toEqual("Task assigned successfully"); const issue2 = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; expect(issue2.assignees).toHaveLength(2); - expect(issue2.assignees).toEqual(expect.arrayContaining(["ubiquity", "user2"])); + expect(issue2.assignees).toEqual(expect.arrayContaining(["ubiquity", "user3"])); }); test("User can stop an issue", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(context as unknown as Context); + const { content } = await userStartStop(context); - expect(output).toEqual("Task unassigned successfully"); + expect(content).toEqual("Task unassigned successfully"); }); test("Stopping an issue should close the author's linked PR", async () => { const infoSpy = jest.spyOn(console, "info").mockImplementation(() => {}); const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as Sender; - const context = createContext(issue, sender, "/stop"); + const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as PayloadSender; + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(context as unknown as Context); + const { content } = await userStartStop(context); - expect(output).toEqual("Task unassigned successfully"); + expect(content).toEqual("Task unassigned successfully"); const logs = infoSpy.mock.calls.flat(); expect(logs[0]).toMatch(/Opened prs/); expect(cleanLogString(logs[3])).toMatch( @@ -97,157 +99,133 @@ describe("User start/stop", () => { test("User can't stop an issue they're not assigned to", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - await expect(userStartStop(context as unknown as Context)).rejects.toThrow("```diff\n! You are not assigned to this task\n```"); + await expect(userStartStop(context)).rejects.toThrow("You are not assigned to this task"); }); test("User can't stop an issue without assignees", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 6 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context as unknown as Context); - await expect(userStartStop(context as unknown as Context)).rejects.toThrow("```diff\n! You are not assigned to this task\n```"); + await expect(userStartStop(context)).rejects.toThrow("You are not assigned to this task"); }); test("User can't start an issue that's already assigned", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender, "/start"); - - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + const context = createContext(issue, sender, "/start") as Context<"issue_comment.created">; - const err = "```diff\n! This issue is already assigned. Please choose another unassigned task.\n```"; + context.adapters = createAdapters(getSupabase(), context); - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual(err); - } - } + await expect(userStartStop(context)).rejects.toThrow("This issue is already assigned. Please choose another unassigned task."); }); test("User can't start an issue without a price label", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 3 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender); + const context = createContext(issue, sender) as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); - - const err = "No price label is set to calculate the duration"; + context.adapters = createAdapters(getSupabase(), context); - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual(err); - } - } + await expect(userStartStop(context)).rejects.toThrow("No price label is set to calculate the duration"); }); test("User can't start an issue without a wallet address", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; - - const context = createContext(issue, sender); + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - context.adapters = createAdapters(getSupabase(false), context as unknown as Context); + const context = createContext(issue, sender, "/start", "2", true) as Context<"issue_comment.created">; - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual("No wallet address found"); - } - } + context.adapters = createAdapters(getSupabase(false), context); + await expect(userStartStop(context)).rejects.toThrow("No wallet address found"); }); test("User can't start an issue that's closed", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 4 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender); + const context = createContext(issue, sender) as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context as unknown as Context); - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual("Issue is closed"); - } - } + await expect(userStartStop(context)).rejects.toThrow("This issue is closed, please choose another."); }); test("User can't start an issue that's a parent issue", async () => { - const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const issue = db.issue.findFirst({ where: { id: { equals: 5 } } }) as unknown as Issue; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender, "/start"); + const context = createContext(issue, sender, "/start") as Context<"issue_comment.created">; - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + context.adapters = createAdapters(getSupabase(), context); - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual("Issue is a parent issue"); - } - } + await expect(userStartStop(context)).rejects.toThrow("Skipping '/start' since the issue is a parent issue"); }); test("User can't start another issue if they have reached the max limit", async () => { - jest.mock("../src/utils/issue", () => ({ - getAvailableOpenedPullRequests: jest.fn().mockResolvedValue([ - { - number: 1, - reviews: [ - { - state: "APPROVED", - }, - ], - }, - { - number: 2, - reviews: [ - { - state: "APPROVED", - }, - ], - }, - { - number: 3, - reviews: [ - { - state: "APPROVED", - }, - ], - }, - ]), - })); + const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; + const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as PayloadSender; + + const context = createContext(issue, sender) as Context<"issue_comment.created">; + context.config.maxConcurrentTasks = 1; + + context.adapters = createAdapters(getSupabase(), context); + + await expect(userStartStop(context)).rejects.toThrow("You have reached your max task limit. Please close out some tasks before assigning new ones."); + }); + + test("User can't start an issue if they have previously been unassigned by an admin", async () => { + const issue = db.issue.findFirst({ where: { id: { equals: 6 } } }) as unknown as Issue; + const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as PayloadSender; + + const context = createContext(issue, sender, "/start") as Context<"issue_comment.created">; + context.adapters = createAdapters(getSupabase(), context); + + await expect(userStartStop(context)).rejects.toThrow("user2 you were previously unassigned from this task. You cannot be reassigned."); + }); + test("Should throw if no APP_ID is set", async () => { const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; - const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; - const context = createContext(issue, sender); + const context = createContext(issue, sender, "/start", undefined); - context.adapters = createAdapters(getSupabase(), context as unknown as Context); + const env = { ...context.env }; + Reflect.deleteProperty(env, "APP_ID"); + if (!envConfigValidator.test(env)) { + const errorDetails: string[] = []; + for (const error of envConfigValidator.errors(env)) { + errorDetails.push(`${error.path}: ${error.message}`); + } + + expect(errorDetails).toContain("/APP_ID: Required property"); + } + }); - try { - await userStartStop(context as unknown as Context); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual("Too many assigned issues, you have reached your max limit of 3 issues."); + test("Should throw if APP_ID is not a number", async () => { + const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; + + const context = createContext(issue, sender, "/start", "testing-one"); + const env = { ...context.env }; + + if (!envConfigValidator.test(env)) { + const errorDetails: string[] = []; + for (const error of envConfigValidator.errors(env)) { + errorDetails.push(`${error.path}: ${error.message}`); } + + expect(errorDetails).toContain("Invalid APP_ID"); } }); }); @@ -337,7 +315,7 @@ async function setupTests() { db.pull.create({ id: 1, - html_url: "", + html_url: "https://github.com/ubiquity/test-repo/pull/1", number: 1, author: { id: 2, @@ -356,7 +334,7 @@ async function setupTests() { db.pull.create({ id: 2, - html_url: "", + html_url: "https://github.com/ubiquity/test-repo/pull/2", number: 2, author: { id: 2, @@ -375,7 +353,7 @@ async function setupTests() { db.pull.create({ id: 3, - html_url: "", + html_url: "https://github.com/ubiquity/test-repo/pull/3", number: 3, author: { id: 1, @@ -412,6 +390,12 @@ async function setupTests() { db.event.create({ id: 1, created_at: new Date().toISOString(), + actor: { + id: 2, + name: "user2", + login: "user2", + type: "User", + }, commit_id: "123", commit_url: "", event: CROSS_REFERENCED, @@ -439,6 +423,12 @@ async function setupTests() { db.event.create({ id: 2, + actor: { + id: 1, + name: "ubiquity", + login: "ubiquity", + type: "User", + }, commit_id: "123", commit_url: "", created_at: new Date().toISOString(), @@ -492,32 +482,98 @@ async function setupTests() { }, }, }); + + db.event.create({ + id: 4, + actor: { + id: 1, + login: "ubiquity", + type: "User", + }, + assignee: { + login: "user2", + }, + created_at: new Date().toISOString(), + event: "assigned", + issue_number: 2, + owner: "ubiquity", + repo: "test-repo", + }); + + db.event.create({ + id: 5, + actor: { + id: 1, + login: "ubiquibot[bot]", + type: "Bot", + }, + assignee: { + login: "user2", + }, + created_at: new Date().toISOString(), + event: "assigned", + issue_number: 2, + owner: "ubiquity", + repo: "test-repo", + }); + + db.event.create({ + id: 6, + actor: { + id: 1, + login: "ubiquity", + type: "User", + }, + assignee: { + login: "user2", + }, + created_at: new Date().toISOString(), + event: "unassigned", + issue_number: 2, + owner: "ubiquity", + repo: "test-repo", + }); + + db.comments.create({ + id: 1, + body: "/start", + owner: "ubiquity", + repo: "test-repo", + }); } -function createContext(issue: Record, sender: Record, body = "/start"): Context { +function createContext( + issue: Record, + sender: Record, + body = "/start", + appId: string | null = "1", + startRequiresWallet = false +): Context { return { adapters: {} as ReturnType, payload: { issue: issue as unknown as Context["payload"]["issue"], sender: sender as unknown as Context["payload"]["sender"], repository: db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["repository"], - comment: { body } as unknown as Context["payload"]["comment"], + comment: { body } as unknown as Context<"issue_comment.created">["payload"]["comment"], action: "created", installation: { id: 1 } as unknown as Context["payload"]["installation"], organization: { login: "ubiquity" } as unknown as Context["payload"]["organization"], - }, + } as Context["payload"], logger: new Logs("debug"), config: { reviewDelayTolerance: "3 Days", taskStaleTimeoutDuration: "30 Days", maxConcurrentTasks: 3, - startRequiresWallet: false, + startRequiresWallet, + emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", }, octokit: new octokit.Octokit(), eventName: "issue_comment.created" as SupportedEventsU, env: { SUPABASE_KEY: "key", SUPABASE_URL: "url", + APP_ID: appId as unknown as number, }, }; } diff --git a/wrangler.toml b/wrangler.toml index 45c390b0..d67116df 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,4 +3,4 @@ main = "src/worker.ts" compatibility_date = "2024-05-23" node_compat = true [env.dev] -[env.prod] \ No newline at end of file +[env.prod]