From 46d8918916a3b4f048c0771857403512acc75a41 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:57:49 +0000 Subject: [PATCH 01/56] feat: enhance error handling and eligibility checks in task assignment - Introduced centralized error messages for various task assignment scenarios, improving clarity and user guidance. - Refactored eligibility checks to streamline the assignment process and ensure proper error handling. - Added detailed logging for task limits and user roles, enhancing debugging capabilities. - Implemented a new function to evaluate start eligibility, consolidating checks for price labels, task staleness, and assignment limits. --- src/handlers/shared/start.ts | 484 +++++++++++++++++++++++------------ 1 file changed, 318 insertions(+), 166 deletions(-) diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts index f98d59b2..3a6f932e 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -1,13 +1,35 @@ +import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; import { AssignedIssue, Context, ISSUE_TYPE, Label } from "../../types/index"; import { addAssignees, getAssignedIssues, getPendingOpenedPullRequests, 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 { getTransformedRole, getUserRoleAndTaskLimit } from "./get-user-task-limit-and-role"; import structuredMetadata from "./structured-metadata"; import { assignTableComment } from "./table"; +const ERROR_MESSAGES = { + UNASSIGNED: "{{username}} you were previously unassigned from this task. You cannot be reassigned.", + MAX_TASK_LIMIT: "You have reached your max task limit. Please close out some tasks before assigning new ones.", + MAX_TASK_LIMIT_PREFIX: "You have reached your max task limit", + MAX_TASK_LIMIT_TEAMMATE_PREFIX: "has reached their max task limit", + ALL_TEAMMATES_REACHED: "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.", + PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", + CLOSED: "This issue is closed, please choose another.", + ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", + PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", + PRICE_LIMIT_EXCEEDED: + "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", + PRICE_LABEL_FORMAT_ERROR: "Price label is not in the correct format", + TASK_STALE: "Task appears stale; confirm specification before starting.", + TASK_ASSIGNED: "Task assigned successfully", + MISSING_SENDER: "Missing sender", + NOT_BUSINESS_PRIORITY: + "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", + PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", +} as const; + export async function checkRequirements( context: Context, issue: Context<"issue_comment.created">["payload"]["issue"], @@ -31,7 +53,11 @@ export async function checkRequirements( if (!currentLabelConfiguration) { // If we didn't find the label in the allowed list, then the user cannot start this task. - const errorText = `This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: ${requiredLabelsToStart.map((label) => "`" + label.name + "`").join(", ")}`; + const errorText = ERROR_MESSAGES.NOT_BUSINESS_PRIORITY.replace( + "{{requiredLabelsToStart}}", + requiredLabelsToStart.map((label) => `\`${label.name}\``).join(", ") + ); + logger.error(errorText, { requiredLabelsToStart, issueLabels, @@ -57,165 +83,342 @@ export async function checkRequirements( return null; } +async function handleStartErrors(context: Context, eligibility: StartEligibilityResult): Promise { + const { logger } = context; + const errorMessages = eligibility.errors.map((e) => e.logMessage.raw.toLowerCase()); + + // Check for unassigned errors first - these should take precedence over all other errors + const unassignedPattern = ERROR_MESSAGES.UNASSIGNED.toLowerCase().replace("{{username}}", "").trim(); + const unassignedError = eligibility.errors.find((e) => { + const lowerMsg = e.logMessage.raw.toLowerCase(); + return lowerMsg.includes(unassignedPattern); + }); + if (unassignedError) { + throw unassignedError; + } + + const hasParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PARENT_ISSUES.toLowerCase())); + const hasPreParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PRICE_LABEL_REQUIRED.toLowerCase())); + + // Preserve original ordering: if pre-parent validations fail, do NOT post parent comment + if (hasParentReason && !hasPreParentReason) { + const message = logger.error(ERROR_MESSAGES.PARENT_ISSUES); + await context.commentHandler.postComment(context, message); + throw message; + } + + // Quota-only cases: post comment with assigned issues list + const quotaPrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase(); + const quotaTeammatePrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase(); + const isQuotaOnly = errorMessages.every((msg) => msg.includes(quotaTeammatePrefixLower) || msg.includes(quotaPrefixLower)); + + if (isQuotaOnly) { + const { toAssign, assignedIssues, consideredCount } = eligibility.computed; + if (toAssign.length === 0 && consideredCount > 1) { + const allTeammatesPattern = "All teammates have reached"; + const error = eligibility.errors.find((e) => e.logMessage.raw.includes(allTeammatesPattern)); + if (error) throw error; + } else if (toAssign.length === 0) { + const error = eligibility.errors.find((e) => e.logMessage.raw.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX)); + if (error) { + let issues = ""; + const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/; + assignedIssues.forEach((el) => { + const match = el.html_url.match(urlPattern); + if (match) { + issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`); + } else { + issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`); + } + }); + + await context.commentHandler.postComment( + context, + logger.warn(` +${ERROR_MESSAGES.MAX_TASK_LIMIT} + +${issues} +`) + ); + return { content: ERROR_MESSAGES.MAX_TASK_LIMIT, status: HttpStatusCode.NOT_MODIFIED }; + } + } + } + + if (eligibility.errors.length > 1) { + throw new AggregateError(eligibility.errors.map((e) => new Error(e.logMessage.raw))); + } + + throw eligibility.errors[0]; +} + export async function start( context: Context, issue: Context<"issue_comment.created">["payload"]["issue"], sender: Context["payload"]["sender"], teammates: string[] ): Promise { - const { logger, config } = context; - const { taskStaleTimeoutDuration, taskAccessControl } = config; + const { logger } = context; if (!sender) { throw logger.error(`Skipping '/start' since there is no sender in the context.`); } - const labels = issue.labels ?? []; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); - const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); - const userRole = userAssociation.role; - - const startErrors: Error[] = []; + // Centralized eligibility gate without side effects + const eligibility = await evaluateStartEligibility(context, issue, sender, teammates); - // Collaborators and admins can start un-priced tasks - if (!priceLabel && userRole === "contributor") { - const errorMessage = "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing."; - logger.error(errorMessage, { issueNumber: issue.number, labels }); - startErrors.push(new Error(errorMessage)); + if (!eligibility.ok) { + // handleStartErrors will either throw or return an error result + return await handleStartErrors(context, eligibility); } - const checkRequirementsError = await checkRequirements(context, issue, userRole); - if (checkRequirementsError) { - startErrors.push(checkRequirementsError); + // All checks passed, perform assignment + return performAssignment(context, issue, sender, eligibility.computed.toAssign); +} + +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 (startErrors.length) { - throw new AggregateError(startErrors); + if (ids.filter((id) => !id).length > 0) { + throw new Error("Error while fetching user ids"); } - // is it a child issue? - if (issue.body && isParentIssue(issue.body)) { - const message = logger.error("Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues."); - await context.commentHandler.postComment(context, message); - throw message; + return ids; +} + +async function handleTaskLimitChecks({ context, logger, sender, username }: { username: string; context: Context; logger: Context["logger"]; sender: string }) { + // Check for unassignment first - this should take precedence over task limit + if (await hasUserBeenUnassigned(context, username)) { + throw logger.warn(ERROR_MESSAGES.UNASSIGNED.replace("{{username}}", username), { username }); } - let commitHash: string | null = null; + const openedPullRequests = await getPendingOpenedPullRequests(context, username); + const assignedIssues = await getAssignedIssues(context, username); + const { limit, role } = await getUserRoleAndTaskLimit(context, username); - try { - const hashResponse = await context.octokit.rest.repos.getCommit({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - ref: context.payload.repository.default_branch, + // check for max and enforce max + if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) { + const errorMessage = username === sender ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${username} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + logger.error(errorMessage, { + assignedIssues: assignedIssues.length, + openedPullRequests: openedPullRequests.length, + limit, }); - commitHash = hashResponse.data.sha; - } catch (e) { - logger.error("Error while getting commit hash", { error: e as Error }); + + return { + isWithinLimit: false, + issues: assignedIssues, + }; + } + + return { + isWithinLimit: true, + issues: [], + role, + }; +} + +export type StartEligibilityResult = { + ok: boolean; + errors: LogReturn[]; + warnings: string[]; + computed: { + deadline: string | null; + isTaskStale: boolean; + wallet: string | null; + toAssign: string[]; + assignedIssues: AssignedIssue[]; + consideredCount: number; + senderRole: ReturnType; + }; +}; + +export async function evaluateStartEligibility( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: Context<"issue_comment.created">["payload"]["sender"], + teammates: string[] +): Promise { + const errors: LogReturn[] = []; + const warnings: string[] = []; + const assignedIssues: AssignedIssue[] = []; + + if (!sender) { + errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); } - // is it assignable? + const labels = (issue.labels ?? []) as Label[]; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); + const userRole = userAssociation.role; + + // Collaborators need price label + if (!priceLabel && userRole === "contributor") { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_REQUIRED)); + } + + const checkReqErr = await checkRequirements(context, issue, userRole); + if (checkReqErr) { + errors.push(context.logger.error(checkReqErr.message)); + } + + if (issue.body && isParentIssue(issue.body)) { + errors.push(context.logger.error(ERROR_MESSAGES.PARENT_ISSUES)); + } if (issue.state === ISSUE_TYPE.CLOSED) { - throw logger.error("This issue is closed, please choose another.", { issueNumber: issue.number }); + errors.push(context.logger.error(ERROR_MESSAGES.CLOSED)); } const assignees = issue?.assignees ?? []; - - // 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 } - ); + if (assignees.length) { + errors.push(context.logger.error(ERROR_MESSAGES.ALREADY_ASSIGNED)); } - teammates.push(sender.login); - - const toAssign = []; - let assignedIssues: AssignedIssue[] = []; - // check max assigned issues - for (const user of teammates) { - const { isWithinLimit, issues, role } = await handleTaskLimitChecks({ context, logger, sender: sender.login, username: user }); - if (isWithinLimit) { - toAssign.push(user); - } else { - issues.forEach((issue) => { - assignedIssues = assignedIssues.concat({ - title: issue.title, - html_url: issue.html_url, + const allUsers = [...new Set([sender.login, ...teammates])]; + const toAssign: string[] = []; + for (const user of allUsers) { + let role: ReturnType | undefined = undefined; + try { + const res = await handleTaskLimitChecks({ context, logger: context.logger, sender: sender.login, username: user }); + // within limit? + if (!res.isWithinLimit) { + const message = user === sender.login ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${user} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + errors.push( + context.logger.error(message, { + assignedIssues: res.issues.length, + openedPullRequests: 0, + limit: 0, + }) + ); + // capture issues for later comment rendering + res.issues.forEach((issue) => { + assignedIssues.push({ title: issue.title, html_url: issue.html_url }); }); - }); + } else { + toAssign.push(user); + } + // role for price ceiling check + role = res.role as ReturnType | undefined; + } catch (e) { + if (e instanceof Error) { + errors.push(context.logger.error(e.message)); + } else if (e instanceof LogReturn) { + errors.push(e); + } else { + errors.push(context.logger.error(`An error occurred while checking the task limit for ${user}`, { e })); + } } - if (priceLabel && role !== "admin") { - const { usdPriceMax } = taskAccessControl; + if (priceLabel && role && role !== "admin") { + const { usdPriceMax } = context.config.taskAccessControl; const min = Math.min(...Object.values(usdPriceMax)); - const allowed = role && role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; + const allowed = role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; - - const priceRegex = /Price:\s*([\d.]+)/; - const match = priceLabel.name.match(priceRegex); - if (!match) { - throw logger.error("Price label is not in the correct format", { priceLabel: priceLabel.name }); - } - const value = match[1]; - if (isNaN(parseFloat(value))) { - throw logger.error("Price label is not in the correct format", { priceLabel: priceLabel.name }); - } - const price = parseFloat(value); - if (userAllowedMaxPrice < 0) { - throw logger.warn(`External contributors are not eligible for rewards at this time. We are preserving resources for core team only.`, { - userRole, - price, - userAllowedMaxPrice, - issueNumber: issue.number, - }); - } else if (price > userAllowedMaxPrice) { - throw logger.warn( - `While we appreciate your enthusiasm @${user}, the price of this task exceeds your allowed limit. Please choose a task with a price of $${userAllowedMaxPrice} or less.`, - { - userRole, - price, - userAllowedMaxPrice, - issueNumber: issue.number, - } - ); + const match = priceLabel.name.match(/Price:\s*([\d.]+)/); + if (!match || isNaN(parseFloat(match[1]))) { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_FORMAT_ERROR, { priceLabel: priceLabel.name })); + } else { + const price = parseFloat(match[1]); + if (userAllowedMaxPrice < 0) { + errors.push(context.logger.warn(ERROR_MESSAGES.PRESERVATION_MODE, { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number })); + } else if (price > userAllowedMaxPrice) { + errors.push( + context.logger.warn( + ERROR_MESSAGES.PRICE_LIMIT_EXCEEDED.replace("{{user}}", user).replace("{{userAllowedMaxPrice}}", userAllowedMaxPrice.toString()), + { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number } + ) + ); + } } } } - 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."; - throw logger.error(error, { issueNumber: issue.number }); - } else if (toAssign.length === 0) { - error = "You have reached your max task limit. Please close out some tasks before assigning new ones."; - let issues = ""; - const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/; - assignedIssues.forEach((el) => { - const match = el.html_url.match(urlPattern); - if (match) { - issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`); - } else { - issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`); - } + // Only add summary error if we haven't already added individual user errors + // (individual errors are added in the loop above when users exceed their limit) + if (toAssign.length === 0 && allUsers.length > 0) { + const message = allUsers.length > 1 ? ERROR_MESSAGES.ALL_TEAMMATES_REACHED : ERROR_MESSAGES.MAX_TASK_LIMIT; + // Only add if we don't already have quota-related errors (to avoid duplicates) + const hasQuotaError = errors.some((e) => { + const lowerMsg = e.logMessage.raw.toLowerCase(); + return ( + lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase()) || lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase()) + ); }); + if (!hasQuotaError) { + errors.push(context.logger.error(message)); + } + } - await context.commentHandler.postComment( - context, - context.logger.warn(` -${error} + // Wallet + let wallet: string | null = null; + try { + wallet = await context.adapters.supabase.user.getWalletByUserId(sender.id, issue.number); + } catch { + errors.push(context.logger.error(context.config.emptyWalletText)); + } -${issues} -`) - ); - return { content: error, status: HttpStatusCode.NOT_MODIFIED }; + // Staleness & deadline + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); + if (isTaskStale) { + warnings.push(ERROR_MESSAGES.TASK_STALE); } + let deadline: string | null = null; + try { + deadline = getDeadline(labels); + } catch { + // don't throw (post a comment) "No labels are set." error + } + + return { + ok: errors.length === 0, + errors, + warnings, + computed: { + deadline, + isTaskStale, + wallet, + toAssign, + assignedIssues, + consideredCount: allUsers.length, + senderRole: userRole, + }, + }; +} +export async function performAssignment( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: { login: string; id: number }, + toAssign: string[] +): Promise { + const { logger } = context; + // compute metadata + let commitHash: string | null = null; + try { + const hashResponse = await context.octokit.rest.repos.getCommit({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + ref: context.payload.repository.default_branch, + }); + commitHash = hashResponse.data.sha; + } catch (e) { + logger.error("Error while getting commit hash", { error: e as Error }); + } + const labels = issue.labels ?? []; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); const toAssignIds = await fetchUserIds(context, toAssign); const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); - const logMessage = logger.info("Task assigned successfully", { + const logMessage = logger.info(ERROR_MESSAGES.TASK_ASSIGNED, { taskDeadline: assignmentComment.deadline, taskAssignees: toAssignIds, priceLabel, @@ -223,11 +426,8 @@ ${issues} }); const metadata = structuredMetadata.create("Assignment", logMessage); - // add assignee await addAssignees(context, issue.number, toAssign); - const isTaskStale = checkTaskStale(getTimeValue(taskStaleTimeoutDuration), issue.created_at); - await context.commentHandler.postComment( context, logger.ok( @@ -245,53 +445,5 @@ ${issues} { raw: true } ); - 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({ context, logger, sender, username }: { username: string; context: Context; logger: Context["logger"]; sender: string }) { - const openedPullRequests = await getPendingOpenedPullRequests(context, username); - const assignedIssues = await getAssignedIssues(context, username); - const { limit, role } = await getUserRoleAndTaskLimit(context, username); - - // check for max and enforce max - if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) { - logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, { - assignedIssues: assignedIssues.length, - openedPullRequests: openedPullRequests.length, - limit, - }); - - return { - isWithinLimit: false, - issues: assignedIssues, - }; - } - - if (await hasUserBeenUnassigned(context, username)) { - throw logger.warn(`${username} you were previously unassigned from this task. You cannot be reassigned.`, { username }); - } - - return { - isWithinLimit: true, - issues: [], - role, - }; + return { content: ERROR_MESSAGES.TASK_ASSIGNED, status: HttpStatusCode.OK }; } From 0c9d6b1b09da94116a988cc216e06d8eb1d270aa Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:07:09 +0000 Subject: [PATCH 02/56] fix: improve error handling for task assignment and enhance test coverage - Added a new error message for cases where an issue is already assigned, providing clearer guidance to users. - Refactored the eligibility check to differentiate between the sender being assigned and the issue being assigned to someone else. - Updated tests to use more concise error assertions, improving readability and maintainability. - Enhanced mock database and handler responses to better simulate real-world scenarios in tests. --- src/handlers/shared/start.ts | 8 +++- tests/__mocks__/db.ts | 5 +++ tests/__mocks__/handlers.ts | 8 +++- tests/core-operations.test.ts | 8 +++- tests/main.test.ts | 70 +++++++++++++++++++++++++---------- tests/pull-request.test.ts | 20 +++++++++- 6 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts index 3a6f932e..796e52ad 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -18,6 +18,7 @@ const ERROR_MESSAGES = { PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", CLOSED: "This issue is closed, please choose another.", ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", + ISSUE_ALREADY_ASSIGNED: "This issue is already assigned. Please choose another unassigned task.", PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", PRICE_LIMIT_EXCEEDED: "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", @@ -280,7 +281,12 @@ export async function evaluateStartEligibility( const assignees = issue?.assignees ?? []; if (assignees.length) { - errors.push(context.logger.error(ERROR_MESSAGES.ALREADY_ASSIGNED)); + // Check if the sender is already assigned to this issue + const isSenderAssigned = assignees.some((assignee) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); + const errorMessage = isSenderAssigned + ? ERROR_MESSAGES.ALREADY_ASSIGNED + : ERROR_MESSAGES.ISSUE_ALREADY_ASSIGNED; + errors.push(context.logger.error(errorMessage)); } const allUsers = [...new Set([sender.login, ...teammates])]; diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index e54bb7b3..f04bb477 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -152,6 +152,11 @@ export const db = factory({ assignee: { login: String, }, + assigner: nullable({ + id: Number, + type: String, + login: String, + }), source: nullable({ issue: { number: Number, diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 963a4197..dd92409f 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -55,8 +55,12 @@ export const handlers = [ HttpResponse.json(db.pull.findMany({ where: { owner: { equals: owner }, repo: { equals: repo } } })) ), // list events for an issue timeline - http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/timeline", () => HttpResponse.json(db.event.getAll())), - http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/events", () => HttpResponse.json(db.event.getAll())), + http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/timeline", ({ params: { issue_number: issueNumber } }) => + HttpResponse.json(db.event.findMany({ where: { issue_number: { equals: Number(issueNumber) } } })) + ), + http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/events", ({ params: { issue_number: issueNumber } }) => + HttpResponse.json(db.event.findMany({ where: { issue_number: { equals: Number(issueNumber) } } })) + ), // update a pull request http.patch("https://api.github.com/repos/:owner/:repo/pulls/:pull_number", ({ params: { owner, repo, pull_number: pullNumber } }) => HttpResponse.json({ owner, repo, pullNumber }) diff --git a/tests/core-operations.test.ts b/tests/core-operations.test.ts index 88771e90..72dd456f 100644 --- a/tests/core-operations.test.ts +++ b/tests/core-operations.test.ts @@ -58,7 +58,13 @@ describe("test", () => { createClient: jest.fn(), })); jest.unstable_mockModule(adaptersModulePath, () => ({ - createAdapters: jest.fn(), + createAdapters: jest.fn(() => ({ + supabase: { + user: { + getWalletByUserId: jest.fn(() => Promise.resolve(null)), + }, + }, + })), })); db.users.create({ id: TEST_USER_ID, diff --git a/tests/main.test.ts b/tests/main.test.ts index 90ee3b54..a1bae56c 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -332,18 +332,11 @@ describe("User start/stop", () => { context.adapters = createAdapters(getSupabase(), context); - try { - await userStartStop(context); - } catch (error) { - expect(error).toBeInstanceOf(AggregateError); - const aggregateError = error as AggregateError; - const errorMessages = aggregateError.errors.map((error) => error.message); - expect(errorMessages).toEqual( - expect.arrayContaining([ - "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: `Priority: 3 (High)`, `Priority: 4 (Urgent)`, `Priority: 5 (Emergency)`", - ]) - ); - } + await expect(userStartStop(context)).rejects.toMatchObject({ + logMessage: { + raw: "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: `Priority: 3 (High)`, `Priority: 4 (Urgent)`, `Priority: 5 (Emergency)`", + }, + }); }); test("Should not allow a user to start if the user role is not listed", async () => { @@ -360,14 +353,11 @@ describe("User start/stop", () => { context.adapters = createAdapters(getSupabase(), context); - try { - await userStartStop(context); - } catch (error) { - expect(error).toBeInstanceOf(AggregateError); - const aggregateError = error as AggregateError; - const errorMessages = aggregateError.errors.map((error) => error.message); - expect(errorMessages).toEqual(expect.arrayContaining(["You must be a core team member, or an administrator to start this task"])); - } + await expect(userStartStop(context)).rejects.toMatchObject({ + logMessage: { + raw: "You must be a core team member, or an administrator to start this task", + }, + }); }); }); @@ -723,6 +713,46 @@ async function setupTests() { repo: "test-repo", }); + // Events for issue 6 (number 5) - user2 was assigned then unassigned by admin + db.event.create({ + id: 7, + actor: { + id: 1, + login: "ubiquity-os[bot]", + type: "Bot", + }, + assignee: { + login: "user2", + }, + created_at: new Date(Date.now() - 2000).toISOString(), + event: "assigned", + issue_number: 5, + owner: "ubiquity", + repo: "test-repo", + }); + + db.event.create({ + id: 8, + actor: { + id: 1, + login: "ubiquity", + type: "User", + }, + assignee: { + login: "user2", + }, + assigner: { + id: 1, + login: "ubiquity", + type: "User", + }, + created_at: new Date(Date.now() - 1000).toISOString(), + event: "unassigned", + issue_number: 5, + owner: "ubiquity", + repo: "test-repo", + }); + db.comments.create({ id: 1, body: "/start", diff --git a/tests/pull-request.test.ts b/tests/pull-request.test.ts index 750f2be6..291d4127 100644 --- a/tests/pull-request.test.ts +++ b/tests/pull-request.test.ts @@ -31,6 +31,11 @@ async function setupTests() { login: "user1", role: "contributor", }); + db.users.create({ + id: 2, + login: userLogin, + role: "contributor", + }); db.issue.create({ ...issueTemplate, labels: [{ name: "Priority: 1 (Normal)", description: "collaborator only" }, ...issueTemplate.labels], @@ -179,16 +184,29 @@ describe("Pull-request tests", () => { createClient: jest.fn(), })); jest.unstable_mockModule("../src/adapters", () => ({ - createAdapters: jest.fn(), + createAdapters: jest.fn(() => ({ + supabase: { + user: { + getWalletByUserId: jest.fn(() => Promise.resolve(null)), + }, + }, + })), })); jest.unstable_mockModule("@ubiquity-os/plugin-sdk/octokit", () => ({ customOctokit: jest.fn().mockReturnValue({ + paginate: jest.fn(() => Promise.resolve([])), rest: { apps: { getRepoInstallation: jest.fn(() => Promise.resolve({ data: { id: 1 } })), }, issues: { get: jest.fn(() => Promise.resolve({ data: { ...issue, labels: [{ name: "Time: <1 Hour" }] } })), + listEvents: jest.fn(() => Promise.resolve({ data: [] })), + listComments: jest.fn(() => Promise.resolve({ data: [] })), + listForRepo: jest.fn(() => Promise.resolve({ data: [] })), + }, + search: { + issuesAndPullRequests: jest.fn(() => Promise.resolve({ data: { items: [] } })), }, repos: { get: jest.fn(() => Promise.resolve({ data: repo })), From 06bb5a7ec3a568912e131177c97efbe72fd70d4f Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:20:52 +0000 Subject: [PATCH 03/56] refactor: reorganize task handling and introduce new command structure - Refactored the task handling logic by consolidating user command processing into a dedicated handler. - Introduced new handlers for managing pull request events, including closing unassigned pull requests and handling new pull request or edit events. - Removed deprecated user start/stop handler and updated references throughout the codebase. - Enhanced the overall structure and readability of the code by organizing related functionalities into separate modules. - Updated tests to reflect changes in the command structure and ensure comprehensive coverage of new functionalities. --- src/handlers/close-pull-on-unassign.ts | 20 + src/handlers/new-pull-request-or-edit.ts | 97 ++++ src/handlers/shared/check-assignments.ts | 14 - src/handlers/shared/start.ts | 455 ------------------ src/handlers/start-command.ts | 43 ++ src/handlers/start-task.ts | 29 ++ src/handlers/start/evaluate-eligibility.ts | 182 +++++++ .../start/helpers/check-assignments.ts | 48 ++ .../start/helpers/check-requirements.ts | 57 +++ .../helpers}/check-task-stale.ts | 0 src/handlers/start/helpers/error-messages.ts | 95 ++++ .../helpers}/generate-assignment-comment.ts | 16 +- .../helpers/generate-assignment-table.ts} | 0 .../helpers/generate-structured-metadata.ts} | 0 src/handlers/start/helpers/get-deadline.ts | 14 + src/handlers/start/helpers/get-user-ids.ts | 19 + src/handlers/start/perform-assignment.ts | 63 +++ src/handlers/{shared/stop.ts => stop-task.ts} | 6 +- src/handlers/user-start-stop.ts | 153 ------ src/plugin.ts | 11 +- src/{handlers => types}/result-types.ts | 0 .../get-assignment-periods.ts} | 2 +- .../get-user-task-limit-and-role.ts | 8 +- src/worker.ts | 8 +- tests/main.test.ts | 7 +- tests/roles.test.ts | 2 +- tests/start.test.ts | 24 +- 27 files changed, 707 insertions(+), 666 deletions(-) create mode 100644 src/handlers/close-pull-on-unassign.ts create mode 100644 src/handlers/new-pull-request-or-edit.ts delete mode 100644 src/handlers/shared/check-assignments.ts delete mode 100644 src/handlers/shared/start.ts create mode 100644 src/handlers/start-command.ts create mode 100644 src/handlers/start-task.ts create mode 100644 src/handlers/start/evaluate-eligibility.ts create mode 100644 src/handlers/start/helpers/check-assignments.ts create mode 100644 src/handlers/start/helpers/check-requirements.ts rename src/handlers/{shared => start/helpers}/check-task-stale.ts (100%) create mode 100644 src/handlers/start/helpers/error-messages.ts rename src/handlers/{shared => start/helpers}/generate-assignment-comment.ts (66%) rename src/handlers/{shared/table.ts => start/helpers/generate-assignment-table.ts} (100%) rename src/handlers/{shared/structured-metadata.ts => start/helpers/generate-structured-metadata.ts} (100%) create mode 100644 src/handlers/start/helpers/get-deadline.ts create mode 100644 src/handlers/start/helpers/get-user-ids.ts create mode 100644 src/handlers/start/perform-assignment.ts rename src/handlers/{shared/stop.ts => stop-task.ts} (87%) delete mode 100644 src/handlers/user-start-stop.ts rename src/{handlers => types}/result-types.ts (100%) rename src/{handlers/shared/user-assigned-timespans.ts => utils/get-assignment-periods.ts} (98%) rename src/{handlers/shared => utils}/get-user-task-limit-and-role.ts (91%) diff --git a/src/handlers/close-pull-on-unassign.ts b/src/handlers/close-pull-on-unassign.ts new file mode 100644 index 00000000..a0bebb9e --- /dev/null +++ b/src/handlers/close-pull-on-unassign.ts @@ -0,0 +1,20 @@ +import { Context} from "../types/index"; +import { closePullRequestForAnIssue } from "../utils/issue"; +import { HttpStatusCode, Result } from "../types/result-types"; + +export async function closeUserUnassignedPr(context: Context<"issues.unassigned">): Promise { + if (!("issue" in context.payload)) { + context.logger.debug("Payload does not contain an issue, skipping issues.unassigned event."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + const { payload } = context; + const { issue, repository, assignee } = payload; + // 'assignee' is the user that actually got un-assigned during this event. Since it can theoretically be null, + // we display an error if none is found in the payload. + if (!assignee) { + throw context.logger.fatal("No assignee found in payload, failed to close pull-requests."); + } + await closePullRequestForAnIssue(context, issue.number, repository, assignee?.login); + return { status: HttpStatusCode.OK, content: "Linked pull-requests closed." }; + } + \ No newline at end of file diff --git a/src/handlers/new-pull-request-or-edit.ts b/src/handlers/new-pull-request-or-edit.ts new file mode 100644 index 00000000..1252ca7c --- /dev/null +++ b/src/handlers/new-pull-request-or-edit.ts @@ -0,0 +1,97 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { Repository } from "@octokit/graphql-schema"; +import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit"; +import { Context } from "../types/index"; +import { QUERY_CLOSING_ISSUE_REFERENCES } from "../utils/get-closing-issue-references"; +import { closePullRequest, getOwnerRepoFromHtmlUrl } from "../utils/issue"; +import { HttpStatusCode, Result } from "../types/result-types"; +import { startTask } from "./start-task"; +import { getDeadline } from "./start/helpers/get-deadline"; + +export async function newPullRequestOrEdit(context: Context<"pull_request.opened" | "pull_request.edited">): Promise { + const { payload } = context; + const { pull_request } = payload; + const { owner, repo } = getOwnerRepoFromHtmlUrl(pull_request.html_url); + const linkedIssues = await context.octokit.graphql.paginate<{ repository: Repository }>(QUERY_CLOSING_ISSUE_REFERENCES, { + owner, + repo, + issue_number: pull_request.number, + }); + const issues = linkedIssues.repository.pullRequest?.closingIssuesReferences?.nodes; + if (!issues) { + context.logger.info("No linked issues were found, nothing to do."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + + const appOctokit = new customOctokit({ + authStrategy: createAppAuth, + auth: { + appId: context.env.APP_ID, + privateKey: context.env.APP_PRIVATE_KEY, + }, + }); + + for (const issue of issues) { + if (!issue || issue.assignees.nodes?.length) { + continue; + } + + const installation = await appOctokit.rest.apps.getRepoInstallation({ + owner: issue.repository.owner.login, + repo: issue.repository.name, + }); + const repoOctokit = new customOctokit({ + authStrategy: createAppAuth, + auth: { + appId: Number(context.env.APP_ID), + privateKey: context.env.APP_PRIVATE_KEY, + installationId: installation.data.id, + }, + }); + + const linkedIssue = ( + await repoOctokit.rest.issues.get({ + owner: issue.repository.owner.login, + repo: issue.repository.name, + issue_number: issue.number, + }) + ).data as Context<"issue_comment.created">["payload"]["issue"]; + const deadline = getDeadline(linkedIssue.labels); + if (!deadline) { + context.logger.debug("Skipping deadline posting message because no deadline has been set."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + + const repository = ( + await repoOctokit.rest.repos.get({ + owner: issue.repository.owner.login, + repo: issue.repository.name, + }) + ).data as Context<"issue_comment.created">["payload"]["repository"]; + let organization: Context<"issue_comment.created">["payload"]["organization"] | undefined = undefined; + if (repository.owner.type === "Organization") { + organization = ( + await repoOctokit.rest.orgs.get({ + org: issue.repository.owner.login, + }) + ).data; + } + const newContext = { + ...context, + octokit: repoOctokit, + payload: { + ...context.payload, + issue: linkedIssue, + repository, + organization, + }, + }; + try { + return await startTask(newContext, linkedIssue, pull_request.user ?? payload.sender, []); + } catch (error) { + await closePullRequest(context, { number: pull_request.number }); + throw error; + } + } + return { status: HttpStatusCode.NOT_MODIFIED }; + } \ No newline at end of file diff --git a/src/handlers/shared/check-assignments.ts b/src/handlers/shared/check-assignments.ts deleted file mode 100644 index 5b99aacf..00000000 --- a/src/handlers/shared/check-assignments.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Context } from "../../types/index"; -import { getOwnerRepoFromHtmlUrl } from "../../utils/issue"; -import { getAssignmentPeriods } from "./user-assigned-timespans"; - -export async function hasUserBeenUnassigned(context: Context, username: string): Promise { - if ("issue" in context.payload) { - const { number, html_url } = context.payload.issue; - const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url); - const assignmentPeriods = await getAssignmentPeriods(context.octokit, { owner, repo, issue_number: number }); - return assignmentPeriods[username]?.some((period) => period.reason === "bot" || period.reason === "admin"); - } - - return false; -} diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts deleted file mode 100644 index 796e52ad..00000000 --- a/src/handlers/shared/start.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; -import { AssignedIssue, Context, ISSUE_TYPE, Label } from "../../types/index"; -import { addAssignees, getAssignedIssues, getPendingOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue"; -import { HttpStatusCode, Result } from "../result-types"; -import { hasUserBeenUnassigned } from "./check-assignments"; -import { checkTaskStale } from "./check-task-stale"; -import { generateAssignmentComment, getDeadline } from "./generate-assignment-comment"; -import { getTransformedRole, getUserRoleAndTaskLimit } from "./get-user-task-limit-and-role"; -import structuredMetadata from "./structured-metadata"; -import { assignTableComment } from "./table"; - -const ERROR_MESSAGES = { - UNASSIGNED: "{{username}} you were previously unassigned from this task. You cannot be reassigned.", - MAX_TASK_LIMIT: "You have reached your max task limit. Please close out some tasks before assigning new ones.", - MAX_TASK_LIMIT_PREFIX: "You have reached your max task limit", - MAX_TASK_LIMIT_TEAMMATE_PREFIX: "has reached their max task limit", - ALL_TEAMMATES_REACHED: "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.", - PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", - CLOSED: "This issue is closed, please choose another.", - ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", - ISSUE_ALREADY_ASSIGNED: "This issue is already assigned. Please choose another unassigned task.", - PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", - PRICE_LIMIT_EXCEEDED: - "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", - PRICE_LABEL_FORMAT_ERROR: "Price label is not in the correct format", - TASK_STALE: "Task appears stale; confirm specification before starting.", - TASK_ASSIGNED: "Task assigned successfully", - MISSING_SENDER: "Missing sender", - NOT_BUSINESS_PRIORITY: - "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", - PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", -} as const; - -export async function checkRequirements( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - userRole: ReturnType -): Promise { - const { - config: { requiredLabelsToStart }, - logger, - } = context; - const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); - - if (requiredLabelsToStart.length) { - const currentLabelConfiguration = requiredLabelsToStart.find((label) => - issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase()) - ); - - // Admins can start any task - if (userRole === "admin") { - return null; - } - - if (!currentLabelConfiguration) { - // If we didn't find the label in the allowed list, then the user cannot start this task. - const errorText = ERROR_MESSAGES.NOT_BUSINESS_PRIORITY.replace( - "{{requiredLabelsToStart}}", - requiredLabelsToStart.map((label) => `\`${label.name}\``).join(", ") - ); - - logger.error(errorText, { - requiredLabelsToStart, - issueLabels, - issue: issue.html_url, - }); - return new Error(errorText); - } else if (!currentLabelConfiguration.allowedRoles.includes(userRole)) { - // If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task. - const humanReadableRoles = [ - ...currentLabelConfiguration.allowedRoles.map((o) => (o === "collaborator" ? "a core team member" : `a ${o}`)), - "an administrator", - ].join(", or "); - const errorText = `You must be ${humanReadableRoles} to start this task`; - logger.error(errorText, { - currentLabelConfiguration, - issueLabels, - issue: issue.html_url, - userRole, - }); - return new Error(errorText); - } - } - return null; -} - -async function handleStartErrors(context: Context, eligibility: StartEligibilityResult): Promise { - const { logger } = context; - const errorMessages = eligibility.errors.map((e) => e.logMessage.raw.toLowerCase()); - - // Check for unassigned errors first - these should take precedence over all other errors - const unassignedPattern = ERROR_MESSAGES.UNASSIGNED.toLowerCase().replace("{{username}}", "").trim(); - const unassignedError = eligibility.errors.find((e) => { - const lowerMsg = e.logMessage.raw.toLowerCase(); - return lowerMsg.includes(unassignedPattern); - }); - if (unassignedError) { - throw unassignedError; - } - - const hasParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PARENT_ISSUES.toLowerCase())); - const hasPreParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PRICE_LABEL_REQUIRED.toLowerCase())); - - // Preserve original ordering: if pre-parent validations fail, do NOT post parent comment - if (hasParentReason && !hasPreParentReason) { - const message = logger.error(ERROR_MESSAGES.PARENT_ISSUES); - await context.commentHandler.postComment(context, message); - throw message; - } - - // Quota-only cases: post comment with assigned issues list - const quotaPrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase(); - const quotaTeammatePrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase(); - const isQuotaOnly = errorMessages.every((msg) => msg.includes(quotaTeammatePrefixLower) || msg.includes(quotaPrefixLower)); - - if (isQuotaOnly) { - const { toAssign, assignedIssues, consideredCount } = eligibility.computed; - if (toAssign.length === 0 && consideredCount > 1) { - const allTeammatesPattern = "All teammates have reached"; - const error = eligibility.errors.find((e) => e.logMessage.raw.includes(allTeammatesPattern)); - if (error) throw error; - } else if (toAssign.length === 0) { - const error = eligibility.errors.find((e) => e.logMessage.raw.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX)); - if (error) { - let issues = ""; - const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/; - assignedIssues.forEach((el) => { - const match = el.html_url.match(urlPattern); - if (match) { - issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`); - } else { - issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`); - } - }); - - await context.commentHandler.postComment( - context, - logger.warn(` -${ERROR_MESSAGES.MAX_TASK_LIMIT} - -${issues} -`) - ); - return { content: ERROR_MESSAGES.MAX_TASK_LIMIT, status: HttpStatusCode.NOT_MODIFIED }; - } - } - } - - if (eligibility.errors.length > 1) { - throw new AggregateError(eligibility.errors.map((e) => new Error(e.logMessage.raw))); - } - - throw eligibility.errors[0]; -} - -export async function start( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context["payload"]["sender"], - teammates: string[] -): Promise { - const { logger } = context; - - if (!sender) { - throw logger.error(`Skipping '/start' since there is no sender in the context.`); - } - - // Centralized eligibility gate without side effects - const eligibility = await evaluateStartEligibility(context, issue, sender, teammates); - - if (!eligibility.ok) { - // handleStartErrors will either throw or return an error result - return await handleStartErrors(context, eligibility); - } - - // All checks passed, perform assignment - return performAssignment(context, issue, sender, eligibility.computed.toAssign); -} - -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({ context, logger, sender, username }: { username: string; context: Context; logger: Context["logger"]; sender: string }) { - // Check for unassignment first - this should take precedence over task limit - if (await hasUserBeenUnassigned(context, username)) { - throw logger.warn(ERROR_MESSAGES.UNASSIGNED.replace("{{username}}", username), { username }); - } - - const openedPullRequests = await getPendingOpenedPullRequests(context, username); - const assignedIssues = await getAssignedIssues(context, username); - const { limit, role } = await getUserRoleAndTaskLimit(context, username); - - // check for max and enforce max - if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) { - const errorMessage = username === sender ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${username} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; - logger.error(errorMessage, { - assignedIssues: assignedIssues.length, - openedPullRequests: openedPullRequests.length, - limit, - }); - - return { - isWithinLimit: false, - issues: assignedIssues, - }; - } - - return { - isWithinLimit: true, - issues: [], - role, - }; -} - -export type StartEligibilityResult = { - ok: boolean; - errors: LogReturn[]; - warnings: string[]; - computed: { - deadline: string | null; - isTaskStale: boolean; - wallet: string | null; - toAssign: string[]; - assignedIssues: AssignedIssue[]; - consideredCount: number; - senderRole: ReturnType; - }; -}; - -export async function evaluateStartEligibility( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context<"issue_comment.created">["payload"]["sender"], - teammates: string[] -): Promise { - const errors: LogReturn[] = []; - const warnings: string[] = []; - const assignedIssues: AssignedIssue[] = []; - - if (!sender) { - errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); - } - - const labels = (issue.labels ?? []) as Label[]; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); - const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); - const userRole = userAssociation.role; - - // Collaborators need price label - if (!priceLabel && userRole === "contributor") { - errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_REQUIRED)); - } - - const checkReqErr = await checkRequirements(context, issue, userRole); - if (checkReqErr) { - errors.push(context.logger.error(checkReqErr.message)); - } - - if (issue.body && isParentIssue(issue.body)) { - errors.push(context.logger.error(ERROR_MESSAGES.PARENT_ISSUES)); - } - - if (issue.state === ISSUE_TYPE.CLOSED) { - errors.push(context.logger.error(ERROR_MESSAGES.CLOSED)); - } - - const assignees = issue?.assignees ?? []; - if (assignees.length) { - // Check if the sender is already assigned to this issue - const isSenderAssigned = assignees.some((assignee) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); - const errorMessage = isSenderAssigned - ? ERROR_MESSAGES.ALREADY_ASSIGNED - : ERROR_MESSAGES.ISSUE_ALREADY_ASSIGNED; - errors.push(context.logger.error(errorMessage)); - } - - const allUsers = [...new Set([sender.login, ...teammates])]; - const toAssign: string[] = []; - for (const user of allUsers) { - let role: ReturnType | undefined = undefined; - try { - const res = await handleTaskLimitChecks({ context, logger: context.logger, sender: sender.login, username: user }); - // within limit? - if (!res.isWithinLimit) { - const message = user === sender.login ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${user} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; - errors.push( - context.logger.error(message, { - assignedIssues: res.issues.length, - openedPullRequests: 0, - limit: 0, - }) - ); - // capture issues for later comment rendering - res.issues.forEach((issue) => { - assignedIssues.push({ title: issue.title, html_url: issue.html_url }); - }); - } else { - toAssign.push(user); - } - // role for price ceiling check - role = res.role as ReturnType | undefined; - } catch (e) { - if (e instanceof Error) { - errors.push(context.logger.error(e.message)); - } else if (e instanceof LogReturn) { - errors.push(e); - } else { - errors.push(context.logger.error(`An error occurred while checking the task limit for ${user}`, { e })); - } - } - - if (priceLabel && role && role !== "admin") { - const { usdPriceMax } = context.config.taskAccessControl; - const min = Math.min(...Object.values(usdPriceMax)); - const allowed = role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; - const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; - const match = priceLabel.name.match(/Price:\s*([\d.]+)/); - if (!match || isNaN(parseFloat(match[1]))) { - errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_FORMAT_ERROR, { priceLabel: priceLabel.name })); - } else { - const price = parseFloat(match[1]); - if (userAllowedMaxPrice < 0) { - errors.push(context.logger.warn(ERROR_MESSAGES.PRESERVATION_MODE, { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number })); - } else if (price > userAllowedMaxPrice) { - errors.push( - context.logger.warn( - ERROR_MESSAGES.PRICE_LIMIT_EXCEEDED.replace("{{user}}", user).replace("{{userAllowedMaxPrice}}", userAllowedMaxPrice.toString()), - { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number } - ) - ); - } - } - } - } - - // Only add summary error if we haven't already added individual user errors - // (individual errors are added in the loop above when users exceed their limit) - if (toAssign.length === 0 && allUsers.length > 0) { - const message = allUsers.length > 1 ? ERROR_MESSAGES.ALL_TEAMMATES_REACHED : ERROR_MESSAGES.MAX_TASK_LIMIT; - // Only add if we don't already have quota-related errors (to avoid duplicates) - const hasQuotaError = errors.some((e) => { - const lowerMsg = e.logMessage.raw.toLowerCase(); - return ( - lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase()) || lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase()) - ); - }); - if (!hasQuotaError) { - errors.push(context.logger.error(message)); - } - } - - // Wallet - let wallet: string | null = null; - try { - wallet = await context.adapters.supabase.user.getWalletByUserId(sender.id, issue.number); - } catch { - errors.push(context.logger.error(context.config.emptyWalletText)); - } - - // Staleness & deadline - const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); - if (isTaskStale) { - warnings.push(ERROR_MESSAGES.TASK_STALE); - } - let deadline: string | null = null; - try { - deadline = getDeadline(labels); - } catch { - // don't throw (post a comment) "No labels are set." error - } - - return { - ok: errors.length === 0, - errors, - warnings, - computed: { - deadline, - isTaskStale, - wallet, - toAssign, - assignedIssues, - consideredCount: allUsers.length, - senderRole: userRole, - }, - }; -} - -export async function performAssignment( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: { login: string; id: number }, - toAssign: string[] -): Promise { - const { logger } = context; - // compute metadata - let commitHash: string | null = null; - try { - const hashResponse = await context.octokit.rest.repos.getCommit({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - ref: context.payload.repository.default_branch, - }); - commitHash = hashResponse.data.sha; - } catch (e) { - logger.error("Error while getting commit hash", { error: e as Error }); - } - const labels = issue.labels ?? []; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); - const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); - const toAssignIds = await fetchUserIds(context, toAssign); - const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); - const logMessage = logger.info(ERROR_MESSAGES.TASK_ASSIGNED, { - taskDeadline: assignmentComment.deadline, - taskAssignees: toAssignIds, - priceLabel, - revision: commitHash?.substring(0, 7), - }); - const metadata = structuredMetadata.create("Assignment", logMessage); - - await addAssignees(context, issue.number, toAssign); - - await context.commentHandler.postComment( - context, - logger.ok( - [ - assignTableComment({ - isTaskStale, - daysElapsedSinceTaskCreation: assignmentComment.daysElapsedSinceTaskCreation, - taskDeadline: assignmentComment.deadline, - registeredWallet: assignmentComment.registeredWallet, - }), - assignmentComment.tips, - metadata, - ].join("\n") as string - ), - { raw: true } - ); - - return { content: ERROR_MESSAGES.TASK_ASSIGNED, status: HttpStatusCode.OK }; -} diff --git a/src/handlers/start-command.ts b/src/handlers/start-command.ts new file mode 100644 index 00000000..dd24bcc4 --- /dev/null +++ b/src/handlers/start-command.ts @@ -0,0 +1,43 @@ +import { Context, isIssueCommentEvent } from "../types/index"; +import { HttpStatusCode, Result } from "../types/result-types"; +import { startTask } from "./start-task"; +import { stop } from "./stop-task"; + +export async function commandHandler(context: Context): Promise { + if (!isIssueCommentEvent(context)) { + return { status: HttpStatusCode.NOT_MODIFIED }; + } + if (!context.command) { + return { status: HttpStatusCode.NOT_MODIFIED }; + } + const { issue, sender, repository } = context.payload; + + if (context.command.name === "stop") { + return await stop(context, issue, sender, repository); + } else if (context.command.name === "start") { + const teammates = context.command.parameters.teammates ?? []; + return await startTask(context, issue, sender, teammates); + } else { + return { status: HttpStatusCode.BAD_REQUEST }; + } +} + +export async function userStartStop(context: Context): Promise { + if (!isIssueCommentEvent(context)) { + return { status: HttpStatusCode.NOT_MODIFIED }; + } + const { issue, comment, sender, repository } = context.payload; + const slashCommand = comment.body.trim().split(" ")[0].replace("/", ""); + const teamMates = comment.body + .split("@") + .slice(1) + .map((teamMate) => teamMate.split(" ")[0]); + + if (slashCommand === "stop") { + return await stop(context, issue, sender, repository); + } else if (slashCommand === "start") { + return await startTask(context, issue, sender, teamMates); + } + + return { status: HttpStatusCode.NOT_MODIFIED }; +} \ No newline at end of file diff --git a/src/handlers/start-task.ts b/src/handlers/start-task.ts new file mode 100644 index 00000000..c960cc76 --- /dev/null +++ b/src/handlers/start-task.ts @@ -0,0 +1,29 @@ +import { Context } from "../types/index"; +import { Result } from "../types/result-types"; +import { handleStartErrors } from "./start/helpers/error-messages"; +import { evaluateStartEligibility } from "./start/evaluate-eligibility"; +import { performAssignment } from "./start/perform-assignment"; + +export async function startTask( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: Context["payload"]["sender"], + teammates: string[] +): Promise { + const { logger } = context; + + if (!sender) { + throw logger.error(`Skipping '/start' since there is no sender in the context.`); + } + + // Centralized eligibility gate without side effects + const eligibility = await evaluateStartEligibility(context, issue, sender, teammates); + + if (!eligibility.ok) { + // handleStartErrors will either throw or return an error result + return await handleStartErrors(context, eligibility); + } + + // All checks passed, perform assignment + return performAssignment(context, issue, sender, eligibility.computed.toAssign); +} \ No newline at end of file diff --git a/src/handlers/start/evaluate-eligibility.ts b/src/handlers/start/evaluate-eligibility.ts new file mode 100644 index 00000000..bf201978 --- /dev/null +++ b/src/handlers/start/evaluate-eligibility.ts @@ -0,0 +1,182 @@ +import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; +import { AssignedIssue, Context, ISSUE_TYPE, Label } from "../../types/index"; +import { getTimeValue, isParentIssue } from "../../utils/issue"; +import { getTransformedRole, getUserRoleAndTaskLimit } from "../../utils/get-user-task-limit-and-role"; +import { checkRequirements } from "./helpers/check-requirements"; +import { ERROR_MESSAGES } from "./helpers/error-messages"; +import { handleTaskLimitChecks } from "./helpers/check-assignments"; +import { checkTaskStale } from "./helpers/check-task-stale"; +import { getDeadline } from "./helpers/get-deadline"; + +export type StartEligibilityResult = { + ok: boolean; + errors: LogReturn[]; + warnings: string[]; + computed: { + deadline: string | null; + isTaskStale: boolean; + wallet: string | null; + toAssign: string[]; + assignedIssues: AssignedIssue[]; + consideredCount: number; + senderRole: ReturnType; + }; + }; + + export async function evaluateStartEligibility( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: Context<"issue_comment.created">["payload"]["sender"], + teammates: string[] + ): Promise { + const errors: LogReturn[] = []; + const warnings: string[] = []; + const assignedIssues: AssignedIssue[] = []; + + if (!sender) { + errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); + } + + const labels = (issue.labels ?? []) as Label[]; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); + const userRole = userAssociation.role; + + // Collaborators need price label + if (!priceLabel && userRole === "contributor") { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_REQUIRED)); + } + + const checkReqErr = await checkRequirements(context, issue, userRole); + if (checkReqErr) { + errors.push(context.logger.error(checkReqErr.message)); + } + + if (issue.body && isParentIssue(issue.body)) { + errors.push(context.logger.error(ERROR_MESSAGES.PARENT_ISSUES)); + } + + if (issue.state === ISSUE_TYPE.CLOSED) { + errors.push(context.logger.error(ERROR_MESSAGES.CLOSED)); + } + + const assignees = issue?.assignees ?? []; + if (assignees.length) { + // Check if the sender is already assigned to this issue + const isSenderAssigned = assignees.some((assignee) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); + const errorMessage = isSenderAssigned + ? ERROR_MESSAGES.ALREADY_ASSIGNED + : ERROR_MESSAGES.ISSUE_ALREADY_ASSIGNED; + errors.push(context.logger.error(errorMessage)); + } + + const allUsers = [...new Set([sender.login, ...teammates])]; + const toAssign: string[] = []; + for (const user of allUsers) { + let role: ReturnType | undefined = undefined; + try { + const res = await handleTaskLimitChecks({ context, logger: context.logger, sender: sender.login, username: user }); + // within limit? + if (!res.isWithinLimit) { + const message = user === sender.login ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${user} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + errors.push( + context.logger.error(message, { + assignedIssues: res.issues.length, + openedPullRequests: 0, + limit: 0, + }) + ); + // capture issues for later comment rendering + res.issues.forEach((issue) => { + assignedIssues.push({ title: issue.title, html_url: issue.html_url }); + }); + } else { + toAssign.push(user); + } + // role for price ceiling check + role = res.role as ReturnType | undefined; + } catch (e) { + if (e instanceof Error) { + errors.push(context.logger.error(e.message)); + } else if (e instanceof LogReturn) { + errors.push(e); + } else { + errors.push(context.logger.error(`An error occurred while checking the task limit for ${user}`, { e })); + } + } + + if (priceLabel && role && role !== "admin") { + const { usdPriceMax } = context.config.taskAccessControl; + const min = Math.min(...Object.values(usdPriceMax)); + const allowed = role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; + const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; + const match = priceLabel.name.match(/Price:\s*([\d.]+)/); + if (!match || isNaN(parseFloat(match[1]))) { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_FORMAT_ERROR, { priceLabel: priceLabel.name })); + } else { + const price = parseFloat(match[1]); + if (userAllowedMaxPrice < 0) { + errors.push(context.logger.warn(ERROR_MESSAGES.PRESERVATION_MODE, { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number })); + } else if (price > userAllowedMaxPrice) { + errors.push( + context.logger.warn( + ERROR_MESSAGES.PRICE_LIMIT_EXCEEDED.replace("{{user}}", user).replace("{{userAllowedMaxPrice}}", userAllowedMaxPrice.toString()), + { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number } + ) + ); + } + } + } + } + + // Only add summary error if we haven't already added individual user errors + // (individual errors are added in the loop above when users exceed their limit) + if (toAssign.length === 0 && allUsers.length > 0) { + const message = allUsers.length > 1 ? ERROR_MESSAGES.ALL_TEAMMATES_REACHED : ERROR_MESSAGES.MAX_TASK_LIMIT; + // Only add if we don't already have quota-related errors (to avoid duplicates) + const hasQuotaError = errors.some((e) => { + const lowerMsg = e.logMessage.raw.toLowerCase(); + return ( + lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase()) || lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase()) + ); + }); + if (!hasQuotaError) { + errors.push(context.logger.error(message)); + } + } + + // Wallet + let wallet: string | null = null; + try { + wallet = await context.adapters.supabase.user.getWalletByUserId(sender.id, issue.number); + } catch { + errors.push(context.logger.error(context.config.emptyWalletText)); + } + + // Staleness & deadline + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); + if (isTaskStale) { + warnings.push(ERROR_MESSAGES.TASK_STALE); + } + let deadline: string | null = null; + try { + deadline = getDeadline(labels); + } catch { + // don't throw (post a comment) "No labels are set." error + } + + return { + ok: errors.length === 0, + errors, + warnings, + computed: { + deadline, + isTaskStale, + wallet, + toAssign, + assignedIssues, + consideredCount: allUsers.length, + senderRole: userRole, + }, + }; + } \ No newline at end of file diff --git a/src/handlers/start/helpers/check-assignments.ts b/src/handlers/start/helpers/check-assignments.ts new file mode 100644 index 00000000..0203a934 --- /dev/null +++ b/src/handlers/start/helpers/check-assignments.ts @@ -0,0 +1,48 @@ +import { Context } from "../../../types/index"; +import { getAssignedIssues, getOwnerRepoFromHtmlUrl, getPendingOpenedPullRequests } from "../../../utils/issue"; +import { getAssignmentPeriods } from "../../../utils/get-assignment-periods"; +import { getUserRoleAndTaskLimit } from "../../../utils/get-user-task-limit-and-role"; +import { ERROR_MESSAGES } from "./error-messages"; + +async function hasUserBeenUnassigned(context: Context, username: string): Promise { + if ("issue" in context.payload) { + const { number, html_url } = context.payload.issue; + const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url); + const assignmentPeriods = await getAssignmentPeriods(context.octokit, { owner, repo, issue_number: number }); + return assignmentPeriods[username]?.some((period) => period.reason === "bot" || period.reason === "admin"); + } + + return false; +} + +export async function handleTaskLimitChecks({ context, logger, sender, username }: { username: string; context: Context; logger: Context["logger"]; sender: string }) { + // Check for unassignment first - this should take precedence over task limit + if (await hasUserBeenUnassigned(context, username)) { + throw logger.warn(ERROR_MESSAGES.UNASSIGNED.replace("{{username}}", username), { username }); + } + + const openedPullRequests = await getPendingOpenedPullRequests(context, username); + const assignedIssues = await getAssignedIssues(context, username); + const { limit, role } = await getUserRoleAndTaskLimit(context, username); + + // check for max and enforce max + if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) { + const errorMessage = username === sender ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${username} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + logger.error(errorMessage, { + assignedIssues: assignedIssues.length, + openedPullRequests: openedPullRequests.length, + limit, + }); + + return { + isWithinLimit: false, + issues: assignedIssues, + }; + } + + return { + isWithinLimit: true, + issues: [], + role, + }; +} diff --git a/src/handlers/start/helpers/check-requirements.ts b/src/handlers/start/helpers/check-requirements.ts new file mode 100644 index 00000000..1bc8e4e1 --- /dev/null +++ b/src/handlers/start/helpers/check-requirements.ts @@ -0,0 +1,57 @@ +import { Context } from "../../../types/index"; +import { getTransformedRole } from "../../../utils/get-user-task-limit-and-role"; +import { ERROR_MESSAGES } from "./error-messages"; + + +export async function checkRequirements( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + userRole: ReturnType + ): Promise { + const { + config: { requiredLabelsToStart }, + logger, + } = context; + const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); + + if (requiredLabelsToStart.length) { + const currentLabelConfiguration = requiredLabelsToStart.find((label) => + issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase()) + ); + + // Admins can start any task + if (userRole === "admin") { + return null; + } + + if (!currentLabelConfiguration) { + // If we didn't find the label in the allowed list, then the user cannot start this task. + const errorText = ERROR_MESSAGES.NOT_BUSINESS_PRIORITY.replace( + "{{requiredLabelsToStart}}", + requiredLabelsToStart.map((label) => `\`${label.name}\``).join(", ") + ); + + logger.error(errorText, { + requiredLabelsToStart, + issueLabels, + issue: issue.html_url, + }); + return new Error(errorText); + } else if (!currentLabelConfiguration.allowedRoles.includes(userRole)) { + // If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task. + const humanReadableRoles = [ + ...currentLabelConfiguration.allowedRoles.map((o) => (o === "collaborator" ? "a core team member" : `a ${o}`)), + "an administrator", + ].join(", or "); + const errorText = `You must be ${humanReadableRoles} to start this task`; + logger.error(errorText, { + currentLabelConfiguration, + issueLabels, + issue: issue.html_url, + userRole, + }); + return new Error(errorText); + } + } + return null; + } \ No newline at end of file diff --git a/src/handlers/shared/check-task-stale.ts b/src/handlers/start/helpers/check-task-stale.ts similarity index 100% rename from src/handlers/shared/check-task-stale.ts rename to src/handlers/start/helpers/check-task-stale.ts diff --git a/src/handlers/start/helpers/error-messages.ts b/src/handlers/start/helpers/error-messages.ts new file mode 100644 index 00000000..51814ae5 --- /dev/null +++ b/src/handlers/start/helpers/error-messages.ts @@ -0,0 +1,95 @@ +import { Context } from "../../../types"; +import { HttpStatusCode, Result } from "../../../types/result-types"; +import { StartEligibilityResult } from "../evaluate-eligibility"; + +export const ERROR_MESSAGES = { + UNASSIGNED: "{{username}} you were previously unassigned from this task. You cannot be reassigned.", + MAX_TASK_LIMIT: "You have reached your max task limit. Please close out some tasks before assigning new ones.", + MAX_TASK_LIMIT_PREFIX: "You have reached your max task limit", + MAX_TASK_LIMIT_TEAMMATE_PREFIX: "has reached their max task limit", + ALL_TEAMMATES_REACHED: "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.", + PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", + CLOSED: "This issue is closed, please choose another.", + ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", + ISSUE_ALREADY_ASSIGNED: "This issue is already assigned. Please choose another unassigned task.", + PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", + PRICE_LIMIT_EXCEEDED: + "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", + PRICE_LABEL_FORMAT_ERROR: "Price label is not in the correct format", + TASK_STALE: "Task appears stale; confirm specification before starting.", + TASK_ASSIGNED: "Task assigned successfully", + MISSING_SENDER: "Missing sender", + NOT_BUSINESS_PRIORITY: + "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", + PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", + } as const; + + +export async function handleStartErrors(context: Context, eligibility: StartEligibilityResult): Promise { + const { logger } = context; + const errorMessages = eligibility.errors.map((e) => e.logMessage.raw.toLowerCase()); + + // Check for unassigned errors first - these should take precedence over all other errors + const unassignedPattern = ERROR_MESSAGES.UNASSIGNED.toLowerCase().replace("{{username}}", "").trim(); + const unassignedError = eligibility.errors.find((e) => { + const lowerMsg = e.logMessage.raw.toLowerCase(); + return lowerMsg.includes(unassignedPattern); + }); + if (unassignedError) { + throw unassignedError; + } + + const hasParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PARENT_ISSUES.toLowerCase())); + const hasPreParentReason = errorMessages.some((msg) => msg.includes(ERROR_MESSAGES.PRICE_LABEL_REQUIRED.toLowerCase())); + + // Preserve original ordering: if pre-parent validations fail, do NOT post parent comment + if (hasParentReason && !hasPreParentReason) { + const message = logger.error(ERROR_MESSAGES.PARENT_ISSUES); + await context.commentHandler.postComment(context, message); + throw message; + } + + // Quota-only cases: post comment with assigned issues list + const quotaPrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase(); + const quotaTeammatePrefixLower = ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase(); + const isQuotaOnly = errorMessages.every((msg) => msg.includes(quotaTeammatePrefixLower) || msg.includes(quotaPrefixLower)); + + if (isQuotaOnly) { + const { toAssign, assignedIssues, consideredCount } = eligibility.computed; + if (toAssign.length === 0 && consideredCount > 1) { + const allTeammatesPattern = "All teammates have reached"; + const error = eligibility.errors.find((e) => e.logMessage.raw.includes(allTeammatesPattern)); + if (error) throw error; + } else if (toAssign.length === 0) { + const error = eligibility.errors.find((e) => e.logMessage.raw.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX)); + if (error) { + let issues = ""; + const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/; + assignedIssues.forEach((el) => { + const match = el.html_url.match(urlPattern); + if (match) { + issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`); + } else { + issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`); + } + }); + + await context.commentHandler.postComment( + context, + logger.warn(` +${ERROR_MESSAGES.MAX_TASK_LIMIT} + +${issues} +`) + ); + return { content: ERROR_MESSAGES.MAX_TASK_LIMIT, status: HttpStatusCode.NOT_MODIFIED }; + } + } + } + + if (eligibility.errors.length > 1) { + throw new AggregateError(eligibility.errors.map((e) => new Error(e.logMessage.raw))); + } + + throw eligibility.errors[0]; +} diff --git a/src/handlers/shared/generate-assignment-comment.ts b/src/handlers/start/helpers/generate-assignment-comment.ts similarity index 66% rename from src/handlers/shared/generate-assignment-comment.ts rename to src/handlers/start/helpers/generate-assignment-comment.ts index 622f8a8f..df03d8d4 100644 --- a/src/handlers/shared/generate-assignment-comment.ts +++ b/src/handlers/start/helpers/generate-assignment-comment.ts @@ -1,5 +1,4 @@ -import { Context } from "../../types/index"; -import { calculateDurations } from "../../utils/shared"; +import { Context } from "../../../types/index"; export const options: Intl.DateTimeFormatOptions = { weekday: "short", @@ -11,17 +10,6 @@ export const options: Intl.DateTimeFormatOptions = { timeZoneName: "short", }; -export function getDeadline(labels: Context<"issue_comment.created">["payload"]["issue"]["labels"] | undefined | null): string | null { - if (!labels?.length) { - throw new Error("No labels are set."); - } - const startTime = new Date().getTime(); - const duration: number = calculateDurations(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(); @@ -42,4 +30,4 @@ export async function generateAssignmentComment(context: Context, issueCreatedAt > - Be sure to open a draft pull request as soon as possible to communicate updates on your progress. > - Be sure to provide timely updates to us when requested, or you will be automatically unassigned from the task.`, }; -} +} \ No newline at end of file diff --git a/src/handlers/shared/table.ts b/src/handlers/start/helpers/generate-assignment-table.ts similarity index 100% rename from src/handlers/shared/table.ts rename to src/handlers/start/helpers/generate-assignment-table.ts diff --git a/src/handlers/shared/structured-metadata.ts b/src/handlers/start/helpers/generate-structured-metadata.ts similarity index 100% rename from src/handlers/shared/structured-metadata.ts rename to src/handlers/start/helpers/generate-structured-metadata.ts diff --git a/src/handlers/start/helpers/get-deadline.ts b/src/handlers/start/helpers/get-deadline.ts new file mode 100644 index 00000000..64c6e89e --- /dev/null +++ b/src/handlers/start/helpers/get-deadline.ts @@ -0,0 +1,14 @@ +import { Context } from "../../../types/index"; +import { options } from "./generate-assignment-comment"; +import { calculateDurations } from "../../../utils/shared"; + +export function getDeadline(labels: Context<"issue_comment.created">["payload"]["issue"]["labels"] | undefined | null): string | null { + if (!labels?.length) { + throw new Error("No labels are set."); + } + const startTime = new Date().getTime(); + const duration: number = calculateDurations(labels).shift() ?? 0; + if (!duration) return null; + const endTime = new Date(startTime + duration * 1000); + return endTime.toLocaleString("en-US", options); + } \ No newline at end of file diff --git a/src/handlers/start/helpers/get-user-ids.ts b/src/handlers/start/helpers/get-user-ids.ts new file mode 100644 index 00000000..022a3e9b --- /dev/null +++ b/src/handlers/start/helpers/get-user-ids.ts @@ -0,0 +1,19 @@ +import { Context } from "../../../types/index"; + +export async function getUserIds(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; + } \ No newline at end of file diff --git a/src/handlers/start/perform-assignment.ts b/src/handlers/start/perform-assignment.ts new file mode 100644 index 00000000..eea6ee81 --- /dev/null +++ b/src/handlers/start/perform-assignment.ts @@ -0,0 +1,63 @@ +import { HttpStatusCode, Result } from "../../types/result-types"; +import { Context, Label } from "../../types"; +import { getTimeValue, addAssignees } from "../../utils/issue"; +import { ERROR_MESSAGES } from "./helpers/error-messages"; +import { checkTaskStale } from "./helpers/check-task-stale"; +import { generateAssignmentComment } from "./helpers/generate-assignment-comment"; +import { getUserIds } from "./helpers/get-user-ids"; +import structuredMetadata from "./helpers/generate-structured-metadata"; +import { assignTableComment } from "./helpers/generate-assignment-table"; + +export async function performAssignment( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: { login: string; id: number }, + toAssign: string[] + ): Promise { + const { logger } = context; + // compute metadata + let commitHash: string | null = null; + try { + const hashResponse = await context.octokit.rest.repos.getCommit({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + ref: context.payload.repository.default_branch, + }); + commitHash = hashResponse.data.sha; + } catch (e) { + logger.error("Error while getting commit hash", { error: e as Error }); + } + const labels = issue.labels ?? []; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); + const toAssignIds = await getUserIds(context, toAssign); + const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); + const logMessage = logger.info(ERROR_MESSAGES.TASK_ASSIGNED, { + taskDeadline: assignmentComment.deadline, + taskAssignees: toAssignIds, + priceLabel, + revision: commitHash?.substring(0, 7), + }); + const metadata = structuredMetadata.create("Assignment", logMessage); + + await addAssignees(context, issue.number, toAssign); + + await context.commentHandler.postComment( + context, + logger.ok( + [ + assignTableComment({ + isTaskStale, + daysElapsedSinceTaskCreation: assignmentComment.daysElapsedSinceTaskCreation, + taskDeadline: assignmentComment.deadline, + registeredWallet: assignmentComment.registeredWallet, + }), + assignmentComment.tips, + metadata, + ].join("\n") as string + ), + { raw: true } + ); + + return { content: ERROR_MESSAGES.TASK_ASSIGNED, status: HttpStatusCode.OK }; + } \ No newline at end of file diff --git a/src/handlers/shared/stop.ts b/src/handlers/stop-task.ts similarity index 87% rename from src/handlers/shared/stop.ts rename to src/handlers/stop-task.ts index 42cb8df3..5d314ccd 100644 --- a/src/handlers/shared/stop.ts +++ b/src/handlers/stop-task.ts @@ -1,6 +1,6 @@ -import { Assignee, Context, Sender } from "../../types/index"; -import { closePullRequestForAnIssue } from "../../utils/issue"; -import { HttpStatusCode, Result } from "../result-types"; +import { Assignee, Context, Sender } from "../types/index"; +import { closePullRequestForAnIssue } from "../utils/issue"; +import { HttpStatusCode, Result } from "../types/result-types"; export async function stop( context: Context, diff --git a/src/handlers/user-start-stop.ts b/src/handlers/user-start-stop.ts deleted file mode 100644 index 264716c3..00000000 --- a/src/handlers/user-start-stop.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { Repository } from "@octokit/graphql-schema"; -import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit"; -import { Context, isIssueCommentEvent } from "../types/index"; -import { QUERY_CLOSING_ISSUE_REFERENCES } from "../utils/get-closing-issue-references"; -import { closePullRequest, closePullRequestForAnIssue, getOwnerRepoFromHtmlUrl } 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 commandHandler(context: Context): Promise { - if (!isIssueCommentEvent(context)) { - return { status: HttpStatusCode.NOT_MODIFIED }; - } - if (!context.command) { - return { status: HttpStatusCode.NOT_MODIFIED }; - } - const { issue, sender, repository } = context.payload; - - if (context.command.name === "stop") { - return await stop(context, issue, sender, repository); - } else if (context.command.name === "start") { - const teammates = context.command.parameters.teammates ?? []; - return await start(context, issue, sender, teammates); - } else { - return { status: HttpStatusCode.BAD_REQUEST }; - } -} - -export async function userStartStop(context: Context): Promise { - if (!isIssueCommentEvent(context)) { - return { status: HttpStatusCode.NOT_MODIFIED }; - } - const { issue, comment, sender, repository } = context.payload; - const slashCommand = comment.body.trim().split(" ")[0].replace("/", ""); - const teamMates = comment.body - .split("@") - .slice(1) - .map((teamMate) => teamMate.split(" ")[0]); - - if (slashCommand === "stop") { - return await stop(context, issue, sender, repository); - } else if (slashCommand === "start") { - return await start(context, issue, sender, teamMates); - } - - return { status: HttpStatusCode.NOT_MODIFIED }; -} - -export async function userPullRequest(context: Context<"pull_request.opened" | "pull_request.edited">): Promise { - const { payload } = context; - const { pull_request } = payload; - const { owner, repo } = getOwnerRepoFromHtmlUrl(pull_request.html_url); - const linkedIssues = await context.octokit.graphql.paginate<{ repository: Repository }>(QUERY_CLOSING_ISSUE_REFERENCES, { - owner, - repo, - issue_number: pull_request.number, - }); - const issues = linkedIssues.repository.pullRequest?.closingIssuesReferences?.nodes; - if (!issues) { - context.logger.info("No linked issues were found, nothing to do."); - return { status: HttpStatusCode.NOT_MODIFIED }; - } - - const appOctokit = new customOctokit({ - authStrategy: createAppAuth, - auth: { - appId: context.env.APP_ID, - privateKey: context.env.APP_PRIVATE_KEY, - }, - }); - - for (const issue of issues) { - if (!issue || issue.assignees.nodes?.length) { - continue; - } - - const installation = await appOctokit.rest.apps.getRepoInstallation({ - owner: issue.repository.owner.login, - repo: issue.repository.name, - }); - const repoOctokit = new customOctokit({ - authStrategy: createAppAuth, - auth: { - appId: Number(context.env.APP_ID), - privateKey: context.env.APP_PRIVATE_KEY, - installationId: installation.data.id, - }, - }); - - const linkedIssue = ( - await repoOctokit.rest.issues.get({ - owner: issue.repository.owner.login, - repo: issue.repository.name, - issue_number: issue.number, - }) - ).data as Context<"issue_comment.created">["payload"]["issue"]; - const deadline = getDeadline(linkedIssue.labels); - if (!deadline) { - context.logger.debug("Skipping deadline posting message because no deadline has been set."); - return { status: HttpStatusCode.NOT_MODIFIED }; - } - - const repository = ( - await repoOctokit.rest.repos.get({ - owner: issue.repository.owner.login, - repo: issue.repository.name, - }) - ).data as Context<"issue_comment.created">["payload"]["repository"]; - let organization: Context<"issue_comment.created">["payload"]["organization"] | undefined = undefined; - if (repository.owner.type === "Organization") { - organization = ( - await repoOctokit.rest.orgs.get({ - org: issue.repository.owner.login, - }) - ).data; - } - const newContext = { - ...context, - octokit: repoOctokit, - payload: { - ...context.payload, - issue: linkedIssue, - repository, - organization, - }, - }; - try { - return await start(newContext, linkedIssue, pull_request.user ?? payload.sender, []); - } catch (error) { - await closePullRequest(context, { number: pull_request.number }); - throw error; - } - } - return { status: HttpStatusCode.NOT_MODIFIED }; -} - -export async function userUnassigned(context: Context<"issues.unassigned">): Promise { - if (!("issue" in context.payload)) { - context.logger.debug("Payload does not contain an issue, skipping issues.unassigned event."); - return { status: HttpStatusCode.NOT_MODIFIED }; - } - const { payload } = context; - const { issue, repository, assignee } = payload; - // 'assignee' is the user that actually got un-assigned during this event. Since it can theoretically be null, - // we display an error if none is found in the payload. - if (!assignee) { - throw context.logger.fatal("No assignee found in payload, failed to close pull-requests."); - } - await closePullRequestForAnIssue(context, issue.number, repository, assignee?.login); - return { status: HttpStatusCode.OK, content: "Linked pull-requests closed." }; -} diff --git a/src/plugin.ts b/src/plugin.ts index b3b94f4f..d9024bc9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,9 +1,11 @@ import { createClient } from "@supabase/supabase-js"; import { createAdapters } from "./adapters/index"; -import { HttpStatusCode } from "./handlers/result-types"; -import { commandHandler, userPullRequest, userStartStop, userUnassigned } from "./handlers/user-start-stop"; +import { HttpStatusCode } from "./types/result-types"; +import { commandHandler, userStartStop } from "./handlers/start-command"; import { Context } from "./types/index"; import { listOrganizations } from "./utils/list-organizations"; +import { closeUserUnassignedPr } from "./handlers/close-pull-on-unassign"; +import { newPullRequestOrEdit } from "./handlers/new-pull-request-or-edit"; export async function startStopTask(context: Context) { context.adapters = createAdapters(createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY), context as Context); @@ -18,11 +20,10 @@ export async function startStopTask(context: Context) { case "issue_comment.created": return await userStartStop(context as Context<"issue_comment.created">); case "pull_request.opened": - return await userPullRequest(context as Context<"pull_request.opened">); case "pull_request.edited": - return await userPullRequest(context as Context<"pull_request.edited">); + return await newPullRequestOrEdit(context as Context<"pull_request.edited">); case "issues.unassigned": - return await userUnassigned(context as Context<"issues.unassigned">); + return await closeUserUnassignedPr(context as Context<"issues.unassigned">); default: context.logger.error(`Unsupported event: ${context.eventName}`); return { status: HttpStatusCode.BAD_REQUEST }; diff --git a/src/handlers/result-types.ts b/src/types/result-types.ts similarity index 100% rename from src/handlers/result-types.ts rename to src/types/result-types.ts diff --git a/src/handlers/shared/user-assigned-timespans.ts b/src/utils/get-assignment-periods.ts similarity index 98% rename from src/handlers/shared/user-assigned-timespans.ts rename to src/utils/get-assignment-periods.ts index dacfd2f0..5921cc91 100644 --- a/src/handlers/shared/user-assigned-timespans.ts +++ b/src/utils/get-assignment-periods.ts @@ -1,4 +1,4 @@ -import { Context } from "../../types/index"; +import { Context } from "../types/index"; interface IssueParams { owner: string; diff --git a/src/handlers/shared/get-user-task-limit-and-role.ts b/src/utils/get-user-task-limit-and-role.ts similarity index 91% rename from src/handlers/shared/get-user-task-limit-and-role.ts rename to src/utils/get-user-task-limit-and-role.ts index cbbf1236..0e81fe2c 100644 --- a/src/handlers/shared/get-user-task-limit-and-role.ts +++ b/src/utils/get-user-task-limit-and-role.ts @@ -1,15 +1,15 @@ -import { ADMIN_ROLES, COLLABORATOR_ROLES, Context, PluginSettings } from "../../types/index"; +import { ADMIN_ROLES, COLLABORATOR_ROLES, Context, PluginSettings } from "../types/index"; interface MatchingUserProps { role: ReturnType; limit: number; } -export function isAdminRole(role: string) { +function isAdminRole(role: string) { return ADMIN_ROLES.includes(role.toLowerCase()); } -export function isCollaboratorRole(role: string) { +function isCollaboratorRole(role: string) { return COLLABORATOR_ROLES.includes(role.toLowerCase()); } @@ -23,7 +23,7 @@ export function getTransformedRole(role: string) { return "contributor"; } -export function getUserTaskLimit(maxConcurrentTasks: PluginSettings["maxConcurrentTasks"], role: string) { +function getUserTaskLimit(maxConcurrentTasks: PluginSettings["maxConcurrentTasks"], role: string) { if (isAdminRole(role)) { return Infinity; } diff --git a/src/worker.ts b/src/worker.ts index 0c87fb9a..e0e1f41a 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,9 +9,10 @@ import { Command } from "./types/command"; import { SupportedEvents } from "./types/context"; import { Env, envSchema } from "./types/env"; import { PluginSettings, pluginSettingsSchema } from "./types/plugin-input"; +import { handlePublicStart } from "./handlers/start/public-api"; export default { - async fetch(request: Request, env: Record, executionCtx?: ExecutionContext) { + async fetch(request: Request, env: Env, executionCtx?: ExecutionContext) { const honoApp = createPlugin( (context) => { return startStopTask({ @@ -31,6 +32,11 @@ export default { } ); + // Public API route + honoApp.post("/public/start", async (c) => { + return await handlePublicStart(c.req.raw as Request, env); + }); + return honoApp.fetch(request, env, executionCtx); }, }; diff --git a/tests/main.test.ts b/tests/main.test.ts index a1bae56c..67dbd201 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -5,14 +5,15 @@ import { createClient } from "@supabase/supabase-js"; import { cleanLogString, LogReturn } from "@ubiquity-os/ubiquity-os-logger"; import dotenv from "dotenv"; import { createAdapters } from "../src/adapters"; -import { HttpStatusCode } from "../src/handlers/result-types"; -import { userStartStop, userUnassigned } from "../src/handlers/user-start-stop"; +import { HttpStatusCode } from "../src/types/result-types"; +import { userStartStop } from "../src/handlers/start-command"; import { Context, Env, envSchema, Sender } from "../src/types"; import { db } from "./__mocks__/db"; import issueTemplate from "./__mocks__/issue-template"; import { server } from "./__mocks__/node"; import usersGet from "./__mocks__/users-get.json"; import { createContext, MAX_CONCURRENT_DEFAULTS } from "./utils"; +import { closeUserUnassignedPr } from "../src/handlers/close-pull-on-unassign"; dotenv.config(); @@ -156,7 +157,7 @@ describe("User start/stop", () => { context.adapters = createAdapters(getSupabase(), context); - const { content } = await userUnassigned(context); + const { content } = await closeUserUnassignedPr(context); expect(content).toEqual("Linked pull-requests closed."); const logs = infoSpy.mock.calls.flat(); diff --git a/tests/roles.test.ts b/tests/roles.test.ts index ac407f96..bae141d1 100644 --- a/tests/roles.test.ts +++ b/tests/roles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; import { Logs } from "@ubiquity-os/ubiquity-os-logger"; -import { getUserRoleAndTaskLimit } from "../src/handlers/shared/get-user-task-limit-and-role"; +import { getUserRoleAndTaskLimit } from "../src/utils/get-user-task-limit-and-role"; import { Context } from "../src/types"; describe("Role tests", () => { diff --git a/tests/start.test.ts b/tests/start.test.ts index 849c40ef..1236c1fe 100644 --- a/tests/start.test.ts +++ b/tests/start.test.ts @@ -18,7 +18,7 @@ type PayloadSender = Context["payload"]["sender"]; const commandStartStop = "command-start-stop"; const ubiquityOsMarketplace = "ubiquity-os-marketplace"; -const modulePath = "../src/handlers/shared/start"; +const modulePath = "../src/handlers/start-task"; const supabaseModulePath = "@supabase/supabase-js"; const adaptersModulePath = "../src/adapters"; @@ -121,9 +121,9 @@ describe("Collaborator tests", () => { jest.unstable_mockModule(adaptersModulePath, () => ({ createAdapters: jest.fn(), })); - const start = jest.fn(); + const startTask = jest.fn(); jest.unstable_mockModule(modulePath, () => ({ - start, + startTask, })); jest.unstable_mockModule("@ubiquity-os/plugin-sdk/octokit", () => ({ customOctokit: jest.fn().mockReturnValue({ @@ -146,8 +146,8 @@ describe("Collaborator tests", () => { const { startStopTask } = await import("../src/plugin"); await startStopTask(context); // Make sure the author is the one who starts and not the sender who modified the comment - expect(start).toHaveBeenCalledWith(expect.anything(), expect.anything(), { id: 1, login: userLogin }, []); - start.mockClear(); + expect(startTask).toHaveBeenCalledWith(expect.anything(), expect.anything(), { id: 1, login: userLogin }, []); + startTask.mockClear(); }); it("should successfully assign if the PR and linked issue are in different organizations", async () => { @@ -209,9 +209,9 @@ describe("Collaborator tests", () => { jest.unstable_mockModule(adaptersModulePath, () => ({ createAdapters: jest.fn(), })); - const start = jest.fn(); + const startTask = jest.fn(); jest.unstable_mockModule(modulePath, () => ({ - start, + startTask, })); jest.unstable_mockModule("@ubiquity-os/plugin-sdk/octokit", () => ({ customOctokit: jest.fn().mockReturnValue({ @@ -233,10 +233,10 @@ describe("Collaborator tests", () => { })); const { startStopTask } = await import("../src/plugin"); await startStopTask(context); - expect(start.mock.calls[0][0]).toMatchObject({ payload: { issue, repository, organization: repository?.owner } }); - expect(start.mock.calls[0][1]).toMatchObject({ id: 1 }); - expect(start.mock.calls[0][2]).toMatchObject({ id: 1, login: "whilefoo" }); - expect(start.mock.calls[0][3]).toEqual([]); - start.mockReset(); + expect(startTask.mock.calls[0][0]).toMatchObject({ payload: { issue, repository, organization: repository?.owner } }); + expect(startTask.mock.calls[0][1]).toMatchObject({ id: 1 }); + expect(startTask.mock.calls[0][2]).toMatchObject({ id: 1, login: "whilefoo" }); + expect(startTask.mock.calls[0][3]).toEqual([]); + startTask.mockReset(); }); }); From 188a42818681d77b3273cee54e6ee1b525ac93de Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:23:10 +0000 Subject: [PATCH 04/56] chore: run format - Consolidated listener definitions in manifest.json for better readability. - Enhanced code formatting across multiple handler files, ensuring consistent style and improved clarity. - Refactored error handling and eligibility checks in task assignment to enhance maintainability. - Updated various helper functions to improve structure and readability, including better error messaging and streamlined logic. - Ensured all files end with a newline for consistency. --- manifest.json | 59 +--- src/handlers/close-pull-on-unassign.ts | 31 +- src/handlers/new-pull-request-or-edit.ts | 152 ++++---- src/handlers/start-command.ts | 2 +- src/handlers/start-task.ts | 2 +- src/handlers/start/evaluate-eligibility.ts | 324 +++++++++--------- .../start/helpers/check-assignments.ts | 12 +- .../start/helpers/check-requirements.ts | 99 +++--- src/handlers/start/helpers/error-messages.ts | 41 ++- .../helpers/generate-assignment-comment.ts | 2 +- src/handlers/start/helpers/get-deadline.ts | 18 +- src/handlers/start/helpers/get-user-ids.ts | 32 +- src/handlers/start/perform-assignment.ts | 102 +++--- 13 files changed, 421 insertions(+), 455 deletions(-) diff --git a/manifest.json b/manifest.json index 54ad34c5..976c6b1d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,7 @@ { "name": "Start | Stop", "description": "Assign or un-assign yourself from an issue/task.", - "ubiquity:listeners": [ - "issues.unassigned", - "pull_request.opened", - "pull_request.edited" - ], + "ubiquity:listeners": ["issues.unassigned", "pull_request.opened", "pull_request.edited"], "commands": { "start": { "ubiquity:example": "/start", @@ -39,19 +35,13 @@ "properties": { "reviewDelayTolerance": { "default": "1 Day", - "examples": [ - "1 Day", - "5 Days" - ], + "examples": ["1 Day", "5 Days"], "description": "When considering a user for a task: if they have existing PRs with no reviews, how long should we wait before 'increasing' their assignable task limit?", "type": "string" }, "taskStaleTimeoutDuration": { "default": "30 Days", - "examples": [ - "1 Day", - "5 Days" - ], + "examples": ["1 Day", "5 Days"], "description": "When displaying the '/start' response, how long should we wait before considering a task 'stale' and provide a warning?", "type": "string" }, @@ -84,11 +74,7 @@ "assignedIssueScope": { "default": "org", "description": "When considering a user for a task: should we consider their assigned issues at the org, repo, or network level?", - "examples": [ - "org", - "repo", - "network" - ], + "examples": ["org", "repo", "network"], "anyOf": [ { "const": "org", @@ -110,23 +96,12 @@ "type": "string" }, "rolesWithReviewAuthority": { - "default": [ - "OWNER", - "ADMIN", - "MEMBER", - "COLLABORATOR" - ], + "default": ["OWNER", "ADMIN", "MEMBER", "COLLABORATOR"], "uniqueItems": true, "description": "When considering a user for a task: which roles should be considered as having review authority? All others are ignored.", "examples": [ - [ - "OWNER", - "ADMIN" - ], - [ - "MEMBER", - "COLLABORATOR" - ] + ["OWNER", "ADMIN"], + ["MEMBER", "COLLABORATOR"] ], "type": "array", "items": { @@ -153,14 +128,7 @@ "requiredLabelsToStart": { "default": [], "description": "If set, a task must have at least one of these labels to be started.", - "examples": [ - [ - "Priority: 5 (Emergency)" - ], - [ - "Good First Issue" - ] - ], + "examples": [["Priority: 5 (Emergency)"], ["Good First Issue"]], "type": "array", "items": { "type": "object", @@ -173,12 +141,7 @@ "description": "The list of allowed roles to start the task with the given label.", "uniqueItems": true, "default": [], - "examples": [ - [ - "collaborator", - "contributor" - ] - ], + "examples": [["collaborator", "contributor"]], "type": "array", "items": { "anyOf": [ @@ -194,9 +157,7 @@ } } }, - "required": [ - "name" - ] + "required": ["name"] } }, "taskAccessControl": { diff --git a/src/handlers/close-pull-on-unassign.ts b/src/handlers/close-pull-on-unassign.ts index a0bebb9e..234a3663 100644 --- a/src/handlers/close-pull-on-unassign.ts +++ b/src/handlers/close-pull-on-unassign.ts @@ -1,20 +1,19 @@ -import { Context} from "../types/index"; -import { closePullRequestForAnIssue } from "../utils/issue"; +import { Context } from "../types/index"; +import { closePullRequestForAnIssue } from "../utils/issue"; import { HttpStatusCode, Result } from "../types/result-types"; export async function closeUserUnassignedPr(context: Context<"issues.unassigned">): Promise { - if (!("issue" in context.payload)) { - context.logger.debug("Payload does not contain an issue, skipping issues.unassigned event."); - return { status: HttpStatusCode.NOT_MODIFIED }; - } - const { payload } = context; - const { issue, repository, assignee } = payload; - // 'assignee' is the user that actually got un-assigned during this event. Since it can theoretically be null, - // we display an error if none is found in the payload. - if (!assignee) { - throw context.logger.fatal("No assignee found in payload, failed to close pull-requests."); - } - await closePullRequestForAnIssue(context, issue.number, repository, assignee?.login); - return { status: HttpStatusCode.OK, content: "Linked pull-requests closed." }; + if (!("issue" in context.payload)) { + context.logger.debug("Payload does not contain an issue, skipping issues.unassigned event."); + return { status: HttpStatusCode.NOT_MODIFIED }; } - \ No newline at end of file + const { payload } = context; + const { issue, repository, assignee } = payload; + // 'assignee' is the user that actually got un-assigned during this event. Since it can theoretically be null, + // we display an error if none is found in the payload. + if (!assignee) { + throw context.logger.fatal("No assignee found in payload, failed to close pull-requests."); + } + await closePullRequestForAnIssue(context, issue.number, repository, assignee?.login); + return { status: HttpStatusCode.OK, content: "Linked pull-requests closed." }; +} diff --git a/src/handlers/new-pull-request-or-edit.ts b/src/handlers/new-pull-request-or-edit.ts index 1252ca7c..8f234dad 100644 --- a/src/handlers/new-pull-request-or-edit.ts +++ b/src/handlers/new-pull-request-or-edit.ts @@ -9,89 +9,89 @@ import { startTask } from "./start-task"; import { getDeadline } from "./start/helpers/get-deadline"; export async function newPullRequestOrEdit(context: Context<"pull_request.opened" | "pull_request.edited">): Promise { - const { payload } = context; - const { pull_request } = payload; - const { owner, repo } = getOwnerRepoFromHtmlUrl(pull_request.html_url); - const linkedIssues = await context.octokit.graphql.paginate<{ repository: Repository }>(QUERY_CLOSING_ISSUE_REFERENCES, { - owner, - repo, - issue_number: pull_request.number, - }); - const issues = linkedIssues.repository.pullRequest?.closingIssuesReferences?.nodes; - if (!issues) { - context.logger.info("No linked issues were found, nothing to do."); - return { status: HttpStatusCode.NOT_MODIFIED }; + const { payload } = context; + const { pull_request } = payload; + const { owner, repo } = getOwnerRepoFromHtmlUrl(pull_request.html_url); + const linkedIssues = await context.octokit.graphql.paginate<{ repository: Repository }>(QUERY_CLOSING_ISSUE_REFERENCES, { + owner, + repo, + issue_number: pull_request.number, + }); + const issues = linkedIssues.repository.pullRequest?.closingIssuesReferences?.nodes; + if (!issues) { + context.logger.info("No linked issues were found, nothing to do."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + + const appOctokit = new customOctokit({ + authStrategy: createAppAuth, + auth: { + appId: context.env.APP_ID, + privateKey: context.env.APP_PRIVATE_KEY, + }, + }); + + for (const issue of issues) { + if (!issue || issue.assignees.nodes?.length) { + continue; } - - const appOctokit = new customOctokit({ + + const installation = await appOctokit.rest.apps.getRepoInstallation({ + owner: issue.repository.owner.login, + repo: issue.repository.name, + }); + const repoOctokit = new customOctokit({ authStrategy: createAppAuth, auth: { - appId: context.env.APP_ID, + appId: Number(context.env.APP_ID), privateKey: context.env.APP_PRIVATE_KEY, + installationId: installation.data.id, }, }); - - for (const issue of issues) { - if (!issue || issue.assignees.nodes?.length) { - continue; - } - - const installation = await appOctokit.rest.apps.getRepoInstallation({ + + const linkedIssue = ( + await repoOctokit.rest.issues.get({ owner: issue.repository.owner.login, repo: issue.repository.name, - }); - const repoOctokit = new customOctokit({ - authStrategy: createAppAuth, - auth: { - appId: Number(context.env.APP_ID), - privateKey: context.env.APP_PRIVATE_KEY, - installationId: installation.data.id, - }, - }); - - const linkedIssue = ( - await repoOctokit.rest.issues.get({ - owner: issue.repository.owner.login, - repo: issue.repository.name, - issue_number: issue.number, - }) - ).data as Context<"issue_comment.created">["payload"]["issue"]; - const deadline = getDeadline(linkedIssue.labels); - if (!deadline) { - context.logger.debug("Skipping deadline posting message because no deadline has been set."); - return { status: HttpStatusCode.NOT_MODIFIED }; - } - - const repository = ( - await repoOctokit.rest.repos.get({ - owner: issue.repository.owner.login, - repo: issue.repository.name, + issue_number: issue.number, + }) + ).data as Context<"issue_comment.created">["payload"]["issue"]; + const deadline = getDeadline(linkedIssue.labels); + if (!deadline) { + context.logger.debug("Skipping deadline posting message because no deadline has been set."); + return { status: HttpStatusCode.NOT_MODIFIED }; + } + + const repository = ( + await repoOctokit.rest.repos.get({ + owner: issue.repository.owner.login, + repo: issue.repository.name, + }) + ).data as Context<"issue_comment.created">["payload"]["repository"]; + let organization: Context<"issue_comment.created">["payload"]["organization"] | undefined = undefined; + if (repository.owner.type === "Organization") { + organization = ( + await repoOctokit.rest.orgs.get({ + org: issue.repository.owner.login, }) - ).data as Context<"issue_comment.created">["payload"]["repository"]; - let organization: Context<"issue_comment.created">["payload"]["organization"] | undefined = undefined; - if (repository.owner.type === "Organization") { - organization = ( - await repoOctokit.rest.orgs.get({ - org: issue.repository.owner.login, - }) - ).data; - } - const newContext = { - ...context, - octokit: repoOctokit, - payload: { - ...context.payload, - issue: linkedIssue, - repository, - organization, - }, - }; - try { - return await startTask(newContext, linkedIssue, pull_request.user ?? payload.sender, []); - } catch (error) { - await closePullRequest(context, { number: pull_request.number }); - throw error; - } + ).data; } - return { status: HttpStatusCode.NOT_MODIFIED }; - } \ No newline at end of file + const newContext = { + ...context, + octokit: repoOctokit, + payload: { + ...context.payload, + issue: linkedIssue, + repository, + organization, + }, + }; + try { + return await startTask(newContext, linkedIssue, pull_request.user ?? payload.sender, []); + } catch (error) { + await closePullRequest(context, { number: pull_request.number }); + throw error; + } + } + return { status: HttpStatusCode.NOT_MODIFIED }; +} diff --git a/src/handlers/start-command.ts b/src/handlers/start-command.ts index dd24bcc4..f4415c24 100644 --- a/src/handlers/start-command.ts +++ b/src/handlers/start-command.ts @@ -40,4 +40,4 @@ export async function userStartStop(context: Context): Promise { } return { status: HttpStatusCode.NOT_MODIFIED }; -} \ No newline at end of file +} diff --git a/src/handlers/start-task.ts b/src/handlers/start-task.ts index c960cc76..b9ce140f 100644 --- a/src/handlers/start-task.ts +++ b/src/handlers/start-task.ts @@ -26,4 +26,4 @@ export async function startTask( // All checks passed, perform assignment return performAssignment(context, issue, sender, eligibility.computed.toAssign); -} \ No newline at end of file +} diff --git a/src/handlers/start/evaluate-eligibility.ts b/src/handlers/start/evaluate-eligibility.ts index bf201978..6d7887c7 100644 --- a/src/handlers/start/evaluate-eligibility.ts +++ b/src/handlers/start/evaluate-eligibility.ts @@ -9,174 +9,172 @@ import { checkTaskStale } from "./helpers/check-task-stale"; import { getDeadline } from "./helpers/get-deadline"; export type StartEligibilityResult = { - ok: boolean; - errors: LogReturn[]; - warnings: string[]; - computed: { - deadline: string | null; - isTaskStale: boolean; - wallet: string | null; - toAssign: string[]; - assignedIssues: AssignedIssue[]; - consideredCount: number; - senderRole: ReturnType; - }; + ok: boolean; + errors: LogReturn[]; + warnings: string[]; + computed: { + deadline: string | null; + isTaskStale: boolean; + wallet: string | null; + toAssign: string[]; + assignedIssues: AssignedIssue[]; + consideredCount: number; + senderRole: ReturnType; }; - - export async function evaluateStartEligibility( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context<"issue_comment.created">["payload"]["sender"], - teammates: string[] - ): Promise { - const errors: LogReturn[] = []; - const warnings: string[] = []; - const assignedIssues: AssignedIssue[] = []; - - if (!sender) { - errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); - } - - const labels = (issue.labels ?? []) as Label[]; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); - const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); - const userRole = userAssociation.role; - - // Collaborators need price label - if (!priceLabel && userRole === "contributor") { - errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_REQUIRED)); - } - - const checkReqErr = await checkRequirements(context, issue, userRole); - if (checkReqErr) { - errors.push(context.logger.error(checkReqErr.message)); - } - - if (issue.body && isParentIssue(issue.body)) { - errors.push(context.logger.error(ERROR_MESSAGES.PARENT_ISSUES)); - } - - if (issue.state === ISSUE_TYPE.CLOSED) { - errors.push(context.logger.error(ERROR_MESSAGES.CLOSED)); - } - - const assignees = issue?.assignees ?? []; - if (assignees.length) { - // Check if the sender is already assigned to this issue - const isSenderAssigned = assignees.some((assignee) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); - const errorMessage = isSenderAssigned - ? ERROR_MESSAGES.ALREADY_ASSIGNED - : ERROR_MESSAGES.ISSUE_ALREADY_ASSIGNED; - errors.push(context.logger.error(errorMessage)); +}; + +export async function evaluateStartEligibility( + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: Context<"issue_comment.created">["payload"]["sender"], + teammates: string[] +): Promise { + const errors: LogReturn[] = []; + const warnings: string[] = []; + const assignedIssues: AssignedIssue[] = []; + + if (!sender) { + errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); + } + + const labels = (issue.labels ?? []) as Label[]; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); + const userRole = userAssociation.role; + + // Collaborators need price label + if (!priceLabel && userRole === "contributor") { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_REQUIRED)); + } + + const checkReqErr = await checkRequirements(context, issue, userRole); + if (checkReqErr) { + errors.push(context.logger.error(checkReqErr.message)); + } + + if (issue.body && isParentIssue(issue.body)) { + errors.push(context.logger.error(ERROR_MESSAGES.PARENT_ISSUES)); + } + + if (issue.state === ISSUE_TYPE.CLOSED) { + errors.push(context.logger.error(ERROR_MESSAGES.CLOSED)); + } + + const assignees = issue?.assignees ?? []; + if (assignees.length) { + // Check if the sender is already assigned to this issue + const isSenderAssigned = assignees.some((assignee) => assignee?.login?.toLowerCase() === sender.login.toLowerCase()); + const errorMessage = isSenderAssigned ? ERROR_MESSAGES.ALREADY_ASSIGNED : ERROR_MESSAGES.ISSUE_ALREADY_ASSIGNED; + errors.push(context.logger.error(errorMessage)); + } + + const allUsers = [...new Set([sender.login, ...teammates])]; + const toAssign: string[] = []; + for (const user of allUsers) { + let role: ReturnType | undefined = undefined; + try { + const res = await handleTaskLimitChecks({ context, logger: context.logger, sender: sender.login, username: user }); + // within limit? + if (!res.isWithinLimit) { + const message = user === sender.login ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${user} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + errors.push( + context.logger.error(message, { + assignedIssues: res.issues.length, + openedPullRequests: 0, + limit: 0, + }) + ); + // capture issues for later comment rendering + res.issues.forEach((issue) => { + assignedIssues.push({ title: issue.title, html_url: issue.html_url }); + }); + } else { + toAssign.push(user); + } + // role for price ceiling check + role = res.role as ReturnType | undefined; + } catch (e) { + if (e instanceof Error) { + errors.push(context.logger.error(e.message)); + } else if (e instanceof LogReturn) { + errors.push(e); + } else { + errors.push(context.logger.error(`An error occurred while checking the task limit for ${user}`, { e })); + } } - - const allUsers = [...new Set([sender.login, ...teammates])]; - const toAssign: string[] = []; - for (const user of allUsers) { - let role: ReturnType | undefined = undefined; - try { - const res = await handleTaskLimitChecks({ context, logger: context.logger, sender: sender.login, username: user }); - // within limit? - if (!res.isWithinLimit) { - const message = user === sender.login ? ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX : `${user} ${ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX}`; + + if (priceLabel && role && role !== "admin") { + const { usdPriceMax } = context.config.taskAccessControl; + const min = Math.min(...Object.values(usdPriceMax)); + const allowed = role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; + const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; + const match = priceLabel.name.match(/Price:\s*([\d.]+)/); + if (!match || isNaN(parseFloat(match[1]))) { + errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_FORMAT_ERROR, { priceLabel: priceLabel.name })); + } else { + const price = parseFloat(match[1]); + if (userAllowedMaxPrice < 0) { + errors.push(context.logger.warn(ERROR_MESSAGES.PRESERVATION_MODE, { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number })); + } else if (price > userAllowedMaxPrice) { errors.push( - context.logger.error(message, { - assignedIssues: res.issues.length, - openedPullRequests: 0, - limit: 0, - }) + context.logger.warn( + ERROR_MESSAGES.PRICE_LIMIT_EXCEEDED.replace("{{user}}", user).replace("{{userAllowedMaxPrice}}", userAllowedMaxPrice.toString()), + { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number } + ) ); - // capture issues for later comment rendering - res.issues.forEach((issue) => { - assignedIssues.push({ title: issue.title, html_url: issue.html_url }); - }); - } else { - toAssign.push(user); - } - // role for price ceiling check - role = res.role as ReturnType | undefined; - } catch (e) { - if (e instanceof Error) { - errors.push(context.logger.error(e.message)); - } else if (e instanceof LogReturn) { - errors.push(e); - } else { - errors.push(context.logger.error(`An error occurred while checking the task limit for ${user}`, { e })); - } - } - - if (priceLabel && role && role !== "admin") { - const { usdPriceMax } = context.config.taskAccessControl; - const min = Math.min(...Object.values(usdPriceMax)); - const allowed = role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; - const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; - const match = priceLabel.name.match(/Price:\s*([\d.]+)/); - if (!match || isNaN(parseFloat(match[1]))) { - errors.push(context.logger.error(ERROR_MESSAGES.PRICE_LABEL_FORMAT_ERROR, { priceLabel: priceLabel.name })); - } else { - const price = parseFloat(match[1]); - if (userAllowedMaxPrice < 0) { - errors.push(context.logger.warn(ERROR_MESSAGES.PRESERVATION_MODE, { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number })); - } else if (price > userAllowedMaxPrice) { - errors.push( - context.logger.warn( - ERROR_MESSAGES.PRICE_LIMIT_EXCEEDED.replace("{{user}}", user).replace("{{userAllowedMaxPrice}}", userAllowedMaxPrice.toString()), - { userRole: role, price, userAllowedMaxPrice, issueNumber: issue.number } - ) - ); - } } } } - - // Only add summary error if we haven't already added individual user errors - // (individual errors are added in the loop above when users exceed their limit) - if (toAssign.length === 0 && allUsers.length > 0) { - const message = allUsers.length > 1 ? ERROR_MESSAGES.ALL_TEAMMATES_REACHED : ERROR_MESSAGES.MAX_TASK_LIMIT; - // Only add if we don't already have quota-related errors (to avoid duplicates) - const hasQuotaError = errors.some((e) => { - const lowerMsg = e.logMessage.raw.toLowerCase(); - return ( - lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase()) || lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase()) - ); - }); - if (!hasQuotaError) { - errors.push(context.logger.error(message)); - } - } - - // Wallet - let wallet: string | null = null; - try { - wallet = await context.adapters.supabase.user.getWalletByUserId(sender.id, issue.number); - } catch { - errors.push(context.logger.error(context.config.emptyWalletText)); - } - - // Staleness & deadline - const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); - if (isTaskStale) { - warnings.push(ERROR_MESSAGES.TASK_STALE); - } - let deadline: string | null = null; - try { - deadline = getDeadline(labels); - } catch { - // don't throw (post a comment) "No labels are set." error + } + + // Only add summary error if we haven't already added individual user errors + // (individual errors are added in the loop above when users exceed their limit) + if (toAssign.length === 0 && allUsers.length > 0) { + const message = allUsers.length > 1 ? ERROR_MESSAGES.ALL_TEAMMATES_REACHED : ERROR_MESSAGES.MAX_TASK_LIMIT; + // Only add if we don't already have quota-related errors (to avoid duplicates) + const hasQuotaError = errors.some((e) => { + const lowerMsg = e.logMessage.raw.toLowerCase(); + return ( + lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_PREFIX.toLowerCase()) || lowerMsg.includes(ERROR_MESSAGES.MAX_TASK_LIMIT_TEAMMATE_PREFIX.toLowerCase()) + ); + }); + if (!hasQuotaError) { + errors.push(context.logger.error(message)); } - - return { - ok: errors.length === 0, - errors, - warnings, - computed: { - deadline, - isTaskStale, - wallet, - toAssign, - assignedIssues, - consideredCount: allUsers.length, - senderRole: userRole, - }, - }; - } \ No newline at end of file + } + + // Wallet + let wallet: string | null = null; + try { + wallet = await context.adapters.supabase.user.getWalletByUserId(sender.id, issue.number); + } catch { + errors.push(context.logger.error(context.config.emptyWalletText)); + } + + // Staleness & deadline + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); + if (isTaskStale) { + warnings.push(ERROR_MESSAGES.TASK_STALE); + } + let deadline: string | null = null; + try { + deadline = getDeadline(labels); + } catch { + // don't throw (post a comment) "No labels are set." error + } + + return { + ok: errors.length === 0, + errors, + warnings, + computed: { + deadline, + isTaskStale, + wallet, + toAssign, + assignedIssues, + consideredCount: allUsers.length, + senderRole: userRole, + }, + }; +} diff --git a/src/handlers/start/helpers/check-assignments.ts b/src/handlers/start/helpers/check-assignments.ts index 0203a934..ab896fff 100644 --- a/src/handlers/start/helpers/check-assignments.ts +++ b/src/handlers/start/helpers/check-assignments.ts @@ -15,7 +15,17 @@ async function hasUserBeenUnassigned(context: Context, username: string): Promis return false; } -export async function handleTaskLimitChecks({ context, logger, sender, username }: { username: string; context: Context; logger: Context["logger"]; sender: string }) { +export async function handleTaskLimitChecks({ + context, + logger, + sender, + username, +}: { + username: string; + context: Context; + logger: Context["logger"]; + sender: string; +}) { // Check for unassignment first - this should take precedence over task limit if (await hasUserBeenUnassigned(context, username)) { throw logger.warn(ERROR_MESSAGES.UNASSIGNED.replace("{{username}}", username), { username }); diff --git a/src/handlers/start/helpers/check-requirements.ts b/src/handlers/start/helpers/check-requirements.ts index 1bc8e4e1..84c192a9 100644 --- a/src/handlers/start/helpers/check-requirements.ts +++ b/src/handlers/start/helpers/check-requirements.ts @@ -2,56 +2,55 @@ import { Context } from "../../../types/index"; import { getTransformedRole } from "../../../utils/get-user-task-limit-and-role"; import { ERROR_MESSAGES } from "./error-messages"; - export async function checkRequirements( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - userRole: ReturnType - ): Promise { - const { - config: { requiredLabelsToStart }, - logger, - } = context; - const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); - - if (requiredLabelsToStart.length) { - const currentLabelConfiguration = requiredLabelsToStart.find((label) => - issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase()) + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + userRole: ReturnType +): Promise { + const { + config: { requiredLabelsToStart }, + logger, + } = context; + const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); + + if (requiredLabelsToStart.length) { + const currentLabelConfiguration = requiredLabelsToStart.find((label) => + issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase()) + ); + + // Admins can start any task + if (userRole === "admin") { + return null; + } + + if (!currentLabelConfiguration) { + // If we didn't find the label in the allowed list, then the user cannot start this task. + const errorText = ERROR_MESSAGES.NOT_BUSINESS_PRIORITY.replace( + "{{requiredLabelsToStart}}", + requiredLabelsToStart.map((label) => `\`${label.name}\``).join(", ") ); - - // Admins can start any task - if (userRole === "admin") { - return null; - } - - if (!currentLabelConfiguration) { - // If we didn't find the label in the allowed list, then the user cannot start this task. - const errorText = ERROR_MESSAGES.NOT_BUSINESS_PRIORITY.replace( - "{{requiredLabelsToStart}}", - requiredLabelsToStart.map((label) => `\`${label.name}\``).join(", ") - ); - - logger.error(errorText, { - requiredLabelsToStart, - issueLabels, - issue: issue.html_url, - }); - return new Error(errorText); - } else if (!currentLabelConfiguration.allowedRoles.includes(userRole)) { - // If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task. - const humanReadableRoles = [ - ...currentLabelConfiguration.allowedRoles.map((o) => (o === "collaborator" ? "a core team member" : `a ${o}`)), - "an administrator", - ].join(", or "); - const errorText = `You must be ${humanReadableRoles} to start this task`; - logger.error(errorText, { - currentLabelConfiguration, - issueLabels, - issue: issue.html_url, - userRole, - }); - return new Error(errorText); - } + + logger.error(errorText, { + requiredLabelsToStart, + issueLabels, + issue: issue.html_url, + }); + return new Error(errorText); + } else if (!currentLabelConfiguration.allowedRoles.includes(userRole)) { + // If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task. + const humanReadableRoles = [ + ...currentLabelConfiguration.allowedRoles.map((o) => (o === "collaborator" ? "a core team member" : `a ${o}`)), + "an administrator", + ].join(", or "); + const errorText = `You must be ${humanReadableRoles} to start this task`; + logger.error(errorText, { + currentLabelConfiguration, + issueLabels, + issue: issue.html_url, + userRole, + }); + return new Error(errorText); } - return null; - } \ No newline at end of file + } + return null; +} diff --git a/src/handlers/start/helpers/error-messages.ts b/src/handlers/start/helpers/error-messages.ts index 51814ae5..20850910 100644 --- a/src/handlers/start/helpers/error-messages.ts +++ b/src/handlers/start/helpers/error-messages.ts @@ -3,28 +3,27 @@ import { HttpStatusCode, Result } from "../../../types/result-types"; import { StartEligibilityResult } from "../evaluate-eligibility"; export const ERROR_MESSAGES = { - UNASSIGNED: "{{username}} you were previously unassigned from this task. You cannot be reassigned.", - MAX_TASK_LIMIT: "You have reached your max task limit. Please close out some tasks before assigning new ones.", - MAX_TASK_LIMIT_PREFIX: "You have reached your max task limit", - MAX_TASK_LIMIT_TEAMMATE_PREFIX: "has reached their max task limit", - ALL_TEAMMATES_REACHED: "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.", - PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", - CLOSED: "This issue is closed, please choose another.", - ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", - ISSUE_ALREADY_ASSIGNED: "This issue is already assigned. Please choose another unassigned task.", - PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", - PRICE_LIMIT_EXCEEDED: - "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", - PRICE_LABEL_FORMAT_ERROR: "Price label is not in the correct format", - TASK_STALE: "Task appears stale; confirm specification before starting.", - TASK_ASSIGNED: "Task assigned successfully", - MISSING_SENDER: "Missing sender", - NOT_BUSINESS_PRIORITY: - "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", - PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", - } as const; + UNASSIGNED: "{{username}} you were previously unassigned from this task. You cannot be reassigned.", + MAX_TASK_LIMIT: "You have reached your max task limit. Please close out some tasks before assigning new ones.", + MAX_TASK_LIMIT_PREFIX: "You have reached your max task limit", + MAX_TASK_LIMIT_TEAMMATE_PREFIX: "has reached their max task limit", + ALL_TEAMMATES_REACHED: "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.", + PARENT_ISSUES: "Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.", + CLOSED: "This issue is closed, please choose another.", + ALREADY_ASSIGNED: "You are already assigned to this task. Please choose another unassigned task.", + ISSUE_ALREADY_ASSIGNED: "This issue is already assigned. Please choose another unassigned task.", + PRICE_LABEL_REQUIRED: "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing.", + PRICE_LIMIT_EXCEEDED: + "While we appreciate your enthusiasm @{{user}}, the price of this task exceeds your allowed limit. Please choose a task with a price of ${{userAllowedMaxPrice}} or less.", + PRICE_LABEL_FORMAT_ERROR: "Price label is not in the correct format", + TASK_STALE: "Task appears stale; confirm specification before starting.", + TASK_ASSIGNED: "Task assigned successfully", + MISSING_SENDER: "Missing sender", + NOT_BUSINESS_PRIORITY: + "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", + PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", +} as const; - export async function handleStartErrors(context: Context, eligibility: StartEligibilityResult): Promise { const { logger } = context; const errorMessages = eligibility.errors.map((e) => e.logMessage.raw.toLowerCase()); diff --git a/src/handlers/start/helpers/generate-assignment-comment.ts b/src/handlers/start/helpers/generate-assignment-comment.ts index df03d8d4..f96830c8 100644 --- a/src/handlers/start/helpers/generate-assignment-comment.ts +++ b/src/handlers/start/helpers/generate-assignment-comment.ts @@ -30,4 +30,4 @@ export async function generateAssignmentComment(context: Context, issueCreatedAt > - Be sure to open a draft pull request as soon as possible to communicate updates on your progress. > - Be sure to provide timely updates to us when requested, or you will be automatically unassigned from the task.`, }; -} \ No newline at end of file +} diff --git a/src/handlers/start/helpers/get-deadline.ts b/src/handlers/start/helpers/get-deadline.ts index 64c6e89e..7ad2fffb 100644 --- a/src/handlers/start/helpers/get-deadline.ts +++ b/src/handlers/start/helpers/get-deadline.ts @@ -3,12 +3,12 @@ import { options } from "./generate-assignment-comment"; import { calculateDurations } from "../../../utils/shared"; export function getDeadline(labels: Context<"issue_comment.created">["payload"]["issue"]["labels"] | undefined | null): string | null { - if (!labels?.length) { - throw new Error("No labels are set."); - } - const startTime = new Date().getTime(); - const duration: number = calculateDurations(labels).shift() ?? 0; - if (!duration) return null; - const endTime = new Date(startTime + duration * 1000); - return endTime.toLocaleString("en-US", options); - } \ No newline at end of file + if (!labels?.length) { + throw new Error("No labels are set."); + } + const startTime = new Date().getTime(); + const duration: number = calculateDurations(labels).shift() ?? 0; + if (!duration) return null; + const endTime = new Date(startTime + duration * 1000); + return endTime.toLocaleString("en-US", options); +} diff --git a/src/handlers/start/helpers/get-user-ids.ts b/src/handlers/start/helpers/get-user-ids.ts index 022a3e9b..a3b3666a 100644 --- a/src/handlers/start/helpers/get-user-ids.ts +++ b/src/handlers/start/helpers/get-user-ids.ts @@ -1,19 +1,19 @@ import { Context } from "../../../types/index"; export async function getUserIds(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; - } \ No newline at end of file + 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; +} diff --git a/src/handlers/start/perform-assignment.ts b/src/handlers/start/perform-assignment.ts index eea6ee81..2ff9b3e6 100644 --- a/src/handlers/start/perform-assignment.ts +++ b/src/handlers/start/perform-assignment.ts @@ -9,55 +9,55 @@ import structuredMetadata from "./helpers/generate-structured-metadata"; import { assignTableComment } from "./helpers/generate-assignment-table"; export async function performAssignment( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: { login: string; id: number }, - toAssign: string[] - ): Promise { - const { logger } = context; - // compute metadata - let commitHash: string | null = null; - try { - const hashResponse = await context.octokit.rest.repos.getCommit({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - ref: context.payload.repository.default_branch, - }); - commitHash = hashResponse.data.sha; - } catch (e) { - logger.error("Error while getting commit hash", { error: e as Error }); - } - const labels = issue.labels ?? []; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); - const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); - const toAssignIds = await getUserIds(context, toAssign); - const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); - const logMessage = logger.info(ERROR_MESSAGES.TASK_ASSIGNED, { - taskDeadline: assignmentComment.deadline, - taskAssignees: toAssignIds, - priceLabel, - revision: commitHash?.substring(0, 7), + context: Context, + issue: Context<"issue_comment.created">["payload"]["issue"], + sender: { login: string; id: number }, + toAssign: string[] +): Promise { + const { logger } = context; + // compute metadata + let commitHash: string | null = null; + try { + const hashResponse = await context.octokit.rest.repos.getCommit({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + ref: context.payload.repository.default_branch, }); - const metadata = structuredMetadata.create("Assignment", logMessage); - - await addAssignees(context, issue.number, toAssign); - - await context.commentHandler.postComment( - context, - logger.ok( - [ - assignTableComment({ - isTaskStale, - daysElapsedSinceTaskCreation: assignmentComment.daysElapsedSinceTaskCreation, - taskDeadline: assignmentComment.deadline, - registeredWallet: assignmentComment.registeredWallet, - }), - assignmentComment.tips, - metadata, - ].join("\n") as string - ), - { raw: true } - ); - - return { content: ERROR_MESSAGES.TASK_ASSIGNED, status: HttpStatusCode.OK }; - } \ No newline at end of file + commitHash = hashResponse.data.sha; + } catch (e) { + logger.error("Error while getting commit hash", { error: e as Error }); + } + const labels = issue.labels ?? []; + const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); + const toAssignIds = await getUserIds(context, toAssign); + const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); + const logMessage = logger.info(ERROR_MESSAGES.TASK_ASSIGNED, { + taskDeadline: assignmentComment.deadline, + taskAssignees: toAssignIds, + priceLabel, + revision: commitHash?.substring(0, 7), + }); + const metadata = structuredMetadata.create("Assignment", logMessage); + + await addAssignees(context, issue.number, toAssign); + + await context.commentHandler.postComment( + context, + logger.ok( + [ + assignTableComment({ + isTaskStale, + daysElapsedSinceTaskCreation: assignmentComment.daysElapsedSinceTaskCreation, + taskDeadline: assignmentComment.deadline, + registeredWallet: assignmentComment.registeredWallet, + }), + assignmentComment.tips, + metadata, + ].join("\n") as string + ), + { raw: true } + ); + + return { content: ERROR_MESSAGES.TASK_ASSIGNED, status: HttpStatusCode.OK }; +} From c36eda11b0e5c453515eb7773b98b0cf089d4061 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:26:06 +0000 Subject: [PATCH 05/56] chore: rename command-handler --- src/handlers/{start-command.ts => command-handler.ts} | 0 src/plugin.ts | 2 +- tests/main.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/handlers/{start-command.ts => command-handler.ts} (100%) diff --git a/src/handlers/start-command.ts b/src/handlers/command-handler.ts similarity index 100% rename from src/handlers/start-command.ts rename to src/handlers/command-handler.ts diff --git a/src/plugin.ts b/src/plugin.ts index d9024bc9..047d8181 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,7 +1,7 @@ import { createClient } from "@supabase/supabase-js"; import { createAdapters } from "./adapters/index"; import { HttpStatusCode } from "./types/result-types"; -import { commandHandler, userStartStop } from "./handlers/start-command"; +import { commandHandler, userStartStop } from "./handlers/command-handler"; import { Context } from "./types/index"; import { listOrganizations } from "./utils/list-organizations"; import { closeUserUnassignedPr } from "./handlers/close-pull-on-unassign"; diff --git a/tests/main.test.ts b/tests/main.test.ts index 67dbd201..eddc0a42 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -6,7 +6,7 @@ import { cleanLogString, LogReturn } from "@ubiquity-os/ubiquity-os-logger"; import dotenv from "dotenv"; import { createAdapters } from "../src/adapters"; import { HttpStatusCode } from "../src/types/result-types"; -import { userStartStop } from "../src/handlers/start-command"; +import { userStartStop } from "../src/handlers/command-handler"; import { Context, Env, envSchema, Sender } from "../src/types"; import { db } from "./__mocks__/db"; import issueTemplate from "./__mocks__/issue-template"; From 823f03ea70a4d6dde0ba1fa36b2b2b920a7f8a97 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 02:06:21 +0000 Subject: [PATCH 06/56] feat: implement public API for task management and recommendations - Introduced a new public API endpoint to handle task management, supporting recommendation retrieval, eligibility validation, and task execution. - Added helper functions for JWT verification, context building, and issue URL parsing to streamline request handling. - Implemented rate limiting to manage API usage effectively. - Enhanced error handling and response structures for better clarity and user experience. - Integrated recommendations based on user embeddings to suggest similar issues when no issue URL is provided. - Updated environment types to include necessary configurations for GitHub integration. --- src/handlers/start/api/helpers/auth.ts | 47 +++++ .../start/api/helpers/context-builder.ts | 120 +++++++++++ src/handlers/start/api/helpers/octokit.ts | 27 +++ src/handlers/start/api/helpers/parsers.ts | 13 ++ src/handlers/start/api/helpers/rate-limit.ts | 27 +++ .../start/api/helpers/recommendations.ts | 90 +++++++++ src/handlers/start/api/helpers/types.ts | 21 ++ src/handlers/start/api/public-api.ts | 186 ++++++++++++++++++ src/utils/constants.ts | 4 + src/utils/get-user-task-limit-and-role.ts | 42 ++-- src/utils/is-dev-env.ts | 4 + src/worker.ts | 2 +- tests/utils.ts | 7 +- 13 files changed, 568 insertions(+), 22 deletions(-) create mode 100644 src/handlers/start/api/helpers/auth.ts create mode 100644 src/handlers/start/api/helpers/context-builder.ts create mode 100644 src/handlers/start/api/helpers/octokit.ts create mode 100644 src/handlers/start/api/helpers/parsers.ts create mode 100644 src/handlers/start/api/helpers/rate-limit.ts create mode 100644 src/handlers/start/api/helpers/recommendations.ts create mode 100644 src/handlers/start/api/helpers/types.ts create mode 100644 src/handlers/start/api/public-api.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/is-dev-env.ts diff --git a/src/handlers/start/api/helpers/auth.ts b/src/handlers/start/api/helpers/auth.ts new file mode 100644 index 00000000..5106f37f --- /dev/null +++ b/src/handlers/start/api/helpers/auth.ts @@ -0,0 +1,47 @@ +import { createClient } from "@supabase/supabase-js"; +import { Env } from "../../../../types/env"; +import { isDevelopment } from "../../../../utils/is-dev-env"; + +/** + * Verifies Supabase JWT token. + * In development mode, bypasses verification and returns a mock user. + */ +export async function verifySupabaseJwt(env: Env, jwt: string) { + if (isDevelopment()) { + // Bypass JWT verification in development + return { + id: "dev-user-id", + email: "dev@example.com", + user_metadata: {}, + app_metadata: {}, + }; + } + + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + const { data, error } = await supabase.auth.getUser(jwt); + if (error || !data?.user) { + throw new Error("Unauthorized"); + } + return data.user; +} + +/** + * Resolves GitHub login from Supabase issues cache. + * In development mode, allows fallback to a static login from env or returns null. + */ +export async function resolveLoginFromSupabaseIssues(env: Env, userId: number): Promise { + if (isDevelopment()) { + // In development, allow using a static login from env or return null to use fallback + const devLogin = process.env.DEV_GITHUB_LOGIN; + if (devLogin) { + return devLogin; + } + // If no DEV_GITHUB_LOGIN is set, return null to allow fallback handling + return null; + } + + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + const { data } = await supabase.from("issues").select("payload").eq("author_id", userId).order("modified_at", { ascending: false }).limit(1); + const payload = data && data[0]?.payload; + return payload?.user?.login ?? null; +} diff --git a/src/handlers/start/api/helpers/context-builder.ts b/src/handlers/start/api/helpers/context-builder.ts new file mode 100644 index 00000000..8e668f3d --- /dev/null +++ b/src/handlers/start/api/helpers/context-builder.ts @@ -0,0 +1,120 @@ +import { createClient } from "@supabase/supabase-js"; +import { Context } from "../../../../types/context"; +import { AssignedIssueScope, PluginSettings, Role } from "../../../../types/plugin-input"; +import { Env } from "../../../../types/env"; +import { createAdapters } from "../../../../adapters/index"; +import { createRepoOctokit } from "./octokit"; +import { MAX_CONCURRENT_DEFAULTS } from "../../../../utils/constants"; +import { LogLevel, Logs } from "@ubiquity-os/ubiquity-os-logger"; + +export async function buildContext( + env: Env, + owner: string, + repo: string, + issueNumber: number, + senderLogin: string, + userId: number +): Promise> { + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + const repoOctokit = await createRepoOctokit(env, owner, repo); + + const issue = (await repoOctokit.rest.issues.get({ owner, repo, issue_number: issueNumber })).data as Context<"issue_comment.created">["payload"]["issue"]; + const repository = (await repoOctokit.rest.repos.get({ owner, repo })).data as Context<"issue_comment.created">["payload"]["repository"]; + + let organization: Context["payload"]["organization"] | undefined; + if (repository.owner.type === "Organization") { + organization = (await repoOctokit.rest.orgs.get({ org: owner })).data as Context<"issue_comment.created">["payload"]["organization"]; + } + + // async function loadConfig(owner: string, repo: string): Promise { + // // try { + // // let configFile = await repoOctokit.rest.repos.getContent({ + // // owner, + // // repo, + // // path: isDevelopment() ? DEV_CONFIG_FULL_PATH : CONFIG_FULL_PATH + // // }); + // // if (configFile && configFile.data){ + // // let content; + // // if ("content" in configFile.data) { + // // content = configFile.data.content; + // // } else if (typeof configFile.data === "string") { + // // content = configFile.data; + // // } else { + // // throw new Error("Invalid config file"); + // // } + // // const parsedConfig = YAML.parse(content); + // // return parsedConfig as PluginSettings; + // // } + // // } catch (error) { + // // console.log("Error loading config file, using defaults:", error); + // // } + // // return null as unknown as PluginSettings; + // } + + // Try to load the .ubiquity-os-config.yml file from the repository + let config = null as PluginSettings | null; + + // config = await loadConfig(owner, repo); + + // if(!config) { + // config = await loadConfig(CONFIG_ORG_REPO, owner); + // } + + if (!config) { + config = { + reviewDelayTolerance: "3 Days", + taskStaleTimeoutDuration: "30 Days", + maxConcurrentTasks: MAX_CONCURRENT_DEFAULTS, + startRequiresWallet: false, + assignedIssueScope: AssignedIssueScope.ORG, + emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", + rolesWithReviewAuthority: [Role.ADMIN, Role.OWNER, Role.MEMBER], + requiredLabelsToStart: [ + { name: "Priority: 1 (Normal)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 2 (Medium)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 3 (High)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 4 (Urgent)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 5 (Emergency)", allowedRoles: ["collaborator", "contributor"] }, + ], + taskAccessControl: { + usdPriceMax: { + collaborator: -1, + contributor: -1, + }, + }, + } as PluginSettings; + } + + const context: Context = { + logger: new Logs((env.LOG_LEVEL as LogLevel) ?? "info"), + env, + config, + command: { + name: "start", + parameters: { + teammates: [], + }, + }, + eventName: "issue_comment.created", + payload: { + action: "created", + issue, + repository, + organization, + sender: { login: senderLogin, id: userId }, + } as unknown as Context["payload"], + octokit: repoOctokit as unknown as Context["octokit"], + adapters: {} as unknown as Context["adapters"], + organizations: [owner], + commentHandler: { + postComment: async () => { + // const body = typeof message === "string" ? message : message?.logMessage?.raw || String(message); + // await repoOctokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); + }, + } as unknown as Context["commentHandler"], + }; + + context.adapters = createAdapters(supabase, context); + + return context as Context<"issue_comment.created">; +} diff --git a/src/handlers/start/api/helpers/octokit.ts b/src/handlers/start/api/helpers/octokit.ts new file mode 100644 index 00000000..25bcbb62 --- /dev/null +++ b/src/handlers/start/api/helpers/octokit.ts @@ -0,0 +1,27 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit"; +import { Env } from "../../../../types/env"; + +export async function createAppOctokit(env: Env) { + return new customOctokit({ + authStrategy: createAppAuth, + auth: { + appId: env.APP_ID, + privateKey: env.APP_PRIVATE_KEY, + }, + }); +} + +export async function createRepoOctokit(env: Env, owner: string, repo: string) { + const appOctokit = await createAppOctokit(env); + const installation = await appOctokit.rest.apps.getRepoInstallation({ owner, repo }); + + return new customOctokit({ + authStrategy: createAppAuth, + auth: { + appId: Number(env.APP_ID), + privateKey: env.APP_PRIVATE_KEY, + installationId: installation.data.id, + }, + }); +} diff --git a/src/handlers/start/api/helpers/parsers.ts b/src/handlers/start/api/helpers/parsers.ts new file mode 100644 index 00000000..74a8de98 --- /dev/null +++ b/src/handlers/start/api/helpers/parsers.ts @@ -0,0 +1,13 @@ +import { IssueUrlParts } from "./types"; + +export function parseIssueUrl(url: string): IssueUrlParts { + const match = url.match(/github\.com\/(.+?)\/(.+?)\/issues\/(\d+)/i); + if (!match) { + throw new Error("Invalid issueUrl"); + } + return { + owner: match[1], + repo: match[2], + issue_number: Number(match[3]), + }; +} diff --git a/src/handlers/start/api/helpers/rate-limit.ts b/src/handlers/start/api/helpers/rate-limit.ts new file mode 100644 index 00000000..2dbf396e --- /dev/null +++ b/src/handlers/start/api/helpers/rate-limit.ts @@ -0,0 +1,27 @@ +import { RateLimitResult } from "./types"; + +const rateState: Map = new Map(); + +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const state = rateState.get(key); + + if (!state || now > state.resetAt) { + rateState.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, remaining: limit - 1, resetAt: now + windowMs }; + } + + if (state.count >= limit) { + return { allowed: false, remaining: 0, resetAt: state.resetAt }; + } + + state.count += 1; + return { allowed: true, remaining: limit - state.count, resetAt: state.resetAt }; +} + +export function getClientId(request: Request): string { + const headers = request.headers; + return ( + (headers.get("cf-connecting-ip") || headers.get("x-forwarded-for") || headers.get("x-real-ip") || "unknown") + "|" + (headers.get("user-agent") || "ua") + ); +} diff --git a/src/handlers/start/api/helpers/recommendations.ts b/src/handlers/start/api/helpers/recommendations.ts new file mode 100644 index 00000000..7ff39b4b --- /dev/null +++ b/src/handlers/start/api/helpers/recommendations.ts @@ -0,0 +1,90 @@ +import { createClient } from "@supabase/supabase-js"; +import { Env } from "../../../../types/env"; +import { Context } from "../../../../types/context"; +import { createRepoOctokit } from "./octokit"; + +export type Recommendation = { + issueUrl: string; + similarity: number; + repo: string; + org: string; + title: string; +}; + +export async function getRecommendations(env: Env, userId: number, options: { topK?: number; threshold?: number } = {}): Promise { + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + const threshold = options.threshold ?? 0.6; // 60% similarity threshold + const topK = options.topK ?? 5; + + // Get user's completed/authored issues with embeddings + // filter out issues that have assignees + const { data: authored } = await supabase.from("issues").select("embedding,payload").eq("author_id", userId).limit(100); + + const vectors = (authored || []) + .map((r) => { + try { + return JSON.parse(r.embedding); + } catch { + return null; + } + }) + .filter((v: number[] | null): v is number[] => Array.isArray(v) && v.length > 0); + + if (!vectors.length) { + console.error("No embeddings found for user", { userId }); + return []; + } + + // query embedding is the average of the vectors + const queryEmbedding = vectors.reduce((acc, v) => acc.map((x, i) => x + v[i]), new Array(vectors[0].length).fill(0)); + queryEmbedding.forEach((v, i) => { + queryEmbedding[i] = v / vectors.length; + }); + + // Find similar issues + const { data: similar, error } = await supabase.rpc("find_similar_issues_annotate", { + current_id: `user-${userId}`, + query_embedding: queryEmbedding, + threshold, + top_k: topK, + }); + + if (error || !Array.isArray(similar)) { + console.error("Embeddings search failed", { error, similar }); + throw new Error("Embeddings search failed"); + } + + // Build candidate list with filters (open/unassigned issues only) + const results: Recommendation[] = []; + + for (const row of similar as Array<{ issue_id: string; similarity: number }>) { + const { data: rec } = await supabase.from("issues").select("payload").eq("id", row.issue_id).maybeSingle(); + const payload = JSON.parse(rec?.payload); + const org = payload?.repository?.owner?.login; + const repo = payload?.repository?.name; + const number = payload?.number ?? payload?.issue?.number; + + if (!org || !repo || !number || payload?.assignees?.length) { + continue; + } + + try { + const octokit = await createRepoOctokit(env, org, repo); + console.log(`Fetching issue ${org}/${repo}#${number}`); + const issue = (await octokit.rest.issues.get({ owner: org, repo, issue_number: number })).data as Context<"issue_comment.created">["payload"]["issue"]; + + const isOpen = issue.state === "open"; + const isUnassigned = !(issue.assignees && issue.assignees.length); + + if (isOpen && isUnassigned) { + const href = `https://www.github.com/${org}/${repo}/issues/${number}`; + results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); + } + } catch (err) { + // Skip issues we can't access + console.warn(`Failed to fetch issue ${org}/${repo}#${number}:`, err); + } + } + + return results; +} diff --git a/src/handlers/start/api/helpers/types.ts b/src/handlers/start/api/helpers/types.ts new file mode 100644 index 00000000..2a896623 --- /dev/null +++ b/src/handlers/start/api/helpers/types.ts @@ -0,0 +1,21 @@ +export type StartBody = { + userId: number; + issueUrl?: string; + teammates?: string[]; + mode?: "validate" | "execute"; + recommend?: { topK?: number; threshold?: number }; + // Development only: allows passing login directly when Supabase lookup is unavailable + login?: string; +}; + +export type IssueUrlParts = { + owner: string; + repo: string; + issue_number: number; +}; + +export type RateLimitResult = { + allowed: boolean; + remaining: number; + resetAt: number; +}; diff --git a/src/handlers/start/api/public-api.ts b/src/handlers/start/api/public-api.ts new file mode 100644 index 00000000..d302bd8d --- /dev/null +++ b/src/handlers/start/api/public-api.ts @@ -0,0 +1,186 @@ +import { Env } from "../../../types/env"; +import { HttpStatusCode } from "../../../types/result-types"; +import { evaluateStartEligibility } from "../evaluate-eligibility"; +import { performAssignment } from "../perform-assignment"; +import { verifySupabaseJwt, resolveLoginFromSupabaseIssues } from "./helpers/auth"; +import { rateLimit, getClientId } from "./helpers/rate-limit"; +import { parseIssueUrl } from "./helpers/parsers"; +import { buildContext } from "./helpers/context-builder"; +import { getRecommendations } from "./helpers/recommendations"; +import { StartBody } from "./helpers/types"; + +/** + * Handles the recommendation flow when no issueUrl is provided. + * Uses embeddings to find similar issues based on user's prior work. + */ +async function handleRecommendations(env: Env, userId: number, recommend?: { topK?: number; threshold?: number }): Promise { + try { + const recommendations = await getRecommendations(env, userId, recommend); + + if (recommendations.length === 0) { + return Response.json({ ok: true, recommendations: [], note: "No prior embeddings found for user" }, { status: 200 }); + } + + return Response.json({ ok: true, recommendations }, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Embeddings search failed"; + return Response.json({ ok: false, reasons: [message] }, { status: 500 }); + } +} + +/** + * Handles the validate or execute flow for a specific issue. + * Validates eligibility and optionally performs assignment. + */ +async function handleValidateOrExecute( + env: Env, + issueUrl: string, + userId: number, + teammates: string[], + mode: "validate" | "execute", + loginFromBody?: string +): Promise { + const { owner, repo, issue_number } = parseIssueUrl(issueUrl); + + // Resolve sender login from Supabase issues cache + let senderLogin = await resolveLoginFromSupabaseIssues(env, userId); + + // In development, allow fallback to login from request body + if (!senderLogin && loginFromBody) { + senderLogin = loginFromBody; + } + + if (!senderLogin) { + return Response.json({ ok: false, reasons: ["Unable to resolve GitHub login for userId. Provide 'login' in request body."] }, { status: 400 }); + } + + // Build context + const context = await buildContext(env, owner, repo, issue_number, senderLogin, userId); + const issue = context.payload.issue; + const sender = context.payload.sender; + + // Evaluate eligibility + const preflight = await evaluateStartEligibility(context, issue, sender, teammates); + + if (mode === "validate") { + const status = preflight.ok ? 200 : 400; + return Response.json( + { + ok: preflight.ok, + reasons: preflight.errors.map((e) => e.logMessage.raw), + warnings: preflight.warnings, + computed: preflight.computed, + assignedIssues: preflight.computed.assignedIssues, + }, + { status } + ); + } + + // Execute mode - check eligibility first + if (!preflight.ok) { + return Response.json( + { + ok: false, + reasons: preflight.errors.map((e) => e.logMessage.raw), + warnings: preflight.warnings, + assignedIssues: preflight.computed.assignedIssues, + computed: preflight.computed, + }, + { status: 400 } + ); + } + + // Perform assignment + try { + const result = await performAssignment(context, issue, sender, preflight.computed.toAssign); + return Response.json( + { + ok: result.status === HttpStatusCode.OK, + content: result.content, + metadata: preflight.computed, + }, + { status: 200 } + ); + } catch (error) { + const reason = error instanceof Error ? error.message : "Start failed"; + return Response.json({ ok: false, reasons: [reason] }, { status: 400 }); + } +} + +/** + * Extracts JWT token from Authorization header. + * Returns null if header is missing or malformed. + */ +function extractJwtFromHeader(request: Request): string | null { + const auth = request.headers.get("authorization") || request.headers.get("Authorization"); + if (!auth || !auth.toLowerCase().startsWith("bearer ")) { + return null; + } + return auth.split(" ")[1]; +} + +/** + * Main handler for the public start API endpoint. + * Supports three modes: + * 1. Recommendations: when issueUrl is omitted + * 2. Validate: validates eligibility without performing assignment + * 3. Execute: validates and performs assignment + * + * @param request - HTTP request object + * @param env - Environment variables + * @returns HTTP response with appropriate status and body + */ +export async function handlePublicStart(request: Request, env: Env): Promise { + try { + // Method check + if (request.method !== "POST") { + return new Response(null, { status: 405 }); + } + + // Authentication + const jwt = extractJwtFromHeader(request); + const isDev = true; // Hardcoded for development testing + + if (!jwt && !isDev) { + return Response.json({ ok: false, reasons: ["Missing Authorization header"] }, { status: 401 }); + } + + // Only verify JWT if provided (in dev, jwt can be optional) + if (jwt) { + await verifySupabaseJwt(env, jwt); + } + + // Parse and validate body + let body: StartBody; + try { + body = (await request.json()) as StartBody; + } catch { + return Response.json({ ok: false, reasons: ["Invalid JSON body"] }, { status: 400 }); + } + + const { userId, issueUrl, teammates = [], mode = "validate", recommend, login } = body; + if (!userId) { + return Response.json({ ok: false, reasons: ["userId is required"] }, { status: 400 }); + } + + // Rate limiting + const clientId = getClientId(request); + const key = `${clientId}|${userId}|${mode}`; + const limit = mode === "execute" ? 3 : 10; + const rl = rateLimit(key, limit, 60_000); + if (!rl.allowed) { + return Response.json({ ok: false, reasons: ["Rate limit exceeded"], resetAt: rl.resetAt }, { status: 429 }); + } + + // Route to appropriate handler + if (!issueUrl) { + return await handleRecommendations(env, userId, recommend); + } + + return await handleValidateOrExecute(env, issueUrl, userId, teammates, mode, login); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + const status = error instanceof Error && error.message === "Unauthorized" ? 401 : 500; + return Response.json({ ok: false, reasons: [message] }, { status }); + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 00000000..df6eff53 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,4 @@ +export const MAX_CONCURRENT_DEFAULTS = { + collaborator: 6, + contributor: 4, +}; diff --git a/src/utils/get-user-task-limit-and-role.ts b/src/utils/get-user-task-limit-and-role.ts index 0e81fe2c..c2646aff 100644 --- a/src/utils/get-user-task-limit-and-role.ts +++ b/src/utils/get-user-task-limit-and-role.ts @@ -61,24 +61,32 @@ export async function getUserRoleAndTaskLimit(context: Context, user: string): P } // If we failed to get organization membership, narrow down to repo role - const permissionLevel = await octokit.rest.repos.getCollaboratorPermissionLevel({ - username: user, - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - }); - role = permissionLevel.data.role_name?.toLowerCase(); - context.logger.debug(`Retrieved collaborator permission level for ${user}.`, { - user, - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - isAdmin: permissionLevel.data.user?.permissions?.admin, - role, - data: permissionLevel.data, - }); - const normalizedRole = getTransformedRole(role); - limit = getUserTaskLimit(maxConcurrentTasks, normalizedRole); + try { + const permissionLevel = await octokit.rest.repos.getCollaboratorPermissionLevel({ + username: user, + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + }); + role = permissionLevel.data.role_name?.toLowerCase(); + context.logger.debug(`Retrieved collaborator permission level for ${user}.`, { + user, + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + isAdmin: permissionLevel.data.user?.permissions?.admin, + role, + data: permissionLevel.data, + }); + const normalizedRole = getTransformedRole(role); + limit = getUserTaskLimit(maxConcurrentTasks, normalizedRole); - return { role: normalizedRole, limit }; + return { role: normalizedRole, limit }; + } catch (err) { + logger.error("Could not get collaborator permission level", { err }); + } + + console.log("Max concurrent tasks", maxConcurrentTasks); + + return { role: "contributor", limit: maxConcurrentTasks.contributor }; } catch (err) { logger.error("Could not get user role", { err }); return { role: "contributor", limit: maxConcurrentTasks.contributor }; diff --git a/src/utils/is-dev-env.ts b/src/utils/is-dev-env.ts new file mode 100644 index 00000000..5f3bb31d --- /dev/null +++ b/src/utils/is-dev-env.ts @@ -0,0 +1,4 @@ +export function isDevelopment() { + const nodeEnv = process.env.NODE_ENV; + return nodeEnv === "development" || nodeEnv === "local"; +} diff --git a/src/worker.ts b/src/worker.ts index e0e1f41a..bbe6023c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,7 +9,7 @@ import { Command } from "./types/command"; import { SupportedEvents } from "./types/context"; import { Env, envSchema } from "./types/env"; import { PluginSettings, pluginSettingsSchema } from "./types/plugin-input"; -import { handlePublicStart } from "./handlers/start/public-api"; +import { handlePublicStart } from "./handlers/start/api/public-api"; export default { async fetch(request: Request, env: Env, executionCtx?: ExecutionContext) { diff --git a/tests/utils.ts b/tests/utils.ts index 4debb01f..4ab84ecb 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -29,10 +29,9 @@ const PRIORITY_LABELS = [ }, ]; -export const MAX_CONCURRENT_DEFAULTS = { - collaborator: 6, - contributor: 4, -}; +import { MAX_CONCURRENT_DEFAULTS } from "../src/utils/constants"; + +export { MAX_CONCURRENT_DEFAULTS }; export function createContext( issue: Record, From 3ace75d6f8f264e2c931ab1802a0af453cb179c3 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:18:18 +0000 Subject: [PATCH 07/56] test: fix account age and experience checks in user start/stop tests --- tests/main.test.ts | 48 ++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/main.test.ts b/tests/main.test.ts index 39fdccc7..6a2bfbc7 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -230,7 +230,6 @@ describe("User start/stop", () => { }); test("User can't start an issue when account age is below the configured minimum", async () => { - const dateNowSpy = jest.spyOn(Date, "now").mockReturnValue(new Date("2025-10-01T00:00:00Z").getTime()); const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; const sender = db.users.findFirst({ where: { id: { equals: 4 } } }) as unknown as PayloadSender; @@ -243,17 +242,20 @@ describe("User start/stop", () => { context.adapters = createAdapters(getSupabase(), context); context.config.taskAccessControl.accountRequiredAge = { minimumDays: 120 }; - try { - await userStartStop(context); - throw new Error("Expected account age restriction to block start"); - } catch (error) { - expect(error).toBeInstanceOf(AggregateError); - const aggregateError = error as AggregateError; - const messages = aggregateError.errors.map((entry) => entry.message); - expect(messages).toEqual(expect.arrayContaining([`@${sender.login} needs an account at least 120 days old (currently 30 days).`])); - } finally { - dateNowSpy.mockRestore(); - } + await expect(userStartStop(context)).rejects.toMatchObject({ + logMessage: { + raw: `@${sender.login} needs an account at least 120 days old (currently 30 days).`, + }, + metadata: { + accountRequiredAgeDays: 120, + ageMetadata: expect.arrayContaining([ + expect.objectContaining({ + accountAge: 30, + username: sender.login, + }), + ]), + }, + }); }); test("User can't start an issue when experience is below the required threshold", async () => { @@ -269,15 +271,19 @@ describe("User start/stop", () => { context.adapters = createAdapters(getSupabase(), context); context.config.taskAccessControl.experience = { priorityThresholds: [{ label: PRIORITY_ONE.name, minimumXp: 1000 }] }; - try { - await userStartStop(context); - throw new Error("Expected experience restriction to block start"); - } catch (error) { - expect(error).toBeInstanceOf(AggregateError); - const aggregateError = error as AggregateError; - const messages = aggregateError.errors.map((entry) => entry.message); - expect(messages).toEqual(expect.arrayContaining([`@${sender.login} needs at least 1000 XP to start this task (currently 200).`])); - } + await expect(userStartStop(context)).rejects.toMatchObject({ + logMessage: { + raw: `@${sender.login} needs at least 1000 XP to start this task (currently 200).`, + }, + metadata: { + xpMetadata: expect.arrayContaining([ + expect.objectContaining({ + username: sender.login, + xp: 200, + }), + ]), + }, + }); }); test("User can't start an issue that's closed", async () => { From 0ab733d3c535704c7d245d7e9e3a7c95c5491147 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:21:04 +0000 Subject: [PATCH 08/56] refactor: extract account age and experience validation helpers --- src/handlers/shared/start.ts | 430 ------------------ .../start/helpers/check-account-age.ts | 93 ++++ .../start/helpers/check-experience.ts | 77 ++++ 3 files changed, 170 insertions(+), 430 deletions(-) delete mode 100644 src/handlers/shared/start.ts create mode 100644 src/handlers/start/helpers/check-account-age.ts create mode 100644 src/handlers/start/helpers/check-experience.ts diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts deleted file mode 100644 index f6523bd4..00000000 --- a/src/handlers/shared/start.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { AssignedIssue, Context, ISSUE_TYPE } from "../../types/index"; -import { getUserExperience } from "../../utils/get-user-experience"; -import { addAssignees, getAssignedIssues, getPendingOpenedPullRequests, 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 { getTransformedRole, getUserRoleAndTaskLimit } from "./get-user-task-limit-and-role"; -import structuredMetadata from "./structured-metadata"; -import { assignTableComment } from "./table"; - -type RoleAndLimit = Awaited>; - -export async function checkRequirements( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - userRole: ReturnType -): Promise { - const { - config: { requiredLabelsToStart }, - logger, - } = context; - const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); - - if (requiredLabelsToStart.length) { - const currentLabelConfiguration = requiredLabelsToStart.find((label) => - issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase()) - ); - - // Admins can start any task - if (userRole === "admin") { - return null; - } - - if (!currentLabelConfiguration) { - // If we didn't find the label in the allowed list, then the user cannot start this task. - const errorText = `This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: ${requiredLabelsToStart.map((label) => "`" + label.name + "`").join(", ")}`; - logger.error(errorText, { - requiredLabelsToStart, - issueLabels, - issue: issue.html_url, - }); - return new Error(errorText); - } else if (!currentLabelConfiguration.allowedRoles.includes(userRole)) { - // If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task. - const humanReadableRoles = [ - ...currentLabelConfiguration.allowedRoles.map((o) => (o === "collaborator" ? "a core team member" : `a ${o}`)), - "an administrator", - ].join(", or "); - const errorText = `You must be ${humanReadableRoles} to start this task`; - logger.error(errorText, { - currentLabelConfiguration, - issueLabels, - issue: issue.html_url, - userRole, - }); - return new Error(errorText); - } - } - return null; -} - -export async function start( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context["payload"]["sender"], - teammates: string[] -): Promise { - const { logger, config } = context; - const { taskStaleTimeoutDuration, taskAccessControl } = config; - - if (!sender) { - throw logger.error(`Skipping '/start' since there is no sender in the context.`); - } - - const labels = issue.labels ?? []; - const priceLabel = labels.find((label) => label.name.startsWith("Price: ")); - const userAssociation = await getUserRoleAndTaskLimit(context, sender.login); - const userRole = userAssociation.role; - - const startErrors: Error[] = []; - const accountRequiredAgeDays = taskAccessControl.accountRequiredAge?.minimumDays ?? 0; - const experienceThresholds = taskAccessControl.experience?.priorityThresholds ?? []; - const issueLabelsLower = labels.map((label) => label.name.toLowerCase()); - const requiredExperience = experienceThresholds - .filter((threshold) => issueLabelsLower.includes(threshold.label.toLowerCase())) - .reduce((accumulator, current) => { - if (accumulator === null) { - return current.minimumXp; - } - return Math.max(accumulator, current.minimumXp); - }, null); - const participants = Array.from(new Set([...teammates, sender.login])); - const userProfiles = new Map(); - const participantRoleAndLimits: Map = new Map(); - participantRoleAndLimits.set(sender.login.toLowerCase(), userAssociation); - const roleFetches = participants - .filter((username) => !participantRoleAndLimits.has(username.toLowerCase())) - .map(async (username) => { - const association = await getUserRoleAndTaskLimit(context, username); - participantRoleAndLimits.set(username.toLowerCase(), association); - }); - if (roleFetches.length) { - await Promise.all(roleFetches); - } - const accessControlledParticipants = participants.filter((username) => { - const role = participantRoleAndLimits.get(username.toLowerCase())?.role ?? "contributor"; - return role !== "collaborator" && role !== "admin"; - }); - - // Collaborators and admins can start un-priced tasks - if (!priceLabel && userRole === "contributor") { - const errorMessage = "You may not start the task because the issue requires a price label. Please ask a maintainer to add pricing."; - logger.error(errorMessage, { issueNumber: issue.number, labels }); - startErrors.push(new Error(errorMessage)); - } - - const checkRequirementsError = await checkRequirements(context, issue, userRole); - if (checkRequirementsError) { - startErrors.push(checkRequirementsError); - } - - if (accountRequiredAgeDays > 0 && accessControlledParticipants.length) { - for (const username of accessControlledParticipants) { - const normalizedUsername = username.toLowerCase(); - if (userProfiles.has(normalizedUsername)) { - continue; - } - try { - const { data } = await context.octokit.rest.users.getByUsername({ username }); - const profile = { id: data.id, login: data.login, created_at: data.created_at }; - userProfiles.set(normalizedUsername, profile); - userProfiles.set(data.login.toLowerCase(), profile); - } catch (err) { - const message = `Unable to load GitHub profile for ${username}.`; - logger.error(message, { username, err }); - startErrors.push(new Error(message)); - } - } - - const now = Date.now(); - const accountAgeMessages: string[] = []; - const ageMetadata: Array> = []; - for (const username of accessControlledParticipants) { - const profile = userProfiles.get(username.toLowerCase()); - if (!profile?.created_at) { - accountAgeMessages.push(`@${username} cannot start this task because the account creation date could not be verified.`); - ageMetadata.push({ username, reason: "missing_created_at" }); - continue; - } - const createdAtMs = Date.parse(profile.created_at); - if (Number.isNaN(createdAtMs)) { - accountAgeMessages.push(`@${username} cannot start this task because the account creation date could not be verified.`); - ageMetadata.push({ username, reason: "invalid_created_at", rawCreatedAt: profile.created_at }); - continue; - } - const accountAge = Math.floor((now - createdAtMs) / (1000 * 60 * 60 * 24)); - if (accountAge < accountRequiredAgeDays) { - accountAgeMessages.push(`@${username} needs an account at least ${accountRequiredAgeDays} days old (currently ${accountAge} days).`); - ageMetadata.push({ username, accountAge }); - } - } - if (accountAgeMessages.length) { - const message = accountAgeMessages.join("\n"); - const warning = logger.warn(message, { accountRequiredAgeDays, ageMetadata }); - await context.commentHandler.postComment(context, warning); - startErrors.push(new Error(message)); - } - } - - if (requiredExperience !== null && accessControlledParticipants.length) { - const xpServiceBaseUrl = context.env.XP_SERVICE_BASE_URL ?? "https://os-daemon-xp.ubq.fi"; - const xpMessages: string[] = []; - const xpMetadata: Array<{ username: string; xp: number }> = []; - for (const username of accessControlledParticipants) { - try { - context.logger.debug(`Trying to fetch XP for the user ${username}`); - const xp = await getUserExperience(context, xpServiceBaseUrl, username); - xpMetadata.push({ username, xp }); - if (xp < requiredExperience) { - xpMessages.push(`@${username} needs at least ${requiredExperience} XP to start this task (currently ${xp}).`); - } - } catch (err) { - const message = `Unable to verify XP for ${username}.`; - logger.error(message, { username, err }); - startErrors.push(new Error(message)); - } - } - if (xpMessages.length) { - const message = xpMessages.join("\n"); - const warning = logger.warn(message, { requiredExperience, xpMetadata }); - await context.commentHandler.postComment(context, warning); - startErrors.push(new Error(message)); - } - } - - if (startErrors.length) { - throw new AggregateError(startErrors); - } - - // is it a child issue? - if (issue.body && isParentIssue(issue.body)) { - const message = logger.error("Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues."); - await context.commentHandler.postComment(context, message); - throw message; - } - - let commitHash: string | null = null; - - try { - const hashResponse = await context.octokit.rest.repos.getCommit({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - ref: context.payload.repository.default_branch, - }); - commitHash = hashResponse.data.sha; - } catch (e) { - logger.error("Error while getting commit hash", { error: e as Error }); - } - - // is it assignable? - - if (issue.state === ISSUE_TYPE.CLOSED) { - throw logger.error("This issue is closed, please choose another.", { issueNumber: issue.number }); - } - - const assignees = issue?.assignees ?? []; - - // 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 } - ); - } - - teammates.push(sender.login); - - const toAssign = []; - let assignedIssues: AssignedIssue[] = []; - // check max assigned issues - for (const user of teammates) { - const { isWithinLimit, issues, role } = await handleTaskLimitChecks({ - context, - logger, - sender: sender.login, - username: user, - roleAndLimit: participantRoleAndLimits.get(user.toLowerCase()), - }); - if (isWithinLimit) { - toAssign.push(user); - } else { - issues.forEach((issue) => { - assignedIssues = assignedIssues.concat({ - title: issue.title, - html_url: issue.html_url, - }); - }); - } - - if (priceLabel && role !== "admin") { - const { usdPriceMax } = taskAccessControl; - const min = Math.min(...Object.values(usdPriceMax)); - const allowed = role && role in usdPriceMax ? usdPriceMax[role as keyof typeof usdPriceMax] : undefined; - const userAllowedMaxPrice = typeof allowed === "number" ? allowed : min; - - const priceRegex = /Price:\s*([\d.]+)/; - const match = priceLabel.name.match(priceRegex); - if (!match) { - throw logger.error("Price label is not in the correct format", { priceLabel: priceLabel.name }); - } - const value = match[1]; - if (isNaN(parseFloat(value))) { - throw logger.error("Price label is not in the correct format", { priceLabel: priceLabel.name }); - } - const price = parseFloat(value); - if (userAllowedMaxPrice < 0) { - throw logger.warn(`External contributors are not eligible for rewards at this time. We are preserving resources for core team only.`, { - userRole, - price, - userAllowedMaxPrice, - issueNumber: issue.number, - }); - } else if (price > userAllowedMaxPrice) { - throw logger.warn( - `While we appreciate your enthusiasm @${user}, the price of this task exceeds your allowed limit. Please choose a task with a price of $${userAllowedMaxPrice} or less.`, - { - userRole, - price, - userAllowedMaxPrice, - issueNumber: issue.number, - } - ); - } - } - } - - 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."; - throw logger.error(error, { issueNumber: issue.number }); - } else if (toAssign.length === 0) { - error = "You have reached your max task limit. Please close out some tasks before assigning new ones."; - let issues = ""; - const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/; - assignedIssues.forEach((el) => { - const match = el.html_url.match(urlPattern); - if (match) { - issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`); - } else { - issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`); - } - }); - - await context.commentHandler.postComment( - context, - context.logger.warn(` -${error} - -${issues} -`) - ); - return { content: error, status: HttpStatusCode.NOT_MODIFIED }; - } - - const toAssignIds = await fetchUserIds(context, toAssign, userProfiles); - const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); - const logMessage = logger.info("Task assigned successfully", { - taskDeadline: assignmentComment.deadline, - taskAssignees: toAssignIds, - priceLabel, - revision: commitHash?.substring(0, 7), - }); - const metadata = structuredMetadata.create("Assignment", logMessage); - - // add assignee - await addAssignees(context, issue.number, toAssign); - - const isTaskStale = checkTaskStale(getTimeValue(taskStaleTimeoutDuration), issue.created_at); - - await context.commentHandler.postComment( - context, - logger.ok( - [ - assignTableComment({ - isTaskStale, - daysElapsedSinceTaskCreation: assignmentComment.daysElapsedSinceTaskCreation, - taskDeadline: assignmentComment.deadline, - registeredWallet: assignmentComment.registeredWallet, - }), - assignmentComment.tips, - metadata, - ].join("\n") as string - ), - { raw: true } - ); - - return { content: "Task assigned successfully", status: HttpStatusCode.OK }; -} - -async function fetchUserIds(context: Context, username: string[], userProfiles?: Map) { - const ids = []; - - for (const user of username) { - const cachedProfile = userProfiles?.get(user.toLowerCase()); - if (cachedProfile?.id) { - ids.push(cachedProfile.id); - continue; - } - const { data } = await context.octokit.rest.users.getByUsername({ - username: user, - }); - - ids.push(data.id); - if (userProfiles) { - const profile = { id: data.id, login: data.login, created_at: data.created_at }; - userProfiles.set(user.toLowerCase(), profile); - userProfiles.set(data.login.toLowerCase(), profile); - } - } - - if (ids.filter((id) => !id).length > 0) { - throw new Error("Error while fetching user ids"); - } - - return ids; -} - -async function handleTaskLimitChecks({ - context, - logger, - sender, - username, - roleAndLimit, -}: { - username: string; - context: Context; - logger: Context["logger"]; - sender: string; - roleAndLimit?: RoleAndLimit; -}) { - const openedPullRequests = await getPendingOpenedPullRequests(context, username); - const assignedIssues = await getAssignedIssues(context, username); - const { limit, role } = roleAndLimit ?? (await getUserRoleAndTaskLimit(context, username)); - - // check for max and enforce max - if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) { - logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, { - assignedIssues: assignedIssues.length, - openedPullRequests: openedPullRequests.length, - limit, - }); - - return { - isWithinLimit: false, - issues: assignedIssues, - }; - } - - if (await hasUserBeenUnassigned(context, username)) { - throw logger.warn(`${username} you were previously unassigned from this task. You cannot be reassigned.`, { username }); - } - - return { - isWithinLimit: true, - issues: [], - role, - }; -} diff --git a/src/handlers/start/helpers/check-account-age.ts b/src/handlers/start/helpers/check-account-age.ts new file mode 100644 index 00000000..887a4530 --- /dev/null +++ b/src/handlers/start/helpers/check-account-age.ts @@ -0,0 +1,93 @@ +import { Context } from "../../../types"; + +export type UserProfile = { + id: number; + login: string; + created_at?: string; +}; + +export type AccountAgeResult = { + messages: string[]; + metadata: Array>; +}; + +/** + * Validates that users meet the minimum account age requirement. + * Only checks contributors (not collaborators or admins). + * + * @param context - The application context + * @param participants - List of usernames to check + * @param userProfiles - Map of already-fetched user profiles + * @param participantRoleAndLimits - Map of user roles + * @param accountRequiredAgeDays - Minimum account age in days + * @returns Object containing warning messages and metadata for users who don't meet requirements + */ +export async function checkAccountAge( + context: Context, + participants: string[], + userProfiles: Map, + participantRoleAndLimits: Map, + accountRequiredAgeDays: number +): Promise { + const { logger } = context; + const accountAgeMessages: string[] = []; + const ageMetadata: Array> = []; + + if (accountRequiredAgeDays <= 0) { + return { messages: [], metadata: [] }; + } + + // Filter to only check access-controlled participants (not collaborators/admins) + const accessControlledParticipants = participants.filter((username) => { + const role = participantRoleAndLimits.get(username.toLowerCase())?.role ?? "contributor"; + return role !== "collaborator" && role !== "admin"; + }); + + if (accessControlledParticipants.length === 0) { + return { messages: [], metadata: [] }; + } + + // Fetch missing user profiles + for (const username of accessControlledParticipants) { + const normalizedUsername = username.toLowerCase(); + if (userProfiles.has(normalizedUsername)) { + continue; + } + try { + const { data } = await context.octokit.rest.users.getByUsername({ username }); + const profile = { id: data.id, login: data.login, created_at: data.created_at }; + userProfiles.set(normalizedUsername, profile); + userProfiles.set(data.login.toLowerCase(), profile); + } catch (err) { + const message = `Unable to load GitHub profile for ${username}.`; + logger.error(message, { username, err }); + throw new Error(message); + } + } + + const now = Date.now(); + + for (const username of accessControlledParticipants) { + const profile = userProfiles.get(username.toLowerCase()); + if (!profile?.created_at) { + accountAgeMessages.push(`@${username} cannot start this task because the account creation date could not be verified.`); + ageMetadata.push({ username, reason: "missing_created_at" }); + continue; + } + + const createdAtMs = Date.parse(profile.created_at); + if (Number.isNaN(createdAtMs)) { + accountAgeMessages.push(`@${username} cannot start this task because the account creation date could not be verified.`); + ageMetadata.push({ username, reason: "invalid_created_at", rawCreatedAt: profile.created_at }); + continue; + } + + const accountAge = Math.floor((now - createdAtMs) / (1000 * 60 * 60 * 24)); + if (accountAge < accountRequiredAgeDays) { + accountAgeMessages.push(`@${username} needs an account at least ${accountRequiredAgeDays} days old (currently ${accountAge} days).`); + ageMetadata.push({ username, accountAge }); + } + } + + return { messages: accountAgeMessages, metadata: ageMetadata }; +} diff --git a/src/handlers/start/helpers/check-experience.ts b/src/handlers/start/helpers/check-experience.ts new file mode 100644 index 00000000..dcdeabe3 --- /dev/null +++ b/src/handlers/start/helpers/check-experience.ts @@ -0,0 +1,77 @@ +import { Context, Label } from "../../../types"; +import { getUserExperience } from "../../../utils/get-user-experience"; + +export type ExperienceResult = { + messages: string[]; + metadata: Array<{ username: string; xp: number }>; + requiredExperience: number | null; +}; + +/** + * Validates that users meet the minimum XP requirement based on issue priority labels. + * Only checks contributors (not collaborators or admins). + * + * @param context - The application context + * @param participants - List of usernames to check + * @param participantRoleAndLimits - Map of user roles + * @param labels - Issue labels to check for priority thresholds + * @returns Object containing warning messages and metadata for users who don't meet requirements + */ +export async function checkExperience( + context: Context, + participants: string[], + participantRoleAndLimits: Map, + labels: Label[] +): Promise { + const { logger, config, env } = context; + const { experience } = config.taskAccessControl; + + const xpMessages: string[] = []; + const xpMetadata: Array<{ username: string; xp: number }> = []; + + // Determine required XP from priority labels + const experienceThresholds = experience?.priorityThresholds ?? []; + const issueLabelsLower = labels.map((label) => label.name.toLowerCase()); + const requiredExperience = experienceThresholds + .filter((threshold) => issueLabelsLower.includes(threshold.label.toLowerCase())) + .reduce((accumulator, current) => { + if (accumulator === null) { + return current.minimumXp; + } + return Math.max(accumulator, current.minimumXp); + }, null); + + // No XP requirement found + if (requiredExperience === null) { + return { messages: [], metadata: [], requiredExperience }; + } + + // Filter to only check access-controlled participants (not collaborators/admins) + const accessControlledParticipants = participants.filter((username) => { + const role = participantRoleAndLimits.get(username.toLowerCase())?.role ?? "contributor"; + return role !== "collaborator" && role !== "admin"; + }); + + if (accessControlledParticipants.length === 0) { + return { messages: [], metadata: [], requiredExperience }; + } + + const xpServiceBaseUrl = env.XP_SERVICE_BASE_URL ?? "https://os-daemon-xp.ubq.fi"; + + for (const username of accessControlledParticipants) { + try { + logger.debug(`Trying to fetch XP for the user ${username}`); + const xp = await getUserExperience(context, xpServiceBaseUrl, username); + xpMetadata.push({ username, xp }); + if (xp < requiredExperience) { + xpMessages.push(`@${username} needs at least ${requiredExperience} XP to start this task (currently ${xp}).`); + } + } catch (err) { + const message = `Unable to verify XP for ${username}.`; + logger.error(message, { username, err }); + throw new Error(message); + } + } + + return { messages: xpMessages, metadata: xpMetadata, requiredExperience }; +} From 49f5b192ebc21ff944126a5eb30f477763c3194b Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:22:42 +0000 Subject: [PATCH 09/56] feat: add account age and experience checks to eligibility evaluation --- src/handlers/start/evaluate-eligibility.ts | 54 ++++++++++++++++++++++ src/types/payload.ts | 1 + 2 files changed, 55 insertions(+) diff --git a/src/handlers/start/evaluate-eligibility.ts b/src/handlers/start/evaluate-eligibility.ts index 6d7887c7..76f091db 100644 --- a/src/handlers/start/evaluate-eligibility.ts +++ b/src/handlers/start/evaluate-eligibility.ts @@ -7,6 +7,8 @@ import { ERROR_MESSAGES } from "./helpers/error-messages"; import { handleTaskLimitChecks } from "./helpers/check-assignments"; import { checkTaskStale } from "./helpers/check-task-stale"; import { getDeadline } from "./helpers/get-deadline"; +import { checkAccountAge, UserProfile } from "./helpers/check-account-age"; +import { checkExperience } from "./helpers/check-experience"; export type StartEligibilityResult = { ok: boolean; @@ -69,6 +71,58 @@ export async function evaluateStartEligibility( } const allUsers = [...new Set([sender.login, ...teammates])]; + + // Build participant role mappings for access control checks + const participantRoleAndLimits: Map }> = new Map(); + participantRoleAndLimits.set(sender.login.toLowerCase(), { role: userRole }); + + const roleFetches = allUsers + .filter((username) => !participantRoleAndLimits.has(username.toLowerCase())) + .map(async (username) => { + const association = await getUserRoleAndTaskLimit(context, username); + participantRoleAndLimits.set(username.toLowerCase(), { role: association.role }); + }); + + if (roleFetches.length) { + await Promise.all(roleFetches); + } + + // User profiles cache for account age checks + const userProfiles = new Map(); + + // Check account age requirements + const accountRequiredAgeDays = context.config.taskAccessControl.accountRequiredAge?.minimumDays ?? 0; + if (accountRequiredAgeDays > 0) { + try { + const accountAgeResult = await checkAccountAge(context, allUsers, userProfiles, participantRoleAndLimits, accountRequiredAgeDays); + + if (accountAgeResult.messages.length > 0) { + const message = accountAgeResult.messages.join("\n"); + const warning = context.logger.warn(message, { accountRequiredAgeDays, ageMetadata: accountAgeResult.metadata }); + errors.push(warning); + } + } catch (err) { + if (err instanceof Error) { + errors.push(context.logger.error(err.message)); + } + } + } + + // Check experience requirements + try { + const experienceResult = await checkExperience(context, allUsers, participantRoleAndLimits, labels); + + if (experienceResult.messages.length > 0) { + const message = experienceResult.messages.join("\n"); + const warning = context.logger.warn(message, { requiredExperience: experienceResult.requiredExperience, xpMetadata: experienceResult.metadata }); + errors.push(warning); + } + } catch (err) { + if (err instanceof Error) { + errors.push(context.logger.error(err.message)); + } + } + const toAssign: string[] = []; for (const user of allUsers) { let role: ReturnType | undefined = undefined; diff --git a/src/types/payload.ts b/src/types/payload.ts index 9c52a18b..4cf9d204 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -7,6 +7,7 @@ export type TimelineEvents = RestEndpointMethodTypes["issues"]["listEventsForTim export type Assignee = Issue["assignee"]; export type GitHubIssueSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"]; export type PrState = "open" | "closed" | "all" | undefined; +export type Label = RestEndpointMethodTypes["issues"]["listLabelsForRepo"]["response"]["data"][0]; export type AssignedIssue = { title: string; From ba2f99102dd206d9985400f791d2599c5f22763f Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:26:37 +0000 Subject: [PATCH 10/56] test: mock date for account age validation in user start/stop tests --- .husky/pre-commit | 6 +++--- tests/main.test.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 4cdd0735..1d67559d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ -# #!/usr/bin/env sh -# . "$(dirname -- "$0")/_/husky.sh" +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" -# bun lint-staged +bun lint-staged diff --git a/tests/main.test.ts b/tests/main.test.ts index 6a2bfbc7..f443b183 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -230,6 +230,7 @@ describe("User start/stop", () => { }); test("User can't start an issue when account age is below the configured minimum", async () => { + jest.spyOn(Date, "now").mockReturnValue(new Date("2025-10-01T00:00:00Z").getTime()); const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue; const sender = db.users.findFirst({ where: { id: { equals: 4 } } }) as unknown as PayloadSender; From 4f7efbbf74c03b1d99cf30bc6f8908fe115dc0b0 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:50:11 +0000 Subject: [PATCH 11/56] feat: enhance context handling by supporting ShallowContext in adapters for public API use --- src/adapters/index.ts | 3 +- src/adapters/supabase/helpers/supabase.ts | 5 ++- src/adapters/supabase/helpers/user.ts | 3 +- src/handlers/start/api/helpers/auth.ts | 44 +++++++------------ src/handlers/start/api/helpers/octokit.ts | 5 ++- src/handlers/start/api/helpers/types.ts | 2 + .../start/helpers/check-requirements.ts | 8 ++-- src/handlers/start/helpers/error-messages.ts | 1 + src/handlers/start/perform-assignment.ts | 16 +++---- src/types/payload.ts | 2 + 10 files changed, 46 insertions(+), 43 deletions(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index a7525a29..148b7612 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,8 +1,9 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Context } from "../types/context"; import { User } from "./supabase/helpers/user"; +import { ShallowContext } from "../handlers/start/api/helpers/context-builder"; -export function createAdapters(supabaseClient: SupabaseClient, context: Context) { +export function createAdapters(supabaseClient: SupabaseClient, context: Context | ShallowContext) { return { supabase: { user: new User(supabaseClient, context), diff --git a/src/adapters/supabase/helpers/supabase.ts b/src/adapters/supabase/helpers/supabase.ts index 7a13b85f..9d6c5b07 100644 --- a/src/adapters/supabase/helpers/supabase.ts +++ b/src/adapters/supabase/helpers/supabase.ts @@ -1,11 +1,12 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Context } from "../../../types/context"; +import { ShallowContext } from "../../../handlers/start/api/helpers/context-builder"; export class Super { protected supabase: SupabaseClient; - protected context: Context; + protected context: Context | ShallowContext; - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: Context | ShallowContext) { this.supabase = supabase; this.context = context; } diff --git a/src/adapters/supabase/helpers/user.ts b/src/adapters/supabase/helpers/user.ts index b12b8f22..1b395598 100644 --- a/src/adapters/supabase/helpers/user.ts +++ b/src/adapters/supabase/helpers/user.ts @@ -1,13 +1,14 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Context } from "../../../types/context"; import { Super } from "./supabase"; +import { ShallowContext } from "../../../handlers/start/api/helpers/context-builder"; type Wallet = { address: string; }; export class User extends Super { - constructor(supabase: SupabaseClient, context: Context) { + constructor(supabase: SupabaseClient, context: Context | ShallowContext) { super(supabase, context); } diff --git a/src/handlers/start/api/helpers/auth.ts b/src/handlers/start/api/helpers/auth.ts index 5106f37f..26b425fe 100644 --- a/src/handlers/start/api/helpers/auth.ts +++ b/src/handlers/start/api/helpers/auth.ts @@ -1,22 +1,11 @@ -import { createClient } from "@supabase/supabase-js"; +import { createClient, User } from "@supabase/supabase-js"; import { Env } from "../../../../types/env"; -import { isDevelopment } from "../../../../utils/is-dev-env"; +import { ShallowContext } from "./context-builder"; /** * Verifies Supabase JWT token. - * In development mode, bypasses verification and returns a mock user. */ -export async function verifySupabaseJwt(env: Env, jwt: string) { - if (isDevelopment()) { - // Bypass JWT verification in development - return { - id: "dev-user-id", - email: "dev@example.com", - user_metadata: {}, - app_metadata: {}, - }; - } - +export async function verifySupabaseJwt(env: Env, jwt: string): Promise { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); const { data, error } = await supabase.auth.getUser(jwt); if (error || !data?.user) { @@ -27,21 +16,22 @@ export async function verifySupabaseJwt(env: Env, jwt: string) { /** * Resolves GitHub login from Supabase issues cache. - * In development mode, allows fallback to a static login from env or returns null. */ -export async function resolveLoginFromSupabaseIssues(env: Env, userId: number): Promise { - if (isDevelopment()) { - // In development, allow using a static login from env or return null to use fallback - const devLogin = process.env.DEV_GITHUB_LOGIN; - if (devLogin) { - return devLogin; - } - // If no DEV_GITHUB_LOGIN is set, return null to allow fallback handling - return null; - } - - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); +export async function resolveLoginFromSupabaseIssues(context: ShallowContext, userId: number): Promise { + const supabase = createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY); const { data } = await supabase.from("issues").select("payload").eq("author_id", userId).order("modified_at", { ascending: false }).limit(1); const payload = data && data[0]?.payload; return payload?.user?.login ?? null; } + +/** + * Extracts JWT token from Authorization header. + * Returns null if header is missing or malformed. + */ +export function extractJwtFromHeader(request: Request): string | null { + const auth = request.headers.get("authorization") || request.headers.get("Authorization"); + if (!auth || !auth.toLowerCase().startsWith("bearer ")) { + return null; + } + return auth.split(" ")[1]; +} diff --git a/src/handlers/start/api/helpers/octokit.ts b/src/handlers/start/api/helpers/octokit.ts index 25bcbb62..cdad8af2 100644 --- a/src/handlers/start/api/helpers/octokit.ts +++ b/src/handlers/start/api/helpers/octokit.ts @@ -2,6 +2,10 @@ import { createAppAuth } from "@octokit/auth-app"; import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit"; import { Env } from "../../../../types/env"; +export async function createUserOctokit(userAccessToken: string) { + return new customOctokit({ auth: userAccessToken }); +} + export async function createAppOctokit(env: Env) { return new customOctokit({ authStrategy: createAppAuth, @@ -15,7 +19,6 @@ export async function createAppOctokit(env: Env) { export async function createRepoOctokit(env: Env, owner: string, repo: string) { const appOctokit = await createAppOctokit(env); const installation = await appOctokit.rest.apps.getRepoInstallation({ owner, repo }); - return new customOctokit({ authStrategy: createAppAuth, auth: { diff --git a/src/handlers/start/api/helpers/types.ts b/src/handlers/start/api/helpers/types.ts index 2a896623..7cc709af 100644 --- a/src/handlers/start/api/helpers/types.ts +++ b/src/handlers/start/api/helpers/types.ts @@ -6,6 +6,8 @@ export type StartBody = { recommend?: { topK?: number; threshold?: number }; // Development only: allows passing login directly when Supabase lookup is unavailable login?: string; + // Optional: GitHub user OAuth access token used when the GitHub App isn't installed on the repo + userAccessToken?: string; }; export type IssueUrlParts = { diff --git a/src/handlers/start/helpers/check-requirements.ts b/src/handlers/start/helpers/check-requirements.ts index 84c192a9..0c35e33e 100644 --- a/src/handlers/start/helpers/check-requirements.ts +++ b/src/handlers/start/helpers/check-requirements.ts @@ -1,17 +1,19 @@ -import { Context } from "../../../types/index"; +import { Context, Issue } from "../../../types/index"; import { getTransformedRole } from "../../../utils/get-user-task-limit-and-role"; import { ERROR_MESSAGES } from "./error-messages"; export async function checkRequirements( context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], + issue: Context<"issue_comment.created">["payload"]["issue"] | Issue, userRole: ReturnType ): Promise { const { config: { requiredLabelsToStart }, logger, } = context; - const issueLabels = issue.labels.map((label) => label.name.toLowerCase()); + const issueLabels = issue.labels + .map((label) => (typeof label === "string" ? label.toLowerCase() : label.name?.toLowerCase())) + .filter((label): label is string => !!label); if (requiredLabelsToStart.length) { const currentLabelConfiguration = requiredLabelsToStart.find((label) => diff --git a/src/handlers/start/helpers/error-messages.ts b/src/handlers/start/helpers/error-messages.ts index 20850910..f9b78923 100644 --- a/src/handlers/start/helpers/error-messages.ts +++ b/src/handlers/start/helpers/error-messages.ts @@ -22,6 +22,7 @@ export const ERROR_MESSAGES = { NOT_BUSINESS_PRIORITY: "This task does not reflect a business priority at the moment.\nYou may start tasks with one of the following labels: {{requiredLabelsToStart}}", PRESERVATION_MODE: "External contributors are not eligible for rewards at this time. We are preserving resources for core team only.", + MALFORMED_COMMAND: "Malformed command parameters.", } as const; export async function handleStartErrors(context: Context, eligibility: StartEligibilityResult): Promise { diff --git a/src/handlers/start/perform-assignment.ts b/src/handlers/start/perform-assignment.ts index 2ff9b3e6..11a952aa 100644 --- a/src/handlers/start/perform-assignment.ts +++ b/src/handlers/start/perform-assignment.ts @@ -8,13 +8,11 @@ import { getUserIds } from "./helpers/get-user-ids"; import structuredMetadata from "./helpers/generate-structured-metadata"; import { assignTableComment } from "./helpers/generate-assignment-table"; -export async function performAssignment( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: { login: string; id: number }, - toAssign: string[] -): Promise { - const { logger } = context; +export async function performAssignment(context: Context<"issue_comment.created">, toAssign: string[]): Promise { + const { + logger, + payload: { issue, sender }, + } = context; // compute metadata let commitHash: string | null = null; try { @@ -28,7 +26,9 @@ export async function performAssignment( logger.error("Error while getting commit hash", { error: e as Error }); } const labels = issue.labels ?? []; - const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: ")); + const priceLabel = labels.find((label: Label) => { + return (typeof label === "string" ? label : label.name)?.startsWith("Price: "); + }); const isTaskStale = checkTaskStale(getTimeValue(context.config.taskStaleTimeoutDuration), issue.created_at); const toAssignIds = await getUserIds(context, toAssign); const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, null); diff --git a/src/types/payload.ts b/src/types/payload.ts index 4cf9d204..aa657d75 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -8,6 +8,8 @@ export type Assignee = Issue["assignee"]; export type GitHubIssueSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"]; export type PrState = "open" | "closed" | "all" | undefined; export type Label = RestEndpointMethodTypes["issues"]["listLabelsForRepo"]["response"]["data"][0]; +export type Repository = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; +export type Organization = Repository["organization"]; export type AssignedIssue = { title: string; From 1c6248f20eb0e0af229c98da93a567913c31a623 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:51:15 +0000 Subject: [PATCH 12/56] refactor: simplify startTask and evaluateStartEligibility by removing unnecessary parameters --- src/handlers/command-handler.ts | 11 ++++-- src/handlers/new-pull-request-or-edit.ts | 13 +++++-- src/handlers/start-task.ts | 16 ++++----- src/handlers/start/evaluate-eligibility.ts | 40 +++++++++++++++++----- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/handlers/command-handler.ts b/src/handlers/command-handler.ts index f4415c24..43d05b80 100644 --- a/src/handlers/command-handler.ts +++ b/src/handlers/command-handler.ts @@ -15,8 +15,7 @@ export async function commandHandler(context: Context): Promise { if (context.command.name === "stop") { return await stop(context, issue, sender, repository); } else if (context.command.name === "start") { - const teammates = context.command.parameters.teammates ?? []; - return await startTask(context, issue, sender, teammates); + return await startTask(context); } else { return { status: HttpStatusCode.BAD_REQUEST }; } @@ -36,7 +35,13 @@ export async function userStartStop(context: Context): Promise { if (slashCommand === "stop") { return await stop(context, issue, sender, repository); } else if (slashCommand === "start") { - return await startTask(context, issue, sender, teamMates); + context.command = context.command || { + name: "start", + parameters: { + teammates: teamMates, + }, + }; + return await startTask(context); } return { status: HttpStatusCode.NOT_MODIFIED }; diff --git a/src/handlers/new-pull-request-or-edit.ts b/src/handlers/new-pull-request-or-edit.ts index 8f234dad..7c980857 100644 --- a/src/handlers/new-pull-request-or-edit.ts +++ b/src/handlers/new-pull-request-or-edit.ts @@ -76,18 +76,25 @@ export async function newPullRequestOrEdit(context: Context<"pull_request.opened }) ).data; } + if (!pull_request.user) { + context.logger.info("Pull request has no user associated, skipping."); + continue; + } + const newContext = { ...context, + eventName: "issue_comment.created" as const, octokit: repoOctokit, payload: { - ...context.payload, + ...(context.payload as unknown as Context<"issue_comment.created">["payload"]), issue: linkedIssue, repository, organization, + sender: pull_request.user, }, - }; + } satisfies Context<"issue_comment.created">; try { - return await startTask(newContext, linkedIssue, pull_request.user ?? payload.sender, []); + return await startTask(newContext); } catch (error) { await closePullRequest(context, { number: pull_request.number }); throw error; diff --git a/src/handlers/start-task.ts b/src/handlers/start-task.ts index b9ce140f..0941ed32 100644 --- a/src/handlers/start-task.ts +++ b/src/handlers/start-task.ts @@ -4,20 +4,18 @@ import { handleStartErrors } from "./start/helpers/error-messages"; import { evaluateStartEligibility } from "./start/evaluate-eligibility"; import { performAssignment } from "./start/perform-assignment"; -export async function startTask( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context["payload"]["sender"], - teammates: string[] -): Promise { - const { logger } = context; +export async function startTask(context: Context<"issue_comment.created">): Promise { + const { + logger, + payload: { sender }, + } = context; if (!sender) { throw logger.error(`Skipping '/start' since there is no sender in the context.`); } // Centralized eligibility gate without side effects - const eligibility = await evaluateStartEligibility(context, issue, sender, teammates); + const eligibility = await evaluateStartEligibility(context); if (!eligibility.ok) { // handleStartErrors will either throw or return an error result @@ -25,5 +23,5 @@ export async function startTask( } // All checks passed, perform assignment - return performAssignment(context, issue, sender, eligibility.computed.toAssign); + return performAssignment(context, eligibility.computed.toAssign); } diff --git a/src/handlers/start/evaluate-eligibility.ts b/src/handlers/start/evaluate-eligibility.ts index 76f091db..2e841f58 100644 --- a/src/handlers/start/evaluate-eligibility.ts +++ b/src/handlers/start/evaluate-eligibility.ts @@ -25,17 +25,15 @@ export type StartEligibilityResult = { }; }; -export async function evaluateStartEligibility( - context: Context, - issue: Context<"issue_comment.created">["payload"]["issue"], - sender: Context<"issue_comment.created">["payload"]["sender"], - teammates: string[] -): Promise { +export async function evaluateStartEligibility(context: Context<"issue_comment.created">): Promise { const errors: LogReturn[] = []; const warnings: string[] = []; const assignedIssues: AssignedIssue[] = []; + const { + payload: { issue, sender }, + } = context; - if (!sender) { + if ((typeof sender === "object" && !sender.login) || !sender) { errors.push(context.logger.error(ERROR_MESSAGES.MISSING_SENDER)); } @@ -70,7 +68,33 @@ export async function evaluateStartEligibility( errors.push(context.logger.error(errorMessage)); } - const allUsers = [...new Set([sender.login, ...teammates])]; + // TODO: Confirm when (command === `null` | `undefined`) is valid + // if (!context.command || !("parameters" in context.command)) { + // errors.push(context.logger.error(ERROR_MESSAGES.MALFORMED_COMMAND)); + // return { + // ok: errors.length === 0, + // errors, + // warnings, + // computed: { + // deadline: null, + // isTaskStale: false, + // wallet: null, + // toAssign: [], + // assignedIssues, + // consideredCount: 0, + // senderRole: userRole, + // }, + // }; + // } + + const params = + context.command && "parameters" in context.command + ? context.command.parameters + : { + teammates: [], + }; + + const allUsers = [...new Set([sender.login, ...(params.teammates ?? []).map((u: string) => u.trim()).filter((u: string) => u.length > 0)])]; // Build participant role mappings for access control checks const participantRoleAndLimits: Map }> = new Map(); From cb63eaae3c502907a062d0ff1c8ca139f2509f6d Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:51:45 +0000 Subject: [PATCH 13/56] feat: implement recommendation handling and validation execution for public API --- .../api/directory-task-recommendations.ts | 28 ++ src/handlers/start/api/public-api.ts | 285 +++++++++--------- src/handlers/start/api/validate-or-execute.ts | 95 ++++++ 3 files changed, 265 insertions(+), 143 deletions(-) create mode 100644 src/handlers/start/api/directory-task-recommendations.ts create mode 100644 src/handlers/start/api/validate-or-execute.ts diff --git a/src/handlers/start/api/directory-task-recommendations.ts b/src/handlers/start/api/directory-task-recommendations.ts new file mode 100644 index 00000000..06b6b0c0 --- /dev/null +++ b/src/handlers/start/api/directory-task-recommendations.ts @@ -0,0 +1,28 @@ +import { Context } from "../../../types"; +import { ShallowContext } from "./helpers/context-builder"; +import { getRecommendations } from "./helpers/recommendations"; + +/** + * Handles the recommendation flow when no issueUrl is provided. + * Uses embeddings to find similar issues based on user's prior work. + */ +export async function handleRecommendations({ + context, + options, +}: { + context: Context | ShallowContext; + options?: { topK?: number; threshold?: number }; +}): Promise { + try { + const recommendations = await getRecommendations({ context, options }); + + if (recommendations.length === 0) { + return Response.json({ ok: true, recommendations: [], note: "No prior embeddings found for user" }, { status: 200 }); + } + + return Response.json({ ok: true, recommendations }, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Embeddings search failed"; + return Response.json({ ok: false, reasons: [message] }, { status: 500 }); + } +} diff --git a/src/handlers/start/api/public-api.ts b/src/handlers/start/api/public-api.ts index d302bd8d..6dab338d 100644 --- a/src/handlers/start/api/public-api.ts +++ b/src/handlers/start/api/public-api.ts @@ -1,186 +1,185 @@ import { Env } from "../../../types/env"; -import { HttpStatusCode } from "../../../types/result-types"; -import { evaluateStartEligibility } from "../evaluate-eligibility"; -import { performAssignment } from "../perform-assignment"; -import { verifySupabaseJwt, resolveLoginFromSupabaseIssues } from "./helpers/auth"; +import { verifySupabaseJwt, extractJwtFromHeader } from "./helpers/auth"; import { rateLimit, getClientId } from "./helpers/rate-limit"; -import { parseIssueUrl } from "./helpers/parsers"; -import { buildContext } from "./helpers/context-builder"; -import { getRecommendations } from "./helpers/recommendations"; +import { buildShallowContextObject } from "./helpers/context-builder"; import { StartBody } from "./helpers/types"; +import { isDevelopment } from "../../../utils/is-dev-env"; +import { User } from "@supabase/supabase-js"; +import { handleRecommendations } from "./directory-task-recommendations"; +import { handleValidateOrExecute } from "./validate-or-execute"; /** - * Handles the recommendation flow when no issueUrl is provided. - * Uses embeddings to find similar issues based on user's prior work. + * Main handler for the public start API endpoint. + * Supports three modes: + * 1. Recommendations: when issueUrl is omitted + * 2. Validate: validates eligibility without performing assignment + * 3. Execute: validates and performs assignment + * + * @param request - HTTP request object + * @param env - Environment variables + * @returns HTTP response with appropriate status and body */ -async function handleRecommendations(env: Env, userId: number, recommend?: { topK?: number; threshold?: number }): Promise { +export async function handlePublicStart(request: Request, env: Env): Promise { try { - const recommendations = await getRecommendations(env, userId, recommend); + // Validate request method + const methodError = validateRequestMethod(request); + if (methodError) return methodError; + + // Authenticate request + const { user, error: authError } = await authenticateRequest(request, env); + if (authError) return authError; + + // Verify user authorization + if (!user) { + return Response.json({ ok: false, reasons: ["Unauthorized"] }, { status: 401 }); + } + + // Parse request body + const { body, error: parseError } = await parseRequestBody(request); + if (parseError) return parseError; + if (!body) return Response.json({ ok: false, reasons: ["Invalid request body"] }, { status: 400 }); + + // Validate body fields + const validationError = validateBodyFields(body); + if (validationError) return validationError; + + const { userId, issueUrl, mode = "validate", recommend } = body; - if (recommendations.length === 0) { - return Response.json({ ok: true, recommendations: [], note: "No prior embeddings found for user" }, { status: 200 }); + // Apply rate limiting + const rateLimitError = applyRateLimit(request, userId, mode); + if (rateLimitError) return rateLimitError; + + // Extract user access token + const { token: userAccessToken, error: tokenError } = extractUserAccessToken(body, user); + if (tokenError) return tokenError; + if (!userAccessToken) return Response.json({ ok: false, reasons: ["Missing user access token"] }, { status: 401 }); + + // Build context + const context = await buildShallowContextObject({ env, userAccessToken }); + + // Route to appropriate handler + if (!issueUrl) { + return await handleRecommendations({ context, options: recommend }); } - return Response.json({ ok: true, recommendations }, { status: 200 }); + return await handleValidateOrExecute({ context, mode, issueUrl }); } catch (error) { - const message = error instanceof Error ? error.message : "Embeddings search failed"; - return Response.json({ ok: false, reasons: [message] }, { status: 500 }); + return handleError(error); } } /** - * Handles the validate or execute flow for a specific issue. - * Validates eligibility and optionally performs assignment. + * Validates that the request method is POST. */ -async function handleValidateOrExecute( - env: Env, - issueUrl: string, - userId: number, - teammates: string[], - mode: "validate" | "execute", - loginFromBody?: string -): Promise { - const { owner, repo, issue_number } = parseIssueUrl(issueUrl); - - // Resolve sender login from Supabase issues cache - let senderLogin = await resolveLoginFromSupabaseIssues(env, userId); - - // In development, allow fallback to login from request body - if (!senderLogin && loginFromBody) { - senderLogin = loginFromBody; +function validateRequestMethod(request: Request): Response | null { + if (request.method !== "POST") { + return new Response(null, { status: 405 }); } + return null; +} - if (!senderLogin) { - return Response.json({ ok: false, reasons: ["Unable to resolve GitHub login for userId. Provide 'login' in request body."] }, { status: 400 }); +/** + * Authenticates the request and returns the user if successful. + * Returns null in development mode or if JWT verification succeeds. + */ +async function authenticateRequest(request: Request, env: Env): Promise<{ user: User | null; error: Response | null }> { + const jwt = extractJwtFromHeader(request); + const isDev = isDevelopment(); + + if (!jwt && !isDev) { + return { + user: null, + error: Response.json({ ok: false, reasons: ["Missing Authorization header"] }, { status: 401 }), + }; } - // Build context - const context = await buildContext(env, owner, repo, issue_number, senderLogin, userId); - const issue = context.payload.issue; - const sender = context.payload.sender; - - // Evaluate eligibility - const preflight = await evaluateStartEligibility(context, issue, sender, teammates); - - if (mode === "validate") { - const status = preflight.ok ? 200 : 400; - return Response.json( - { - ok: preflight.ok, - reasons: preflight.errors.map((e) => e.logMessage.raw), - warnings: preflight.warnings, - computed: preflight.computed, - assignedIssues: preflight.computed.assignedIssues, - }, - { status } - ); + if (isDev) { + console.log("Development mode: Bypassing JWT verification"); + return { user: null, error: null }; } - // Execute mode - check eligibility first - if (!preflight.ok) { - return Response.json( - { - ok: false, - reasons: preflight.errors.map((e) => e.logMessage.raw), - warnings: preflight.warnings, - assignedIssues: preflight.computed.assignedIssues, - computed: preflight.computed, - }, - { status: 400 } - ); + if (jwt) { + const user = await verifySupabaseJwt(env, jwt); + return { user, error: null }; } - // Perform assignment - try { - const result = await performAssignment(context, issue, sender, preflight.computed.toAssign); - return Response.json( - { - ok: result.status === HttpStatusCode.OK, - content: result.content, - metadata: preflight.computed, - }, - { status: 200 } - ); - } catch (error) { - const reason = error instanceof Error ? error.message : "Start failed"; - return Response.json({ ok: false, reasons: [reason] }, { status: 400 }); - } + return { user: null, error: null }; } /** - * Extracts JWT token from Authorization header. - * Returns null if header is missing or malformed. + * Parses and validates the request body. */ -function extractJwtFromHeader(request: Request): string | null { - const auth = request.headers.get("authorization") || request.headers.get("Authorization"); - if (!auth || !auth.toLowerCase().startsWith("bearer ")) { - return null; +async function parseRequestBody(request: Request): Promise<{ body: StartBody | null; error: Response | null }> { + try { + const body = (await request.json()) as StartBody; + return { body, error: null }; + } catch { + return { + body: null, + error: Response.json({ ok: false, reasons: ["Invalid JSON body"] }, { status: 400 }), + }; } - return auth.split(" ")[1]; } /** - * Main handler for the public start API endpoint. - * Supports three modes: - * 1. Recommendations: when issueUrl is omitted - * 2. Validate: validates eligibility without performing assignment - * 3. Execute: validates and performs assignment - * - * @param request - HTTP request object - * @param env - Environment variables - * @returns HTTP response with appropriate status and body + * Validates the required fields in the request body. */ -export async function handlePublicStart(request: Request, env: Env): Promise { - try { - // Method check - if (request.method !== "POST") { - return new Response(null, { status: 405 }); - } +function validateBodyFields(body: StartBody): Response | null { + const { userId, issueUrl, mode = "validate" } = body; - // Authentication - const jwt = extractJwtFromHeader(request); - const isDev = true; // Hardcoded for development testing + if (!userId) { + return Response.json({ ok: false, reasons: ["userId is required"] }, { status: 400 }); + } - if (!jwt && !isDev) { - return Response.json({ ok: false, reasons: ["Missing Authorization header"] }, { status: 401 }); - } + if (!issueUrl && mode !== "validate" && mode !== "execute") { + return Response.json({ ok: false, reasons: ["mode must be 'validate' or 'execute' when issueUrl is provided"] }, { status: 400 }); + } - // Only verify JWT if provided (in dev, jwt can be optional) - if (jwt) { - await verifySupabaseJwt(env, jwt); - } + return null; +} - // Parse and validate body - let body: StartBody; - try { - body = (await request.json()) as StartBody; - } catch { - return Response.json({ ok: false, reasons: ["Invalid JSON body"] }, { status: 400 }); - } +/** + * Applies rate limiting based on client ID, user ID, and mode. + */ +function applyRateLimit(request: Request, userId: number, mode: string): Response | null { + const clientId = getClientId(request); + const key = `${clientId}|${userId}|${mode}`; + const limit = mode === "execute" ? 3 : 10; + const rl = rateLimit(key, limit, 60_000); + + if (!rl.allowed) { + return Response.json({ ok: false, reasons: ["Rate limit exceeded"], resetAt: rl.resetAt }, { status: 429 }); + } - const { userId, issueUrl, teammates = [], mode = "validate", recommend, login } = body; - if (!userId) { - return Response.json({ ok: false, reasons: ["userId is required"] }, { status: 400 }); - } + return null; +} - // Rate limiting - const clientId = getClientId(request); - const key = `${clientId}|${userId}|${mode}`; - const limit = mode === "execute" ? 3 : 10; - const rl = rateLimit(key, limit, 60_000); - if (!rl.allowed) { - return Response.json({ ok: false, reasons: ["Rate limit exceeded"], resetAt: rl.resetAt }, { status: 429 }); - } +/** + * Extracts the user access token from the body or user metadata. + */ +function extractUserAccessToken(body: StartBody, user: User | null): { token: string | null; error: Response | null } { + if (body.userAccessToken) { + return { token: body.userAccessToken, error: null }; + } - // Route to appropriate handler - if (!issueUrl) { - return await handleRecommendations(env, userId, recommend); + if (user) { + const { access_token } = user.user_metadata as { access_token?: string }; + if (access_token) { + return { token: access_token, error: null }; } - - return await handleValidateOrExecute(env, issueUrl, userId, teammates, mode, login); - } catch (error) { - const message = error instanceof Error ? error.message : "Internal error"; - const status = error instanceof Error && error.message === "Unauthorized" ? 401 : 500; - return Response.json({ ok: false, reasons: [message] }, { status }); } + + return { + token: null, + error: Response.json({ ok: false, reasons: ["Missing user access token"] }, { status: 401 }), + }; +} + +/** + * Handles errors and returns an appropriate response. + */ +function handleError(error: unknown): Response { + const message = error instanceof Error ? error.message : "Internal error"; + const status = error instanceof Error && error.message === "Unauthorized" ? 401 : 500; + return Response.json({ ok: false, reasons: [message] }, { status }); } diff --git a/src/handlers/start/api/validate-or-execute.ts b/src/handlers/start/api/validate-or-execute.ts new file mode 100644 index 00000000..8fa6fbcf --- /dev/null +++ b/src/handlers/start/api/validate-or-execute.ts @@ -0,0 +1,95 @@ +import { HttpStatusCode } from "../../../types/result-types"; +import { evaluateStartEligibility } from "../evaluate-eligibility"; +import { performAssignment } from "../perform-assignment"; +import { createCommand, createPayload, ShallowContext } from "./helpers/context-builder"; +import { Context } from "../../../types"; +import { parseIssueUrl } from "./helpers/parsers"; + +/** + * Handles the validate or execute flow for a specific issue. + * Validates eligibility and optionally performs assignment. + */ +export async function handleValidateOrExecute({ + context, + mode, + issueUrl, +}: { + context: ShallowContext; + mode: "validate" | "execute"; + issueUrl: string; +}): Promise { + if (!issueUrl) { + return Response.json( + { + ok: false, + reasons: ["issueUrl is required for validate or execute mode."], + }, + { status: 400 } + ); + } + + const { owner, repo, issue_number: issueNumber } = parseIssueUrl(issueUrl); + const issue = (await context.octokit.rest.issues.get({ owner, repo, issue_number: issueNumber })).data; + const repository = (await context.octokit.rest.repos.get({ owner, repo })).data; + const organization = repository.organization; + + // Build context + const ctx: Context<"issue_comment.created"> = { + ...context, + payload: createPayload({ + issue, + repository, + organization, + sender: context.payload.sender, + }) as Context<"issue_comment.created">["payload"], + command: createCommand([context.payload.sender.login || ""]), + organizations: organization ? [organization.login] : [], + }; + + // Evaluate eligibility + const preflight = await evaluateStartEligibility(ctx); + + if (mode === "validate") { + const status = preflight.ok ? 200 : 400; + return Response.json( + { + ok: preflight.ok, + reasons: preflight.errors.map((e) => e.logMessage.raw), + warnings: preflight.warnings, + computed: preflight.computed, + assignedIssues: preflight.computed.assignedIssues, + }, + { status } + ); + } + + // Execute mode - check eligibility first + if (!preflight.ok) { + return Response.json( + { + ok: false, + reasons: preflight.errors.map((e) => e.logMessage.raw), + warnings: preflight.warnings, + assignedIssues: preflight.computed.assignedIssues, + computed: preflight.computed, + }, + { status: 400 } + ); + } + + // Perform assignment + try { + const result = await performAssignment(ctx, preflight.computed.toAssign); + return Response.json( + { + ok: result.status === HttpStatusCode.OK, + content: result.content, + metadata: preflight.computed, + }, + { status: 200 } + ); + } catch (error) { + const reason = error instanceof Error ? error.message : "Start failed"; + return Response.json({ ok: false, reasons: [reason] }, { status: 400 }); + } +} From 6c67e58ed97ff8b63f82425f33e237aad055a64b Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:52:15 +0000 Subject: [PATCH 14/56] refactor: update getRecommendations to use ShallowContext and improve parameter handling --- .../start/api/helpers/context-builder.ts | 217 ++++++++++-------- .../start/api/helpers/recommendations.ts | 41 ++-- 2 files changed, 140 insertions(+), 118 deletions(-) diff --git a/src/handlers/start/api/helpers/context-builder.ts b/src/handlers/start/api/helpers/context-builder.ts index 8e668f3d..a98e3b3c 100644 --- a/src/handlers/start/api/helpers/context-builder.ts +++ b/src/handlers/start/api/helpers/context-builder.ts @@ -3,118 +3,133 @@ import { Context } from "../../../../types/context"; import { AssignedIssueScope, PluginSettings, Role } from "../../../../types/plugin-input"; import { Env } from "../../../../types/env"; import { createAdapters } from "../../../../adapters/index"; -import { createRepoOctokit } from "./octokit"; +import { createUserOctokit } from "./octokit"; import { MAX_CONCURRENT_DEFAULTS } from "../../../../utils/constants"; -import { LogLevel, Logs } from "@ubiquity-os/ubiquity-os-logger"; +import { LogLevel, LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger"; +import { Issue, Organization, Repository } from "../../../../types"; -export async function buildContext( - env: Env, - owner: string, - repo: string, - issueNumber: number, - senderLogin: string, - userId: number -): Promise> { - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); - const repoOctokit = await createRepoOctokit(env, owner, repo); - - const issue = (await repoOctokit.rest.issues.get({ owner, repo, issue_number: issueNumber })).data as Context<"issue_comment.created">["payload"]["issue"]; - const repository = (await repoOctokit.rest.repos.get({ owner, repo })).data as Context<"issue_comment.created">["payload"]["repository"]; +export type ShallowContext = Omit, "repository" | "issue" | "organization" | "organizations" | "payload"> & { + env: Env; + payload: { + sender: { + login?: string; + id: number; + }; + }; +}; - let organization: Context["payload"]["organization"] | undefined; - if (repository.owner.type === "Organization") { - organization = (await repoOctokit.rest.orgs.get({ org: owner })).data as Context<"issue_comment.created">["payload"]["organization"]; - } +/** + * Builds a partner context-free context object + * + * DOES NOT load payload, repository, issue, organization. + * These must be injected once you know them. + */ +export async function buildShallowContextObject({ env, userAccessToken }: { env: Env; userAccessToken: string }): Promise { + const { octokit, supabase } = await initializeClients(env, userAccessToken); + const userData = await octokit.rest.users.getAuthenticated(); - // async function loadConfig(owner: string, repo: string): Promise { - // // try { - // // let configFile = await repoOctokit.rest.repos.getContent({ - // // owner, - // // repo, - // // path: isDevelopment() ? DEV_CONFIG_FULL_PATH : CONFIG_FULL_PATH - // // }); - // // if (configFile && configFile.data){ - // // let content; - // // if ("content" in configFile.data) { - // // content = configFile.data.content; - // // } else if (typeof configFile.data === "string") { - // // content = configFile.data; - // // } else { - // // throw new Error("Invalid config file"); - // // } - // // const parsedConfig = YAML.parse(content); - // // return parsedConfig as PluginSettings; - // // } - // // } catch (error) { - // // console.log("Error loading config file, using defaults:", error); - // // } - // // return null as unknown as PluginSettings; - // } + const ctx: ShallowContext = { + env, + octokit, + logger: createLogger(env), + config: getDefaultConfig(), + command: createCommand([userData.data.login]), + eventName: "issue_comment.created" as const, + commentHandler: createCommentHandler({ + userOctokit: octokit, + }), + payload: { + sender: { + login: userData.data.login, + id: userData.data.id, + }, + }, + adapters: {} as unknown as Context["adapters"], + }; - // Try to load the .ubiquity-os-config.yml file from the repository - let config = null as PluginSettings | null; + ctx.adapters = createAdapters(supabase, ctx as Context); + return ctx; +} - // config = await loadConfig(owner, repo); +export function createCommentHandler({ userOctokit }: { userOctokit: Awaited> }): Context["commentHandler"] { + return { + postComment: async (context: Context<"issue_comment.created">, msg: LogReturn | string) => { + const body = typeof msg === "string" ? msg : msg?.logMessage?.raw || String(msg); + await userOctokit.rest.issues.createComment({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.issue.number, + body, + }); + }, + } as unknown as Context["commentHandler"]; +} - // if(!config) { - // config = await loadConfig(CONFIG_ORG_REPO, owner); - // } +export function createPayload({ + issue, + repository, + organization, + sender, +}: { + sender: { login?: string; id: number }; + issue: Issue; + repository: Repository; + organization: Organization; +}): Context["payload"] { + return { + action: "created", + issue, + repository, + organization, + sender, + comment: { + issue_url: issue.url, + user: sender, + body: "/start", + }, + } as unknown as Context["payload"]; +} - if (!config) { - config = { - reviewDelayTolerance: "3 Days", - taskStaleTimeoutDuration: "30 Days", - maxConcurrentTasks: MAX_CONCURRENT_DEFAULTS, - startRequiresWallet: false, - assignedIssueScope: AssignedIssueScope.ORG, - emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", - rolesWithReviewAuthority: [Role.ADMIN, Role.OWNER, Role.MEMBER], - requiredLabelsToStart: [ - { name: "Priority: 1 (Normal)", allowedRoles: ["collaborator", "contributor"] }, - { name: "Priority: 2 (Medium)", allowedRoles: ["collaborator", "contributor"] }, - { name: "Priority: 3 (High)", allowedRoles: ["collaborator", "contributor"] }, - { name: "Priority: 4 (Urgent)", allowedRoles: ["collaborator", "contributor"] }, - { name: "Priority: 5 (Emergency)", allowedRoles: ["collaborator", "contributor"] }, - ], - taskAccessControl: { - usdPriceMax: { - collaborator: -1, - contributor: -1, - }, - }, - } as PluginSettings; - } +export function createCommand(assignees: string[]): Context["command"] { + return { + name: "start", + parameters: { + teammates: assignees, + }, + }; +} - const context: Context = { - logger: new Logs((env.LOG_LEVEL as LogLevel) ?? "info"), - env, - config, - command: { - name: "start", - parameters: { - teammates: [], +function getDefaultConfig(): PluginSettings { + return { + reviewDelayTolerance: "3 Days", + taskStaleTimeoutDuration: "30 Days", + maxConcurrentTasks: MAX_CONCURRENT_DEFAULTS, + startRequiresWallet: false, + assignedIssueScope: AssignedIssueScope.ORG, + emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", + rolesWithReviewAuthority: [Role.ADMIN, Role.OWNER, Role.MEMBER], + requiredLabelsToStart: [ + { name: "Priority: 1 (Normal)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 2 (Medium)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 3 (High)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 4 (Urgent)", allowedRoles: ["collaborator", "contributor"] }, + { name: "Priority: 5 (Emergency)", allowedRoles: ["collaborator", "contributor"] }, + ], + taskAccessControl: { + usdPriceMax: { + collaborator: -1, + contributor: -1, }, }, - eventName: "issue_comment.created", - payload: { - action: "created", - issue, - repository, - organization, - sender: { login: senderLogin, id: userId }, - } as unknown as Context["payload"], - octokit: repoOctokit as unknown as Context["octokit"], - adapters: {} as unknown as Context["adapters"], - organizations: [owner], - commentHandler: { - postComment: async () => { - // const body = typeof message === "string" ? message : message?.logMessage?.raw || String(message); - // await repoOctokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); - }, - } as unknown as Context["commentHandler"], - }; + } as PluginSettings; +} - context.adapters = createAdapters(supabase, context); +function createLogger(env: Env): Logs { + return new Logs((env.LOG_LEVEL as LogLevel) ?? "info"); +} - return context as Context<"issue_comment.created">; +async function initializeClients(env: Env, userAccessToken: string) { + const octokit = await createUserOctokit(userAccessToken); + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + return { octokit, supabase }; } diff --git a/src/handlers/start/api/helpers/recommendations.ts b/src/handlers/start/api/helpers/recommendations.ts index 7ff39b4b..11edb4bb 100644 --- a/src/handlers/start/api/helpers/recommendations.ts +++ b/src/handlers/start/api/helpers/recommendations.ts @@ -1,7 +1,6 @@ import { createClient } from "@supabase/supabase-js"; -import { Env } from "../../../../types/env"; import { Context } from "../../../../types/context"; -import { createRepoOctokit } from "./octokit"; +import { ShallowContext } from "./context-builder"; export type Recommendation = { issueUrl: string; @@ -11,14 +10,24 @@ export type Recommendation = { title: string; }; -export async function getRecommendations(env: Env, userId: number, options: { topK?: number; threshold?: number } = {}): Promise { - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); - const threshold = options.threshold ?? 0.6; // 60% similarity threshold - const topK = options.topK ?? 5; +export async function getRecommendations({ + context, + options, +}: { + context: Context | ShallowContext; + options?: { topK?: number; threshold?: number }; +}): Promise { + const supabase = createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY); + const threshold = options?.threshold ?? 0.6; // 60% similarity threshold + const topK = options?.topK ?? 5; + const { + octokit, + payload: { sender }, + } = context; // Get user's completed/authored issues with embeddings // filter out issues that have assignees - const { data: authored } = await supabase.from("issues").select("embedding,payload").eq("author_id", userId).limit(100); + const { data: authored } = await supabase.from("issues").select("embedding,payload").eq("author_id", sender?.id).limit(100); const vectors = (authored || []) .map((r) => { @@ -31,7 +40,7 @@ export async function getRecommendations(env: Env, userId: number, options: { to .filter((v: number[] | null): v is number[] => Array.isArray(v) && v.length > 0); if (!vectors.length) { - console.error("No embeddings found for user", { userId }); + console.error("No embeddings found for user", { userId: sender?.id }); return []; } @@ -43,7 +52,7 @@ export async function getRecommendations(env: Env, userId: number, options: { to // Find similar issues const { data: similar, error } = await supabase.rpc("find_similar_issues_annotate", { - current_id: `user-${userId}`, + current_id: `user-${sender?.id}`, query_embedding: queryEmbedding, threshold, top_k: topK, @@ -69,17 +78,15 @@ export async function getRecommendations(env: Env, userId: number, options: { to } try { - const octokit = await createRepoOctokit(env, org, repo); - console.log(`Fetching issue ${org}/${repo}#${number}`); const issue = (await octokit.rest.issues.get({ owner: org, repo, issue_number: number })).data as Context<"issue_comment.created">["payload"]["issue"]; - const isOpen = issue.state === "open"; - const isUnassigned = !(issue.assignees && issue.assignees.length); + // const isOpen = issue.state === "open"; + // const isUnassigned = !(issue.assignees && issue.assignees.length); - if (isOpen && isUnassigned) { - const href = `https://www.github.com/${org}/${repo}/issues/${number}`; - results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); - } + // if (isOpen && isUnassigned) { + const href = `https://www.github.com/${org}/${repo}/issues/${number}`; + results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); + // } } catch (err) { // Skip issues we can't access console.warn(`Failed to fetch issue ${org}/${repo}#${number}:`, err); From 2b290cd69446f24fb0b72166ced6609983c426e9 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:53:34 +0000 Subject: [PATCH 15/56] feat: add CORS support for public API and enhance environment variable handling --- src/types/env.ts | 4 +++ src/worker.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/types/env.ts b/src/types/env.ts index 63efb234..ffebf17c 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -28,6 +28,10 @@ export const envSchema = T.Object({ KERNEL_PUBLIC_KEY: T.Optional(T.String()), LOG_LEVEL: T.Optional(T.String()), XP_SERVICE_BASE_URL: T.Optional(T.String()), + /** + * Comma-separated list of allowed origins for public API CORS. Example: "http://localhost:3000,http://127.0.0.1:5173" + */ + PUBLIC_API_ALLOWED_ORIGINS: T.Optional(T.String()), }); export type Env = StaticDecode; diff --git a/src/worker.ts b/src/worker.ts index bbe6023c..846eb1c2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,8 +11,46 @@ import { Env, envSchema } from "./types/env"; import { PluginSettings, pluginSettingsSchema } from "./types/plugin-input"; import { handlePublicStart } from "./handlers/start/api/public-api"; +const START_API_PATH = "/public/start"; + +function computeAllowedOrigin(origin: string | null, env: Env): string | null { + if (!origin) return null; + const configured = (env.PUBLIC_API_ALLOWED_ORIGINS || "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + if (configured.includes("*")) return origin; // wildcard allowed (no credentials usage expected) + if (configured.length > 0 && configured.includes(origin)) return origin; + // Dev convenience: allow localhost & 127.0.0.1 if not explicitly set + if (configured.length === 0 && /^(http:\/\/)?(localhost|127\.0\.0\.1)/.test(origin)) return origin; + return null; +} + +function applyCors(request: Request, response: Response, env: Env): Response { + const origin = request.headers.get("origin") || request.headers.get("Origin"); + const allowed = computeAllowedOrigin(origin, env); + if (!allowed) return response; // Not adding headers if origin not allowed + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", allowed); + headers.set("Vary", "Origin"); + return new Response(response.body, { status: response.status, headers }); +} + export default { async fetch(request: Request, env: Env, executionCtx?: ExecutionContext) { + // Merge runtime-provided env with process.env for local dev (e.g., `bun dev`) + const mergedEnv: Env = { + APP_ID: (env.APP_ID ?? process.env.APP_ID) as string, + APP_PRIVATE_KEY: (env.APP_PRIVATE_KEY ?? process.env.APP_PRIVATE_KEY) as string, + SUPABASE_URL: (env.SUPABASE_URL ?? process.env.SUPABASE_URL) as string, + SUPABASE_KEY: (env.SUPABASE_KEY ?? process.env.SUPABASE_KEY) as string, + BOT_USER_ID: (env.BOT_USER_ID ?? (process.env.BOT_USER_ID as unknown)) as number, + KERNEL_PUBLIC_KEY: (env.KERNEL_PUBLIC_KEY ?? process.env.KERNEL_PUBLIC_KEY) as string | undefined, + LOG_LEVEL: (env.LOG_LEVEL ?? process.env.LOG_LEVEL) as string | undefined, + XP_SERVICE_BASE_URL: (env.XP_SERVICE_BASE_URL ?? process.env.XP_SERVICE_BASE_URL) as string | undefined, + PUBLIC_API_ALLOWED_ORIGINS: env.PUBLIC_API_ALLOWED_ORIGINS ?? process.env.PUBLIC_API_ALLOWED_ORIGINS, + } as Env; + const honoApp = createPlugin( (context) => { return startStopTask({ @@ -26,17 +64,42 @@ export default { envSchema: envSchema, postCommentOnError: true, settingsSchema: pluginSettingsSchema, - logLevel: (env.LOG_LEVEL as LogLevel) ?? LOG_LEVEL.INFO, - kernelPublicKey: env.KERNEL_PUBLIC_KEY as string, + logLevel: (mergedEnv.LOG_LEVEL as LogLevel) ?? LOG_LEVEL.INFO, + kernelPublicKey: mergedEnv.KERNEL_PUBLIC_KEY as string, bypassSignatureVerification: process.env.NODE_ENV === "local", } ); - // Public API route - honoApp.post("/public/start", async (c) => { - return await handlePublicStart(c.req.raw as Request, env); + // CORS preflight for public API + honoApp.options(START_API_PATH, (c) => { + const origin = c.req.header("origin") || c.req.header("Origin") || null; + const allowed = computeAllowedOrigin(origin, mergedEnv); + if (!allowed) { + return new Response(null, { status: 403 }); + } + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": allowed, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }, + }); + }); + + // Public API route with CORS applied + honoApp.post(START_API_PATH, async (c) => { + const res = await handlePublicStart(c.req.raw as Request, mergedEnv); + return applyCors(c.req.raw as Request, res, mergedEnv); }); - return honoApp.fetch(request, env, executionCtx); + // Global fetch: attach CORS if response is for public route & origin allowed + const response = await honoApp.fetch(request, mergedEnv, executionCtx); + if (new URL(request.url).pathname === START_API_PATH) { + return applyCors(request, response, mergedEnv); + } + return response; }, }; From 59e450adfd2fd9af8280791bcc59f351ff1fb60b Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:55:06 +0000 Subject: [PATCH 16/56] test: enhance collaborator tests to validate pull request user assignment and repository ownership --- tests/start.test.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/start.test.ts b/tests/start.test.ts index cd147dd3..ffe80de4 100644 --- a/tests/start.test.ts +++ b/tests/start.test.ts @@ -152,7 +152,15 @@ describe("Collaborator tests", () => { const { startStopTask } = await import("../src/plugin"); await startStopTask(context); // Make sure the author is the one who starts and not the sender who modified the comment - expect(startTask).toHaveBeenCalledWith(expect.anything(), expect.anything(), { id: 1, login: userLogin }, []); + expect(startTask).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + pull_request: expect.objectContaining({ + user: expect.objectContaining({ login: userLogin }), + }), + }), + }) + ); startTask.mockClear(); }); @@ -183,7 +191,7 @@ describe("Collaborator tests", () => { id: 2, name: commandStartStop, owner: { - login: "ubiquity-os-marketplace", + login: ubiquityOsMarketplace, }, } as unknown as Context<"pull_request.edited">["payload"]["repository"]; context.octokit = { @@ -242,10 +250,18 @@ describe("Collaborator tests", () => { })); const { startStopTask } = await import("../src/plugin"); await startStopTask(context); - expect(startTask.mock.calls[0][0]).toMatchObject({ payload: { issue, repository, organization: repository?.owner } }); - expect(startTask.mock.calls[0][1]).toMatchObject({ id: 1 }); - expect(startTask.mock.calls[0][2]).toMatchObject({ id: 1, login: "whilefoo" }); - expect(startTask.mock.calls[0][3]).toEqual([]); + // expect the task to be assigned to whilefoo in the new organization + expect(startTask).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + pull_request: expect.objectContaining({ + user: expect.objectContaining({ login: "whilefoo" }), + html_url: expect.stringContaining(ubiquityOsMarketplace), + }), + }), + }) + ); + startTask.mockReset(); }); }); From 3b4630327d85a07939449889cdbe9c310ec41c6d Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 7 Nov 2025 23:09:33 +0000 Subject: [PATCH 17/56] feat: enhance JWT verification and authentication flow for Supabase and GitHub tokens --- src/handlers/start/api/helpers/auth.ts | 107 ++++++++++++++++-- .../start/api/helpers/context-builder.ts | 8 +- src/handlers/start/api/public-api.ts | 61 +++------- 3 files changed, 120 insertions(+), 56 deletions(-) diff --git a/src/handlers/start/api/helpers/auth.ts b/src/handlers/start/api/helpers/auth.ts index 26b425fe..9952d3f6 100644 --- a/src/handlers/start/api/helpers/auth.ts +++ b/src/handlers/start/api/helpers/auth.ts @@ -1,24 +1,95 @@ -import { createClient, User } from "@supabase/supabase-js"; +import { createClient, SupabaseClient, User } from "@supabase/supabase-js"; import { Env } from "../../../../types/env"; import { ShallowContext } from "./context-builder"; +import { Octokit } from "@octokit/rest"; +import { StartBody } from "./types"; /** * Verifies Supabase JWT token. */ -export async function verifySupabaseJwt(env: Env, jwt: string): Promise { - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); - const { data, error } = await supabase.auth.getUser(jwt); - if (error || !data?.user) { - throw new Error("Unauthorized"); +export async function verifySupabaseJwt(body: StartBody, env: Env, jwt: string): Promise<{ user: User, accessToken: string | null }> { + const trimmedJwt = jwt.trim(); + + if (!trimmedJwt) { + throw new Error("Unauthorized: Empty JWT"); + } + + const supabase: SupabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); + const accessToken = extractUserAccessToken({ body }); + + let user: User; + + function isValidGitAccessToken(token: string) { + return token.startsWith("ghu_") || token.startsWith("ghs_") || token.startsWith("gho_"); + } + + let isOauthTokenValid, isPayloadTokenValid: boolean; + isOauthTokenValid = isValidGitAccessToken(trimmedJwt); + isPayloadTokenValid = accessToken ? isValidGitAccessToken(accessToken) : false; + + if (isPayloadTokenValid && accessToken) { + user = await verifyGitHubToken(supabase, accessToken); + } else if (isOauthTokenValid) { + user = await verifyGitHubToken(supabase, trimmedJwt); + } else if (trimmedJwt.split(".").length === 3) { + user = await verifySupabaseToken(supabase, trimmedJwt); + } else { + throw new Error("Unauthorized: Malformed JWT"); + } + + return { + user, + accessToken, } - return data.user; +} + +async function verifyGitHubToken(supabase: SupabaseClient, token: string): Promise { + const octo = new Octokit({ auth: token }); + const { data: user } = await octo.users.getAuthenticated(); + + const { data: dbUser, error } = await supabase + .from("users") + .select("*") + .eq("id", user.id) + .single(); + + if (error || !dbUser) { + throw new Error("Unauthorized: GitHub token not linked to any user"); + } + + return { ...dbUser, accessToken: token }; +} + +async function verifySupabaseToken(supabase: SupabaseClient, token: string): Promise { + const { data: userOauthData, error } = await supabase.auth.getUser(token); + + if (error || !userOauthData?.user) { + throw new Error("Unauthorized: Invalid JWT, expired, or user not found"); + } + + const userGithubId = userOauthData.user.user_metadata?.provider_id; + if (!userGithubId) { + throw new Error("Unauthorized: User GitHub ID not found in OAuth metadata"); + } + + const { data: dbUser, error: dbError } = await supabase + .from("users") + .select("*") + .eq("id", userGithubId) + .single(); + + if (dbError || !dbUser) { + throw new Error("Unauthorized: User not found in database"); + } + + return { ...dbUser, accessToken: token }; } /** * Resolves GitHub login from Supabase issues cache. */ export async function resolveLoginFromSupabaseIssues(context: ShallowContext, userId: number): Promise { - const supabase = createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY); + const supabase: SupabaseClient = createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY); const { data } = await supabase.from("issues").select("payload").eq("author_id", userId).order("modified_at", { ascending: false }).limit(1); const payload = data && data[0]?.payload; return payload?.user?.login ?? null; @@ -35,3 +106,23 @@ export function extractJwtFromHeader(request: Request): string | null { } return auth.split(" ")[1]; } + + + +/** + * Extracts the user access token from the body or user metadata. + */ +function extractUserAccessToken({ body, user }: { body?: StartBody, user?: User | null }): string | null { + if (body?.userAccessToken) { + return body.userAccessToken; + } + + if (user) { + const { access_token } = user.user_metadata as { access_token?: string }; + if (access_token) { + return access_token; + } + } + + return null; +} \ No newline at end of file diff --git a/src/handlers/start/api/helpers/context-builder.ts b/src/handlers/start/api/helpers/context-builder.ts index a98e3b3c..d40c967c 100644 --- a/src/handlers/start/api/helpers/context-builder.ts +++ b/src/handlers/start/api/helpers/context-builder.ts @@ -24,8 +24,8 @@ export type ShallowContext = Omit, "repository" * DOES NOT load payload, repository, issue, organization. * These must be injected once you know them. */ -export async function buildShallowContextObject({ env, userAccessToken }: { env: Env; userAccessToken: string }): Promise { - const { octokit, supabase } = await initializeClients(env, userAccessToken); +export async function buildShallowContextObject({ env, accessToken }: { env: Env; accessToken: string }): Promise { + const { octokit, supabase } = await initializeClients(env, accessToken); const userData = await octokit.rest.users.getAuthenticated(); const ctx: ShallowContext = { @@ -128,8 +128,8 @@ function createLogger(env: Env): Logs { return new Logs((env.LOG_LEVEL as LogLevel) ?? "info"); } -async function initializeClients(env: Env, userAccessToken: string) { - const octokit = await createUserOctokit(userAccessToken); +async function initializeClients(env: Env, accessToken: string) { + const octokit = await createUserOctokit(accessToken); const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); return { octokit, supabase }; } diff --git a/src/handlers/start/api/public-api.ts b/src/handlers/start/api/public-api.ts index 6dab338d..5c376d10 100644 --- a/src/handlers/start/api/public-api.ts +++ b/src/handlers/start/api/public-api.ts @@ -25,15 +25,6 @@ export async function handlePublicStart(request: Request, env: Env): Promise { +async function authenticateRequest(body: StartBody, request: Request, env: Env) { const jwt = extractJwtFromHeader(request); const isDev = isDevelopment(); if (!jwt && !isDev) { + console.log("Unauthorized: !JWT provided in production"); return { user: null, + accessToken: null, error: Response.json({ ok: false, reasons: ["Missing Authorization header"] }, { status: 401 }), }; } - if (isDev) { - console.log("Development mode: Bypassing JWT verification"); - return { user: null, error: null }; - } - if (jwt) { - const user = await verifySupabaseJwt(env, jwt); - return { user, error: null }; + const { user, accessToken } = await verifySupabaseJwt(body, env, jwt); + return { user, accessToken, error: null }; } - return { user: null, error: null }; + return { user: null, accessToken: null, error: null }; } /** @@ -154,26 +147,6 @@ function applyRateLimit(request: Request, userId: number, mode: string): Respons return null; } -/** - * Extracts the user access token from the body or user metadata. - */ -function extractUserAccessToken(body: StartBody, user: User | null): { token: string | null; error: Response | null } { - if (body.userAccessToken) { - return { token: body.userAccessToken, error: null }; - } - - if (user) { - const { access_token } = user.user_metadata as { access_token?: string }; - if (access_token) { - return { token: access_token, error: null }; - } - } - - return { - token: null, - error: Response.json({ ok: false, reasons: ["Missing user access token"] }, { status: 401 }), - }; -} /** * Handles errors and returns an appropriate response. From 9da5559ba283c8c9588771c7f9991ed63476a3d5 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 8 Nov 2025 01:52:01 +0000 Subject: [PATCH 18/56] refactor: improve JWT verification and error handling in authentication flow --- src/handlers/start/api/helpers/auth.ts | 59 ++++++++----------- .../start/api/helpers/recommendations.ts | 23 ++++---- src/handlers/start/api/public-api.ts | 25 +++----- 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/handlers/start/api/helpers/auth.ts b/src/handlers/start/api/helpers/auth.ts index 9952d3f6..1d8da5ad 100644 --- a/src/handlers/start/api/helpers/auth.ts +++ b/src/handlers/start/api/helpers/auth.ts @@ -7,7 +7,7 @@ import { StartBody } from "./types"; /** * Verifies Supabase JWT token. */ -export async function verifySupabaseJwt(body: StartBody, env: Env, jwt: string): Promise<{ user: User, accessToken: string | null }> { +export async function verifySupabaseJwt(body: StartBody, env: Env, jwt: string): Promise<{ user: User; accessToken: string | null }> { const trimmedJwt = jwt.trim(); if (!trimmedJwt) { @@ -15,43 +15,42 @@ export async function verifySupabaseJwt(body: StartBody, env: Env, jwt: string): } const supabase: SupabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); - const accessToken = extractUserAccessToken({ body }); - let user: User; function isValidGitAccessToken(token: string) { return token.startsWith("ghu_") || token.startsWith("ghs_") || token.startsWith("gho_"); } - let isOauthTokenValid, isPayloadTokenValid: boolean; - isOauthTokenValid = isValidGitAccessToken(trimmedJwt); - isPayloadTokenValid = accessToken ? isValidGitAccessToken(accessToken) : false; + const isOauthTokenValid = isValidGitAccessToken(trimmedJwt); + const initialAccessToken = extractUserAccessToken({ body }); + const isPayloadTokenValid = initialAccessToken ? isValidGitAccessToken(initialAccessToken) : false; - if (isPayloadTokenValid && accessToken) { - user = await verifyGitHubToken(supabase, accessToken); + if (isPayloadTokenValid && initialAccessToken) { + user = await verifyGitHubToken(supabase, initialAccessToken); } else if (isOauthTokenValid) { user = await verifyGitHubToken(supabase, trimmedJwt); - } else if (trimmedJwt.split(".").length === 3) { - user = await verifySupabaseToken(supabase, trimmedJwt); } else { - throw new Error("Unauthorized: Malformed JWT"); + // Fallback: treat as Supabase JWT (even if it doesn't look like a JWT) and let Supabase validate it + user = await verifySupabaseToken(supabase, trimmedJwt); + } + + // Prefer body.userAccessToken then fall back to OAuth token when provided + let finalAccessToken = extractUserAccessToken({ body, user }); + if (!finalAccessToken && isOauthTokenValid) { + finalAccessToken = trimmedJwt; } return { user, - accessToken, - } + accessToken: finalAccessToken ?? null, + }; } async function verifyGitHubToken(supabase: SupabaseClient, token: string): Promise { - const octo = new Octokit({ auth: token }); - const { data: user } = await octo.users.getAuthenticated(); + const octokit = new Octokit({ auth: token }); + const { data: user } = await octokit.users.getAuthenticated(); - const { data: dbUser, error } = await supabase - .from("users") - .select("*") - .eq("id", user.id) - .single(); + const { data: dbUser, error } = await supabase.from("users").select("*").eq("id", user.id).single(); if (error || !dbUser) { throw new Error("Unauthorized: GitHub token not linked to any user"); @@ -72,11 +71,7 @@ async function verifySupabaseToken(supabase: SupabaseClient, token: string): Pro throw new Error("Unauthorized: User GitHub ID not found in OAuth metadata"); } - const { data: dbUser, error: dbError } = await supabase - .from("users") - .select("*") - .eq("id", userGithubId) - .single(); + const { data: dbUser, error: dbError } = await supabase.from("users").select("*").eq("id", userGithubId).single(); if (dbError || !dbUser) { throw new Error("Unauthorized: User not found in database"); @@ -107,22 +102,18 @@ export function extractJwtFromHeader(request: Request): string | null { return auth.split(" ")[1]; } - - /** * Extracts the user access token from the body or user metadata. */ -function extractUserAccessToken({ body, user }: { body?: StartBody, user?: User | null }): string | null { +function extractUserAccessToken({ body, user }: { body?: StartBody; user?: User | null }): string | null { if (body?.userAccessToken) { return body.userAccessToken; } - if (user) { - const { access_token } = user.user_metadata as { access_token?: string }; - if (access_token) { - return access_token; - } + const accessToken = user?.user_metadata?.access_token; + if (typeof accessToken === "string" && accessToken.length > 0) { + return accessToken; } return null; -} \ No newline at end of file +} diff --git a/src/handlers/start/api/helpers/recommendations.ts b/src/handlers/start/api/helpers/recommendations.ts index 11edb4bb..ad71cc12 100644 --- a/src/handlers/start/api/helpers/recommendations.ts +++ b/src/handlers/start/api/helpers/recommendations.ts @@ -40,7 +40,6 @@ export async function getRecommendations({ .filter((v: number[] | null): v is number[] => Array.isArray(v) && v.length > 0); if (!vectors.length) { - console.error("No embeddings found for user", { userId: sender?.id }); return []; } @@ -59,7 +58,6 @@ export async function getRecommendations({ }); if (error || !Array.isArray(similar)) { - console.error("Embeddings search failed", { error, similar }); throw new Error("Embeddings search failed"); } @@ -73,23 +71,24 @@ export async function getRecommendations({ const repo = payload?.repository?.name; const number = payload?.number ?? payload?.issue?.number; - if (!org || !repo || !number || payload?.assignees?.length) { + if (!org || !repo || !number || (payload?.assignees && payload.assignees.length)) { continue; } try { const issue = (await octokit.rest.issues.get({ owner: org, repo, issue_number: number })).data as Context<"issue_comment.created">["payload"]["issue"]; - // const isOpen = issue.state === "open"; - // const isUnassigned = !(issue.assignees && issue.assignees.length); + const isOpen = issue.state === "open"; + const isUnassigned = !(issue.assignees && issue.assignees.length); - // if (isOpen && isUnassigned) { - const href = `https://www.github.com/${org}/${repo}/issues/${number}`; - results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); - // } - } catch (err) { - // Skip issues we can't access - console.warn(`Failed to fetch issue ${org}/${repo}#${number}:`, err); + if (isOpen && isUnassigned) { + const href = `https://www.github.com/${org}/${repo}/issues/${number}`; + results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); + } + } catch (e) { + if ((e as { status: number }).status !== 404) { + throw e; + } } } diff --git a/src/handlers/start/api/public-api.ts b/src/handlers/start/api/public-api.ts index 5c376d10..be11a49a 100644 --- a/src/handlers/start/api/public-api.ts +++ b/src/handlers/start/api/public-api.ts @@ -3,8 +3,6 @@ import { verifySupabaseJwt, extractJwtFromHeader } from "./helpers/auth"; import { rateLimit, getClientId } from "./helpers/rate-limit"; import { buildShallowContextObject } from "./helpers/context-builder"; import { StartBody } from "./helpers/types"; -import { isDevelopment } from "../../../utils/is-dev-env"; -import { User } from "@supabase/supabase-js"; import { handleRecommendations } from "./directory-task-recommendations"; import { handleValidateOrExecute } from "./validate-or-execute"; @@ -41,9 +39,12 @@ export async function handlePublicStart(request: Request, env: Env): Promise Date: Sat, 8 Nov 2025 01:54:35 +0000 Subject: [PATCH 19/56] feat: add mock handlers for Supabase authentication and public API tests --- tests/__mocks__/embeddings.json | 46 +++++ tests/__mocks__/handlers.ts | 97 ++++++++- tests/api-recommendations.test.ts | 327 ++++++++++++++++++++++++++++++ tests/public-api.test.ts | 315 ++++++++++++++++++++++++++++ 4 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 tests/__mocks__/embeddings.json create mode 100644 tests/api-recommendations.test.ts create mode 100644 tests/public-api.test.ts diff --git a/tests/__mocks__/embeddings.json b/tests/__mocks__/embeddings.json new file mode 100644 index 00000000..a53a629d --- /dev/null +++ b/tests/__mocks__/embeddings.json @@ -0,0 +1,46 @@ +[ + { + "idx": 0, + "id": "I_kwDOF4fVBs5dGEVy", + "plaintext": "When user wants to test a PR he should be able to update blockchain's state for testing purposes. Anvil provides custom RPC methods for better testing: https://book.getfoundry.sh/reference/anvil/#custom-methods What should be done: Add \"Custom RPC methods\" button to the bottom of the https://uad.ubq.fi/ (button should be hidden for production builds) On \"Custom RPC methods\" click popup should be opened where user can send txs for the following RPC methods: anvil_impersonateAccount anvil_stopImpersonatingAccount anvil_setBalance anvil_setStorageAt anvil_dumpState anvil_loadState evm_increaseTime eth_sendTransaction Create UI for custom RPC methods ", + "embedding": "[0.014128988,0.03003673,-0.018013837,0.020125326,-0.028266449,0.0021227375,0.00710705,0.002680297,0.019403184,0.046812437,0.010437572,0.002210943,0.035032686,-0.011003547,-0.031776976,0.027224272,-0.01282445,0.02502257,-0.027531967,-0.01847567,-0.046823975,0.004553818,-0.03838101,-0.049148396,-0.06949962,0.0034096004,-0.047622696,-0.0035033978,0.08783241,0.03993156,-0.034032803,-0.008676708,-0.018772606,-0.004369622,0.006617375,-0.036688246,0.009720639,-0.0018817458,-0.071963154,-0.04553036,0.0114100315,0.010875442,0.0038381694,-0.021657804,-0.07101253,-0.0113038365,-0.03100581,-0.015323145,-0.032346673,-0.015599041,-0.012676941,0.0807538,0.01770387,-0.013466142,-0.03343677,-0.029850109,-0.03345455,-0.041067254,-0.04485015,0.032241635,0.039444923,-0.042027503,-0.003518812,-0.03149588,0.026686382,0.048602924,-0.014723264,0.0032131989,0.0036828371,-0.038001325,-0.033920024,0.04322304,-0.014685536,-0.0381295,0.02680303,0.028937606,0.0062863356,0.00553247,0.008367443,0.023895413,-0.03333876,0.018969499,-0.007326879,0.0015514031,-0.04075261,-0.026356028,0.009399451,0.011858959,-0.00822301,-0.016397871,0.02309124,0.029212853,-0.0012062554,0.02070729,0.01102699,0.054743648,0.005841084,0.0047670417,-0.0012905139,0.029764656,0.058207363,0.022793785,-0.03807451,-0.020794742,0.009527982,0.05913253,0.02450369,-0.04299394,-0.056706533,-0.023825614,-0.0089428425,-0.024927774,-0.014624511,-0.006803108,-0.027830608,0.017364379,-0.029414661,-0.0025247547,-0.0049188416,-0.039943375,0.03377097,-0.006161164,-0.022057788,-0.029837709,-0.024471644,-0.0056718956,-0.004405331,0.0357292,0.004474485,-0.052847214,0.008473305,0.009516035,0.0059982776,0.013830117,0.0050616935,0.0026695728,-0.0021563182,-0.022699721,0.021466034,-0.02674415,-0.017221635,0.0055407644,0.015714075,0.04526764,-0.0006033822,0.028166942,-0.010854261,-0.01869727,-0.056045983,-0.0010840553,-0.024297107,-0.05602718,0.004561974,0.0053365068,0.02961703,-0.008423023,-0.06393915,-0.010436673,-0.004689985,0.0361246,-0.0013972837,0.03363109,-0.026720835,0.0349318,-0.012845589,-0.0046561966,-0.0003109723,-0.06047648,-0.011303244,-0.033295326,0.031419583,0.013831586,-0.0032302106,-0.023321986,0.039572567,-0.022464518,0.038839523,0.043082688,-0.010358241,0.020723993,0.031661075,0.05114846,0.010919857,0.031179389,-0.0054734787,0.029240998,0.002425312,0.0108045,0.001238778,0.019746482,0.00399273,-0.008127356,-0.059777502,0.056543697,-0.020430831,-0.009559331,-0.009728301,0.017677847,0.001328083,-0.035749115,-0.029058322,0.007435128,-0.03168258,0.022247473,-0.0067604166,-0.019140132,-0.0061085667,-0.001887538,-0.00337121,-0.01010099,0.033505574,0.021553297,-0.057643626,-0.011064925,0.033611313,-0.019999938,-0.043756884,0.028857619,0.017489765,-0.011560653,0.018091261,0.021958733,0.06531973,0.037554275,-0.00027122855,-0.008081358,-0.0058772783,0.02468263,-0.017596759,-0.004527174,0.04681734,0.01764772,0.014628849,0.049142517,0.026348576,0.029944004,0.032914463,0.011058531,0.0031253765,0.004313167,0.019626724,0.01770132,-0.018633103,0.0046364837,-0.005507852,-0.039197817,-0.01677579,0.007521398,-0.012003723,-0.0051732687,-0.044783585,0.01022856,0.01057431,0.02210896,-0.037196733,-0.00404222,0.0199846,0.015538488,-0.006046798,0.032876045,0.05059734,0.006151702,-0.025360402,-0.015286494,0.023407463,-0.0058365483,0.0007519615,0.00775612,-0.04016329,-0.023023061,-0.017411534,-0.022851225,-0.019000797,-0.01799572,-0.022070883,0.0067729303,0.031156223,-0.06800008,0.009889778,-0.06859351,0.03630861,-0.021158569,0.015871549,0.037715323,0.011919596,0.026156904,-0.04062822,-0.015220756,-0.037160926,0.0271083,-0.005974724,0.017212754,0.023700316,-0.03161932,0.02661753,-0.011147262,0.004335997,0.0043100165,0.0029340165,-0.010624346,-0.024708191,0.025484707,0.039780963,0.00479871,-0.011237434,0.040751208,0.016689507,0.010449646,0.017591517,-0.01267416,0.038394645,-0.0010728857,0.01378152,-0.02919329,-0.041641343,0.057569347,0.035766177,0.01726994,-0.026704691,-0.033036485,0.01732615,-0.014809541,-0.020642327,-0.026496226,0.025607707,0.013766407,-0.023273144,-0.058061574,0.007742719,-0.004849351,-0.0031729166,-0.013323859,-0.008543324,0.036085956,-0.038029887,-0.019441608,-0.002835452,0.008381865,-0.04390923,0.0020912807,0.036700338,-0.023484444,0.022994975,-0.0028599899,-0.018542469,-0.022091398,-0.026158895,-0.061129104,-0.016407628,-0.037460197,-0.012091569,-0.006978134,-0.037667897,0.03989185,0.03658237,0.066692814,0.00049153034,0.0042249872,0.029439561,0.034117896,-0.00780161,0.03384923,0.017775783,0.012360406,-0.037899327,-0.042995233,-0.07983597,0.045946635,0.049671102,-0.05012737,-0.008296165,-0.029688818,0.0041810633,-0.030417144,0.021211356,-0.040931284,0.009955606,0.02146784,0.057640806,-0.026761746,-0.056438006,0.029743169,0.006358407,0.049701616,0.025680715,-0.031986054,-0.044720676,0.026668936,-0.05078464,-0.035980135,0.021597937,0.06931979,-0.0023985372,-0.0033066499,-0.05811065,-0.04376664,0.019944703,-0.0052195466,0.027847702,0.019363886,0.018405179,-0.013128798,-0.022576917,0.014471143,-0.042630177,0.025755484,-0.06367333,0.013075015,-0.0015078974,0.004852161,0.0023093275,-0.049875844,-0.018313233,-0.025445355,-0.03347853,0.014350301,-0.011569449,0.041607372,-0.0038044818,-0.0064517143,-0.042713642,-0.04381021,-0.039002337,0.039364424,0.06408646,-0.03458199,0.0158471,-0.029534578,0.0015871229,0.050097708,-0.068846874,-0.03865301,-0.019829784,-0.030039117,-0.072451256,0.04037101,0.07698115,-0.020027151,-0.0015092321,-0.012375385,-0.009349435,0.044262126,0.006846355,-0.0043542525,-0.002273885,0.010526637,0.0124517195,-0.01247069,0.05265748,-0.008616772,0.00017910624,-0.0641354,0.013968213,-0.07278419,0.0039532427,0.002069085,0.04769501,0.009546717,0.033748776,-0.010793004,0.025130624,-0.0455305,0.048712067,0.0047325995,0.013523962,0.0053034048,0.0039405203,0.010764173,0.023262486,-0.008502978,-0.011787994,0.01120812,0.0009109779,0.016642928,0.045054104,-0.03786646,0.040937897,-0.00037766556,0.018629514,-0.0030259325,-0.043562014,0.07161815,0.003341654,0.013292056,-0.020247402,-0.019266343,-0.0008831516,0.0055756285,-0.0090803495,0.094006866,-0.020076616,-0.03831123,0.017194826,-0.017321689,-0.0044428213,0.06569601,-0.029689768,-0.019120205,0.05294829,0.057155002,0.013728824,-0.0077496506,-0.05261979,0.033876494,-0.022216398,0.0063483743,-0.0068346728,-0.018484816,-0.016342796,0.035097327,-0.009955721,0.042966403,-0.019045474,0.021851009,0.030265609,0.042998098,0.0052660685,-0.015457283,-0.05266107,0.03613845,0.026509587,-0.06717522,-0.015956396,0.0052624685,-0.022629453,0.05592164,0.0148615455,-0.025569167,-0.015616392,-0.015453398,0.0026108127,-0.024284704,-0.013154373,-0.023997178,-0.037909355,-0.009529329,0.025341965,0.035766624,-0.011439833,-0.0076236064,-0.049779467,0.04101703,-0.001885296,0.023523536,0.00830799,0.041193496,0.012674057,0.039002836,0.00015823057,8.396799e-05,-0.033513237,-0.025975464,0.039843395,0.08593612,-0.026315706,-0.056781773,0.0089420965,0.0022475435,-0.035578117,0.023315813,-0.054921918,0.036647998,-0.007371475,0.005476571,-0.03940729,-0.028159022,-0.020641893,-0.016082088,0.015894955,-0.038662042,0.0270955,0.021824043,0.035079636,-0.011595562,0.027152857,-0.0434923,0.0110041145,-0.017423913,-0.023409689,0.03764093,0.03711145,-0.036098476,0.0009767654,-0.038635407,0.05390232,-0.037106585,0.03649253,0.04352943,0.0018983055,0.051154777,-0.023764264,0.06306589,0.05533724,0.011397057,-0.03114783,0.006058497,-0.006038627,-0.027363798,0.011849312,-0.01411218,-0.019019248,-0.002148209,-0.011812999,-0.03176985,0.025180914,-0.05051491,-0.035730246,-0.022930002,-0.00792009,0.031077882,0.041797742,0.011905258,0.024581384,0.0013098626,0.027362565,-0.018895198,0.01028886,-0.024982948,0.02602035,0.010627945,-0.0070440783,-0.013776436,0.02444832,-0.032856185,-0.030298213,-0.031796087,0.031151203,-0.029618299,-0.022975406,-0.047803372,-0.023525726,-0.03307891,-0.017264213,0.039959326,-0.021660062,-0.00014239516,-0.06096202,-0.00821478,-0.02430546,-0.0112752495,0.022407053,0.027882183,0.013951435,-0.0889191,-0.020615708,-0.0136453435,-0.0054839617,0.021420348,-0.030920267,0.025837962,0.063497044,0.015102931,0.11728495,0.032556172,0.027159292,0.044406574,-0.05282599,-0.051268224,0.030119099,-0.0048539704,0.0070498125,-0.0053372364,-0.03398926,-0.010909191,0.0064060236,0.028294954,0.022411097,-0.024410928,-0.0015619547,-0.011129869,0.0068098363,-0.036672745,-0.0010972738,-0.065544784,0.0066183074,-0.0070671015,-0.004741142,-0.019834599,-0.0069509917,0.009496368,-0.048048418,-0.037594296,-0.013404762,0.031168358,-0.031973552,-0.06785166,-0.009045516,-0.002298496,0.018169068,-0.0054447814,-0.0044517056,0.009647855,0.04393139,-0.0155568905,-0.019978369,-0.023415063,-0.007432738,-0.015199615,-0.013775758,-0.0010144844,0.0031616904,0.018911183,-0.03658967,0.045223124,-0.0048874062,-0.02793305,-0.011405778,0.0034590042,-0.008824383,-0.0029702,0.017983582,-0.035679713,0.028390745,-0.013345196,0.025860533,-0.060470413,0.0069268486,0.0034786665,-0.004200679,0.013961993,0.015243206,-0.036811776,0.028818805,0.030116592,0.012697964,0.044862416,0.022573266,0.079930544,-0.020184863,-0.003015914,-0.007946216,0.02687501,-0.017269747,0.035745967,0.012001731,-0.036146093,0.026363987,0.00041240003,-0.01627562,-0.024048163,0.017302908,-0.008459617,0.021784209,0.0012949496,0.008377407,-0.0006457062,0.0035127683,-0.06156625,-0.06776891,0.0145854065,-0.009636669,0.010235612,-0.04390288,-0.026813665,-0.02707249,0.0048834914,-0.031401392,-0.015104147,0.03415592,0.04830582,0.06675708,-0.0043653,-0.0095379045,0.013879024,0.048677232,-0.031292796,-0.033948958,-0.0077598104,0.05051551,-0.016745206,0.003847379,-0.0501341,0.002751498,-0.042841688,0.05175161,0.04283044,0.01832754,-0.04337354,0.039189428,-0.048152283,0.046308648,-0.010587658,0.029736957,0.035906974,0.010835305,-0.05325122,-0.00246459,-0.0086083375,-0.06446642,0.057159357,0.017284172,-0.01826204,-0.00403343,0.008333153,-0.005537812,0.069010735,0.012037089,0.05905031,0.039492153,-0.01596477,0.020438233,-0.0076391706,1.6677797e-05,-0.011054199,0.020076223,-0.023671111,0.06934276,-0.028633922,-0.01141926,0.018074974,-0.004186448,-0.030131882,-0.0007466987,0.014117711,-0.03028877,-0.035071503,0.023778617,-0.0103184255,0.022604145,-0.00321155,-0.05938171,0.06763287,-0.00018...", + "payload": "\"{\\\"issue\\\":{\\\"nodeId\\\":\\\"I_kwDOF4fVBs5dGEVy\\\",\\\"number\\\":528,\\\"title\\\":\\\"Create UI for custom RPC methods\\\",\\\"body\\\":\\\"When user wants to test a PR he should be able to update blockchain's state for testing purposes.\\\\r\\\\n\\\\r\\\\nAnvil provides custom RPC methods for better testing: [https://book.getfoundry.sh/reference/anvil/#custom-methods](https://book.getfoundry.sh/reference/anvil/#custom-methods)\\\\r\\\\n\\\\r\\\\nWhat should be done:\\\\r\\\\n\\\\r\\\\n1. Add \\\\\\\"Custom RPC methods\\\\\\\" button to the bottom of the https://uad.ubq.fi/ (button should be hidden for production builds)\\\\r\\\\n2. On \\\\\\\"Custom RPC methods\\\\\\\" click popup should be opened where user can send txs for the following RPC methods:\\\\r\\\\n- anvil_impersonateAccount\\\\r\\\\n- anvil_stopImpersonatingAccount\\\\r\\\\n- anvil_setBalance\\\\r\\\\n- anvil_setStorageAt\\\\r\\\\n- anvil_dumpState\\\\r\\\\n- anvil_loadState\\\\r\\\\n- evm_increaseTime\\\\r\\\\n- eth_sendTransaction\\\",\\\"state\\\":\\\"CLOSED\\\",\\\"stateReason\\\":\\\"COMPLETED\\\",\\\"repositoryName\\\":\\\"ubiquity-dollar\\\",\\\"repositoryId\\\":null,\\\"assignees\\\":[\\\"Keyrxng\\\"],\\\"authorId\\\":119500907,\\\"createdAt\\\":\\\"2023-01-30T07:09:38Z\\\",\\\"closedAt\\\":\\\"2023-08-23T22:28:33Z\\\",\\\"updatedAt\\\":\\\"2023-08-29T07:36:21Z\\\"},\\\"action\\\":\\\"created\\\",\\\"sender\\\":{\\\"login\\\":\\\"keyrxng\\\"},\\\"repository\\\":{\\\"id\\\":null,\\\"node_id\\\":\\\"MDEwOlJlcG9zaXRvcnkzOTQ3Nzc4NjI=\\\",\\\"name\\\":\\\"ubiquity-dollar\\\",\\\"full_name\\\":\\\"ubiquity/ubiquity-dollar\\\",\\\"owner\\\":{\\\"login\\\":\\\"ubiquity\\\",\\\"id\\\":119500907,\\\"type\\\":\\\"User\\\",\\\"site_admin\\\":false}}}\"", + "author_id": "119500907", + "created_at": "2025-11-06 22:32:18.267615+00", + "modified_at": "2023-08-29 07:36:21+00", + "markdown": "When user wants to test a PR he should be able to update blockchain's state for testing purposes.\r\n\r\nAnvil provides custom RPC methods for better testing: [https://book.getfoundry.sh/reference/anvil/#custom-methods](https://book.getfoundry.sh/reference/anvil/#custom-methods)\r\n\r\nWhat should be done:\r\n\r\n1. Add \"Custom RPC methods\" button to the bottom of the https://uad.ubq.fi/ (button should be hidden for production builds)\r\n2. On \"Custom RPC methods\" click popup should be opened where user can send txs for the following RPC methods:\r\n- anvil_impersonateAccount\r\n- anvil_stopImpersonatingAccount\r\n- anvil_setBalance\r\n- anvil_setStorageAt\r\n- anvil_dumpState\r\n- anvil_loadState\r\n- evm_increaseTime\r\n- eth_sendTransaction Create UI for custom RPC methods" + }, + { + "idx": 3, + "id": "I_kwDOH92Z-c5um0as", + "plaintext": "Error messages should be written as a comment leveraging diff and not prefix \"Error: \" because \"Error: \" recurses. - My error message Markdown source code for reference: \"Screenshot Permits valued at 0 should be skipped for commenting out or logged as debt. Error: Error: new row violates row-level security policy for table \"permits\" Perhaps we should not prefix error messages with \"Error: \" or else nested errors look like this pavlovcik: [ CLAIM 0 WXDAI ] Also 0 WXDAI permits we should not print out. its payment of 0.0 WXDAI will be deducted from your next bounty. Or appear as debt. Originally posted by @pavlovcik in https://github.com/ubiquibot/staging/issues/80#issuecomment-1676941586 Bot Comment Tweaks ", + "embedding": "[-0.006869567,-0.026427813,0.0037511426,0.019962706,-0.01849582,-0.034006786,0.04230011,-0.017894778,0.00996562,0.07763446,-0.015948823,0.050122242,0.013048708,0.0055307867,-0.022094715,-0.033067323,0.016709033,-0.011905909,-0.06303332,0.026268497,-0.0030541916,-0.008200932,-0.07721239,0.014616327,-0.056403574,0.06309152,-0.0419174,0.00029404435,0.07690318,0.054236673,-0.025289694,0.021059277,-0.04395109,-0.020040266,-0.005131975,-0.026550746,0.022073943,-0.010332908,-0.0013563834,-0.042116944,0.014872662,-0.0053468323,0.033397697,-0.054973517,-0.06967925,-0.011368771,0.0030372664,0.027208155,0.0074186767,-0.039158095,0.008148459,0.005641484,0.035775054,-0.011855726,-0.047527704,-0.0009930682,0.009882489,-0.022492424,-0.039493732,-0.02681932,0.022125045,-0.0027750852,-0.0024038448,-0.048259452,0.04013601,0.009004356,-0.057903666,-0.013427654,-0.013300869,0.01691345,-0.014471934,0.048096307,0.0033800697,-0.02508968,-0.064395495,-0.022127723,0.023127485,-0.013666293,-0.047137897,0.055146985,-0.023587989,0.015117817,0.04295515,-0.020687819,-0.020927045,-0.0027626727,0.037223104,0.051002596,0.020228809,0.00553759,-0.014820275,0.024212321,-0.01138147,0.014091551,0.01506168,0.06215762,-0.01800233,0.02659241,-0.028752526,0.02135494,-0.008728702,0.05346825,-0.006704073,0.05486486,-0.047086388,-0.004510256,0.039217893,-0.017436832,-0.031086965,0.020464333,0.012614855,0.03472066,-0.016728828,-0.03369022,-0.03218228,0.04761766,0.014815791,0.019047251,-0.033011865,-0.019196153,0.037141472,-0.05213957,0.03397396,-0.030139543,-0.055637803,-0.0280933,0.0114681525,0.05979159,-0.0051800795,0.0061854767,0.037166167,0.0037331379,0.004691614,-0.00027610274,0.017368073,0.017944207,0.008850095,0.0129303215,0.039648842,0.009001053,0.021433735,-0.018700829,-0.007439262,0.056465,0.04546083,-0.004788373,-0.00013851357,0.029166214,-0.039612945,-6.240249e-05,-0.046818536,-0.011650218,0.037629046,-0.013990667,-0.024057671,-0.011415432,0.015249893,-0.016918201,0.008900945,0.00878246,-0.0030072425,0.033485774,-0.015272982,0.010598914,-0.056504935,0.049395386,0.0015132674,-0.014350477,-0.019965038,-0.044006236,0.068076566,0.020719808,-0.038201224,-0.01731716,0.025597876,-0.003694888,-0.010260333,-0.0075714574,0.024192434,-0.0012015967,-0.0026698227,0.029074114,0.0044336882,-0.009952593,0.01167353,0.009769562,-0.002774351,-0.0067984946,-0.046269163,-0.015534733,0.060267814,-0.008147416,-0.014272627,0.06914278,0.03246114,-0.025159804,-0.04081147,0.049033973,0.034715854,-0.01901869,-0.046763018,0.020995153,0.002278423,0.04373102,-0.0075006057,-0.037698388,0.013770209,0.027596343,-0.05732938,-0.002490516,0.052012824,-0.020074135,-0.052727327,0.0048124604,0.042819902,-0.030769294,-0.057415653,0.047636747,0.02146345,0.021505916,-0.04064017,0.02154369,0.0025104955,0.03486022,-0.009584929,-0.03159816,0.02380572,0.023303762,-0.03258836,0.019151501,0.01592853,-0.009421638,0.017588468,0.03724381,0.0021010742,-0.0376506,0.048744686,0.029714791,0.01152042,0.059780177,-0.015988415,0.0374107,-0.028415406,-0.0051039024,0.023989964,-0.002096161,0.008672672,-0.0056917183,0.016525824,-0.00039254245,-0.060007587,0.069405146,-0.007830282,-0.011102316,-0.021634331,-0.054198466,0.02544796,0.028301734,-0.07886723,0.009104531,0.026887525,0.032834698,-0.0031150316,-0.014091673,0.008002205,-0.014876172,-0.003476309,0.014026576,-0.009502026,-0.04968495,-0.010780209,-0.009365307,-0.06702213,0.00029077235,-0.039222162,-0.025978982,0.02573467,-0.01072046,0.036854867,-0.037978604,0.006229061,-0.0058513638,-0.006889374,0.040638268,-0.0001937616,0.0040456774,-0.046011895,0.0017587732,0.0096658105,0.0064034373,-0.017063506,-0.00023371438,0.021753445,0.02288434,0.019939037,-0.010453127,0.009186644,0.04926459,0.0008272922,-0.020958671,-0.054438807,-0.02535515,-0.024378369,-0.053750273,-0.00067717343,0.020858161,0.040137712,-0.0031625065,0.022751726,0.02734029,-0.012626436,-0.02264521,-0.022946328,0.0447057,-0.0516357,0.0076043587,0.006665566,0.035399932,-0.04699741,-0.041781303,-0.030215064,0.011787033,-0.011771916,-0.026161991,-0.0008280741,0.01229216,0.037142728,-0.071532734,0.04376926,-0.027743557,-0.025130289,-0.016039684,-0.016126273,0.03697644,0.030683,0.020949999,-0.0014776989,-0.05055411,-0.044818174,-0.0030941023,0.01859255,-0.004140272,0.0209493,0.010428883,-0.014364115,0.016150555,-0.012008042,-0.03956706,-0.011541153,0.021767484,-0.031339765,0.021273347,0.008328131,0.01120984,0.039203767,0.054609224,-0.021273816,-0.0012980959,-0.011095861,0.03533875,0.06506404,0.058860525,-0.01620652,-0.036857717,0.016629994,-0.03929143,-0.00043135387,0.016267784,0.047079712,0.029674103,0.03758515,0.0067748856,-0.015184767,-0.006646423,-0.03430318,-0.047378317,0.040155437,-0.021593632,0.035035137,-0.011646173,-0.015899371,0.03179106,0.0011098415,0.04464243,0.0032561556,0.018915562,-0.01301074,0.0066792243,-0.009323462,-0.00557201,-0.029815393,-0.007704885,0.02109417,-0.028990893,-0.0146773085,-0.05443627,0.04343424,0.035028707,0.019466465,0.04741638,0.023704447,0.0080241095,0.043154452,0.010721492,0.004446348,0.010585563,-0.05905118,0.007727307,-0.022204522,0.028714506,-0.021945465,-0.022070074,0.02276445,0.013018321,-0.014745555,-0.006707664,-0.027971236,-0.016338432,0.031179596,-0.007518888,-0.04472684,-0.021379042,0.017077029,-0.035910685,0.031601544,-0.082730755,0.037389636,0.0030602033,0.02445504,0.048078984,0.0030709363,-0.025576813,-0.011160255,-0.04605336,-0.050035775,0.017074388,0.002951626,-0.022656301,0.029266313,-0.02414845,0.0054986514,0.052524608,-0.00784478,-0.005655449,-0.0060272776,-0.031110715,0.036089674,0.030060142,-0.012128706,-0.06280273,0.06738891,-0.048221868,-0.0062920186,-0.0743721,0.019143982,-0.0061161215,0.032275155,0.010531233,0.031777076,-0.03964149,0.0097641405,0.0014263984,-0.019673126,-0.008914243,-0.028721081,0.03384731,0.044382922,-0.041977245,0.028710522,0.0077262027,-0.031307537,0.0326506,0.029596452,-0.0043083113,0.0139875,-0.037594616,0.017469864,-0.007840402,-0.014214427,0.029817594,-0.032310165,0.052411914,0.02243484,-0.028803911,-0.011313562,-0.08961111,0.013573128,-0.009289648,-0.049963027,0.017072015,-0.0038634688,0.038175657,-0.00569678,-0.06278765,-0.042426225,0.0020687787,-0.020875461,-0.02738898,0.01938099,0.031933457,0.005186211,0.010672654,-0.027153404,0.057552364,0.006836432,-0.004317531,-0.04315315,-0.020222608,0.07553311,0.04433497,-0.04624115,0.016386725,-0.00075732055,0.016800895,0.015302497,0.0089519955,0.016692057,-0.044346757,0.0013536928,0.023152366,0.029758727,-0.042019118,-0.016491106,0.031954303,-0.031581048,0.02692268,-0.011511997,0.0057446607,-0.03911306,-0.013337934,0.0033702238,-0.022017173,-0.024948701,-0.05704989,-0.043010984,0.012995623,0.013898641,0.03356946,-0.04702681,0.0048784986,-0.030803764,0.0053230715,-0.035365496,-0.0049267504,-0.0067439047,0.0072683385,-0.005200581,0.025498303,0.014558428,0.004493366,0.013738962,-0.015988467,0.0427119,0.04506445,-0.026264194,0.030076753,-0.025572993,0.01821806,-0.017743874,0.0051683555,-0.023398658,0.034753405,-0.031579446,0.008403949,0.009372592,0.010087589,-0.013444072,0.0097879935,0.009595421,-0.023037475,-0.02181777,-0.023957176,0.018966552,-0.054973297,-0.0040150643,-0.040250115,-0.0059711235,-0.055691212,0.001422407,0.010988891,-0.010274076,-0.03427074,0.032267854,-0.02802894,0.03619399,-0.021561233,0.010260377,0.09328749,0.014910139,-0.005559342,-0.029133089,0.05862914,0.02437736,-0.0013336387,0.0023103654,0.029633775,-0.049969204,-0.020593425,-0.009129105,-0.011009619,-0.017805375,-0.020267107,0.08313723,-0.012420663,0.06949349,-0.009470375,-0.053248823,-0.043218724,-0.0012477189,0.01735298,0.025307663,0.043593366,-0.016259335,0.019556556,0.041874807,-0.0055464217,0.018718624,-0.011814402,0.040054493,0.026608342,0.0058262656,0.019293435,0.020133205,-0.027320378,-0.034745183,-0.010265961,-0.012494023,-0.031243214,-0.02538785,0.00032725395,0.029885713,-0.025237555,0.020038124,-0.003022084,0.00824534,0.0066489144,-0.052067168,0.016993659,-0.0031753494,0.02316443,0.082911074,-0.030893635,0.020858109,-0.013186594,-0.01584348,0.0093289185,0.014177896,0.052853376,0.011323008,-0.022415472,0.012672877,0.019666279,0.085815154,-0.05522108,0.0054620197,0.044065412,-0.029953824,-0.0097769275,-0.0012105162,-0.040992726,-0.006410559,0.027094929,-0.021003237,0.04448505,0.009121192,0.0080170585,-0.02435083,-0.0407972,0.0051222327,-0.018640976,0.019319845,-0.03485767,-0.01198435,-0.0033328324,0.0136795845,0.009905182,-0.019840827,-0.01979412,-0.0044570183,-0.052200023,0.007843024,-0.014098371,-0.02762458,-0.044640925,-0.033084262,-0.05934802,-0.036759682,0.037559137,-0.026148213,-0.009775492,-0.03824399,-0.0067822197,0.04787199,0.017624263,-0.015310314,0.01707979,-0.028612077,0.05128843,-0.028040372,-0.034719445,0.007528051,0.025817206,0.010588861,0.017984632,0.0041306936,0.005023419,-0.005839431,0.004836806,0.026281286,-0.0471748,-0.029526131,-0.022422511,0.015281343,-0.047342148,-0.01539115,-0.009898133,-0.033013783,0.0070523215,-0.042644218,-0.026115172,0.026012689,0.007017653,0.04135126,-0.015896585,0.034940977,0.020520428,0.00068121706,-0.0006881314,-0.0241479,0.007522514,0.011198467,0.02001327,0.023946209,-0.008984072,0.034492698,-0.0062991683,0.005869348,-0.030027661,0.008597712,-0.008657548,0.0368357,0.031584818,0.006941589,0.03730767,-0.0022485112,-0.037626144,0.029102309,-0.043924958,-0.0052782074,0.005224426,-0.038897987,0.0064395303,-0.06802786,-0.03540392,-0.009044571,0.038808625,-0.0122490125,-0.009264841,0.04418151,0.018284759,0.0025444583,-0.037841197,0.039322287,0.012200619,-0.010970294,0.013201094,-0.05031618,-0.01877382,0.055359263,0.0052793557,0.010548467,-0.018204762,0.007891639,-0.053168204,0.03776041,0.028676018,0.029362466,-0.07035622,-0.019312434,-0.011464264,-0.002560267,-0.037811313,0.03715326,0.012738676,-0.0026585083,-0.034113396,-0.016299754,0.00047604775,0.005695394,0.036640275,0.050628603,0.0040279343,-0.043116808,-0.043463636,0.0022793303,0.042047456,0.03492644,0.013326554,-0.041276164,0.035509374,0.0081047155,-0.031884342,-0.038051702,0.023475952,-0.00066006486,0.013117246,0.02571842,0.010206211,-0.0037025283,-0.014086134,-0.0050677625,0.029582854,-0.0138878655,0.005871247,-0.0042680856,-0.06068041,0.048969477,0.01738597,0.0052613043,-0.030460339,-0.038228747,0.07530325,0.049621943...", + "payload": "\"{\\\"issue\\\":{\\\"nodeId\\\":\\\"I_kwDOH92Z-c5um0as\\\",\\\"number\\\":632,\\\"title\\\":\\\"Bot Comment Tweaks\\\",\\\"body\\\":\\\"1. Error messages should be written as a comment leveraging `diff` and not prefix \\\\\\\"Error: \\\\\\\" because \\\\\\\"Error: \\\\\\\" recurses.\\\\r\\\\n\\\\r\\\\n```diff\\\\r\\\\n- My error message\\\\r\\\\n```\\\\r\\\\n\\\\r\\\\nMarkdown source code for reference:\\\\r\\\\n\\\\r\\\\n\\\\\\\"Screenshot\\\\r\\\\n\\\\r\\\\n2. Permits valued at 0 should be skipped for commenting out or logged as debt. \\\\r\\\\n\\\\r\\\\n>> Error: Error: new row violates row-level security policy for table \\\\\\\"permits\\\\\\\"\\\\r\\\\n> \\\\r\\\\n> Perhaps we should not prefix error messages with \\\\\\\"Error: \\\\\\\" or else nested errors look like this \\\\r\\\\n> \\\\r\\\\n>> ### [ **pavlovcik: [ CLAIM 0 WXDAI ]** ](https://pay.ubq.fi?claim=eyJwZXJtaXQiOnsicGVybWl0dGVkIjp7InRva2VuIjoiMHhlOTFEMTUzRTBiNDE1MThBMkNlOERkM0Q3OTQ0RmE4NjM0NjNhOTdkIiwiYW1vdW50IjoiMCJ9LCJub25jZSI6IjcwMDA5NTc5MTE3MjM0MzA5MTExOTE4NTYwNjI3MjU0MzI2MzAxODYzMjA3NDcyMjkwNjI2NTU3ODA2MTYyMjg2MTc0MjcwODA4OTk2IiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHg0MDA3Q0UyMDgzYzdGM0UxODA5N2FlQjNBMzliYjhlQzE0OWEzNDFkIiwicmVxdWVzdGVkQW1vdW50IjoiMCJ9LCJvd25lciI6IjB4NzBmYmNGODJmZmE4OTFDNDI2N0I3Nzg0N2MyMTI0M2M1NjZmNzYxNyIsInNpZ25hdHVyZSI6IjB4ZGRkYTYzODIzNjVlMWQ0YTE0ZjhkZjJlMDA1NmJiODgwNjA4ZDhkNmMzOWYyNTMzNTgxMjhjNGE2MjUwYjEzZDEzNGRhYjA2YjA5ZjIwMzdlN2Y4YzJjYjdlMjk1MmE1MzE2ODhkYThiYTM5MmFiZWY1YzA2MjMzZWJiZTMwNjcxYyJ9&network=100)\\\\r\\\\n>> \\\\r\\\\n> \\\\r\\\\n> Also 0 WXDAI permits we should not print out. \\\\r\\\\n> \\\\r\\\\n>> its payment of 0.0 WXDAI will be deducted from your next bounty.\\\\r\\\\n> \\\\r\\\\n> Or appear as debt.\\\\r\\\\n\\\\r\\\\n_Originally posted by @pavlovcik in https://github.com/ubiquibot/staging/issues/80#issuecomment-1676941586_\\\",\\\"state\\\":\\\"CLOSED\\\",\\\"stateReason\\\":\\\"COMPLETED\\\",\\\"repositoryName\\\":\\\"ubiquibot\\\",\\\"repositoryId\\\":null,\\\"assignees\\\":[\\\"Keyrxng\\\"],\\\"authorId\\\":4975670,\\\"createdAt\\\":\\\"2023-08-17T20:36:26Z\\\",\\\"closedAt\\\":\\\"2023-09-10T21:53:11Z\\\",\\\"updatedAt\\\":\\\"2023-09-10T21:53:23Z\\\"},\\\"action\\\":\\\"created\\\",\\\"sender\\\":{\\\"login\\\":\\\"keyrxng\\\"},\\\"repository\\\":{\\\"id\\\":null,\\\"node_id\\\":\\\"R_kgDOH92Z-Q\\\",\\\"name\\\":\\\"ubiquibot\\\",\\\"full_name\\\":\\\"ubiquity/ubiquibot\\\",\\\"owner\\\":{\\\"login\\\":\\\"ubiquity\\\",\\\"id\\\":4975670,\\\"type\\\":\\\"User\\\",\\\"site_admin\\\":false}}}\"", + "author_id": "4975670", + "created_at": "2025-11-06 22:32:17.294227+00", + "modified_at": "2023-09-10 21:53:23+00", + "markdown": "1. Error messages should be written as a comment leveraging `diff` and not prefix \"Error: \" because \"Error: \" recurses.\r\n\r\n```diff\r\n- My error message\r\n```\r\n\r\nMarkdown source code for reference:\r\n\r\n\"Screenshot\r\n\r\n2. Permits valued at 0 should be skipped for commenting out or logged as debt. \r\n\r\n>> Error: Error: new row violates row-level security policy for table \"permits\"\r\n> \r\n> Perhaps we should not prefix error messages with \"Error: \" or else nested errors look like this \r\n> \r\n>> ### [ **pavlovcik: [ CLAIM 0 WXDAI ]** ](https://pay.ubq.fi?claim=eyJwZXJtaXQiOnsicGVybWl0dGVkIjp7InRva2VuIjoiMHhlOTFEMTUzRTBiNDE1MThBMkNlOERkM0Q3OTQ0RmE4NjM0NjNhOTdkIiwiYW1vdW50IjoiMCJ9LCJub25jZSI6IjcwMDA5NTc5MTE3MjM0MzA5MTExOTE4NTYwNjI3MjU0MzI2MzAxODYzMjA3NDcyMjkwNjI2NTU3ODA2MTYyMjg2MTc0MjcwODA4OTk2IiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHg0MDA3Q0UyMDgzYzdGM0UxODA5N2FlQjNBMzliYjhlQzE0OWEzNDFkIiwicmVxdWVzdGVkQW1vdW50IjoiMCJ9LCJvd25lciI6IjB4NzBmYmNGODJmZmE4OTFDNDI2N0I3Nzg0N2MyMTI0M2M1NjZmNzYxNyIsInNpZ25hdHVyZSI6IjB4ZGRkYTYzODIzNjVlMWQ0YTE0ZjhkZjJlMDA1NmJiODgwNjA4ZDhkNmMzOWYyNTMzNTgxMjhjNGE2MjUwYjEzZDEzNGRhYjA2YjA5ZjIwMzdlN2Y4YzJjYjdlMjk1MmE1MzE2ODhkYThiYTM5MmFiZWY1YzA2MjMzZWJiZTMwNjcxYyJ9&network=100)\r\n>> \r\n> \r\n> Also 0 WXDAI permits we should not print out. \r\n> \r\n>> its payment of 0.0 WXDAI will be deducted from your next bounty.\r\n> \r\n> Or appear as debt.\r\n\r\n_Originally posted by @pavlovcik in https://github.com/ubiquibot/staging/issues/80#issuecomment-1676941586_ Bot Comment Tweaks" + }, + { + "idx": 5, + "id": "I_kwDOH92Z-c5vXfii", + "plaintext": "The bot should be careful to not unassign like this when a task is reopened. It should reset the \"timer\" e.g. to be a full 7 days again. @whilefoo please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of 450.0 WXDAI will be deducted from your next bounty. Originally posted by @ubiquibot[bot] in https://github.com/ubiquity/ubiquibot/issues/500#issuecomment-1694619062 Reopened & Unassigned ", + "embedding": "[0.0076892846,0.0058176224,-0.05348173,0.037261203,-0.0110426,-0.004548742,-0.024567012,0.03813065,0.0008355682,0.05567773,0.021930186,0.012014857,-0.016575847,-0.010257401,-0.08338148,-0.019533318,0.011520101,0.009659338,-0.014533858,0.0016651909,0.016691087,0.05107512,-0.046259135,-0.036644753,-0.003600923,0.055731118,-0.038709108,-0.014683145,0.06798482,0.07800196,-0.003500439,-0.014474494,-0.009866191,-0.011761359,-0.00874984,-0.024577968,0.049793623,0.0018649749,-0.0059439735,-0.03944121,0.0024233758,-0.05131134,-0.020909488,-0.050285473,-0.025419934,-0.051336486,-0.04729552,0.013116364,0.023081232,-0.07684745,0.017964076,0.01449102,-0.0017482603,-0.0043867407,-0.010644763,-0.0011225067,-0.044159528,-0.035748456,-0.04742032,0.0294773,0.05367127,0.003427341,-0.0016665175,-0.02493481,-0.006601201,0.03337187,-0.017235933,-0.03146429,-0.029232156,0.00195328,-0.026637683,0.031882565,-0.010013713,-0.017922478,-0.023170663,-0.014747491,0.029281227,-0.004789656,-0.028279642,0.07156644,0.0007594269,0.021762108,0.014024348,-0.012569745,-0.034334645,-0.031644017,0.038768005,-0.0012125912,0.0020437005,-0.013135188,-0.061589442,-0.0029015886,-0.004233473,0.0021985974,0.011254237,0.053459324,-0.014747304,0.021368716,-0.007982479,-0.011293961,0.03387577,0.024981124,-0.040945873,0.009117345,-0.029656583,-0.014384443,0.033044945,-0.028433464,-0.045757454,0.008886391,0.021255504,0.016747225,0.009019279,0.012128565,-0.013169974,0.06097365,-0.041101754,-0.0026982746,0.006048266,-0.026777476,0.03994946,-0.01807045,0.010827915,-0.02146682,0.00011464929,-0.029838828,-0.013491844,0.04038158,0.014398717,-0.04715177,0.004738802,0.011989901,0.016289761,-0.026081743,0.017522402,0.03109258,-0.017013922,0.016899588,0.056101643,-0.048878603,0.0122185685,-0.029213762,0.00414941,0.07829179,0.0033068308,0.0031543202,0.014324874,0.024231251,-0.030417196,0.028896168,-0.027413225,0.0030933763,0.030535063,-0.020901022,-0.028376566,-0.010317141,0.034133773,-0.010839635,0.04359697,-0.013938966,-0.005266517,0.034439057,-0.057237953,0.009262473,0.00380171,0.011186986,-0.016813895,-0.010886503,0.010264894,-0.02793755,0.030545516,-0.009841795,-0.03343989,0.020934133,0.050978813,0.017201697,-0.016937627,0.006256823,0.047163866,0.0277217,-0.038963266,-0.03300717,-0.00285213,0.019083718,0.028576216,0.020355267,-0.0058025788,-0.015708424,-0.0049408553,-0.029151002,0.011338619,0.011272536,-0.008383928,0.043666463,0.040441778,-0.010426808,-0.0058804695,0.032839797,0.018422158,-0.008481991,-0.005176003,0.006981482,-0.022806946,0.06441499,-0.05966554,-0.031464305,0.036393307,0.031999823,-0.019725643,-0.0053819716,0.0029342237,0.00030199622,-0.036901012,-0.019463986,-0.004227573,-0.028500432,-0.0024746358,0.04862249,0.01626941,0.00013778826,-0.0052784556,0.026966624,0.026528131,0.015543648,0.035020083,-0.011089143,0.0024293107,0.004480626,0.012654607,0.019922933,0.003871716,0.015300663,-0.006199655,0.03600489,0.003729169,0.019186717,0.05933418,0.046167538,0.0053123212,0.026448587,-0.0029672594,0.030478368,0.025912499,0.0016721553,0.028261507,0.008524206,0.015045778,0.021319905,-0.018544732,0.011119285,-0.03377091,0.03746997,0.014345052,0.018832488,-0.0062445784,-0.040421408,0.011543247,0.020495664,-0.032437295,-0.011294471,-0.044501867,-0.005030122,0.01725938,-0.04109735,0.01991047,-0.0024238466,0.033852402,0.042668786,0.011668289,-0.04438842,-0.03857798,-0.0010498672,-0.05108368,-0.045672886,-0.026214102,-0.002752859,0.03535006,-0.018235574,0.03548145,-0.044522375,0.003066916,0.004033733,-0.017698642,0.048215665,-0.016730383,0.02738277,-0.04663334,-0.015605443,0.0043465407,0.03157073,-0.05781802,-0.0046340497,0.019762026,0.018897045,0.01970178,0.019320503,0.0070641283,0.020825388,-0.013780102,0.0054751616,-0.027086763,-0.03991685,0.036613375,-0.0041304235,-0.011817141,0.002592015,0.03962768,-0.01595086,0.049204275,0.013143839,-0.023437792,0.043841384,-0.02110298,0.035676323,-0.035934445,0.030828297,0.05258409,0.008113468,-0.0344742,-0.04316652,-0.03537717,-0.0025155912,0.004077619,-0.0059012505,-0.011856676,-0.04373733,-0.0012797012,-0.058696456,0.03144097,-0.052174896,-0.023186786,-0.038546685,-0.0551999,0.042664558,-0.007179047,0.007926186,-0.007872846,-0.005980928,-0.05454315,0.010834527,0.042680517,-0.015328472,0.0040009636,0.038085967,-0.010889649,-0.0044631683,0.040965408,-0.03333765,-0.011067766,0.028917152,-0.030206325,0.03261356,0.0080486,0.045872964,0.020360695,0.05509223,-0.03718959,0.019282442,0.012129379,0.053602792,-0.00048825445,0.017007641,0.012717466,-0.0004773296,-0.009011959,-0.03337225,0.03884893,0.01593297,0.06752366,-0.013600909,0.011353031,-0.0057356413,0.017599003,-0.038598426,-0.04814581,-0.034995027,0.0064076236,-0.028557431,0.02279128,-0.032460332,0.011491949,-0.005808667,-0.011196514,0.043634508,0.04656309,0.010259544,-0.04364702,0.031670954,0.019240754,-0.017208183,-0.0143126445,-0.021608097,0.011254808,-0.027614946,-0.0053687333,-0.05739835,0.024974922,0.01072791,0.0077024093,0.011759801,-0.01674865,-0.014639919,0.029925115,-0.0025884288,-0.006527721,0.013576617,-0.07133811,0.045286696,-0.006972079,0.014501366,-0.016087176,-0.0047637178,-0.027864926,0.034472104,-0.0055812313,-0.02346047,-0.06812397,0.027237017,0.0005320271,-0.06540421,0.0028938781,-0.015946578,-0.021455608,-0.015380117,0.029751182,-0.031313665,0.008062699,0.0047520604,0.0071909167,0.03753944,0.010257285,-0.043253016,0.01202472,-0.020795003,-0.070616275,0.052153982,0.036202773,-0.05285874,-0.0070045106,-0.013225705,0.06966159,0.03850396,-0.018263558,-0.028132102,-0.011520161,-0.012976328,-0.014398426,0.033471573,-0.015050856,0.008313367,0.038373295,-0.049432747,0.008414493,-0.05455186,0.0024175965,-0.024915028,0.05363494,0.029253906,0.05427272,-0.025931489,-0.0029690396,-0.030809766,0.006960511,-0.012864349,-0.020697432,0.009286584,0.060931966,-0.017359609,0.015427008,0.021458711,-0.011467286,-0.009789633,0.06312679,-0.014270487,0.008605904,-0.018343713,0.02351281,-0.015063054,0.045017008,0.054913115,-0.06251743,0.07309396,0.026660753,0.006541343,-0.011126733,-0.025006285,0.011032295,-0.014521966,-0.038322322,0.058256418,-0.001197679,0.00877102,-0.0056430125,0.022402888,-0.051667523,0.011237292,-0.03969774,0.005119816,0.04993985,0.03426244,-0.013102639,-0.06328607,-0.04636601,0.07749713,0.020196835,-0.05567539,-0.028513245,-0.06492814,-0.0008878372,0.003231995,-0.02189962,0.02940069,0.0022932114,0.0028027,0.03612564,0.033860583,0.04698965,0.009027417,0.017736847,0.01648759,-0.011402383,-0.09950213,-0.046093922,0.04393993,-0.03397916,0.03826213,0.029491933,-0.0013414159,-0.08396683,-0.04123471,-0.04417856,-0.041128866,-0.01736613,-0.03927091,-0.056965575,-0.017435152,0.030810453,0.041925114,-0.03055423,0.013832701,-0.013639304,0.038225163,-0.04057822,0.016802568,-0.005896415,0.0049870596,-0.028684713,0.042911373,-0.008072087,0.011736335,-0.009359756,-0.00093829446,0.020082856,0.055605046,-0.048382998,0.0076677576,0.011732972,-0.027486473,-0.017802592,0.032151543,-0.035787888,0.020260371,0.0023031824,-0.012028085,-0.010591301,-0.03041425,-0.027552739,-0.012943641,0.017803293,0.017222887,0.025621453,0.017077876,0.055503603,0.0071022357,0.022110227,-0.042462472,-0.05806415,-0.06802483,-0.03376035,0.004884697,-0.02746842,0.026433652,0.0009951922,-0.027915014,0.026807347,-0.07224439,0.015812753,0.055494945,0.009889684,0.023936564,-0.037191406,0.032412548,0.018003548,-0.015974835,-0.016513564,-0.017487522,-0.02332627,-0.013850888,-0.0324265,-0.004070817,-0.030397967,0.020010827,0.01072943,-0.013458106,0.05318269,-0.032684036,-0.05853383,-0.028425427,0.027993757,0.022202712,0.021752108,0.06163102,-0.008422916,-0.024727773,0.0051431223,0.0061966972,0.022386849,-0.053702474,0.038865026,0.05309903,-0.011409527,0.047433235,0.02431794,-0.012905169,-0.05458029,0.02335343,-0.01536422,-0.0852784,-0.02071746,0.02714729,-0.0051301937,-0.027505185,0.05606799,0.027488038,-0.026669828,0.0118038915,-0.022976045,0.0126203615,-0.04041313,-0.0008137406,0.034797717,0.003145252,0.019012788,-0.08674864,-0.036571123,0.025983859,-0.025266547,0.015394393,-0.013685409,-0.0139991315,0.0024315137,0.023461925,0.06504747,-0.014220053,0.02904613,0.047626067,-0.03962611,-0.03978787,0.003378489,-0.01655027,-0.03459981,0.023449231,-0.04108139,0.0077050184,0.006574721,0.0021444506,-0.025349004,-0.012606296,0.00044422827,-0.035018332,0.0052593322,-0.043921158,0.006899965,-0.04641896,0.016760673,0.012906323,-0.027048336,-0.04722397,0.031208495,-0.0312914,-0.025294311,-0.0027014706,0.015556372,-0.041670438,-0.043767378,-0.014356459,-0.0016088971,-0.024512354,0.00028004678,0.0008234791,-0.0051110843,-0.031601086,0.040706586,0.020742346,0.0012429568,-0.025466274,0.031134957,0.0066052782,-0.00096905604,-0.016490132,0.021320047,0.059585202,-0.025256028,0.026356429,-0.037097078,-0.005531147,0.0007769444,-0.01157671,-0.009553534,-0.0017616564,-0.04145931,-0.004060953,0.00048778,-0.024669103,-0.0058351248,-0.020267002,-0.02725207,0.005338088,-0.040557865,-0.009636152,0.041957058,-0.03420827,0.011145944,-0.022613248,-0.014761517,0.027168468,-0.0021300989,0.01548825,-0.048070956,0.016703824,0.04653811,0.029060083,0.018563665,-0.00023976399,0.044293195,-0.02976389,0.014549249,0.019512024,-0.011179358,-0.009275581,0.015943142,0.01917666,0.047836047,0.042158287,-0.014751316,-0.02946876,0.017295137,-0.054029897,-0.05408064,0.033602115,0.0008522136,-0.015617693,-0.02139109,-0.01723406,-0.008107856,0.029692002,-0.038459245,0.016326362,0.030600062,0.061684113,0.06875154,0.005859262,0.019346377,-1.2202041e-05,0.034294955,-0.019793116,-0.06341565,-0.021996718,0.04398604,-0.032137163,-0.0017669096,-0.047507335,0.06533307,-0.023865825,0.050092533,0.0064445003,0.015378323,-0.040705852,0.012824802,-0.0021668442,0.012780927,-0.028439932,0.007980543,0.0060477084,-0.008358186,-0.0531277,-0.020694552,0.005514949,-0.03001575,0.045103554,0.040541135,-0.03845902,0.027961139,-0.021625316,-0.028434405,0.066471204,0.02258964,0.038688105,-0.029217746,0.02448948,0.011791945,-0.0025666475,-0.008191316,0.030007295,0.011478525,0.008256378,0.03748955,-0.0010187826,-0.031706605,-0.033071466,-0.01611842,0.0052257134,-0.014124476,0.01656579,-0.003465064,-0.03433187,0.036021724,-0.0032290097,0.01725315,0.017115152,-0.04857849,0.03100334,0.03741961,-0.025518058,-0.0071441643,0.026...", + "payload": "\"{\\\"issue\\\":{\\\"nodeId\\\":\\\"I_kwDOH92Z-c5vXfii\\\",\\\"number\\\":681,\\\"title\\\":\\\"Reopened & Unassigned\\\",\\\"body\\\":\\\"The bot should be careful to not unassign like this when a task is reopened. It should reset the \\\\\\\"timer\\\\\\\" e.g. to be a full 7 days again.\\\\r\\\\n\\\\r\\\\n> @whilefoo please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **450.0 WXDAI** will be deducted from your next bounty.\\\\r\\\\n\\\\r\\\\n_Originally posted by @ubiquibot[bot] in https://github.com/ubiquity/ubiquibot/issues/500#issuecomment-1694619062_\\\\r\\\\n \\\",\\\"state\\\":\\\"CLOSED\\\",\\\"stateReason\\\":\\\"COMPLETED\\\",\\\"repositoryName\\\":\\\"ubiquibot\\\",\\\"repositoryId\\\":null,\\\"assignees\\\":[\\\"Keyrxng\\\"],\\\"authorId\\\":4975670,\\\"createdAt\\\":\\\"2023-08-27T09:40:06Z\\\",\\\"closedAt\\\":\\\"2023-09-18T07:13:50Z\\\",\\\"updatedAt\\\":\\\"2023-09-20T13:48:31Z\\\"},\\\"action\\\":\\\"created\\\",\\\"sender\\\":{\\\"login\\\":\\\"keyrxng\\\"},\\\"repository\\\":{\\\"id\\\":null,\\\"node_id\\\":\\\"R_kgDOH92Z-Q\\\",\\\"name\\\":\\\"ubiquibot\\\",\\\"full_name\\\":\\\"ubiquity/ubiquibot\\\",\\\"owner\\\":{\\\"login\\\":\\\"ubiquity\\\",\\\"id\\\":4975670,\\\"type\\\":\\\"User\\\",\\\"site_admin\\\":false}}}\"", + "author_id": "4975670", + "created_at": "2025-11-06 22:32:14.511701+00", + "modified_at": "2023-09-20 13:48:31+00", + "markdown": "The bot should be careful to not unassign like this when a task is reopened. It should reset the \"timer\" e.g. to be a full 7 days again.\r\n\r\n> @whilefoo please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **450.0 WXDAI** will be deducted from your next bounty.\r\n\r\n_Originally posted by @ubiquibot[bot] in https://github.com/ubiquity/ubiquibot/issues/500#issuecomment-1694619062_\r\n Reopened & Unassigned" + }, + { + "idx": 14, + "id": "I_kwDOI-EJSM6FW-ET", + "plaintext": "Non-Web3 friendly mobile browsers need better feedback. it should not be stuck in a loading state feedback should be either repeatable or persistent additional context Had already given up. On Brave browser it doesn't even throw an error it just loads for ever. Originally posted by @jordan-ae in https://github.com/ubiquity/devpool-directory-bounties/issues/24#issuecomment-2040538335 better mobile feedback ", + "embedding": "[0.0023517206,-0.017554289,-0.039840292,0.0044957045,-0.028223943,0.00430025,0.04570311,-0.0066510863,0.0643076,0.034547504,0.017158244,0.018634172,0.019619131,-0.0007438237,-0.0073766205,-0.038183287,-0.0064652944,-0.0076645985,-0.0374175,-0.028024113,0.0152642215,0.023638392,-0.039004717,-0.0057970877,-0.047081087,0.042476848,-0.050499924,0.017009074,0.105385795,0.03817102,0.0063593737,-0.0046116426,0.0067140223,0.011742745,-0.017042482,-0.06783544,0.0018062139,-0.0022205748,-0.034374498,-0.043250628,0.014166193,-0.031555686,-0.0039487495,-0.057529893,-0.040767074,-0.022699052,0.009990506,-0.0022682683,-0.0143177835,-0.015682854,0.00984322,0.045573406,-0.025662387,-0.027885035,-0.019012373,-0.01524589,0.009858101,-0.012623739,-0.043267686,0.010543893,0.017517557,0.021527784,0.011003545,-0.016941784,0.013580009,0.05640755,-0.047866188,-0.014005738,0.009929427,-0.014318739,-0.09646928,0.05405901,-0.01808268,-0.06980717,-0.024749044,0.0056627532,-0.024072208,-0.0139248865,-0.011474804,0.03699176,0.0015474581,0.032226916,0.02721046,0.0055089747,-0.010519261,-0.015968852,0.031838935,-0.0236703,-0.03232576,-0.006303981,0.023130102,0.034184147,-0.0144625,-0.01835333,0.045587674,0.07718041,0.028792154,-0.02073768,-0.008747946,-0.008620593,0.007187997,0.065719716,-0.07045006,0.02125313,-0.06535801,0.0055997916,-0.022115905,-0.044694807,-0.06807418,-0.049918875,-0.014615197,0.032769784,-0.011402463,0.020380408,-0.019219453,0.03984589,-0.016641252,0.017747471,0.014174824,-0.037179843,0.011543884,0.004745052,-0.030569825,-0.033851616,-0.0061900797,0.010100167,0.02923326,0.06622846,0.04107573,0.04469352,0.021167943,0.033071302,0.054347523,-0.0019110331,0.0018004889,-0.015161711,0.019189397,-0.05006661,0.028601747,-0.004896489,0.011184521,-0.037383985,0.010284831,0.05751317,0.0005224734,-0.011530023,-0.0097150365,-0.038604848,-0.012255428,0.026882092,-0.00953706,0.04645911,0.0165963,0.01604296,0.038209878,-0.0075292974,-0.019460427,-0.009818817,0.0631795,0.021261875,0.0008627593,0.080317184,-0.072102524,0.048390828,-0.048454024,-0.012021785,0.01991043,-0.048073262,0.014283659,-0.010840179,0.020758538,0.0665303,-0.010250632,0.022103673,0.05887883,0.014603139,-0.009141256,0.0054165893,0.013319688,0.0013232483,0.005482198,0.022061197,0.0366715,0.029701797,-0.016984329,0.02632425,0.001187649,-0.0038014634,-0.036587954,0.007190244,-0.024867522,0.0316115,0.0015475812,-0.00077679625,-0.007344595,0.022530828,-0.007968917,-0.0054071713,0.02929716,-0.060714237,-0.046111856,0.0057245065,-0.028591648,-0.00096443045,-0.016328117,-0.016677434,0.021079894,-0.003095525,-0.05275891,0.0063342997,0.023385143,0.012734098,-0.08853287,-0.02087539,0.011368945,-0.023906345,-0.0147154275,0.035866,0.047384787,0.02996481,-0.016807085,0.030453606,0.022789754,0.0074287537,-0.009841103,0.02249039,-0.015429879,-0.007990608,-0.014957129,0.0075909034,0.009739887,-0.0037179065,-0.029895538,0.06957892,-0.0036625785,0.048531547,0.053296853,-0.0014336873,0.028305346,0.009161202,-0.021819973,0.04748543,0.02720177,0.008769838,-0.04524734,0.016897246,0.0038694197,0.028866181,-0.027957544,0.008233654,-0.013630301,0.03945131,0.028802663,0.011804204,-0.046830446,-0.04834772,-0.015766587,0.05211605,-0.03531919,-0.030021776,0.0119772535,0.0037400937,-0.008987775,-0.032641426,0.007372199,0.022734422,-0.042519175,0.011996252,-0.014557473,-0.071897365,-0.046800327,0.015061155,-0.06632491,0.02474508,-0.044153553,-0.001432243,0.034522504,-0.031250004,0.06917562,0.008104489,0.004793919,0.0098441085,0.01988945,0.048138503,0.007715104,0.0088290535,-0.024730856,0.0016828878,0.006179268,0.0060818084,-0.008090185,-0.011626822,0.019758973,0.016111005,0.0068227244,0.002219099,-0.008018792,0.06696053,-0.007924622,-0.035375834,0.016827775,0.0018881799,0.014626212,0.00550467,-0.0020478193,0.06602771,-0.0054711313,-0.0026627353,0.059457686,0.020760193,-0.019898267,0.049488667,-0.014360438,0.03044135,-0.044795338,0.046007704,0.04146488,-0.004192777,-0.008245488,-0.046128634,-0.02101756,-0.015135356,-0.0014409829,-0.03865897,-0.034140464,0.056482974,0.004814524,-0.040714927,0.037377533,-0.047266237,-0.0305042,-0.012833071,-0.025655417,0.02406764,0.03299065,0.04544639,0.035187934,-0.015746785,-0.052835166,0.035517,0.012927098,-0.015324256,-0.016642112,0.014075675,-0.013268224,0.0066342964,0.018128248,-0.06537005,0.013299674,0.052249815,-0.0518408,-0.0008405941,0.000987183,-0.009356012,-0.0033781421,0.054288633,-0.055232853,-0.017896788,-0.016679503,0.024027187,0.004782959,0.006137649,0.032658607,-0.0042413003,0.0127248475,-0.049483128,-0.01006084,0.01988556,0.041831754,-0.016281912,0.0071072015,-0.037153576,0.00017719233,0.008644611,-0.038555194,-0.03833613,0.07996008,0.014316762,0.02443689,-0.009399778,-0.015016583,-0.009189337,0.027672585,0.035563704,-0.021422345,0.005140028,-0.029760797,0.0038602566,-0.008732494,-0.01842251,0.00071029254,0.033007585,0.024983399,-0.020395584,-0.031845514,-0.029178001,-0.012154085,0.013369705,0.0624523,0.007867349,0.028628403,-0.024356784,0.0261325,0.0052081733,-0.00038623196,-0.009348043,-0.012573794,-0.00017752904,0.026458398,0.0043611242,-0.044427417,-0.0012830439,-0.010941419,0.006703819,-0.025814982,-0.013147837,-0.056239463,0.016604485,0.03873618,-0.003111403,-0.011233664,-0.030849569,-0.010740878,0.04614433,0.03876418,-0.020691328,-0.0027064024,0.0049817376,-0.0050930814,0.039943196,0.021651618,0.03918524,-0.026263349,-0.043716185,-0.06148814,0.06619807,0.053544544,-0.0067658364,0.03171173,-0.045187686,0.0045541567,0.07996974,0.00015344644,0.0005276449,-0.018169874,-0.03687065,0.011298775,0.017750015,-0.017785914,0.030532358,0.036354464,-0.025494063,0.03239297,-0.043705273,-0.013341271,-0.011897536,0.053522535,0.034982406,0.054889366,0.010082183,0.0099225,0.012613998,-0.017082723,0.009136262,-0.014824657,-0.014399669,-0.010470278,0.043706886,0.026209155,0.025291054,-0.050041974,0.028958917,-0.024712548,-0.0348676,0.024251718,-0.027510108,0.018172057,0.018891448,-0.0068675727,0.04951064,-0.038534604,0.057594463,0.046448994,-0.0020953203,-0.0050684214,-0.026955068,0.019361522,-0.0075381696,-0.01729865,0.0494803,0.00010520149,-0.0043052454,0.003524095,-0.022780888,-0.050930236,-0.01074718,-0.020876935,-0.0035296045,0.008823233,0.018374152,0.041982085,-0.005677065,-0.05414416,0.009416056,-0.019284833,0.0055488194,-0.05675716,0.00032946217,0.028552843,0.0076655354,0.023074675,0.064294964,0.003419063,0.026566917,0.03447286,-0.004764082,-0.0004925399,-0.063783795,-0.024972588,0.019480748,-0.024389027,-0.041738518,-0.013613513,0.016633676,0.01432874,0.03317648,0.010012735,-0.04163973,-0.046917714,-0.005810259,-0.026292868,-0.0018287828,-0.031238645,-0.012527576,0.006119043,-0.008899301,-0.0073255226,-0.0060662376,-0.004418407,-0.008267036,-0.0112209795,0.000648854,-0.02447927,0.007722554,0.027429774,0.001599623,0.018515037,0.047101468,0.0054676784,0.008549476,0.0067078886,0.016960567,0.034845434,0.044560548,-0.044716198,-0.03022231,-0.024557594,-0.04193142,0.011497822,0.03884473,-0.045655113,-0.0040787025,-0.05752572,0.024698334,-0.062286984,0.0033876165,-0.012924975,-0.024074987,0.025526868,-0.0055998974,0.03975676,0.001026879,0.026541438,-0.020650597,0.005901191,-0.07177258,0.03164231,-0.040629845,-0.007544283,-0.03823129,-0.01227238,0.00855386,-0.014151928,-0.03170364,0.03983463,-0.013034712,-0.016626673,0.06326452,-0.021937301,0.0067183045,-0.059997093,0.05632662,0.017267184,-0.005067676,-0.0538491,0.038443707,-0.0017900005,-0.043984976,-0.01237191,0.001981814,-0.036207564,-0.017658042,0.043680172,-0.026694784,0.020443192,-0.009260439,-0.0841962,-0.02912992,0.03509501,0.023701202,0.035757873,0.00046195614,0.02979957,0.01467394,0.04096263,-0.017357247,0.0022794693,-0.019741815,0.014929718,-0.025134442,-0.008470409,0.036124267,0.038386468,-0.0030913744,-0.06052092,-0.058896173,0.036434572,-0.035856195,-0.025518596,0.00680977,-0.034559492,-0.018460423,0.038163867,0.003846145,-0.03699342,-0.003763036,-0.005479058,0.030246416,-0.023123622,0.0026447477,0.061710738,-0.022547577,0.018841598,-0.045318406,-0.0070846025,0.008784875,-0.010895882,0.06942534,-0.025845362,-0.023792176,-0.00038168198,0.042610385,0.04108059,-0.016089676,0.027842114,0.025923677,-0.053669017,-0.031024603,-0.016690418,0.04483044,-0.0149049405,0.04066559,-0.00624366,0.027002865,-0.016758023,-0.02927778,-0.0326105,-0.03432184,-0.005334441,-0.038463715,-0.0034037228,0.0008067203,-0.023125874,-0.014216495,-0.0258641,-0.023211274,-0.0034454672,-0.05031805,0.039549246,-0.018048802,-0.047990456,0.0035182927,-0.0035705846,-0.030171258,-0.014190866,-0.04387725,0.00019319,0.022491088,-0.027673312,-0.037798118,-0.047220968,-0.02028129,0.039071847,-0.028935669,0.005071786,-0.022789024,0.0044902246,0.010322299,0.0143472515,-0.02751301,0.03364476,0.038169496,-0.0034278017,-0.033884935,0.002485187,-0.0022179838,-0.0011136081,0.0043389034,-0.006733061,-0.019169493,-0.030171745,0.007693608,0.044669572,-0.04269606,-0.016969353,-0.028295727,-0.04728079,-0.03196212,0.015705792,0.048704285,0.040286124,-0.016095676,0.0030015,-0.05178687,0.0121514825,0.036588747,-0.016260412,0.046915226,0.016090995,-0.0152722,-0.019383209,0.052064795,-0.005520124,0.012496875,-0.011268536,-0.046488665,-0.0028228047,-0.01969406,-0.004089098,-0.007824733,0.042113293,0.010388816,0.0035060646,0.054660395,-0.00047428647,0.006935092,0.019209482,-0.0563103,-0.028973313,0.015712792,-0.0039321627,-0.008403996,-0.050732505,-0.035759445,0.027148101,0.010076515,-0.012744154,-0.00859767,0.011233682,0.030118998,0.034894515,-0.044370536,0.037527546,0.023297936,0.028220728,-0.0067965603,-0.045794852,-0.047052253,0.006426042,0.029320577,-0.024412392,-0.04755777,0.019949567,-0.025508042,0.011653755,-0.0028663003,0.028918067,-0.030806331,0.023667978,-0.025021339,0.017170152,0.0059376187,0.024555411,0.01576202,0.014028165,0.005970833,-0.0074647604,0.013348655,-0.0132711055,0.003482679,0.022377368,-0.01804484,-0.024772944,-0.032636322,0.0021381245,0.06506253,0.013079311,0.023937138,-0.029751314,0.009827686,0.008173684,-0.04483375,0.012957062,0.04744799,-0.026743865,-0.028382678,0.00024941994,-0.029100472,0.015333971,-0.010052065,-0.020773808,-0.025696239,-0.025709638,-0.0056186137,0.038222007,-0.040016845,0.025071187,-0.041087832,0.014998046,-0.05530576,-0.024562253,0.027504114,0...", + "payload": "\"{\\\"issue\\\":{\\\"nodeId\\\":\\\"I_kwDOI-EJSM6FW-ET\\\",\\\"number\\\":220,\\\"title\\\":\\\"better mobile feedback\\\",\\\"body\\\":\\\"Non-Web3 friendly mobile browsers need better feedback.\\\\r\\\\n\\\\r\\\\n- it should not be stuck in a loading state\\\\r\\\\n- feedback should be either repeatable or persistent\\\\r\\\\n\\\\r\\\\n\\\\r\\\\n\\\\r\\\\n_additional context_\\\\r\\\\n\\\\r\\\\n> Had already given up. On Brave browser it doesn't even throw an error it just loads for ever.\\\\r\\\\n\\\\r\\\\n_Originally posted by @jordan-ae in https://github.com/ubiquity/devpool-directory-bounties/issues/24#issuecomment-2040538335_\\\",\\\"state\\\":\\\"CLOSED\\\",\\\"stateReason\\\":\\\"COMPLETED\\\",\\\"repositoryName\\\":\\\"pay.ubq.fi\\\",\\\"repositoryId\\\":null,\\\"assignees\\\":[\\\"Keyrxng\\\"],\\\"authorId\\\":106303466,\\\"createdAt\\\":\\\"2024-04-11T10:19:53Z\\\",\\\"closedAt\\\":\\\"2024-05-27T08:00:35Z\\\",\\\"updatedAt\\\":\\\"2024-05-27T10:27:44Z\\\"},\\\"action\\\":\\\"created\\\",\\\"sender\\\":{\\\"login\\\":\\\"keyrxng\\\"},\\\"repository\\\":{\\\"id\\\":null,\\\"node_id\\\":\\\"R_kgDOI-EJSA\\\",\\\"name\\\":\\\"pay.ubq.fi\\\",\\\"full_name\\\":\\\"ubiquity/pay.ubq.fi\\\",\\\"owner\\\":{\\\"login\\\":\\\"ubiquity\\\",\\\"id\\\":106303466,\\\"type\\\":\\\"User\\\",\\\"site_admin\\\":false}}}\"", + "author_id": "106303466", + "created_at": "2025-11-06 22:31:48.733411+00", + "modified_at": "2024-05-27 10:27:44+00", + "markdown": "Non-Web3 friendly mobile browsers need better feedback.\r\n\r\n- it should not be stuck in a loading state\r\n- feedback should be either repeatable or persistent\r\n\r\n\r\n\r\n_additional context_\r\n\r\n> Had already given up. On Brave browser it doesn't even throw an error it just loads for ever.\r\n\r\n_Originally posted by @jordan-ae in https://github.com/ubiquity/devpool-directory-bounties/issues/24#issuecomment-2040538335_ better mobile feedback" + } +] diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 100fd7c6..273231b5 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -1,11 +1,79 @@ import { http, HttpResponse } from "msw"; import { db } from "./db"; import issueTemplate from "./issue-template"; +import embeddings from "./embeddings.json"; /** * Intercepts the routes and returns a custom payload */ export const handlers = [ + // --- Supabase Auth: GET /auth/v1/user --- + http.get("https://test.supabase.co/auth/v1/user", ({ request }) => { + const auth = request.headers.get("authorization") || request.headers.get("Authorization"); + if (!auth || !auth.toLowerCase().startsWith("bearer ")) { + return HttpResponse.json({ user: null }, { status: 401 }); + } + const token = auth.split(" ")[1]; + // Simulate invalid token by checking for specific test token + if (token === "invalid-jwt") { + return HttpResponse.json({ user: null, error: { message: "Invalid token" } }, { status: 401 }); + } + // Return a minimal supabase auth payload shape for valid tokens + return HttpResponse.json({ + user: { + id: "test-id", + user_metadata: { + provider_id: 123, + access_token: "metadata-token", + }, + }, + }); + }), + // --- Supabase REST: users table lookups --- + http.get("https://test.supabase.co/rest/v1/users", ({ request }) => { + const url = new URL(request.url); + // Expect queries like: select=*&id=eq.123&limit=1 + const idEq = url.searchParams.get("id"); + const id = idEq?.startsWith("eq.") ? idEq.slice(3) : null; + if (!id) { + return HttpResponse.json([], { status: 200 }); + } + const row = { id: Number.isNaN(Number(id)) ? id : Number(id), user_metadata: { access_token: "metadata-token" } }; + return HttpResponse.json([row], { status: 200, headers: { "content-range": "0-0/1" } }); + }), + // --- Supabase REST: issues table queries --- + http.get("https://test.supabase.co/rest/v1/issues", ({ request }) => { + const url = new URL(request.url); + const select = url.searchParams.get("select") ?? ""; + // author query: select=embedding,payload&author_id=eq.&limit=100 + const authorEq = url.searchParams.get("author_id"); + const idEq = url.searchParams.get("id"); + if (authorEq && select.includes("embedding")) { + const authorId = authorEq.startsWith("eq.") ? authorEq.slice(3) : authorEq; + const rows = embeddings.filter((r) => String(r.author_id) === String(authorId)).map((r) => ({ embedding: r.embedding, payload: r.payload })); + return HttpResponse.json(rows, { status: 200, headers: { "content-range": `0-${Math.max(rows.length - 1, 0)}/${rows.length}` } }); + } + // payload by id query: select=payload&id=eq. + if (idEq && select.includes("payload")) { + const targetId = idEq.startsWith("eq.") ? idEq.slice(3) : idEq; + // Try to find matching row from embeddings fixture; else return a minimal stub + const row = embeddings.find((r) => String(r.id) === String(targetId)); + const payload = row?.payload ? row.payload : JSON.stringify({ repository: { owner: { login: "owner" }, name: "repo" }, number: 42, assignees: [] }); + return HttpResponse.json([{ payload }], { status: 200, headers: { "content-range": "0-0/1" } }); + } + return HttpResponse.json([], { status: 200 }); + }), + // --- Supabase REST RPC: find_similar_issues_annotate --- + http.post("https://test.supabase.co/rest/v1/rpc/find_similar_issues_annotate", async ({ request }) => { + // We can optionally parse body if needed; return a small fixed set + try { + await request.json(); + } catch { + // Ignore parse errors for tests + } + const items = embeddings.slice(0, 2).map((r, i) => ({ issue_id: r.id ?? `issue-${i + 1}`, similarity: 0.8 - i * 0.1 })); + return HttpResponse.json(items, { status: 200 }); + }), http.get("*/xp", ({ request }) => { const url = new URL(request.url); const identifier = url.searchParams.get("user"); @@ -157,8 +225,35 @@ export const handlers = [ if (!user) { return new HttpResponse(null, { status: 404 }); } - return HttpResponse.json(user); + return HttpResponse.json({ + login: user.login, + id: user.id, + created_at: user.created_at, + type: "User", + site_admin: false, + }); }), // get comments for an issue http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/comments", () => HttpResponse.json(db.comments.getAll())), + http.get("https://api.github.com/user", () => { + return HttpResponse.json({ + login: "test-user", + id: 123456, + username: "test-user", + }); + }), + http.get("https://api.github.com/repos/:owner/:repo", () => { + return HttpResponse.json({ + login: "test-user", + id: 123456, + username: "test-user", + }); + }), + http.get("https://api.github.com/repos/owner/repo", () => { + return HttpResponse.json({ + login: "test-user", + id: 123456, + username: "test-user", + }); + }), ]; diff --git a/tests/api-recommendations.test.ts b/tests/api-recommendations.test.ts new file mode 100644 index 00000000..74ae72eb --- /dev/null +++ b/tests/api-recommendations.test.ts @@ -0,0 +1,327 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { drop } from "@mswjs/data"; +import { db } from "./__mocks__/db"; +import { server } from "./__mocks__/node"; +import { handleRecommendations } from "../src/handlers/start/api/directory-task-recommendations"; +import { ShallowContext } from "../src/handlers/start/api/helpers/context-builder"; +import { Logs } from "@ubiquity-os/ubiquity-os-logger"; +import { Context, PluginSettings } from "../src/types"; +import { CommentHandler } from "@ubiquity-os/plugin-sdk"; + +beforeAll(() => server.listen()); +afterEach(() => { + server.resetHandlers(); + drop(db); + jest.clearAllMocks(); +}); +afterAll(() => server.close()); + +const mockSupabaseClient = { + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + limit: jest.fn(() => Promise.resolve({ data: [] })), + maybeSingle: jest.fn(() => Promise.resolve({ data: null })), + })), + })), + })), + rpc: jest.fn(), +}; + +const mockOctokit = { + rest: { + issues: { + get: jest.fn(), + }, + repos: { + get: jest.fn(), + }, + }, +}; + +jest.mock("@supabase/supabase-js", () => ({ + createClient: jest.fn(() => mockSupabaseClient), +})); + +function createMockContext(): ShallowContext { + return { + env: { + APP_ID: "123", + APP_PRIVATE_KEY: "test-key", + SUPABASE_URL: "https://test.supabase.co", + SUPABASE_KEY: "test-key", + BOT_USER_ID: 1, + }, + octokit: mockOctokit as unknown as Context["octokit"], + logger: new Logs("info"), + config: {} as PluginSettings, + command: { name: "start", parameters: { teammates: [] } }, + eventName: "issue_comment.created", + commentHandler: { postComment: jest.fn() } as unknown as CommentHandler, + payload: { + sender: { + login: "test-user", + id: 123, + }, + }, + adapters: {} as Context["adapters"], + }; +} + +describe("handleRecommendations - Basic Functionality", () => { + it("should return empty recommendations when user has no prior embeddings", async () => { + const context = createMockContext(); + + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ data: [] } as never), + }), + }) as never, + }); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toMatchObject({ + ok: true, + recommendations: [], + note: expect.stringContaining("No prior embeddings"), + }); + }); + + it("should return recommendations when user has prior work", async () => { + const context = createMockContext(); + + mockOctokit.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + title: "Test Issue", + state: "open", + assignees: [], + }, + } as never); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.ok).toBe(true); + expect(Array.isArray(data.recommendations)).toBe(true); + // MSW returns recommendations based on embeddings.json fixture + expect(data.recommendations.length).toBeGreaterThanOrEqual(0); + }); + + it("should handle custom topK and threshold options", async () => { + const context = createMockContext(); + const options = { topK: 10, threshold: 0.8 }; + + // Since we're testing with MSW, we verify the response is valid + const response = await handleRecommendations({ context, options }); + const data = await response.json(); + + // Verify response is successful (options are passed through to RPC via MSW) + expect(response.status).toBe(200); + expect(data.ok).toBe(true); + expect(Array.isArray(data.recommendations)).toBe(true); + }); +}); + +describe("handleRecommendations - Embedding Processing", () => { + it("should skip invalid embeddings", async () => { + const context = createMockContext(); + + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ + data: [ + { embedding: "invalid json", payload: JSON.stringify({}) }, + { embedding: JSON.stringify([1, 2, 3]), payload: JSON.stringify({}) }, + { embedding: JSON.stringify([]), payload: JSON.stringify({}) }, // Empty array + ], + } as never), + }), + }) as never, + }); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.recommendations).toEqual([]); + }); + + it("should calculate average embedding from multiple vectors", async () => { + const context = createMockContext(); + + // This test verifies the averaging logic works correctly + // MSW will return embeddings from fixture; we verify the response is valid + const response = await handleRecommendations({ context }); + const data = await response.json(); + + // Verify successful processing (averaging happens internally) + expect(response.status).toBe(200); + expect(data.ok).toBe(true); + }); +}); + +describe("handleRecommendations - Filtering Logic", () => { + it("should filter out issues with assignees", async () => { + const context = createMockContext(); + + const mockEmbedding = JSON.stringify(Array(384).fill(0.5)); + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ + data: [{ embedding: mockEmbedding, payload: JSON.stringify({}) }], + } as never), + }), + }) as never, + }); + + mockSupabaseClient.rpc.mockResolvedValueOnce({ + data: [{ issue_id: "issue-1", similarity: 0.85 }], + error: null, + } as never); + + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + payload: JSON.stringify({ + repository: { owner: { login: "owner" }, name: "repo" }, + number: 42, + assignees: [{ login: "someone" }], // Has assignees + }), + }, + } as never), + }), + }) as never, + }); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(data.recommendations).toEqual([]); + }); + + it("should skip issues missing org, repo, or number", async () => { + const context = createMockContext(); + + const mockEmbedding = JSON.stringify(Array(384).fill(0.5)); + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ + data: [{ embedding: mockEmbedding, payload: JSON.stringify({}) }], + } as never), + }), + }) as never, + }); + + mockSupabaseClient.rpc.mockResolvedValueOnce({ + data: [{ issue_id: "issue-1", similarity: 0.85 }], + error: null, + } as never); + + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + payload: JSON.stringify({ + repository: { owner: { login: "owner" } }, // Missing 'name' + number: 42, + assignees: [], + }), + }, + } as never), + }), + }) as never, + }); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(data.recommendations).toEqual([]); + }); + + it("should handle GitHub API errors gracefully", async () => { + const context = createMockContext(); + + const mockEmbedding = JSON.stringify(Array(384).fill(0.5)); + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue({ + data: [{ embedding: mockEmbedding, payload: JSON.stringify({}) }], + } as never), + }), + }) as never, + }); + + mockSupabaseClient.rpc.mockResolvedValueOnce({ + data: [{ issue_id: "issue-1", similarity: 0.85 }], + error: null, + } as never); + + mockSupabaseClient.from.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + payload: JSON.stringify({ + repository: { owner: { login: "owner" }, name: "repo" }, + number: 42, + assignees: [], + }), + }, + } as never), + }), + }) as never, + }); + + mockOctokit.rest.issues.get.mockRejectedValue(new Error("Not found") as never); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.recommendations).toEqual([]); + }); +}); + +describe("handleRecommendations - Response Format", () => { + it("should return properly formatted recommendations", async () => { + const context = createMockContext(); + + mockOctokit.rest.issues.get.mockResolvedValue({ + data: { + number: 123, + title: "Recommended Issue", + state: "open", + assignees: [], + }, + } as never); + + const response = await handleRecommendations({ context }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.ok).toBe(true); + + if (data.recommendations.length > 0) { + expect(data.recommendations[0]).toMatchObject({ + issueUrl: expect.any(String), + similarity: expect.any(Number), + repo: expect.any(String), + org: expect.any(String), + title: expect.any(String), + }); + } + }); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts new file mode 100644 index 00000000..a6879e4c --- /dev/null +++ b/tests/public-api.test.ts @@ -0,0 +1,315 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { drop } from "@mswjs/data"; +import { db } from "./__mocks__/db"; +import { server } from "./__mocks__/node"; +import { handlePublicStart } from "../src/handlers/start/api/public-api"; +import { Env } from "../src/types/env"; + +const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); +const FIRST_ISSUE_URL = "https://github.com/owner/repo/issues/1"; + +beforeAll(() => server.listen()); +afterEach(() => { + server.resetHandlers(); + drop(db); + jest.clearAllMocks(); + logSpy.mockClear(); +}); +afterAll(() => server.close()); + +const mockOctokit = { + rest: { + users: { + getAuthenticated: jest.fn(() => + Promise.resolve({ + data: { login: "test-user", id: 123 }, + }) + ), + }, + issues: { + get: jest.fn(), + createComment: jest.fn(), + }, + repos: { + get: jest.fn(), + }, + orgs: { + get: jest.fn(), + }, + }, +}; + +jest.mock("@ubiquity-os/plugin-sdk/octokit", () => ({ + customOctokit: jest.fn(() => mockOctokit), +})); + +function createMockEnv(): Env { + return { + APP_ID: "123", + APP_PRIVATE_KEY: "test-key", + SUPABASE_URL: "https://test.supabase.co", + SUPABASE_KEY: "test-key", + BOT_USER_ID: 1, + LOG_LEVEL: "info", + }; +} + +function createMockRequest(body: unknown, method = "POST", jwt?: string): Request { + const headers: Record = { + "content-type": "application/json", + }; + + if (jwt) { + headers.authorization = `Bearer ${jwt}`; + } + + return new Request("http://localhost:3000/start", { + method, + headers, + body: JSON.stringify(body), + }); +} + +describe("handlePublicStart - HTTP Method Validation", () => { + it("should reject non-POST requests with 405", async () => { + const request = new Request("https://test.com/start", { method: "GET" }); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + + expect(response.status).toBe(405); + }); + + it("should accept POST requests", async () => { + const request = createMockRequest({ userId: 123, issueUrl: FIRST_ISSUE_URL }); + const env = createMockEnv(); + + mockOctokit.rest.issues.get.mockResolvedValueOnce({ + data: { number: 1, title: "Test Issue", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValueOnce({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + const response = await handlePublicStart(request, env); + + expect(response.status).not.toBe(405); + }); +}); + +describe("handlePublicStart - Authentication", () => { + it("should reject requests without JWT token", async () => { + const request = createMockRequest({ userId: 123 }); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toMatchObject({ + ok: false, + reasons: expect.arrayContaining([expect.stringContaining("Authorization")]), + }); + }); + + it("should verify JWT token with Supabase", async () => { + const request = createMockRequest({ userId: 123, issueUrl: FIRST_ISSUE_URL }, "POST", "ghu_valid_token"); + const env = createMockEnv(); + + mockOctokit.rest.issues.get.mockResolvedValueOnce({ + data: { number: 1, title: "Test", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValueOnce({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + const response = await handlePublicStart(request, env); + + expect(response.status).not.toBe(401); + }); + + it("should reject invalid JWT tokens", async () => { + const request = createMockRequest({ userId: 123 }, "POST", "invalid_token"); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.ok).toBe(false); + }); +}); + +describe("handlePublicStart - Request Body Validation", () => { + it("should reject invalid JSON body", async () => { + const request = new Request("https://test.com/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "invalid json{", + }); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + ok: false, + reasons: expect.arrayContaining([expect.stringContaining("JSON")]), + }); + }); + + it("should reject missing userId", async () => { + const request = createMockRequest({}, "POST", "ghu_valid_token"); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + ok: false, + reasons: expect.arrayContaining([expect.stringContaining("userId")]), + }); + }); + + it("should validate mode parameter", async () => { + const request = createMockRequest({ userId: 123, mode: "invalid" }, "POST", "ghu_valid_token"); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.ok).toBe(false); + }); +}); + +describe("handlePublicStart - Rate Limiting", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should enforce rate limits for execute mode (3 requests per minute)", async () => { + const env = createMockEnv(); + const userId = 456; + + mockOctokit.rest.issues.get.mockResolvedValue({ + data: { number: 1, title: "Test", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + // Make 3 successful requests + for (let i = 0; i < 3; i++) { + const request = createMockRequest({ userId, issueUrl: FIRST_ISSUE_URL, mode: "execute" }, "POST", "ghu_valid_token"); + const response = await handlePublicStart(request, env); + expect(response.status).not.toBe(429); + } + + // 4th request should be rate limited + const request = createMockRequest({ userId, issueUrl: FIRST_ISSUE_URL, mode: "execute" }, "POST", "ghu_valid_token"); + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data).toMatchObject({ + ok: false, + reasons: expect.arrayContaining([expect.stringContaining("Rate limit")]), + resetAt: expect.any(Number), + }); + }); + + it("should enforce higher rate limits for validate mode (10 requests per minute)", async () => { + const env = createMockEnv(); + const userId = 789; + + mockOctokit.rest.issues.get.mockResolvedValue({ + data: { number: 1, title: "Test", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + // Make 10 successful requests + for (let i = 0; i < 10; i++) { + const request = createMockRequest({ userId, issueUrl: FIRST_ISSUE_URL, mode: "validate" }, "POST", "ghu_valid_token"); + const response = await handlePublicStart(request, env); + expect(response.status).not.toBe(429); + } + + // 11th request should be rate limited + const request = createMockRequest({ userId, issueUrl: FIRST_ISSUE_URL, mode: "validate" }, "POST", "ghu_valid_token"); + const response = await handlePublicStart(request, env); + + expect(response.status).toBe(429); + }); +}); + +describe("handlePublicStart - User Access Token Handling", () => { + it("should accept userAccessToken from request body", async () => { + const request = createMockRequest( + { + userId: 123, + issueUrl: FIRST_ISSUE_URL, + userAccessToken: "user-provided-token", + }, + "POST", + "ghu_valid_token" + ); + const env = createMockEnv(); + + mockOctokit.rest.issues.get.mockResolvedValueOnce({ + data: { number: 1, title: "Test", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValueOnce({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + const response = await handlePublicStart(request, env); + + expect(response.status).not.toBe(401); + }); + + it("should extract token from user metadata if not provided", async () => { + const request = createMockRequest({ userId: 123, issueUrl: FIRST_ISSUE_URL }, "POST", "ghu_valid_token"); + const env = createMockEnv(); + + mockOctokit.rest.issues.get.mockResolvedValueOnce({ + data: { number: 1, title: "Test", state: "open", assignees: [], labels: [] }, + } as never); + mockOctokit.rest.repos.get.mockResolvedValueOnce({ + data: { id: 1, name: "repo", owner: { login: "owner" } }, + } as never); + + const response = await handlePublicStart(request, env); + + expect(response.status).not.toBe(401); + }); + + it("should return 401 if no access token available", async () => { + const request = createMockRequest({ userId: 123, issueUrl: FIRST_ISSUE_URL }, "POST", "invalid-jwt"); + const env = createMockEnv(); + + const response = await handlePublicStart(request, env); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toMatchObject({ + ok: false, + reasons: expect.arrayContaining([expect.stringContaining("Unauthorized: Invalid JWT, expired, or user not found")]), + }); + }); +}); + +describe("handlePublicStart - Error Handling", () => { + it("should return 401 for unauthorized errors", async () => { + const request = createMockRequest({ userId: 123, issueUrl: FIRST_ISSUE_URL }, "POST", "invalid-jwt"); + const env = createMockEnv(); + + mockOctokit.rest.issues.get.mockRejectedValueOnce(new Error("Unauthorized") as never); + const response = await handlePublicStart(request, env); + expect(response.status).toBe(401); + }); +}); From 2dd5c2e8e66dbf1ec21776d0e3e54ccc46b724f0 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 8 Nov 2025 10:16:02 +0000 Subject: [PATCH 20/56] refactor: remove API task recommendation handling related code and tests --- .../api/directory-task-recommendations.ts | 28 -- .../start/api/helpers/recommendations.ts | 96 ----- src/handlers/start/api/helpers/types.ts | 12 +- src/handlers/start/api/public-api.ts | 15 +- tests/__mocks__/embeddings.json | 46 --- tests/__mocks__/handlers.ts | 34 -- tests/api-recommendations.test.ts | 327 ------------------ tests/public-api.test.ts | 121 ++++--- 8 files changed, 86 insertions(+), 593 deletions(-) delete mode 100644 src/handlers/start/api/directory-task-recommendations.ts delete mode 100644 src/handlers/start/api/helpers/recommendations.ts delete mode 100644 tests/__mocks__/embeddings.json delete mode 100644 tests/api-recommendations.test.ts diff --git a/src/handlers/start/api/directory-task-recommendations.ts b/src/handlers/start/api/directory-task-recommendations.ts deleted file mode 100644 index 06b6b0c0..00000000 --- a/src/handlers/start/api/directory-task-recommendations.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Context } from "../../../types"; -import { ShallowContext } from "./helpers/context-builder"; -import { getRecommendations } from "./helpers/recommendations"; - -/** - * Handles the recommendation flow when no issueUrl is provided. - * Uses embeddings to find similar issues based on user's prior work. - */ -export async function handleRecommendations({ - context, - options, -}: { - context: Context | ShallowContext; - options?: { topK?: number; threshold?: number }; -}): Promise { - try { - const recommendations = await getRecommendations({ context, options }); - - if (recommendations.length === 0) { - return Response.json({ ok: true, recommendations: [], note: "No prior embeddings found for user" }, { status: 200 }); - } - - return Response.json({ ok: true, recommendations }, { status: 200 }); - } catch (error) { - const message = error instanceof Error ? error.message : "Embeddings search failed"; - return Response.json({ ok: false, reasons: [message] }, { status: 500 }); - } -} diff --git a/src/handlers/start/api/helpers/recommendations.ts b/src/handlers/start/api/helpers/recommendations.ts deleted file mode 100644 index ad71cc12..00000000 --- a/src/handlers/start/api/helpers/recommendations.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; -import { Context } from "../../../../types/context"; -import { ShallowContext } from "./context-builder"; - -export type Recommendation = { - issueUrl: string; - similarity: number; - repo: string; - org: string; - title: string; -}; - -export async function getRecommendations({ - context, - options, -}: { - context: Context | ShallowContext; - options?: { topK?: number; threshold?: number }; -}): Promise { - const supabase = createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY); - const threshold = options?.threshold ?? 0.6; // 60% similarity threshold - const topK = options?.topK ?? 5; - const { - octokit, - payload: { sender }, - } = context; - - // Get user's completed/authored issues with embeddings - // filter out issues that have assignees - const { data: authored } = await supabase.from("issues").select("embedding,payload").eq("author_id", sender?.id).limit(100); - - const vectors = (authored || []) - .map((r) => { - try { - return JSON.parse(r.embedding); - } catch { - return null; - } - }) - .filter((v: number[] | null): v is number[] => Array.isArray(v) && v.length > 0); - - if (!vectors.length) { - return []; - } - - // query embedding is the average of the vectors - const queryEmbedding = vectors.reduce((acc, v) => acc.map((x, i) => x + v[i]), new Array(vectors[0].length).fill(0)); - queryEmbedding.forEach((v, i) => { - queryEmbedding[i] = v / vectors.length; - }); - - // Find similar issues - const { data: similar, error } = await supabase.rpc("find_similar_issues_annotate", { - current_id: `user-${sender?.id}`, - query_embedding: queryEmbedding, - threshold, - top_k: topK, - }); - - if (error || !Array.isArray(similar)) { - throw new Error("Embeddings search failed"); - } - - // Build candidate list with filters (open/unassigned issues only) - const results: Recommendation[] = []; - - for (const row of similar as Array<{ issue_id: string; similarity: number }>) { - const { data: rec } = await supabase.from("issues").select("payload").eq("id", row.issue_id).maybeSingle(); - const payload = JSON.parse(rec?.payload); - const org = payload?.repository?.owner?.login; - const repo = payload?.repository?.name; - const number = payload?.number ?? payload?.issue?.number; - - if (!org || !repo || !number || (payload?.assignees && payload.assignees.length)) { - continue; - } - - try { - const issue = (await octokit.rest.issues.get({ owner: org, repo, issue_number: number })).data as Context<"issue_comment.created">["payload"]["issue"]; - - const isOpen = issue.state === "open"; - const isUnassigned = !(issue.assignees && issue.assignees.length); - - if (isOpen && isUnassigned) { - const href = `https://www.github.com/${org}/${repo}/issues/${number}`; - results.push({ issueUrl: href, similarity: row.similarity, repo, org, title: issue.title }); - } - } catch (e) { - if ((e as { status: number }).status !== 404) { - throw e; - } - } - } - - return results; -} diff --git a/src/handlers/start/api/helpers/types.ts b/src/handlers/start/api/helpers/types.ts index 7cc709af..d339c2ed 100644 --- a/src/handlers/start/api/helpers/types.ts +++ b/src/handlers/start/api/helpers/types.ts @@ -1,13 +1,11 @@ export type StartBody = { userId: number; - issueUrl?: string; - teammates?: string[]; - mode?: "validate" | "execute"; - recommend?: { topK?: number; threshold?: number }; - // Development only: allows passing login directly when Supabase lookup is unavailable - login?: string; - // Optional: GitHub user OAuth access token used when the GitHub App isn't installed on the repo + issueUrl: string; + mode: "validate" | "execute"; + // Optional: GitHub OAuth access token (of any kind, e.g., user or app) userAccessToken?: string; + // Optional: Multi-assignee support - list of teammates to assign along with the user + teammates?: string[]; }; export type IssueUrlParts = { diff --git a/src/handlers/start/api/public-api.ts b/src/handlers/start/api/public-api.ts index be11a49a..0c13c8a7 100644 --- a/src/handlers/start/api/public-api.ts +++ b/src/handlers/start/api/public-api.ts @@ -3,15 +3,13 @@ import { verifySupabaseJwt, extractJwtFromHeader } from "./helpers/auth"; import { rateLimit, getClientId } from "./helpers/rate-limit"; import { buildShallowContextObject } from "./helpers/context-builder"; import { StartBody } from "./helpers/types"; -import { handleRecommendations } from "./directory-task-recommendations"; import { handleValidateOrExecute } from "./validate-or-execute"; /** * Main handler for the public start API endpoint. - * Supports three modes: - * 1. Recommendations: when issueUrl is omitted - * 2. Validate: validates eligibility without performing assignment - * 3. Execute: validates and performs assignment + * Supports two modes: + * 1. Validate: validates eligibility without performing assignment + * 2. Execute: validates and performs assignment * * @param request - HTTP request object * @param env - Environment variables @@ -32,7 +30,7 @@ export async function handlePublicStart(request: Request, env: Env): Promise