From ec6a4469e1ffb83a7bc0f44a06bc16f3974fab12 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 14:09:08 -0700 Subject: [PATCH] feat: replace create-task and create-issue with unified create-ticket (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removes `/create-task` (LeanTime) and `/create-issue` (Gitea) slash commands - Introduces `/create-ticket` with a `platform` choice argument: **LeanTime**, **Asana**, **Gitea**, **GitHub** - User provides only a `description`; the AI generates both the title and a fleshed-out body - `owner` and `repo` arguments are optional but validated at runtime when Gitea or GitHub is selected - Adds `ASANA_KEY` to `prod.env` (1Password reference — key needs to be added to vault) - Command remains restricted to Naomi's user ID ## Test plan - [ ] Register updated `commands.json` against the Discord API - [ ] Add `ASANA_KEY` to 1Password vault at `op://Environment Variables - Naomi/Amari/asana key` - [ ] Test `/create-ticket platform:LeanTime description:...` creates a task on the LeanTime board - [ ] Test `/create-ticket platform:Asana description:...` creates a task in Naomi's Asana project - [ ] Test `/create-ticket platform:Gitea owner:nhcarrigan repo:amari description:...` creates a Gitea issue - [ ] Test `/create-ticket platform:GitHub owner:naomi-lgbt repo:... description:...` creates a GitHub issue - [ ] Test that omitting `owner`/`repo` for Gitea/GitHub returns a helpful error - [ ] Verify AI generates a sensible title and description from the raw description ✨ This issue was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/amari/pulls/18 Co-authored-by: Hikari Co-committed-by: Hikari --- commands.json | 65 ++---- prod.env | 1 + src/commands/createIssue.ts | 126 ---------- src/commands/createTask.ts | 131 ----------- src/commands/createTicket.ts | 319 ++++++++++++++++++++++++++ src/events/handleInteractionCreate.ts | 11 +- 6 files changed, 346 insertions(+), 307 deletions(-) delete mode 100644 src/commands/createIssue.ts delete mode 100644 src/commands/createTask.ts create mode 100644 src/commands/createTicket.ts diff --git a/commands.json b/commands.json index 67bbbfb..a4e05c6 100644 --- a/commands.json +++ b/commands.json @@ -1,56 +1,37 @@ [ { - "name": "create-issue", + "name": "create-ticket", "type": 1, - "description": "Creates a Gitea issue with an AI-generated body.", + "description": "Creates a ticket on the chosen platform with an AI-generated title and description.", "options": [ { - "name": "owner", - "description": "The owner of the repository.", + "name": "platform", + "description": "The platform to create the ticket on.", + "type": 3, + "required": true, + "choices": [ + { "name": "LeanTime", "value": "leantime" }, + { "name": "Asana", "value": "asana" }, + { "name": "Gitea", "value": "gitea" }, + { "name": "GitHub", "value": "github" } + ] + }, + { + "name": "description", + "description": "Describe what you need. The AI will generate a title and full description.", "type": 3, "required": true }, + { + "name": "owner", + "description": "The repository owner (required for Gitea/GitHub).", + "type": 3, + "required": false + }, { "name": "repo", - "description": "The name of the repository.", + "description": "The repository name (required for Gitea/GitHub).", "type": 3, - "required": true - }, - { - "name": "title", - "description": "The issue title.", - "type": 3, - "required": true - }, - { - "name": "description", - "description": "Optional additional context for the issue body.", - "type": 3, - "required": false - } - ] - }, - { - "name": "create-task", - "type": 1, - "description": "Creates a Leantime task with an AI-generated description.", - "options": [ - { - "name": "title", - "description": "The task title.", - "type": 3, - "required": true - }, - { - "name": "description", - "description": "Optional additional context for the task description.", - "type": 3, - "required": false - }, - { - "name": "priority", - "description": "The task priority level (1-5, default 3).", - "type": 4, "required": false } ] diff --git a/prod.env b/prod.env index 5cfe48d..1e570ad 100644 --- a/prod.env +++ b/prod.env @@ -9,4 +9,5 @@ BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token" 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" +ASANA_KEY="op://Environment Variables - Naomi/Amari/asana 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 deleted file mode 100644 index 07d5c28..0000000 --- a/src/commands/createIssue.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @copyright NHCarrigan - * @license Naomi's Public License - * @author Naomi Carrigan - */ - -import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; -import { ids } from "../config/ids.js"; -import { logger } from "../utils/logger.js"; -import { makeAiRequest } from "../utils/makeAiRequest.js"; - -interface GiteaIssueResponse { - // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. - html_url: string; - number: number; -} - -interface GiteaIssueOptions { - body: string; - owner: string; - repo: string; - title: string; -} - -const issueSystemPrompt = "Create well-structured Gitea issue bodies in" - + " markdown. Include relevant sections like 'Description' and" - + " 'Acceptance Criteria'. Be clear and actionable." - + " Return only the body text."; - -/** - * Generates an AI-augmented Gitea issue body. - * @param description - Optional additional context for the issue. - * @param title - The subject of the Gitea issue. - * @returns The generated issue body text, or the original description as fallback. - */ -const generateIssueBody = async( - description: string, - title: string, -): Promise => { - const result = await makeAiRequest({ - maxTokens: 1000, - systemPrompt: issueSystemPrompt, - userMessage: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - }); - return result ?? description; -}; - -/** - * Creates a Gitea issue via the API. - * @param options -- The issue fields to submit. - * @returns The created issue data. - */ -const postGiteaIssue = async( - options: GiteaIssueOptions, -): Promise => { - const response = await fetch( - `https://git.nhcarrigan.com/api/v1/repos/${options.owner}/${options.repo}/issues`, - { - body: JSON.stringify({ body: options.body, title: options.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", - }, - ); - if (!response.ok) { - throw new Error(await response.text()); - } - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. - return await response.json() as GiteaIssueResponse; -}; - -/** - * Creates a Gitea issue using AI-augmented body content. - * @param interaction - The Discord slash command 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({ flags: [ MessageFlags.Ephemeral ] }); - - try { - const augmentedBody = await generateIssueBody(description, title); - const data = await postGiteaIssue({ - body: augmentedBody, - owner: owner, - repo: repo, - title: title, - }); - await logger.metric("created_issue", 1, { - repository: `${owner}/${repo}`, - title: title, - }); - await interaction.editReply({ - content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, - }); - } catch (error) { - if (error instanceof Error) { - await logger.error("createIssue command", error); - } - const errorMessage = error instanceof Error - ? error.message - : "Unknown error"; - await interaction.editReply({ - content: `❌ Failed to create issue: ${errorMessage}`, - }); - } -}; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts deleted file mode 100644 index f4d4fe5..0000000 --- a/src/commands/createTask.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @copyright NHCarrigan - * @license Naomi's Public License - * @author Naomi Carrigan - */ - -import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; -import { ids } from "../config/ids.js"; -import { logger } from "../utils/logger.js"; -import { makeAiRequest } from "../utils/makeAiRequest.js"; - -interface LeantimeResponse { - error?: { message: string }; - result?: number; -} - -const taskSystemPrompt = "Create well-structured task descriptions." - + " Be concise and actionable." - + " Return only the description text with no extra formatting or headers."; - -/** - * Generates an AI-augmented task description. - * @param description - Optional additional context for the task. - * @param title - The subject of the Leantime task. - * @returns The generated task description text, or the original description as fallback. - */ -const generateTaskDescription = async( - description: string, - title: string, -): Promise => { - const result = await makeAiRequest({ - maxTokens: 500, - systemPrompt: taskSystemPrompt, - userMessage: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - }); - return result ?? 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, -): 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({ flags: [ MessageFlags.Ephemeral ] }); - - try { - const augmentedDesc = await generateTaskDescription(description, title); - const data = await postLeantimeTask(augmentedDesc, priority, title); - - 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" - : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`; - - await logger.metric("created_task", 1, { title }); - await interaction.editReply({ - content: `✅ Task created: **${title}**\n${taskUrl}`, - }); - } catch (error) { - if (error instanceof Error) { - await logger.error("createTask command", error); - } - await interaction.editReply({ - content: "❌ An unexpected error occurred while creating the task.", - }); - } -}; diff --git a/src/commands/createTicket.ts b/src/commands/createTicket.ts new file mode 100644 index 0000000..9311581 --- /dev/null +++ b/src/commands/createTicket.ts @@ -0,0 +1,319 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; +import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; +import { makeAiRequest } from "../utils/makeAiRequest.js"; +import type { Amari } from "../interfaces/amari.js"; + +interface GeneratedTicket { + body: string; + title: string; +} + +interface LeantimeResponse { + error?: { message: string }; + result?: number; +} + +interface GiteaIssueResponse { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. + html_url: string; + number: number; +} + +interface AsanaTaskResponse { + data: { + gid: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- Asana API field. + permalink_url: string; + }; +} + +interface PostOptions { + body: string; + owner: string; + repo: string; + title: string; +} + +interface RepoTicketOptions extends PostOptions { + amari: Amari; +} + +interface TicketRouteOptions { + amari: Amari; + owner: string; + platform: string; + repo: string; +} + +interface RepoValidationContext { + interaction: ChatInputCommandInteraction; + owner: string; + platform: string; + repo: string; +} + +const ticketSystemPrompt = "Generate a well-structured ticket. Return ONLY a" + + " valid JSON object with exactly two keys: \"title\" (a concise title" + + " under 80 characters) and \"body\" (a detailed markdown description" + + " with relevant sections such as Description and Acceptance Criteria)." + + " No extra text or formatting outside the JSON object."; + +/** + * Generates an AI title and body for a ticket from a raw description. + * @param description - The user's raw description of what they need. + * @param platform - The target platform for context. + * @returns A generated ticket with title and body, or null on failure. + */ +const generateTicket = async( + description: string, + platform: string, +): Promise => { + const result = await makeAiRequest({ + maxTokens: 1000, + systemPrompt: ticketSystemPrompt, + userMessage: `Platform: ${platform}\n\nUser description: ${description}`, + }); + if (result === null) { + return null; + } + try { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsing known AI JSON output. + return JSON.parse(result) as GeneratedTicket; + } catch { + return null; + } +}; + +/** + * Posts a task to LeanTime via JSON-RPC. + * @param title - The task headline. + * @param body - The task description. + * @returns A URL to the created task. + */ +const postToLeantime = async(title: string, body: 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: body, + editorId: "1", + headline: title, + priority: "3", + 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; + if (data.error !== undefined) { + throw new Error(data.error.message); + } + return data.result === undefined + ? "https://board.nhcarrigan.com" + : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${data.result.toString()}`; +}; + +/** + * Posts a task to Asana. + * @param title - The task name. + * @param body - The task notes. + * @returns A URL to the created Asana task. + */ +const postToAsana = async(title: string, body: string): Promise => { + const response = await fetch( + "https://app.asana.com/api/1.0/tasks?opt_fields=gid,permalink_url", + { + body: JSON.stringify({ + data: { + name: title, + notes: body, + projects: [ "1210018361945076" ], + }, + }), + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Authorization": `Bearer ${process.env.ASANA_KEY ?? ""}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + const data = await response.json() as AsanaTaskResponse; + return data.data.permalink_url; +}; + +/** + * Posts an issue to Gitea. + * @param options - The repository and content details. + * @returns A URL to the created Gitea issue. + */ +const postToGitea = async(options: PostOptions): Promise => { + const response = await fetch( + `https://git.nhcarrigan.com/api/v1/repos/${options.owner}/${options.repo}/issues`, + { + body: JSON.stringify({ body: options.body, title: options.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", + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + const data = await response.json() as GiteaIssueResponse; + return data.html_url; +}; + +/** + * Posts an issue to GitHub using the authenticated app octokit. + * @param options - The Amari instance, repository, and content details. + * @returns A URL to the created GitHub issue. + */ +const postToGitHub = async(options: RepoTicketOptions): Promise => { + const { data } = await options.amari.github.rest.issues.create({ + body: options.body, + owner: options.owner, + repo: options.repo, + title: options.title, + }); + return data.html_url; +}; + +/** + * Validates that owner and repo are provided when required by the platform. + * Replies with an error if validation fails. + * @param context - The validation context including platform, owner, repo, and interaction. + * @returns True if validation passes, false if an error reply was sent. + */ +const validateRepoArguments = async( + context: RepoValidationContext, +): Promise => { + if (context.platform !== "gitea" && context.platform !== "github") { + return true; + } + if (context.owner !== "" && context.repo !== "") { + return true; + } + const platformLabel = context.platform === "gitea" + ? "Gitea" + : "GitHub"; + await context.interaction.reply({ + content: `❌ The \`owner\` and \`repo\` arguments are required for ${platformLabel}.`, + flags: [ MessageFlags.Ephemeral ], + }); + return false; +}; + +/** + * Routes a generated ticket to the correct platform and logs the metric. + * @param ticket - The AI-generated ticket content. + * @param options - Routing context including platform, owner, repo, and Amari instance. + * @returns A URL to the created ticket. + */ +const routeTicket = async( + ticket: GeneratedTicket, + options: TicketRouteOptions, +): Promise => { + const { amari, owner, platform, repo } = options; + const { body, title } = ticket; + if (platform === "leantime") { + await logger.metric("created_ticket", 1, { platform, title }); + return await postToLeantime(title, body); + } + if (platform === "asana") { + await logger.metric("created_ticket", 1, { platform, title }); + return await postToAsana(title, body); + } + const repository = `${owner}/${repo}`; + if (platform === "gitea") { + await logger.metric("created_ticket", 1, { platform, repository, title }); + return await postToGitea({ body, owner, repo, title }); + } + await logger.metric("created_ticket", 1, { platform, repository, title }); + return await postToGitHub({ amari, body, owner, repo, title }); +}; + +/** + * Creates a ticket on the specified platform using an AI-generated title and body. + * @param amari - The Amari instance. + * @param interaction - The Discord slash command interaction. + */ +export const createTicket = 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 platform = interaction.options.getString("platform", true); + const description = interaction.options.getString("description", true); + const { owner, repo } = { + owner: interaction.options.getString("owner") ?? "", + repo: interaction.options.getString("repo") ?? "", + }; + + if (!await validateRepoArguments({ interaction, owner, platform, repo })) { + return; + } + + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + + const ticket = await generateTicket(description, platform); + if (ticket === null) { + await interaction.editReply({ + content: "❌ Failed to generate ticket content from AI.", + }); + return; + } + + try { + const url = await routeTicket(ticket, { amari, owner, platform, repo }); + await interaction.editReply({ + content: `✅ Ticket created: **${ticket.title}**\n${url}`, + }); + } catch (error) { + if (error instanceof Error) { + await logger.error("createTicket command", error); + } + const errorMessage = error instanceof Error + ? error.message + : "Unknown error"; + await interaction.editReply({ + content: `❌ Failed to create ticket: ${errorMessage}`, + }); + } +}; diff --git a/src/events/handleInteractionCreate.ts b/src/events/handleInteractionCreate.ts index c66230b..6d8e613 100644 --- a/src/events/handleInteractionCreate.ts +++ b/src/events/handleInteractionCreate.ts @@ -9,8 +9,7 @@ import { type ChatInputCommandInteraction, type Interaction, } from "discord.js"; -import { createIssue } from "../commands/createIssue.js"; -import { createTask } from "../commands/createTask.js"; +import { createTicket } from "../commands/createTicket.js"; import { forwardToOwner } from "../commands/forwardToOwner.js"; import { onboardMentee } from "../commands/onboardMentee.js"; import { ids } from "../config/ids.js"; @@ -30,12 +29,8 @@ const handleChatInputCommand = ( void onboardMentee(amari, interaction); return; } - if (commandName === "create-task") { - void createTask(interaction); - return; - } - if (commandName === "create-issue") { - void createIssue(interaction); + if (commandName === "create-ticket") { + void createTicket(amari, interaction); } };