diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts index e252d66..8609ec1 100644 --- a/src/commands/createIssue.ts +++ b/src/commands/createIssue.ts @@ -4,11 +4,10 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +import { MessageFlags, type ChatInputCommandInteraction } 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. @@ -16,8 +15,44 @@ interface GiteaIssueResponse { number: number; } +const issueSystemPrompt = "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."; + /** - * @param interaction + * Generates an AI-augmented Gitea issue body using Claude. + * @param description - Optional additional context for the issue. + * @param title - The subject of the Gitea issue. + * @returns The generated issue body text. + */ +const generateIssueBody = async( + description: string, + title: string, +): Promise => { + const aiResponse = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + 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: issueSystemPrompt, + }); + const [ firstContent ] = aiResponse.content; + return firstContent?.type === "text" + ? firstContent.text + : description; +}; + +/** + * Creates a Gitea issue using AI-augmented body content. + * @param interaction - The Discord slash command interaction. */ export const createIssue = async( interaction: ChatInputCommandInteraction, @@ -37,34 +72,19 @@ export const createIssue = async( 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 augmentedBody = await generateIssueBody(description, title); const response = await fetch( `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, { body: JSON.stringify({ - body: augmentedBody, - title, + body: augmentedBody, + title: title, }), headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. "Content-Type": "application/json", }, method: "POST", @@ -79,11 +99,12 @@ export const createIssue = async( return; } - const data: GiteaIssueResponse = await response.json(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + const data = await response.json() as GiteaIssueResponse; await logger.metric("created_issue", 1, { repository: `${owner}/${repo}`, - title, + title: 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 index ddc8eb2..1b206ec 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -4,19 +4,94 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +import { MessageFlags, type ChatInputCommandInteraction } 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; } +const taskSystemPrompt = "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."; + /** - * @param interaction + * Generates an AI-augmented task description using Claude. + * @param description - Optional additional context for the task. + * @param title - The subject of the Leantime task. + * @returns The generated task description text. + */ +const generateTaskDescription = async( + description: string, + title: string, +): Promise => { + const aiResponse = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + 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: taskSystemPrompt, + }); + const [ firstContent ] = aiResponse.content; + return firstContent.type === "text" + ? firstContent.text + : description; +}; + +/** + * Posts a task to the Leantime board via JSON-RPC. + * @param description - Body copy for the Leantime task. + * @param priority - The task priority level. + * @param title - The headline for the Leantime task. + * @returns The Leantime API response. + */ +const postLeantimeTask = async( + description: string, + priority: number, + title: string, +): Promise => { + 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: description, + editorId: "1", + headline: title, + priority: priority.toString(), + projectId: "1", + type: "task", + }, + }, + }), + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/json", + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "x-api-key": process.env.LEANTIME_KEY ?? "", + }, + method: "POST", + }); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + const data = await response.json() as LeantimeResponse; + return data; +}; + +/** + * Creates a Leantime task using AI-augmented description content. + * @param interaction - The Discord slash command interaction. */ export const createTask = async( interaction: ChatInputCommandInteraction, @@ -35,51 +110,8 @@ export const createTask = async( 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(); + const augmentedDesc = await generateTaskDescription(description, title); + const data = await postLeantimeTask(augmentedDesc, priority, title); if (data.error !== undefined) { await interaction.editReply({ @@ -89,9 +121,9 @@ export const createTask = async( } const taskId = data.result; - const taskUrl = taskId !== undefined - ? `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}` - : "https://board.nhcarrigan.com"; + const taskUrl = taskId === undefined + ? "https://board.nhcarrigan.com" + : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`; await logger.metric("created_task", 1, { title }); await interaction.editReply({ diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts index 56a8732..ac62f54 100644 --- a/src/commands/onboardMentee.ts +++ b/src/commands/onboardMentee.ts @@ -4,15 +4,52 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +import { MessageFlags, type ChatInputCommandInteraction } 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 + * Creates a mentee repository and configures collaborator access. + * @param amari - The Amari instance. + * @param githubUsername - The mentee's GitHub username. + * @returns The URL of the created or existing repository. + */ +const setupMenteeRepository = async( + amari: Amari, + githubUsername: string, +): Promise => { + const orgApps = amari.githubApp.octokit.rest.apps; + const { data: installation } = await orgApps.getOrgInstallation({ + org: "nhcarrigan-mentorship", + }); + const mentorshipOctokit + = await amari.githubApp.getInstallationOctokit(installation.id); + let repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`; + 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 { + // Repo likely already exists - use the default URL. + } + await mentorshipOctokit.rest.repos.addCollaborator({ + owner: "nhcarrigan-mentorship", + permission: "maintain", + repo: githubUsername, + username: githubUsername, + }); + return repoUrl; +}; + +/** + * Onboards a new mentee by creating their GitHub repository and notifying them. + * @param amari - The Amari instance. + * @param interaction - The Discord slash command interaction. */ export const onboardMentee = async( amari: Amari, @@ -29,35 +66,11 @@ export const onboardMentee = async( 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 repoUrl = await setupMenteeRepository(amari, githubUsername); const channel = amari.discord.channels.cache.get(ids.channels.menteeChat) @@ -71,7 +84,7 @@ export const onboardMentee = async( } 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!`, + content: `Hey <@${menteeUser.id}>! 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 }); diff --git a/src/index.ts b/src/index.ts index 5aabaac..1a2d6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ const amari: Amari = { partials: [ Partials.Channel ], }), github: octokit, - githubApp, + githubApp: githubApp, lastRssItems: { freeCodeCamp: null, hackerNews: null, diff --git a/src/utils/anthropic.ts b/src/utils/anthropic.ts index 7c08b45..fb73f4a 100644 --- a/src/utils/anthropic.ts +++ b/src/utils/anthropic.ts @@ -4,6 +4,7 @@ * @author Naomi Carrigan */ +// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK default export uses PascalCase. import Anthropic from "@anthropic-ai/sdk"; export const anthropic = new Anthropic({