diff --git a/.gitignore b/.gitignore index 2427db2..f5e4c9d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,4 @@ cypress/screenshots .dev.vars /tests/http/http-client.private.env.json .wrangler -test-dashboard.md -t.ts \ No newline at end of file +test-dashboard.md \ No newline at end of file diff --git a/graphql.config.yml b/graphql.config.yml new file mode 100644 index 0000000..c523508 --- /dev/null +++ b/graphql.config.yml @@ -0,0 +1,6 @@ +schema: + - https://api.github.com/graphql: + headers: + Authorization: Bearer ${GITHUB_TOKEN} +documents: src/handlers/collect-linked-pulls.ts +projects: {} \ No newline at end of file diff --git a/package.json b/package.json index c9524a4..297b164 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "src/worker.ts", "author": "Ubiquity DAO", "license": "MIT", + "type": "module", "engines": { "node": ">=20.10.0" }, @@ -31,10 +32,11 @@ "dependencies": { "@actions/core": "1.10.1", "@actions/github": "6.0.0", + "@octokit/graphql-schema": "^15.25.0", "@octokit/rest": "20.1.1", "@octokit/webhooks": "13.2.7", "@sinclair/typebox": "0.32.31", - "@ubiquity-dao/ubiquibot-logger": "^1.3.0", + "@ubiquity-dao/ubiquibot-logger": "^1.3.1", "dotenv": "16.4.5", "luxon": "3.4.4", "ms": "2.1.3", @@ -46,6 +48,7 @@ "@cspell/dict-node": "5.0.1", "@cspell/dict-software-terms": "3.4.0", "@cspell/dict-typescript": "3.1.5", + "@jest/globals": "^29.7.0", "@mswjs/data": "0.16.1", "@types/jest": "29.5.12", "@types/luxon": "3.4.2", @@ -86,4 +89,4 @@ ] }, "packageManager": "yarn@1.22.22" -} \ No newline at end of file +} diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index fffb751..15d3180 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -1,19 +1,66 @@ -import { parseIssueUrl } from "../helpers/github-url"; import { Context } from "../types/context"; -import { IssuesSearch } from "../types/github-types"; +import { PullRequest, User, validate } from "@octokit/graphql-schema"; -export type IssueParams = ReturnType; +type closedByPullRequestsReferences = { + node: Pick & Pick; +} -function additionalBooleanFilters(issueNumber: number) { - return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "closes #${issueNumber}" OR "fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}"`; +type IssueWithClosedByPRs = { + repository: { + issue: { + closedByPullRequestsReferences: { + edges: closedByPullRequestsReferences[]; + }; + }; + }; } -export async function collectLinkedPullRequests(context: Context, issue: IssueParams) { - return await context.octokit.paginate( - context.octokit.rest.search.issuesAndPullRequests, - { - q: `repo:${issue.owner}/${issue.repo} is:pr is:open ${additionalBooleanFilters(issue.issue_number)}`, - per_page: 100, +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 + } + } + } + } + } } - ) as IssuesSearch[]; + } +}` + +const queryErrors = validate(query); + +/** + * > 1 because the schema package is slightly out of date and does not include the + * `closedByPullRequestsReferences` object in the schema as it is a recent addition to the GitHub API. + */ +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; +}) { + const { owner, repo, issue_number } = issue; + const result = await context.octokit.graphql(query, { + owner, + repo, + issue_number, + }); + + return result.repository.issue.closedByPullRequestsReferences.edges.map((edge) => edge.node); } diff --git a/src/handlers/watch-user-activity.ts b/src/handlers/watch-user-activity.ts new file mode 100644 index 0000000..1caae0c --- /dev/null +++ b/src/handlers/watch-user-activity.ts @@ -0,0 +1,47 @@ +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 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; + } + + 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 new file mode 100644 index 0000000..ae146b2 --- /dev/null +++ b/src/helpers/get-assignee-activity.ts @@ -0,0 +1,32 @@ +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.url || ""); + const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + 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 diff --git a/src/helpers/get-watched-repos.ts b/src/helpers/get-watched-repos.ts index ad5f54a..a261367 100644 --- a/src/helpers/get-watched-repos.ts +++ b/src/helpers/get-watched-repos.ts @@ -2,27 +2,35 @@ import { Context } from "../types/context"; import { ListForOrg } from "../types/github-types"; export async function getWatchedRepos(context: Context) { - const { config: { watch: { optOut } } } = context; + const { + config: { + watch: { optOut }, + }, + } = context; const repoNames = new Set(); - const orgRepos = await getReposForOrg(context, context.payload.repository.owner.login); + const owner = context.payload.repository.owner?.login; + if (!owner) { + throw new Error("No owner found in the payload"); + } + const orgRepos = await getReposForOrg(context, owner); orgRepos.forEach((repo) => repoNames.add(repo.name.toLowerCase())); for (const repo of optOut) { - repoNames.forEach((name) => name.includes(repo) ? repoNames.delete(name) : null); + repoNames.forEach((name) => (name.includes(repo) ? repoNames.delete(name) : null)); } - return Array.from(repoNames).map((name) => orgRepos.find((repo) => repo.name.toLowerCase() === name)) + return Array.from(repoNames) + .map((name) => orgRepos.find((repo) => repo.name.toLowerCase() === name)) .filter((repo) => repo !== undefined) as ListForOrg["data"]; } - export async function getReposForOrg(context: Context, orgOrRepo: string) { const { octokit } = context; try { - return await octokit.paginate(octokit.rest.repos.listForOrg, { + return (await octokit.paginate(octokit.rest.repos.listForOrg, { org: orgOrRepo, per_page: 100, - }) as ListForOrg["data"]; + })) as ListForOrg["data"]; } catch (er) { throw new Error(`Error getting repositories for org ${orgOrRepo}: ` + JSON.stringify(er)); } diff --git a/src/helpers/remind-and-remove.ts b/src/helpers/remind-and-remove.ts new file mode 100644 index 0000000..095e584 --- /dev/null +++ b/src/helpers/remind-and-remove.ts @@ -0,0 +1,71 @@ +import { Context } from "../types/context"; +import { parseIssueUrl } from "./github-url"; +import { ListIssueForRepo } from "../types/github-types"; +import { createStructuredMetadata } from "./structured-metadata"; + +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(", @"); + + 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); + + 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); + + 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 diff --git a/src/helpers/structured-metadata.ts b/src/helpers/structured-metadata.ts new file mode 100644 index 0000000..ab46e5e --- /dev/null +++ b/src/helpers/structured-metadata.ts @@ -0,0 +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; + } + + 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"); + + 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; +} diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts new file mode 100644 index 0000000..737de4b --- /dev/null +++ b/src/helpers/task-deadline.ts @@ -0,0 +1,48 @@ +import { DateTime } from "luxon"; +import { Context } from "../types/context"; +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, +) { + 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, + assigneeIds, + }); + } + + 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 }; +} \ No newline at end of file diff --git a/src/helpers/task-metadata.ts b/src/helpers/task-metadata.ts new file mode 100644 index 0000000..b5048eb --- /dev/null +++ b/src/helpers/task-metadata.ts @@ -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 getTaskMetadata( + 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 = /`; +} + +export function updatingRemindersFor(repo: string) { + return `Updating reminders for ${repo}`; +} + +export function noAssignmentCommentFor(repo: string) { + return `No assignment or followup comments found for ${repo}`; +} + +export function getIssueUrl(issueId: number) { + return STRINGS.TEST_REPO_URL.replace("issues/1", `issues/${issueId}`); +} + +export function getIssueHtmlUrl(issueId: number) { + return STRINGS.TEST_REPO_HTML_URL.replace("issues/1", `issues/${issueId}`); +} + +export function getRepoUrl(repo: string) { + return `https://api.github.com/repos/${repo}`; +} + +export function getRepoHtmlUrl(repo: string) { + return `https://github.com/${repo}`; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 3e78bfa..83a6e2d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,62 +1,229 @@ import { drop } from "@mswjs/data"; import { TransformDecodeError, Value } from "@sinclair/typebox/value"; -import program from "../src/parser/payload"; -import { run } from "../src/run"; +import { runPlugin } from "../src/run"; import { userActivityWatcherSettingsSchema } from "../src/types/plugin-inputs"; -import { db as mockDb } from "./__mocks__/db"; -import dbSeed from "./__mocks__/db-seed.json"; +import { db } from "./__mocks__/db"; import { server } from "./__mocks__/node"; import cfg from "./__mocks__/results/valid-configuration.json"; +import { expect, describe, beforeAll, beforeEach, afterAll, afterEach } from "@jest/globals"; +import dotenv from "dotenv"; +import { Logs } from "@ubiquity-dao/ubiquibot-logger"; +import { Context } from "../src/types/context"; +import mockUsers from "./__mocks__/mock-users"; +import { botAssignmentComment, getIssueHtmlUrl, getIssueUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; +import { createComment, createIssue, createRepo, ONE_DAY } from "./__mocks__/helpers"; +import { collectLinkedPullRequests } from "../src/handlers/collect-linked-pulls"; +import { createStructuredMetadata } from "../src/helpers/structured-metadata"; -beforeAll(() => server.listen()); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); +dotenv.config(); +const octokit = jest.requireActual("@octokit/rest"); -jest.mock("../src/parser/payload", () => { - // Require is needed because mock cannot access elements out of scope - const cfg = require("./__mocks__/results/valid-configuration.json"); - return { - stateId: 1, - eventName: "issues.assigned", - authToken: process.env.GITHUB_TOKEN, - ref: "", - eventPayload: { - issue: { html_url: "https://github.com/ubiquibot/user-activity-watcher/issues/1", number: 1, assignees: [{ login: "ubiquibot" }] }, - repository: { - owner: { - login: "ubiquibot", - }, - name: "user-activity-watcher", - }, - }, - settings: cfg, - }; +beforeAll(() => { + server.listen(); +}); +afterEach(() => { + drop(db); + server.resetHandlers(); }); +afterAll(() => server.close()); -describe("Run tests", () => { - beforeAll(() => { - drop(mockDb); - for (const item of dbSeed.issues) { - mockDb.issues.create(item); - } +describe("User start/stop", () => { + beforeEach(async () => { + await setupTests(); }); it("Should parse thresholds", async () => { const settings = Value.Decode(userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, cfg)); - expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optOut: ["private-repo"] } }); + expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optOut: [STRINGS.PRIVATE_REPO_NAME] } }); expect(() => Value.Decode( userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, { warning: "12 foobars", disqualification: "2 days", - watch: { optOut: ["private-repo"] }, + watch: { optOut: [STRINGS.PRIVATE_REPO_NAME] }, }) ) ).toThrow(TransformDecodeError); }); it("Should run", async () => { - const result = await run(program); - expect(JSON.parse(result)).toEqual({ status: 200 }); + const context = createContext(1, 1); + const result = await runPlugin(context); + expect(result).toBe(true); + }); + + it("Should process update for all repos except optOut", async () => { + const context = createContext(1, 1); + const infoSpy = jest.spyOn(context.logger, "info"); + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueHtmlUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueHtmlUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueHtmlUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueHtmlUrl(4))); + }); + + it("Should include the previously excluded repo", async () => { + const context = createContext(1, 1); + const infoSpy = jest.spyOn(context.logger, "info"); + context.config.watch.optOut = []; + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueHtmlUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueHtmlUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueHtmlUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueHtmlUrl(4))); + }); + + it("Should eject the user after the disqualification period", async () => { + const context = createContext(4, 2); + const infoSpy = jest.spyOn(context.logger, "info"); + + const timestamp = daysPriorToNow(9); + createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, timestamp), timestamp); + + const issue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(issue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); + + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, `Assignees mismatch found for ${getIssueHtmlUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, assigneeIds: [1], metadata: { taskDeadline: timestamp, taskAssignees: [2] } }); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Passed the deadline on ${getIssueHtmlUrl(1)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(3, `Passed the deadline on ${getIssueHtmlUrl(2)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(4, `Passed the deadline on ${getIssueHtmlUrl(3)} and no activity is detected, removing assignees.`); + + const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(updatedIssue?.assignees).toEqual([]); + }); + + it("Should warn the user after the warning period", async () => { + const context = createContext(4, 2); + const infoSpy = jest.spyOn(context.logger, "info"); + + const timestamp = daysPriorToNow(5); + + createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, timestamp), timestamp); + + const issue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(issue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); + + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, `Assignees mismatch found for ${getIssueHtmlUrl(1)}`, { + caller: STRINGS.LOGS_ANON_CALLER, assigneeIds: [1], metadata: { + taskDeadline: timestamp, + taskAssignees: [2], + } + }); + + const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); + + const comments = db.issueComments.getAll(); + const latestComment = comments[comments.length - 1]; + const partialComment = "@user2, this task has been idle for a while. Please provide an update.\\n\\n\\n