diff --git a/README.md b/README.md index f59122a..30e5448 100644 --- a/README.md +++ b/README.md @@ -144,3 +144,39 @@ Project Items Query } } ``` + +Project Item Fields Query + +``` +{ + organization(login: "CarletonComputerScienceSociety") { + projectV2(number: 18) { + fields(first: 100) { + nodes { + __typename + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + } + } + } +} +``` + +Members with Role Query + +``` +query { + organization(login: "CarletonComputerScienceSociety") { + membersWithRole(first: 100) { + nodes { + login + id + } + } + } +} +``` diff --git a/data/githubDiscordMap.json b/data/githubDiscordMap.json index 11f0334..4ab0fc0 100644 --- a/data/githubDiscordMap.json +++ b/data/githubDiscordMap.json @@ -1,16 +1,77 @@ { - "AJaccP": "693093284998021141", - "eros-mcguire": "981895462992891935", - "exkellybur": "398923451311587338", - "JohnLu2004": "358054341179080704", - "kimiaKR": "706977821502865578", - "LandonJMM": "657607140835328059", - "MathyouMB": "147881865548791808", - "MrRibcage": "142782738615762944", - "rebeccakempe12": "730876980861599744", - "richard-dh-kim": "241421629543022592", - "rj-sci": "327557300497809422", - "ryangchung": "365948481946517504", - "VictorLi5611": "247472197902270465", - "VMordvinova": "759902786107473932" + "AJaccP": { + "githubUsername": "AJaccP", + "githubId": "U_kgDOBrIU2w", + "discordId": "693093284998021141" + }, + "eros-mcguire": { + "githubUsername": "eros-mcguire", + "githubId": "tbd", + "discordId": "981895462992891935" + }, + "exkellybur": { + "githubUsername": "exkellybur", + "githubId": "U_kgDOBx7TuA", + "discordId": "398923451311587338" + }, + "JohnLu2004": { + "githubUsername": "JohnLu2004", + "githubId": "MDQ6VXNlcjg3NjczMDY4", + "discordId": "358054341179080704" + }, + "kimiaKR": { + "githubUsername": "kimiaKR", + "githubId": "U_kgDOC0aBdw", + "discordId": "706977821502865578" + }, + "LandonJMM": { + "githubUsername": "LandonJMM", + "githubId": "U_kgDOBs5OWw", + "discordId": "657607140835328059" + }, + "MathyouMB": { + "githubUsername": "MathyouMB", + "githubId": "MDQ6VXNlcjQzMjIzNjgy", + "discordId": "147881865548791808" + }, + "MrRibcage": { + "githubUsername": "MrRibcage", + "githubId": "MDQ6VXNlcjQzNjU2MTM3", + "discordId": "142782738615762944" + }, + "Nguyen-HanhNong": { + "githubUsername": "Nguyen-HanhNong", + "githubId": "MDQ6VXNlcjgxOTc3MzUw", + "discordId": "929100662082531398" + }, + "rebeccakempe12": { + "githubUsername": "rebeccakempe12", + "githubId": "MDQ6VXNlcjc3MzY4MTky", + "discordId": "730876980861599744" + }, + "richard-dh-kim": { + "githubUsername": "richard-dh-kim", + "githubId": "MDQ6VXNlcjU4OTU5NjQ5", + "discordId": "241421629543022592" + }, + "rj-sci": { + "githubUsername": "rj-sci", + "githubId": "tbd", + "discordId": "327557300497809422" + }, + "ryangchung": { + "githubUsername": "ryangchung", + "githubId": "MDQ6VXNlcjg3MDI3OTgx", + "discordId": "365948481946517504" + }, + "VictorLi5611": { + "githubUsername": "VictorLi5611", + "githubId": "MDQ6VXNlcjczMzA1Mjg3", + "discordId": "247472197902270465" + }, + "VMordvinova": { + "githubUsername": "VMordvinova", + "githubId": "U_kgDOCFXXvw", + "discordId": "759902786107473932" + } } diff --git a/package-lock.json b/package-lock.json index a590ca9..8374605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "axios": "^1.7.2", "commander": "^12.1.0", + "discord.js": "^14.19.3", "dotenv": "^16.4.5", "node-cron": "^3.0.3", "node-fetch": "^3.3.2", @@ -647,6 +648,125 @@ "kuler": "^2.0.0" } }, + "node_modules/@discordjs/builders": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.2.tgz", + "integrity": "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A==", + "dependencies": { + "@discordjs/formatters": "^0.6.1", + "@discordjs/util": "^1.1.1", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.1", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", + "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "dependencies": { + "discord-api-types": "^0.38.1" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.5.0.tgz", + "integrity": "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ==", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.2.tgz", + "integrity": "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w==", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.0", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1038,6 +1158,36 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1174,7 +1324,6 @@ "version": "20.14.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1196,6 +1345,14 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -1211,6 +1368,15 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", + "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/acorn": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", @@ -1909,6 +2075,37 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/discord-api-types": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.4.tgz", + "integrity": "sha512-EgxEQ4vrJUjXaTjif4ItOGoD6TH87nfESJ6XBSqoVgqkZrcmdLPjkciCzuIMdHxLjY2al3BcIcElqnpOoaqxHg==" + }, + "node_modules/discord.js": { + "version": "14.19.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.19.3.tgz", + "integrity": "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA==", + "dependencies": { + "@discordjs/builders": "^1.11.2", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.1", + "@discordjs/rest": "^2.5.0", + "@discordjs/util": "^1.1.1", + "@discordjs/ws": "^1.2.2", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.1", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -2037,6 +2234,11 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3189,12 +3391,22 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -3220,6 +3432,11 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-bytes.js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==" + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4138,6 +4355,11 @@ } } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -4252,11 +4474,18 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/undici": { + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/update-browserslist-db": { "version": "1.0.16", @@ -4433,6 +4662,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 37d7de6..d764a5d 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,16 @@ "task": "npx ts-node -r tsconfig-paths/register src/tasks.ts", "dev": "nodemon src/jobs/dailyTaskReminder.ts", "check": "prettier --check '**/*.{css,scss,html,js,md,ts,tsx}'", - "format": "prettier --write '**/*.{css,scss,html,js,md,ts,tsx}'" + "format": "prettier --write '**/*.{css,scss,html,js,md,ts,tsx}'", + "discord:commands:deploy": "npx ts-node -r tsconfig-paths/register src/infrastructure/discord/deployCommands.ts", + "discord:bot:start": "npx ts-node -r tsconfig-paths/register src/infrastructure/discord/bot.ts" }, "author": "", "license": "ISC", "dependencies": { "axios": "^1.7.2", "commander": "^12.1.0", + "discord.js": "^14.19.3", "dotenv": "^16.4.5", "node-cron": "^3.0.3", "node-fetch": "^3.3.2", diff --git a/src/config/logger.ts b/src/config/logger.ts index 3348136..a8a807c 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,6 +1,5 @@ import winston from "winston"; import { sendDiscordAlertMessage } from "@infrastructure/discord"; -import { send } from "process"; const base = winston.createLogger({ level: "info", diff --git a/src/infrastructure/discord/authz.ts b/src/infrastructure/discord/authz.ts new file mode 100644 index 0000000..c5ddf41 --- /dev/null +++ b/src/infrastructure/discord/authz.ts @@ -0,0 +1,22 @@ +import githubDiscordMapJson from "../../../data/githubDiscordMap.json"; + +// 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 => { + const userId = interaction.user?.id; + + const discordIds = Object.values(githubDiscordMap).map( + (entry) => entry.discordId, + ); + const isAuthorized = discordIds.includes(userId); + + return isAuthorized; +}; diff --git a/src/infrastructure/discord/bot.ts b/src/infrastructure/discord/bot.ts new file mode 100644 index 0000000..c75b39d --- /dev/null +++ b/src/infrastructure/discord/bot.ts @@ -0,0 +1,68 @@ +import { Client, GatewayIntentBits, Events } from "discord.js"; +import { config } from "dotenv"; +import fs from "fs"; +import path from "path"; +import { handleModalSubmit } from "./commands/createIssue"; +import { + assigneeSelectInteraction, + issueButtonInteraction, +} from "./interactions"; + +config(); + +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +const commands = new Map(); +const tempIssueData: Record< + string, + { title: string; description: string; dueDate: string } +> = {}; + +// Load slash commands +const commandFiles = fs.readdirSync(path.join(__dirname, "commands")); +(async () => { + for (const file of commandFiles) { + const command = await import(`./commands/${file}`); + commands.set(command.data.name, command); + } +})(); + +client.once(Events.ClientReady, (c) => { + console.log(`✅ Logged in as ${c.user.tag}`); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.isChatInputCommand()) { + const command = commands.get(interaction.commandName); + if (!command) return; + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + await interaction.reply({ + content: "There was an error executing this command.", + ephemeral: true, + }); + } + } + + if ( + interaction.isModalSubmit() && + interaction.customId === "create-issue:modal" + ) { + await handleModalSubmit(interaction); + } + + // TODO: need more specificity on this check as there could be other user select interactions + if (interaction.isUserSelectMenu()) { + await assigneeSelectInteraction(interaction); + } + + // TODO: need more specificity on this check as there could be other button interactions + if (interaction.isButton()) { + await issueButtonInteraction(interaction); + } +}); + +client.login(process.env.DISCORD_APP_TOKEN); diff --git a/src/infrastructure/discord/builders/assigneeSelectionRow.ts b/src/infrastructure/discord/builders/assigneeSelectionRow.ts new file mode 100644 index 0000000..6658c03 --- /dev/null +++ b/src/infrastructure/discord/builders/assigneeSelectionRow.ts @@ -0,0 +1,11 @@ +import { ActionRowBuilder, UserSelectMenuBuilder } from "discord.js"; + +export const buildAssigneeSelectionRow = (githubIssueId: string) => { + return new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId(`issue:assignee:select:${githubIssueId}`) + .setPlaceholder("Choose a user") + .setMinValues(1) + .setMaxValues(1), + ); +}; diff --git a/src/infrastructure/discord/builders/index.ts b/src/infrastructure/discord/builders/index.ts new file mode 100644 index 0000000..5066433 --- /dev/null +++ b/src/infrastructure/discord/builders/index.ts @@ -0,0 +1,2 @@ +export { buildIssueButtonRow } from "./issueButtonRow"; +export { buildAssigneeSelectionRow } from "./assigneeSelectionRow"; diff --git a/src/infrastructure/discord/builders/issueButtonRow.ts b/src/infrastructure/discord/builders/issueButtonRow.ts new file mode 100644 index 0000000..6f3b0ac --- /dev/null +++ b/src/infrastructure/discord/builders/issueButtonRow.ts @@ -0,0 +1,64 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; + +type IssueButtonOperation = "edit" | "assign" | "unassign" | "delete" | "open"; + +export const buildIssueButtonRow = ( + issueId: string, + link: string, + operations: IssueButtonOperation[] = [ + "edit", + "assign", + "unassign", + "delete", + "open", + ], +) => { + const row = new ActionRowBuilder(); + + if (operations.includes("edit")) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`issue:edit:${issueId}`) + .setLabel("Edit") + .setStyle(ButtonStyle.Primary), + ); + } + + if (operations.includes("assign")) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`issue:assign:${issueId}`) + .setLabel("Assign") + .setStyle(ButtonStyle.Secondary), + ); + } + + if (operations.includes("unassign")) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`issue:unassign:${issueId}`) + .setLabel("Unassign") + .setStyle(ButtonStyle.Secondary), + ); + } + + if (operations.includes("delete")) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`issue:delete:${issueId}`) + .setLabel("Delete") + .setStyle(ButtonStyle.Danger), + ); + } + + if (operations.includes("open")) { + row.addComponents( + new ButtonBuilder() + .setLabel("Open") + .setStyle(ButtonStyle.Link) + .setURL(link), + ); + } + + return row; +}; diff --git a/src/infrastructure/discord/commands/createIssue.ts b/src/infrastructure/discord/commands/createIssue.ts new file mode 100644 index 0000000..79a8352 --- /dev/null +++ b/src/infrastructure/discord/commands/createIssue.ts @@ -0,0 +1,92 @@ +import { + SlashCommandBuilder, + CommandInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} from "discord.js"; +import { ModalSubmitInteraction } from "discord.js"; +import { ItemService } from "@src/items/services"; +import { can } from "../authz"; +import { promptAssigneeSelection } from "../interactions"; + +export const data = new SlashCommandBuilder() + .setName("create-issue") + .setDescription( + "Create a new issue with title, description, due date, and assignee", + ); + +export async function execute(interaction: CommandInteraction) { + if (!can(interaction)) { + await interaction.reply({ + content: "You do not have permission to create an issue.", + ephemeral: true, + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId("create-issue:modal") + .setTitle("Create New Issue"); + + const titleInput = new TextInputBuilder() + .setCustomId("title") + .setLabel("Title") + .setStyle(TextInputStyle.Short) + .setRequired(true); + + const descriptionInput = new TextInputBuilder() + .setCustomId("description") + .setLabel("Description (Markdown supported)") + .setStyle(TextInputStyle.Paragraph) + .setRequired(false); + + const dueDateInput = new TextInputBuilder() + .setCustomId("dueDate") + .setLabel("Due Date (e.g. yyyy-mm-dd)") + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const firstRow = new ActionRowBuilder().addComponents( + titleInput, + ); + const secondRow = new ActionRowBuilder().addComponents( + descriptionInput, + ); + const thirdRow = new ActionRowBuilder().addComponents( + dueDateInput, + ); + + modal.addComponents(firstRow, secondRow, thirdRow); + + await interaction.showModal(modal); +} + +export async function handleModalSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const title = interaction.fields.getTextInputValue("title"); + const description = interaction.fields.getTextInputValue("description"); + const dueDate = interaction.fields.getTextInputValue("dueDate"); + + if (dueDate && !/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) { + throw new Error("Invalid due date format. Please use yyyy-mm-dd."); + } + + const result = await ItemService.create({ + title, + description, + dueDate: new Date(dueDate), + }); + + if (result.err) { + await interaction.reply({ + content: "Failed to create issue. Please try again.", + ephemeral: true, + }); + return; + } + + await promptAssigneeSelection(interaction, result.val.githubIssueId); +} diff --git a/src/infrastructure/discord/commands/myIssues.ts b/src/infrastructure/discord/commands/myIssues.ts new file mode 100644 index 0000000..ade9f42 --- /dev/null +++ b/src/infrastructure/discord/commands/myIssues.ts @@ -0,0 +1,126 @@ +import { SlashCommandBuilder, CommandInteraction } from "discord.js"; +import { GithubAPI } from "@infrastructure/github"; +import logger from "@config/logger"; +import githubDiscordMapJson from "../../../../data/githubDiscordMap.json"; +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") + .addIntegerOption((option) => + option + .setName("index") + .setDescription("Index of the specific issue to display") + .setRequired(false), + ); + +export async function execute(interaction: CommandInteraction) { + if (!can(interaction)) { + await interaction.reply({ + content: "You do not have permission to create an issue.", + ephemeral: true, + }); + return; + } + + const discordUserId = interaction.user.id; + + const githubUsername = Object.values(githubDiscordMap).find( + (entry) => entry.discordId === discordUserId, + )?.githubUsername; + + if (!githubUsername) { + await interaction.reply({ + content: "❌ You don’t appear to be linked to a GitHub account.", + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + const githubItemsResult = await GithubAPI.fetchProjectItems(); + if (githubItemsResult.err) { + logger.error({ + event: "myIssues.error", + body: githubItemsResult.val.message, + }); + + await interaction.editReply({ + content: "❌ Failed to fetch issues from GitHub.", + }); + return; + } + + const assignedItems = githubItemsResult.val.filter( + (item) => + Array.isArray(item.assignedUsers) && + item.assignedUsers.some((assigneeUrl: string) => + assigneeUrl.endsWith(`/${githubUsername}`), + ), + ); + + if (assignedItems.length === 0) { + await interaction.editReply({ + content: "✅ You have no assigned issues at the moment.", + }); + return; + } + + // @ts-ignore // TODO: Fix type error + const issueIndex = interaction.options.getInteger("index"); + + if (issueIndex !== null) { + if (issueIndex < 0 || issueIndex >= assignedItems.length) { + await interaction.editReply({ + content: `❌ Invalid issue index. Please use a number between 0 and ${assignedItems.length - 1}.`, + }); + return; + } + + const item = assignedItems[issueIndex]; + const link = item.url ?? "https://github.com/"; + + const buttons = buildIssueButtonRow(item.githubIssueId, link, [ + "unassign", + "open", + ]); + + await interaction.editReply({ + content: `📌 **Issue #${issueIndex}**\n## ${item.title}`, + components: [buttons], + }); + + return; + } + + // Show list of issues with index numbers + const list = assignedItems + .map((item, idx) => { + const titleWithLink = `[${item.title}](<${item.url}>)`; + const due = item.dueDate ? formatDiscordDate(item.dueDate) : ""; + const status = item.status ?? ""; + + return `\`${idx}\` — ${titleWithLink}${due ? ` - ${due}` : ""}${status ? ` - ${status}` : ""}`; + }) + .join("\n"); + await interaction.editReply({ + content: `📋 You have ${assignedItems.length} assigned issue(s):\n\n${list}\n\nUse \`/my-issues index:\` to view a specific issue.`, + }); + + logger.info({ + event: "myIssues.success", + body: `${assignedItems.length} issues returned for ${githubUsername}`, + }); +} diff --git a/src/infrastructure/discord/commands/unassignedIssues.ts b/src/infrastructure/discord/commands/unassignedIssues.ts new file mode 100644 index 0000000..a51458c --- /dev/null +++ b/src/infrastructure/discord/commands/unassignedIssues.ts @@ -0,0 +1,122 @@ +import { SlashCommandBuilder, CommandInteraction } from "discord.js"; +import { GithubAPI } from "@infrastructure/github"; +import { filterForUnassigned } from "@src/items"; +import logger from "@config/logger"; +import { can } from "../authz"; +import { buildIssueButtonRow } from "../builders"; +import { formatDiscordDate } from "../webhookMessages"; + +export const data = new SlashCommandBuilder() + .setName("unassigned-issues") + .setDescription("List unassigned issues created in a specific date range") + .addStringOption((option) => + option + .setName("date-range") + .setDescription("Date range to filter issues by") + .setRequired(true) + .addChoices( + { name: "Today", value: "today" }, + { name: "All Time", value: "all-time" }, + ), + ) + .addIntegerOption((option) => + option + .setName("index") + .setDescription("Index of the specific issue to display") + .setRequired(false), + ); + +export async function execute(interaction: CommandInteraction) { + if (!can(interaction)) { + await interaction.reply({ + content: "You do not have permission to view issues.", + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + // @ts-ignore // TODO: Fix types + const dateRange = interaction.options.getString("date-range", true); + // @ts-ignore + const issueIndex = interaction.options.getInteger("index"); + + const githubItemsResult = await GithubAPI.fetchProjectItems(); + if (githubItemsResult.err) { + logger.error({ + event: "unassignedIssues.error", + body: githubItemsResult.val.message, + }); + + await interaction.editReply({ + content: "❌ Failed to fetch issues from GitHub.", + }); + return; + } + + let filtered = githubItemsResult.val; + + if (dateRange === "today") { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + filtered = filtered.filter((item) => { + const createdAt = new Date(item.createdAt); + return createdAt >= startOfDay; + }); + } + + const unassignedItems = filterForUnassigned(filtered); + + if (unassignedItems.length === 0) { + await interaction.editReply({ + content: `✅ No unassigned issues found.`, + }); + return; + } + + if (issueIndex !== null) { + if (issueIndex < 0 || issueIndex >= unassignedItems.length) { + await interaction.editReply({ + content: `❌ Invalid issue index. Please use a number between 0 and ${unassignedItems.length - 1}.`, + }); + return; + } + + const item = unassignedItems[issueIndex]; + const link = item.url ?? "https://github.com/"; + + const buttons = buildIssueButtonRow(item.githubIssueId, link, [ + "assign", + "open", + ]); + + await interaction.editReply({ + content: `📌 **Issue #${issueIndex}**\n## ${item.title}`, + components: [buttons], + }); + + return; + } + + // Show list of issues with index numbers + const list = unassignedItems + .map((item, idx) => { + const titleWithLink = `[${item.title}](<${item.url}>)`; + const due = item.dueDate ? formatDiscordDate(item.dueDate) : ""; + const status = item.status ?? ""; + + return `\`${idx}\` — ${titleWithLink}${due ? ` - ${due}` : ""}${status ? ` - ${status}` : ""}`; + }) + .join("\n"); + + await interaction.editReply({ + content: `📋 ${unassignedItems.length} unassigned issue(s) found:\n\n${list}\n\nUse \`/unassigned-issues date-range:${dateRange} index:\` to view a specific issue.`, + }); + + logger.info({ + event: "unassignedIssues.success", + body: `${unassignedItems.length} unassigned issues returned.`, + }); +} diff --git a/src/infrastructure/discord/deployCommands.ts b/src/infrastructure/discord/deployCommands.ts new file mode 100644 index 0000000..f93f85d --- /dev/null +++ b/src/infrastructure/discord/deployCommands.ts @@ -0,0 +1,37 @@ +import { REST, Routes } from "discord.js"; +import { config } from "dotenv"; +import fs from "fs"; +import path from "path"; + +config(); + +const commands = []; +const commandsPath = path.join(__dirname, "commands"); +const commandFiles = fs + .readdirSync(commandsPath) + .filter((file) => file.endsWith(".ts")); + +(async () => { + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = await import(filePath); + commands.push(command.data.toJSON()); + } + + const rest = new REST({ version: "10" }).setToken( + process.env.DISCORD_APP_TOKEN!, + ); + + rest + .put( + Routes.applicationGuildCommands( + process.env.DISCORD_CLIENT_ID!, + process.env.DISCORD_GUILD_ID!, + ), + { + body: commands, + }, + ) + .then(() => console.log("✅ Successfully registered application commands.")) + .catch(console.error); +})(); diff --git a/src/infrastructure/discord/index.ts b/src/infrastructure/discord/index.ts index f7818ca..a13170d 100644 --- a/src/infrastructure/discord/index.ts +++ b/src/infrastructure/discord/index.ts @@ -1,101 +1,5 @@ -import axios from "axios"; -import { Err, Ok, Result } from "ts-results"; -import dotenv from "dotenv"; -import githubDiscordMap from "../../../data/githubDiscordMap.json"; -import { Item } from "../../items"; -import logger from "@config/logger"; - -dotenv.config(); - -export interface DiscordItemMessage { - title: string; - sections: { - title: string; - items: Item[]; - includeLinks: boolean; - }[]; - message: string; -} - -export const sendDiscordAlertMessage = async ( - message: string, -): Promise> => { - const alertWebhook = process.env.DISCORD_ALERTS_WEBHOOK_URL ?? ""; - await axios.post(alertWebhook, { - content: `Failed to send Discord message: ${message}`, - }); - return Ok("Alert sent"); -}; - -// TODO: any type -export const sendDiscordItemMessage = async ( - message: DiscordItemMessage, -): Promise> => { - const webhookUrl = process.env.DISCORD_CHANNEL_WEBHOOK_URL ?? ""; - const messageHeader = formatMessageTitle(message.title, message.message); - const messageSections = message.sections.map((section) => { - const sectionHeader = formatMessageSectionTitle(section.title); - const sectionItems = section.items - .map((item) => - section.includeLinks ? formatItemWithLink(item) : formatItem(item), - ) - .join("\n"); - return `${sectionHeader} ${sectionItems}`; - }); - - try { - const response = await axios.post(webhookUrl, { - content: `${messageHeader} ${messageSections.join()}`, - }); - return Ok(response.data); - } catch (error) { - logger.error({ - event: "github.fetchProjectV2Items.error", - body: error instanceof Error ? error.message : "Failed to fetch data", - }); - return Err(new Error("Failed to send Discord message")); - } -}; - -const githubUrlToDiscordId = (githubUrl: string) => { - return githubDiscordMap[ - githubUrl.replace( - "https://github.com/", - "", - ) as keyof typeof githubDiscordMap - ]; -}; - -const formatDiscordDate = (date: Date) => { - return ``; -}; - -const formatMessageTitle = (title: string, message: string) => { - return `# ${title} \n ${message}`; -}; - -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 formatItem = (item: Item) => { - return `- ${item.title}: ${formatDiscordAssignees(item.assignedUsers)} - ${item.dueDate ? formatDiscordDate(item.dueDate) : ""} - ${item.status}`; -}; - -const formatItemWithLink = (item: Item) => { - return `- [${item.title}](<${item.url}>): ${formatDiscordAssignees(item.assignedUsers)} - ${item.dueDate ? formatDiscordDate(item.dueDate) : ""} - ${item.status}`; -}; +export { + sendDiscordAlertMessage, + sendDiscordItemMessage, + DiscordItemMessage, +} from "./webhookMessages"; diff --git a/src/infrastructure/discord/interactions/assigneeSelectInteraction.ts b/src/infrastructure/discord/interactions/assigneeSelectInteraction.ts new file mode 100644 index 0000000..6406ba9 --- /dev/null +++ b/src/infrastructure/discord/interactions/assigneeSelectInteraction.ts @@ -0,0 +1,56 @@ +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; + +export async function assigneeSelectInteraction( + interaction: UserSelectMenuInteraction, +): Promise { + const match = interaction.customId.match(/^issue:assignee:select:(.+)$/); + if (!match) { + throw new Error("Invalid customId format"); + } + + 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) { + await interaction.reply({ + content: "❌ Unable to find linked GitHub account for selected user.", + ephemeral: true, + }); + return; + } + + const result = await ItemService.updateAssignee({ + itemId: githubIssueId, + assigneeId: githubId, + }); + + if (result.err) { + await interaction.reply({ + content: + "❌ Failed to update assignee. Cannot assign to Draft Issues (yet).", + ephemeral: true, + }); + return; + } + + await interaction.update({ + content: `**Assigned**: <@${selectedUserId}>`, + components: [], + }); +} diff --git a/src/infrastructure/discord/interactions/index.ts b/src/infrastructure/discord/interactions/index.ts new file mode 100644 index 0000000..8db256d --- /dev/null +++ b/src/infrastructure/discord/interactions/index.ts @@ -0,0 +1,3 @@ +export { assigneeSelectInteraction } from "./assigneeSelectInteraction"; +export { issueButtonInteraction } from "./issueButtonInteraction"; +export { promptAssigneeSelection } from "./promptAssigneeSelection"; diff --git a/src/infrastructure/discord/interactions/issueButtonInteraction.ts b/src/infrastructure/discord/interactions/issueButtonInteraction.ts new file mode 100644 index 0000000..b469b4d --- /dev/null +++ b/src/infrastructure/discord/interactions/issueButtonInteraction.ts @@ -0,0 +1,44 @@ +import { CacheType, Interaction } from "discord.js"; +import { promptAssigneeSelection } from "./promptAssigneeSelection"; + +export async function issueButtonInteraction( + interaction: Interaction, +) { + if (!interaction.isButton()) return; + + const [_issue, action, githubIssueId] = interaction.customId.split(":"); + + if (!githubIssueId) { + await interaction.reply({ + content: "⚠️ Invalid button ID.", + ephemeral: true, + }); + return; + } + + switch (action) { + case "edit": + await interaction.reply({ + content: `✏️ Editing issue with ID \`${githubIssueId}\` (not yet implemented).`, + ephemeral: true, + }); + break; + + case "assign": + await promptAssigneeSelection(interaction, githubIssueId); + break; + + case "delete": + await interaction.reply({ + content: `🗑️ Deleting issue \`${githubIssueId}\` (not yet implemented).`, + ephemeral: true, + }); + break; + + default: + await interaction.reply({ + content: `❌ Unknown action: \`${action}\``, + ephemeral: true, + }); + } +} diff --git a/src/infrastructure/discord/interactions/promptAssigneeSelection.ts b/src/infrastructure/discord/interactions/promptAssigneeSelection.ts new file mode 100644 index 0000000..f8bf7ee --- /dev/null +++ b/src/infrastructure/discord/interactions/promptAssigneeSelection.ts @@ -0,0 +1,15 @@ +import { buildAssigneeSelectionRow } from "../builders"; + +// TODO: fix any type +export const promptAssigneeSelection = async ( + interaction: any, + githubIssueId: string, +) => { + const assigneeSelectionRow = buildAssigneeSelectionRow(githubIssueId); + + await interaction.reply({ + content: "Select an assignee for this issue:", + components: [assigneeSelectionRow], + ephemeral: true, + }); +}; diff --git a/src/infrastructure/discord/webhookMessages.ts b/src/infrastructure/discord/webhookMessages.ts new file mode 100644 index 0000000..800acd2 --- /dev/null +++ b/src/infrastructure/discord/webhookMessages.ts @@ -0,0 +1,101 @@ +import axios from "axios"; +import { Err, Ok, Result } from "ts-results"; +import dotenv from "dotenv"; +import githubDiscordMap from "../../../data/githubDiscordMap.json"; +import { Item } from "../../items"; +import logger from "@config/logger"; + +dotenv.config(); + +export interface DiscordItemMessage { + title: string; + sections: { + title: string; + items: Item[]; + includeLinks: boolean; + }[]; + message: string; +} + +export const sendDiscordAlertMessage = async ( + message: string, +): Promise> => { + const alertWebhook = process.env.DISCORD_ALERTS_WEBHOOK_URL ?? ""; + await axios.post(alertWebhook, { + content: `Failed to send Discord message: ${message}`, + }); + return Ok("Alert sent"); +}; + +// TODO: any type +export const sendDiscordItemMessage = async ( + message: DiscordItemMessage, +): Promise> => { + const webhookUrl = process.env.DISCORD_CHANNEL_WEBHOOK_URL ?? ""; + const messageHeader = formatMessageTitle(message.title, message.message); + const messageSections = message.sections.map((section) => { + const sectionHeader = formatMessageSectionTitle(section.title); + const sectionItems = section.items + .map((item) => + section.includeLinks ? formatItemWithLink(item) : formatItem(item), + ) + .join("\n"); + return `${sectionHeader} ${sectionItems}`; + }); + + try { + const response = await axios.post(webhookUrl, { + content: `${messageHeader} ${messageSections.join()}`, + }); + return Ok(response.data); + } catch (error) { + logger.error({ + event: "github.fetchProjectV2Items.error", + body: error instanceof Error ? error.message : "Failed to fetch data", + }); + return Err(new Error("Failed to send Discord message")); + } +}; + +const githubUrlToDiscordId = (githubUrl: string) => { + return githubDiscordMap[ + githubUrl.replace( + "https://github.com/", + "", + ) as keyof typeof githubDiscordMap + ].discordId; +}; + +export const formatDiscordDate = (date: Date) => { + return ``; +}; + +const formatMessageTitle = (title: string, message: string) => { + return `# ${title} \n ${message}`; +}; + +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 formatItem = (item: Item) => { + return `- ${item.title}: ${formatDiscordAssignees(item.assignedUsers)} - ${item.dueDate ? formatDiscordDate(item.dueDate) : ""} - ${item.status}`; +}; + +const formatItemWithLink = (item: Item) => { + return `- [${item.title}](<${item.url}>): ${formatDiscordAssignees(item.assignedUsers)} - ${item.dueDate ? formatDiscordDate(item.dueDate) : ""} - ${item.status}`; +}; diff --git a/src/infrastructure/github/constants.ts b/src/infrastructure/github/constants.ts new file mode 100644 index 0000000..3b47863 --- /dev/null +++ b/src/infrastructure/github/constants.ts @@ -0,0 +1,4 @@ +export const REPO_ID = "R_kgDOLyx0yw"; +export const PROJECT_ID = "PVT_kwDOABL5c84Ag5Rq"; +export const DUE_DATE_FIELD_ID = "PVTF_lADOABL5c84Ag5RqzgXvHkQ"; +export const ASSIGNEES_FIELD_ID = "PVTF_lADOABL5c84Ag5RqzgXodws"; diff --git a/src/infrastructure/github/functions/addIssueToProject.ts b/src/infrastructure/github/functions/addIssueToProject.ts new file mode 100644 index 0000000..e2796a7 --- /dev/null +++ b/src/infrastructure/github/functions/addIssueToProject.ts @@ -0,0 +1,53 @@ +import logger from "@src/config/logger"; +import axios from "axios"; +import { Result, Err, Ok } from "ts-results"; +import { ADD_PROJECT_V2_ITEM_BY_ID } from "../graphql"; +import dotenv from "dotenv"; + +dotenv.config(); + +const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; + +export const addIssueToProject = async ({ + projectId, + issueId, +}: { + projectId: string; + issueId: string; +}): Promise> => { + try { + const response = await axios.post( + "https://api.github.com/graphql", + { + query: ADD_PROJECT_V2_ITEM_BY_ID, + variables: { + projectId, + contentId: issueId, + }, + }, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (response.data.errors) { + logger.error({ + event: "github.addIssueToProject.error", + body: response.data.errors + .map((error: any) => error.message) + .join(", "), + }); + return Err(new Error("Failed to add issue to project")); + } + + return Ok(response.data.data.addProjectV2ItemById.item); + } catch (error) { + logger.error({ + event: "github.addIssueToProject.error", + body: error instanceof Error ? error.message : "Unknown error", + }); + return Err(new Error("Failed to add issue to project")); + } +}; diff --git a/src/infrastructure/github/functions/createIssue.ts b/src/infrastructure/github/functions/createIssue.ts new file mode 100644 index 0000000..724d01c --- /dev/null +++ b/src/infrastructure/github/functions/createIssue.ts @@ -0,0 +1,57 @@ +import logger from "@src/config/logger"; +import axios from "axios"; +import { Result, Err, Ok } from "ts-results"; +import { CREATE_ISSUE_WITH_PROJECT } from "../graphql"; +import { REPO_ID } from "../constants"; +import dotenv from "dotenv"; + +dotenv.config(); + +const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; + +export const createIssue = async ({ + title, + description, + dueDate, +}: { + title: string; + description: string; + dueDate: Date; +}): Promise> => { + try { + const response = await axios.post( + "https://api.github.com/graphql", + { + query: CREATE_ISSUE_WITH_PROJECT, + variables: { + repositoryId: REPO_ID, + title, + body: description, + }, + }, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (response.data.errors) { + logger.error({ + event: "github.createIssue.error", + body: response.data.errors + .map((error: any) => error.message) + .join(", "), + }); + return Err(new Error("Failed to create issue")); + } + + return Ok(response.data.data.createIssue.issue); + } catch (error) { + logger.error({ + event: "github.createIssue.error", + body: error instanceof Error ? error.message : "Unknown error", + }); + return Err(new Error("Failed to create issue")); + } +}; diff --git a/src/infrastructure/github/functions/fetchProjectItems.ts b/src/infrastructure/github/functions/fetchProjectItems.ts new file mode 100644 index 0000000..6b42796 --- /dev/null +++ b/src/infrastructure/github/functions/fetchProjectItems.ts @@ -0,0 +1,87 @@ +import logger from "@src/config/logger"; +import { Item } from "@src/items"; +import axios from "axios"; +import { Result, Ok, Err } from "ts-results"; +import { ProjectV2Item } from ".."; +import { PROJECT_V2_ITEMS } from "../graphql"; + +export const fetchProjectItems = async (): Promise> => { + const result = await fetchData(); + if (result.err) { + return result; + } + + const formattedItems = convertGithubItems( + result.val.data.organization.projectV2.items.nodes, + ); + return Ok(formattedItems); +}; + +const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; + +const fetchData = async (): Promise> => { + try { + const response = await axios.post( + "https://api.github.com/graphql", + { + query: PROJECT_V2_ITEMS, + }, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + return Ok(response.data); + } catch (error) { + logger.error({ + event: "github.fetchProjectV2Items.error", + body: error instanceof Error ? error.message : "Failed to fetch data", + }); + return Err(new Error("Failed to fetch data")); + } +}; + +const convertGithubItems = (items: ProjectV2Item[]) => { + return items + .map((item: ProjectV2Item) => { + const assignedUsers = item.fieldValues.nodes + .filter((field) => field.users) + .flatMap((field) => field.users.nodes.map((user) => user.url)); + const status = item.fieldValues.nodes + .filter((field) => field.name) + .map((field) => field.name)[0]; + const labels = item.fieldValues.nodes + .filter((field) => field.labels) + .flatMap((field) => field.labels.nodes.map((label) => label.name)); + + // TODO: improve this + let dueDate: Date | undefined; + if (item.fieldValueByName?.date) { + dueDate = new Date(item.fieldValueByName.date); + dueDate.setDate(dueDate.getDate() + 1); + } + + return { + githubProjectItemId: item.id, + githubIssueId: item.content.id, + title: item.content.title, + url: item.content.url, + assignedUsers, + labels, + dueDate: dueDate, + status: status, + createdAt: new Date(item.createdAt), + updatedAt: new Date(item.updatedAt), + }; + }) + .sort(sortByDate); +}; + +const sortByDate = (item1: Item, item2: Item): number => { + if (item1.dueDate === undefined && item2.dueDate === undefined) return 0; + if (item1.dueDate === undefined) return 1; + if (item2.dueDate === undefined) return -1; + if (item1.dueDate === item2.dueDate) return 0; + return item1.dueDate < item2.dueDate ? -1 : 1; +}; diff --git a/src/infrastructure/github/functions/index.ts b/src/infrastructure/github/functions/index.ts new file mode 100644 index 0000000..7fe5f6f --- /dev/null +++ b/src/infrastructure/github/functions/index.ts @@ -0,0 +1,5 @@ +export { addIssueToProject } from "./addIssueToProject"; +export { createIssue } from "./createIssue"; +export { fetchProjectItems } from "./fetchProjectItems"; +export { updateProjectItemDueDate } from "./updateProjectItemDueDate"; +export { updateProjectItemAssignee } from "./updateProjectItemAssignee"; diff --git a/src/infrastructure/github/functions/updateProjectItemAssignee.ts b/src/infrastructure/github/functions/updateProjectItemAssignee.ts new file mode 100644 index 0000000..a6169ed --- /dev/null +++ b/src/infrastructure/github/functions/updateProjectItemAssignee.ts @@ -0,0 +1,53 @@ +import logger from "@src/config/logger"; +import axios from "axios"; +import { Result, Err, Ok } from "ts-results"; +import { UPDATE_ISSUE_ASSIGNEE } from "../graphql"; +import dotenv from "dotenv"; + +dotenv.config(); + +const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; + +export const updateProjectItemAssignee = async ({ + issueId, + assigneeId, +}: { + issueId: string; + assigneeId: string; +}): Promise> => { + try { + const response = await axios.post( + "https://api.github.com/graphql", + { + query: UPDATE_ISSUE_ASSIGNEE, + variables: { + issueId: issueId, + assigneeIds: [assigneeId], + }, + }, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (response.data.errors) { + logger.error({ + event: "github.updateProjectItemAssignee.error", + body: response.data.errors + .map((error: any) => error.message) + .join(", "), + }); + return Err(new Error("Failed to update assignee")); + } + + return Ok(response.data.data.updateIssue); + } catch (error) { + logger.error({ + event: "github.updateProjectItemAssignee.error", + body: error instanceof Error ? error.message : "Unknown error", + }); + return Err(new Error("Failed to update assignee")); + } +}; diff --git a/src/infrastructure/github/functions/updateProjectItemDueDate.ts b/src/infrastructure/github/functions/updateProjectItemDueDate.ts new file mode 100644 index 0000000..5d48a13 --- /dev/null +++ b/src/infrastructure/github/functions/updateProjectItemDueDate.ts @@ -0,0 +1,59 @@ +import logger from "@src/config/logger"; +import axios from "axios"; +import { Result, Err, Ok } from "ts-results"; +import { UPDATE_PROJECT_V2_ITEM_DUE_DATE } from "../graphql"; +import dotenv from "dotenv"; + +dotenv.config(); + +const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; + +export const updateProjectItemDueDate = async ({ + projectId, + itemId, + fieldId, + date, +}: { + projectId: string; + itemId: string; + fieldId: string; + date: string; // e.g. "2025-06-01" +}): Promise> => { + try { + const response = await axios.post( + "https://api.github.com/graphql", + { + query: UPDATE_PROJECT_V2_ITEM_DUE_DATE, + variables: { + projectId, + itemId, + fieldId, + date, + }, + }, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (response.data.errors) { + logger.error({ + event: "github.updateProjectItemDueDate.error", + body: response.data.errors + .map((error: any) => error.message) + .join(", "), + }); + return Err(new Error("Failed to update due date")); + } + + return Ok(response.data.data.updateProjectV2ItemFieldValue.projectV2Item); + } catch (error) { + logger.error({ + event: "github.updateProjectItemDueDate.error", + body: error instanceof Error ? error.message : "Unknown error", + }); + return Err(new Error("Failed to update due date")); + } +}; diff --git a/src/infrastructure/github/graphql/addProjectV2ItemById.ts b/src/infrastructure/github/graphql/addProjectV2ItemById.ts new file mode 100644 index 0000000..d871e54 --- /dev/null +++ b/src/infrastructure/github/graphql/addProjectV2ItemById.ts @@ -0,0 +1,15 @@ +export const ADD_PROJECT_V2_ITEM_BY_ID = ` +mutation( + $projectId: ID!, + $contentId: ID! +) { + addProjectV2ItemById(input: { + projectId: $projectId, + contentId: $contentId + }) { + item { + id + } + } +} +`; diff --git a/src/infrastructure/github/graphql/createIssue.ts b/src/infrastructure/github/graphql/createIssue.ts new file mode 100644 index 0000000..1a1fbc9 --- /dev/null +++ b/src/infrastructure/github/graphql/createIssue.ts @@ -0,0 +1,22 @@ +export const CREATE_ISSUE_WITH_PROJECT = ` +mutation( + $repositoryId: ID!, + $title: String!, + $body: String, +) { + createIssue(input: { + repositoryId: $repositoryId, + title: $title, + body: $body, + }) { + issue { + id + number + title + url + createdAt + updatedAt + } + } +} +`; diff --git a/src/infrastructure/github/graphql/index.ts b/src/infrastructure/github/graphql/index.ts index bfc7813..e5834ca 100644 --- a/src/infrastructure/github/graphql/index.ts +++ b/src/infrastructure/github/graphql/index.ts @@ -1 +1,5 @@ +export { ADD_PROJECT_V2_ITEM_BY_ID } from "./addProjectV2ItemById"; +export { CREATE_ISSUE_WITH_PROJECT } from "./createIssue"; export { PROJECT_V2_ITEMS } from "./projectV2Items"; +export { UPDATE_PROJECT_V2_ITEM_DUE_DATE } from "./updateProjectV2ItemFieldValue"; +export { UPDATE_ISSUE_ASSIGNEE } from "./updateIssue"; diff --git a/src/infrastructure/github/graphql/projectV2Items.ts b/src/infrastructure/github/graphql/projectV2Items.ts index f172d62..6923f8e 100644 --- a/src/infrastructure/github/graphql/projectV2Items.ts +++ b/src/infrastructure/github/graphql/projectV2Items.ts @@ -5,13 +5,17 @@ export const PROJECT_V2_ITEMS = ` items(first: 100) { nodes { id + createdAt + updatedAt content { ... on DraftIssue { title + id } ... on Issue { title url + id } } fieldValueByName(name: "Due") { diff --git a/src/infrastructure/github/graphql/updateIssue.ts b/src/infrastructure/github/graphql/updateIssue.ts new file mode 100644 index 0000000..f892be8 --- /dev/null +++ b/src/infrastructure/github/graphql/updateIssue.ts @@ -0,0 +1,14 @@ +export const UPDATE_ISSUE_ASSIGNEE = `mutation UpdateIssueAssignee( + $issueId: ID! + $assigneeIds: [ID!]! +) { + updateIssue(input: { + id: $issueId + assigneeIds: $assigneeIds + }) { + issue { + id + } + } +} +`; diff --git a/src/infrastructure/github/graphql/updateProjectV2ItemFieldValue.ts b/src/infrastructure/github/graphql/updateProjectV2ItemFieldValue.ts new file mode 100644 index 0000000..730c273 --- /dev/null +++ b/src/infrastructure/github/graphql/updateProjectV2ItemFieldValue.ts @@ -0,0 +1,19 @@ +export const UPDATE_PROJECT_V2_ITEM_DUE_DATE = `mutation UpdateDueDate( + $projectId: ID!, + $itemId: ID!, + $fieldId: ID!, + $date: Date! +) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { + date: $date + } + }) { + projectV2Item { + id + } + } +}`; diff --git a/src/infrastructure/github/index.ts b/src/infrastructure/github/index.ts index 31a2d87..fcca8cc 100644 --- a/src/infrastructure/github/index.ts +++ b/src/infrastructure/github/index.ts @@ -1,18 +1,12 @@ -import axios from "axios"; -import { Ok, Err, Result } from "ts-results"; -import dotenv from "dotenv"; -import { PROJECT_V2_ITEMS } from "./graphql"; -import { Item } from "../../items"; -import logger from "@config/logger"; - -dotenv.config(); - // TODO: improve this export interface ProjectV2Item { id: string; + createdAt: string; + updatedAt: string; content: { title: string; url: string; + id: string; }; fieldValueByName: { id: string; @@ -38,79 +32,4 @@ export interface ProjectV2Item { }; } -export const fetchProjectV2Items = async (): Promise> => { - const result = await fetchData(); - if (result.err) { - return result; - } - - const formattedItems = convertGithubItems( - result.val.data.organization.projectV2.items.nodes, - ); - return Ok(formattedItems); -}; - -const TOKEN = process.env.GITHUB_ACCESS_TOKEN ?? ""; - -const fetchData = async (): Promise> => { - try { - const response = await axios.post( - "https://api.github.com/graphql", - { - query: PROJECT_V2_ITEMS, - }, - { - headers: { - Authorization: `Bearer ${TOKEN}`, - }, - }, - ); - return Ok(response.data); - } catch (error) { - logger.error({ - event: "github.fetchProjectV2Items.error", - body: error instanceof Error ? error.message : "Failed to fetch data", - }); - return Err(new Error("Failed to fetch data")); - } -}; - -const convertGithubItems = (items: ProjectV2Item[]) => { - return items - .map((item: ProjectV2Item) => { - const assignedUsers = item.fieldValues.nodes - .filter((field) => field.users) - .flatMap((field) => field.users.nodes.map((user) => user.url)); - const status = item.fieldValues.nodes - .filter((field) => field.name) - .map((field) => field.name)[0]; - const labels = item.fieldValues.nodes - .filter((field) => field.labels) - .flatMap((field) => field.labels.nodes.map((label) => label.name)); - - // TODO: improve this - let dueDate: Date | undefined; - if (item.fieldValueByName?.date) { - dueDate = new Date(item.fieldValueByName.date); - dueDate.setDate(dueDate.getDate() + 1); - } - - return { - title: item.content.title, - url: item.content.url, - assignedUsers, - labels, - dueDate: dueDate, - status: status, - }; - }) - .sort(sortByDate); -}; - -export const sortByDate = (item1: Item, item2: Item): number => { - if (item1.dueDate === undefined && item2.dueDate === undefined) return 0; - if (item1.dueDate === undefined) return 1; - if (item2.dueDate === undefined) return -1; - if (item1.dueDate === item2.dueDate) return 0; - return item1.dueDate < item2.dueDate ? -1 : 1; -}; +export * as GithubAPI from "./functions"; diff --git a/src/items/index.ts b/src/items/index.ts index a6ef3cf..b6b35ae 100644 --- a/src/items/index.ts +++ b/src/items/index.ts @@ -1,10 +1,14 @@ export interface Item { + githubProjectItemId: string; + githubIssueId: string; title: string; status: string; assignedUsers: string[]; labels?: string[]; dueDate?: Date; url?: string; + createdAt: Date; + updatedAt: Date; } export const filterByDateRange = ( diff --git a/src/items/services/ItemService.ts b/src/items/services/ItemService.ts new file mode 100644 index 0000000..b805b49 --- /dev/null +++ b/src/items/services/ItemService.ts @@ -0,0 +1,85 @@ +import { Ok, Result } from "ts-results"; +import { GithubAPI } from "@infrastructure/github"; +import { + PROJECT_ID, + DUE_DATE_FIELD_ID, +} from "@infrastructure/github/constants"; +import { Item } from ".."; + +export const create = async ({ + title, + description, + dueDate, +}: { + title: string; + description: string; + dueDate: Date; +}): Promise> => { + // TODO: it is worth considering how we should handle the case of when one of this operations fails... + // For now, we will just return the error of the first operation that fails, but these will leave dangling issues + // We should consider using background jobs to process the sequential operations or have jobs that retry the failed operations + + const result = await GithubAPI.createIssue({ + title, + description, + dueDate, + }); + + if (result.err) { + return result; + } + + const addItemResult = await GithubAPI.addIssueToProject({ + issueId: result.val.id, + projectId: PROJECT_ID, // TODO: item service should not be aware of Github domain constant + }); + + if (addItemResult.err) { + return addItemResult; + } + + const updateDueDate = await GithubAPI.updateProjectItemDueDate({ + projectId: PROJECT_ID, // TODO: item service should not be aware of Github domain constant + itemId: addItemResult.val.id, + fieldId: DUE_DATE_FIELD_ID, // TODO: item service should not be aware of Github domain constant + date: dueDate.toISOString(), + }); + + if (updateDueDate.err) { + return updateDueDate; + } + + const item: Item = { + githubIssueId: result.val.id, + githubProjectItemId: addItemResult.val.id, + title: result.val.title, + dueDate: dueDate, + assignedUsers: [], + labels: [], + url: result.val.url, + createdAt: new Date(result.val.createdAt), + updatedAt: new Date(result.val.updatedAt), + status: "Backlog", // TODO: we should set the status when we create the item + }; + + return Ok(item); +}; + +export const updateAssignee = async ({ + assigneeId, + itemId, +}: { + assigneeId: string; + itemId: string; +}): Promise> => { + const result = await GithubAPI.updateProjectItemAssignee({ + issueId: itemId, + assigneeId, + }); + + if (result.err) { + return result; + } + + return result; +}; diff --git a/src/items/services/index.ts b/src/items/services/index.ts new file mode 100644 index 0000000..518e1ba --- /dev/null +++ b/src/items/services/index.ts @@ -0,0 +1 @@ +export * as ItemService from "./ItemService"; diff --git a/src/reminders/tasks/dailyTasksReminder.ts b/src/reminders/tasks/dailyTasksReminder.ts index b83fac6..5948424 100644 --- a/src/reminders/tasks/dailyTasksReminder.ts +++ b/src/reminders/tasks/dailyTasksReminder.ts @@ -1,4 +1,4 @@ -import { fetchProjectV2Items } from "@infrastructure/github"; +import { GithubAPI } from "@infrastructure/github"; import { completeTaskReportMessage, simpleTaskReportMessage, @@ -27,7 +27,7 @@ export const dailyTasksReminder = async () => { event: "dailyTasksReminder.start", }); - const githubItemsResult = await fetchProjectV2Items(); + const githubItemsResult = await GithubAPI.fetchProjectItems(); if (githubItemsResult.err) { return githubItemsResult; } diff --git a/src/reminders/tasks/promotionReminder.ts b/src/reminders/tasks/promotionReminder.ts index c7cb89e..80271ef 100644 --- a/src/reminders/tasks/promotionReminder.ts +++ b/src/reminders/tasks/promotionReminder.ts @@ -1,4 +1,4 @@ -import { fetchProjectV2Items } from "@infrastructure/github"; +import { GithubAPI } from "@infrastructure/github"; import { urgentPromotionMessage } from "../messages"; import { sendDiscordItemMessage } from "@infrastructure/discord"; import { @@ -13,7 +13,7 @@ export const promotionReminder = async () => { event: "promotionReminder.start", }); - const githubItemsResult = await fetchProjectV2Items(); + const githubItemsResult = await GithubAPI.fetchProjectItems(); if (githubItemsResult.err) { return githubItemsResult; } diff --git a/test/factories/itemFactory.ts b/test/factories/itemFactory.ts index 8e57a0d..8ba52af 100644 --- a/test/factories/itemFactory.ts +++ b/test/factories/itemFactory.ts @@ -16,6 +16,10 @@ export const itemFactory = ({ url?: string; } = {}): Item => { return { + githubProjectItemId: "githubProjectItemId", + githubIssueId: "githubIssueId", + createdAt: new Date(), + updatedAt: new Date(), title: title ?? "title", status: status ?? "status", assignedUsers: assignedUsers ?? ["https://github.com/MathyouMB"], diff --git a/test/infrastructure/discord/commands/createIssue.test.ts b/test/infrastructure/discord/commands/createIssue.test.ts new file mode 100644 index 0000000..35edeaa --- /dev/null +++ b/test/infrastructure/discord/commands/createIssue.test.ts @@ -0,0 +1,125 @@ +import { ItemService } from "@src/items/services"; +import { Ok, Err } from "ts-results"; +import { + execute, + handleModalSubmit, +} from "@infrastructure/discord/commands/createIssue"; +import { promptAssigneeSelection } from "@infrastructure/discord/interactions"; +import { can } from "@infrastructure/discord/authz"; +import { CommandInteraction, ModalSubmitInteraction } from "discord.js"; + +jest.mock("@src/items/services", () => ({ + ItemService: { + create: jest.fn(), + }, +})); + +jest.mock("@infrastructure/discord/interactions", () => ({ + promptAssigneeSelection: jest.fn(), +})); + +jest.mock("@infrastructure/discord/authz", () => ({ + can: jest.fn(), +})); + +const mockShowModal = jest.fn(); +const mockReply = jest.fn(); + +describe("create-issue slash command", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("execute", () => { + it("will block unauthorized users", async () => { + (can as jest.Mock).mockReturnValue(false); + + const interaction = { + user: { id: "unauthorized" }, + reply: mockReply, + } as unknown as CommandInteraction; + + await execute(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: "You do not have permission to create an issue.", + ephemeral: true, + }); + }); + + it("will show modal for authorized users", async () => { + (can as jest.Mock).mockReturnValue(true); + + const interaction = { + user: { id: "authorized" }, + showModal: mockShowModal, + reply: mockReply, + } as unknown as CommandInteraction; + + await execute(interaction); + + expect(mockShowModal).toHaveBeenCalled(); + }); + }); + + describe("handleModalSubmit", () => { + const interaction = { + fields: { + getTextInputValue: jest.fn(), + }, + reply: mockReply, + } as unknown as ModalSubmitInteraction; + + it("will reject invalid due date format", async () => { + interaction.fields.getTextInputValue = jest.fn((key) => + key === "dueDate" ? "bad-date" : "test value", + ); + + await expect(() => handleModalSubmit(interaction)).rejects.toThrow( + "Invalid due date format. Please use yyyy-mm-dd.", + ); + }); + + it("will handle error from ItemService.create", async () => { + interaction.fields.getTextInputValue = jest.fn((key) => { + if (key === "dueDate") return "2025-05-17"; + return "test value"; + }); + + (ItemService.create as jest.Mock).mockResolvedValue( + Err(new Error("creation failed")), + ); + + await handleModalSubmit(interaction); + + expect(ItemService.create).toHaveBeenCalledWith({ + title: "test value", + description: "test value", + dueDate: new Date("2025-05-17"), + }); + + expect(mockReply).toHaveBeenCalledWith({ + content: "Failed to create issue. Please try again.", + ephemeral: true, + }); + }); + + it("will call promptAssigneeSelection on success", async () => { + interaction.fields.getTextInputValue = jest.fn((key) => { + if (key === "dueDate") return "2025-05-17"; + return "test value"; + }); + + (ItemService.create as jest.Mock).mockResolvedValue( + Ok({ githubIssueId: "abc-123" }), + ); + + await handleModalSubmit(interaction); + + expect(promptAssigneeSelection).toHaveBeenCalledWith( + interaction, + "abc-123", + ); + }); + }); +}); diff --git a/test/infrastructure/discord/commands/myIssues.test.ts b/test/infrastructure/discord/commands/myIssues.test.ts new file mode 100644 index 0000000..fd18211 --- /dev/null +++ b/test/infrastructure/discord/commands/myIssues.test.ts @@ -0,0 +1,172 @@ +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"; + +jest.mock("@infrastructure/github", () => ({ + GithubAPI: { + fetchProjectItems: jest.fn(), + }, +})); + +jest.mock("@infrastructure/discord/authz", () => ({ + can: jest.fn(), +})); + +jest.mock("@infrastructure/discord/builders", () => ({ + buildIssueButtonRow: jest.fn(() => "[buttons]"), +})); + +jest.mock("@infrastructure/discord/webhookMessages", () => ({ + formatDiscordDate: jest.fn((date) => `Formatted(${date})`), +})); + +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" }, + options: { getInteger: jest.fn(() => null), ...options }, + reply: mockReply, + deferReply: mockDeferReply, + editReply: mockEditReply, + followUp: mockFollowUp, + }); + + const defaultItem = (overrides = {}) => ({ + title: "Issue Title", + url: "https://github.com/test", + githubIssueId: "123", + assignedUsers: ["https://github.com/test-user"], + createdAt: new Date().toISOString(), + dueDate: "2025-05-20", + status: "Open", + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will block unauthorized users", async () => { + (can as jest.Mock).mockReturnValue(false); + const interaction = makeInteraction(); + + await execute(interaction as any); + + expect(mockReply).toHaveBeenCalledWith({ + content: "You do not have permission to create an issue.", + ephemeral: true, + }); + }); + + 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", + }, + ]); + + await execute(interaction as any); + + expect(mockReply).toHaveBeenCalledWith({ + content: expect.stringContaining("linked to a GitHub account"), + ephemeral: true, + }); + }); + + 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" }, + ]); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: true, + val: { message: "Boom" }, + }); + + const interaction = makeInteraction(); + + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("Failed to fetch issues"), + }); + }); + + 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" }, + ]); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [], + }); + + const interaction = makeInteraction(); + + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("no assigned issues"), + }); + }); + + 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" }, + ]); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [defaultItem({ assignedUsers: ["https://github.com/test-user"] })], + }); + + const interaction = makeInteraction({ getInteger: () => 0 }); + + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("**Issue #0**"), + components: ["[buttons]"], + }); + }); + + 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" }, + ]); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [defaultItem({ assignedUsers: ["https://github.com/test-user"] })], + }); + + const interaction = makeInteraction(); + + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("assigned issue(s):\n\n`0`"), + }); + }); +}); diff --git a/test/infrastructure/discord/commands/unassignedIssues.test.ts b/test/infrastructure/discord/commands/unassignedIssues.test.ts new file mode 100644 index 0000000..6fb9eed --- /dev/null +++ b/test/infrastructure/discord/commands/unassignedIssues.test.ts @@ -0,0 +1,139 @@ +import { execute } from "@infrastructure/discord/commands/unassignedIssues"; +import { GithubAPI } from "@infrastructure/github"; +import { can } from "@infrastructure/discord/authz"; +import { filterForUnassigned } from "@src/items"; +import { buildIssueButtonRow } from "@infrastructure/discord/builders"; +import { formatDiscordDate } from "@infrastructure/discord/webhookMessages"; + +jest.mock("@infrastructure/github", () => ({ + GithubAPI: { + fetchProjectItems: jest.fn(), + }, +})); + +jest.mock("@infrastructure/discord/authz", () => ({ + can: jest.fn(), +})); + +jest.mock("@src/items", () => ({ + filterForUnassigned: jest.fn(), +})); + +jest.mock("@infrastructure/discord/builders", () => ({ + buildIssueButtonRow: jest.fn(() => "[buttons]"), +})); + +jest.mock("@infrastructure/discord/webhookMessages", () => ({ + formatDiscordDate: jest.fn((date) => `Formatted(${date})`), +})); + +describe("unassigned-issues command", () => { + const mockReply = jest.fn(); + const mockEditReply = jest.fn(); + const mockDeferReply = jest.fn(); + const mockFollowUp = jest.fn(); + + const makeInteraction = (options = {}) => ({ + user: { id: "user-123" }, + options: { + getString: jest.fn(() => "today"), + getInteger: jest.fn(() => null), + ...options, + }, + reply: mockReply, + deferReply: mockDeferReply, + editReply: mockEditReply, + followUp: mockFollowUp, + }); + + const defaultItem = (overrides = {}) => ({ + title: "Unassigned Issue", + url: "https://github.com/test", + githubIssueId: "456", + assignedUsers: [], + createdAt: new Date().toISOString(), + dueDate: "2025-06-01", + status: "Open", + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will block unauthorized users", async () => { + (can as jest.Mock).mockReturnValue(false); + const interaction = makeInteraction(); + + await execute(interaction as any); + + expect(mockReply).toHaveBeenCalledWith({ + content: "You do not have permission to view issues.", + ephemeral: true, + }); + }); + + it("will show error if GitHub API fails", async () => { + (can as jest.Mock).mockReturnValue(true); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: true, + val: { message: "fail" }, + }); + + const interaction = makeInteraction(); + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("Failed to fetch issues"), + }); + }); + + it("will show message if no unassigned issues are found", async () => { + (can as jest.Mock).mockReturnValue(true); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [], + }); + (filterForUnassigned as jest.Mock).mockReturnValue([]); + + const interaction = makeInteraction(); + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("No unassigned issues found"), + }); + }); + + it("will return a specific unassigned issue by index", async () => { + (can as jest.Mock).mockReturnValue(true); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [defaultItem()], + }); + (filterForUnassigned as jest.Mock).mockReturnValue([defaultItem()]); + + const interaction = makeInteraction({ getInteger: () => 0 }); + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("**Issue #0**"), + components: ["[buttons]"], + }); + }); + + it("will show an index list of unassigned issues when no index is provided", async () => { + (can as jest.Mock).mockReturnValue(true); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + err: false, + val: [defaultItem()], + }); + (filterForUnassigned as jest.Mock).mockReturnValue([defaultItem()]); + + const interaction = makeInteraction(); + await execute(interaction as any); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: expect.stringContaining("unassigned issue(s) found:\n\n`0`"), + }); + }); +}); diff --git a/test/infrastructure/discord/interactions/assigneeSelectInteraction.test.ts b/test/infrastructure/discord/interactions/assigneeSelectInteraction.test.ts new file mode 100644 index 0000000..441ad00 --- /dev/null +++ b/test/infrastructure/discord/interactions/assigneeSelectInteraction.test.ts @@ -0,0 +1,105 @@ +import { ItemService } from "@src/items/services"; + +// Define IDs +const discordId = "147881865548791808"; +const githubId = "MDQ6VXNlcjQzMjIzNjgy"; +const githubUsername = "MathyouMB"; + +// Manual mock of ItemService +jest.mock("@src/items/services", () => ({ + ItemService: { + updateAssignee: jest.fn(), + }, +})); + +// Use dynamic mocking for githubDiscordMapJson +beforeAll(() => { + jest.doMock("../../../../data/githubDiscordMap.json", () => { + return { + [githubUsername]: { + githubUsername, + githubId, + discordId, + }, + }; + }); +}); + +import { assigneeSelectInteraction } from "@infrastructure/discord/interactions/assigneeSelectInteraction"; + +describe("assigneeSelectInteraction", () => { + const mockReply = jest.fn(); + const mockUpdate = jest.fn(); + + const makeInteraction = ( + customId = "issue:assignee:select:issue-123", + values = [discordId], + ) => ({ + customId, + values, + reply: mockReply, + update: mockUpdate, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will throw an error for invalid customId format", async () => { + const interaction = makeInteraction("invalid:format"); + + await expect(() => + assigneeSelectInteraction(interaction as any), + ).rejects.toThrow("Invalid customId format"); + }); + + it("will show error if Discord ID is not found in GitHub map", async () => { + const interaction = makeInteraction("issue:assignee:select:issue-789", [ + "not-in-map", + ]); + + await assigneeSelectInteraction(interaction as any); + + expect(mockReply).toHaveBeenCalledWith({ + content: "❌ Unable to find linked GitHub account for selected user.", + ephemeral: true, + }); + }); + + it("will show error if updateAssignee fails", async () => { + const interaction = makeInteraction("issue:assignee:select:issue-001"); + + (ItemService.updateAssignee as jest.Mock).mockResolvedValue({ err: true }); + + await assigneeSelectInteraction(interaction as any); + + expect(ItemService.updateAssignee).toHaveBeenCalledWith({ + itemId: "issue-001", + assigneeId: githubId, + }); + + expect(mockReply).toHaveBeenCalledWith({ + content: + "❌ Failed to update assignee. Cannot assign to Draft Issues (yet).", + ephemeral: true, + }); + }); + + it("will update message if assignee update succeeds", async () => { + const interaction = makeInteraction("issue:assignee:select:issue-002"); + + (ItemService.updateAssignee as jest.Mock).mockResolvedValue({ err: false }); + + await assigneeSelectInteraction(interaction as any); + + expect(ItemService.updateAssignee).toHaveBeenCalledWith({ + itemId: "issue-002", + assigneeId: githubId, + }); + + expect(mockUpdate).toHaveBeenCalledWith({ + content: `**Assigned**: <@${discordId}>`, + components: [], + }); + }); +}); diff --git a/test/infrastructure/discord/interactions/issueButtonInteraction.test.ts b/test/infrastructure/discord/interactions/issueButtonInteraction.test.ts new file mode 100644 index 0000000..d7ad0c9 --- /dev/null +++ b/test/infrastructure/discord/interactions/issueButtonInteraction.test.ts @@ -0,0 +1,88 @@ +import { issueButtonInteraction } from "@infrastructure/discord/interactions/issueButtonInteraction"; +import { promptAssigneeSelection } from "@infrastructure/discord/interactions/promptAssigneeSelection"; + +jest.mock( + "@infrastructure/discord/interactions/promptAssigneeSelection", + () => ({ + promptAssigneeSelection: jest.fn(), + }), +); + +describe("issueButtonInteraction", () => { + const mockReply = jest.fn(); + + const makeInteraction = (customId: string) => + ({ + isButton: () => true, + customId, + reply: mockReply, + }) as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will ignore non-button interactions", async () => { + const interaction = { + isButton: () => false, + } as any; + + await issueButtonInteraction(interaction); + expect(mockReply).not.toHaveBeenCalled(); + }); + + it("will respond with error if githubIssueId is missing", async () => { + const interaction = makeInteraction("issue:edit:"); + + await issueButtonInteraction(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: "⚠️ Invalid button ID.", + ephemeral: true, + }); + }); + + it("will respond with edit message", async () => { + const interaction = makeInteraction("issue:edit:abc-123"); + + await issueButtonInteraction(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: "✏️ Editing issue with ID `abc-123` (not yet implemented).", + ephemeral: true, + }); + }); + + it("will call promptAssigneeSelection on assign", async () => { + const interaction = makeInteraction("issue:assign:abc-123"); + + await issueButtonInteraction(interaction); + + expect(promptAssigneeSelection).toHaveBeenCalledWith( + interaction, + "abc-123", + ); + }); + + it("will respond with delete message", async () => { + const interaction = makeInteraction("issue:delete:abc-123"); + + await issueButtonInteraction(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: "🗑️ Deleting issue `abc-123` (not yet implemented).", + ephemeral: true, + }); + }); + + it("will respond with unknown action", async () => { + const interaction = makeInteraction("issue:unknown:abc-123"); + + await issueButtonInteraction(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: "❌ Unknown action: `unknown`", + ephemeral: true, + }); + }); +}); diff --git a/test/infrastructure/discord/interactions/promptAssigneeSelection.test.ts b/test/infrastructure/discord/interactions/promptAssigneeSelection.test.ts new file mode 100644 index 0000000..09453e8 --- /dev/null +++ b/test/infrastructure/discord/interactions/promptAssigneeSelection.test.ts @@ -0,0 +1,34 @@ +import { promptAssigneeSelection } from "@infrastructure/discord/interactions/promptAssigneeSelection"; +import { buildAssigneeSelectionRow } from "@infrastructure/discord/builders"; + +jest.mock("@infrastructure/discord/builders", () => ({ + buildAssigneeSelectionRow: jest.fn(), +})); + +describe("promptAssigneeSelection", () => { + const mockReply = jest.fn(); + const mockInteraction = { + reply: mockReply, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will reply with an assignee selection row", async () => { + const githubIssueId = "abc-123"; + const fakeRow = { type: "fake-row" }; + + (buildAssigneeSelectionRow as jest.Mock).mockReturnValue(fakeRow); + + await promptAssigneeSelection(mockInteraction as any, githubIssueId); + + expect(buildAssigneeSelectionRow).toHaveBeenCalledWith(githubIssueId); + + expect(mockReply).toHaveBeenCalledWith({ + content: "Select an assignee for this issue:", + components: [fakeRow], + ephemeral: true, + }); + }); +}); diff --git a/test/items/services/ItemService.test.ts b/test/items/services/ItemService.test.ts new file mode 100644 index 0000000..41c5acc --- /dev/null +++ b/test/items/services/ItemService.test.ts @@ -0,0 +1,139 @@ +import { ItemService } from "@src/items/services"; +import { GithubAPI } from "@infrastructure/github"; +import { Ok, Err } from "ts-results"; +import { + PROJECT_ID, + DUE_DATE_FIELD_ID, +} from "@infrastructure/github/constants"; + +jest.mock("@infrastructure/github", () => ({ + GithubAPI: { + createIssue: jest.fn(), + addIssueToProject: jest.fn(), + updateProjectItemDueDate: jest.fn(), + updateProjectItemAssignee: jest.fn(), + }, +})); + +const mockIssue = { + id: "issue-123", + title: "Mock Issue Title", + url: "https://github.com/mock/issue", + createdAt: "2025-05-01T00:00:00Z", + updatedAt: "2025-05-02T00:00:00Z", +}; +const mockItem = { id: "item-456" }; +const mockSuccessResult = Ok({ success: true }); + +describe("create", () => { + const title = "Test Issue"; + const description = "This is a test"; + const dueDate = new Date("2025-05-17T12:00:00Z"); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will return success if all steps succeed", async () => { + (GithubAPI.createIssue as jest.Mock).mockResolvedValue(Ok(mockIssue)); + (GithubAPI.addIssueToProject as jest.Mock).mockResolvedValue(Ok(mockItem)); + (GithubAPI.updateProjectItemDueDate as jest.Mock).mockResolvedValue( + mockSuccessResult, + ); + + const result = await ItemService.create({ title, description, dueDate }); + + expect(GithubAPI.createIssue).toHaveBeenCalledWith({ + title, + description, + dueDate, + }); + expect(GithubAPI.addIssueToProject).toHaveBeenCalledWith({ + issueId: mockIssue.id, + projectId: PROJECT_ID, + }); + expect(GithubAPI.updateProjectItemDueDate).toHaveBeenCalledWith({ + projectId: PROJECT_ID, + itemId: mockItem.id, + fieldId: DUE_DATE_FIELD_ID, + date: dueDate.toISOString(), + }); + + expect(result.ok).toBe(true); + }); + + it("will return error if createIssue fails", async () => { + const error = new Error("createIssue failed"); + (GithubAPI.createIssue as jest.Mock).mockResolvedValue(Err(error)); + + const result = await ItemService.create({ title, description, dueDate }); + + expect(result.err).toBe(true); + expect(result.val).toEqual(error); + expect(GithubAPI.addIssueToProject).not.toHaveBeenCalled(); + expect(GithubAPI.updateProjectItemDueDate).not.toHaveBeenCalled(); + }); + + it("will return error if addIssueToProject fails", async () => { + const error = new Error("addIssueToProject failed"); + (GithubAPI.createIssue as jest.Mock).mockResolvedValue(Ok(mockIssue)); + (GithubAPI.addIssueToProject as jest.Mock).mockResolvedValue(Err(error)); + + const result = await ItemService.create({ title, description, dueDate }); + + expect(result.err).toBe(true); + expect(result.val).toEqual(error); + expect(GithubAPI.updateProjectItemDueDate).not.toHaveBeenCalled(); + }); + + it("will return error if updateProjectItemDueDate fails", async () => { + const error = new Error("updateProjectItemDueDate failed"); + (GithubAPI.createIssue as jest.Mock).mockResolvedValue(Ok(mockIssue)); + (GithubAPI.addIssueToProject as jest.Mock).mockResolvedValue(Ok(mockItem)); + (GithubAPI.updateProjectItemDueDate as jest.Mock).mockResolvedValue( + Err(error), + ); + + const result = await ItemService.create({ title, description, dueDate }); + + expect(result.err).toBe(true); + expect(result.val).toEqual(error); + }); +}); + +describe("updateAssignee", () => { + const assigneeId = "MDQ6VXNlcjQzMjIzNjgy"; + const itemId = "issue-123"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("will return success if GitHubAPI.updateProjectItemAssignee succeeds", async () => { + const mockResult = Ok({ status: "success" }); + (GithubAPI.updateProjectItemAssignee as jest.Mock).mockResolvedValue( + mockResult, + ); + + const result = await ItemService.updateAssignee({ assigneeId, itemId }); + + expect(GithubAPI.updateProjectItemAssignee).toHaveBeenCalledWith({ + issueId: itemId, + assigneeId, + }); + expect(result.ok).toBe(true); + expect(result.val).toEqual({ status: "success" }); + }); + + it("will return error if GitHubAPI.updateProjectItemAssignee fails", async () => { + const error = new Error("updateProjectItemAssignee failed"); + (GithubAPI.updateProjectItemAssignee as jest.Mock).mockResolvedValue( + Err(error), + ); + + const result = await ItemService.updateAssignee({ assigneeId, itemId }); + + expect(result.err).toBe(true); + expect(result.val).toEqual(error); + }); +}); diff --git a/test/reminders/tasks/dailyTasksReminder.test.ts b/test/reminders/tasks/dailyTasksReminder.test.ts index afcb45e..3ca1aec 100644 --- a/test/reminders/tasks/dailyTasksReminder.test.ts +++ b/test/reminders/tasks/dailyTasksReminder.test.ts @@ -1,5 +1,5 @@ import { dailyTasksReminder } from "@src/reminders/tasks/dailyTasksReminder"; -import { fetchProjectV2Items } from "@infrastructure/github"; +import { GithubAPI } from "@infrastructure/github"; import { sendDiscordItemMessage } from "@infrastructure/discord"; import { completeTaskReportMessage, @@ -13,7 +13,11 @@ import { } from "@src/items"; // Mock all external dependencies -jest.mock("@infrastructure/github"); +jest.mock("@infrastructure/github", () => ({ + GithubAPI: { + fetchProjectItems: jest.fn(), + }, +})); jest.mock("@infrastructure/discord"); jest.mock("@src/reminders/messages"); jest.mock("@src/items"); @@ -35,13 +39,15 @@ describe("dailyTasksReminder", () => { it("will send a complete report on Tuesday", async () => { mockDayOfWeek(2); // Tuesday - (fetchProjectV2Items as jest.Mock).mockResolvedValue({ val: mockItems }); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + val: mockItems, + }); (completeTaskReportMessage as jest.Mock).mockReturnValue("full message"); (sendDiscordItemMessage as jest.Mock).mockResolvedValue({ ok: true }); const result = await dailyTasksReminder(); - expect(fetchProjectV2Items).toHaveBeenCalled(); + expect(GithubAPI.fetchProjectItems).toHaveBeenCalled(); expect(completeTaskReportMessage).toHaveBeenCalledWith({ urgentItems: ["urgent"], unassignedItems: ["unassigned"], @@ -53,7 +59,9 @@ describe("dailyTasksReminder", () => { it("will send a simple report on Wednesday", async () => { mockDayOfWeek(3); // Wednesday - (fetchProjectV2Items as jest.Mock).mockResolvedValue({ val: mockItems }); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + val: mockItems, + }); (simpleTaskReportMessage as jest.Mock).mockReturnValue("simple message"); (sendDiscordItemMessage as jest.Mock).mockResolvedValue({ ok: true }); @@ -67,13 +75,13 @@ describe("dailyTasksReminder", () => { expect(result).toEqual({ ok: true }); }); - it("will return early if fetchProjectV2Items fails", async () => { + it("will return early if fetchProjectItems fails", async () => { const error = { err: "fetch failed" }; - (fetchProjectV2Items as jest.Mock).mockResolvedValue(error); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue(error); const result = await dailyTasksReminder(); - expect(fetchProjectV2Items).toHaveBeenCalled(); + expect(GithubAPI.fetchProjectItems).toHaveBeenCalled(); expect(sendDiscordItemMessage).not.toHaveBeenCalled(); expect(result).toEqual(error); }); diff --git a/test/reminders/tasks/promotionReminder.test.ts b/test/reminders/tasks/promotionReminder.test.ts index b2a2954..9fcde04 100644 --- a/test/reminders/tasks/promotionReminder.test.ts +++ b/test/reminders/tasks/promotionReminder.test.ts @@ -1,5 +1,5 @@ import { promotionReminder } from "@src/reminders/tasks/promotionReminder"; -import { fetchProjectV2Items } from "@infrastructure/github"; +import { GithubAPI } from "@infrastructure/github"; import { sendDiscordItemMessage } from "@infrastructure/discord"; import { urgentPromotionMessage } from "@src/reminders/messages"; import { @@ -9,7 +9,11 @@ import { } from "@src/items"; // Mock dependencies -jest.mock("@infrastructure/github"); +jest.mock("@infrastructure/github", () => ({ + GithubAPI: { + fetchProjectItems: jest.fn(), + }, +})); jest.mock("@infrastructure/discord"); jest.mock("@src/reminders/messages"); jest.mock("@src/items"); @@ -27,36 +31,40 @@ describe("promotionReminder", () => { (filterByLabel as jest.Mock).mockReturnValue(mockLabeledItems); }); - it("will return early if fetchProjectV2Items fails", async () => { + it("will return early if fetchProjectItems fails", async () => { const error = { err: "Failed to fetch" }; - (fetchProjectV2Items as jest.Mock).mockResolvedValue(error); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue(error); const result = await promotionReminder(); - expect(fetchProjectV2Items).toHaveBeenCalled(); + expect(GithubAPI.fetchProjectItems).toHaveBeenCalled(); expect(sendDiscordItemMessage).not.toHaveBeenCalled(); expect(result).toEqual(error); }); it("will return null if there are no matching labeled items", async () => { - (fetchProjectV2Items as jest.Mock).mockResolvedValue({ val: mockItems }); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + val: mockItems, + }); (filterByLabel as jest.Mock).mockReturnValue([]); const result = await promotionReminder(); - expect(fetchProjectV2Items).toHaveBeenCalled(); + expect(GithubAPI.fetchProjectItems).toHaveBeenCalled(); expect(sendDiscordItemMessage).not.toHaveBeenCalled(); expect(result).toBeNull(); }); it("will send a Discord message if matching items exist", async () => { - (fetchProjectV2Items as jest.Mock).mockResolvedValue({ val: mockItems }); + (GithubAPI.fetchProjectItems as jest.Mock).mockResolvedValue({ + val: mockItems, + }); (urgentPromotionMessage as jest.Mock).mockReturnValue("promotion message"); (sendDiscordItemMessage as jest.Mock).mockResolvedValue({ ok: true }); const result = await promotionReminder(); - expect(fetchProjectV2Items).toHaveBeenCalled(); + expect(GithubAPI.fetchProjectItems).toHaveBeenCalled(); expect(filterOutStatus).toHaveBeenCalledWith(mockItems, "Backlog"); expect(filterForTwentyFourHours).toHaveBeenCalledWith(mockItems); expect(filterByLabel).toHaveBeenCalledWith(mockUrgentItems, [ diff --git a/tsconfig.json b/tsconfig.json index b9e5c3c..686a793 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */