generated from nhcarrigan/template
feat: add slash commands and context menu command #16
+46
-25
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user