Skip to content

Commit

Permalink
Merge pull request #113 from gentlementlegen/fix/start-again
Browse files Browse the repository at this point in the history
fix: users now can start again after a `/stop` command
  • Loading branch information
gentlementlegen authored Dec 28, 2024
2 parents 355cfed + 94d773f commit d80c772
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 91 deletions.
4 changes: 2 additions & 2 deletions dist/index.js

Large diffs are not rendered by default.

96 changes: 7 additions & 89 deletions src/handlers/shared/check-assignments.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,16 @@
import { Context } from "../../types";
import { getOwnerRepoFromHtmlUrl } from "../../utils/issue";

async function getUserStopComments(context: Context, username: string): Promise<number> {
if (!("issue" in context.payload)) {
throw new Error("The context does not contain an issue.");
}
const { payload, octokit, logger } = context;
const { number, html_url } = payload.issue;
const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url);

try {
const comments = await octokit.paginate(octokit.rest.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);
}
}
import { getAssignmentPeriods } from "./user-assigned-timespans";

export async function hasUserBeenUnassigned(context: Context, username: string): Promise<boolean> {
const {
env: { BOT_USER_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 === BOT_USER_ID);
// UI assignment
const adminUnassigned = unassignedEvents.filter((event) => event.actor !== username && event.actorId !== BOT_USER_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) {
if (!("issue" in context.payload)) {
throw new Error("The context does not contain an issue.");
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");
}
const { repository, issue } = context.payload;
try {
const data = await context.octokit.paginate(context.octokit.rest.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);
}
return false;
}
83 changes: 83 additions & 0 deletions src/handlers/shared/user-assigned-timespans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Context } from "../../types";

interface IssueParams {
owner: string;
repo: string;
issue_number: number;
}

interface UserAssignments {
[username: string]: AssignmentPeriod[];
}

interface AssignmentPeriod {
assignedAt: string;
unassignedAt: string | null;
reason: "user" | "bot" | "admin";
}

/*
* Returns all the assignment periods by user, with the reason of the un-assignments. If it is instigated by the user,
* (e.g. GitHub UI or using /stop), the reason will be "user", otherwise "bot", or "admin" if the admin is the
* instigator.
*/
export async function getAssignmentPeriods(octokit: Context["octokit"], issueParams: IssueParams) {
const [events, comments] = await Promise.all([
octokit.paginate(octokit.rest.issues.listEvents, {
...issueParams,
per_page: 100,
}),
octokit.paginate(octokit.rest.issues.listComments, {
...issueParams,
per_page: 100,
}),
]);
const stopComments = comments
.filter((comment) => comment.body?.trim() === "/stop")
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const userAssignments: UserAssignments = {};
const sortedEvents = events
.filter((event) => ["assigned", "unassigned"].includes(event.event))
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());

sortedEvents.forEach((event) => {
const username = "assignee" in event ? event.assignee?.login : null;
if (!username) return;

if (!userAssignments[username]) {
userAssignments[username] = [];
}

const lastPeriod = userAssignments[username][userAssignments[username].length - 1];

if (event.event === "assigned") {
const newPeriod: AssignmentPeriod = {
assignedAt: event.created_at,
unassignedAt: null,
reason: "bot",
};
userAssignments[username].push(newPeriod);
} else if (event.event === "unassigned" && lastPeriod && lastPeriod.unassignedAt === null) {
lastPeriod.unassignedAt = event.created_at;
const periodStart = new Date(lastPeriod.assignedAt).getTime();
const periodEnd = new Date(event.created_at).getTime();

if ("assigner" in event && event.assigner.type !== "Bot" && event.assigner.login !== username) {
lastPeriod.reason = "admin";
} else {
const hasStopCommand =
stopComments.some((comment) => {
const commentTime = new Date(comment.created_at).getTime();
return commentTime >= periodStart && commentTime <= periodEnd;
}) ||
("assigner" in event && event.assigner.type !== "Bot");

if (hasStopCommand) {
lastPeriod.reason = "user";
}
}
}
});

return userAssignments;
}
1 change: 1 addition & 0 deletions tests/__mocks__/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const handlers = [
),
// 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())),
// 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 })
Expand Down

0 comments on commit d80c772

Please sign in to comment.