/** * @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}`, }); } };