From 2200b4760ba26d2ed67bc4aa5d4ea77b2a0ad123 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Wed, 31 Jul 2024 21:47:44 +0100 Subject: [PATCH 01/39] chore: prep --- src/run.ts | 7 +- src/types/context.ts | 2 +- tests/__mocks__/db-seed.json | 12 ---- tests/__mocks__/db.ts | 113 ++++++++++++++++++++++++++++-- tests/__mocks__/handlers.ts | 25 ++----- tests/__mocks__/issue-template.ts | 56 +++++++++++++++ tests/__mocks__/mock-users.ts | 10 +++ tests/__mocks__/repo-template.ts | 11 +++ tests/__mocks__/strings.ts | 8 +++ tests/main.test.ts | 109 +++++++++++++++++++--------- 10 files changed, 278 insertions(+), 75 deletions(-) delete mode 100644 tests/__mocks__/db-seed.json create mode 100644 tests/__mocks__/issue-template.ts create mode 100644 tests/__mocks__/mock-users.ts create mode 100644 tests/__mocks__/repo-template.ts create mode 100644 tests/__mocks__/strings.ts diff --git a/src/run.ts b/src/run.ts index 874d9ec..9979dcf 100644 --- a/src/run.ts +++ b/src/run.ts @@ -13,7 +13,10 @@ export async function run(inputs: PluginInputs) { octokit, logger: new Logs("info"), }; - - await updateTasks(context); + await runPlugin(context); return JSON.stringify({ status: 200 }); } + +export async function runPlugin(context: Context) { + return await updateTasks(context); +} diff --git a/src/types/context.ts b/src/types/context.ts index 8266aec..0ff5ce5 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -3,7 +3,7 @@ import { Octokit } from "@octokit/rest"; import { SupportedEvents, UserActivityWatcherSettings } from "./plugin-inputs"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; -export interface Context { +export interface Context { eventName: T; payload: WebhookEvent["payload"]; octokit: InstanceType; diff --git a/tests/__mocks__/db-seed.json b/tests/__mocks__/db-seed.json deleted file mode 100644 index 26eea97..0000000 --- a/tests/__mocks__/db-seed.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "issues": [ - { - "id": 1, - "createdAt": "2024-06-10 05:03:00.213+00", - "url": "https://github.com/ubiquibot/user-activity-watcher/issues/1", - "last_check": "2024-06-10 05:03:00.213+00", - "deadline": "2024-06-11 05:03:00.213+00", - "last_reminder": null - } - ] -} diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index e192f9d..7156e99 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -5,17 +5,118 @@ import { factory, nullable, primaryKey } from "@mswjs/data"; * Creates an object that can be used as a db to persist data within tests */ export const db = factory({ - issues: { + users: { id: primaryKey(Number), - created_at: String, + login: String, + }, + issue: { + id: primaryKey(Number), + assignees: Array, + html_url: String, + repository_url: String, + state: String, + owner: String, + repo: String, + labels: Array, + author_association: String, + body: nullable(String), + closed_at: nullable(Date), + created_at: nullable(Date), + comments: Number, + comments_url: String, + events_url: String, + labels_url: String, + locked: Boolean, + node_id: String, + title: String, + number: Number, + updated_at: Date, url: String, - last_check: String, - deadline: String, - last_reminder: nullable(String), + user: nullable(Object), + milestone: nullable(Object), + assignee: nullable({ + avatar_url: String, + email: nullable(String), + events_url: String, + followers_url: String, + following_url: String, + gists_url: String, + gravatar_id: nullable(String), + html_url: String, + id: Number, + login: String, + name: nullable(String), + node_id: String, + organizations_url: String, + received_events_url: String, + repos_url: String, + site_admin: Boolean, + starred_at: String, + starred_url: String, + subscriptions_url: String, + type: String, + url: String, + }), }, - repos: { + repo: { id: primaryKey(Number), + html_url: String, name: String, + owner: { + login: String, + id: Number, + }, + issues: Array, + }, + event: { + id: primaryKey(Number), + actor: { + id: Number, + type: String, + login: String, + name: nullable(String), + }, owner: String, + repo: String, + issue_number: Number, + event: String, + commit_id: nullable(String), + commit_url: String, + created_at: Date, + assignee: { + login: String, + }, + source: nullable({ + issue: { + number: Number, + html_url: String, + state: String, + body: nullable(String), + repository: { + full_name: String, + }, + user: { + login: String, + }, + pull_request: { + url: String, + html_url: String, + diff_url: String, + patch_url: String, + merged_at: Date, + }, + }, + }), + }, + issueComments: { + id: primaryKey(Number), + issueId: Number, + body: String, + created_at: Date, + updated_at: Date, + user: { + login: String, + id: Number, + }, }, }); diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 67a899f..b28c964 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -8,22 +8,6 @@ import issueTimeline from "./routes/get-timeline.json"; * Intercepts the routes and returns a custom payload */ export const handlers = [ - http.get("http://127.0.0.1:54321/rest/v1/issues", () => { - const repos = db.issues.getAll(); - return HttpResponse.json(repos); - }), - http.post("http://127.0.0.1:54321/rest/v1/issues", async ({ request }) => { - const body = await request.json(); - - if (typeof body === "object") { - const newItem = { - ...body, - id: db.issues.count() + 1, - }; - db.issues.create(newItem); - } - return HttpResponse.json({}); - }), http.get("https://api.github.com/repos/:owner/:repo/issues/:id/events", () => { return HttpResponse.json(issueEventsGet); }), @@ -35,14 +19,17 @@ export const handlers = [ }), http.get("https://api.github.com/:org/repos", () => { - return HttpResponse.json(db.repos.getAll()); + return HttpResponse.json(db.repo.getAll()); }), http.get("https://api.github.com/repos/:owner/:repo/issues", () => { - return HttpResponse.json(db.issues.getAll()); + return HttpResponse.json(db.issue.getAll()); }), http.get("https://api.github.com/orgs/:org/repos", () => { - return HttpResponse.json(db.repos.getAll()); + return HttpResponse.json(db.repo.getAll()); + }), + http.get("https://api.github.com/repos/:owner/:repo/issues/:id/comments", () => { + return HttpResponse.json(db.issueComments.getAll()); }), ]; diff --git a/tests/__mocks__/issue-template.ts b/tests/__mocks__/issue-template.ts new file mode 100644 index 0000000..c8f7dab --- /dev/null +++ b/tests/__mocks__/issue-template.ts @@ -0,0 +1,56 @@ +export default { + assignees: [], + assignee: { + login: "", + avatar_url: "", + email: "undefined", + events_url: "", + followers_url: "", + following_url: "", + gists_url: "", + gravatar_id: null, + html_url: "", + id: 1, + name: "undefined", + node_id: "", + organizations_url: "", + received_events_url: "", + repos_url: "", + site_admin: false, + starred_at: "", + starred_url: "", + subscriptions_url: "", + type: "", + url: "", + }, + author_association: "NONE", + closed_at: null, + comments: 0, + comments_url: "", + created_at: new Date().toISOString(), + events_url: "", + html_url: "https://github.com/ubiquity/test-repo/issues/1", + id: 1, + labels_url: "", + locked: false, + milestone: null, + node_id: "1", + owner: "ubiquity", + number: 1, + repository_url: "https://github.com/ubiquity/test-repo", + state: "open", + title: "issue", + updated_at: "", + url: "", + user: null, + repo: "test-repo", + labels: [ + { + name: "Price: 200 USD", + }, + { + name: "Time: 1h", + }, + ], + body: "body", +}; diff --git a/tests/__mocks__/mock-users.ts b/tests/__mocks__/mock-users.ts new file mode 100644 index 0000000..f3c25fb --- /dev/null +++ b/tests/__mocks__/mock-users.ts @@ -0,0 +1,10 @@ +export default [ + { + "id": 1, + "login": "ubiquity" + }, + { + "id": 2, + "login": "user2" + } +] diff --git a/tests/__mocks__/repo-template.ts b/tests/__mocks__/repo-template.ts new file mode 100644 index 0000000..8090229 --- /dev/null +++ b/tests/__mocks__/repo-template.ts @@ -0,0 +1,11 @@ +export default { + id: 1, + html_url: "https://github.com/ubiquity/test-repo", + url: "https://api.github.com/repos/ubiquity/test-repo", + name: "test-repo", + owner: { + login: "ubiquity", + id: 1, + }, + issues: [], +} diff --git a/tests/__mocks__/strings.ts b/tests/__mocks__/strings.ts new file mode 100644 index 0000000..12ab3d5 --- /dev/null +++ b/tests/__mocks__/strings.ts @@ -0,0 +1,8 @@ +export const STRINGS = { + UBIQUITY: "ubiquity", + UBIQUIBOT: "ubiquibot", + USER: "user2", + TEST_REPO: "ubiquity/test-repo", + TEST_REPO_NAME: "test-repo", + PRIVATE_REPO: "ubiquity/private-repo", +} \ No newline at end of file diff --git a/tests/main.test.ts b/tests/main.test.ts index b7d7de2..4904196 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,62 +1,101 @@ import { drop } from "@mswjs/data"; import { TransformDecodeError, Value } from "@sinclair/typebox/value"; import program from "../src/parser/payload"; -import { run } from "../src/run"; +import { run, 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 issueTemplate from "./__mocks__/issue-template"; +import repoTemplate from "./__mocks__/repo-template"; +import { STRINGS } from "./__mocks__/strings"; -beforeAll(() => server.listen()); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); +dotenv.config(); +const ONE_DAY = 1000 * 60 * 60 * 24; +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: { optIn: ["ubiquity"], optOut: ["ubiquity/private-repo"] } }); + expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] } }); expect(() => Value.Decode( userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, { warning: "12 foobars", disqualification: "2 days", - watch: { optIn: ["ubiquity"], optOut: ["ubiquity/private-repo"] }, + watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] }, }) ) ).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); }); }); + +async function setupTests() { + for (const item of mockUsers) { + db.users.create(item); + } + + db.repo.create(repoTemplate); + db.repo.create({ ...repoTemplate, id: 2, name: "private-repo" }); + db.repo.create({ ...repoTemplate, id: 3, name: "user-activity-watcher" }); + db.repo.create({ ...repoTemplate, id: 4, name: "filler-repo" }); + db.repo.create({ ...repoTemplate, id: 5, owner: { login: STRINGS.UBIQUIBOT }, html_url: repoTemplate.html_url.replace("ubiquity", STRINGS.UBIQUIBOT) }); + + // nothing to do + db.issue.create({ ...issueTemplate, id: 1, assignees: [STRINGS.UBIQUITY], created_at: new Date(Date.now() - ONE_DAY).toISOString() }); + // nothing to do + db.issue.create({ ...issueTemplate, id: 2, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); + // warning + db.issue.create({ ...issueTemplate, id: 4, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 4).toISOString() }); + // disqualification + db.issue.create({ ...issueTemplate, id: 5, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 8).toISOString() }); + + db.issueComments.create({ id: 1, issueId: 1, body: "test", created_at: new Date(Date.now() - ONE_DAY).toISOString() }); + db.issueComments.create({ id: 2, issueId: 2, body: "test", created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); + db.issueComments.create({ id: 3, issueId: 4, body: "test", created_at: new Date(Date.now() - ONE_DAY * 4).toISOString() }); +} + +function createContext(issueId: number, senderId: number): Context { + return { + payload: { + issue: db.issue.findFirst({ where: { id: { equals: issueId } } }) as unknown as Context["payload"]["issue"], + sender: db.users.findFirst({ where: { id: { equals: senderId } } }) as unknown as Context["payload"]["sender"], + repository: db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["repository"], + action: "assigned", + installation: { id: 1 } as unknown as Context["payload"]["installation"], + organization: { login: STRINGS.UBIQUITY } as unknown as Context["payload"]["organization"], + }, + logger: new Logs("debug"), + config: { + disqualification: ONE_DAY * 7, + warning: ONE_DAY * 3.5, + watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] }, + }, + octokit: new octokit.Octokit(), + eventName: "issues.assigned", + }; +} \ No newline at end of file From b84f543ff942faaa32b96754685701c2947e2202 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 14:02:12 +0100 Subject: [PATCH 02/39] chore: test prep --- tests/__mocks__/db.ts | 8 ++++++++ tests/__mocks__/handlers.ts | 16 +++++++-------- tests/__mocks__/issue-template.ts | 2 +- tests/__mocks__/strings.ts | 34 ++++++++++++++++++++++++++++++- tests/main.test.ts | 26 +++++++++++++++++------ 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 7156e99..ed7cdbb 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -61,6 +61,7 @@ export const db = factory({ repo: { id: primaryKey(Number), html_url: String, + url: String, name: String, owner: { login: String, @@ -114,6 +115,13 @@ export const db = factory({ body: String, created_at: Date, updated_at: Date, + owner: { + login: String, + id: Number, + }, + repo: { + name: String, + }, user: { login: String, id: Number, diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index b28c964..48afac7 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -18,18 +18,18 @@ export const handlers = [ return HttpResponse.json(issueTimeline); }), - http.get("https://api.github.com/:org/repos", () => { - return HttpResponse.json(db.repo.getAll()); + http.get("https://api.github.com/:org/repos", ({ params: { org } }) => { + return HttpResponse.json(db.repo.findMany({ where: { owner: { login: { equals: org as string } } } })); }), - http.get("https://api.github.com/repos/:owner/:repo/issues", () => { - return HttpResponse.json(db.issue.getAll()); + http.get("https://api.github.com/repos/:owner/:repo/issues", ({ params: { owner, repo } }) => { + return HttpResponse.json(db.issue.findMany({ where: { owner: { equals: owner as string }, repo: { equals: repo as string } } })); }), - http.get("https://api.github.com/orgs/:org/repos", () => { - return HttpResponse.json(db.repo.getAll()); + http.get("https://api.github.com/orgs/:org/repos", ({ params: { org } }) => { + return HttpResponse.json(db.repo.findMany({ where: { owner: { login: { equals: org as string } } } })); }), - http.get("https://api.github.com/repos/:owner/:repo/issues/:id/comments", () => { - return HttpResponse.json(db.issueComments.getAll()); + http.get("https://api.github.com/repos/:owner/:repo/issues/:id/comments", ({ params: { owner, repo } }) => { + return HttpResponse.json(db.issueComments.findMany({ where: { owner: { login: { equals: owner as string } }, repo: { name: { equals: repo as string } } } })); }), ]; diff --git a/tests/__mocks__/issue-template.ts b/tests/__mocks__/issue-template.ts index c8f7dab..402b644 100644 --- a/tests/__mocks__/issue-template.ts +++ b/tests/__mocks__/issue-template.ts @@ -37,11 +37,11 @@ export default { node_id: "1", owner: "ubiquity", number: 1, + url: "https://api.github.com/repos/ubiquity/test-repo/issues/1", repository_url: "https://github.com/ubiquity/test-repo", state: "open", title: "issue", updated_at: "", - url: "", user: null, repo: "test-repo", labels: [ diff --git a/tests/__mocks__/strings.ts b/tests/__mocks__/strings.ts index 12ab3d5..56eac9f 100644 --- a/tests/__mocks__/strings.ts +++ b/tests/__mocks__/strings.ts @@ -5,4 +5,36 @@ export const STRINGS = { TEST_REPO: "ubiquity/test-repo", TEST_REPO_NAME: "test-repo", PRIVATE_REPO: "ubiquity/private-repo", -} \ No newline at end of file + PRIVATE_REPO_NAME: "private-repo", + FILLER_REPO: "ubiquity/filler-repo", + FILLER_REPO_NAME: "filler-repo", + USER_ACTIVITY_WATCHER: "ubiquity/user-activity-watcher", + USER_ACTIVITY_WATCHER_NAME: "user-activity-watcher", + TEST_REPO_URL: "https://api.github.com/repos/ubiquity/test-repo/issues/1", + TEST_REPO_HTML_URL: "https://github.com/ubiquity/test-repo/issues/1", +} + + +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 4904196..29ab1cf 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -13,7 +13,7 @@ import { Context } from "../src/types/context"; import mockUsers from "./__mocks__/mock-users"; import issueTemplate from "./__mocks__/issue-template"; import repoTemplate from "./__mocks__/repo-template"; -import { STRINGS } from "./__mocks__/strings"; +import { getIssueHtmlUrl, getIssueUrl, getRepoHtmlUrl, getRepoUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; dotenv.config(); const ONE_DAY = 1000 * 60 * 60 * 24; @@ -52,6 +52,20 @@ describe("User start/stop", () => { const result = await runPlugin(context); expect(result).toBe(true); }); + + it.only("Should process update for all repos except optOut", async () => { + const context = createContext(2, 1); + const infoSpy = jest.spyOn(context.logger, "info"); + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, "Getting ubiquity org repositories: 4") + expect(infoSpy).toHaveBeenNthCalledWith(2, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(1))) + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(2))) + expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(3))) + expect(infoSpy).toHaveBeenNthCalledWith(6, noAssignmentCommentFor(getIssueUrl(4))) + expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.PRIVATE_REPO)); + }); }); async function setupTests() { @@ -63,16 +77,16 @@ async function setupTests() { db.repo.create({ ...repoTemplate, id: 2, name: "private-repo" }); db.repo.create({ ...repoTemplate, id: 3, name: "user-activity-watcher" }); db.repo.create({ ...repoTemplate, id: 4, name: "filler-repo" }); - db.repo.create({ ...repoTemplate, id: 5, owner: { login: STRINGS.UBIQUIBOT }, html_url: repoTemplate.html_url.replace("ubiquity", STRINGS.UBIQUIBOT) }); + db.repo.create({ ...repoTemplate, id: 5, name: "ubiquibot", owner: { login: STRINGS.UBIQUIBOT }, url: getRepoUrl(STRINGS.UBIQUIBOT), html_url: getRepoHtmlUrl(STRINGS.UBIQUIBOT), }); // nothing to do - db.issue.create({ ...issueTemplate, id: 1, assignees: [STRINGS.UBIQUITY], created_at: new Date(Date.now() - ONE_DAY).toISOString() }); + db.issue.create({ ...issueTemplate, id: 1, assignees: [STRINGS.UBIQUITY], created_at: new Date(Date.now() - ONE_DAY).toISOString(), url: getIssueUrl(1), html_url: getIssueHtmlUrl(1) }); // nothing to do - db.issue.create({ ...issueTemplate, id: 2, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); + db.issue.create({ ...issueTemplate, id: 2, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 2).toISOString(), url: getIssueUrl(2), html_url: getIssueHtmlUrl(2) }); // warning - db.issue.create({ ...issueTemplate, id: 4, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 4).toISOString() }); + db.issue.create({ ...issueTemplate, id: 3, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 4).toISOString(), url: getIssueUrl(3), html_url: getIssueHtmlUrl(3) }); // disqualification - db.issue.create({ ...issueTemplate, id: 5, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 8).toISOString() }); + db.issue.create({ ...issueTemplate, id: 4, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 8).toISOString(), url: getIssueUrl(4), html_url: getIssueHtmlUrl(2) }); db.issueComments.create({ id: 1, issueId: 1, body: "test", created_at: new Date(Date.now() - ONE_DAY).toISOString() }); db.issueComments.create({ id: 2, issueId: 2, body: "test", created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); From 254919c5e5d0ecd172636e9210646ee1b2b9db68 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Wed, 31 Jul 2024 21:06:52 +0100 Subject: [PATCH 03/39] chore: comment metadata parsing From 8ef8cd0e542c32dc213f2ea34c0701a601c696a7 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 15:28:31 +0100 Subject: [PATCH 04/39] chore: no multi org --- src/helpers/get-watched-repos.ts | 108 ++++-------------- src/helpers/update-tasks.ts | 73 ++++++------ src/types/plugin-inputs.ts | 10 +- .../results/valid-configuration.json | 5 +- tests/main.test.ts | 63 +++++++--- 5 files changed, 106 insertions(+), 153 deletions(-) diff --git a/src/helpers/get-watched-repos.ts b/src/helpers/get-watched-repos.ts index 5b82502..f0ade6d 100644 --- a/src/helpers/get-watched-repos.ts +++ b/src/helpers/get-watched-repos.ts @@ -1,99 +1,33 @@ import { Context } from "../types/context"; import { ListForOrg } from "../types/github-types"; -import { parseRepoUrl } from "./github-url"; -export async function getWatchedRepos(context: Context, watch: Context["config"]["watch"]) { - const repoUrls = new Set(); - let repos: ListForOrg["data"] = []; - - for (const orgOrRepo of watch.optIn) { - const repositories: ListForOrg["data"] = await getReposForOrg(context, orgOrRepo); - repositories.forEach((repo) => repoUrls.add(repo.html_url)); - repos = repos.concat(repositories); - } - - for (const orgOrRepo of watch.optOut) { - const len = orgOrRepo.split("/").length; - - if (len === 1) { - //it's an org, delete all org repos in the list - repoUrls.forEach((url) => { - if (url.includes(orgOrRepo)) { - const parsed = parseRepoUrl(url); - if (!parsed) return; - const { owner, repo } = parsed; - if (watch.optIn.includes(`${owner}/${repo}`)) { - return; - } - repoUrls.delete(url); - } - }); - } else { - // it's a repo, delete the repo from the list - repoUrls.forEach((url) => url.includes(orgOrRepo) && repoUrls.delete(url)); - } +export async function getWatchedRepos(context: Context) { + const { + config: { + watch: { optOut }, + }, + } = context; + const repoNames = new Set(); + const orgRepos = await getReposForOrg(context, context.payload.repository.owner.login); + orgRepos.forEach((repo) => repoNames.add(repo.name.toLowerCase())); + + for (const repo of optOut) { + repoNames.forEach((name) => (name.includes(repo) ? repoNames.delete(name) : null)); } - return { repoUrls: Array.from(repoUrls), repos }; + return Array.from(repoNames) + .map((name) => orgRepos.find((repo) => repo.name.toLowerCase() === name)) + .filter((repo) => repo !== undefined) as ListForOrg["data"]; } -/** - * Returns all org repositories urls or owner/repo url - * @param orgOrRepo org or repository name - * @returns array of repository urls - */ export async function getReposForOrg(context: Context, orgOrRepo: string) { - const { logger } = context; - if (!orgOrRepo) { - logger.info("No org or repo provided: ", { orgOrRepo }); - return []; - } - - if (orgOrRepo.startsWith("/") || orgOrRepo.endsWith("/")) { - logger.info("Invalid org or repo provided: ", { orgOrRepo }); - return []; - } - const { octokit } = context; - - const params = orgOrRepo.split("/"); - let repos: ListForOrg["data"] = []; try { - switch (params.length) { - case 1: // org - try { - const res = await octokit.paginate(octokit.rest.repos.listForOrg, { - org: orgOrRepo, - }); - repos = res.map((repo) => repo); - logger.info(`Getting ${orgOrRepo} org repositories: ${repos.length}`); - } catch (error: unknown) { - logger.error(`Getting ${orgOrRepo} org repositories failed: ${error}`); - throw error; - } - break; - case 2: // owner/repo - try { - const res = await octokit.rest.repos.get({ - owner: params[0], - repo: params[1], - }); - - if (res.status === 200) { - repos.push(res.data as ListForOrg["data"][0]); - logger.info(`Getting repo ${params[0]}/${params[1]}: ${res.data.html_url}`); - } else logger.error(`Getting repo ${params[0]}/${params[1]} failed: ${res.status}`) - } catch (error: unknown) { - logger.error(`Getting repo ${params[0]}/${params[1]} failed: ${error}`); - throw error; - } - break; - default: - logger.error(`Neither org or nor repo GitHub provided: ${orgOrRepo}.`); - } - } catch (err) { - logger.error("Error getting repositories: ", { err }); + return (await octokit.paginate(octokit.rest.repos.listForOrg, { + org: orgOrRepo, + per_page: 100, + })) as ListForOrg["data"]; + } catch (er) { + throw new Error(`Error getting repositories for org ${orgOrRepo}: ` + JSON.stringify(er)); } - - return repos } diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index 8574ebe..70ec1ce 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -6,14 +6,11 @@ import { parseIssueUrl } from "./github-url"; import { GitHubListEvents, ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types"; export async function updateTasks(context: Context) { - const { - logger, - config: { watch } - } = context; + const { logger } = context; - const { repoUrls, repos } = await getWatchedRepos(context, watch); + const repos = await getWatchedRepos(context); - if (!repoUrls?.length && !repos?.length) { + if (!repos?.length) { logger.info("No watched repos have been found, no work to do."); return false; } @@ -27,15 +24,13 @@ export async function updateTasks(context: Context) { } async function updateReminders(context: Context, repo: ListForOrg["data"][0]) { - const { - octokit - } = context; - const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { - owner: repo.owner.login, + const { octokit } = context; + const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, { + owner: context.payload.repository.owner.login, repo: repo.name, per_page: 100, state: "open", - }) as ListIssueForRepo[]; + })) as ListIssueForRepo[]; for (const issue of issues) { if (issue.assignees?.length || issue.assignee) { @@ -45,23 +40,22 @@ async function updateReminders(context: Context, repo: ListForOrg["data"][0]) { } async function updateReminderForIssue(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) { - const { - logger, - config, - octokit - } = context; - const comments = await octokit.paginate(octokit.rest.issues.listComments, { + const { logger, config, 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[]; + })) 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 = sortAndReturn(botComments.filter((o) => assignmentRegex.test(o?.body || "")), "desc"); + const botAssignmentComments = sortAndReturn( + botComments.filter((o) => assignmentRegex.test(o?.body || "")), + "desc" + ); const botFollowup = /this task has been idle for a while. Please provide an update./gi; const botFollowupComments = botComments.filter((o) => botFollowup.test(o?.body || "")); @@ -83,7 +77,10 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] const metadata = { taskDeadline: taskDeadlineMatch[1], - taskAssignees: taskAssigneesMatch[1].split(",").map((o) => o.trim()).map(Number), + taskAssignees: taskAssigneesMatch[1] + .split(",") + .map((o) => o.trim()) + .map(Number), }; if (!metadata.taskAssignees.length) { @@ -98,7 +95,7 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] logger.info(`No deadline found for ${issue.url}`); return false; } - const deadline = DateTime.fromFormat(metadata?.taskDeadline, "EEE, LLL d, h:mm a 'UTC'") + const deadline = DateTime.fromFormat(metadata?.taskDeadline, "EEE, LLL d, h:mm a 'UTC'"); const now = DateTime.now(); if (!deadline.isValid && !lastCheck.isValid) { @@ -106,7 +103,7 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] return false; } - const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => DateTime.fromISO(o.created_at) > lastCheck) + const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => DateTime.fromISO(o.created_at) > lastCheck); let deadlineWithThreshold = deadline.plus({ milliseconds: config.disqualification }); let reminderWithThreshold = deadline.plus({ milliseconds: config.warning }); @@ -139,28 +136,22 @@ function sortAndReturn(array: ListCommentsForIssue[], direction: "asc" | "desc") } 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.url} and no activity is detected, removing assignees.`); - await removeAllAssignees(context, issue) + await removeAllAssignees(context, issue); } } async function remindAssigneesForIssue(context: Context, issue: ListIssueForRepo) { - const { - logger, - config, - } = context; + const { logger, config } = context; if (config.warning <= 0) { logger.info("The reminder threshold is <= 0, won't send any reminder."); } else { - await remindAssignees(context, issue) + await remindAssignees(context, issue); } } @@ -188,14 +179,13 @@ async function getAssigneesActivityForIssue(context: Context, issue: ListIssueFo } const assignees = issue.assignees ? issue.assignees.map((assignee) => assignee.login) : issue.assignee ? [issue.assignee.login] : []; - return issueEvents.reduce((acc, event) => { - if (event.actor && event.actor.login && event.actor.login) { - - if (assignees.includes(event.actor.login)) - acc.push(event); - } - return acc; - }, [] as GitHubListEvents[]) + return issueEvents + .reduce((acc, event) => { + if (event.actor && event.actor.login && event.actor.login) { + if (assignees.includes(event.actor.login)) acc.push(event); + } + return acc; + }, [] as GitHubListEvents[]) .sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis()); } @@ -211,6 +201,7 @@ async function remindAssignees(context: Context, issue: ListIssueForRepo) { .map((o) => o?.login) .filter((o) => !!o) .join(", @"); + await octokit.rest.issues.createComment({ owner, repo, diff --git a/src/types/plugin-inputs.ts b/src/types/plugin-inputs.ts index 53ceb68..294f923 100644 --- a/src/types/plugin-inputs.ts +++ b/src/types/plugin-inputs.ts @@ -2,7 +2,7 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as Webhook import { StaticDecode, StringOptions, Type as T, TypeBoxError } from "@sinclair/typebox"; import ms from "ms"; -export type SupportedEvents = "issues.assigned" +export type SupportedEvents = "issues.assigned"; export interface PluginInputs { stateId: string; @@ -37,14 +37,10 @@ export const userActivityWatcherSettingsSchema = T.Object({ */ warning: thresholdType({ default: "3.5 days" }), /** - * Define how different organizations, users or specific repositories - * should be watched. Use the following format: - * - * - "ubiquibot" - all repositories in the organization - * - "ubiquity/ubiquibot-logger" - specific repository + * By default all repositories are watched. Use this option to opt-out from watching specific repositories + * within your organization. The value is an array of repository names. */ watch: T.Object({ - optIn: T.Array(T.String()), optOut: T.Array(T.String()), }), /** diff --git a/tests/__mocks__/results/valid-configuration.json b/tests/__mocks__/results/valid-configuration.json index 446fbdb..c5addbb 100644 --- a/tests/__mocks__/results/valid-configuration.json +++ b/tests/__mocks__/results/valid-configuration.json @@ -2,11 +2,8 @@ "warning": "3.5 days", "disqualification": "7 days", "watch": { - "optIn": [ - "ubiquity" - ], "optOut": [ - "ubiquity/private-repo" + "private-repo" ] } } \ No newline at end of file diff --git a/tests/main.test.ts b/tests/main.test.ts index 29ab1cf..1834501 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -35,14 +35,14 @@ describe("User start/stop", () => { it("Should parse thresholds", async () => { const settings = Value.Decode(userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, cfg)); - expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] } }); + expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optOut: [STRINGS.PRIVATE_REPO] } }); expect(() => Value.Decode( userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, { warning: "12 foobars", disqualification: "2 days", - watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] }, + watch: { optOut: [STRINGS.PRIVATE_REPO] }, }) ) ).toThrow(TransformDecodeError); @@ -58,12 +58,12 @@ describe("User start/stop", () => { const infoSpy = jest.spyOn(context.logger, "info"); await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, "Getting ubiquity org repositories: 4") + expect(infoSpy).toHaveBeenNthCalledWith(1, "Getting ubiquity org repositories: 4"); expect(infoSpy).toHaveBeenNthCalledWith(2, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(1))) - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(2))) - expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(3))) - expect(infoSpy).toHaveBeenNthCalledWith(6, noAssignmentCommentFor(getIssueUrl(4))) + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(6, noAssignmentCommentFor(getIssueUrl(4))); expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.PRIVATE_REPO)); }); }); @@ -77,16 +77,51 @@ async function setupTests() { db.repo.create({ ...repoTemplate, id: 2, name: "private-repo" }); db.repo.create({ ...repoTemplate, id: 3, name: "user-activity-watcher" }); db.repo.create({ ...repoTemplate, id: 4, name: "filler-repo" }); - db.repo.create({ ...repoTemplate, id: 5, name: "ubiquibot", owner: { login: STRINGS.UBIQUIBOT }, url: getRepoUrl(STRINGS.UBIQUIBOT), html_url: getRepoHtmlUrl(STRINGS.UBIQUIBOT), }); + db.repo.create({ + ...repoTemplate, + id: 5, + name: "ubiquibot", + owner: { login: STRINGS.UBIQUIBOT }, + url: getRepoUrl(STRINGS.UBIQUIBOT), + html_url: getRepoHtmlUrl(STRINGS.UBIQUIBOT), + }); // nothing to do - db.issue.create({ ...issueTemplate, id: 1, assignees: [STRINGS.UBIQUITY], created_at: new Date(Date.now() - ONE_DAY).toISOString(), url: getIssueUrl(1), html_url: getIssueHtmlUrl(1) }); + db.issue.create({ + ...issueTemplate, + id: 1, + assignees: [STRINGS.UBIQUITY], + created_at: new Date(Date.now() - ONE_DAY).toISOString(), + url: getIssueUrl(1), + html_url: getIssueHtmlUrl(1), + }); // nothing to do - db.issue.create({ ...issueTemplate, id: 2, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 2).toISOString(), url: getIssueUrl(2), html_url: getIssueHtmlUrl(2) }); + db.issue.create({ + ...issueTemplate, + id: 2, + assignees: [STRINGS.USER], + created_at: new Date(Date.now() - ONE_DAY * 2).toISOString(), + url: getIssueUrl(2), + html_url: getIssueHtmlUrl(2), + }); // warning - db.issue.create({ ...issueTemplate, id: 3, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 4).toISOString(), url: getIssueUrl(3), html_url: getIssueHtmlUrl(3) }); + db.issue.create({ + ...issueTemplate, + id: 3, + assignees: [STRINGS.USER], + created_at: new Date(Date.now() - ONE_DAY * 4).toISOString(), + url: getIssueUrl(3), + html_url: getIssueHtmlUrl(3), + }); // disqualification - db.issue.create({ ...issueTemplate, id: 4, assignees: [STRINGS.USER], created_at: new Date(Date.now() - ONE_DAY * 8).toISOString(), url: getIssueUrl(4), html_url: getIssueHtmlUrl(2) }); + db.issue.create({ + ...issueTemplate, + id: 4, + assignees: [STRINGS.USER], + created_at: new Date(Date.now() - ONE_DAY * 8).toISOString(), + url: getIssueUrl(4), + html_url: getIssueHtmlUrl(2), + }); db.issueComments.create({ id: 1, issueId: 1, body: "test", created_at: new Date(Date.now() - ONE_DAY).toISOString() }); db.issueComments.create({ id: 2, issueId: 2, body: "test", created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); @@ -107,9 +142,9 @@ function createContext(issueId: number, senderId: number): Context { config: { disqualification: ONE_DAY * 7, warning: ONE_DAY * 3.5, - watch: { optIn: [STRINGS.UBIQUITY], optOut: [STRINGS.PRIVATE_REPO] }, + watch: { optOut: [STRINGS.PRIVATE_REPO] }, }, octokit: new octokit.Octokit(), eventName: "issues.assigned", }; -} \ No newline at end of file +} From a9d71792a1218730e936d22b7509132abd0a1858 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 16:17:21 +0100 Subject: [PATCH 05/39] chore: test --- src/helpers/github-url.ts | 22 ------- src/helpers/update-tasks.ts | 4 +- tests/__mocks__/db.ts | 5 +- tests/__mocks__/handlers.ts | 2 +- tests/__mocks__/helpers.ts | 36 +++++++++++ tests/__mocks__/repo-template.ts | 4 +- tests/main.test.ts | 108 +++++++++++++------------------ 7 files changed, 90 insertions(+), 91 deletions(-) create mode 100644 tests/__mocks__/helpers.ts diff --git a/src/helpers/github-url.ts b/src/helpers/github-url.ts index 371801e..24ac54f 100644 --- a/src/helpers/github-url.ts +++ b/src/helpers/github-url.ts @@ -8,26 +8,4 @@ export function parseIssueUrl(url: string): { owner: string; repo: string; issue repo: path[2], issue_number: Number(path[4]), }; -} - -export function parseRepoUrl(repoUrl: string) { - if (!repoUrl) { - throw new Error(`[parseRepoUrl] Missing repo URL`); - } - const urlObject = new URL(repoUrl); - const urlPath = urlObject.pathname.split("/"); - - if (urlPath.length === 3) { - const ownerName = urlPath[1]; - const repoName = urlPath[2]; - if (!ownerName || !repoName) { - throw new Error(`Missing owner name or repo name in [${repoUrl}]`); - } - return { - owner: ownerName, - repo: repoName, - }; - } else { - throw new Error(`[parseRepoUrl] Invalid repo URL: [${repoUrl}]`); - } } \ No newline at end of file diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index 70ec1ce..b3d2d13 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -24,9 +24,9 @@ export async function updateTasks(context: Context) { } async function updateReminders(context: Context, repo: ListForOrg["data"][0]) { - const { octokit } = context; + const { octokit, payload } = context; const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, { - owner: context.payload.repository.owner.login, + owner: payload.repository.owner.login, repo: repo.name, per_page: 100, state: "open", diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index ed7cdbb..3e913d8 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -15,7 +15,10 @@ export const db = factory({ html_url: String, repository_url: String, state: String, - owner: String, + owner: { + login: String, + id: Number + }, repo: String, labels: Array, author_association: String, diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 48afac7..2a1be3e 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -23,7 +23,7 @@ export const handlers = [ }), http.get("https://api.github.com/repos/:owner/:repo/issues", ({ params: { owner, repo } }) => { - return HttpResponse.json(db.issue.findMany({ where: { owner: { equals: owner as string }, repo: { equals: repo as string } } })); + return HttpResponse.json(db.issue.findMany({ where: { owner: { login: { equals: owner as string } }, repo: { equals: repo as string } } })); }), http.get("https://api.github.com/orgs/:org/repos", ({ params: { org } }) => { diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts new file mode 100644 index 0000000..1a05f36 --- /dev/null +++ b/tests/__mocks__/helpers.ts @@ -0,0 +1,36 @@ +import { db } from "./db"; +import issueTemplate from "./issue-template"; +import repoTemplate from "./repo-template"; +import { STRINGS, getIssueUrl, getIssueHtmlUrl } from "./strings"; + +export const ONE_DAY = 1000 * 60 * 60 * 24; + +export function createRepo(name?: string, id?: number, owner?: string) { + db.repo.create({ ...repoTemplate, id: id || 1, name: name || repoTemplate.name, owner: { login: owner || STRINGS.UBIQUITY } }); +} + +export function createComment( + id: number, + issueId: number, + body?: string, + created_at?: string, +) { + db.issueComments.create({ id, issueId, body: body || "test", created_at: created_at || new Date(Date.now() - ONE_DAY).toISOString() }); +} + +export function createIssue( + id: number, + assignees: string[], + owner?: string, + created_at?: string, +) { + db.issue.create({ + ...issueTemplate, + id, + assignees: assignees.length ? assignees : [STRINGS.UBIQUITY], + created_at: created_at || new Date(Date.now() - ONE_DAY).toISOString(), + url: getIssueUrl(id), + html_url: getIssueHtmlUrl(id), + owner: { login: owner || STRINGS.UBIQUITY }, + }); +} diff --git a/tests/__mocks__/repo-template.ts b/tests/__mocks__/repo-template.ts index 8090229..0238ddc 100644 --- a/tests/__mocks__/repo-template.ts +++ b/tests/__mocks__/repo-template.ts @@ -1,7 +1,7 @@ export default { id: 1, - html_url: "https://github.com/ubiquity/test-repo", - url: "https://api.github.com/repos/ubiquity/test-repo", + html_url: "https://github.com/ubiquity/test-repo/test-repo", + url: "https://api.github.com/repos/ubiquity/test-repo/test-repo", name: "test-repo", owner: { login: "ubiquity", diff --git a/tests/main.test.ts b/tests/main.test.ts index 1834501..0e8faa7 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -11,12 +11,10 @@ import dotenv from "dotenv"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; import { Context } from "../src/types/context"; import mockUsers from "./__mocks__/mock-users"; -import issueTemplate from "./__mocks__/issue-template"; -import repoTemplate from "./__mocks__/repo-template"; -import { getIssueHtmlUrl, getIssueUrl, getRepoHtmlUrl, getRepoUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; +import { getIssueUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; +import { createComment, createIssue, createRepo, ONE_DAY } from "./__mocks__/helpers"; dotenv.config(); -const ONE_DAY = 1000 * 60 * 60 * 24; const octokit = jest.requireActual("@octokit/rest"); beforeAll(() => { @@ -35,14 +33,14 @@ describe("User start/stop", () => { it("Should parse thresholds", async () => { const settings = Value.Decode(userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, cfg)); - expect(settings).toEqual({ warning: 302400000, disqualification: 604800000, watch: { optOut: [STRINGS.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: [STRINGS.PRIVATE_REPO] }, + watch: { optOut: [STRINGS.PRIVATE_REPO_NAME] }, }) ) ).toThrow(TransformDecodeError); @@ -53,19 +51,39 @@ describe("User start/stop", () => { expect(result).toBe(true); }); - it.only("Should process update for all repos except optOut", async () => { + it("Should process update for all repos except optOut", async () => { const context = createContext(2, 1); const infoSpy = jest.spyOn(context.logger, "info"); await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, "Getting ubiquity org repositories: 4"); - expect(infoSpy).toHaveBeenNthCalledWith(2, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(1))); - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(2))); - expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(3))); - expect(infoSpy).toHaveBeenNthCalledWith(6, noAssignmentCommentFor(getIssueUrl(4))); - expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.PRIVATE_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(4))); + expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); + expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.FILLER_REPO)); + expect(infoSpy).toHaveBeenCalledTimes(7); }); + + it("Should include the previously excluded repo", async () => { + const context = createContext(2, 1); + const infoSpy = jest.spyOn(context.logger, "info"); + context.config.watch.optOut = []; + await runPlugin(context); + + expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(4))); + expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.PRIVATE_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); + expect(infoSpy).toHaveBeenNthCalledWith(8, updatingRemindersFor(STRINGS.FILLER_REPO)); + expect(infoSpy).toHaveBeenCalledTimes(8); + }); + + }); async function setupTests() { @@ -73,59 +91,23 @@ async function setupTests() { db.users.create(item); } - db.repo.create(repoTemplate); - db.repo.create({ ...repoTemplate, id: 2, name: "private-repo" }); - db.repo.create({ ...repoTemplate, id: 3, name: "user-activity-watcher" }); - db.repo.create({ ...repoTemplate, id: 4, name: "filler-repo" }); - db.repo.create({ - ...repoTemplate, - id: 5, - name: "ubiquibot", - owner: { login: STRINGS.UBIQUIBOT }, - url: getRepoUrl(STRINGS.UBIQUIBOT), - html_url: getRepoHtmlUrl(STRINGS.UBIQUIBOT), - }); + createRepo() + createRepo(STRINGS.PRIVATE_REPO_NAME, 2); + createRepo(STRINGS.USER_ACTIVITY_WATCHER_NAME, 3); + createRepo(STRINGS.FILLER_REPO_NAME, 4); + createRepo(STRINGS.UBIQUIBOT, 5, STRINGS.UBIQUIBOT); + createIssue(1, []); // nothing to do - db.issue.create({ - ...issueTemplate, - id: 1, - assignees: [STRINGS.UBIQUITY], - created_at: new Date(Date.now() - ONE_DAY).toISOString(), - url: getIssueUrl(1), - html_url: getIssueHtmlUrl(1), - }); - // nothing to do - db.issue.create({ - ...issueTemplate, - id: 2, - assignees: [STRINGS.USER], - created_at: new Date(Date.now() - ONE_DAY * 2).toISOString(), - url: getIssueUrl(2), - html_url: getIssueHtmlUrl(2), - }); + createIssue(2, [STRINGS.USER]); // warning - db.issue.create({ - ...issueTemplate, - id: 3, - assignees: [STRINGS.USER], - created_at: new Date(Date.now() - ONE_DAY * 4).toISOString(), - url: getIssueUrl(3), - html_url: getIssueHtmlUrl(3), - }); + createIssue(3, [STRINGS.USER]); // disqualification - db.issue.create({ - ...issueTemplate, - id: 4, - assignees: [STRINGS.USER], - created_at: new Date(Date.now() - ONE_DAY * 8).toISOString(), - url: getIssueUrl(4), - html_url: getIssueHtmlUrl(2), - }); + createIssue(4, [STRINGS.USER]); - db.issueComments.create({ id: 1, issueId: 1, body: "test", created_at: new Date(Date.now() - ONE_DAY).toISOString() }); - db.issueComments.create({ id: 2, issueId: 2, body: "test", created_at: new Date(Date.now() - ONE_DAY * 2).toISOString() }); - db.issueComments.create({ id: 3, issueId: 4, body: "test", created_at: new Date(Date.now() - ONE_DAY * 4).toISOString() }); + createComment(1, 1); + createComment(2, 2); + createComment(3, 3); } function createContext(issueId: number, senderId: number): Context { @@ -142,7 +124,7 @@ function createContext(issueId: number, senderId: number): Context { config: { disqualification: ONE_DAY * 7, warning: ONE_DAY * 3.5, - watch: { optOut: [STRINGS.PRIVATE_REPO] }, + watch: { optOut: [STRINGS.PRIVATE_REPO_NAME] }, }, octokit: new octokit.Octokit(), eventName: "issues.assigned", From b8597db5021696a85e88064c2bec210f5af92c41 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 18:16:31 +0100 Subject: [PATCH 06/39] fix: cast created_at as date first --- src/helpers/update-tasks.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index b3d2d13..49ca199 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -86,8 +86,15 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] if (!metadata.taskAssignees.length) { logger.error(`No assignees found for ${issue.url}`); return false; - } else if (metadata.taskAssignees.length && issue.assignees?.length && metadata.taskAssignees.some((a) => !issue.assignees?.map((o) => o.id).includes(a))) { - logger.error(`Assignees mismatch found for ${issue.url}`); + } + + 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.url}`, { + metadata: metadata.taskAssignees, + issue: assigneeIds, + }); return false; } @@ -95,7 +102,7 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] logger.info(`No deadline found for ${issue.url}`); return false; } - const deadline = DateTime.fromFormat(metadata?.taskDeadline, "EEE, LLL d, h:mm a 'UTC'"); + const deadline = DateTime.fromISO(new Date(metadata.taskDeadline).toISOString()); const now = DateTime.now(); if (!deadline.isValid && !lastCheck.isValid) { @@ -103,20 +110,22 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] return false; } - const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => DateTime.fromISO(o.created_at) > lastCheck); + const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => DateTime.fromISO(new Date(o.created_at).toISOString()) > 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); + const lastActivity = DateTime.fromISO(new Date(activity[0].created_at).toISOString()); deadlineWithThreshold = lastActivity.plus({ milliseconds: config.disqualification }); reminderWithThreshold = lastActivity.plus({ milliseconds: config.warning }); } if (now >= deadlineWithThreshold) { + console.log(`now >= deadlineWithThreshold: ${now} >= ${deadlineWithThreshold}`); await unassignUserFromIssue(context, issue); } else if (now >= reminderWithThreshold) { + console.log(`now >= reminderWithThreshold: ${now} >= ${reminderWithThreshold}`); await remindAssigneesForIssue(context, issue); } else { logger.info( From 4f3a7260a12bf59b712e223abf8495be43c2b080 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 19:01:56 +0100 Subject: [PATCH 07/39] feat: tests --- src/helpers/update-tasks.ts | 11 ++--- tests/__mocks__/db.ts | 1 + tests/__mocks__/handlers.ts | 15 +++++- tests/__mocks__/helpers.ts | 14 ++++-- tests/__mocks__/mock-users.ts | 2 +- tests/__mocks__/strings.ts | 40 ++++++++++++++++ tests/main.test.ts | 87 ++++++++++++++++++++++++++++++++--- 7 files changed, 151 insertions(+), 19 deletions(-) diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index 49ca199..d33f415 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -121,16 +121,13 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] reminderWithThreshold = lastActivity.plus({ milliseconds: config.warning }); } - if (now >= deadlineWithThreshold) { - console.log(`now >= deadlineWithThreshold: ${now} >= ${deadlineWithThreshold}`); + if (now >= deadlineWithThreshold && now >= reminderWithThreshold) { await unassignUserFromIssue(context, issue); } else if (now >= reminderWithThreshold) { - console.log(`now >= reminderWithThreshold: ${now} >= ${reminderWithThreshold}`); await remindAssigneesForIssue(context, issue); } else { - logger.info( - `Nothing to do for ${issue.html_url}, still within due-time (now: ${now.toLocaleString(DateTime.DATETIME_MED)}, reminder ${reminderWithThreshold.toLocaleString(DateTime.DATETIME_MED)}, deadline: ${deadlineWithThreshold.toLocaleString(DateTime.DATETIME_MED)})` - ); + 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) }); } } @@ -167,7 +164,7 @@ async function remindAssigneesForIssue(context: Context, issue: ListIssueForRepo /** * Retrieves all the activity for users that are assigned to the issue. Also takes into account linked pull requests. */ -async function getAssigneesActivityForIssue(context: Context, issue: ListIssueForRepo) { +export async function getAssigneesActivityForIssue(context: Context, issue: ListIssueForRepo) { const gitHubUrl = parseIssueUrl(issue.html_url); const issueEvents: GitHubListEvents[] = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { owner: gitHubUrl.owner, diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 3e913d8..ec756b1 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -128,6 +128,7 @@ export const db = factory({ user: { login: String, id: Number, + type: String, }, }, }); diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 2a1be3e..f5235d3 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -30,6 +30,19 @@ export const handlers = [ return HttpResponse.json(db.repo.findMany({ where: { owner: { login: { equals: org as string } } } })); }), http.get("https://api.github.com/repos/:owner/:repo/issues/:id/comments", ({ params: { owner, repo } }) => { - return HttpResponse.json(db.issueComments.findMany({ where: { owner: { login: { equals: owner as string } }, repo: { name: { equals: repo as string } } } })); + return HttpResponse.json(db.issueComments.getAll()); + }), + http.post("https://api.github.com/repos/:owner/:repo/issues/:id/comments", async ({ params: { owner, repo, id }, request: { body } }) => { + const comment = await body?.getReader().read().then((r) => new TextDecoder().decode(r.value)); + if (!comment) { + return HttpResponse.json({ message: "No body" }); + } + + db.issueComments.create({ issueId: Number(id), body: comment, created_at: new Date().toISOString(), id: db.issueComments.count() + 1, owner: { login: owner as string }, repo: { name: repo as string } }); + return HttpResponse.json({ message: "Comment created" }); + }), + http.delete("https://api.github.com/repos/:owner/:repo/issues/:id/assignees", ({ params: { owner, repo, id } }) => { + db.issue.update({ where: { owner: { login: { equals: owner as string } }, repo: { equals: repo as string }, id: { equals: Number(id) } }, data: { assignees: [] } }); + return HttpResponse.json({ message: "Assignees removed" }); }), ]; diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index 1a05f36..9c8ed73 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -12,22 +12,30 @@ export function createRepo(name?: string, id?: number, owner?: string) { export function createComment( id: number, issueId: number, + user: string, + type: "User" | "Bot" = "User", body?: string, created_at?: string, ) { - db.issueComments.create({ id, issueId, body: body || "test", created_at: created_at || new Date(Date.now() - ONE_DAY).toISOString() }); + db.issueComments.create({ + id, + user: { login: user, type }, + issueId, + body: body || "test", + created_at: created_at || new Date(Date.now() - ONE_DAY).toISOString(), + }); } export function createIssue( id: number, - assignees: string[], + assignees: { login: string, id: number }[], owner?: string, created_at?: string, ) { db.issue.create({ ...issueTemplate, id, - assignees: assignees.length ? assignees : [STRINGS.UBIQUITY], + assignees: assignees.length ? assignees : [{ login: STRINGS.UBIQUITY, id: 1 }], created_at: created_at || new Date(Date.now() - ONE_DAY).toISOString(), url: getIssueUrl(id), html_url: getIssueHtmlUrl(id), diff --git a/tests/__mocks__/mock-users.ts b/tests/__mocks__/mock-users.ts index f3c25fb..03eac35 100644 --- a/tests/__mocks__/mock-users.ts +++ b/tests/__mocks__/mock-users.ts @@ -4,7 +4,7 @@ export default [ "login": "ubiquity" }, { - "id": 2, + "id": 100000000, "login": "user2" } ] diff --git a/tests/__mocks__/strings.ts b/tests/__mocks__/strings.ts index 56eac9f..8d12f78 100644 --- a/tests/__mocks__/strings.ts +++ b/tests/__mocks__/strings.ts @@ -1,6 +1,7 @@ export const STRINGS = { UBIQUITY: "ubiquity", UBIQUIBOT: "ubiquibot", + BOT: "ubiquibot[bot]", USER: "user2", TEST_REPO: "ubiquity/test-repo", TEST_REPO_NAME: "test-repo", @@ -12,8 +13,47 @@ export const STRINGS = { USER_ACTIVITY_WATCHER_NAME: "user-activity-watcher", TEST_REPO_URL: "https://api.github.com/repos/ubiquity/test-repo/issues/1", TEST_REPO_HTML_URL: "https://github.com/ubiquity/test-repo/issues/1", + LOGS_ANON_CALLER: "_Logs.", } +export function botAssignmentComment(assigneeId: number, deadlineStr: string) { + return ` + + + + + + + +
Deadline${deadlineStr}
Registered WalletRegister your wallet address using the following slash command: '/wallet 0x0000...0000'
+
+ +
Tips:
+
    +
  • Use /wallet 0x0000...0000 if you want to update your registered payment wallet address.
  • +
  • Be sure to open a draft pull request as soon as possible to communicate updates on your progress.
  • +
  • Be sure to provide timely updates to us when requested, or you will be automatically unassigned from the task.
  • +
      +`; +} export function updatingRemindersFor(repo: string) { return `Updating reminders for ${repo}`; diff --git a/tests/main.test.ts b/tests/main.test.ts index 0e8faa7..f0d9972 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -11,7 +11,7 @@ import dotenv from "dotenv"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; import { Context } from "../src/types/context"; import mockUsers from "./__mocks__/mock-users"; -import { getIssueUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; +import { botAssignmentComment, getIssueHtmlUrl, getIssueUrl, getRepoHtmlUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; import { createComment, createIssue, createRepo, ONE_DAY } from "./__mocks__/helpers"; dotenv.config(); @@ -83,7 +83,77 @@ describe("User start/stop", () => { expect(infoSpy).toHaveBeenCalledTimes(8); }); + it("Should eject the user after the disqualification period", async () => { + const context = createContext(4, 2); + const infoSpy = jest.spyOn(context.logger, "info"); + + createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(9)), daysPriorToNow(9)); + + 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, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenNthCalledWith(3, `Passed the deadline on ${getIssueUrl(2)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(4, `Passed the deadline on ${getIssueUrl(3)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(5, `Passed the deadline on ${getIssueUrl(4)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); + expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.FILLER_REPO)); + expect(infoSpy).toHaveBeenCalledTimes(7); + + 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"); + + createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(5)), daysPriorToNow(5)); + + 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, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenNthCalledWith(3, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); + expect(infoSpy).toHaveBeenNthCalledWith(4, updatingRemindersFor(STRINGS.FILLER_REPO)); + expect(infoSpy).toHaveBeenCalledTimes(4); + + const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); + + const comments = db.issueComments.getAll(); + expect(comments[comments.length - 1].body).toEqual(JSON.stringify({ body: `@${STRINGS.USER}, this task has been idle for a while. Please provide an update.` })); + }); + + it("Should have nothing do withing the warning period", async () => { + const context = createContext(4, 2); + const infoSpy = jest.spyOn(context.logger, "info"); + + createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(2)), daysPriorToNow(2)); + 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, updatingRemindersFor(STRINGS.TEST_REPO)); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenNthCalledWith(3, `Nothing to do for ${getIssueHtmlUrl(2)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(5, `Nothing to do for ${getIssueHtmlUrl(3)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(7, `Nothing to do for ${getIssueHtmlUrl(4)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(9, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); + expect(infoSpy).toHaveBeenNthCalledWith(10, updatingRemindersFor(STRINGS.FILLER_REPO)); + expect(infoSpy).toHaveBeenCalledTimes(10); + + const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); + expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); + }); }); async function setupTests() { @@ -99,15 +169,18 @@ async function setupTests() { createIssue(1, []); // nothing to do - createIssue(2, [STRINGS.USER]); + createIssue(2, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(1)); // warning - createIssue(3, [STRINGS.USER]); + createIssue(3, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(4)); // disqualification - createIssue(4, [STRINGS.USER]); + createIssue(4, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(12)); + + createComment(1, 1, STRINGS.UBIQUITY); + createComment(2, 2, STRINGS.UBIQUITY); +} - createComment(1, 1); - createComment(2, 2); - createComment(3, 3); +function daysPriorToNow(days: number) { + return new Date(Date.now() - ONE_DAY * days).toISOString(); } function createContext(issueId: number, senderId: number): Context { From bed3f955f425d053bf21efd7cde1fec41c3f88a9 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 20:24:55 +0100 Subject: [PATCH 08/39] fix: new date casting --- src/helpers/update-tasks.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index d33f415..a11bc4f 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -102,7 +102,7 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] logger.info(`No deadline found for ${issue.url}`); return false; } - const deadline = DateTime.fromISO(new Date(metadata.taskDeadline).toISOString()); + const deadline = DateTime.fromISO(metadata.taskDeadline); const now = DateTime.now(); if (!deadline.isValid && !lastCheck.isValid) { @@ -110,13 +110,15 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] return false; } - const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => DateTime.fromISO(new Date(o.created_at).toISOString()) > lastCheck); + const activity = (await getAssigneesActivityForIssue(context, issue)).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(new Date(activity[0].created_at).toISOString()); + const lastActivity = DateTime.fromISO(activity[0].created_at); deadlineWithThreshold = lastActivity.plus({ milliseconds: config.disqualification }); reminderWithThreshold = lastActivity.plus({ milliseconds: config.warning }); } From 50d680e0ce53a8b2bf6f2729782e585dd947c90a Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:20:26 +0100 Subject: [PATCH 09/39] chore: fix test env --- src/handlers/collect-linked-pulls.ts | 3 +- src/types/github-types.ts | 1 + tests/__mocks__/db.ts | 13 + tests/__mocks__/routes/get-timeline.json | 332 ++++++++++++++++++++--- tests/main.test.ts | 44 ++- 5 files changed, 357 insertions(+), 36 deletions(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 87caa0c..25a8994 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -19,6 +19,7 @@ export async function collectLinkedPullRequests(context: Context, issue: IssuePa if (!linkedPrUrls) { return false; } + let isClosingPr = false; for (let i = 0; i < linkedPrUrls.length && !isClosingPr; ++i) { const idx = linkedPrUrls[i].indexOf("#"); @@ -32,6 +33,7 @@ export async function collectLinkedPullRequests(context: Context, issue: IssuePa } } } + return isGitHubLinkEvent(event) && event.source.issue.pull_request?.merged_at === null && isClosingPr; }); } @@ -49,7 +51,6 @@ function eliminateDisconnects(issueLinkEvents: GitHubLinkEvent[]) { issueLinkEvents.forEach((issueEvent: GitHubLinkEvent) => { const issueNumber = issueEvent.source.issue.number as number; - if (issueEvent.event === "connected" || issueEvent.event === "cross-referenced") { // Only add to connections if there is no corresponding disconnected event if (!disconnections.has(issueNumber)) { diff --git a/src/types/github-types.ts b/src/types/github-types.ts index ec8f3b2..4f7fa28 100644 --- a/src/types/github-types.ts +++ b/src/types/github-types.ts @@ -8,6 +8,7 @@ export type ListForOrg = RestEndpointMethodTypes["repos"]["listForOrg"]["respons export type ListIssueForRepo = RestEndpointMethodTypes["issues"]["listForRepo"]["response"]["data"][0]; export type ListCommentsForIssue = RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"][0]; export type GitHubListEvents = RestEndpointMethodTypes["issues"]["listEvents"]["response"]["data"][0]; + type LinkPullRequestDetail = { url: string; html_url: string; diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index ec756b1..bcc02c7 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -9,6 +9,19 @@ export const db = factory({ id: primaryKey(Number), login: String, }, + issueEvents: { + id: primaryKey(Number), + source: { + issue: { + body: String, + number: Number, + pull_request: nullable({ + merged_at: nullable(Date), + }), + } + } + + }, issue: { id: primaryKey(Number), assignees: Array, diff --git a/tests/__mocks__/routes/get-timeline.json b/tests/__mocks__/routes/get-timeline.json index a54a40c..805db27 100644 --- a/tests/__mocks__/routes/get-timeline.json +++ b/tests/__mocks__/routes/get-timeline.json @@ -4,6 +4,15 @@ "node_id": "C_kwDOMDzQsNoAKDJjM2YwNzQxYTJlODJjYzc4ZTM0N2Y1YjQxYWVjOTJjM2RjNjg4MDE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/2c3f0741a2e82cc78e347f5b41aec92c3dc68801", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/2c3f0741a2e82cc78e347f5b41aec92c3dc68801", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -32,13 +41,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "561ebf525573ce65d70c7f8fb0214ed1342c1032", "node_id": "C_kwDOMDzQsNoAKDU2MWViZjUyNTU3M2NlNjVkNzBjN2Y4ZmIwMjE0ZWQxMzQyYzEwMzI", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/561ebf525573ce65d70c7f8fb0214ed1342c1032", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/561ebf525573ce65d70c7f8fb0214ed1342c1032", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Fixes #2" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -67,13 +85,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "141a332505336b39c0d902027e49ce57ff98429d", "node_id": "C_kwDOMDzQsNoAKDE0MWEzMzI1MDUzMzZiMzljMGQ5MDIwMjdlNDljZTU3ZmY5ODQyOWQ", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/141a332505336b39c0d902027e49ce57ff98429d", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/141a332505336b39c0d902027e49ce57ff98429d", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Closes https://github.com/ubiquibot/user-activity-watcher/issues/1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -102,13 +129,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "499d25491a0781b900556adb5f8ddb5e61338c09", "node_id": "C_kwDOMDzQsNoAKDQ5OWQyNTQ5MWEwNzgxYjkwMDU1NmFkYjVmOGRkYjVlNjEzMzhjMDk", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/499d25491a0781b900556adb5f8ddb5e61338c09", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/499d25491a0781b900556adb5f8ddb5e61338c09", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolved #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -137,13 +173,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "83d1e7794823d168189a6ecb99d45d9a8b88b76c", "node_id": "C_kwDOMDzQsNoAKDgzZDFlNzc5NDgyM2QxNjgxODlhNmVjYjk5ZDQ1ZDlhOGI4OGI3NmM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/83d1e7794823d168189a6ecb99d45d9a8b88b76c", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/83d1e7794823d168189a6ecb99d45d9a8b88b76c", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Fixes #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -172,13 +217,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "037f7f3eaa2d25a2f5034537e46e16c63c03da8b", "node_id": "C_kwDOMDzQsNoAKDAzN2Y3ZjNlYWEyZDI1YTJmNTAzNDUzN2U0NmUxNmM2M2MwM2RhOGI", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/037f7f3eaa2d25a2f5034537e46e16c63c03da8b", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/037f7f3eaa2d25a2f5034537e46e16c63c03da8b", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Close #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -207,13 +261,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "417b2784352e983469c995e38833f950dc198af5", "node_id": "C_kwDOMDzQsNoAKDQxN2IyNzg0MzUyZTk4MzQ2OWM5OTVlMzg4MzNmOTUwZGMxOThhZjU", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/417b2784352e983469c995e38833f950dc198af5", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/417b2784352e983469c995e38833f950dc198af5", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Fix #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -242,13 +305,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "f6a065fc93025b4f60cfb179f33c4c4654d5df8b", "node_id": "C_kwDOMDzQsNoAKGY2YTA2NWZjOTMwMjViNGY2MGNmYjE3OWYzM2M0YzQ2NTRkNWRmOGI", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/f6a065fc93025b4f60cfb179f33c4c4654d5df8b", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/f6a065fc93025b4f60cfb179f33c4c4654d5df8b", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -277,13 +349,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "fb4be189de5c07794d05099acc9b61991f9813bf", "node_id": "C_kwDOMDzQsNoAKGZiNGJlMTg5ZGU1YzA3Nzk0ZDA1MDk5YWNjOWI2MTk5MWY5ODEzYmY", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/fb4be189de5c07794d05099acc9b61991f9813bf", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/fb4be189de5c07794d05099acc9b61991f9813bf", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -312,13 +393,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac", "node_id": "C_kwDOMDzQsNoAKDZmMTlkNGQwNzIyZGJjZmQ0ZTNiNTljZTFkZGRiOTRhNTUwYTIwYWM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -347,13 +437,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "2b1aaee984aee68973f4fb387465ca9b0807766a", "node_id": "C_kwDOMDzQsNoAKDJiMWFhZWU5ODRhZWU2ODk3M2Y0ZmIzODc0NjVjYTliMDgwNzc2NmE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/2b1aaee984aee68973f4fb387465ca9b0807766a", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/2b1aaee984aee68973f4fb387465ca9b0807766a", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -382,13 +481,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "d5824e5e2e6fd5d87dea2165189654cc1ef3ffd3", "node_id": "C_kwDOMDzQsNoAKGQ1ODI0ZTVlMmU2ZmQ1ZDg3ZGVhMjE2NTE4OTY1NGNjMWVmM2ZmZDM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/d5824e5e2e6fd5d87dea2165189654cc1ef3ffd3", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/d5824e5e2e6fd5d87dea2165189654cc1ef3ffd3", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -417,13 +525,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "connected" }, { "sha": "f256464e5c7fe5aff9275af1dc8d123b43a3a2bc", "node_id": "C_kwDOMDzQsNoAKGYyNTY0NjRlNWM3ZmU1YWZmOTI3NWFmMWRjOGQxMjNiNDNhM2EyYmM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/f256464e5c7fe5aff9275af1dc8d123b43a3a2bc", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/f256464e5c7fe5aff9275af1dc8d123b43a3a2bc", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -452,13 +569,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "a1f914df8a63d77a9e808b5a9224c0dcb5f86baa", "node_id": "C_kwDOMDzQsNoAKGExZjkxNGRmOGE2M2Q3N2E5ZTgwOGI1YTkyMjRjMGRjYjVmODZiYWE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/a1f914df8a63d77a9e808b5a9224c0dcb5f86baa", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/a1f914df8a63d77a9e808b5a9224c0dcb5f86baa", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -487,13 +613,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "bb6a38644d22f86009b9142e56a2e4d2b33b8eff", "node_id": "C_kwDOMDzQsNoAKGJiNmEzODY0NGQyMmY4NjAwOWI5MTQyZTU2YTJlNGQyYjMzYjhlZmY", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/bb6a38644d22f86009b9142e56a2e4d2b33b8eff", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/bb6a38644d22f86009b9142e56a2e4d2b33b8eff", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "github-actions[bot]", "email": "github-actions[bot]@users.noreply.github.com", @@ -522,13 +657,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "86d6aee1b28f9fb8f9a9b7c871c1880cd376e9fa", "node_id": "C_kwDOMDzQsNoAKDg2ZDZhZWUxYjI4ZjlmYjhmOWE5YjdjODcxYzE4ODBjZDM3NmU5ZmE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/86d6aee1b28f9fb8f9a9b7c871c1880cd376e9fa", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/86d6aee1b28f9fb8f9a9b7c871c1880cd376e9fa", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -557,13 +701,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "d8bab65d4f721339bed9918be06bd3bcbf8cc80d", "node_id": "C_kwDOMDzQsNoAKGQ4YmFiNjVkNGY3MjEzMzliZWQ5OTE4YmUwNmJkM2JjYmY4Y2M4MGQ", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/d8bab65d4f721339bed9918be06bd3bcbf8cc80d", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/d8bab65d4f721339bed9918be06bd3bcbf8cc80d", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "github-actions[bot]", "email": "github-actions[bot]@users.noreply.github.com", @@ -592,13 +745,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "0f257468779dca54f9da5c15c66f6d5a56cedc14", "node_id": "C_kwDOMDzQsNoAKDBmMjU3NDY4Nzc5ZGNhNTRmOWRhNWMxNWM2NmY2ZDVhNTZjZWRjMTQ", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/0f257468779dca54f9da5c15c66f6d5a56cedc14", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/0f257468779dca54f9da5c15c66f6d5a56cedc14", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -627,13 +789,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "dce9745932f9adc27b7f8004e0352b2d62f600bb", "node_id": "C_kwDOMDzQsNoAKGRjZTk3NDU5MzJmOWFkYzI3YjdmODAwNGUwMzUyYjJkNjJmNjAwYmI", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/dce9745932f9adc27b7f8004e0352b2d62f600bb", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/dce9745932f9adc27b7f8004e0352b2d62f600bb", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -662,13 +833,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "68c72e35f80eea8fa7319ebf2e68e536fb41dc33", "node_id": "C_kwDOMDzQsNoAKDY4YzcyZTM1ZjgwZWVhOGZhNzMxOWViZjJlNjhlNTM2ZmI0MWRjMzM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/68c72e35f80eea8fa7319ebf2e68e536fb41dc33", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/68c72e35f80eea8fa7319ebf2e68e536fb41dc33", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -697,13 +877,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "b1c379a1792a93f2ebd2112039790a45c5eab1f6", "node_id": "C_kwDOMDzQsNoAKGIxYzM3OWExNzkyYTkzZjJlYmQyMTEyMDM5NzkwYTQ1YzVlYWIxZjY", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/b1c379a1792a93f2ebd2112039790a45c5eab1f6", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/b1c379a1792a93f2ebd2112039790a45c5eab1f6", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -732,13 +921,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "4014c0b6ac7a5a6c6a421542758d68b13171a27c", "node_id": "C_kwDOMDzQsNoAKDQwMTRjMGI2YWM3YTVhNmM2YTQyMTU0Mjc1OGQ2OGIxMzE3MWEyN2M", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/4014c0b6ac7a5a6c6a421542758d68b13171a27c", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/4014c0b6ac7a5a6c6a421542758d68b13171a27c", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -767,13 +965,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "aeba8b437baa5f7178c44cfae09a565d1335e3bf", "node_id": "C_kwDOMDzQsNoAKGFlYmE4YjQzN2JhYTVmNzE3OGM0NGNmYWUwOWE1NjVkMTMzNWUzYmY", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/aeba8b437baa5f7178c44cfae09a565d1335e3bf", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/aeba8b437baa5f7178c44cfae09a565d1335e3bf", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -802,13 +1009,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "47b64a93175c4b452059e50568c9427507dd92ac", "node_id": "C_kwDOMDzQsNoAKDQ3YjY0YTkzMTc1YzRiNDUyMDU5ZTUwNTY4Yzk0Mjc1MDdkZDkyYWM", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/47b64a93175c4b452059e50568c9427507dd92ac", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/47b64a93175c4b452059e50568c9427507dd92ac", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -837,13 +1053,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "7deda8b1d00b6e6c3b003be0fd7490fe7dac3992", "node_id": "C_kwDOMDzQsNoAKDdkZWRhOGIxZDAwYjZlNmMzYjAwM2JlMGZkNzQ5MGZlN2RhYzM5OTI", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/7deda8b1d00b6e6c3b003be0fd7490fe7dac3992", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/7deda8b1d00b6e6c3b003be0fd7490fe7dac3992", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -872,13 +1097,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "c2fbb92c9f02679766efecfb5f801559e9b1e5ba", "node_id": "C_kwDOMDzQsNoAKGMyZmJiOTJjOWYwMjY3OTc2NmVmZWNmYjVmODAxNTU5ZTliMWU1YmE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/c2fbb92c9f02679766efecfb5f801559e9b1e5ba", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/c2fbb92c9f02679766efecfb5f801559e9b1e5ba", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -907,13 +1141,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "f97acec24cff4bcc60487c6d06c0e66c65059dba", "node_id": "C_kwDOMDzQsNoAKGY5N2FjZWMyNGNmZjRiY2M2MDQ4N2M2ZDA2YzBlNjZjNjUwNTlkYmE", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/f97acec24cff4bcc60487c6d06c0e66c65059dba", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/f97acec24cff4bcc60487c6d06c0e66c65059dba", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -942,13 +1185,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "1eb4e1aab67a2946d5fcdd517f248523ef62e5af", "node_id": "C_kwDOMDzQsNoAKDFlYjRlMWFhYjY3YTI5NDZkNWZjZGQ1MTdmMjQ4NTIzZWY2MmU1YWY", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/1eb4e1aab67a2946d5fcdd517f248523ef62e5af", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/1eb4e1aab67a2946d5fcdd517f248523ef62e5af", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -977,13 +1229,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "e191bb71302a0612a541e8059b34b24ccef277a5", "node_id": "C_kwDOMDzQsNoAKGUxOTFiYjcxMzAyYTA2MTJhNTQxZTgwNTliMzRiMjRjY2VmMjc3YTU", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/e191bb71302a0612a541e8059b34b24ccef277a5", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/e191bb71302a0612a541e8059b34b24ccef277a5", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "ubiquibot", "email": "ubiquibot@ubq.fi", @@ -1012,13 +1273,22 @@ "signature": null, "payload": null }, - "event": "committed" + "event": "cross-referenced" }, { "sha": "be21381a33d906f1cb04e216dcf70a119a35ef7a", "node_id": "C_kwDOMDzQsNoAKGJlMjEzODFhMzNkOTA2ZjFjYjA0ZTIxNmRjZjcwYTExOWEzNWVmN2E", "url": "https://api.github.com/repos/ubiquibot/user-activity-watcher/git/commits/be21381a33d906f1cb04e216dcf70a119a35ef7a", "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/be21381a33d906f1cb04e216dcf70a119a35ef7a", + "source": { + "issue": { + "number": 1, + "pull_request": { + "merged_at": null + }, + "body": "Resolves #1" + } + }, "author": { "name": "github-actions[bot]", "email": "41898282+github-actions[bot]@users.noreply.github.com", @@ -1047,6 +1317,6 @@ "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJmXlkPCRC1aQ7uu5UhlAAAh0YQAFV1gc8M2QWmvmR9TK6zhDlM\ni9Yjk9q92ZyyauNcTv4nv1tCm9FRbG+HcomH8mxDiysY1GvPGhNoiKXYeIyueQY6\nGA8dOd6S/OBbLTOKyor7iXyJTz8iaW1GuCPKvoEt9CKcc5rHgx9QX/Q9+VM9SH5d\npgIMOCaCgwxHXtVXURic+zY1FSRSr83b30OmLxRIQkWQx3QtS+eQzxWls4DaRtuz\nJxc+V1igl90s/fAO/5zrVAFSrl/4loFXmGC3VBEdDB/kLSfdJqdevLC3bcdK0NEr\ntJ4RppK2QRf+wl6VNUgmTisfcmPoIH9WhALdcbzHzyAcP9qgM+yPXeyYKMPXuMbw\nZgFkpRtGWJ8YsGwHLAYtGvRmTfKrzntSy1UlWD8bNOR7IkVvOG8iECN6tTm8YIUw\nXS33KxNeurNhdEgVJtwIvCmz4866nkAm3jV8rcGANrM+EdzuclFeA4qNLoump4S0\nr8lz1syX/pf2HUL5uaLJfLRJvNTo+T8obF6Qpaj2AnPfMJMdZkSDZcvhKc5l1T+w\n50NvAj389LWhgxixlr3caYzlt+EpqT2u/TIL3ZyLKrVJ/Qojt/m813inQDdYdUUp\naCIF5nUP+E04F4vRMyg9+gODAeVnN4j82+Qv22v/hwPOqpGxUKCHmbh4YnAhqDjY\nAJTCJ320ZROttEPNXJEE\n=rj+7\n-----END PGP SIGNATURE-----\n", "payload": "tree 76e639aa66bc0a51fcc0a149dbe16eac3bfff79c\nparent e191bb71302a0612a541e8059b34b24ccef277a5\nauthor github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 1717459215 +0000\ncommitter GitHub 1717459215 +0000\n\nchore(main): release 1.0.0" }, - "event": "committed" + "event": "cross-referenced" } -] +] \ No newline at end of file diff --git a/tests/main.test.ts b/tests/main.test.ts index f0d9972..7b8f085 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,7 +1,6 @@ import { drop } from "@mswjs/data"; import { TransformDecodeError, Value } from "@sinclair/typebox/value"; -import program from "../src/parser/payload"; -import { run, runPlugin } from "../src/run"; +import { runPlugin } from "../src/run"; import { userActivityWatcherSettingsSchema } from "../src/types/plugin-inputs"; import { db } from "./__mocks__/db"; import { server } from "./__mocks__/node"; @@ -11,8 +10,9 @@ 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, getRepoHtmlUrl, noAssignmentCommentFor, STRINGS, updatingRemindersFor } from "./__mocks__/strings"; +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"; dotenv.config(); const octokit = jest.requireActual("@octokit/rest"); @@ -131,7 +131,7 @@ describe("User start/stop", () => { expect(comments[comments.length - 1].body).toEqual(JSON.stringify({ body: `@${STRINGS.USER}, this task has been idle for a while. Please provide an update.` })); }); - it("Should have nothing do withing the warning period", async () => { + it("Should have nothing do within the warning period", async () => { const context = createContext(4, 2); const infoSpy = jest.spyOn(context.logger, "info"); @@ -154,6 +154,17 @@ describe("User start/stop", () => { const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); }); + + it.only("Should handle collecting pull requests", async () => { + for (let i = 1; i <= 6; i++) { + createEvent(getIssueUrl(i)); + } + + const context = createContext(1, 1); + const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }); + const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner as string }); + console.log("result", result); + }); }); async function setupTests() { @@ -183,6 +194,31 @@ function daysPriorToNow(days: number) { return new Date(Date.now() - ONE_DAY * days).toISOString(); } +function createEvent(url: string) { + const testPhrases = [ + "Closes #1", + "Fix #1", + "Resolve #1", + "Closes #1", + "Fixes #1", + "Resolves #1", + "Closed #1", + "Close #1", + ] + + return db.issueEvents.create({ + id: db.issueEvents.count() + 1, + source: { + issue: { + pull_request: { + merged_at: Math.random() > 0.5 ? new Date().toISOString() : null, + }, + body: testPhrases[Math.floor(Math.random() * testPhrases.length)] + " " + url, + } + } + }); +} + function createContext(issueId: number, senderId: number): Context { return { payload: { From 0f28006111ecf5648d896d01d8a998f0171c2667 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 5 Aug 2024 23:04:48 +0100 Subject: [PATCH 10/39] chore: remove unused --- tests/__mocks__/db.ts | 13 ------------- tests/main.test.ts | 33 ++------------------------------- 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index bcc02c7..ec756b1 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -9,19 +9,6 @@ export const db = factory({ id: primaryKey(Number), login: String, }, - issueEvents: { - id: primaryKey(Number), - source: { - issue: { - body: String, - number: Number, - pull_request: nullable({ - merged_at: nullable(Date), - }), - } - } - - }, issue: { id: primaryKey(Number), assignees: Array, diff --git a/tests/main.test.ts b/tests/main.test.ts index 7b8f085..b0c40ec 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -155,15 +155,11 @@ describe("User start/stop", () => { expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); }); - it.only("Should handle collecting pull requests", async () => { - for (let i = 1; i <= 6; i++) { - createEvent(getIssueUrl(i)); - } - + it("Should handle collecting pull requests", async () => { const context = createContext(1, 1); const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }); const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner as string }); - console.log("result", result); + expect(result).toHaveLength(1); }); }); @@ -194,31 +190,6 @@ function daysPriorToNow(days: number) { return new Date(Date.now() - ONE_DAY * days).toISOString(); } -function createEvent(url: string) { - const testPhrases = [ - "Closes #1", - "Fix #1", - "Resolve #1", - "Closes #1", - "Fixes #1", - "Resolves #1", - "Closed #1", - "Close #1", - ] - - return db.issueEvents.create({ - id: db.issueEvents.count() + 1, - source: { - issue: { - pull_request: { - merged_at: Math.random() > 0.5 ? new Date().toISOString() : null, - }, - body: testPhrases[Math.floor(Math.random() * testPhrases.length)] + " " + url, - } - } - }); -} - function createContext(issueId: number, senderId: number): Context { return { payload: { From fcf4a7d9e0ec25426a256b297fffacd5cac02a69 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Wed, 7 Aug 2024 00:19:37 +0100 Subject: [PATCH 11/39] chore: tests --- src/handlers/collect-linked-pulls.ts | 1 - tests/__mocks__/helpers.ts | 3 + tests/__mocks__/routes/get-timeline.json | 136 +++++++++++++++++++++-- tests/main.test.ts | 20 +++- 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 25a8994..472bd0f 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -48,7 +48,6 @@ function eliminateDisconnects(issueLinkEvents: GitHubLinkEvent[]) { // Track connections and disconnections const connections = new Map(); // Use issue/pr number as key for easy access const disconnections = new Map(); // Track disconnections - issueLinkEvents.forEach((issueEvent: GitHubLinkEvent) => { const issueNumber = issueEvent.source.issue.number as number; if (issueEvent.event === "connected" || issueEvent.event === "cross-referenced") { diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index 9c8ed73..94b055c 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -31,6 +31,7 @@ export function createIssue( assignees: { login: string, id: number }[], owner?: string, created_at?: string, + repo?: string, ) { db.issue.create({ ...issueTemplate, @@ -40,5 +41,7 @@ export function createIssue( url: getIssueUrl(id), html_url: getIssueHtmlUrl(id), owner: { login: owner || STRINGS.UBIQUITY }, + number: id, + repo: repo || "test-repo", }); } diff --git a/tests/__mocks__/routes/get-timeline.json b/tests/__mocks__/routes/get-timeline.json index 805db27..a6a500b 100644 --- a/tests/__mocks__/routes/get-timeline.json +++ b/tests/__mocks__/routes/get-timeline.json @@ -7,9 +7,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -51,9 +55,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Fixes #2" } }, @@ -94,11 +102,15 @@ "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/141a332505336b39c0d902027e49ce57ff98429d", "source": { "issue": { - "number": 1, + "number": 2, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pull/2", "pull_request": { "merged_at": null }, - "body": "Closes https://github.com/ubiquibot/user-activity-watcher/issues/1" + "owner": { + "login": "ubiquity" + }, + "body": "Closes https://github.com/ubiquibot/user-activity-watcher/issues/2" } }, "author": { @@ -139,9 +151,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/1", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolved #1" } }, @@ -183,9 +199,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/1", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Fixes #1" } }, @@ -227,9 +247,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/1", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Close #1" } }, @@ -271,10 +295,14 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/1", "pull_request": { "merged_at": null }, - "body": "Fix #1" + "owner": { + "login": "ubiquity" + }, + "body": "Fix #2" } }, "author": { @@ -314,11 +342,15 @@ "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/f6a065fc93025b4f60cfb179f33c4c4654d5df8b", "source": { "issue": { - "number": 1, + "number": 3, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/3", "pull_request": { "merged_at": null }, - "body": "Resolves #1" + "owner": { + "login": "ubiquity" + }, + "body": "Resolves #3" } }, "author": { @@ -359,10 +391,14 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, - "body": "Resolves #1" + "owner": { + "login": "ubiquity" + }, + "body": "Resolves #4" } }, "author": { @@ -402,11 +438,15 @@ "html_url": "https://github.com/ubiquibot/user-activity-watcher/commit/6f19d4d0722dbcfd4e3b59ce1dddb94a550a20ac", "source": { "issue": { - "number": 1, + "number": 5, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, - "body": "Resolves #1" + "owner": { + "login": "ubiquity" + }, + "body": "Resolves #5" } }, "author": { @@ -447,9 +487,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -491,9 +535,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -535,9 +583,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -579,9 +631,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -623,9 +679,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -667,9 +727,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -711,9 +775,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -755,9 +823,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -799,9 +871,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -843,9 +919,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -887,9 +967,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -931,9 +1015,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -975,9 +1063,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1019,9 +1111,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1063,9 +1159,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1107,9 +1207,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1151,9 +1255,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1195,9 +1303,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1239,9 +1351,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, @@ -1283,9 +1399,13 @@ "source": { "issue": { "number": 1, + "html_url": "https://github.com/ubiquity/user-activity-watcher/pulls/2", "pull_request": { "merged_at": null }, + "owner": { + "login": "ubiquity" + }, "body": "Resolves #1" } }, diff --git a/tests/main.test.ts b/tests/main.test.ts index b0c40ec..190d120 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -52,7 +52,7 @@ describe("User start/stop", () => { }); it("Should process update for all repos except optOut", async () => { - const context = createContext(2, 1); + const context = createContext(1, 1); const infoSpy = jest.spyOn(context.logger, "info"); await runPlugin(context); @@ -67,7 +67,7 @@ describe("User start/stop", () => { }); it("Should include the previously excluded repo", async () => { - const context = createContext(2, 1); + const context = createContext(1, 1); const infoSpy = jest.spyOn(context.logger, "info"); context.config.watch.optOut = []; await runPlugin(context); @@ -155,11 +155,23 @@ describe("User start/stop", () => { expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); }); - it("Should handle collecting pull requests", async () => { + it("Should handle collecting linked PR with # format", async () => { const context = createContext(1, 1); const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }); - const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner as string }); + const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner.login as string }); + expect(result).toHaveLength(1); + expect(result[0].source.issue.number).toEqual(1); + expect(result[0].source.issue.body).toMatch(/Resolves #1/g); + }); + + it("Should handle collecting linked PR with URL format", async () => { + db.issue.update({ where: { id: { equals: 2 } }, data: { repo: "user-activity-watcher", owner: { login: STRINGS.UBIQUIBOT } } }); + const context = createContext(2, 1); + const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }); + const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner.login as string }); expect(result).toHaveLength(1); + expect(result[0].source.issue.number).toEqual(2); + expect(result[0].source.issue.body).toMatch(/Closes https:\/\/github.com\/ubiquibot\/user-activity-watcher\/issues\/2/); }); }); From c1963b78413389f4bd8f76741e0d2b6f39308d7d Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Wed, 7 Aug 2024 00:47:43 +0100 Subject: [PATCH 12/39] ci: knip - jest/globals unlisted --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c9524a4..d26c20e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,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", From bf670a9627e910cec2016d2180c02506df613732 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Thu, 8 Aug 2024 09:38:56 +0100 Subject: [PATCH 13/39] chore: .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From f88a8197cafbcf1c06781742154d7cf574226f5e Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 12 Aug 2024 19:13:25 +0100 Subject: [PATCH 14/39] chore: use search API --- src/handlers/collect-linked-pulls.ts | 78 +++------------------------- src/helpers/update-tasks.ts | 8 ++- src/types/github-types.ts | 25 +-------- 3 files changed, 16 insertions(+), 95 deletions(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 472bd0f..8203a1c 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -1,78 +1,16 @@ import { parseIssueUrl } from "../helpers/github-url"; import { Context } from "../types/context"; -import { GitHubLinkEvent, GitHubTimelineEvent, isGitHubLinkEvent } from "../types/github-types"; +import { IssuesSearch } from "../types/github-types"; export type IssueParams = ReturnType; -export async function collectLinkedPullRequests(context: Context, issue: IssueParams) { - const onlyPullRequests = await collectLinkedPulls(context, issue); - return onlyPullRequests.filter((event) => { - if (!event.source.issue.body) { - return false; - } - // Matches all keywords according to the docs: - // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword - // Works on multiple linked issues, and matches # or URL patterns - const linkedIssueRegex = - /\b(?:Close(?:s|d)?|Fix(?:es|ed)?|Resolve(?:s|d)?):?\s+(?:#(\d+)|https?:\/\/(?:www\.)?github\.com\/(?:[^/\s]+\/[^/\s]+\/(?:issues|pull)\/(\d+)))\b/gi; - const linkedPrUrls = event.source.issue.body.match(linkedIssueRegex); - if (!linkedPrUrls) { - return false; - } - - let isClosingPr = false; - for (let i = 0; i < linkedPrUrls.length && !isClosingPr; ++i) { - const idx = linkedPrUrls[i].indexOf("#"); - if (idx !== -1) { - isClosingPr = Number(linkedPrUrls[i].slice(idx + 1)) === issue.issue_number; - } else { - const url = linkedPrUrls[i].match(/https.+/)?.[0]; - if (url) { - const linkedRepo = parseIssueUrl(url); - isClosingPr = linkedRepo.issue_number === issue.issue_number && linkedRepo.repo === issue.repo && linkedRepo.owner === issue.owner; - } - } - } - - return isGitHubLinkEvent(event) && event.source.issue.pull_request?.merged_at === null && isClosingPr; - }); +function additionalBooleanFilters(issueNumber: number) { + return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "closes #${issueNumber}" OR "fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}"`; } -export async function collectLinkedPulls(context: Context, issue: IssueParams) { - const issueLinkEvents = await getLinkedEvents(context, issue); - const onlyConnected = eliminateDisconnects(issueLinkEvents); - return onlyConnected.filter((event) => isGitHubLinkEvent(event) && event.source.issue.pull_request); -} - -function eliminateDisconnects(issueLinkEvents: GitHubLinkEvent[]) { - // Track connections and disconnections - const connections = new Map(); // Use issue/pr number as key for easy access - const disconnections = new Map(); // Track disconnections - issueLinkEvents.forEach((issueEvent: GitHubLinkEvent) => { - const issueNumber = issueEvent.source.issue.number as number; - if (issueEvent.event === "connected" || issueEvent.event === "cross-referenced") { - // Only add to connections if there is no corresponding disconnected event - if (!disconnections.has(issueNumber)) { - connections.set(issueNumber, issueEvent); - } - } else if (issueEvent.event === "disconnected") { - disconnections.set(issueNumber, issueEvent); - // If a disconnected event is found, remove the corresponding connected event - if (connections.has(issueNumber)) { - connections.delete(issueNumber); - } - } - }); - - return Array.from(connections.values()); -} - -async function getLinkedEvents(context: Context, params: IssueParams): Promise { - const issueEvents = await getAllTimelineEvents(context, params); - return issueEvents.filter(isGitHubLinkEvent); -} - -export async function getAllTimelineEvents({ octokit }: Context, issueParams: IssueParams): Promise { - const options = octokit.issues.listEventsForTimeline.endpoint.merge(issueParams); - return await octokit.paginate(options); +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, + })) as IssuesSearch[]; } diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index a11bc4f..472839e 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -129,7 +129,11 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] 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) }); + 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), + }); } } @@ -176,7 +180,7 @@ export async function getAssigneesActivityForIssue(context: Context, issue: List }); const linkedPullRequests = await collectLinkedPullRequests(context, gitHubUrl); for (const linkedPullRequest of linkedPullRequests) { - const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.source.issue.html_url); + const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.pull_request?.html_url || ""); const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { owner, repo, diff --git a/src/types/github-types.ts b/src/types/github-types.ts index 4f7fa28..ae85ea3 100644 --- a/src/types/github-types.ts +++ b/src/types/github-types.ts @@ -1,28 +1,7 @@ import { RestEndpointMethodTypes } from "@octokit/rest"; -export type GitHubIssue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; -export type GitHubPullRequest = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"]; -export type GitHubTimelineEvent = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0]; -export type GitHubRepository = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; -export type ListForOrg = RestEndpointMethodTypes["repos"]["listForOrg"]["response"] +export type IssuesSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"]["items"][0]; +export type ListForOrg = RestEndpointMethodTypes["repos"]["listForOrg"]["response"]; export type ListIssueForRepo = RestEndpointMethodTypes["issues"]["listForRepo"]["response"]["data"][0]; export type ListCommentsForIssue = RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"][0]; export type GitHubListEvents = RestEndpointMethodTypes["issues"]["listEvents"]["response"]["data"][0]; - -type LinkPullRequestDetail = { - url: string; - html_url: string; - diff_url: string; - patch_url: string; - merged_at: string; -}; - -type SourceIssueWithPullRequest = GitHubIssue | ((GitHubPullRequest & { pull_request: LinkPullRequestDetail }) & { repository: GitHubRepository }); - -export type GitHubLinkEvent = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0] & { - event: "connected" | "disconnected" | "cross-referenced"; - source: { issue: SourceIssueWithPullRequest }; -}; -export function isGitHubLinkEvent(event: GitHubTimelineEvent): event is GitHubLinkEvent { - return "source" in event; -} From 60b2c59999e3b5e0be84b9eff7018986bea45562 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 12 Aug 2024 20:08:33 +0100 Subject: [PATCH 15/39] chore: search api tests --- src/handlers/collect-linked-pulls.ts | 2 +- tests/__mocks__/db.ts | 3 +++ tests/__mocks__/handlers.ts | 21 +++++++++++++++++++++ tests/__mocks__/helpers.ts | 3 +++ tests/main.test.ts | 22 ++++++---------------- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 8203a1c..52b33d9 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -5,7 +5,7 @@ import { IssuesSearch } from "../types/github-types"; export type IssueParams = ReturnType; function additionalBooleanFilters(issueNumber: number) { - return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "closes #${issueNumber}" OR "fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}"`; + return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "Closes #${issueNumber}" OR "fixes #${issueNumber}" OR "Fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}" OR "Resolves #${issueNumber}"`; } export async function collectLinkedPullRequests(context: Context, issue: IssueParams) { diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index ec756b1..114971d 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -37,6 +37,9 @@ export const db = factory({ url: String, user: nullable(Object), milestone: nullable(Object), + pull_request: { + html_url: String, + }, assignee: nullable({ avatar_url: String, email: nullable(String), diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index f5235d3..aa9c041 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -45,4 +45,25 @@ export const handlers = [ db.issue.update({ where: { owner: { login: { equals: owner as string } }, repo: { equals: repo as string }, id: { equals: Number(id) } }, data: { assignees: [] } }); return HttpResponse.json({ message: "Assignees removed" }); }), + http.get("https://api.github.com/search/issues", ({ request }) => { + const query = new URL(request.url); + const params = query.searchParams; + const q = params.get("q"); + + if (!q) { + return HttpResponse.json({ message: "No query" }); + } + + const issueNumber = q.match(/#(\d+)/)?.[1]; + if (!issueNumber) { + return HttpResponse.json({ message: "No issue number" }); + } + + const issue = db.issue.findFirst({ where: { number: { equals: Number(issueNumber) } } }); + if (!issue) { + return HttpResponse.json({ message: "Issue not found" }); + } + + return HttpResponse.json(issue); + }) ]; diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index 94b055c..a00aec1 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -31,6 +31,7 @@ export function createIssue( assignees: { login: string, id: number }[], owner?: string, created_at?: string, + body?: string, repo?: string, ) { db.issue.create({ @@ -43,5 +44,7 @@ export function createIssue( owner: { login: owner || STRINGS.UBIQUITY }, number: id, repo: repo || "test-repo", + body: body || "test", + pull_request: { html_url: `https://github.com/ubiquity/test-repo/pulls/${id}` }, }); } diff --git a/tests/main.test.ts b/tests/main.test.ts index 190d120..922a26a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -160,18 +160,8 @@ describe("User start/stop", () => { const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }); const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner.login as string }); expect(result).toHaveLength(1); - expect(result[0].source.issue.number).toEqual(1); - expect(result[0].source.issue.body).toMatch(/Resolves #1/g); - }); - - it("Should handle collecting linked PR with URL format", async () => { - db.issue.update({ where: { id: { equals: 2 } }, data: { repo: "user-activity-watcher", owner: { login: STRINGS.UBIQUIBOT } } }); - const context = createContext(2, 1); - const issue = db.issue.findFirst({ where: { id: { equals: 2 } } }); - const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner.login as string }); - expect(result).toHaveLength(1); - expect(result[0].source.issue.number).toEqual(2); - expect(result[0].source.issue.body).toMatch(/Closes https:\/\/github.com\/ubiquibot\/user-activity-watcher\/issues\/2/); + expect(result[0].number).toEqual(1); + expect(result[0].body).toMatch(/Resolves #1/gi); }); }); @@ -186,13 +176,13 @@ async function setupTests() { createRepo(STRINGS.FILLER_REPO_NAME, 4); createRepo(STRINGS.UBIQUIBOT, 5, STRINGS.UBIQUIBOT); - createIssue(1, []); + createIssue(1, [], STRINGS.UBIQUITY, daysPriorToNow(1), "resolves #1"); // nothing to do - createIssue(2, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(1)); + createIssue(2, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(1), "resolves #1"); // warning - createIssue(3, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(4)); + createIssue(3, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(4), "fixes #2"); // disqualification - createIssue(4, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(12)); + createIssue(4, [{ login: STRINGS.USER, id: 2 }], STRINGS.UBIQUITY, daysPriorToNow(12), "closes #3"); createComment(1, 1, STRINGS.UBIQUITY); createComment(2, 2, STRINGS.UBIQUITY); From c14c3388cf1956b0107f19d6a79d3a2b8c4ee174 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:13:00 +0100 Subject: [PATCH 16/39] chore: add v1 support --- src/helpers/update-tasks.ts | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index 472839e..4c1e2dd 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -4,6 +4,7 @@ import { Context } from "../types/context"; import { getWatchedRepos } from "./get-watched-repos"; import { parseIssueUrl } from "./github-url"; import { GitHubListEvents, ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types"; +import ms from "ms"; export async function updateTasks(context: Context) { const { logger } = context; @@ -72,22 +73,42 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] if (!taskDeadlineMatch || !taskAssigneesMatch) { logger.error(`Missing metadata from ${issue.url}`); - return false; } const metadata = { - taskDeadline: taskDeadlineMatch[1], - taskAssignees: taskAssigneesMatch[1] + taskDeadline: taskDeadlineMatch?.[1] || "", + taskAssignees: taskAssigneesMatch?.[1] .split(",") .map((o) => o.trim()) .map(Number), }; - if (!metadata.taskAssignees.length) { - logger.error(`No assignees found for ${issue.url}`); + // 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.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.url}`); + return false; + } + const duration = taskDeadlineMatch[1] || ""; + const durationInMs = ms(duration); + if (!durationInMs) { + logger.error(`Invalid duration found on ${issue.url}`); + return false; + } + metadata.taskDeadline = DateTime.fromMillis(lastCheck.toMillis() + durationInMs).toISO() || ""; + } + const assigneeIds = issue.assignees?.map((o) => o.id) || []; if (assigneeIds.length && metadata.taskAssignees.some((a) => !assigneeIds.includes(a))) { @@ -137,6 +158,9 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] } } +async function legacyUpdateReminderForIssue(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) { +} + function sortAndReturn(array: ListCommentsForIssue[], direction: "asc" | "desc") { return array.sort((a, b) => { if (direction === "asc") { From 55f86c43e480f48dee37ef2486bd9a6cd4082e70 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:37:57 +0100 Subject: [PATCH 17/39] chore: fix search query and v1 support --- src/handlers/collect-linked-pulls.ts | 3 ++- src/helpers/update-tasks.ts | 14 +------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 52b33d9..e78f198 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -4,8 +4,9 @@ import { IssuesSearch } from "../types/github-types"; export type IssueParams = ReturnType; +// cannot use more than 5 AND / OR / NOT operators in a query function additionalBooleanFilters(issueNumber: number) { - return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "Closes #${issueNumber}" OR "fixes #${issueNumber}" OR "Fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}" OR "Resolves #${issueNumber}"`; + return `in:body "closes #${issueNumber}" OR "fixes #${issueNumber}" OR "resolves #${issueNumber}"`; } export async function collectLinkedPullRequests(context: Context, issue: IssueParams) { diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index 4c1e2dd..cb1a30d 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -17,7 +17,6 @@ export async function updateTasks(context: Context) { } for (const repo of repos) { - logger.info(`Updating reminders for ${repo.owner.login}/${repo.name}`); await updateReminders(context, repo); } @@ -71,10 +70,6 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] const taskDeadlineMatch = taskDeadlineJsonRegex.exec(botAssignmentComments[0]?.body || ""); const taskAssigneesMatch = taskAssigneesJsonRegex.exec(botAssignmentComments[0]?.body || ""); - if (!taskDeadlineMatch || !taskAssigneesMatch) { - logger.error(`Missing metadata from ${issue.url}`); - } - const metadata = { taskDeadline: taskDeadlineMatch?.[1] || "", taskAssignees: taskAssigneesMatch?.[1] @@ -94,7 +89,7 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] } if (!metadata.taskDeadline) { - const taskDeadlineJsonRegex = /"duration": "([^"]*)"/g; + const taskDeadlineJsonRegex = /"duration": ([^,]*),/g; const taskDeadlineMatch = taskDeadlineJsonRegex.exec(botAssignmentComments[0]?.body || ""); if (!taskDeadlineMatch) { logger.error(`Missing deadline from ${issue.url}`); @@ -119,10 +114,6 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] return false; } - if (!metadata.taskDeadline) { - logger.info(`No deadline found for ${issue.url}`); - return false; - } const deadline = DateTime.fromISO(metadata.taskDeadline); const now = DateTime.now(); @@ -158,9 +149,6 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] } } -async function legacyUpdateReminderForIssue(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) { -} - function sortAndReturn(array: ListCommentsForIssue[], direction: "asc" | "desc") { return array.sort((a, b) => { if (direction === "asc") { From cfcdfed70100c294ff98aa68179d7329d0d26d8a Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:44:47 +0100 Subject: [PATCH 18/39] chore: duration can be equal to 0 --- src/helpers/update-tasks.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts index cb1a30d..d1ac1f4 100644 --- a/src/helpers/update-tasks.ts +++ b/src/helpers/update-tasks.ts @@ -97,8 +97,11 @@ async function updateReminderForIssue(context: Context, repo: ListForOrg["data"] } const duration = taskDeadlineMatch[1] || ""; const durationInMs = ms(duration); - if (!durationInMs) { - logger.error(`Invalid duration found on ${issue.url}`); + 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.url}`); return false; } metadata.taskDeadline = DateTime.fromMillis(lastCheck.toMillis() + durationInMs).toISO() || ""; From eebf3280de63c05dd57dc7c9d0a5d86af1113f4b Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:55:56 +0100 Subject: [PATCH 19/39] chore: update tests --- tests/main.test.ts | 60 +++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/tests/main.test.ts b/tests/main.test.ts index 922a26a..fa1fbea 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -56,14 +56,11 @@ describe("User start/stop", () => { const infoSpy = jest.spyOn(context.logger, "info"); await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(1))); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(2))); - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(3))); - expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(4))); - expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); - expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.FILLER_REPO)); - expect(infoSpy).toHaveBeenCalledTimes(7); + expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(4))); + expect(infoSpy).toHaveBeenCalledTimes(4); }); it("Should include the previously excluded repo", async () => { @@ -72,15 +69,11 @@ describe("User start/stop", () => { context.config.watch.optOut = []; await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(1))); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(2))); - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(3))); - expect(infoSpy).toHaveBeenNthCalledWith(5, noAssignmentCommentFor(getIssueUrl(4))); - expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.PRIVATE_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); - expect(infoSpy).toHaveBeenNthCalledWith(8, updatingRemindersFor(STRINGS.FILLER_REPO)); - expect(infoSpy).toHaveBeenCalledTimes(8); + expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueUrl(1))); + expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(2))); + expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(3))); + expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(4))); + expect(infoSpy).toHaveBeenCalledTimes(4); }); it("Should eject the user after the disqualification period", async () => { @@ -94,14 +87,11 @@ describe("User start/stop", () => { await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenNthCalledWith(3, `Passed the deadline on ${getIssueUrl(2)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenNthCalledWith(4, `Passed the deadline on ${getIssueUrl(3)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenNthCalledWith(5, `Passed the deadline on ${getIssueUrl(4)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenNthCalledWith(6, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); - expect(infoSpy).toHaveBeenNthCalledWith(7, updatingRemindersFor(STRINGS.FILLER_REPO)); - expect(infoSpy).toHaveBeenCalledTimes(7); + expect(infoSpy).toHaveBeenNthCalledWith(1, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Passed the deadline on ${getIssueUrl(2)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(3, `Passed the deadline on ${getIssueUrl(3)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenNthCalledWith(4, `Passed the deadline on ${getIssueUrl(4)} and no activity is detected, removing assignees.`); + expect(infoSpy).toHaveBeenCalledTimes(4); const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); expect(updatedIssue?.assignees).toEqual([]); @@ -118,11 +108,8 @@ describe("User start/stop", () => { await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenNthCalledWith(3, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); - expect(infoSpy).toHaveBeenNthCalledWith(4, updatingRemindersFor(STRINGS.FILLER_REPO)); - expect(infoSpy).toHaveBeenCalledTimes(4); + expect(infoSpy).toHaveBeenNthCalledWith(1, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenCalledTimes(1); const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); @@ -142,14 +129,11 @@ describe("User start/stop", () => { await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, updatingRemindersFor(STRINGS.TEST_REPO)); - expect(infoSpy).toHaveBeenNthCalledWith(2, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenNthCalledWith(3, `Nothing to do for ${getIssueHtmlUrl(2)}, still within due-time.`); - expect(infoSpy).toHaveBeenNthCalledWith(5, `Nothing to do for ${getIssueHtmlUrl(3)}, still within due-time.`); - expect(infoSpy).toHaveBeenNthCalledWith(7, `Nothing to do for ${getIssueHtmlUrl(4)}, still within due-time.`); - expect(infoSpy).toHaveBeenNthCalledWith(9, updatingRemindersFor(STRINGS.USER_ACTIVITY_WATCHER)); - expect(infoSpy).toHaveBeenNthCalledWith(10, updatingRemindersFor(STRINGS.FILLER_REPO)); - expect(infoSpy).toHaveBeenCalledTimes(10); + expect(infoSpy).toHaveBeenNthCalledWith(1, `Assignees mismatch found for ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); + expect(infoSpy).toHaveBeenNthCalledWith(2, `Nothing to do for ${getIssueHtmlUrl(2)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(4, `Nothing to do for ${getIssueHtmlUrl(3)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(6, `Nothing to do for ${getIssueHtmlUrl(4)}, still within due-time.`); + expect(infoSpy).toHaveBeenCalledTimes(7); const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); From 7dd82e5a0a68d1b8ebf53b5e79e9cbf49c8607ad Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 23:04:13 +0100 Subject: [PATCH 20/39] chore: index fix --- src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4249512..ef6c2be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,7 @@ import program from "./parser/payload"; import { run } from "./run"; run(program) - .then((result) => { - core?.setOutput("result", result); - }) + .then((result) => core?.setOutput("result", result)) .catch((e) => { console.error("Failed to run user-activity-watcher:", e); core?.setFailed(e.toString()); From c6dfebcc36ed615e5e2fa976e6ea029087f99680 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 16 Aug 2024 23:05:15 +0100 Subject: [PATCH 21/39] chore: remove packageManager field from package.json --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index d26c20e..367ef2e 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,5 @@ "extends": [ "@commitlint/config-conventional" ] - }, - "packageManager": "yarn@1.22.22" + } } \ No newline at end of file From 8902bba80d3e6d28ec07406558481b9927e74034 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 09:21:11 +0100 Subject: [PATCH 22/39] chore: code clean up and verbose logging --- src/handlers/watch-user-activity.ts | 43 +++++ src/helpers/get-assignee-activity.ts | 38 ++++ src/helpers/remind-and-remove.ts | 64 +++++++ src/helpers/task-deadline.ts | 49 +++++ src/helpers/task-metadata.ts | 82 +++++++++ src/helpers/task-update.ts | 44 +++++ src/helpers/update-tasks.ts | 257 --------------------------- src/run.ts | 6 +- 8 files changed, 323 insertions(+), 260 deletions(-) create mode 100644 src/handlers/watch-user-activity.ts create mode 100644 src/helpers/get-assignee-activity.ts create mode 100644 src/helpers/remind-and-remove.ts create mode 100644 src/helpers/task-deadline.ts create mode 100644 src/helpers/task-metadata.ts create mode 100644 src/helpers/task-update.ts delete mode 100644 src/helpers/update-tasks.ts diff --git a/src/handlers/watch-user-activity.ts b/src/handlers/watch-user-activity.ts new file mode 100644 index 0000000..1c781e1 --- /dev/null +++ b/src/handlers/watch-user-activity.ts @@ -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); + } + } +} diff --git a/src/helpers/get-assignee-activity.ts b/src/helpers/get-assignee-activity.ts new file mode 100644 index 0000000..3a80521 --- /dev/null +++ b/src/helpers/get-assignee-activity.ts @@ -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()); +} \ No newline at end of file diff --git a/src/helpers/remind-and-remove.ts b/src/helpers/remind-and-remove.ts new file mode 100644 index 0000000..7157204 --- /dev/null +++ b/src/helpers/remind-and-remove.ts @@ -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; +} \ No newline at end of file diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts new file mode 100644 index 0000000..7400cbe --- /dev/null +++ b/src/helpers/task-deadline.ts @@ -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 }; +} \ 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..f3cb906 --- /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 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 }; +} \ No newline at end of file diff --git a/src/helpers/task-update.ts b/src/helpers/task-update.ts new file mode 100644 index 0000000..2ea0039 --- /dev/null +++ b/src/helpers/task-update.ts @@ -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), + }); + } +} \ No newline at end of file diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts deleted file mode 100644 index d1ac1f4..0000000 --- a/src/helpers/update-tasks.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { DateTime } from "luxon"; -import { collectLinkedPullRequests } from "../handlers/collect-linked-pulls"; -import { Context } from "../types/context"; -import { getWatchedRepos } from "./get-watched-repos"; -import { parseIssueUrl } from "./github-url"; -import { GitHubListEvents, ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types"; -import ms from "ms"; - -export async function updateTasks(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 { 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) { - if (issue.assignees?.length || issue.assignee) { - await updateReminderForIssue(context, repo, issue); - } - } -} - -async function updateReminderForIssue(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) { - const { logger, config, 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 = sortAndReturn( - botComments.filter((o) => assignmentRegex.test(o?.body || "")), - "desc" - ); - 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.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.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.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.url}`); - return false; - } - metadata.taskDeadline = DateTime.fromMillis(lastCheck.toMillis() + durationInMs).toISO() || ""; - } - - 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.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.url}`); - return false; - } - - const activity = (await getAssigneesActivityForIssue(context, issue)).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 }); - } - - if (now >= deadlineWithThreshold && now >= reminderWithThreshold) { - 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), - }); - } -} - -function sortAndReturn(array: ListCommentsForIssue[], direction: "asc" | "desc") { - return array.sort((a, b) => { - if (direction === "asc") { - return DateTime.fromISO(a.created_at).toMillis() - DateTime.fromISO(b.created_at).toMillis(); - } else { - return DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis(); - } - }); -} - -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.url} and no activity is detected, removing assignees.`); - await removeAllAssignees(context, issue); - } -} - -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 { - await remindAssignees(context, issue); - } -} - -/** - * 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) { - 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); - } - const assignees = issue.assignees ? issue.assignees.map((assignee) => assignee.login) : issue.assignee ? [issue.assignee.login] : []; - - return issueEvents - .reduce((acc, event) => { - if (event.actor && event.actor.login && event.actor.login) { - if (assignees.includes(event.actor.login)) acc.push(event); - } - return acc; - }, [] as GitHubListEvents[]) - .sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis()); -} - -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.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.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/run.ts b/src/run.ts index 9979dcf..7c48a1b 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,8 +1,8 @@ import { Octokit } from "@octokit/rest"; -import { updateTasks } from "./helpers/update-tasks"; import { Context } from "./types/context"; import { PluginInputs } from "./types/plugin-inputs"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; +import { watchUserActivity } from "./handlers/watch-user-activity"; export async function run(inputs: PluginInputs) { const octokit = new Octokit({ auth: inputs.authToken }); @@ -11,12 +11,12 @@ export async function run(inputs: PluginInputs) { payload: inputs.eventPayload, config: inputs.settings, octokit, - logger: new Logs("info"), + logger: new Logs("verbose"), }; await runPlugin(context); return JSON.stringify({ status: 200 }); } export async function runPlugin(context: Context) { - return await updateTasks(context); + return await watchUserActivity(context); } From 5a295dc4453879fd677ee98c1e16c2a4953e8eba Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 09:51:04 +0100 Subject: [PATCH 23/39] chore: add linked:issue to pr search query --- src/handlers/collect-linked-pulls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index e78f198..a718ed3 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -11,7 +11,7 @@ function additionalBooleanFilters(issueNumber: number) { 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)}`, + q: `repo:${issue.owner}/${issue.repo} is:pr is:open linked:issue ${additionalBooleanFilters(issue.issue_number)}`, per_page: 100, })) as IssuesSearch[]; } From 4901365616b7c26939fd16481948fd093890f1f9 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:20:45 +0100 Subject: [PATCH 24/39] chore: move to graphql --- package.json | 5 +- src/handlers/collect-linked-pulls.ts | 70 +++++++++++++++++++++++----- yarn.lock | 25 ++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 367ef2e..5846288 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,6 +32,7 @@ "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", @@ -85,5 +87,6 @@ "extends": [ "@commitlint/config-conventional" ] - } + }, + "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 a718ed3..963f16e 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -1,17 +1,65 @@ -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; +} -// cannot use more than 5 AND / OR / NOT operators in a query -function additionalBooleanFilters(issueNumber: number) { - return `in:body "closes #${issueNumber}" OR "fixes #${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 linked:issue ${additionalBooleanFilters(issue.issue_number)}`, - per_page: 100, - })) as IssuesSearch[]; +const query = `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 + } + } + } + } + } + } + } +}` + +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); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 12976d2..4fd8c75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1469,6 +1469,14 @@ "@octokit/types" "^13.1.0" universal-user-agent "^6.0.0" +"@octokit/graphql-schema@^15.25.0": + version "15.25.0" + resolved "https://registry.yarnpkg.com/@octokit/graphql-schema/-/graphql-schema-15.25.0.tgz#30bb8ecc494c249650991b33f2f0d9332dbe87e9" + integrity sha512-aqz9WECtdxVWSqgKroUu9uu+CRt5KnfErWs0dBPKlTdrreAeWzS5NRu22ZVcGdPP7s3XDg2Gnf5iyoZPCRZWmQ== + dependencies: + graphql "^16.0.0" + graphql-tag "^2.10.3" + "@octokit/graphql@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-7.1.0.tgz#9bc1c5de92f026648131f04101cab949eeffe4e0" @@ -3181,6 +3189,18 @@ graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +graphql-tag@^2.10.3: + version "2.12.6" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" + integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== + dependencies: + tslib "^2.1.0" + +graphql@^16.0.0: + version "16.9.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" + integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== + graphql@^16.8.1: version "16.8.1" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" @@ -5122,6 +5142,11 @@ ts-jest@29.1.4: semver "^7.5.3" yargs-parser "^21.0.1" +tslib@^2.1.0: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tslib@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" From 7bfe8154f4bd5a663388697195ba6de9f02a44a0 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:20:51 +0100 Subject: [PATCH 25/39] chore: correct prop access --- src/helpers/get-assignee-activity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/get-assignee-activity.ts b/src/helpers/get-assignee-activity.ts index 3a80521..de4cd22 100644 --- a/src/helpers/get-assignee-activity.ts +++ b/src/helpers/get-assignee-activity.ts @@ -17,7 +17,7 @@ export async function getAssigneesActivityForIssue(context: Context, issue: List }); const linkedPullRequests = await collectLinkedPullRequests(context, gitHubUrl); for (const linkedPullRequest of linkedPullRequests) { - const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.pull_request?.html_url || ""); + const { owner, repo, issue_number } = parseIssueUrl(linkedPullRequest.url || ""); const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, { owner, repo, From 92155628708ab4b637035d1a06e6883e619c689a Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:21:28 +0100 Subject: [PATCH 26/39] chore: omit return false until live --- src/helpers/task-deadline.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts index 7400cbe..09ab5e9 100644 --- a/src/helpers/task-deadline.ts +++ b/src/helpers/task-deadline.ts @@ -18,10 +18,10 @@ export async function handleDeadline( if (assigneeIds.length && metadata.taskAssignees?.some((a) => !assigneeIds.includes(a))) { logger.info(`Assignees mismatch found for ${issue.html_url}`, { - metadata: metadata.taskAssignees, - issue: assigneeIds, + metadata, + assigneeIds, }); - return false; + // return false; } const deadline = DateTime.fromISO(metadata.taskDeadline); From c8c40390926ade2a9be53e7778267d8f2e946ebe Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:22:02 +0100 Subject: [PATCH 27/39] chore: update tests --- tests/__mocks__/@octokit/graphql-schema.js | 3 + tests/__mocks__/db.ts | 3 - tests/__mocks__/handlers.ts | 55 ++++++++------ tests/__mocks__/helpers.ts | 1 - tests/main.test.ts | 85 ++++++++++++++-------- 5 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 tests/__mocks__/@octokit/graphql-schema.js diff --git a/tests/__mocks__/@octokit/graphql-schema.js b/tests/__mocks__/@octokit/graphql-schema.js new file mode 100644 index 0000000..170dcfa --- /dev/null +++ b/tests/__mocks__/@octokit/graphql-schema.js @@ -0,0 +1,3 @@ +module.exports = { + validate: () => [], +} \ No newline at end of file diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 114971d..ec756b1 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -37,9 +37,6 @@ export const db = factory({ url: String, user: nullable(Object), milestone: nullable(Object), - pull_request: { - html_url: String, - }, assignee: nullable({ avatar_url: String, email: nullable(String), diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index aa9c041..c5469c7 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -45,25 +45,38 @@ export const handlers = [ db.issue.update({ where: { owner: { login: { equals: owner as string } }, repo: { equals: repo as string }, id: { equals: Number(id) } }, data: { assignees: [] } }); return HttpResponse.json({ message: "Assignees removed" }); }), - http.get("https://api.github.com/search/issues", ({ request }) => { - const query = new URL(request.url); - const params = query.searchParams; - const q = params.get("q"); - - if (!q) { - return HttpResponse.json({ message: "No query" }); - } - - const issueNumber = q.match(/#(\d+)/)?.[1]; - if (!issueNumber) { - return HttpResponse.json({ message: "No issue number" }); - } - - const issue = db.issue.findFirst({ where: { number: { equals: Number(issueNumber) } } }); - if (!issue) { - return HttpResponse.json({ message: "Issue not found" }); - } - - return HttpResponse.json(issue); - }) + http.post("https://api.github.com/graphql", () => { + return HttpResponse.json({ + data: { + repository: { + issue: { + closedByPullRequestsReferences: { + edges: [ + { + node: { + url: "https://github.com/ubiquity/test-repo/pull/1", + title: "test", + body: "test", + state: "OPEN", + number: 1, + author: { login: "ubiquity", id: 1 }, + }, + }, + { + node: { + url: "https://github.com/ubiquity/test-repo/pull/1", + title: "test", + body: "test", + state: "CLOSED", + number: 2, + author: { login: "user2", id: 2 }, + }, + } + ], + }, + }, + }, + }, + }); + }), ]; diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index a00aec1..24fa7f3 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -45,6 +45,5 @@ export function createIssue( number: id, repo: repo || "test-repo", body: body || "test", - pull_request: { html_url: `https://github.com/ubiquity/test-repo/pulls/${id}` }, }); } diff --git a/tests/main.test.ts b/tests/main.test.ts index fa1fbea..999b6bd 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -56,11 +56,10 @@ describe("User start/stop", () => { const infoSpy = jest.spyOn(context.logger, "info"); await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueUrl(1))); - expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(2))); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(3))); - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(4))); - expect(infoSpy).toHaveBeenCalledTimes(4); + 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 () => { @@ -69,29 +68,28 @@ describe("User start/stop", () => { context.config.watch.optOut = []; await runPlugin(context); - expect(infoSpy).toHaveBeenNthCalledWith(1, noAssignmentCommentFor(getIssueUrl(1))); - expect(infoSpy).toHaveBeenNthCalledWith(2, noAssignmentCommentFor(getIssueUrl(2))); - expect(infoSpy).toHaveBeenNthCalledWith(3, noAssignmentCommentFor(getIssueUrl(3))); - expect(infoSpy).toHaveBeenNthCalledWith(4, noAssignmentCommentFor(getIssueUrl(4))); - expect(infoSpy).toHaveBeenCalledTimes(4); + 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"); - createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(9)), daysPriorToNow(9)); + 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 ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenNthCalledWith(2, `Passed the deadline on ${getIssueUrl(2)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenNthCalledWith(3, `Passed the deadline on ${getIssueUrl(3)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenNthCalledWith(4, `Passed the deadline on ${getIssueUrl(4)} and no activity is detected, removing assignees.`); - expect(infoSpy).toHaveBeenCalledTimes(4); + 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([]); @@ -101,15 +99,21 @@ describe("User start/stop", () => { const context = createContext(4, 2); const infoSpy = jest.spyOn(context.logger, "info"); - createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(5)), daysPriorToNow(5)); + 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 ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenCalledTimes(1); + 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 }]); @@ -122,30 +126,53 @@ describe("User start/stop", () => { const context = createContext(4, 2); const infoSpy = jest.spyOn(context.logger, "info"); - createComment(3, 3, STRINGS.BOT, "Bot", botAssignmentComment(2, daysPriorToNow(2)), daysPriorToNow(2)); + const timestamp = daysPriorToNow(2) + 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 ${getIssueUrl(1)}`, { caller: STRINGS.LOGS_ANON_CALLER, issue: [1], metadata: [2] }); - expect(infoSpy).toHaveBeenNthCalledWith(2, `Nothing to do for ${getIssueHtmlUrl(2)}, still within due-time.`); - expect(infoSpy).toHaveBeenNthCalledWith(4, `Nothing to do for ${getIssueHtmlUrl(3)}, still within due-time.`); - expect(infoSpy).toHaveBeenNthCalledWith(6, `Nothing to do for ${getIssueHtmlUrl(4)}, still within due-time.`); - expect(infoSpy).toHaveBeenCalledTimes(7); + 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, `Nothing to do for ${getIssueHtmlUrl(1)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(4, `Nothing to do for ${getIssueHtmlUrl(2)}, still within due-time.`); + expect(infoSpy).toHaveBeenNthCalledWith(6, `Nothing to do for ${getIssueHtmlUrl(3)}, still within due-time.`); const updatedIssue = db.issue.findFirst({ where: { id: { equals: 4 } } }); expect(updatedIssue?.assignees).toEqual([{ login: STRINGS.USER, id: 2 }]); }); - it("Should handle collecting linked PR with # format", async () => { + it("Should handle collecting linked PRs", async () => { const context = createContext(1, 1); const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }); const result = await collectLinkedPullRequests(context, { issue_number: issue?.number as number, repo: issue?.repo as string, owner: issue?.owner.login as string }); - expect(result).toHaveLength(1); - expect(result[0].number).toEqual(1); - expect(result[0].body).toMatch(/Resolves #1/gi); + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + url: "https://github.com/ubiquity/test-repo/pull/1", + title: "test", + body: "test", + state: "OPEN", + number: 1, + author: { login: "ubiquity", id: 1 }, + }, + { + url: "https://github.com/ubiquity/test-repo/pull/1", + title: "test", + body: "test", + state: "CLOSED", + number: 2, + author: { login: "user2", id: 2 }, + } + ]); }); }); From 06912a2a85f6dfa8072ceeceaa1ab4ceec773ea5 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:41:15 +0100 Subject: [PATCH 28/39] chore: knip fix --- src/types/github-types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/types/github-types.ts b/src/types/github-types.ts index ae85ea3..c6d5995 100644 --- a/src/types/github-types.ts +++ b/src/types/github-types.ts @@ -1,7 +1,6 @@ import { RestEndpointMethodTypes } from "@octokit/rest"; -export type IssuesSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"]["items"][0]; export type ListForOrg = RestEndpointMethodTypes["repos"]["listForOrg"]["response"]; export type ListIssueForRepo = RestEndpointMethodTypes["issues"]["listForRepo"]["response"]["data"][0]; export type ListCommentsForIssue = RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"][0]; -export type GitHubListEvents = RestEndpointMethodTypes["issues"]["listEvents"]["response"]["data"][0]; +export type GitHubListEvents = RestEndpointMethodTypes["issues"]["listEvents"]["response"]["data"][0]; \ No newline at end of file From 2c542cb7977237a0198a5cfeb6e18e4d5e8fb18c Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 10:23:07 +0100 Subject: [PATCH 29/39] chore: graph config and comment --- graphql.config.yml | 6 ++++++ src/handlers/collect-linked-pulls.ts | 3 ++- src/helpers/task-deadline.ts | 1 - .../@octokit/{graphql-schema.js => graphql-schema.ts} | 0 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 graphql.config.yml rename tests/__mocks__/@octokit/{graphql-schema.js => graphql-schema.ts} (100%) 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/src/handlers/collect-linked-pulls.ts b/src/handlers/collect-linked-pulls.ts index 963f16e..6875c01 100644 --- a/src/handlers/collect-linked-pulls.ts +++ b/src/handlers/collect-linked-pulls.ts @@ -15,7 +15,8 @@ type IssueWithClosedByPRs = { }; } -const query = `query collectLinkedPullRequests($owner: String!, $repo: String!, $issue_number: Int!) { +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) { diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts index 09ab5e9..6727d87 100644 --- a/src/helpers/task-deadline.ts +++ b/src/helpers/task-deadline.ts @@ -21,7 +21,6 @@ export async function handleDeadline( metadata, assigneeIds, }); - // return false; } const deadline = DateTime.fromISO(metadata.taskDeadline); diff --git a/tests/__mocks__/@octokit/graphql-schema.js b/tests/__mocks__/@octokit/graphql-schema.ts similarity index 100% rename from tests/__mocks__/@octokit/graphql-schema.js rename to tests/__mocks__/@octokit/graphql-schema.ts From f62218239349a35aeadc0120e0ea836fbee3367f Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 10:37:22 +0100 Subject: [PATCH 30/39] chore: sync SupportedEvents with manifest --- src/types/plugin-inputs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/plugin-inputs.ts b/src/types/plugin-inputs.ts index 294f923..44ca63d 100644 --- a/src/types/plugin-inputs.ts +++ b/src/types/plugin-inputs.ts @@ -2,7 +2,7 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as Webhook import { StaticDecode, StringOptions, Type as T, TypeBoxError } from "@sinclair/typebox"; import ms from "ms"; -export type SupportedEvents = "issues.assigned"; +export type SupportedEvents = "pull_request_review_comment.created" | "issue_comment.created" | "push" export interface PluginInputs { stateId: string; From d908e069487ba04fdc1da1e6709cd572f6bf02ef Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 10:43:00 +0100 Subject: [PATCH 31/39] chore: remove unused --- src/helpers/update-tasks.ts | 239 ------------------------------------ 1 file changed, 239 deletions(-) delete mode 100644 src/helpers/update-tasks.ts diff --git a/src/helpers/update-tasks.ts b/src/helpers/update-tasks.ts deleted file mode 100644 index 88e04e1..0000000 --- a/src/helpers/update-tasks.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { DateTime } from "luxon"; -import { collectLinkedPullRequests } from "../handlers/collect-linked-pulls"; -import { Context } from "../types/context"; -import { getWatchedRepos } from "./get-watched-repos"; -import { parseIssueUrl } from "./github-url"; -import { GitHubListEvents, ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types"; - -export async function updateTasks(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) { - logger.info(`Updating reminders for ${repo.owner.login}/${repo.name}`); - await updateReminders(context, repo); - } - - return true; -} - -async function updateReminders(context: Context, repo: ListForOrg["data"][0]) { - const { - octokit - } = context; - const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { - owner: context.payload.repository.owner.login, - repo: repo.name, - per_page: 100, - state: "open", - }) as ListIssueForRepo[]; - - for (const issue of issues) { - if (issue.assignees?.length || issue.assignee) { - await updateReminderForIssue(context, repo, issue); - } - } -} - -async function updateReminderForIssue(context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo) { - const { - logger, - config, - 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 = sortAndReturn(botComments.filter((o) => assignmentRegex.test(o?.body || "")), "desc"); - 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.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 || ""); - - if (!taskDeadlineMatch || !taskAssigneesMatch) { - logger.error(`Missing metadata from ${issue.url}`); - return false; - } - - const metadata = { - taskDeadline: taskDeadlineMatch[1], - taskAssignees: taskAssigneesMatch[1].split(",").map((o) => o.trim()).map(Number), - }; - - if (!metadata.taskAssignees.length) { - logger.error(`No assignees found for ${issue.url}`); - return false; - } else if (metadata.taskAssignees.length && issue.assignees?.length && metadata.taskAssignees.some((a) => !issue.assignees?.map((o) => o.id).includes(a))) { - logger.error(`Assignees mismatch found for ${issue.url}`); - return false; - } - - if (!metadata.taskDeadline) { - logger.info(`No deadline found for ${issue.url}`); - return false; - } - const deadline = DateTime.fromFormat(metadata?.taskDeadline, "EEE, LLL d, h:mm a 'UTC'") - const now = DateTime.now(); - - if (!deadline.isValid && !lastCheck.isValid) { - logger.error(`Invalid date found on ${issue.url}`); - return false; - } - - const activity = (await getAssigneesActivityForIssue(context, issue)).filter((o) => 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 }); - } - - 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 (now: ${now.toLocaleString(DateTime.DATETIME_MED)}, reminder ${reminderWithThreshold.toLocaleString(DateTime.DATETIME_MED)}, deadline: ${deadlineWithThreshold.toLocaleString(DateTime.DATETIME_MED)})` - ); - } -} - -function sortAndReturn(array: ListCommentsForIssue[], direction: "asc" | "desc") { - return array.sort((a, b) => { - if (direction === "asc") { - return DateTime.fromISO(a.created_at).toMillis() - DateTime.fromISO(b.created_at).toMillis(); - } else { - return DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis(); - } - }); -} - -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.url} and no activity is detected, removing assignees.`); - await removeAllAssignees(context, issue) - } -} - -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 { - await remindAssignees(context, issue) - } -} - -/** - * Retrieves all the activity for users that are assigned to the issue. Also takes into account linked pull requests. - */ -async function getAssigneesActivityForIssue(context: Context, issue: ListIssueForRepo) { - 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); - } - const assignees = issue.assignees ? issue.assignees.map((assignee) => assignee.login) : issue.assignee ? [issue.assignee.login] : []; - - return issueEvents.reduce((acc, event) => { - if (event.actor && event.actor.login && event.actor.login) { - - if (assignees.includes(event.actor.login)) - acc.push(event); - } - return acc; - }, [] as GitHubListEvents[]) - .sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis()); -} - -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.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.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; -} From 0dcc361a181e06f3ae12a003948ddd59c3e31f7e Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 10:43:27 +0100 Subject: [PATCH 32/39] chore: if and throw for null types --- src/handlers/watch-user-activity.ts | 6 +++++- src/helpers/get-watched-repos.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/handlers/watch-user-activity.ts b/src/handlers/watch-user-activity.ts index 1c781e1..1caae0c 100644 --- a/src/handlers/watch-user-activity.ts +++ b/src/handlers/watch-user-activity.ts @@ -22,8 +22,12 @@ export async function watchUserActivity(context: Context) { 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: payload.repository.owner.login, + owner, repo: repo.name, per_page: 100, state: "open", diff --git a/src/helpers/get-watched-repos.ts b/src/helpers/get-watched-repos.ts index f0ade6d..a261367 100644 --- a/src/helpers/get-watched-repos.ts +++ b/src/helpers/get-watched-repos.ts @@ -8,7 +8,11 @@ export async function getWatchedRepos(context: Context) { }, } = 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) { From 7625c17c3e6ada9d9a78eb0daa294fd1162fa953 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 10:43:57 +0100 Subject: [PATCH 33/39] chore: refactor test event payload --- tests/main.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/main.test.ts b/tests/main.test.ts index 999b6bd..79b6160 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -203,15 +203,16 @@ function daysPriorToNow(days: number) { return new Date(Date.now() - ONE_DAY * days).toISOString(); } -function createContext(issueId: number, senderId: number): Context { +function createContext(issueId: number, senderId: number): Context<"issue_comment.created"> { return { payload: { - issue: db.issue.findFirst({ where: { id: { equals: issueId } } }) as unknown as Context["payload"]["issue"], - sender: db.users.findFirst({ where: { id: { equals: senderId } } }) as unknown as Context["payload"]["sender"], - repository: db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["repository"], - action: "assigned", + issue: db.issue.findFirst({ where: { id: { equals: issueId } } }) as unknown as Context<"issue_comment.created">["payload"]["issue"], + sender: db.users.findFirst({ where: { id: { equals: senderId } } }) as unknown as Context<"issue_comment.created">["payload"]["sender"], + repository: db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context<"issue_comment.created">["payload"]["repository"], + action: "created", installation: { id: 1 } as unknown as Context["payload"]["installation"], organization: { login: STRINGS.UBIQUITY } as unknown as Context["payload"]["organization"], + comment: db.issueComments.findFirst({ where: { issueId: { equals: issueId } } }) as unknown as Context<"issue_comment.created">["payload"]["comment"], }, logger: new Logs("debug"), config: { @@ -220,6 +221,6 @@ function createContext(issueId: number, senderId: number): Context { watch: { optOut: [STRINGS.PRIVATE_REPO_NAME] }, }, octokit: new octokit.Octokit(), - eventName: "issues.assigned", + eventName: "issue_comment.created", }; } From 53e68382247fee3c92b322b32e4be7f71ad6188a Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:03:34 +0100 Subject: [PATCH 34/39] chore: fix indentation --- tests/__mocks__/@octokit/graphql-schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/__mocks__/@octokit/graphql-schema.ts b/tests/__mocks__/@octokit/graphql-schema.ts index 170dcfa..23aa2ae 100644 --- a/tests/__mocks__/@octokit/graphql-schema.ts +++ b/tests/__mocks__/@octokit/graphql-schema.ts @@ -1,3 +1,3 @@ module.exports = { - validate: () => [], -} \ No newline at end of file + validate: () => [], +}; \ No newline at end of file From bc4039601d561b021610050a9d2f681b549af37e Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:05:18 +0100 Subject: [PATCH 35/39] chore: use filter() --- src/helpers/get-assignee-activity.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/helpers/get-assignee-activity.ts b/src/helpers/get-assignee-activity.ts index de4cd22..ae146b2 100644 --- a/src/helpers/get-assignee-activity.ts +++ b/src/helpers/get-assignee-activity.ts @@ -27,12 +27,6 @@ export async function getAssigneesActivityForIssue(context: Context, issue: List 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[]) + 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 From 81afb6a66aa204e668a27f51d4db26c2e280198e Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:06:12 +0100 Subject: [PATCH 36/39] chore: better fn names --- src/helpers/task-deadline.ts | 2 +- src/helpers/task-metadata.ts | 2 +- src/helpers/task-update.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/helpers/task-deadline.ts b/src/helpers/task-deadline.ts index 6727d87..737de4b 100644 --- a/src/helpers/task-deadline.ts +++ b/src/helpers/task-deadline.ts @@ -3,7 +3,7 @@ import { Context } from "../types/context"; import { ListIssueForRepo } from "../types/github-types"; import { getAssigneesActivityForIssue } from "./get-assignee-activity"; -export async function handleDeadline( +export async function getDeadlineWithThreshold( context: Context, metadata: { taskDeadline: string; diff --git a/src/helpers/task-metadata.ts b/src/helpers/task-metadata.ts index f3cb906..2e7a382 100644 --- a/src/helpers/task-metadata.ts +++ b/src/helpers/task-metadata.ts @@ -3,7 +3,7 @@ import { Context } from "../types/context"; import { ListCommentsForIssue, ListForOrg, ListIssueForRepo } from "../types/github-types"; import ms from "ms"; -export async function handleCommentsAndMetadata( +export async function getTaskMetadata( context: Context, repo: ListForOrg["data"][0], issue: ListIssueForRepo diff --git a/src/helpers/task-update.ts b/src/helpers/task-update.ts index 2ea0039..5816e81 100644 --- a/src/helpers/task-update.ts +++ b/src/helpers/task-update.ts @@ -2,21 +2,21 @@ 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"; +import { getDeadlineWithThreshold } from "./task-deadline"; +import { getTaskMetadata } 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); + const handledMetadata = await getTaskMetadata(context, repo, issue); if (handledMetadata) { metadata = handledMetadata.metadata; lastCheck = handledMetadata.lastCheck; - const handledDeadline = await handleDeadline(context, metadata, issue, lastCheck); + const handledDeadline = await getDeadlineWithThreshold(context, metadata, issue, lastCheck); if (handledDeadline) { deadlineWithThreshold = handledDeadline.deadlineWithThreshold; reminderWithThreshold = handledDeadline.reminderWithThreshold; From 4a776199838a589652e4aa793c480336d5ab413a Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:15:16 +0100 Subject: [PATCH 37/39] chore: update followup with comment metadata --- package.json | 4 ++-- src/helpers/remind-and-remove.ts | 9 ++++++++- src/helpers/structured-metadata.ts | 28 ++++++++++++++++++++++++++++ yarn.lock | 8 ++++---- 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/helpers/structured-metadata.ts diff --git a/package.json b/package.json index 5846288..297b164 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@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", @@ -89,4 +89,4 @@ ] }, "packageManager": "yarn@1.22.22" -} \ No newline at end of file +} diff --git a/src/helpers/remind-and-remove.ts b/src/helpers/remind-and-remove.ts index 7157204..095e584 100644 --- a/src/helpers/remind-and-remove.ts +++ b/src/helpers/remind-and-remove.ts @@ -1,6 +1,7 @@ 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; @@ -36,11 +37,17 @@ async function remindAssignees(context: Context, issue: ListIssueForRepo) { .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: `@${logins}, this task has been idle for a while. Please provide an update.`, + body: [logMessage.logMessage.raw, metadata].join("\n"), }); return true; } 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/yarn.lock b/yarn.lock index 4fd8c75..6005b1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1810,10 +1810,10 @@ dependencies: "@types/yargs-parser" "*" -"@ubiquity-dao/ubiquibot-logger@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@ubiquity-dao/ubiquibot-logger/-/ubiquibot-logger-1.3.0.tgz#b07364658be95b3be3876305c66b2adc906e9590" - integrity sha512-ifkd7fB2OMTSt3OL9L14bCIvCMXV+IHFdJYU5S8FUzE2U88b4xKxuEAYDFX+DX3wwDEswFAVUwx5aP3QcMIRWA== +"@ubiquity-dao/ubiquibot-logger@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@ubiquity-dao/ubiquibot-logger/-/ubiquibot-logger-1.3.1.tgz#c3f45d70014dcc2551442c28101046e1c8ea6886" + integrity sha512-kDLnVP87Y3yZV6NnqIEDAOz+92IW0nIcccML2lUn93uZ5ada78vfdTPtwPJo8tkXl1Z9qMKAqqHkwBMp1Ksnag== JSONStream@^1.3.5: version "1.3.5" From b704d0fbd4822c39908d464b0e9e83c8bebaa4bf Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:18:48 +0100 Subject: [PATCH 38/39] chore: track followup with metadata header --- src/helpers/task-metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/task-metadata.ts b/src/helpers/task-metadata.ts index 2e7a382..b5048eb 100644 --- a/src/helpers/task-metadata.ts +++ b/src/helpers/task-metadata.ts @@ -27,7 +27,7 @@ export async function getTaskMetadata( 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 botFollowup = /