Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/teams #11

Merged
merged 18 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 44 additions & 32 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 Down Expand Up @@ -34,26 +34,6 @@ 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) {
Expand All @@ -62,15 +42,27 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
throw new Error("Issue is closed");
}

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 });
const isCurrentUserAssigned = !!assignees.find((assignee) => assignee?.login === sender.login);
const log = logger.error(
isCurrentUserAssigned ? "You are already assigned to this task." : "This 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");
throw new Error(log?.logMessage.diff);
}

// 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: "));

Expand All @@ -82,16 +74,20 @@ export async function start(context: Context, issue: Context["payload"]["issue"]

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 +107,19 @@ 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) {
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
const openedPullRequests = await getAvailableOpenedPullRequests(context, username);
const assignedIssues = await getAssignedIssues(context, username);

// check for max and enforce max
if (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);
throw new Error(log?.logMessage.diff);
}
}
18 changes: 11 additions & 7 deletions src/handlers/shared/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ 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 new Error(`Error while removing ${userToUnassign.login} from the issue: ${err}`);
}

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
4 changes: 1 addition & 3 deletions src/utils/get-linked-prs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
65 changes: 44 additions & 21 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.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 @@ -41,7 +33,7 @@ export async function addCommentToIssue(context: Context, message: string | null
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,7 +49,7 @@ 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 });
}
}

Expand Down Expand Up @@ -112,11 +104,42 @@ 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.issues.get({
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: issueNumber,
});

if (!assignees?.length) {
const log = logger.error("We detected that this task was not assigned to anyone. Please report this to the maintainers.", { issueNumber, usernames });
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error(log?.logMessage.raw);
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
}

if (isPrivate && assignees?.length <= 1) {
const log = logger.error("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;

try {
await context.octokit.rest.issues.addAssignees({
await context.octokit.issues.addAssignees({
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: issueNo,
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
15 changes: 14 additions & 1 deletion tests/__mocks__/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const handlers = [
db.issue.update({
where: { id: { equals: issue.id } },
data: {
assignees,
assignees: [...issue.assignees, ...assignees],
},
});
}
Expand All @@ -107,4 +107,17 @@ export const handlers = [
http.delete("https://api.github.com/repos/:owner/:repo/issues/:issue_number/assignees", ({ params: { owner, repo, issue_number: issueNumber } }) =>
HttpResponse.json({ owner, repo, issueNumber })
),
// search issues
http.get("https://api.github.com/search/issues", () => {
const issues = [db.issue.findFirst({ where: { number: { equals: 1 } } })];
return HttpResponse.json({ items: issues });
}),
// get issue by number
http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number", ({ params: { owner, repo, issue_number: issueNumber } }) =>
HttpResponse.json(
db.issue.findFirst({
where: { owner: { equals: owner as string }, repo: { equals: repo as string }, number: { equals: Number(issueNumber) } },
})
)
),
];
20 changes: 18 additions & 2 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ describe("User start/stop", () => {
expect(output).toEqual("Task assigned successfully");
});

test("User can start an issue with teammates", async () => {
const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue;
const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Sender;

const context = createContext(issue, sender, "/start @user2");

context.adapters = createAdapters(getSupabase(), context as unknown as Context);

const { output } = await userStartStop(context as unknown as Context);

expect(output).toEqual("Task assigned successfully");

const issue2 = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue;
expect(issue2.assignees).toHaveLength(2);
expect(issue2.assignees).toEqual(expect.arrayContaining(["ubiquity", "user2"]));
});

test("User can stop an issue", async () => {
const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }) as unknown as Issue;
const sender = db.users.findFirst({ where: { id: { equals: 2 } } }) as unknown as Sender;
Expand Down Expand Up @@ -119,7 +136,7 @@ describe("User start/stop", () => {

context.adapters = createAdapters(getSupabase(), context as unknown as Context);

const err = "Issue is already assigned";
const err = "```diff\n! This issue is already assigned. Please choose another unassigned task.\n```";

try {
await userStartStop(context as unknown as Context);
Expand Down Expand Up @@ -416,7 +433,6 @@ async function setupTests() {
},
body: "Pull request body",
owner: "ubiquity",

repo: "test-repo",
state: "open",
closed_at: null,
Expand Down