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/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/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 7f4e2624..19a66494 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -1,13 +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 { checkTaskStale } from "./check-task-stale"; +import { addAssignees, addCommentToIssue, getAssignedIssues, getAvailableOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue"; +import { HttpStatusCode, Result } from "../result-types"; import { hasUserBeenUnassigned } from "./check-assignments"; -import { generateAssignmentComment } from "./generate-assignment-comment"; +import { checkTaskStale } from "./check-task-stale"; +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; @@ -75,17 +75,17 @@ export async function start(context: Context, issue: Context["payload"]["issue"] } // get labels - const labels = issue.labels; + const labels = issue.labels ?? []; const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); if (!priceLabel) { 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 assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, duration); + const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, deadline); const logMessage = logger.info("Task assigned successfully", { taskDeadline: assignmentComment.deadline, taskAssignees: toAssignIds, @@ -113,7 +113,7 @@ 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[]) { diff --git a/src/handlers/shared/stop.ts b/src/handlers/shared/stop.ts index 38a3eaf8..21161510 100644 --- a/src/handlers/shared/stop.ts +++ b/src/handlers/shared/stop.ts @@ -1,7 +1,8 @@ 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: 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; @@ -47,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 f95c3880..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("/", ""); @@ -11,13 +17,27 @@ export async function userStartStop(context: Context): Promise<{ output: string .slice(1) .map((teamMate) => teamMate.split(" ")[0]); - const user = comment.user?.login ? { login: comment.user.login, id: comment.user.id } : { login: sender.login, id: sender.id }; - if (slashCommand === "stop") { - return await stop(context, issue, user, repository); + return await stop(context, issue, sender, repository); } else if (slashCommand === "start") { 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/worker.ts b/src/worker.ts index c7db8033..d6c19548 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,7 @@ import { Value } from "@sinclair/typebox/value"; +import manifest from "../manifest.json"; import { startStopTask } from "./plugin"; import { Env, envConfigValidator, startStopSchema, startStopSettingsValidator } from "./types"; -import manifest from "../manifest.json"; export default { async fetch(request: Request, env: Env): Promise { diff --git a/tests/main.test.ts b/tests/main.test.ts index 8bd67ee7..a99e1c48 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -38,26 +38,26 @@ describe("User start/stop", () => { 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); + const context = createContext(issue, sender) as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(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 @user3"); + 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); @@ -68,26 +68,26 @@ describe("User start/stop", () => { 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 PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(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 PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); - const { output } = await userStartStop(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( @@ -101,28 +101,28 @@ describe("User start/stop", () => { 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 PayloadSender; - const context = createContext(issue, sender, "/stop"); + const context = createContext(issue, sender, "/stop") as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); - await expect(userStartStop(context as unknown as Context)).rejects.toThrow("You are not assigned to this task"); + 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 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("You are not assigned to this task"); + 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 PayloadSender; - const context = createContext(issue, sender, "/start"); + const context = createContext(issue, sender, "/start") as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); @@ -133,7 +133,7 @@ describe("User start/stop", () => { 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 PayloadSender; - const context = createContext(issue, sender); + const context = createContext(issue, sender) as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(), context); @@ -144,7 +144,7 @@ describe("User start/stop", () => { 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", "2", true); + const context = createContext(issue, sender, "/start", "2", true) as Context<"issue_comment.created">; context.adapters = createAdapters(getSupabase(false), context); await expect(userStartStop(context)).rejects.toThrow("No wallet address found"); @@ -154,18 +154,18 @@ describe("User start/stop", () => { 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 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); - await expect(userStartStop(context as unknown as Context)).rejects.toThrow("This issue is closed, please choose another."); + 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: 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); @@ -176,7 +176,7 @@ describe("User start/stop", () => { 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); + const context = createContext(issue, sender) as Context<"issue_comment.created">; context.config.maxConcurrentTasks = 1; context.adapters = createAdapters(getSupabase(), context); @@ -188,7 +188,7 @@ describe("User start/stop", () => { 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"); + 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."); @@ -555,11 +555,11 @@ function createContext( 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", 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]