generated from nhcarrigan/template
feat: add slash commands and context menu command #16
+46
-25
@@ -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<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(
|
||||
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}`,
|
||||
|
||||
+83
-51
@@ -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<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(
|
||||
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({
|
||||
|
||||
@@ -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<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(
|
||||
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 });
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ const amari: Amari = {
|
||||
partials: [ Partials.Channel ],
|
||||
}),
|
||||
github: octokit,
|
||||
githubApp,
|
||||
githubApp: githubApp,
|
||||
lastRssItems: {
|
||||
freeCodeCamp: null,
|
||||
hackerNews: null,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user