From 478df24e0dd53958d656e22d5bc4a3ffec329acd Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:12:08 +0100 Subject: [PATCH] chore: prettier --- .github/workflows/deploy.yml | 2 +- CHANGELOG.md | 20 +- README.md | 13 +- graphql.config.yml | 2 +- manifest.json | 2 +- src/handlers/collect-linked-pulls.ts | 52 +++-- src/handlers/watch-user-activity.ts | 62 ++--- src/helpers/get-assignee-activity.ts | 41 ++-- src/helpers/github-url.ts | 2 +- src/helpers/remind-and-remove.ts | 100 ++++----- src/helpers/structured-metadata.ts | 40 ++-- src/helpers/task-deadline.ts | 66 +++--- src/helpers/task-metadata.ts | 212 +++++++++--------- src/helpers/task-update.ts | 2 +- src/types/github-types.ts | 2 +- src/types/plugin-inputs.ts | 2 +- tests/__mocks__/@octokit/graphql-schema.ts | 2 +- tests/__mocks__/db.ts | 2 +- tests/__mocks__/handlers.ts | 21 +- tests/__mocks__/helpers.ts | 139 ++++++------ tests/__mocks__/mock-users.ts | 12 +- tests/__mocks__/repo-template.ts | 2 +- .../results/valid-configuration.json | 6 +- tests/__mocks__/routes/get-timeline.json | 2 +- tests/__mocks__/strings.ts | 48 ++-- tests/main.test.ts | 16 +- 26 files changed, 443 insertions(+), 427 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c4e112..b304668 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - uses: cloudflare/wrangler-action@v3 with: - wranglerVersion: '3.57.0' + wranglerVersion: "3.57.0" apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} secrets: | SUPABASE_URL diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ae171..8a4c02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,17 @@ ## 1.0.0 (2024-07-09) - ### Features -* added testing ([c834de9](https://github.com/ubiquibot/user-activity-watcher/commit/c834de9edefce23c11dc4d91ecc48d7e16ed3e5f)) -* changed the time parsing to be with ms package ([99fa8f7](https://github.com/ubiquibot/user-activity-watcher/commit/99fa8f74524552b8dd17ae0dd6a66da3782abab3)) -* database generation script ([6f19d4d](https://github.com/ubiquibot/user-activity-watcher/commit/6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac)) -* database generation script ([fb4be18](https://github.com/ubiquibot/user-activity-watcher/commit/fb4be189de5c07794d05099acc9b61991f9813bf)) -* linked pull request activity is now taken into account ([790d1c1](https://github.com/ubiquibot/user-activity-watcher/commit/790d1c12e3b1d716e72756e486723c3fe018d252)) -* threshold can be expressed as human-readable strings ([df167d0](https://github.com/ubiquibot/user-activity-watcher/commit/df167d0b29335c1143ff6e1e6c2f11f0529e59c5)) -* user get reminded and unassigned ([797cd6e](https://github.com/ubiquibot/user-activity-watcher/commit/797cd6e27788e119de27722118fbcf766ce4e79a)) - +- added testing ([c834de9](https://github.com/ubiquibot/user-activity-watcher/commit/c834de9edefce23c11dc4d91ecc48d7e16ed3e5f)) +- changed the time parsing to be with ms package ([99fa8f7](https://github.com/ubiquibot/user-activity-watcher/commit/99fa8f74524552b8dd17ae0dd6a66da3782abab3)) +- database generation script ([6f19d4d](https://github.com/ubiquibot/user-activity-watcher/commit/6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac)) +- database generation script ([fb4be18](https://github.com/ubiquibot/user-activity-watcher/commit/fb4be189de5c07794d05099acc9b61991f9813bf)) +- linked pull request activity is now taken into account ([790d1c1](https://github.com/ubiquibot/user-activity-watcher/commit/790d1c12e3b1d716e72756e486723c3fe018d252)) +- threshold can be expressed as human-readable strings ([df167d0](https://github.com/ubiquibot/user-activity-watcher/commit/df167d0b29335c1143ff6e1e6c2f11f0529e59c5)) +- user get reminded and unassigned ([797cd6e](https://github.com/ubiquibot/user-activity-watcher/commit/797cd6e27788e119de27722118fbcf766ce4e79a)) ### Bug Fixes -* moved get env outside of main file ([cb55e61](https://github.com/ubiquibot/user-activity-watcher/commit/cb55e610d5ec2d7dd936f97155f2cc1814c1302d)) -* updated Jest test comment ([d6d5e28](https://github.com/ubiquibot/user-activity-watcher/commit/d6d5e2881a106568f1b2eb6ba9710041dba75950)) +- moved get env outside of main file ([cb55e61](https://github.com/ubiquibot/user-activity-watcher/commit/cb55e610d5ec2d7dd936f97155f2cc1814c1302d)) +- updated Jest test comment ([d6d5e28](https://github.com/ubiquibot/user-activity-watcher/commit/d6d5e2881a106568f1b2eb6ba9710041dba75950)) diff --git a/README.md b/README.md index 825d802..3070da2 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,46 @@ # @ubiquibot/user-activity-watcher -Watches user activity on issues, sends reminders on deadlines, and eventually unassigns inactive user to ensure that +Watches user activity on issues, sends reminders on deadlines, and eventually unassigns inactive user to ensure that tasks don't stall, and subtracts XP. ## Setup + ```shell yarn install ``` ### Database + To start a local instance, run + ```shell supabase start ``` Afterward, you can generate types for full auto-completion with + ```shell yarn supabase:generate:local ``` ### Test + To start Jest testing, run + ```shell yarn test ``` ## Valid configuration + ```yaml - plugin: ubiquibot/user-activity-watcher type: github with: disqualification: "7 days" warning: "3.5 days" - watch: - optOut: + watch: + optOut: - "repoName" - "repoName2" ``` diff --git a/graphql.config.yml b/graphql.config.yml index c523508..ced861e 100644 --- a/graphql.config.yml +++ b/graphql.config.yml @@ -3,4 +3,4 @@ schema: headers: Authorization: Bearer ${GITHUB_TOKEN} documents: src/handlers/collect-linked-pulls.ts -projects: {} \ No newline at end of file +projects: {} diff --git a/manifest.json b/manifest.json index f799117..7731d05 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { "name": "User activity watcher", "description": "Watches user activity on issues, sends reminders on deadlines, and unassign inactive users.", - "ubiquity:listeners": [ "pull_request_review_comment.created", "issue_comment.created", "push" ] + "ubiquity:listeners": ["pull_request_review_comment.created", "issue_comment.created", "push"] } diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 6875c01..dc9d090 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -3,7 +3,7 @@ import { PullRequest, User, validate } from "@octokit/graphql-schema"; type closedByPullRequestsReferences = { node: Pick & Pick; -} +}; type IssueWithClosedByPRs = { repository: { @@ -13,24 +13,25 @@ type IssueWithClosedByPRs = { }; }; }; -} +}; const query = /* GraphQL */ ` -query collectLinkedPullRequests($owner: String!, $repo: String!, $issue_number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issue_number) { - closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { - edges { - node { - url - title - body - state - number - author { - login - ... on User { - id: databaseId + query collectLinkedPullRequests($owner: String!, $repo: String!, $issue_number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issue_number) { + closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { + edges { + node { + url + title + body + state + number + author { + login + ... on User { + id: databaseId + } } } } @@ -38,7 +39,7 @@ query collectLinkedPullRequests($owner: String!, $repo: String!, $issue_number: } } } -}` +`; const queryErrors = validate(query); @@ -50,11 +51,14 @@ if (queryErrors.length > 1) { throw new Error(`Invalid query: ${queryErrors.join(", ")}`); } -export async function collectLinkedPullRequests(context: Context, issue: { - owner: string; - repo: string; - issue_number: number; -}) { +export async function collectLinkedPullRequests( + context: Context, + issue: { + owner: string; + repo: string; + issue_number: number; + } +) { const { owner, repo, issue_number } = issue; const result = await context.octokit.graphql(query, { owner, @@ -63,4 +67,4 @@ export async function collectLinkedPullRequests(context: Context, issue: { }); return result.repository.issue.closedByPullRequestsReferences.edges.map((edge) => edge.node); -} \ No newline at end of file +} diff --git a/src/handlers/watch-user-activity.ts b/src/handlers/watch-user-activity.ts index 1caae0c..74870a4 100644 --- a/src/handlers/watch-user-activity.ts +++ b/src/handlers/watch-user-activity.ts @@ -4,44 +4,44 @@ import { Context } from "../types/context"; import { ListForOrg, ListIssueForRepo } from "../types/github-types"; export async function watchUserActivity(context: Context) { - const { logger } = context; + const { logger } = context; - const repos = await getWatchedRepos(context); + const repos = await getWatchedRepos(context); - if (!repos?.length) { - logger.info("No watched repos have been found, no work to do."); - return false; - } + 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); - } + for (const repo of repos) { + await updateReminders(context, repo); + } - return true; + return true; } async function updateReminders(context: Context, repo: ListForOrg["data"][0]) { - const { logger, octokit, payload } = context; - const owner = payload.repository.owner?.login; - if (!owner) { - throw new Error("No owner found in the payload"); + const { logger, octokit, payload } = context; + const owner = payload.repository.owner?.login; + if (!owner) { + throw new Error("No owner found in the payload"); + } + const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, { + owner, + 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; } - const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, { - owner, - 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); - } + + if (issue.assignees?.length || issue.assignee) { + logger.debug(`Checking assigned issue: ${issue.html_url}`); + await updateTaskReminder(context, repo, issue); } + } } diff --git a/src/helpers/get-assignee-activity.ts b/src/helpers/get-assignee-activity.ts index ae146b2..a8371b3 100644 --- a/src/helpers/get-assignee-activity.ts +++ b/src/helpers/get-assignee-activity.ts @@ -8,25 +8,26 @@ 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 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.url || ""); + const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, }); - const linkedPullRequests = await collectLinkedPullRequests(context, gitHubUrl); - for (const linkedPullRequest of linkedPullRequests) { - const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.url || ""); - const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { - owner, - repo, - issue_number, - per_page: 100, - }); - issueEvents.push(...events); - } + issueEvents.push(...events); + } - return issueEvents.filter((o) => o.actor && o.actor.id && assigneeIds.includes(o.actor.id)) - .sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis()); -} \ No newline at end of file + return issueEvents + .filter((o) => o.actor && o.actor.id && assigneeIds.includes(o.actor.id)) + .sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis()); +} diff --git a/src/helpers/github-url.ts b/src/helpers/github-url.ts index 24ac54f..ee6dc0b 100644 --- a/src/helpers/github-url.ts +++ b/src/helpers/github-url.ts @@ -8,4 +8,4 @@ export function parseIssueUrl(url: string): { owner: string; repo: string; issue repo: path[2], issue_number: Number(path[4]), }; -} \ No newline at end of file +} diff --git a/src/helpers/remind-and-remove.ts b/src/helpers/remind-and-remove.ts index 095e584..7369a3a 100644 --- a/src/helpers/remind-and-remove.ts +++ b/src/helpers/remind-and-remove.ts @@ -4,68 +4,68 @@ import { ListIssueForRepo } from "../types/github-types"; import { createStructuredMetadata } from "./structured-metadata"; export async function unassignUserFromIssue(context: Context, issue: ListIssueForRepo) { - const { logger, config } = context; + 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); - } + 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); - } + 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); + 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(", @"); + 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(", @"); - const logMessage = logger.info(`@${logins}, this task has been idle for a while. Please provide an update.\n\n`, { - taskAssignees: issue.assignees.map((o) => o?.id), - }); + const logMessage = logger.info(`@${logins}, this task has been idle for a while. Please provide an update.\n\n`, { + taskAssignees: issue.assignees.map((o) => o?.id), + }); - const metadata = createStructuredMetadata("Followup", logMessage); + const metadata = createStructuredMetadata("Followup", logMessage); - await octokit.rest.issues.createComment({ - owner, - repo, - issue_number, - body: [logMessage.logMessage.raw, metadata].join("\n"), - }); - return true; + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number, + body: [logMessage.logMessage.raw, metadata].join("\n"), + }); + return true; } async function removeAllAssignees(context: Context, issue: ListIssueForRepo) { - const { octokit, logger } = context; - const { repo, owner, issue_number } = parseIssueUrl(issue.html_url); + 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; -} \ No newline at end of file + 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; +} diff --git a/src/helpers/structured-metadata.ts b/src/helpers/structured-metadata.ts index ab46e5e..a508703 100644 --- a/src/helpers/structured-metadata.ts +++ b/src/helpers/structured-metadata.ts @@ -1,28 +1,28 @@ import { LogReturn } from "@ubiquity-dao/ubiquibot-logger"; export function createStructuredMetadata(className: string, logReturn: LogReturn | null) { - let logMessage, metadata; - if (logReturn) { - logMessage = logReturn.logMessage; - metadata = logReturn.metadata; - } + let logMessage, metadata; + if (logReturn) { + logMessage = logReturn.logMessage; + metadata = logReturn.metadata; + } - const jsonPretty = JSON.stringify(metadata, null, 2); - const stackLine = new Error().stack?.split("\n")[2] ?? ""; - const caller = stackLine.match(/at (\S+)/)?.[1] ?? ""; - const ubiquityMetadataHeader = `"].join("\n"); + let metadataSerialized: string; + const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n"); + const metadataSerializedHidden = [ubiquityMetadataHeader, jsonPretty, "-->"].join("\n"); - if (logMessage?.type === "fatal") { - // if the log message is fatal, then we want to show the metadata - metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n"); - } else { - // otherwise we want to hide it - metadataSerialized = metadataSerializedHidden; - } + if (logMessage?.type === "fatal") { + // if the log message is fatal, then we want to show the metadata + metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n"); + } else { + // otherwise we want to hide it + metadataSerialized = metadataSerializedHidden; + } - return metadataSerialized; + return metadataSerialized; } diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts index 737de4b..5c2b1d8 100644 --- a/src/helpers/task-deadline.ts +++ b/src/helpers/task-deadline.ts @@ -4,45 +4,45 @@ import { ListIssueForRepo } from "../types/github-types"; import { getAssigneesActivityForIssue } from "./get-assignee-activity"; export async function getDeadlineWithThreshold( - context: Context, - metadata: { - taskDeadline: string; - taskAssignees: number[] | undefined; - }, - issue: ListIssueForRepo, - lastCheck: DateTime, + context: Context, + metadata: { + taskDeadline: string; + taskAssignees: number[] | undefined; + }, + issue: ListIssueForRepo, + lastCheck: DateTime ) { - const { logger, config } = context; + const { logger, config } = context; - const assigneeIds = issue.assignees?.map((o) => o.id) || []; + 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, - assigneeIds, - }); - } + if (assigneeIds.length && metadata.taskAssignees?.some((a) => !assigneeIds.includes(a))) { + logger.info(`Assignees mismatch found for ${issue.html_url}`, { + metadata, + assigneeIds, + }); + } - const deadline = DateTime.fromISO(metadata.taskDeadline); - const now = DateTime.now(); + 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; - } + 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; - }) + 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 }); + 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 }); - } + 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 }; -} \ No newline at end of file + return { deadlineWithThreshold, reminderWithThreshold, now }; +} diff --git a/src/helpers/task-metadata.ts b/src/helpers/task-metadata.ts index fdb6dba..c226214 100644 --- a/src/helpers/task-metadata.ts +++ b/src/helpers/task-metadata.ts @@ -4,120 +4,122 @@ import { ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/git import ms from "ms"; export async function getTaskMetadata( - context: Context, - repo: ListForOrg["data"][0], - issue: ListIssueForRepo -): Promise<{ metadata: { taskDeadline: string; taskAssignees: number[] }, lastCheck: DateTime } | false> { - 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"); - // Has the bot assigned them, typically via the `/start` command - 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() - ) - - // Has the bot previously reminded them? - const botFollowup = /