/**
* Issue update formatter
*/
import type { StringFormatter } from "@features/jira/shared";
import type { Issue } from "../models";
/**
* Issue update context interface for additional formatting information
*/
export interface IssueUpdateContext {
fieldsUpdated?: string[];
arraysUpdated?: string[];
hasTransition?: boolean;
hasWorklog?: boolean;
}
/**
* Issue update formatter class - formats updated issues for display
*/
export class IssueUpdateFormatter implements StringFormatter<Issue> {
format(issue: Issue, context?: IssueUpdateContext): string {
const sections: string[] = [];
sections.push("# ✅ Issue Updated Successfully");
this.addIssueHeader(issue, sections);
this.addIssueDetails(issue, sections);
if (context) {
this.addUpdateContext(context, sections);
}
this.addIssueFields(issue, sections);
this.addQuickActions(issue, sections);
this.addNextActions(issue, sections);
this.addSuccessFooter(sections, context);
return sections.join("\n\n");
}
/**
* Add issue header with key and summary
*/
private addIssueHeader(issue: Issue, sections: string[]): void {
const summary = issue.fields?.summary || "No summary";
sections.push(`**${issue.key}**: ${summary}`);
}
/**
* Add basic issue details
*/
private addIssueDetails(issue: Issue, sections: string[]): void {
const details: string[] = [];
// Extract project from key
const project = issue.key.split("-")[0];
details.push(`**Project**: ${project}`);
// Issue type
const issueType = issue.fields?.issuetype?.name || "Unknown";
details.push(`**Type**: ${issueType}`);
// Status
const status = issue.fields?.status?.name || "Open";
details.push(`**Status**: ${status}`);
// Assignee
const assignee = issue.fields?.assignee?.displayName || "Unassigned";
details.push(`**Assignee**: ${assignee}`);
// Priority
const priority = issue.fields?.priority?.name || "Medium";
details.push(`**Priority**: ${priority}`);
sections.push(details.join(" | "));
}
/**
* Add update context information
*/
private addUpdateContext(
context: IssueUpdateContext,
sections: string[],
): void {
const contextInfo: string[] = [];
if (context.fieldsUpdated && context.fieldsUpdated.length > 0) {
contextInfo.push(
`**Fields Updated:** ${context.fieldsUpdated.join(", ")}`,
);
}
if (context.arraysUpdated && context.arraysUpdated.length > 0) {
contextInfo.push(
`**Arrays Modified:** ${context.arraysUpdated.join(", ")}`,
);
}
// Only show transition/worklog info if they're true, or if there are other context items
const hasOtherContext =
(context.fieldsUpdated && context.fieldsUpdated.length > 0) ||
(context.arraysUpdated && context.arraysUpdated.length > 0);
if (context.hasTransition === true) {
contextInfo.push("**Status Transition:** ✅ Applied");
} else if (hasOtherContext) {
contextInfo.push("**Status Transition:** ❌ Not Applied");
}
if (context.hasWorklog === true) {
contextInfo.push("**Worklog Entry:** ✅ Added");
} else if (context.hasWorklog === false && hasOtherContext) {
contextInfo.push("**Worklog Entry:** ❌ Not Added");
}
if (contextInfo.length > 0) {
sections.push(contextInfo.join(" | "));
}
}
/**
* Add detailed issue fields
*/
private addIssueFields(issue: Issue, sections: string[]): void {
const fields: string[] = [];
// Description with truncation
if (issue.fields?.description) {
const description =
typeof issue.fields.description === "string"
? issue.fields.description
: JSON.stringify(issue.fields.description);
const maxLength = 150;
const truncatedDescription =
description.length > maxLength
? `${description.substring(0, maxLength)}...`
: description;
fields.push(`**Description:** ${truncatedDescription}`);
}
// Time tracking
this.addTimeTracking(issue, fields);
// Story points
this.addStoryPoints(issue, fields);
// Labels
if (
issue.fields?.labels &&
Array.isArray(issue.fields.labels) &&
issue.fields.labels.length > 0
) {
fields.push(`**Labels:** ${issue.fields.labels.join(", ")}`);
}
// Components
if (
issue.fields?.components &&
Array.isArray(issue.fields.components) &&
issue.fields.components.length > 0
) {
const componentNames = issue.fields.components
.map((c: { name?: string } | string) =>
typeof c === "string" ? c : c.name || "Unknown",
)
.join(", ");
fields.push(`**Components:** ${componentNames}`);
}
// Fix Versions
if (
issue.fields?.fixVersions &&
Array.isArray(issue.fields.fixVersions) &&
issue.fields.fixVersions.length > 0
) {
const versionNames = issue.fields.fixVersions
.map((v: { name?: string } | string) =>
typeof v === "string" ? v : v.name || "Unknown",
)
.join(", ");
fields.push(`**Fix Versions:** ${versionNames}`);
}
// Updated timestamp - show label even when missing
if (issue.fields?.updated) {
const updatedDate = new Date(issue.fields.updated);
fields.push(`**Last Updated:** ${updatedDate.toLocaleString()}`);
} else {
fields.push("**Last Updated:**");
}
if (fields.length > 0) {
sections.push(fields.join("\n"));
}
}
/**
* Add time tracking information
*/
private addTimeTracking(issue: Issue, fields: string[]): void {
const originalEstimate = issue.fields?.timeoriginalestimate;
const remainingEstimate = issue.fields?.timeestimate;
if (originalEstimate !== undefined || remainingEstimate !== undefined) {
const timeInfo: string[] = [];
if (originalEstimate && typeof originalEstimate === "number") {
const hours = this.convertSecondsToHours(originalEstimate);
timeInfo.push(`Original: ${hours}h`);
}
if (remainingEstimate && typeof remainingEstimate === "number") {
const hours = this.convertSecondsToHours(remainingEstimate);
timeInfo.push(`Remaining: ${hours}h`);
}
if (timeInfo.length > 0) {
fields.push(`**Time Tracking:** ${timeInfo.join(", ")}`);
}
}
}
/**
* Convert seconds to hours (rounded)
*/
private convertSecondsToHours(seconds: number): number {
return Math.round(seconds / 3600);
}
/**
* Add story points information
*/
private addStoryPoints(issue: Issue, fields: string[]): void {
const storyPoints = issue.fields?.customfield_10004;
if (storyPoints !== undefined && storyPoints !== null) {
fields.push(`Story Points: ${storyPoints}`);
}
}
/**
* Add quick action links
*/
private addQuickActions(issue: Issue, sections: string[]): void {
const baseUrl = issue.self
? issue.self
.replace("/rest/api/3/issue/", "/browse/")
.replace(/\/[^\/]*$/, `/${issue.key}`)
: `https://your-domain.atlassian.net/browse/${issue.key}`;
const editUrl = issue.self
? issue.self
.replace("/rest/api/3/issue/", "/secure/EditIssue!default.jspa?key=")
.replace(/\/[^\/]*$/, `=${issue.key}`)
.replace(/\/secure=/, "/secure/EditIssue!default.jspa?key=")
: `https://your-domain.atlassian.net/secure/EditIssue!default.jspa?key=${issue.key}`;
const commentUrl = issue.self
? `${baseUrl}?focusedCommentId=&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#action_id=comment`
: `${baseUrl}#add-comment`;
const actions = [
`[View Issue](${baseUrl})`,
`[Edit Issue](${editUrl})`,
`[Add Comment](${commentUrl})`,
];
sections.push(`**Quick Actions:** ${actions.join(" | ")}`);
}
/**
* Add next actions with specific commands
*/
private addNextActions(issue: Issue, sections: string[]): void {
const project = issue.key.split("-")[0];
sections.push("## 🚀 **Next Actions:**");
const actions = [
`• Use \`jira_get_issue ${issue.key}\` to view the updated issue details`,
`• Use \`jira_update_issue ${issue.key}\` to make further changes`,
`• Use \`jira_transition_issue ${issue.key}\` to change the issue status`,
`• Use \`jira_add_worklog ${issue.key}\` to log time spent on this issue`,
`• Use \`jira_get_issue_comments ${issue.key}\` to view or add comments`,
`• Use \`search_jira_issues project=${project}\` to find related issues`,
];
sections.push(actions.join("\n"));
}
/**
* Add success footer
*/
private addSuccessFooter(
sections: string[],
context?: IssueUpdateContext,
): void {
// Use simple message only for specific empty context scenarios
if (context && this.isEmptyContextScenario(context)) {
sections.push("Issue updated successfully");
} else {
sections.push("✨ Issue update completed successfully!");
}
}
/**
* Check if this is an empty context scenario that should use simple message
*/
private isEmptyContextScenario(context: IssueUpdateContext): boolean {
// Empty context object
if (Object.keys(context).length === 0) {
return true;
}
// Context with empty arrays and false booleans (no meaningful content)
const hasEmptyArrays =
(context.fieldsUpdated && context.fieldsUpdated.length === 0) ||
(context.arraysUpdated && context.arraysUpdated.length === 0);
const hasFalseBooleans =
context.hasTransition === false || context.hasWorklog === false;
const hasNoTrueValues =
context.hasTransition !== true && context.hasWorklog !== true;
const hasNoNonEmptyArrays =
(!context.fieldsUpdated || context.fieldsUpdated.length === 0) &&
(!context.arraysUpdated || context.arraysUpdated.length === 0);
return (
(hasEmptyArrays &&
hasFalseBooleans &&
hasNoTrueValues &&
hasNoNonEmptyArrays) ||
false
);
}
}