generated from nhcarrigan/template
feat: replace create-task and create-issue with unified create-ticket #18
+17
-36
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user