feat: replace create-task and create-issue with unified create-ticket #18

Merged
naomi merged 1 commits from feat/tickets-better into main 2026-03-09 14:09:08 -07:00
6 changed files with 346 additions and 307 deletions
+17 -36
View File
@@ -1,56 +1,37 @@
[ [
{ {
"name": "create-issue", "name": "create-ticket",
"type": 1, "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": [ "options": [
{ {
"name": "owner", "name": "platform",
"description": "The owner of the repository.", "description": "The platform to create the ticket on.",
"type": 3, "type": 3,
"required": true "required": true,
}, "choices": [
{ { "name": "LeanTime", "value": "leantime" },
"name": "repo", { "name": "Asana", "value": "asana" },
"description": "The name of the repository.", { "name": "Gitea", "value": "gitea" },
"type": 3, { "name": "GitHub", "value": "github" }
"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", "name": "description",
"type": 1, "description": "Describe what you need. The AI will generate a title and full description.",
"description": "Creates a Leantime task with an AI-generated description.",
"options": [
{
"name": "title",
"description": "The task title.",
"type": 3, "type": 3,
"required": true "required": true
}, },
{ {
"name": "description", "name": "owner",
"description": "Optional additional context for the task description.", "description": "The repository owner (required for Gitea/GitHub).",
"type": 3, "type": 3,
"required": false "required": false
}, },
{ {
"name": "priority", "name": "repo",
"description": "The task priority level (1-5, default 3).", "description": "The repository name (required for Gitea/GitHub).",
"type": 4, "type": 3,
"required": false "required": false
} }
] ]
+1
View File
@@ -9,4 +9,5 @@ BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token"
RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key" RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key"
LEANTIME_KEY="op://Environment Variables - Naomi/Amari/leantime key" LEANTIME_KEY="op://Environment Variables - Naomi/Amari/leantime key"
GITEA_KEY="op://Environment Variables - Naomi/Amari/gitea 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" ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key"
-126
View File
@@ -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<string> => {
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<GiteaIssueResponse> => {
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<void> => {
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}`,
});
}
};
-131
View File
@@ -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<string> => {
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<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,
): Promise<void> => {
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.",
});
}
};
+319
View File
@@ -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}`,
});
}
};
+3 -8
View File
@@ -9,8 +9,7 @@ import {
type ChatInputCommandInteraction, type ChatInputCommandInteraction,
type Interaction, type Interaction,
} from "discord.js"; } from "discord.js";
import { createIssue } from "../commands/createIssue.js"; import { createTicket } from "../commands/createTicket.js";
import { createTask } from "../commands/createTask.js";
import { forwardToOwner } from "../commands/forwardToOwner.js"; import { forwardToOwner } from "../commands/forwardToOwner.js";
import { onboardMentee } from "../commands/onboardMentee.js"; import { onboardMentee } from "../commands/onboardMentee.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
@@ -30,12 +29,8 @@ const handleChatInputCommand = (
void onboardMentee(amari, interaction); void onboardMentee(amari, interaction);
return; return;
} }
if (commandName === "create-task") { if (commandName === "create-ticket") {
void createTask(interaction); void createTicket(amari, interaction);
return;
}
if (commandName === "create-issue") {
void createIssue(interaction);
} }
}; };