Skip to content

Commit

Permalink
Merge pull request #11 from ubq-testing/feat/teams
Browse files Browse the repository at this point in the history
Feat/teams
  • Loading branch information
Keyrxng authored Aug 20, 2024
2 parents b50f0a8 + 06127cb commit ac245ff
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 125 deletions.
86 changes: 45 additions & 41 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Assignee, Context, ISSUE_TYPE, Label } from "../../types";
import { Context, ISSUE_TYPE, Label } from "../../types";
import { isParentIssue, getAvailableOpenedPullRequests, getAssignedIssues, addAssignees, addCommentToIssue } from "../../utils/issue";
import { calculateDurations } from "../../utils/shared";
import { checkTaskStale } from "./check-task-stale";
import { generateAssignmentComment } 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"]) {
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;
Expand All @@ -18,13 +18,13 @@ 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" };
}

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 @@ -34,64 +34,54 @@ 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: ${JSON.stringify(openedPullRequests)}`);

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

// check for max and enforce max

if (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 duration: number = calculateDurations(labels).shift() ?? 0;

const { id, login } = sender;
const logMessage = logger.info("Task assigned successfully", { duration, priceLabel, revision: commitHash?.substring(0, 7) });
const { id } = sender;
const logMessage = logger.info("Task assigned successfully", {
duration,
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(login)) {
await addAssignees(context, issue.number, [login]);
}
// assign the issue
await addAssignees(context, issue.number, teammates);

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

Expand All @@ -111,3 +101,17 @@ export async function start(context: Context, issue: Context["payload"]["issue"]

return { output: "Task assigned successfully" };
}

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 @@ -11,9 +11,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 { output: "You are not assigned to this task" };
throw new Error(logger.error("You are not assigned to this task", { issueNumber, user: sender.login })?.logMessage.diff as string);
}

// close PR
Expand All @@ -26,16 +24,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
6 changes: 5 additions & 1 deletion src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ export async function userStartStop(context: Context): Promise<{ output: string
const { payload } = context;
const { issue, comment, sender, repository } = payload;
const slashCommand = comment.body.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);
return await start(context, issue, sender, teamMates);
}

return { output: null };
Expand Down
23 changes: 21 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 { userStartStop } from "./handlers/user-start-stop";
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 @@ -22,8 +23,26 @@ export async function startStopTask(inputs: PluginInputs, env: Env) {
context.adapters = createAdapters(supabase, context);

if (context.eventName === "issue_comment.created") {
await userStartStop(context);
try {
return await userStartStop(context);
} catch (err) {
let errorMessage;
if (err instanceof LogReturn) {
errorMessage = context.logger.error(`Failed to run comment evaluation. ${err.logMessage?.raw || err}`, { err });
} else {
errorMessage = context.logger.error(`Failed to run comment evaluation. ${err}`, { err });
}

await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n<!--\n${sanitizeMetadata(errorMessage?.metadata)}\n-->`);
}
} else {
context.logger.error(`Unsupported event: ${context.eventName}`);
}
}

function sanitizeMetadata(obj: LogReturn["metadata"]): string {
return JSON.stringify(obj, null, 2)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/--/g, "&#45;&#45;")
}
6 changes: 2 additions & 4 deletions src/utils/get-linked-prs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function getLinkedPullRequests(context: Context, { owner, repositor
throw new Error("Issue is not defined");
}

const { data: timeline } = (await context.octokit.issues.listEventsForTimeline({
const { data: timeline } = (await context.octokit.rest.issues.listEventsForTimeline({
owner,
repo: repository,
issue_number: issue,
Expand All @@ -42,7 +42,5 @@ export async function getLinkedPullRequests(context: Context, { owner, repositor
state: pr.state,
body: pr.body,
};
})
.filter((pr) => pr !== null)
.filter((pr) => pr.state === "open") as GetLinkedResults[];
}).filter((pr) => pr !== null && pr.state === "open") as GetLinkedResults[];
}
69 changes: 46 additions & 23 deletions src/utils/issue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Context } from "../types/context";
import { Issue, ISSUE_TYPE, PullRequest, Review } from "../types/payload";
import { Issue, PullRequest, Review } from "../types/payload";
import { getLinkedPullRequests, GetLinkedResults } from "./get-linked-prs";

export function isParentIssue(body: string) {
Expand All @@ -8,22 +8,14 @@ export function isParentIssue(body: string) {
}

export async function getAssignedIssues(context: Context, username: string): Promise<Issue[]> {
const payload = context.payload;
const { payload } = context;

try {
return await context.octokit.paginate(
context.octokit.issues.listForRepo,
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
state: ISSUE_TYPE.OPEN,
per_page: 100,
},
({ data: issues }) => issues.filter((issue: Issue) => !issue.pull_request && issue.assignee && issue.assignee.login === username)
);
return (await context.octokit.paginate(context.octokit.rest.search.issuesAndPullRequests, {
q: `is:issue is:open assignee:${username} org:${payload.repository.owner.login}`,
})) as Issue[];
} catch (err: unknown) {
context.logger.error("Fetching assigned issues failed!", { error: err as Error });
return [];
throw context.logger.error("Fetching assigned issues failed!", { error: err as Error });
}
}

Expand All @@ -34,14 +26,14 @@ export async function addCommentToIssue(context: Context, message: string | null

const issueNumber = payload.issue.number;
try {
await context.octokit.issues.createComment({
await context.octokit.rest.issues.createComment({
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: issueNumber,
body: comment,
});
} catch (err: unknown) {
context.logger.error("Adding a comment failed!", { error: err as Error });
throw context.logger.error("Adding a comment failed!", { error: err as Error });
}
}

Expand All @@ -57,15 +49,17 @@ export async function closePullRequest(context: Context, results: GetLinkedResul
state: "closed",
});
} catch (err: unknown) {
context.logger.error("Closing pull requests failed!", { error: err as Error });
throw context.logger.error("Closing pull requests failed!", { error: err as Error });
}
}

export async function closePullRequestForAnIssue(context: Context, issueNumber: number, repository: Context["payload"]["repository"], author: string) {
const { logger } = context;
if (!issueNumber) {
logger.error("Issue is not defined");
return;
throw logger.error("Issue is not defined", {
issueNumber,
repository: repository.name,
});
}

const linkedPullRequests = await getLinkedPullRequests(context, {
Expand Down Expand Up @@ -112,6 +106,35 @@ export async function closePullRequestForAnIssue(context: Context, issueNumber:
return logger.info(comment);
}

async function confirmMultiAssignment(context: Context, issueNumber: number, usernames: string[]) {
const { logger, payload, octokit } = context;

if (usernames.length < 2) {
return;
}

const { private: isPrivate } = payload.repository;

const {
data: { assignees },
} = await octokit.rest.issues.get({
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: issueNumber,
});

if (!assignees?.length) {
throw logger.error("We detected that this task was not assigned to anyone. Please report this to the maintainers.", { issueNumber, usernames });
}

if (isPrivate && assignees?.length <= 1) {
const log = logger.info("This task belongs to a private repo and can only be assigned to one user without an official paid GitHub subscription.", {
issueNumber,
});
await addCommentToIssue(context, log?.logMessage.diff as string);
}
}

export async function addAssignees(context: Context, issueNo: number, assignees: string[]) {
const payload = context.payload;

Expand All @@ -125,6 +148,8 @@ export async function addAssignees(context: Context, issueNo: number, assignees:
} catch (e: unknown) {
throw context.logger.error("Adding the assignee failed", { assignee: assignees, issueNo, error: e as Error });
}

await confirmMultiAssignment(context, issueNo, assignees);
}

export async function getAllPullRequests(context: Context, state: "open" | "closed" | "all" = "open") {
Expand All @@ -138,8 +163,7 @@ export async function getAllPullRequests(context: Context, state: "open" | "clos
per_page: 100,
})) as PullRequest[];
} catch (err: unknown) {
context.logger.error("Fetching all pull requests failed!", { error: err as Error });
return [];
throw context.logger.error("Fetching all pull requests failed!", { error: err as Error });
}
}

Expand All @@ -160,8 +184,7 @@ export async function getAllPullRequestReviews(context: Context, pullNumber: num
},
})) as Review[];
} catch (err: unknown) {
context.logger.error("Fetching all pull request reviews failed!", { error: err as Error });
return [];
throw context.logger.error("Fetching all pull request reviews failed!", { error: err as Error });
}
}

Expand Down
Loading

0 comments on commit ac245ff

Please sign in to comment.