generated from nhcarrigan/template
feat: replace create-task and create-issue with unified create-ticket (#18)
## 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: #18 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #18.
This commit is contained in:
@@ -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<GeneratedTicket | null> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<boolean> => {
|
||||
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<string> => {
|
||||
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<void> => {
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user