diff --git a/commands.json b/commands.json new file mode 100644 index 0000000..b0eb9cc --- /dev/null +++ b/commands.json @@ -0,0 +1,86 @@ +[ + { + "name": "onboard-mentee", + "description": "Onboard a new mentee to the nhcarrigan-mentorship GitHub org", + "options": [ + { + "name": "mentee", + "description": "The mentee's Discord user", + "type": 6, + "required": true + }, + { + "name": "mentee_name", + "description": "The mentee's full name", + "type": 3, + "required": true + }, + { + "name": "github_username", + "description": "The mentee's GitHub username", + "type": 3, + "required": true + } + ] + }, + { + "name": "create-task", + "description": "Create an AI-augmented task on Leantime", + "options": [ + { + "name": "title", + "description": "The task title", + "type": 3, + "required": true + }, + { + "name": "description", + "description": "Additional context for the task (AI will expand this)", + "type": 3, + "required": false + }, + { + "name": "priority", + "description": "Task priority level", + "type": 4, + "required": false, + "choices": [ + { "name": "Urgent", "value": 1 }, + { "name": "High", "value": 2 }, + { "name": "Medium", "value": 3 }, + { "name": "Low", "value": 4 } + ] + } + ] + }, + { + "name": "create-issue", + "description": "Create an AI-augmented issue on a Gitea repository", + "options": [ + { + "name": "owner", + "description": "The repository owner", + "type": 3, + "required": true + }, + { + "name": "repo", + "description": "The repository name", + "type": 3, + "required": true + }, + { + "name": "title", + "description": "The issue title", + "type": 3, + "required": true + }, + { + "name": "description", + "description": "Additional context for the issue (AI will expand this)", + "type": 3, + "required": false + } + ] + } +] diff --git a/package.json b/package.json index fc0e446..d370647 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "5.9.3" }, "dependencies": { + "@anthropic-ai/sdk": "0.78.0", "@nhcarrigan/discord-analytics": "0.0.6", "@nhcarrigan/logger": "1.1.1", "@retroachievements/api": "2.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24317fb..46226ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: 0.78.0 + version: 0.78.0 '@nhcarrigan/discord-analytics': specifier: 0.0.6 version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0) @@ -54,6 +57,15 @@ importers: packages: + '@anthropic-ai/sdk@0.78.0': + resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -62,6 +74,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@discordjs/builders@1.11.3': resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} engines: {node: '>=16.11.0'} @@ -1610,6 +1626,10 @@ packages: json-schema-ref-resolver@2.0.1: resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2176,6 +2196,9 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -2392,6 +2415,10 @@ packages: snapshots: + '@anthropic-ai/sdk@0.78.0': + dependencies: + json-schema-to-ts: 3.1.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -2400,6 +2427,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/runtime@7.28.6': {} + '@discordjs/builders@1.11.3': dependencies: '@discordjs/formatters': 0.6.1 @@ -4193,6 +4222,11 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -4813,6 +4847,8 @@ snapshots: toad-cache@3.7.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/prod.env b/prod.env index 80d88a0..5cfe48d 100644 --- a/prod.env +++ b/prod.env @@ -6,4 +6,7 @@ GH_PRIVATE_KEY="op://Environment Variables - Naomi/Amari/gh private key" GH_WEBHOOK_SECRET="op://Environment Variables - Naomi/Amari/gh webhook secret" BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth" BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token" -RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key" \ No newline at end of file +RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key" +LEANTIME_KEY="op://Environment Variables - Naomi/Amari/leantime key" +GITEA_KEY="op://Environment Variables - Naomi/Amari/gitea key" +ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" \ No newline at end of file diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts new file mode 100644 index 0000000..e252d66 --- /dev/null +++ b/src/commands/createIssue.ts @@ -0,0 +1,91 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { anthropic } from "../utils/anthropic.js"; +import { logger } from "../utils/logger.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +interface GiteaIssueResponse { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. + html_url: string; + number: number; +} + +/** + * @param interaction + */ +export const createIssue = async( + interaction: ChatInputCommandInteraction, +): Promise => { + if (interaction.user.id !== ids.users.naomi) { + await interaction.reply({ + content: "This command is restricted to Naomi.", + flags: [ MessageFlags.Ephemeral ], + }); + return; + } + + const owner = interaction.options.getString("owner", true); + const repo = interaction.options.getString("repo", true); + const title = interaction.options.getString("title", true); + const description = interaction.options.getString("description") ?? ""; + + await interaction.deferReply({ ephemeral: true }); + + const aiResponse = await anthropic.messages.create({ + max_tokens: 1000, + messages: [ + { + content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description + ? `\nAdditional context: ${description}` + : ""}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: "You are a helpful assistant that creates well-structured Gitea issue bodies in markdown. Include relevant sections like 'Description' and 'Acceptance Criteria'. Be clear and actionable. Return only the body text.", + }); + + const firstContent = aiResponse.content[0]; + const augmentedBody = firstContent.type === "text" + ? firstContent.text + : description; + + const response = await fetch( + `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, + { + body: JSON.stringify({ + body: augmentedBody, + title, + }), + headers: { + "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + await interaction.editReply({ + content: `❌ Failed to create issue: ${errorText}`, + }); + return; + } + + const data: GiteaIssueResponse = await response.json(); + + await logger.metric("created_issue", 1, { + repository: `${owner}/${repo}`, + title, + }); + await interaction.editReply({ + content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, + }); +}; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts new file mode 100644 index 0000000..ddc8eb2 --- /dev/null +++ b/src/commands/createTask.ts @@ -0,0 +1,100 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { anthropic } from "../utils/anthropic.js"; +import { logger } from "../utils/logger.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +interface LeantimeResponse { + error?: { message: string }; + result?: number; +} + +/** + * @param interaction + */ +export const createTask = async( + interaction: ChatInputCommandInteraction, +): Promise => { + if (interaction.user.id !== ids.users.naomi) { + await interaction.reply({ + content: "This command is restricted to Naomi.", + flags: [ MessageFlags.Ephemeral ], + }); + return; + } + + const title = interaction.options.getString("title", true); + const description = interaction.options.getString("description") ?? ""; + const priority = interaction.options.getInteger("priority") ?? 3; + + await interaction.deferReply({ ephemeral: true }); + + const aiResponse = await anthropic.messages.create({ + max_tokens: 500, + messages: [ + { + content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description + ? `\nAdditional context: ${description}` + : ""}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: "You are a helpful assistant that creates well-structured task descriptions. Be concise and actionable. Return only the description text with no extra formatting or headers.", + }); + + const firstContent = aiResponse.content[0]; + const augmentedDesc = firstContent.type === "text" + ? firstContent.text + : description; + + const response = await fetch("https://board.nhcarrigan.com/api/jsonrpc", { + body: JSON.stringify({ + id: `amari-task-${Date.now().toString()}`, + jsonrpc: "2.0", + method: "leantime.rpc.tickets.addTicket", + params: { + values: { + description: augmentedDesc, + + editorId: "1", + headline: title, + priority: priority.toString(), + + projectId: "1", + type: "task", + }, + }, + }), + headers: { + "Content-Type": "application/json", + "x-api-key": process.env.LEANTIME_KEY ?? "", + }, + method: "POST", + }); + + const data: LeantimeResponse = await response.json(); + + if (data.error !== undefined) { + await interaction.editReply({ + content: `❌ Failed to create task: ${data.error.message}`, + }); + return; + } + + const taskId = data.result; + const taskUrl = taskId !== undefined + ? `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}` + : "https://board.nhcarrigan.com"; + + await logger.metric("created_task", 1, { title }); + await interaction.editReply({ + content: `✅ Task created: **${title}**\n${taskUrl}`, + }); +}; diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts new file mode 100644 index 0000000..56a8732 --- /dev/null +++ b/src/commands/onboardMentee.ts @@ -0,0 +1,87 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; +import type { Amari } from "../interfaces/amari.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +/** + * @param amari + * @param interaction + */ +export const onboardMentee = async( + amari: Amari, + interaction: ChatInputCommandInteraction, +): Promise => { + if (interaction.user.id !== ids.users.naomi) { + await interaction.reply({ + content: "This command is restricted to Naomi.", + flags: [ MessageFlags.Ephemeral ], + }); + return; + } + + const menteeName = interaction.options.getString("mentee_name", true); + const githubUsername = interaction.options.getString("github_username", true); + const menteeUser = interaction.options.getUser("mentee", true); + const discordId = menteeUser.id; + + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: installation } = await amari.githubApp.octokit.rest.apps.getOrgInstallation({ + org: "nhcarrigan-mentorship", + }); + const mentorshipOctokit = await amari.githubApp.getInstallationOctokit(installation.id); + + let repoUrl: string; + try { + const { data: repoData } = await mentorshipOctokit.rest.repos.createInOrg({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit API field. + auto_init: true, + name: githubUsername, + org: "nhcarrigan-mentorship", + }); + repoUrl = repoData.html_url; + } catch { + repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`; + } + + await mentorshipOctokit.rest.repos.addCollaborator({ + owner: "nhcarrigan-mentorship", + permission: "maintain", + repo: githubUsername, + username: githubUsername, + }); + + const channel + = amari.discord.channels.cache.get(ids.channels.menteeChat) + ?? await amari.discord.channels.fetch(ids.channels.menteeChat); + + if (channel?.isSendable() !== true) { + await interaction.editReply({ + content: "Repo created but could not send Discord notification.", + }); + return; + } + + await channel.send({ + content: `Hey <@${discordId}>! I've created your mentorship repository: ${repoUrl}\n\nYou should have received an invitation to collaborate - please accept it to get started!`, + }); + + await logger.metric("onboarded_mentee", 1, { mentee: menteeName }); + await interaction.editReply({ + content: `✅ Successfully onboarded **${menteeName}**!\nRepository: ${repoUrl}`, + }); + } catch (error) { + await logger.log("error", `Failed to onboard mentee: ${String(error)}`); + await interaction.editReply({ + content: `❌ Failed to onboard mentee: ${String(error)}`, + }); + } +}; diff --git a/src/index.ts b/src/index.ts index cf6c89a..5aabaac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ import { } from "discord.js"; import { scheduleJob } from "node-schedule"; import { App } from "octokit"; +import { createIssue } from "./commands/createIssue.js"; +import { createTask } from "./commands/createTask.js"; +import { onboardMentee } from "./commands/onboardMentee.js"; import { ids } from "./config/ids.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { cacheData } from "./modules/cacheData.js"; @@ -60,6 +63,7 @@ const amari: Amari = { partials: [ Partials.Channel ], }), github: octokit, + githubApp, lastRssItems: { freeCodeCamp: null, hackerNews: null, @@ -114,6 +118,18 @@ amari.discord.on(Events.InteractionCreate, (interaction) => { } return void interaction.message.delete(); } + if (interaction.isChatInputCommand()) { + const { commandName } = interaction; + if (commandName === "onboard-mentee") { + return void onboardMentee(amari, interaction); + } + if (commandName === "create-task") { + return void createTask(interaction); + } + if (commandName === "create-issue") { + return void createIssue(interaction); + } + } if (interaction.isAutocomplete()) { return void interaction; } diff --git a/src/interfaces/amari.ts b/src/interfaces/amari.ts index 40b1c45..b901846 100644 --- a/src/interfaces/amari.ts +++ b/src/interfaces/amari.ts @@ -10,6 +10,7 @@ import type { App } from "octokit"; export interface Amari { discord: Client; github: App["octokit"]; + githubApp: App; lastRssItems: { freeCodeCamp: string | null; hackerNews: string | null; diff --git a/src/utils/anthropic.ts b/src/utils/anthropic.ts new file mode 100644 index 0000000..7c08b45 --- /dev/null +++ b/src/utils/anthropic.ts @@ -0,0 +1,11 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import Anthropic from "@anthropic-ai/sdk"; + +export const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_KEY ?? "", +});