Skip to content

Commit

Permalink
chore: merging
Browse files Browse the repository at this point in the history
  • Loading branch information
gentlementlegen committed Aug 27, 2024
2 parents df7ff28 + 930c45e commit c148fba
Show file tree
Hide file tree
Showing 15 changed files with 9,637 additions and 6,792 deletions.
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,10 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu
command: "\/start|\/stop"
example: "/start" # or "/stop"
with:
timers:
reviewDelayTolerance: 86000
taskStaleTimeoutDuration: 2580000
miscellaneous:
maxConcurrentTasks: 3
startRequiresWallet: true # default is true
reviewDelayTolerance: "3 Days"
taskStaleTimeoutDuration: "30 Days"
maxConcurrentTasks: 3
startRequiresWallet: true # default is true
```
# Testing
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@octokit/webhooks": "13.2.7",
"@sinclair/typebox": "^0.32.5",
"@supabase/supabase-js": "2.42.0",
"@ubiquity-dao/ubiquibot-logger": "^1.3.0",
"@ubiquity-dao/ubiquibot-logger": "^1.3.1",
"dotenv": "^16.4.4",
"ms": "^2.1.3",
"typebox-validators": "^0.3.5"
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/supabase/helpers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class User extends Super {
const { data, error } = (await this.supabase.from("users").select("wallets(*)").eq("id", userId).single()) as { data: { wallets: Wallet }; error: unknown };
if ((error && !data) || !data.wallets?.address) {
this.context.logger.error("No wallet address found", { userId, issueNumber });
if (this.context.config.miscellaneous.startRequiresWallet) {
if (this.context.config.startRequiresWallet) {
await addCommentToIssue(this.context, "```diff\n! Please set your wallet address with the /wallet command first and try again.\n```");
throw new Error("No wallet address found");
} else {
Expand Down
14 changes: 8 additions & 6 deletions src/handlers/shared/check-task-stale.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export function checkTaskStale(staleTask: number, createdAt: string) {
if (staleTask !== 0) {
const days = Math.floor((new Date().getTime() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24));
const staleToDays = Math.floor(staleTask / (1000 * 60 * 60 * 24));
return days >= staleToDays && staleToDays > 0;
export function checkTaskStale(staleTaskMilliseconds: number, createdAt: string): boolean {
if (staleTaskMilliseconds === 0) {
return false;
}

return false;
const currentDate = new Date();
const createdDate = new Date(createdAt);
const millisecondsSinceCreation = currentDate.getTime() - createdDate.getTime();

return millisecondsSinceCreation >= staleTaskMilliseconds;
}
2 changes: 1 addition & 1 deletion src/handlers/shared/generate-assignment-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function generateAssignmentComment(context: Context, issueCreatedAt

return {
daysElapsedSinceTaskCreation: Math.floor((startTime - new Date(issueCreatedAt).getTime()) / 1000 / 60 / 60 / 24),
deadline,
deadline: duration > 0 ? deadline : null,
registeredWallet:
(await context.adapters.supabase.user.getWalletByUserId(senderId, issueNumber)) ||
"Register your wallet address using the following slash command: `/wallet 0x0000...0000`",
Expand Down
95 changes: 46 additions & 49 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { Assignee, Context, ISSUE_TYPE, Label, Sender } from "../../types";
import { addAssignees, addCommentToIssue, getAssignedIssues, getAvailableOpenedPullRequests, isParentIssue } from "../../utils/issue";
import { Result } from "../proxy";
import { Context, ISSUE_TYPE, Label } from "../../types";
import { addAssignees, addCommentToIssue, getAssignedIssues, getAvailableOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue";
import { hasUserBeenUnassigned } from "./check-assignments";
import { checkTaskStale } from "./check-task-stale";
import { generateAssignmentComment, getDeadline } from "./generate-assignment-comment";
import structuredMetadata from "./structured-metadata";
import { assignTableComment } from "./table";

export async function start(context: Context, issue: Context["payload"]["issue"], sender: Sender): Promise<Result> {
export async function start(context: Context, issue: Context["payload"]["issue"], sender: Context["payload"]["sender"], teammates: string[]) {
const { logger, config } = context;
const { maxConcurrentTasks } = config.miscellaneous;
const { taskStaleTimeoutDuration } = config.timers;
const { maxConcurrentTasks, taskStaleTimeoutDuration } = config;

// is it a child issue?
if (issue.body && isParentIssue(issue.body)) {
Expand All @@ -19,7 +17,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
"```diff\n# Please select a child issue from the specification checklist to work on. The '/start' command is disabled on parent issues.\n```"
);
logger.error(`Skipping '/start' since the issue is a parent issue`);
throw new Error("Issue is a parent issue");
return { output: "Parent issue detected" };
}

const hasBeenPreviouslyUnassigned = await hasUserBeenUnassigned(context);
Expand All @@ -33,7 +31,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
let commitHash: string | null = null;

try {
const hashResponse = await context.octokit.repos.getCommit({
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,
Expand All @@ -43,71 +41,56 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
logger.error("Error while getting commit hash", { error: e as Error });
}

// check max assigned issues

const openedPullRequests = await getAvailableOpenedPullRequests(context, sender.login);
logger.info(`Opened Pull Requests with approved reviews or with no reviews but over 24 hours have passed: `, {
openedPullRequests: openedPullRequests.map((p) => p.html_url),
});

const assignedIssues = await getAssignedIssues(context, sender.login);
logger.info("Max issue allowed is", { maxConcurrentTasks, assignedIssues: assignedIssues.map((i) => i.html_url) });

// check for max and enforce max

if (Math.abs(assignedIssues.length - openedPullRequests.length) >= maxConcurrentTasks) {
const log = logger.error("Too many assigned issues, you have reached your max limit", {
assignedIssues: assignedIssues.length,
openedPullRequests: openedPullRequests.length,
maxConcurrentTasks,
});
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error(`Too many assigned issues, you have reached your max limit of ${maxConcurrentTasks} issues.`);
}

// is it assignable?

if (issue.state === ISSUE_TYPE.CLOSED) {
const log = logger.error("This issue is closed, please choose another.", { issueNumber: issue.number });
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error("Issue is closed");
throw logger.error("This issue is closed, please choose another.", { issueNumber: issue.number });
}

const assignees = (issue?.assignees ?? []).filter(Boolean);
const assignees = issue?.assignees ?? [];

// find out if the issue is already assigned
if (assignees.length !== 0) {
const log = logger.error("The issue is already assigned. Please choose another unassigned task.", { issueNumber: issue.number });
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error("Issue is already assigned");
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 }
);
}

// get labels
teammates.push(sender.login);

// check max assigned issues
for (const user of teammates) {
await handleTaskLimitChecks(user, context, maxConcurrentTasks, logger, sender.login);
}

// get labels
const labels = issue.labels;
const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: "));

if (!priceLabel) {
const log = logger.error("No price label is set to calculate the duration", { issueNumber: issue.number });
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error("No price label is set to calculate the duration");
throw logger.error("No price label is set to calculate the duration", { issueNumber: issue.number });
}

const deadline = getDeadline(issue);

const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, deadline);
const { id } = sender;
const logMessage = logger.info("Task assigned successfully", {
taskDeadline: assignmentComment.deadline,
taskAssignees: [...assignees.map((a) => a?.login), sender.id],
deadline,
priceLabel,
revision: commitHash?.substring(0, 7),
assignees: teammates,
issue: issue.number,
});

const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, id, duration);
const metadata = structuredMetadata.create("Assignment", logMessage);

// add assignee
if (!assignees.map((i: Partial<Assignee>) => i?.login).includes(sender.login)) {
await addAssignees(context, issue.number, [sender.login]);
}
// assign the issue
await addAssignees(context, issue.number, teammates);

const isTaskStale = checkTaskStale(taskStaleTimeoutDuration, issue.created_at);
const isTaskStale = checkTaskStale(getTimeValue(taskStaleTimeoutDuration), issue.created_at);

await addCommentToIssue(
context,
Expand All @@ -125,3 +108,17 @@ export async function start(context: Context, issue: Context["payload"]["issue"]

return { content: "Task assigned successfully", status: "ok" };
}

async function handleTaskLimitChecks(username: string, context: Context, maxConcurrentTasks: number, logger: Context["logger"], sender: string) {
const openedPullRequests = await getAvailableOpenedPullRequests(context, username);
const assignedIssues = await getAssignedIssues(context, username);

// check for max and enforce max
if (assignedIssues.length - openedPullRequests.length >= maxConcurrentTasks) {
throw logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, {
assignedIssues: assignedIssues.length,
openedPullRequests: openedPullRequests.length,
maxConcurrentTasks,
});
}
}
26 changes: 16 additions & 10 deletions src/handlers/shared/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ export async function stop(context: Context, issue: Context["payload"]["issue"],
const userToUnassign = assignees.find((assignee: Partial<Assignee>) => assignee?.login?.toLowerCase() === sender.login.toLowerCase());

if (!userToUnassign) {
const log = logger.error("You are not assigned to this task", { issueNumber, user: sender.login });
await addCommentToIssue(context, log?.logMessage.diff as string);
return { content: "You are not assigned to this task", status: "ok" };
throw new Error(logger.error("You are not assigned to this task", { issueNumber, user: sender.login })?.logMessage.diff as string);
}

// close PR
Expand All @@ -27,16 +25,24 @@ export async function stop(context: Context, issue: Context["payload"]["issue"],

// remove assignee

await context.octokit.rest.issues.removeAssignees({
owner: login,
repo: name,
issue_number: issueNumber,
assignees: [sender.login],
});
try {
await context.octokit.rest.issues.removeAssignees({
owner: login,
repo: name,
issue_number: issueNumber,
assignees: [userToUnassign.login],
});
} catch (err) {
throw logger.error(`Error while removing ${userToUnassign.login} from the issue: `, {
err,
issueNumber,
user: userToUnassign.login,
});
}

const unassignedLog = logger.info("You have been unassigned from the task", {
issueNumber,
user: sender.login,
user: userToUnassign.login,
});

await addCommentToIssue(context, unassignedLog?.logMessage.diff as string);
Expand Down
21 changes: 9 additions & 12 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,22 @@ import { getDeadline } from "./shared/generate-assignment-comment";
import { start } from "./shared/start";
import { stop } from "./shared/stop";

function getUser(payload: Context["payload"]) {
const { comment, sender } = payload;
return comment.user?.login ? { login: comment.user.login, id: comment.user.id } : { login: sender.login, id: sender.id };
}

export async function userStartStop(context: Context): Promise<Result> {
export async function userStartStop(context: Context): Promise<{ output: string | null }> {
const { payload } = context;
const { issue, comment, repository } = payload;
const { issue, comment, sender, repository } = payload;
const slashCommand = comment.body.split(" ")[0].replace("/", "");

const user = getUser(context.payload);
const teamMates = comment.body
.split("@")
.slice(1)
.map((teamMate) => teamMate.split(" ")[0]);

if (slashCommand === "stop") {
return await stop(context, issue, user, repository);
return await stop(context, issue, sender, repository);
} else if (slashCommand === "start") {
return await start(context, issue, user);
return await start(context, issue, sender, teamMates);
}

return { status: "skipped" };
return { output: null };
}

export async function userSelfAssign(context: Context): Promise<Result> {
Expand Down
21 changes: 19 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Octokit } from "@octokit/rest";
import { createClient } from "@supabase/supabase-js";
import { Logs } from "@ubiquity-dao/ubiquibot-logger";
import { LogReturn, Logs } from "@ubiquity-dao/ubiquibot-logger";
import { createAdapters } from "./adapters";
import { proxyCallbacks } from "./handlers/proxy";
import { Context, Env, PluginInputs } from "./types";
import { addCommentToIssue } from "./utils/issue";

export async function startStopTask(inputs: PluginInputs, env: Env) {
const octokit = new Octokit({ auth: inputs.authToken });
Expand All @@ -21,5 +22,21 @@ export async function startStopTask(inputs: PluginInputs, env: Env) {

context.adapters = createAdapters(supabase, context);

return proxyCallbacks(context)[inputs.eventName](context, env);
try {
return proxyCallbacks(context)[inputs.eventName](context, env);
} catch (err) {
let errorMessage;
if (err instanceof LogReturn) {
errorMessage = err;
} else if (err instanceof Error) {
errorMessage = context.logger.error(err.message, { error: err });
} else {
errorMessage = context.logger.error("An error occurred", { err });
}
await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n<!--\n${sanitizeMetadata(errorMessage?.metadata)}\n-->`);
}
}

function sanitizeMetadata(obj: LogReturn["metadata"]): string {
return JSON.stringify(obj, null, 2).replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
}
54 changes: 11 additions & 43 deletions src/types/plugin-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,17 @@ export interface PluginInputs<T extends SupportedEventsU = SupportedEventsU, TU
ref: string;
}

const ONE_DAY = 24 * 60 * 60 * 1000;

const userRoleSchema = T.Union([T.Literal("Admin"), T.Literal("Member"), T.Literal("Contributor"), T.String()]);

export const startStopSchema = T.Object({
timers: T.Object(
{
reviewDelayTolerance: T.Number(),
taskStaleTimeoutDuration: T.Number(),
},
{ default: { reviewDelayTolerance: ONE_DAY * 5, taskStaleTimeoutDuration: ONE_DAY * 30 } }
),
miscellaneous: T.Object(
{
startRequiresWallet: T.Boolean(),
maxConcurrentTasks: T.Array(
T.Object({
role: userRoleSchema,
limit: T.Integer(),
})
),
},
{
default: {
maxConcurrentTasks: [
{
role: "Admin",
limit: 20,
},
{
role: "Member",
limit: 10,
},
{
role: "Contributor",
limit: 2,
},
],
startRequiresWallet: true,
},
}
),
});
export const startStopSchema = T.Object(
{
reviewDelayTolerance: T.String({ default: "1 Day" }),
taskStaleTimeoutDuration: T.String({ default: "30 Days" }),
maxConcurrentTasks: T.Number({ default: 3 }),
startRequiresWallet: T.Boolean({ default: true }),
},
{
default: {},
}
);

export type StartStopSettings = StaticDecode<typeof startStopSchema>;
export const startStopSettingsValidator = new StandardValidator(startStopSchema);
Loading

0 comments on commit c148fba

Please sign in to comment.