diff --git a/package.json b/package.json index eb4a8dd..5b0cc91 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@nestjs/core": "10.3.9", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.3.9", + "@octokit/rest": "^21.0.0", "@sentry/integrations": "7.114.0", "@sentry/node": "7.118.0", "ascii-table3": "0.9.0", @@ -83,4 +84,4 @@ "@semantic-release/github" ] } -} \ No newline at end of file +} diff --git a/src/commands/gh.command.ts b/src/commands/gh.command.ts new file mode 100644 index 0000000..48f8c7b --- /dev/null +++ b/src/commands/gh.command.ts @@ -0,0 +1,203 @@ +import { Injectable } from "@nestjs/common"; +import { EmbedBuilder, codeBlock } from "discord.js"; +import { + Context, + Options, + SlashCommand, + type SlashCommandContext, +} from "necord"; +import { + ContributionScores, + ContributionTimeSpan, + GHService, +} from "src/services/gh.service"; +import { AsciiTable3 } from "ascii-table3"; +import { GHSetupModal } from "src/modals/GHSetup.modal"; +import { GHLeaderboardCommandDto } from "src/dto/GHLeaderboardCommandDto"; + +@Injectable() +export class GHCommands { + constructor(private readonly ghService: GHService) {} + + @SlashCommand({ + name: "ghsetup", + description: + "Provide the bot with a Github Personal Access Token and an organisation name to track contributions", + defaultMemberPermissions: ["Administrator"], + }) + public async ghsetup(@Context() [interaction]: SlashCommandContext) { + await interaction.showModal(GHSetupModal.getModal()); + } + + @SlashCommand({ + name: "contributing", + description: + "Get information on how to contribute to the organisation's projects", + }) + public async contributing(@Context() [interaction]: SlashCommandContext) { + const organisation = await this.ghService.get_organisation( + interaction.guildId! + ); + const embed = new EmbedBuilder().setTitle( + `Contributing to ${organisation} Projects` + ) + .setDescription(`Contributions to ${organisation} projects are always welcome! To contribute: +1. Find a project you're interested in on the [Github](https://github.com/${organisation}). +1. Find a feature you would like to add or a bug you would like to fix. This can either be something you've found yourself or something from the issues tab. This can be anything from a small typo fix to a large new feature. +1. [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) the repository and clone it to your local machine. +1. Get a development environment set up. Most of the projects will contain instructions in their READMEs. +1. Add your changes and test locally. Make sure to follow the project's coding style, add comments to anything that might be unclear, and run any tests or formatters that are specified for the project. Be tidy and make sure not to include any unnecessary changes. If you're adding multiple features or fixes, consider creating separate [branches](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches) and PRs for each. +1. Push your changes to your fork and create a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). Make sure to outline what you have done in the PR description. If you've fixed an issue, you can [reference it in your PR](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). +1. Wait for a maintainer to review your PR. They may ask for changes or approve it. + `); + + await interaction.reply({ + embeds: [embed], + }); + } + + @SlashCommand({ + name: "ghleaderboard", + description: + "Get the Github contribution score leaderboard for this server", + }) + public async ghleaderboard( + @Context() [interaction]: SlashCommandContext, + @Options() { period }: GHLeaderboardCommandDto + ) { + const guildId = interaction.guildId; + + interaction.reply({ + content: "Fetching...", + }); + + if (!guildId) { + await interaction.editReply({ + content: "This command can only be used in a guild", + }); + return; + } + + let scores = await this.ghService.get_scores(guildId, timespan(period)); + if (!scores) { + await interaction.editReply({ + content: "An error occurred while fetching the leaderboard", + }); + return; + } + + const table = await createTable(scores); + + const embed = new EmbedBuilder() + .setTitle( + `${periodName(period)} ${scores.organisation} Contribution Leaderboard` + ) + .setDescription( + `${codeBlock( + table + )}\nFor information on how to contribute, use the \`/contributing\` command.` + ); + + await interaction.editReply({ + content: "", + embeds: [embed], + }); + } +} + +/** + * Create ASCII table from contribution scores to display in Discord + */ +async function createTable(scores: ContributionScores) { + const members = Object.values(scores.members) + .sort((a, b) => b.score - a.score) + .filter((member) => member.mergedPrs > 0 || member.createdIssues > 0); + + const table = new AsciiTable3().setHeading( + "Rank", + "Name", + "Score", + "PRs", + "Issues" + ); + + for (const [i, member] of members.entries()) { + let name: string = member.githubUsername; + if (name.length > 16) { + name = `${name.substring(0, 16)}…`; + } + + table.addRow( + i + 1, + name, + member.score, + member.mergedPrs, + member.createdIssues + ); + } + + return table.toString(); +} + +/** + * Convert period parameter to ContributionTimeSpan + * @param period The period to include contributions from. e.g. all/a, month/m, week/w, year/y + * @returns ContributionTimeSpan interval starting from now and going back the queried period + */ +function timespan(period: string): ContributionTimeSpan { + const now = new Date(); + + period = periodFromShortForm(period); + + switch (period) { + case "month": { + const monthAgo = new Date(now); + monthAgo.setMonth(now.getMonth() - 1); + return { start: monthAgo }; + } + case "week": { + const monthAgo = new Date(now); + monthAgo.setDate(now.getDate() - 7); + return { start: monthAgo }; + } + case "year": { + const monthAgo = new Date(now); + monthAgo.setFullYear(now.getFullYear() - 1); + return { start: monthAgo }; + } + } + + return {}; +} + +/** + * Convert period parameter to human readable period name for leaderboard title + * @param period The period to include contributions from. e.g. all/a, month/m, week/w, year/y + */ +function periodName(period: string) { + period = periodFromShortForm(period); + + switch (period) { + case "month": + return "Monthly"; + case "week": + return "Weekly"; + case "year": + return "Yearly"; + default: + return "All Time"; + } +} + +function periodFromShortForm(period: string) { + switch (period) { + case "m": + return "month"; + case "w": + return "week"; + case "y": + return "year"; + } + + return period; +} diff --git a/src/db/migrations/0004_damp_brother_voodoo.sql b/src/db/migrations/0004_damp_brother_voodoo.sql new file mode 100644 index 0000000..7388423 --- /dev/null +++ b/src/db/migrations/0004_damp_brother_voodoo.sql @@ -0,0 +1,3 @@ +ALTER TABLE "guilds" ADD COLUMN "gh_organisation" text;--> statement-breakpoint +ALTER TABLE "guilds" ADD COLUMN "gh_api_token" text;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "cased_email" text; \ No newline at end of file diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..d26bdc1 --- /dev/null +++ b/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,199 @@ +{ + "id": "c567d657-5b6f-42aa-ba5c-ab6f7ad7a8e5", + "prevId": "d7b6277b-0ee9-407b-8e52-7acf392fccc5", + "version": "5", + "dialect": "pg", + "tables": { + "discordUsers": { + "name": "discordUsers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "guilds": { + "name": "guilds", + "schema": "", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "member_role": { + "name": "member_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aoc_leaderboard_url": { + "name": "aoc_leaderboard_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aoc_session_cookie": { + "name": "aoc_session_cookie", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "members_last_updated": { + "name": "members_last_updated", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "gh_organisation": { + "name": "gh_organisation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gh_api_token": { + "name": "gh_api_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "memberships": { + "name": "memberships", + "schema": "", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cased_email": { + "name": "cased_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "membership_type", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_guild_id_guilds_guild_id_fk": { + "name": "memberships_guild_id_guilds_guild_id_fk", + "tableFrom": "memberships", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_user_id_discordUsers_user_id_fk": { + "name": "memberships_user_id_discordUsers_user_id_fk", + "tableFrom": "memberships", + "tableTo": "discordUsers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_guild_id_email_pk": { + "name": "memberships_guild_id_email_pk", + "columns": [ + "guild_id", + "email" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": { + "membership_type": { + "name": "membership_type", + "values": { + "Staff": "Staff", + "Student": "Student", + "Alumni": "Alumni", + "Public": "Public" + } + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 3c23642..562ba58 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1708593814117, "tag": "0003_nervous_alex_wilder", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1719738368159, + "tag": "0004_damp_brother_voodoo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 2a767f8..03a6298 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -21,6 +21,10 @@ export const guilds = pgTable("guilds", { membersLastUpdated: date("members_last_updated", { mode: "string", }).defaultNow(), + /** Github organisation as it appears in URL */ + ghOrganisation: text("gh_organisation"), + /** Github API Token */ + ghApiToken: text("gh_api_token"), }); export const membershipTypeEnum = pgEnum("membership_type", [ @@ -76,7 +80,7 @@ export const memberships = pgTable( return { pk: primaryKey(table.guildId, table.email), }; - }, + } ); export const membershipsRelations = relations(memberships, ({ one }) => ({ diff --git a/src/discord.module.ts b/src/discord.module.ts index 3074631..56ba955 100644 --- a/src/discord.module.ts +++ b/src/discord.module.ts @@ -35,6 +35,9 @@ import { VerifyEmailButton } from "./buttons/EmailButton"; import { AoCService } from "./services/aoc.service"; import { AocSetupModal } from "./modals/AocSetup"; import { AoCCommands } from "./commands/aoc.command"; +import { GHCommands } from "./commands/gh.command"; +import { GHService } from "./services/gh.service"; +import { GHSetupModal } from "./modals/GHSetup.modal"; // import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; // @InjectDynamicProviders({ pattern: 'dist/commands/**/*.command.js' }) @@ -105,6 +108,9 @@ import { AoCCommands } from "./commands/aoc.command"; AoCService, AocSetupModal, AoCCommands, + GHService, + GHCommands, + GHSetupModal, ], controllers: [DiscordController], }) diff --git a/src/dto/GHLeaderboardCommandDto.ts b/src/dto/GHLeaderboardCommandDto.ts new file mode 100644 index 0000000..d26473d --- /dev/null +++ b/src/dto/GHLeaderboardCommandDto.ts @@ -0,0 +1,11 @@ +import { StringOption } from "necord"; + +export class GHLeaderboardCommandDto { + @StringOption({ + name: "period", + description: + "The period to include contributions from. e.g. all, month, week, year", + required: true, + }) + period: string; +} diff --git a/src/modals/GHSetup.modal.ts b/src/modals/GHSetup.modal.ts new file mode 100644 index 0000000..6e44468 --- /dev/null +++ b/src/modals/GHSetup.modal.ts @@ -0,0 +1,77 @@ +import { Injectable } from "@nestjs/common"; +import { Octokit } from "@octokit/rest"; +import { + ActionRowBuilder, + ModalActionRowComponentBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { Ctx, Modal, type ModalContext } from "necord"; +import { GHService } from "src/services/gh.service"; + +@Injectable() +export class GHSetupModal { + constructor(private readonly ghService: GHService) {} + + @Modal("gh-setup") + public async modal(@Ctx() [interaction]: ModalContext) { + const organisationName = + interaction.fields.getTextInputValue("organisation-name"); + const apiToken = interaction.fields.getTextInputValue("gh-api-token"); + const guild = interaction.guild; + + if (!guild) { + await interaction.reply({ + content: "This command can only be used in a guild", + ephemeral: true, + }); + return; + } + + // Validate the input + try { + const octokit = new Octokit({ auth: apiToken }); + await octokit.orgs.get({ org: organisationName }); + } catch (e) { + await interaction.reply({ + content: `Error: ${e.message}`, + ephemeral: true, + }); + return; + } + + await this.ghService.setup(guild.id, organisationName, apiToken); + + await interaction.reply({ + content: "Successfully setup contribution leaderboard for this server!", + ephemeral: true, + }); + } + + public static getModal() { + return new ModalBuilder() + .setTitle("Contribution Tracking Setup") + .setCustomId("gh-setup") + .setComponents([ + new ActionRowBuilder().addComponents([ + new TextInputBuilder() + .setCustomId("organisation-name") + .setLabel("Organisation Name") + .setPlaceholder( + "Enter the name of your github organisation as it appears in the url (e.g. 'progsoc')" + ) + .setRequired(true) + .setStyle(TextInputStyle.Paragraph), + ]), + new ActionRowBuilder().addComponents([ + new TextInputBuilder() + .setCustomId("gh-api-token") + .setLabel("Github API Token") + .setPlaceholder("Enter your Github API token.") + .setRequired(true) + .setStyle(TextInputStyle.Paragraph), + ]), + ]); + } +} diff --git a/src/services/gh.service.ts b/src/services/gh.service.ts new file mode 100644 index 0000000..4a02e6f --- /dev/null +++ b/src/services/gh.service.ts @@ -0,0 +1,321 @@ +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Global, Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { type Cache } from "cache-manager"; +import { eq } from "drizzle-orm"; +import { DATABASE_TOKEN, type Database } from "src/db/db.module"; +import { guilds } from "src/db/schema"; +import { Octokit } from "@octokit/rest"; + +/** + * The GH service is responsible for setting up GH for a guild and managing the leaderboard + */ +@Global() +@Injectable() +export class GHService { + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly configService: ConfigService, + @Inject(DATABASE_TOKEN) private readonly db: Database + ) {} + + /** + * Store the organisation name and api token for the guild + * @param guildId The ID of the guild the setup info was added in + * @param organisationName The provided Github organisation name + * @param apiToken The provided Github API token + */ + public async setup( + guildId: string, + organisationName: string, + apiToken: string + ) { + await this.db + .insert(guilds) + .values({ + guildId, + ghApiToken: apiToken, + ghOrganisation: organisationName, + }) + .onConflictDoUpdate({ + set: { + ghApiToken: apiToken, + ghOrganisation: organisationName, + }, + target: guilds.guildId, + }); + } + + /** + * @param guildId The ID of the guild to get the organisation for + * @returns The organisation name or null if an error occurred + */ + public async get_organisation(guildId: string): Promise { + const [guild] = await this.db + .select() + .from(guilds) + .where(eq(guilds.guildId, guildId)); + + if (!guild) { + return null; + } + + return guild.ghOrganisation; + } + + /** + * @param guildId The ID of the guild to get the contribution scores for + * @param timespan The timespan to include PRs and issues from when calculating scores + * @returns The contribution scores for the organisation or null if an error occurred + */ + public async get_scores( + guildId: string, + timespan?: ContributionTimeSpan + ): Promise { + const [guild] = await this.db + .select() + .from(guilds) + .where(eq(guilds.guildId, guildId)); + + if (!guild) { + throw new Error("Guild not found"); + } + + const token = guild.ghApiToken; + const organisation = guild.ghOrganisation; + + if (!token || !organisation) { + return null; + } + + try { + const items = await organisationItems(token, organisation, timespan); + return scoresFromItems(items, organisation); + } catch (e) { + console.error(e); + return null; + } + } +} + +/** + * Calculate the contribution scores for a list of PRs and issues + * @param items The PRs and issues to calculate scores for + * @param organisation The organisation the contributions are from + */ +function scoresFromItems( + items: (PullRequest | Issue)[], + organisation: string +): ContributionScores { + const scores: ContributionScores = { + organisation, + members: {}, + }; + + for (const item of items) { + if (!scores.members[item.author]) { + scores.members[item.author] = { + githubUsername: item.author, + score: 0, + mergedPrs: 0, + createdIssues: 0, + }; + } + + scores.members[item.author].score += scoreOf(item); + if ("merged" in item) { + if (item.merged) { + scores.members[item.author].mergedPrs++; + } + } else { + scores.members[item.author].createdIssues++; + } + } + + return scores; +} + +/** + * Calculate the amount of points a PR or issue is worth + */ +function scoreOf(item: PullRequest | Issue): number { + let score = 0; + + // If the item is a PR + if ("merged" in item) { + // Base score for PRs + score += 2; + + if (item.merged) { + // Bonus for merge + score += 5; + } + } else { + // Score for issues + score += 1; + } + return score; +} + +/** + * Fetch all issues an PRs for an organisation + * @param token The Github API token to use + * @param organisation The organisation to fetch contributions for + * @param timespan The timespan to include contributions from + * @returns An array of all issues and PRs for the organisation + */ +async function organisationItems( + token: string, + organisation: string, + timespan?: ContributionTimeSpan +): Promise<(PullRequest | Issue)[]> { + const octokit = new Octokit({ auth: token }); + + const repos = await organisationRepositories(organisation, octokit); + + let items = ( + await Promise.all( + repos.map(async (repo) => { + const prs = await repositoryPullRequests(organisation, repo, octokit); + const issues = await repositoryIssues(organisation, repo, octokit); + return [...prs, ...issues]; + }) + ) + ).flat(); + + if (timespan) { + if (timespan.start !== undefined) { + items = items.filter((item) => item.dateCreated >= timespan.start!); + } + if (timespan.end !== undefined) { + items = items.filter((item) => item.dateCreated <= timespan.end!); + } + } + + return items; +} + +/** + * Fetch the names of all repositories in an organisation + */ +async function organisationRepositories( + organisation: string, + octokit: Octokit +): Promise { + const res = await octokit.repos.listForOrg({ + org: organisation, + type: "public", + per_page: 100, + }); + + return res.data.map((repo) => repo.name); +} + +/** + * Fetch all PRs for a repository + */ +async function repositoryPullRequests( + owner: string, + repo: string, + octokit: Octokit +): Promise { + let page = 1; + let prs: PullRequest[] = []; + + while (true) { + let res = await octokit.pulls.list({ + owner, + repo, + state: "all", + per_page: 100, + page: page++, + }); + + if (res.data.length === 0) { + break; + } + + prs.push( + ...res.data + .filter((pr) => pr.user?.type === "User") + .map((pr) => ({ + author: pr.user?.login ?? "Unknown", + merged: pr.merged_at !== null, + dateCreated: new Date(pr.created_at), + dateMerged: !pr.merged_at ? undefined : new Date(pr.merged_at), + })) + ); + } + + return prs; +} + +/** + * Fetch all issues for a repository + */ +async function repositoryIssues( + owner: string, + repo: string, + octokit: Octokit +): Promise { + let page = 1; + let issues: Issue[] = []; + + while (true) { + let res = await octokit.issues.listForRepo({ + owner, + repo, + state: "all", + per_page: 100, + page: page++, + }); + + if (res.data.length === 0) { + break; + } + + issues.push( + ...res.data + .filter((issue) => !issue.pull_request && issue.user?.type === "User") + .map((issue) => ({ + author: issue.user?.login ?? "Unknown", + dateCreated: new Date(issue.created_at), + })) + ); + } + + return issues; +} + +interface PullRequest { + author: string; + merged: boolean; + dateCreated: Date; + dateMerged?: Date; +} + +interface Issue { + author: string; + dateCreated: Date; +} + +interface ContributionScoresMember { + githubUsername: string; + score: number; + mergedPrs: number; + createdIssues: number; +} + +export interface ContributionScores { + organisation: string; + members: Record; +} + +/** + * @member start The start of the timespan. If not provided, the start is unbounded. + * @member end The end of the timespan. If not provided, the end is unbounded. + */ +export interface ContributionTimeSpan { + start?: Date; + end?: Date; +}