diff --git a/.env.example b/.env.example index 7fbd0a20..d22ec5ce 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ TWITTER_API_KEY= TWITTER_API_KEY_SECRET= TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= + +DEVPOOL_OWNER_NAME=ubiquity +DEVPOOL_REPO_NAME=devpool-directory +RFC=false \ No newline at end of file diff --git a/.github/workflows/jest-testing.yml b/.github/workflows/jest-testing.yml index 1a255742..53b0a5cd 100644 --- a/.github/workflows/jest-testing.yml +++ b/.github/workflows/jest-testing.yml @@ -14,6 +14,9 @@ env: TWITTER_API_KEY_SECRET: "" TWITTER_ACCESS_TOKEN: "" TWITTER_ACCESS_TOKEN_SECRET: "" + DEVPOOL_OWNER_NAME: "ubiquity" + DEVPOOL_REPO_NAME: "devpool-directory" + RFC: "false" jobs: testing: diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 682a4a11..47bc17de 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -46,6 +46,9 @@ jobs: TWITTER_API_KEY_SECRET: ${{ secrets.TWITTER_API_KEY_SECRET }} TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + DEVPOOL_OWNER_NAME: "ubiquity" + DEVPOOL_REPO_NAME: "devpool-directory" + RFC: "false" run: npx tsx index.ts - uses: actions/upload-artifact@v4 diff --git a/helpers/github.ts b/helpers/github.ts index 13b9ed2b..cd2d6a85 100644 --- a/helpers/github.ts +++ b/helpers/github.ts @@ -7,6 +7,10 @@ import { writeFile } from "fs/promises"; import twitter from "./twitter"; import { TwitterMap } from ".."; +export const DEVPOOL_OWNER_NAME = process.env.DEVPOOL_OWNER_NAME!; +export const DEVPOOL_REPO_NAME = process.env.DEVPOOL_REPO_NAME!; +export const IS_RFC = JSON.parse(process.env.RFC!); + export type GitHubIssue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; export type GitHubLabel = RestEndpointMethodTypes["issues"]["listLabelsOnIssue"]["response"]["data"][0]; @@ -23,8 +27,6 @@ export const projects = _projects as { category?: Record; }; -export const DEVPOOL_OWNER_NAME = "ubiquity"; -export const DEVPOOL_REPO_NAME = "devpool-directory"; export enum LABELS { PRICE = "Price", UNAVAILABLE = "Unavailable", @@ -129,12 +131,24 @@ export function getDevpoolIssueLabels(issue: GitHubIssue, projectUrl: string) { // get owner and repo name from issue's URL because the repo name could be updated const [ownerName, repoName] = getRepoCredentials(issue.html_url); + const pricing = getIssuePriceLabel(issue) + + let devpoolIssueLabels: string[]; + // default labels - const devpoolIssueLabels = [ - getIssuePriceLabel(issue), // price - `Partner: ${ownerName}/${repoName}`, // partner - `id: ${issue.node_id}`, // id - ]; + if (pricing != "Pricing: not set") { + devpoolIssueLabels = [ + pricing, + `Partner: ${ownerName}/${repoName}`, // partner + `id: ${issue.node_id}`, // id + ]; + } + else { + devpoolIssueLabels = [ + `Partner: ${ownerName}/${repoName}`, // partner + `id: ${issue.node_id}`, // id + ]; + } // if project is already assigned then add the "Unavailable" label if (issue.assignee?.login) devpoolIssueLabels.push(LABELS.UNAVAILABLE); @@ -380,8 +394,10 @@ export async function createDevPoolIssue(projectIssue: GitHubIssue, projectUrl: // if the project issue is assigned to someone, then skip it if (projectIssue.assignee) return; - // if issue doesn't have the "Price" label then skip it, no need to pollute repo with draft issues - if (!(projectIssue.labels as GitHubLabel[]).some((label) => label.name.includes(LABELS.PRICE))) return; + // check if the issue is the same type as it should be + const hasPriceLabel = (projectIssue.labels as GitHubLabel[]).some((label) => label.name.includes(LABELS.PRICE)); + const hasCorrectPriceLabel = (IS_RFC && !hasPriceLabel) || (!IS_RFC && hasPriceLabel) + if (!hasCorrectPriceLabel) return; // create a new issue try { @@ -399,15 +415,17 @@ export async function createDevPoolIssue(projectIssue: GitHubIssue, projectUrl: return; } - // post to social media - try { - const socialMediaText = getSocialMediaText(createdIssue.data); - const tweetId = await twitter.postTweet(socialMediaText); - - twitterMap[createdIssue.data.node_id] = tweetId?.id ?? ""; - await writeFile("./twitterMap.json", JSON.stringify(twitterMap)); - } catch (err) { - console.error("Failed to post tweet: ", err); + // post to social media (only if it's not an RFC) + if (!IS_RFC) { + try { + const socialMediaText = getSocialMediaText(createdIssue.data); + const tweetId = await twitter.postTweet(socialMediaText); + + twitterMap[createdIssue.data.node_id] = tweetId?.id ?? ""; + await writeFile("./twitterMap.json", JSON.stringify(twitterMap)); + } catch (err) { + console.error("Failed to post tweet: ", err); + } } } catch (err) { console.error("Failed to create new issue: ", err); @@ -426,7 +444,7 @@ export async function handleDevPoolIssue( const labelRemoved = getDevpoolIssueLabels(projectIssue, projectUrl).filter((label) => label != LABELS.UNAVAILABLE); const originals = devpoolIssue.labels.map((label) => (label as GitHubLabel).name); const hasChanges = !areEqual(originals, labelRemoved); - const hasNoPriceLabels = !(projectIssue.labels as GitHubLabel[]).some((label) => label.name.includes(LABELS.PRICE)); + const hasPriceLabel = (projectIssue.labels as GitHubLabel[]).some((label) => label.name.includes(LABELS.PRICE)); const metaChanges = { // the title of the issue has changed @@ -439,7 +457,7 @@ export async function handleDevPoolIssue( await applyMetaChanges(metaChanges, devpoolIssue, projectIssue, isFork, labelRemoved, originals); - const newState = await applyStateChanges(projectIssues, projectIssue, devpoolIssue, hasNoPriceLabels); + const newState = await applyStateChanges(projectIssues, projectIssue, devpoolIssue, hasPriceLabel); await applyUnavailableLabelToDevpoolIssue( projectIssue, @@ -481,7 +499,11 @@ async function applyMetaChanges( } } -async function applyStateChanges(projectIssues: GitHubIssue[], projectIssue: GitHubIssue, devpoolIssue: GitHubIssue, hasNoPriceLabels: boolean) { +async function applyStateChanges(projectIssues: GitHubIssue[], projectIssue: GitHubIssue, devpoolIssue: GitHubIssue, hasPriceLabel: boolean) { + const hasCorrectPriceLabel = (IS_RFC && !hasPriceLabel) || (!IS_RFC && hasPriceLabel) + + // console.log(hasCorrectPriceLabel) + const stateChanges: StateChanges = { // missing in the partners forceMissing_Close: { @@ -491,10 +513,16 @@ async function applyStateChanges(projectIssues: GitHubIssue[], projectIssue: Git }, // no price labels set and open in the devpool noPriceLabels_Close: { - cause: hasNoPriceLabels && devpoolIssue.state === "open", + cause: (!IS_RFC) && (!hasCorrectPriceLabel) && devpoolIssue.state === "open", effect: "closed", comment: "Closed (no price labels)", }, + // HAS price labels set and open in the RFC devpool + rfcPriceLabels_Close: { + cause: IS_RFC && (!hasCorrectPriceLabel) && devpoolIssue.state === "open", + effect: "closed", + comment: "Closed (has price labels)", + }, // it's closed, been merged and still open in the devpool issueComplete_Close: { cause: projectIssue.state === "closed" && devpoolIssue.state === "open" && !!projectIssue.pull_request?.merged_at, @@ -519,20 +547,20 @@ async function applyStateChanges(projectIssues: GitHubIssue[], projectIssue: Git effect: "closed", comment: "Closed (assigned-open)", }, - // it's open, merged, unassigned, has price labels and is closed in the devpool + // it's open, merged, unassigned, has CORRECT price labels and is closed in the devpool issueReopenedMerged_Open: { cause: projectIssue.state === "open" && devpoolIssue.state === "closed" && !!projectIssue.pull_request?.merged_at && - !hasNoPriceLabels && + hasCorrectPriceLabel && !projectIssue.assignee?.login, effect: "open", comment: "Reopened (merged)", }, - // it's open, unassigned, has price labels and is closed in the devpool + // it's open, unassigned, has CORRECT price labels and is closed in the devpool issueUnassigned_Open: { - cause: projectIssue.state === "open" && devpoolIssue.state === "closed" && !projectIssue.assignee?.login && !hasNoPriceLabels, + cause: projectIssue.state === "open" && devpoolIssue.state === "closed" && !projectIssue.assignee?.login && hasCorrectPriceLabel, effect: "open", comment: "Reopened (unassigned)", }, diff --git a/index.ts b/index.ts index 52f580b3..618325ab 100644 --- a/index.ts +++ b/index.ts @@ -2,6 +2,7 @@ import dotenv from "dotenv"; import { DEVPOOL_OWNER_NAME, DEVPOOL_REPO_NAME, + IS_RFC, getAllIssues, getIssueByLabel, getProjectUrls, @@ -35,7 +36,7 @@ async function main() { } // get devpool issues - const devpoolIssues: GitHubIssue[] = await getAllIssues(DEVPOOL_OWNER_NAME, DEVPOOL_REPO_NAME); + const devpoolIssues: GitHubIssue[] = (await getAllIssues(DEVPOOL_OWNER_NAME, DEVPOOL_REPO_NAME)) // aggregate projects.urls and opt settings const projectUrls = await getProjectUrls(); @@ -74,10 +75,12 @@ async function main() { } // Calculate total rewards from devpool issues - const { rewards, tasks } = await calculateStatistics(await getAllIssues(DEVPOOL_OWNER_NAME, DEVPOOL_REPO_NAME)); - const statistics: Statistics = { rewards, tasks }; + if (IS_RFC) { + const { rewards, tasks } = await calculateStatistics(await getAllIssues(DEVPOOL_OWNER_NAME, DEVPOOL_REPO_NAME)); + const statistics: Statistics = { rewards, tasks }; - await writeTotalRewardsToGithub(statistics); + await writeTotalRewardsToGithub(statistics); + } } void (async () => { diff --git a/mocks/issue-devpool-template-rfc.json b/mocks/issue-devpool-template-rfc.json new file mode 100644 index 00000000..1d7b71be --- /dev/null +++ b/mocks/issue-devpool-template-rfc.json @@ -0,0 +1,55 @@ +{ + "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": "", + "events_url": "", + "html_url": "https://github.com/ubiquity/devpool-rfc/issues/1", + "id": 1, + "labels_url": "", + "locked": false, + "milestone": null, + "node_id": "1", + "number": 1, + "repository_url": "https://github.com/ubiquity/devpool-rfc", + "state": "open", + "title": "issue", + "updated_at": "", + "url": "", + "user": null, + "owner": "ubiquity", + "repo": "devpool-rfc", + "labels": [ + { + "name": "Time: 1h" + }, + { + "name": "id: 2" + } + ], + "body": "https://github.com/ubiquity/test-repo/issues/1" +} diff --git a/mocks/issue-template-rfc.json b/mocks/issue-template-rfc.json new file mode 100644 index 00000000..9b141447 --- /dev/null +++ b/mocks/issue-template-rfc.json @@ -0,0 +1,52 @@ +{ + "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": "", + "events_url": "", + "html_url": "https://github.com/ubiquity/test-repo/issues/1", + "id": 2, + "labels_url": "", + "locked": false, + "milestone": null, + "node_id": "2", + "number": 2, + "repository_url": "https://github.com/ubiquity/test-repo", + "state": "open", + "title": "issue", + "updated_at": "", + "url": "", + "user": null, + "owner": "ubiquity", + "repo": "test-repo", + "labels": [ + { + "name": "Time: 1h" + } + ], + "body": "body" +} diff --git a/tests/rfc-issue-handler.test.ts b/tests/rfc-issue-handler.test.ts new file mode 100644 index 00000000..e24b24c3 --- /dev/null +++ b/tests/rfc-issue-handler.test.ts @@ -0,0 +1,1602 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +process.env.DEVPOOL_REPO_NAME = "devpool-rfc"; +process.env.RFC = "true"; + +import { setupServer } from "msw/node"; +import { GitHubIssue, calculateStatistics, checkIfForked, getProjectUrls, getRepoUrls, writeTotalRewardsToGithub } from "../helpers/github"; +import { db } from "../mocks/db"; +import { handlers } from "../mocks/handlers"; +import { drop } from "@mswjs/data"; +import issueDevpoolTemplate from "../mocks/issue-devpool-template-rfc.json"; +import issueTemplate from "../mocks/issue-template-rfc.json"; +import { handleDevPoolIssue, createDevPoolIssue } from "../helpers/github"; + +const DEVPOOL_OWNER_NAME = "ubiquity"; +const DEVPOOL_REPO_NAME = "devpool-rfc"; +const UBIQUITY_TEST_REPO = "https://github.com/ubiquity/test-repo"; + +const server = setupServer(...handlers); + +beforeAll(() => server.listen()); +afterEach(() => { + const openIssues = db.issue.findMany({ + where: { + state: { + equals: "open", + }, + }, + }); + + openIssues.forEach((issue) => { + const unavailableLabel = issue.labels.find((label: string | unknown) => { + if (label && typeof label === "object" && "name" in label) { + return label.name === "Unavailable"; + } else if (typeof label === "string") { + return label.includes("Unavailable"); + } else { + return false; + } + }); + expect(unavailableLabel).toBeUndefined(); + }); + + server.resetHandlers(); + drop(db); +}); +afterAll(() => server.close()); + +function createIssues(devpoolIssue: GitHubIssue, projectIssue: GitHubIssue) { + db.issue.create(devpoolIssue); + db.issue.create(projectIssue); + + return db.issue.findFirst({ + where: { + id: { + equals: devpoolIssue.id, + }, + }, + }) as GitHubIssue; +} + +describe("handleDevPoolIssue", () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(); + + beforeEach(() => { + logSpy.mockClear(); + }); + + describe("Devpool Directory", () => { + beforeEach(() => { + db.repo.create({ + id: 1, + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/${DEVPOOL_REPO_NAME}`, + name: DEVPOOL_REPO_NAME, + owner: DEVPOOL_OWNER_NAME, + }); + db.repo.create({ + id: 2, + owner: DEVPOOL_OWNER_NAME, + name: "test-repo", + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/test-repo`, + }); + }); + test("updates issue title in devpool when project issue title changes", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + title: "Original Title", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + title: "Updated Title", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(logSpy).toHaveBeenCalledWith(`Updated metadata: ${updatedIssue.html_url} (${partnerIssue.html_url})`); + }); + + test("updates issue labels in devpool when project issue labels change", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + labels: issueTemplate.labels?.concat({ name: "enhancement" }), + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.labels).toEqual(expect.arrayContaining([{ name: "enhancement" }])); + + expect(logSpy).toHaveBeenCalledWith(`Updated metadata: ${updatedIssue.html_url} (${partnerIssue.html_url})`); + }); + + test("does not update issue when no metadata changes are detected", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [ { name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test("keeps devpool issue state unchanged when project issue state matches devpool issue state", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [ { name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "open", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "open", + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + test("keeps devpool issue state unchanged when project issue state is closed and devpool issue state is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "closed", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "closed", + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + test("keeps devpool issue state unchanged when project issue state is closed, assigned and devpool issue state is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "closed", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "closed", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + test("keeps devpool issue state unchanged when project issue state is closed, merged, unassigned and devpool issue state is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "closed", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "closed", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + test("keeps devpool state unchanged when project issue state is open, assigned, merged and devpool issue state is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "closed", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "open", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated")); + }); + + test("keeps devpool state unchanged when project issue state is open, unassigned and devpool issue state is open", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "open", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "open", + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + test("keeps devpool state unchanged when project issue state is open, unassigned, merged and devpool issue state is open", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + labels: [{ name: "Partner: ubiquity/test-repo" }, { name: "id: 2" }, { name: "Time: 1h" }], + state: "open", + } as GitHubIssue; + const partnerIssue = { + ...issueTemplate, + state: "open", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Updated state")); + }); + + // cause: !projectIssues.some((projectIssue) => projectIssue.node_id == getIssueLabelValue(devpoolIssue, "id:")) + // comment: "Closed (missing in partners):" + test("closes devpool issue when project issue is missing", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + node_id: "1234", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (missing in partners))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: (!hasCorrectPriceLabel) && devpoolIssue.state == "open" + // comment: "Closed (has price labels):" + test("closes devpool issue when project issue has price labels", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + labels: [{ name: "Price: 200 USD" }], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (has price labels))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "closed" && devpoolIssue.state == "open" && !!projectIssue.pull_request?.merged_at, + // comment: "Closed (merged):" + test("closes devpool issue when project issue is merged", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + state: "closed", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (merged))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "closed" && devpoolIssue.state == "open" + // comment: "Closed (not merged):" + test("closes devpool issue when project issue is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "open", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + state: "closed", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (not merged))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "closed" && devpoolIssue.state == "open" && !!projectIssue.assignee?.login, + // comment: "Closed (assigned-closed):", + test("closes devpool issue when project issue is closed and assigned", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + state: "closed", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (assigned-closed))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "open" && devpoolIssue.state == "open" && !!projectIssue.assignee?.login, + // comment: "Closed (assigned-open):" + test("closes devpool issue when project issue is open and assigned", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (assigned-open))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "open" && devpoolIssue.state == "closed" && !projectIssue.assignee?.login && !hasNoPriceLabels + // comment: "Reopened (unassigned):", + test("reopens devpool issue when project issue is reopened", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + state: "open", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("open"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Reopened (unassigned))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + // cause: projectIssue.state == "open" && devpoolIssue.state == "closed" && !!projectIssue.pull_request?.merged_at && !hasNoPriceLabels, + // comment: "Reopened (merged):", + test("reopens devpool issue when project issue is unassigned, reopened and merged", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + state: "open", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, UBIQUITY_TEST_REPO, issueInDb, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Reopened (merged))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("adds Unavailable label to devpool issue when project issue is assigned, open and devpool is closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + } as GitHubIssue; + + const projectIssue = { + ...issueTemplate, + state: "open", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(updatedIssue.state).toEqual("closed"); + + expect(updatedIssue.labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + }); + + test("removes Unavailable label from devpool issue when project issue is assigned and closed", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + labels: issueDevpoolTemplate.labels.concat({ name: "Unavailable" }), + } as GitHubIssue; + + const projectIssue = { + ...issueTemplate, + state: "closed", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(updatedIssue.state).toEqual("closed"); + + expect(updatedIssue.labels).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + }); + + test("removes Unavailable label from devpool issue when project issue is unassigned, closed and merged", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + labels: issueDevpoolTemplate.labels.concat({ name: "Unavailable" }), + pull_request: { + diff_url: "...", + html_url: "...", + patch_url: "...", + url: "...", + merged_at: new Date().toISOString(), + }, + } as GitHubIssue; + + const projectIssue = { + ...issueTemplate, + state: "closed", + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(updatedIssue.state).toEqual("closed"); + + expect(updatedIssue.labels).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + }); + + test("removes Unavailable label from devpool issue when project issue is unassigned and reopened", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + labels: issueDevpoolTemplate.labels.concat({ name: "Unavailable" }), + } as GitHubIssue; + + const projectIssue = { + ...issueTemplate, + state: "open", + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(updatedIssue.state).toEqual("open"); + + expect(updatedIssue.labels).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + }); + + test("removes Unavailable label from devpool issue when project issue is unassigned, merged and reopened", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + labels: issueDevpoolTemplate.labels.concat({ name: "Unavailable" }), + } as GitHubIssue; + + const projectIssue = { + ...issueTemplate, + state: "open", + pull_request: { + diff_url: "...", + html_url: "...", + patch_url: "...", + url: "...", + merged_at: new Date().toISOString(), + }, + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(updatedIssue.state).toEqual("open"); + + expect(updatedIssue.labels).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + }); + + test("does not add the Unavailable to an open devpool issue, _ever_", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "open", + id: 1, + number: 1, + node_id: "1", + repository_url: `https://github.com/ubiquity/${DEVPOOL_REPO_NAME}`, + html_url: `https://github.com/ubiquity/${DEVPOOL_REPO_NAME}/issues/1`, + } as GitHubIssue; + + // project issue is open and unassigned + let projectIssue = { + ...issueTemplate, + state: "open", + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await validateOpen(projectIssue, devpoolIssue); + + // project issue is open, unassigned and merged + projectIssue = { + ...issueTemplate, + state: "open", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + await validateOpen(projectIssue, devpoolIssue); + }); + + test("does not remove the Unavailable label from an assigned devpool issue that's open, _ever_", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + state: "closed", + id: 1, + number: 1, + node_id: "1", + repository_url: `https://github.com/ubiquity/${DEVPOOL_REPO_NAME}`, + html_url: `https://github.com/ubiquity/${DEVPOOL_REPO_NAME}/issues/1`, + } as GitHubIssue; + + // project issue is open, assigned and unmerged + let projectIssue = { + ...issueTemplate, + state: "open", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + createIssues(devpoolIssue, projectIssue); + + await validateClosed(projectIssue, devpoolIssue); + + // project issue is open, assigned and merged + projectIssue = { + ...issueTemplate, + state: "open", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + await validateClosed(projectIssue, devpoolIssue); + }); + + test("checkIfForkedRepo", async () => { + expect(await checkIfForked("test-repo")).toBe(true); + }); + + test("getRepoUrls", async () => { + let orgOrRepo = "test/org/bad-repo/still/bad"; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + await getRepoUrls(orgOrRepo); + + expect(warnSpy).toHaveBeenCalledWith(`Neither org or nor repo GitHub provided: test/org/bad-repo/still/bad.`); + + orgOrRepo = "/test"; + + await getRepoUrls(orgOrRepo); + + expect(warnSpy).toHaveBeenCalledWith(`Invalid org or repo provided: `, orgOrRepo); + + (orgOrRepo as any) = undefined; + + await getRepoUrls(orgOrRepo); + + expect(warnSpy).toHaveBeenCalledWith(`No org or repo provided: `, orgOrRepo); + + warnSpy.mockClear(); + + jest.resetModules(); + + (orgOrRepo as any) = "."; + + await getRepoUrls(orgOrRepo); + + const localErr = `Getting ${orgOrRepo} org repositories failed: HttpError: Bad credentials`; + const githubErr = `Getting ${orgOrRepo} org repositories failed: HttpError: Not Found`; + + if (warnSpy.mock.calls.length > 0) { + const errThrown: string = warnSpy.mock.calls.flatMap((call) => call).includes(localErr) ? localErr : githubErr; + if (errThrown.includes("Bad credentials")) { + expect(errThrown).toEqual(localErr); + } else { + expect(errThrown).toEqual(githubErr); + } + } + + (orgOrRepo as any) = "-/test"; + + await getRepoUrls(orgOrRepo); + expect(warnSpy).toHaveBeenCalledWith(`Getting repo ${orgOrRepo} failed: HttpError`); + }); + }); + + function getDB() { + return db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + } + + async function validateClosed(projectIssue: GitHubIssue, devpoolIssue: GitHubIssue) { + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = getDB(); + if (updatedIssue === null) { + throw new Error("Updated issue is null"); + } + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue.state).toEqual("closed"); + expect(updatedIssue.labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + } + + async function validateOpen(projectIssue: GitHubIssue, devpoolIssue: GitHubIssue) { + await handleDevPoolIssue([projectIssue], projectIssue, UBIQUITY_TEST_REPO, devpoolIssue, false); + + const updatedIssue = getDB(); + if (updatedIssue === null) { + throw new Error("Updated issue is null"); + } + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue.state).toEqual("open"); + expect(updatedIssue.labels).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Unavailable", + }), + ]) + ); + } + + const HTML_URL = `https://github.com/not-ubiquity/${DEVPOOL_REPO_NAME}/issues/1`; + const REPO_URL = `https://github.com/not-ubiquity/${DEVPOOL_REPO_NAME}`; + const PROJECT_URL = "https://github.com/ubiquity/test-repo"; + const BODY = "https://www.github.com/ubiquity/test-repo/issues/1"; + /** + * ======================== + * DEVPOOL FORKED REPO + * ======================== + */ + + describe("Forked Devpool", () => { + jest.mock("../helpers/github", () => ({ + ...jest.requireActual("../helpers/github"), + DEVPOOL_OWNER_NAME: "not-ubiquity", + })); + + beforeEach(() => { + db.repo.create({ + id: 1, + owner: "not-ubiquity", + name: DEVPOOL_REPO_NAME, + html_url: REPO_URL, + }); + db.repo.create({ + id: 2, + owner: DEVPOOL_OWNER_NAME, + name: "test-repo", + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/test-repo`, + }); + db.repo.create({ + id: 3, + owner: DEVPOOL_OWNER_NAME, + name: DEVPOOL_REPO_NAME, + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/${DEVPOOL_REPO_NAME}`, + }); + }); + + afterAll(() => { + jest.unmock("../helpers/github"); + }); + + test("updates issue title in devpool when project issue title changes in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + title: "Original Title", + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + title: "Updated Title", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.title).toEqual("Updated Title"); + + expect(logSpy).toHaveBeenCalledWith(`Updated metadata: ${updatedIssue.html_url} (${partnerIssue.html_url})`); + }); + + test("updates issue labels in devpool when project issue labels change in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + labels: issueTemplate.labels?.concat({ name: "enhancement" }), + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + + expect(logSpy).toHaveBeenCalledWith(`Updated metadata: ${updatedIssue.html_url} (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue is missing in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + node_id: "1234", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (missing in partners))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue has no price labels in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + labels: [{ name: "Price: 200 USD" }], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (has price labels))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue is merged in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + state: "closed", + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (merged))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue is closed in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + state: "closed", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (not merged))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue is closed and assigned in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + state: "closed", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (assigned-closed))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("closes devpool issue when project issue is open and assigned in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Closed (assigned-open))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("reopens devpool issue when project issue is reopened and unassigned in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + id: 1, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + state: "closed", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + state: "open", + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("open"); + + expect(logSpy).toHaveBeenCalledWith(`Updated state: (Reopened (unassigned))\n${updatedIssue.html_url} - (${partnerIssue.html_url})`); + }); + + test("should not reopen devpool issue when project issue is reopened, assigned and merged in forked repo", async () => { + const devpoolIssue = { + ...issueDevpoolTemplate, + html_url: HTML_URL, + repository_url: REPO_URL, + body: BODY, + state: "closed", + } as GitHubIssue; + + const partnerIssue = { + ...issueTemplate, + id: 2, + state: "open", + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + pull_request: { + merged_at: new Date().toISOString(), + diff_url: "https//github.com/ubiquity/test-repo/pull/1.diff", + html_url: "https//github.com/ubiquity/test-repo/pull/1", + patch_url: "https//github.com/ubiquity/test-repo/pull/1.patch", + url: "https//github.com/ubiquity/test-repo/pull/1", + }, + } as GitHubIssue; + + const issueInDb = createIssues(devpoolIssue, partnerIssue); + + await handleDevPoolIssue([partnerIssue], partnerIssue, PROJECT_URL, issueInDb, true); + + const updatedIssue = db.issue.findFirst({ + where: { + id: { + equals: 1, + }, + }, + }) as GitHubIssue; + + expect(updatedIssue).not.toBeNull(); + expect(updatedIssue?.state).toEqual("closed"); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining(`Updated state`)); + }); + }); +}); + +describe("createDevPoolIssue", () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(); + const twitterMap: { [key: string]: string } = { + "ubiquity/test-repo": DEVPOOL_OWNER_NAME, + }; + + beforeEach(() => { + logSpy.mockClear(); + }); + + describe("Devpool Directory", () => { + beforeEach(() => { + db.repo.create({ + id: 1, + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/${DEVPOOL_REPO_NAME}`, + name: DEVPOOL_REPO_NAME, + owner: DEVPOOL_OWNER_NAME, + }); + db.repo.create({ + id: 2, + owner: DEVPOOL_OWNER_NAME, + name: "test-repo", + html_url: `https://github.com/${DEVPOOL_OWNER_NAME}/test-repo`, + }); + }); + + afterEach(() => { + drop(db); + }); + + test("only creates a new devpool issue if price labels are NOT set, it's unassigned, opened and not already a devpool issue", async () => { + const partnerIssue = { + ...issueTemplate, + assignee: null, + } as GitHubIssue; + + logSpy.mockClear(); + await createDevPoolIssue(partnerIssue, partnerIssue.html_url, UBIQUITY_TEST_REPO, twitterMap); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created")); + }); + + test("does not create a new devpool issue if price labels are set", async () => { + const partnerIssue = { + ...issueTemplate, + labels: [], + } as GitHubIssue; + + await createDevPoolIssue(partnerIssue, partnerIssue.html_url, UBIQUITY_TEST_REPO, twitterMap); + + const devpoolIssue = db.issue.findFirst({ + where: { + title: { + equals: partnerIssue.title, + }, + }, + }) as GitHubIssue; + + expect(devpoolIssue).toBeNull(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + test("does not create a new devpool issue if it's already a devpool issue", async () => { + const partnerIssue = { + ...issueTemplate, + } as GitHubIssue; + + db.issue.create({ + ...issueDevpoolTemplate, + id: partnerIssue.id, + }); + logSpy.mockClear(); + + await createDevPoolIssue(partnerIssue, partnerIssue.html_url, UBIQUITY_TEST_REPO, twitterMap); + + const devpoolIssue = db.issue.findFirst({ + where: { + id: { + equals: partnerIssue.id, + }, + }, + }) as GitHubIssue; + + expect(devpoolIssue).not.toBeNull(); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Created")); + }); + + test("does not create a new devpool issue if it's assigned", async () => { + const partnerIssue = { + ...issueTemplate, + assignee: { + login: "hunter", + } as GitHubIssue["assignee"], + } as GitHubIssue; + + db.issue.create({ + ...issueDevpoolTemplate, + title: partnerIssue.title, + body: partnerIssue.html_url, + repository_url: UBIQUITY_TEST_REPO, + }); + + await createDevPoolIssue(partnerIssue, partnerIssue.html_url, UBIQUITY_TEST_REPO, twitterMap); + + const devpoolIssue = db.issue.findFirst({ + where: { + title: { + equals: partnerIssue.title, + }, + }, + }) as GitHubIssue; + + expect(devpoolIssue).not.toBeNull(); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Created")); + }); + + test("does not create a new devpool issue if it's closed", async () => { + const partnerIssue = { + ...issueTemplate, + state: "closed", + } as GitHubIssue; + + await createDevPoolIssue(partnerIssue, partnerIssue.html_url, UBIQUITY_TEST_REPO, twitterMap); + + const devpoolIssue = db.issue.findFirst({ + where: { + title: { + equals: partnerIssue.title, + }, + }, + }) as GitHubIssue; + + expect(devpoolIssue).toBeNull(); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Created")); + }); + }); +}); + +describe("getProjectUrls", () => { + const responseMap = { + ubiquity: { + "series-a": "https://github.com/ubiquity/series-a", + hackbar: "https://github.com/ubiquity/hackbar", + "devpool-directory": "https://github.com/ubiquity/devpool-directory", + "card-issuance": "https://github.com/ubiquity/card-issuance", + research: "https://github.com/ubiquity/research", + recruiting: "https://github.com/ubiquity/recruiting", + "business-development": "https://github.com/ubiquity/business-development", + ubiquibot: "https://github.com/ubiquity/ubiquibot", + ubiquibar: "https://github.com/ubiquity/ubiquibar", + "ubiquibot-telegram": "https://github.com/ubiquity/ubiquibot-telegram", + // ^ out + + "work.fi": "https://github.com/ubiquity/work.fi", + "pay.fi": "https://github.com/ubiquity/pay.fi", + "gas-faucet": "https://github.com/ubiquity/gas-faucet", + "keygen.fi": "https://github.com/ubiquity/keygen.fi", + "onboarding.fi": "https://github.com/ubiquity/onboarding.fi", + }, + ubiquibot: { + configuration: "https://github.com/ubiquibot/configuration", + production: "https://github.com/ubiquibot/production", + sandbox: "https://github.com/ubiquibot/sandbox", + "e2e-tests": "https://github.com/ubiquibot/e2e-tests", + staging: "https://github.com/ubiquibot/staging", + // ^ out + + "test-repo": "https://github.com/ubiquibot/test-repo", + }, + "pavlovcik/uad.ubq.fi": { + "uad.ubq.fi": "https://github.com/pavlovcik/uad.ubq.fi", + // ^ in + }, + "private-test-org": { + "secret-repo": "https://github.com/private-test-org/secret-repo", + "secret-repo-2": "https://github.com/private-test-org/secret-repo-2", + // ^ out + + "public-repo": "https://github.com/private-test-org/public-repo", + // ^ in + }, + "test-org": { + "secret-repo": "https://github.com/test-org/secret-repo", + "secret-repo-2": "https://github.com/test-org/secret-repo-2", + // ^ out + }, + }; + + beforeAll(() => { + jest.spyOn(console, "log").mockImplementation(); + }); + + const opt = { + in: ["ubiquity", "ubiquibot", "private-test-org", "private-test-org/public-repo", "pavlovcik/uad.ubq.fi"], + out: [ + "private-test-org", + "ubiquity/series-a", + "ubiquity/hackbar", + "ubiquity/devpool-directory", + "ubiquity/card-issuance", + "ubiquity/research", + "ubiquity/recruiting", + "ubiquity/business-development", + "ubiquity/ubiquibar", + "ubiquity/ubiquibot", + "ubiquity/ubiquibot-telegram", + "ubiquity/test-repo", + "ubiquibot/configuration", + "ubiquibot/production", + "ubiquibot/sandbox", + "ubiquibot/e2e-tests", + "ubiquibot/staging", + ], + }; + + beforeEach(() => { + drop(db); + + let index = 1; + Object.entries(responseMap).forEach(([owner, repos]) => { + for (const [name, url] of Object.entries(repos)) { + db.repo.create({ + id: index++, + owner: owner.split("/")[0], + name, + html_url: url, + }); + } + }); + }); + + test("returns projects not included in Opt.out", async () => { + const urls = Array.from(await getProjectUrls(opt)); + + opt.out.forEach((url) => { + expect(urls).not.toContain("https://github.com/" + url); + }); + + expect(urls).not.toEqual("https://github.com/" + opt.out); + + expect(urls).toEqual( + expect.arrayContaining([ + "https://github.com/ubiquity/work.fi", + "https://github.com/ubiquity/pay.fi", + "https://github.com/ubiquity/gas-faucet", + "https://github.com/ubiquity/keygen.fi", + "https://github.com/ubiquity/onboarding.fi", + "https://github.com/pavlovcik/uad.ubq.fi", + "https://github.com/private-test-org/public-repo", + ]) + ); + }); +});