feat: add slash commands and context menu command #16

Merged
naomi merged 7 commits from feat/commands into main 2026-03-03 15:05:10 -08:00
5 changed files with 174 additions and 107 deletions
Showing only changes of commit d0aaa7ec2f - Show all commits
+46 -25
View File
@@ -4,11 +4,10 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { MessageFlags } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js"; import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { ChatInputCommandInteraction } from "discord.js";
interface GiteaIssueResponse { interface GiteaIssueResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field.
@@ -16,8 +15,44 @@ interface GiteaIssueResponse {
number: number; 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<string> => {
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( export const createIssue = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
@@ -37,34 +72,19 @@ export const createIssue = async(
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const aiResponse = await anthropic.messages.create({ const augmentedBody = await generateIssueBody(description, title);
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( const response = await fetch(
`https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`,
{ {
body: JSON.stringify({ body: JSON.stringify({
body: augmentedBody, body: augmentedBody,
title, title: title,
}), }),
headers: { headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method: "POST", method: "POST",
@@ -79,11 +99,12 @@ export const createIssue = async(
return; 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, { await logger.metric("created_issue", 1, {
repository: `${owner}/${repo}`, repository: `${owner}/${repo}`,
title, title: title,
}); });
await interaction.editReply({ await interaction.editReply({
content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`,
+83 -51
View File
@@ -4,19 +4,94 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { MessageFlags } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js"; import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { ChatInputCommandInteraction } from "discord.js";
interface LeantimeResponse { interface LeantimeResponse {
error?: { message: string }; error?: { message: string };
result?: number; 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<string> => {
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<LeantimeResponse> => {
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( export const createTask = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
@@ -35,51 +110,8 @@ export const createTask = async(
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const aiResponse = await anthropic.messages.create({ const augmentedDesc = await generateTaskDescription(description, title);
max_tokens: 500, const data = await postLeantimeTask(augmentedDesc, priority, title);
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) { if (data.error !== undefined) {
await interaction.editReply({ await interaction.editReply({
@@ -89,9 +121,9 @@ export const createTask = async(
} }
const taskId = data.result; const taskId = data.result;
const taskUrl = taskId !== undefined const taskUrl = taskId === undefined
? `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}` ? "https://board.nhcarrigan.com"
: "https://board.nhcarrigan.com"; : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`;
await logger.metric("created_task", 1, { title }); await logger.metric("created_task", 1, { title });
await interaction.editReply({ await interaction.editReply({
+43 -30
View File
@@ -4,15 +4,52 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { MessageFlags } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js"; import type { Amari } from "../interfaces/amari.js";
import type { ChatInputCommandInteraction } from "discord.js";
/** /**
* @param amari * Creates a mentee repository and configures collaborator access.
* @param interaction * @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<string> => {
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( export const onboardMentee = async(
amari: Amari, amari: Amari,
@@ -29,35 +66,11 @@ export const onboardMentee = async(
const menteeName = interaction.options.getString("mentee_name", true); const menteeName = interaction.options.getString("mentee_name", true);
const githubUsername = interaction.options.getString("github_username", true); const githubUsername = interaction.options.getString("github_username", true);
const menteeUser = interaction.options.getUser("mentee", true); const menteeUser = interaction.options.getUser("mentee", true);
const discordId = menteeUser.id;
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
try { try {
const { data: installation } = await amari.githubApp.octokit.rest.apps.getOrgInstallation({ const repoUrl = await setupMenteeRepository(amari, githubUsername);
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 const channel
= amari.discord.channels.cache.get(ids.channels.menteeChat) = amari.discord.channels.cache.get(ids.channels.menteeChat)
@@ -71,7 +84,7 @@ export const onboardMentee = async(
} }
await channel.send({ 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 }); await logger.metric("onboarded_mentee", 1, { mentee: menteeName });
+1 -1
View File
@@ -63,7 +63,7 @@ const amari: Amari = {
partials: [ Partials.Channel ], partials: [ Partials.Channel ],
}), }),
github: octokit, github: octokit,
githubApp, githubApp: githubApp,
lastRssItems: { lastRssItems: {
freeCodeCamp: null, freeCodeCamp: null,
hackerNews: null, hackerNews: null,
+1
View File
@@ -4,6 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK default export uses PascalCase.
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
export const anthropic = new Anthropic({ export const anthropic = new Anthropic({