Skip to content

Commit

Permalink
Merge pull request #40 from ubiquibot/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
gentlementlegen authored Sep 8, 2024
2 parents 1a2feed + 2be6491 commit adb28b3
Show file tree
Hide file tree
Showing 24 changed files with 567 additions and 251 deletions.
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"ubiquibot",
"signoff",
"sonarjs",
"mswjs"
"mswjs",
"unassignment",
"unassignments"
],
"dictionaries": ["typescript", "node", "software-terms"],
"import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"],
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/worker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ jobs:
secrets: |
SUPABASE_URL
SUPABASE_KEY
APP_ID
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
APP_ID: ${{ secrets.APP_ID }}

- name: Write Deployment URL to Summary
run: |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu
taskStaleTimeoutDuration: "30 Days"
maxConcurrentTasks: 3
startRequiresWallet: true # default is true
emptyWalletText: "Please set your wallet address with the /wallet command first and try again."
```
# Testing
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Start | Stop",
"description": "Assign or un-assign yourself from an issue.",
"ubiquity:listeners": ["issue_comment.created"],
"ubiquity:listeners": ["issue_comment.created", "issues.assigned"],
"commands": {
"start": {
"ubiquity:example": "/start",
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/supabase/helpers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ export class User extends Super {
if ((error && !data) || !data.wallets?.address) {
this.context.logger.error("No wallet address found", { userId, issueNumber });
if (this.context.config.startRequiresWallet) {
await addCommentToIssue(this.context, "```diff\n! Please set your wallet address with the /wallet command first and try again.\n```");
await addCommentToIssue(this.context, this.context.config.emptyWalletText);
throw new Error("No wallet address found");
} else {
await addCommentToIssue(this.context, "```diff\n# Please set your wallet address with the /wallet command in order to be eligible for rewards.\n```");
await addCommentToIssue(this.context, this.context.config.emptyWalletText);
}
} else {
this.context.logger.info("Successfully fetched wallet", { userId, address: data.wallets?.address });
Expand Down
10 changes: 10 additions & 0 deletions src/handlers/result-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export enum HttpStatusCode {
OK = 200,
NOT_MODIFIED = 304,
}

export interface Result {
status: HttpStatusCode;
content?: string;
reason?: string;
}
92 changes: 92 additions & 0 deletions src/handlers/shared/check-assignments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Context } from "../../types";
import { getOwnerRepoFromHtmlUrl } from "../../utils/issue";

async function getUserStopComments(context: Context, username: string): Promise<number> {
const { payload, octokit, logger } = context;
const { number, html_url } = payload.issue;
const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url);

try {
const comments = await octokit.paginate(octokit.issues.listComments, {
owner,
repo,
issue_number: number,
});

return comments.filter((comment) => comment.body?.includes("/stop") && comment.user?.login.toLowerCase() === username.toLowerCase()).length;
} catch (error) {
throw new Error(logger.error("Error while getting user stop comments", { error: error as Error }).logMessage.raw);
}
}

export async function hasUserBeenUnassigned(context: Context, username: string): Promise<boolean> {
const {
env: { APP_ID },
} = context;
const events = await getAssignmentEvents(context);
const userAssignments = events.filter((event) => event.assignee === username);

if (userAssignments.length === 0) {
return false;
}

const unassignedEvents = userAssignments.filter((event) => event.event === "unassigned");
// all bot unassignments (/stop, disqualification, etc)
// TODO: task-xp-guard: will also prevent future assignments so we need to add a comment tracker we can use here
const botUnassigned = unassignedEvents.filter((event) => event.actorId === APP_ID);
// UI assignment
const adminUnassigned = unassignedEvents.filter((event) => event.actor !== username && event.actorId !== APP_ID);
// UI assignment
const userUnassigned = unassignedEvents.filter((event) => event.actor === username);
const userStopComments = await getUserStopComments(context, username);
/**
* Basically the bot will be the actor in most cases but if we
* remove the /stop usage which does not trigger future disqualification
* then any other bot unassignment will be considered valid
*/

const botMinusUserStopCommands = Math.max(0, botUnassigned.length - userStopComments);
const userUiMinusUserStopCommands = Math.max(0, userUnassigned.length - userStopComments);

return botMinusUserStopCommands > 0 || userUiMinusUserStopCommands > 0 || adminUnassigned.length > 0;
}

async function getAssignmentEvents(context: Context) {
const { repository, issue } = context.payload;
try {
const data = await context.octokit.paginate(context.octokit.issues.listEventsForTimeline, {
owner: repository.owner.login,
repo: repository.name,
issue_number: issue.number,
});

const events = data
.filter((event) => event.event === "assigned" || event.event === "unassigned")
.map((event) => {
let actor, assignee, createdAt, actorId;

if ((event.event === "unassigned" || event.event === "assigned") && "actor" in event && event.actor && "assignee" in event && event.assignee) {
actor = event.actor.login;
assignee = event.assignee.login;
createdAt = event.created_at;
actorId = event.actor.id;
}

return {
event: event.event,
actor,
actorId,
assignee,
createdAt,
};
});

return events
.filter((event) => event !== undefined)
.sort((a, b) => {
return new Date(a.createdAt || "").getTime() - new Date(b.createdAt || "").getTime();
});
} catch (error) {
throw new Error(context.logger.error("Error while getting assignment events", { error: error as Error }).logMessage.raw);
}
}
24 changes: 16 additions & 8 deletions src/handlers/shared/generate-assignment-comment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Context } from "../../types/context";
import { Context } from "../../types";
import { calculateDurations } from "../../utils/shared";

const options: Intl.DateTimeFormatOptions = {
export const options: Intl.DateTimeFormatOptions = {
weekday: "short",
month: "short",
day: "numeric",
Expand All @@ -10,16 +11,23 @@ const options: Intl.DateTimeFormatOptions = {
timeZoneName: "short",
};

export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, duration: number) {
export function getDeadline(issue: Context["payload"]["issue"]): string | null {
if (!issue?.labels) {
throw new Error("No labels are set.");
}
const startTime = new Date().getTime();
const duration: number = calculateDurations(issue.labels).shift() ?? 0;
if (!duration) return null;
const endTime = new Date(startTime + duration * 1000);
return endTime.toLocaleString("en-US", options);
}

export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, deadline: string | null) {
const startTime = new Date().getTime();
let endTime: null | Date = null;
let deadline: null | string = null;
endTime = new Date(startTime + duration * 1000);
deadline = endTime.toLocaleString("en-US", options);

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

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

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

let commitHash: string | null = null;
Expand All @@ -36,51 +36,66 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
// is it assignable?

if (issue.state === ISSUE_TYPE.CLOSED) {
throw logger.error("This issue is closed, please choose another.", { issueNumber: issue.number });
throw new Error(logger.error("This issue is closed, please choose another.", { issueNumber: issue.number }).logMessage.raw);
}

const assignees = issue?.assignees ?? [];

// find out if the issue is already assigned
if (assignees.length !== 0) {
const isCurrentUserAssigned = !!assignees.find((assignee) => assignee?.login === sender.login);
throw logger.error(
isCurrentUserAssigned ? "You are already assigned to this task." : "This issue is already assigned. Please choose another unassigned task.",
{ issueNumber: issue.number }
throw new Error(
logger.error(
isCurrentUserAssigned ? "You are already assigned to this task." : "This issue is already assigned. Please choose another unassigned task.",
{ issueNumber: issue.number }
).logMessage.raw
);
}

teammates.push(sender.login);

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

let error: string | null = null;

if (toAssign.length === 0 && teammates.length > 1) {
error = "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.";
} else if (toAssign.length === 0) {
error = "You have reached your max task limit. Please close out some tasks before assigning new ones.";
}

if (error) {
throw new Error(logger.error(error, { issueNumber: issue.number }).logMessage.raw);
}

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

if (!priceLabel) {
throw logger.error("No price label is set to calculate the duration", { issueNumber: issue.number });
throw new Error(logger.error("No price label is set to calculate the duration", { issueNumber: issue.number }).logMessage.raw);
}

const duration: number = calculateDurations(labels).shift() ?? 0;
const deadline = getDeadline(issue);
const toAssignIds = await fetchUserIds(context, toAssign);

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

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

// assign the issue
await addAssignees(context, issue.number, teammates);
// add assignee
await addAssignees(context, issue.number, toAssign);

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

Expand All @@ -98,19 +113,46 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
].join("\n") as string
);

return { output: "Task assigned successfully" };
return { content: "Task assigned successfully", status: HttpStatusCode.OK };
}

async function fetchUserIds(context: Context, username: string[]) {
const ids = [];

for (const user of username) {
const { data } = await context.octokit.rest.users.getByUsername({
username: user,
});

ids.push(data.id);
}

if (ids.filter((id) => !id).length > 0) {
throw new Error("Error while fetching user ids");
}

return ids;
}

async function handleTaskLimitChecks(username: string, context: Context, maxConcurrentTasks: number, logger: Context["logger"], sender: string) {
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`, {

if (Math.abs(assignedIssues.length - openedPullRequests.length) >= maxConcurrentTasks) {
const log = logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, {
assignedIssues: assignedIssues.length,
openedPullRequests: openedPullRequests.length,
maxConcurrentTasks,
});
await addCommentToIssue(context, log?.logMessage.diff as string);
return false;
}

if (await hasUserBeenUnassigned(context, username)) {
throw new Error(logger.error(`${username} you were previously unassigned from this task. You cannot be reassigned.`, { username }).logMessage.raw);
}

return true;
}
Loading

0 comments on commit adb28b3

Please sign in to comment.