Skip to content

Feat/refactor duplicate GitHub discord map access usage #33

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,258 changes: 677 additions & 581 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dependencies": {
"axios": "^1.7.2",
"commander": "^12.1.0",
"discord.js": "^14.19.3",
"discord.js": "^14.20.0",
"dotenv": "^16.4.5",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
Expand Down
23 changes: 5 additions & 18 deletions src/infrastructure/discord/authz.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
import githubDiscordMapJson from "../../../data/githubDiscordMap.json";
import { UserService } from "@src/items/services/UserService";

// New structure: map from GitHub username to object with discordId
const githubDiscordMap: {
[key: string]: {
githubUsername: string;
githubId: string;
discordId: string;
};
} = githubDiscordMapJson;

// TODO: this any should be the generalized discord.js interaction type so that all interactions can leverage this method
export const can = (interaction: any): boolean => {
export const can = async (interaction: any): Promise<boolean> => {
const userId = interaction.user?.id;
if (!userId) return false;

const discordIds = Object.values(githubDiscordMap).map(
(entry) => entry.discordId,
);
const isAuthorized = discordIds.includes(userId);

return isAuthorized;
const userResult = await UserService.findUserByDiscordID(userId);
return userResult.ok;
};
20 changes: 5 additions & 15 deletions src/infrastructure/discord/commands/myIssues.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { SlashCommandBuilder, CommandInteraction } from "discord.js";
import { GithubAPI } from "@infrastructure/github";
import logger from "@config/logger";
import githubDiscordMapJson from "../../../../data/githubDiscordMap.json";
import { UserService } from "@src/items/services/UserService";
import { can } from "../authz";
import { buildIssueButtonRow } from "../builders";
import { formatDiscordDate } from "../webhookMessages";

// Update type to reflect new structure
const githubDiscordMap: {
[githubUsername: string]: {
githubUsername: string;
githubId: string;
discordId: string;
};
} = githubDiscordMapJson;

export const data = new SlashCommandBuilder()
.setName("my-issues")
.setDescription("List all GitHub project issues assigned to you")
Expand All @@ -36,18 +27,17 @@ export async function execute(interaction: CommandInteraction) {

const discordUserId = interaction.user.id;

const githubUsername = Object.values(githubDiscordMap).find(
(entry) => entry.discordId === discordUserId,
)?.githubUsername;

if (!githubUsername) {
const userResult = await UserService.findUserByDiscordID(discordUserId);
if (userResult.err) {
await interaction.reply({
content: "❌ You don’t appear to be linked to a GitHub account.",
ephemeral: true,
});
return;
}

const githubUsername = userResult.val.githubUsername;

await interaction.deferReply({ ephemeral: true });

const githubItemsResult = await GithubAPI.fetchProjectItems();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { ItemService } from "@src/items/services";
import { UserSelectMenuInteraction } from "discord.js";
import githubDiscordMapJson from "../../../../data/githubDiscordMap.json";

// Updated structure of the map
const githubDiscordMap: {
[githubUsername: string]: {
githubUsername: string;
githubId: string;
discordId: string;
};
} = githubDiscordMapJson;
import { UserService } from "@src/items/services/UserService";

export async function assigneeSelectInteraction(
interaction: UserSelectMenuInteraction,
Expand All @@ -22,19 +13,18 @@ export async function assigneeSelectInteraction(
const githubIssueId = match[1];
const selectedUserId = interaction.values[0];

// Find the GitHub ID using the selected Discord ID
const githubId = Object.values(githubDiscordMap).find(
(entry) => entry.discordId === selectedUserId,
)?.githubId;

if (!githubId) {
const userResult = await UserService.findUserByDiscordID(selectedUserId);
if (userResult.err) {
await interaction.reply({
content: "❌ Unable to find linked GitHub account for selected user.",
content: "❌ You don’t appear to be linked to a GitHub account.",
ephemeral: true,
});
return;
}

// Find the GitHub ID using the selected Discord ID
const githubId = userResult.val.githubId;

const result = await ItemService.updateAssignee({
itemId: githubIssueId,
assigneeId: githubId,
Expand Down
18 changes: 5 additions & 13 deletions src/infrastructure/discord/tasks/urgentItemsDirectMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,7 @@ import { GithubAPI } from "@infrastructure/github";
import logger from "@src/config/logger";
import { filterOutStatus, filterForUrgentItems } from "@src/items";
import { formatDiscordDate } from "../webhookMessages";

type GithubDiscordMapping = {
[githubUsername: string]: {
githubUsername: string;
githubId: string;
discordId: string;
};
};
import githubDiscordMapJson from "../../../../data/githubDiscordMap.json";
const githubDiscordMap = githubDiscordMapJson as GithubDiscordMapping;
import { UserService } from "@src/items/services/UserService";

const urgencyEmojis = ["⏰", "🚨", "⚠️", "❗", "🧨", "💥"];

Expand Down Expand Up @@ -47,16 +38,17 @@ export const urgentItemsDirectMessage = async (client: any) => {
const githubUsername = githubUrl.split("/").pop();
if (!githubUsername) continue;

const mapping = githubDiscordMap[githubUsername];
if (!mapping || !mapping.discordId) {
const userResult =
await UserService.findUserByGithubUsername(githubUsername);
if (userResult.err || !userResult.val.discordId) {
logger.warn({
event: "dailyTasksReminder.missingMapping",
body: `No Discord ID found for GitHub user: ${githubUsername}`,
});
continue;
}

const discordId = mapping.discordId;
const discordId = userResult.val.discordId;
const list = groupedByDiscordId.get(discordId) || [];
list.push({
title: item.title,
Expand Down
35 changes: 13 additions & 22 deletions src/infrastructure/discord/webhookMessages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axios from "axios";
import { Err, Ok, Result } from "ts-results";
import dotenv from "dotenv";
import githubDiscordMap from "../../../data/githubDiscordMap.json";
import { UserService } from "@src/items/services/UserService";
import { Item } from "../../items";
import logger from "@config/logger";

Expand Down Expand Up @@ -57,15 +57,6 @@ export const sendDiscordItemMessage = async (
}
};

const githubUrlToDiscordId = (githubUrl: string) => {
return githubDiscordMap[
githubUrl.replace(
"https://github.com/",
"",
) as keyof typeof githubDiscordMap
].discordId;
};

export const formatDiscordDate = (date: Date) => {
return `<t:${Math.floor(date.getTime() / 1000)}:D>`;
};
Expand All @@ -78,18 +69,18 @@ const formatMessageSectionTitle = (title: string) => {
return `\n### ${title}: \n`;
};

const formatDiscordAssignees = (assignees: string[]) => {
// TODO: we should filter out the github url before getting to this stuff
return assignees
.map((assignee) => {
const discordId = githubUrlToDiscordId(assignee);
if (discordId) {
return `<@${discordId}>`;
} else {
return assignee;
}
})
.join(", ");
const formatDiscordAssignees = async (assignees: string[]) => {
const mentions = await Promise.all(
assignees.map(async (githubUrl) => {
const githubUsername = githubUrl.replace("https://github.com/", "");
const userResult =
await UserService.findUserByGithubUsername(githubUsername);

return userResult.ok ? `<@${userResult.val.discordId}>` : githubUsername;
}),
);

return mentions.join(", ");
};

const formatItem = (item: Item) => {
Expand Down
30 changes: 30 additions & 0 deletions src/items/services/UserService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import githubDiscordMap from "../../../data/githubDiscordMap.json";
import { Err, Ok, Result } from "ts-results";

export interface User {
githubUsername: string;
githubId: string;
discordId: string;
}

const users: User[] = Object.values(githubDiscordMap);

export class UserService {
static async findUserByDiscordID(
discordId: string,
): Promise<Result<User, Error>> {
const user = users.find((u) => u.discordId === discordId);
return user
? Ok(user)
: Err(new Error("Could not find user with that DiscordID"));
}

static async findUserByGithubUsername(
githubUsername: string,
): Promise<Result<User, Error>> {
const user = users.find((u) => u.githubUsername === githubUsername);
return user
? Ok(user)
: Err(new Error("Could not find user with that GitHub username"));
}
}
60 changes: 29 additions & 31 deletions test/infrastructure/discord/commands/myIssues.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { execute } from "@infrastructure/discord/commands/myIssues";
import { GithubAPI } from "@infrastructure/github";
import { can } from "@infrastructure/discord/authz";
import { buildIssueButtonRow } from "@infrastructure/discord/builders";
import { formatDiscordDate } from "@infrastructure/discord/webhookMessages";
import { UserService } from "@src/items/services/UserService";
import { execute } from "@infrastructure/discord/commands/myIssues";

// Mocks
jest.mock("@infrastructure/github", () => ({
GithubAPI: {
fetchProjectItems: jest.fn(),
Expand All @@ -22,12 +24,17 @@ jest.mock("@infrastructure/discord/webhookMessages", () => ({
formatDiscordDate: jest.fn((date) => `Formatted(${date})`),
}));

jest.mock("@src/items/services/UserService", () => ({
UserService: {
findUserByDiscordID: jest.fn(),
},
}));

describe("my-issues command", () => {
const mockReply = jest.fn();
const mockEditReply = jest.fn();
const mockDeferReply = jest.fn();
const mockFollowUp = jest.fn();
const mockLogger = { info: jest.fn(), error: jest.fn() };

const makeInteraction = (options = {}) => ({
user: { id: "user-123" },
Expand Down Expand Up @@ -67,16 +74,11 @@ describe("my-issues command", () => {

it("will show error if user is not linked to a GitHub account", async () => {
(can as jest.Mock).mockReturnValue(true);
const interaction = makeInteraction();
interaction.user.id = "not-in-map";
jest.spyOn(Object, "values").mockReturnValueOnce([
{
githubUsername: "test-user",
discordId: "someone-else",
githubId: "123",
},
]);
(UserService.findUserByDiscordID as jest.Mock).mockResolvedValue({
err: true,
});

const interaction = makeInteraction();
await execute(interaction as any);

expect(mockReply).toHaveBeenCalledWith({
Expand All @@ -87,11 +89,10 @@ describe("my-issues command", () => {

it("will show error if GitHub API fails", async () => {
(can as jest.Mock).mockReturnValue(true);
jest
.spyOn(Object, "values")
.mockReturnValue([
{ githubUsername: "test-user", discordId: "user-123" },
]);
(UserService.findUserByDiscordID as jest.Mock).mockResolvedValue({
ok: true,
val: { githubUsername: "test-user" },
});
(GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({
err: true,
val: { message: "Boom" },
Expand All @@ -108,11 +109,10 @@ describe("my-issues command", () => {

it("will show message if no assigned issues are found", async () => {
(can as jest.Mock).mockReturnValue(true);
jest
.spyOn(Object, "values")
.mockReturnValue([
{ githubUsername: "test-user", discordId: "user-123" },
]);
(UserService.findUserByDiscordID as jest.Mock).mockResolvedValue({
ok: true,
val: { githubUsername: "test-user" },
});
(GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({
err: false,
val: [],
Expand All @@ -129,11 +129,10 @@ describe("my-issues command", () => {

it("will show a specific issue by index", async () => {
(can as jest.Mock).mockReturnValue(true);
jest
.spyOn(Object, "values")
.mockReturnValue([
{ githubUsername: "test-user", discordId: "user-123" },
]);
(UserService.findUserByDiscordID as jest.Mock).mockResolvedValue({
ok: true,
val: { githubUsername: "test-user" },
});
(GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({
err: false,
val: [defaultItem({ assignedUsers: ["https://github.com/test-user"] })],
Expand All @@ -151,11 +150,10 @@ describe("my-issues command", () => {

it("will show index list when no index is provided", async () => {
(can as jest.Mock).mockReturnValue(true);
jest
.spyOn(Object, "values")
.mockReturnValue([
{ githubUsername: "test-user", discordId: "user-123" },
]);
(UserService.findUserByDiscordID as jest.Mock).mockResolvedValue({
ok: true,
val: { githubUsername: "test-user" },
});
(GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({
err: false,
val: [defaultItem({ assignedUsers: ["https://github.com/test-user"] })],
Expand Down
Loading