Skip to content

Commit

Permalink
chore: code clean up and verbose logging
Browse files Browse the repository at this point in the history
  • Loading branch information
Keyrxng committed Aug 19, 2024
1 parent c6dfebc commit 8902bba
Show file tree
Hide file tree
Showing 8 changed files with 323 additions and 260 deletions.
43 changes: 43 additions & 0 deletions src/handlers/watch-user-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getWatchedRepos } from "../helpers/get-watched-repos";
import { updateTaskReminder } from "../helpers/task-update";
import { Context } from "../types/context";
import { ListForOrg, ListIssueForRepo } from "../types/github-types";

export async function watchUserActivity(context: Context) {
const { logger } = context;

const repos = await getWatchedRepos(context);

if (!repos?.length) {
logger.info("No watched repos have been found, no work to do.");
return false;
}

for (const repo of repos) {
await updateReminders(context, repo);
}

return true;
}

async function updateReminders(context: Context, repo: ListForOrg["data"][0]) {
const { logger, octokit, payload } = context;
const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: payload.repository.owner.login,
repo: repo.name,
per_page: 100,
state: "open",
})) as ListIssueForRepo[];

for (const issue of issues) {
// I think we can safely ignore the following
if (issue.draft || issue.pull_request || issue.locked || issue.state !== "open") {
continue;
}

if (issue.assignees?.length || issue.assignee) {
logger.debug(`Checking assigned issue: ${issue.html_url}`);
await updateTaskReminder(context, repo, issue);
}
}
}
38 changes: 38 additions & 0 deletions src/helpers/get-assignee-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { DateTime } from "luxon";
import { collectLinkedPullRequests } from "../handlers/collect-linked-pulls";
import { Context } from "../types/context";
import { parseIssueUrl } from "./github-url";
import { GitHubListEvents, ListIssueForRepo } from "../types/github-types";

/**
* Retrieves all the activity for users that are assigned to the issue. Also takes into account linked pull requests.
*/
export async function getAssigneesActivityForIssue(context: Context, issue: ListIssueForRepo, assigneeIds: number[]) {
const gitHubUrl = parseIssueUrl(issue.html_url);
const issueEvents: GitHubListEvents[] = await context.octokit.paginate(context.octokit.rest.issues.listEvents, {
owner: gitHubUrl.owner,
repo: gitHubUrl.repo,
issue_number: gitHubUrl.issue_number,
per_page: 100,
});
const linkedPullRequests = await collectLinkedPullRequests(context, gitHubUrl);
for (const linkedPullRequest of linkedPullRequests) {
const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.pull_request?.html_url || "");
const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, {
owner,
repo,
issue_number,
per_page: 100,
});
issueEvents.push(...events);
}

return issueEvents
.reduce((acc, event) => {
if (event.actor && event.actor.id) {
if (assigneeIds.includes(event.actor.id)) acc.push(event);
}
return acc;
}, [] as GitHubListEvents[])
.sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis());
}
64 changes: 64 additions & 0 deletions src/helpers/remind-and-remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Context } from "../types/context";
import { parseIssueUrl } from "./github-url";
import { ListIssueForRepo } from "../types/github-types";

export async function unassignUserFromIssue(context: Context, issue: ListIssueForRepo) {
const { logger, config } = context;

if (config.disqualification <= 0) {
logger.info("The unassign threshold is <= 0, won't unassign users.");
} else {
logger.info(`Passed the deadline on ${issue.html_url} and no activity is detected, removing assignees.`);
await removeAllAssignees(context, issue);
}
}

export async function remindAssigneesForIssue(context: Context, issue: ListIssueForRepo) {
const { logger, config } = context;
if (config.warning <= 0) {
logger.info("The reminder threshold is <= 0, won't send any reminder.");
} else {
logger.info(`Passed the reminder threshold on ${issue.html_url}, sending a reminder.`);
await remindAssignees(context, issue);
}
}

async function remindAssignees(context: Context, issue: ListIssueForRepo) {
const { octokit, logger } = context;
const { repo, owner, issue_number } = parseIssueUrl(issue.html_url);

if (!issue?.assignees?.length) {
logger.error(`Missing Assignees from ${issue.html_url}`);
return false;
}
const logins = issue.assignees
.map((o) => o?.login)
.filter((o) => !!o)
.join(", @");

await octokit.rest.issues.createComment({
owner,
repo,
issue_number,
body: `@${logins}, this task has been idle for a while. Please provide an update.`,
});
return true;
}

async function removeAllAssignees(context: Context, issue: ListIssueForRepo) {
const { octokit, logger } = context;
const { repo, owner, issue_number } = parseIssueUrl(issue.html_url);

if (!issue?.assignees?.length) {
logger.error(`Missing Assignees from ${issue.html_url}`);
return false;
}
const logins = issue.assignees.map((o) => o?.login).filter((o) => !!o) as string[];
await octokit.rest.issues.removeAssignees({
owner,
repo,
issue_number,
assignees: logins,
});
return true;
}
49 changes: 49 additions & 0 deletions src/helpers/task-deadline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DateTime } from "luxon";
import { Context } from "../types/context";
import { ListIssueForRepo } from "../types/github-types";
import { getAssigneesActivityForIssue } from "./get-assignee-activity";

export async function handleDeadline(
context: Context,
metadata: {
taskDeadline: string;
taskAssignees: number[] | undefined;
},
issue: ListIssueForRepo,
lastCheck: DateTime,
) {
const { logger, config } = context;

const assigneeIds = issue.assignees?.map((o) => o.id) || [];

if (assigneeIds.length && metadata.taskAssignees?.some((a) => !assigneeIds.includes(a))) {
logger.info(`Assignees mismatch found for ${issue.html_url}`, {
metadata: metadata.taskAssignees,
issue: assigneeIds,
});
return false;
}

const deadline = DateTime.fromISO(metadata.taskDeadline);
const now = DateTime.now();

if (!deadline.isValid && !lastCheck.isValid) {
logger.error(`Invalid date found on ${issue.html_url}`);
return false;
}

const activity = (await getAssigneesActivityForIssue(context, issue, assigneeIds)).filter((o) => {
return DateTime.fromISO(o.created_at) > lastCheck;
})

let deadlineWithThreshold = deadline.plus({ milliseconds: config.disqualification });
let reminderWithThreshold = deadline.plus({ milliseconds: config.warning });

if (activity?.length) {
const lastActivity = DateTime.fromISO(activity[0].created_at);
deadlineWithThreshold = lastActivity.plus({ milliseconds: config.disqualification });
reminderWithThreshold = lastActivity.plus({ milliseconds: config.warning });
}

return { deadlineWithThreshold, reminderWithThreshold, now };
}
82 changes: 82 additions & 0 deletions src/helpers/task-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { DateTime } from "luxon";
import { Context } from "../types/context";
import { ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types";
import ms from "ms";

export async function handleCommentsAndMetadata(
context: Context,
repo: ListForOrg["data"][0],
issue: ListIssueForRepo
) {
const { logger, octokit } = context;

const comments = (await octokit.paginate(octokit.rest.issues.listComments, {
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
per_page: 100,
})) as ListCommentsForIssue[];

const botComments = comments.filter((o) => o.user?.type === "Bot");
const taskDeadlineJsonRegex = /"taskDeadline": "([^"]*)"/g;
const taskAssigneesJsonRegex = /"taskAssignees": \[([^\]]*)\]/g;
const assignmentRegex = /Ubiquity - Assignment - start -/gi;
const botAssignmentComments = botComments.filter(
(o) => assignmentRegex.test(o?.body || "")
).sort((a, b) =>
DateTime.fromISO(a.created_at).toMillis() - DateTime.fromISO(b.created_at).toMillis()
)

const botFollowup = /this task has been idle for a while. Please provide an update./gi;
const botFollowupComments = botComments.filter((o) => botFollowup.test(o?.body || ""));

if (!botAssignmentComments.length && !botFollowupComments.length) {
logger.info(`No assignment or followup comments found for ${issue.html_url}`);
return false;
}

const lastCheckComment = botFollowupComments[0]?.created_at ? botFollowupComments[0] : botAssignmentComments[0];
const lastCheck = DateTime.fromISO(lastCheckComment.created_at);

const taskDeadlineMatch = taskDeadlineJsonRegex.exec(botAssignmentComments[0]?.body || "");
const taskAssigneesMatch = taskAssigneesJsonRegex.exec(botAssignmentComments[0]?.body || "");

const metadata = {
taskDeadline: taskDeadlineMatch?.[1] || "",
taskAssignees: taskAssigneesMatch?.[1]
.split(",")
.map((o) => o.trim())
.map(Number),
};

// supporting legacy format
if (!metadata.taskAssignees?.length) {
metadata.taskAssignees = issue.assignees ? issue.assignees.map((o) => o.id) : issue.assignee ? [issue.assignee.id] : [];
}

if (!metadata.taskAssignees?.length) {
logger.error(`Missing Assignees from ${issue.html_url}`);
return false;
}

if (!metadata.taskDeadline) {
const taskDeadlineJsonRegex = /"duration": ([^,]*),/g;
const taskDeadlineMatch = taskDeadlineJsonRegex.exec(botAssignmentComments[0]?.body || "");
if (!taskDeadlineMatch) {
logger.error(`Missing deadline from ${issue.html_url}`);
return false;
}
const duration = taskDeadlineMatch[1] || "";
const durationInMs = ms(duration);
if (durationInMs === 0) {
// it could mean there was no time label set on the issue
// but it could still be workable and priced
} else if (durationInMs < 0 || !durationInMs) {
logger.error(`Invalid deadline found on ${issue.html_url}`);
return false;
}
metadata.taskDeadline = DateTime.fromMillis(lastCheck.toMillis() + durationInMs).toISO() || "";
}

return { metadata, lastCheck };
}
44 changes: 44 additions & 0 deletions src/helpers/task-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { DateTime } from "luxon";
import { Context } from "../types/context";
import { ListForOrg, ListIssueForRepo } from "../types/github-types";
import { remindAssigneesForIssue, unassignUserFromIssue } from "./remind-and-remove";
import { handleDeadline } from "./task-deadline";
import { handleCommentsAndMetadata } from "./task-metadata";

export async function updateTaskReminder(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) {
const { logger } = context;

let metadata, lastCheck, deadlineWithThreshold, reminderWithThreshold, now;

const handledMetadata = await handleCommentsAndMetadata(context, repo, issue);

if (handledMetadata) {
metadata = handledMetadata.metadata;
lastCheck = handledMetadata.lastCheck;

const handledDeadline = await handleDeadline(context, metadata, issue, lastCheck);
if (handledDeadline) {
deadlineWithThreshold = handledDeadline.deadlineWithThreshold;
reminderWithThreshold = handledDeadline.reminderWithThreshold;
now = handledDeadline.now;
}
}

if (!metadata || !lastCheck || !deadlineWithThreshold || !reminderWithThreshold || !now) {
logger.error(`Failed to handle metadata or deadline for ${issue.html_url}`);
return false;
}

if (now >= deadlineWithThreshold) {
await unassignUserFromIssue(context, issue);
} else if (now >= reminderWithThreshold) {
await remindAssigneesForIssue(context, issue);
} else {
logger.info(`Nothing to do for ${issue.html_url}, still within due-time.`);
logger.info(`Last check was on ${lastCheck.toISO()}`, {
now: now.toLocaleString(DateTime.DATETIME_MED),
reminder: reminderWithThreshold.toLocaleString(DateTime.DATETIME_MED),
deadline: deadlineWithThreshold.toLocaleString(DateTime.DATETIME_MED),
});
}
}
Loading

0 comments on commit 8902bba

Please sign in to comment.