says "Hello" & \'Goodbye\'');
+ });
+
+ it('should replace non-breaking spaces', () => {
+ const html = 'Word1 Word2 Word3';
+ const result = stripHtmlTags(html);
+
+ expect(result).toBe('Word1 Word2 Word3');
+ });
+
+ it('should handle nested HTML tags', () => {
+ const html = '';
+ const result = stripHtmlTags(html);
+
+ expect(result).toBe('Nested content here');
+ });
+
+ it('should handle malformed HTML gracefully', () => {
+ const html = 'Unclosed tag bold text';
+ const result = stripHtmlTags(html);
+
+ expect(result).toBe('Unclosed tag bold text');
+ });
+
+ it('should trim whitespace after processing', () => {
+ const html = ' Content with spaces
';
+ const result = stripHtmlTags(html);
+
+ expect(result).toBe('Content with spaces');
+ });
+
+ it('should handle empty string', () => {
+ const result = stripHtmlTags('');
+
+ expect(result).toBe('');
+ });
+
+ it('should handle text without HTML tags', () => {
+ const text = 'Plain text without any HTML';
+ const result = stripHtmlTags(text);
+
+ expect(result).toBe(text);
+ });
+
+ it('should handle HTML with attributes', () => {
+ const html = 'Link text';
+ const result = stripHtmlTags(html);
+
+ expect(result).toBe('Link text');
+ });
+
+ it('should handle complex HTML document structure', () => {
+ const html = `
+
+ Title
+
+ Header
+ Paragraph with link
+
+
+
+ `;
+ const result = stripHtmlTags(html);
+
+ expect(result).toContain('Header');
+ expect(result).toContain('Paragraph with link');
+ expect(result).toContain('Item 1');
+ expect(result).toContain('Item 2');
+ expect(result).not.toContain('<');
+ expect(result).not.toContain('>');
+ });
+ });
+});
diff --git a/src/formatters/field-selector.ts b/src/formatters/field-selector.ts
new file mode 100644
index 0000000..ef519f5
--- /dev/null
+++ b/src/formatters/field-selector.ts
@@ -0,0 +1,228 @@
+/**
+ * Utilities for selective field inclusion in brief mode formatting
+ */
+
+import type { RedmineIssue } from "../lib/types/index.js";
+import type { BriefFieldOptions } from "./format-options.js";
+
+/**
+ * Result of field selection with optional warnings
+ */
+export interface FieldSelectionResult {
+ /** Selected issue fields */
+ issue: Partial;
+ /** Warnings about missing or invalid fields */
+ warnings?: string[];
+}
+
+/**
+ * Select fields from an issue based on brief field options
+ */
+export function selectFields(issue: RedmineIssue, options: BriefFieldOptions): FieldSelectionResult {
+ const warnings: string[] = [];
+
+ // Always include essential fields
+ const selected: Partial = {
+ id: issue.id,
+ subject: issue.subject,
+ project: issue.project,
+ tracker: issue.tracker,
+ status: issue.status,
+ priority: issue.priority,
+ author: issue.author
+ };
+
+ // Conditionally include optional fields based on options
+ if (options.assignee && issue.assigned_to) {
+ selected.assigned_to = issue.assigned_to;
+ }
+
+ if (options.description && issue.description) {
+ selected.description = issue.description;
+ }
+
+ if (options.dates) {
+ if (issue.start_date) selected.start_date = issue.start_date;
+ if (issue.due_date) selected.due_date = issue.due_date;
+ selected.created_on = issue.created_on;
+ selected.updated_on = issue.updated_on;
+ if (issue.closed_on) selected.closed_on = issue.closed_on;
+ }
+
+ if (options.category && issue.category) {
+ selected.category = issue.category;
+ }
+
+ if (options.version && issue.fixed_version) {
+ selected.fixed_version = issue.fixed_version;
+ }
+
+ if (options.time_tracking) {
+ if (issue.estimated_hours !== undefined) selected.estimated_hours = issue.estimated_hours;
+ if (issue.spent_hours !== undefined) selected.spent_hours = issue.spent_hours;
+ if (issue.done_ratio !== undefined) selected.done_ratio = issue.done_ratio;
+ }
+
+ // Handle custom fields with selective filtering
+ if (options.custom_fields && issue.custom_fields) {
+ const customFieldResult = selectCustomFields(issue.custom_fields, options.custom_fields);
+ selected.custom_fields = customFieldResult.fields;
+ if (customFieldResult.warnings.length > 0) {
+ warnings.push(...customFieldResult.warnings);
+ }
+ }
+
+ if (options.journals && issue.journals) {
+ selected.journals = issue.journals;
+ }
+
+ if (options.relations && issue.relations) {
+ selected.relations = issue.relations;
+ }
+
+ return {
+ issue: selected,
+ warnings: warnings.length > 0 ? warnings : undefined
+ };
+}
+
+/**
+ * Result of custom field selection
+ */
+interface CustomFieldSelectionResult {
+ fields: Array<{ id: number; name: string; value: string | string[] | null }>;
+ warnings: string[];
+}
+
+/**
+ * Select custom fields based on the custom_fields option
+ */
+function selectCustomFields(
+ customFields: Array<{ id: number; name: string; value: string | string[] | null }>,
+ option: boolean | string[]
+): CustomFieldSelectionResult {
+ const warnings: string[] = [];
+
+ // If true, include all non-empty custom fields
+ if (option === true) {
+ return {
+ fields: skipEmptyCustomFields(customFields),
+ warnings: []
+ };
+ }
+
+ // If false or empty array, include no custom fields
+ if (option === false || (Array.isArray(option) && option.length === 0)) {
+ return {
+ fields: [],
+ warnings: []
+ };
+ }
+
+ // If array of field names, include only matching fields
+ if (Array.isArray(option)) {
+ const selectedFields: Array<{ id: number; name: string; value: string | string[] | null }> = [];
+ const foundFieldNames = new Set();
+
+ // Find matching fields by name
+ for (const field of customFields) {
+ if (option.includes(field.name)) {
+ foundFieldNames.add(field.name);
+ // Only include non-empty fields
+ if (isCustomFieldNonEmpty(field)) {
+ selectedFields.push(field);
+ }
+ }
+ }
+
+ // Check for requested fields that weren't found or are empty
+ for (const requestedName of option) {
+ if (!foundFieldNames.has(requestedName)) {
+ warnings.push(`Custom field "${requestedName}" not found or empty`);
+ } else {
+ // Field was found, check if it was included (non-empty)
+ const foundField = customFields.find(f => f.name === requestedName);
+ if (foundField && !isCustomFieldNonEmpty(foundField)) {
+ warnings.push(`Custom field "${requestedName}" not found or empty`);
+ }
+ }
+ }
+
+ return {
+ fields: selectedFields,
+ warnings: warnings
+ };
+ }
+
+ // Fallback: treat as false
+ return {
+ fields: [],
+ warnings: []
+ };
+}
+
+/**
+ * Check if a custom field has a non-empty value
+ */
+function isCustomFieldNonEmpty(field: { id: number; name: string; value: string | string[] | null }): boolean {
+ if (field.value === null || field.value === undefined) {
+ return false;
+ }
+
+ if (typeof field.value === 'string') {
+ return field.value.trim().length > 0;
+ }
+
+ if (Array.isArray(field.value)) {
+ return field.value.length > 0 && field.value.some(v => v && v.trim().length > 0);
+ }
+
+ return true;
+}
+
+/**
+ * Filter out empty custom fields to reduce noise
+ */
+export function skipEmptyCustomFields(customFields: Array<{
+ id: number;
+ name: string;
+ value: string | string[] | null;
+}>): Array<{ id: number; name: string; value: string | string[] | null }> {
+ return customFields.filter(field => {
+ if (field.value === null || field.value === undefined) {
+ return false;
+ }
+
+ if (typeof field.value === 'string') {
+ return field.value.trim().length > 0;
+ }
+
+ if (Array.isArray(field.value)) {
+ return field.value.length > 0 && field.value.some(v => v && v.trim().length > 0);
+ }
+
+ return true;
+ });
+}
+
+/**
+ * Check if any brief fields are enabled
+ */
+export function hasBriefFieldsEnabled(options?: BriefFieldOptions): boolean {
+ if (!options) return false;
+
+ return Object.values(options).some(value => value === true);
+}
+
+/**
+ * Get a summary of enabled brief fields for logging/debugging
+ */
+export function getBriefFieldsSummary(options?: BriefFieldOptions): string {
+ if (!options) return 'none';
+
+ const enabled = Object.entries(options)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key);
+
+ return enabled.length > 0 ? enabled.join(', ') : 'none';
+}
diff --git a/src/formatters/format-options.ts b/src/formatters/format-options.ts
new file mode 100644
index 0000000..0f1bce4
--- /dev/null
+++ b/src/formatters/format-options.ts
@@ -0,0 +1,115 @@
+/**
+ * Format options for controlling output verbosity in Redmine MCP server responses
+ */
+
+/**
+ * Output detail levels for issue formatting
+ */
+export enum OutputDetailLevel {
+ BRIEF = 'brief',
+ FULL = 'full'
+}
+
+/**
+ * Options for selective field inclusion in brief mode
+ */
+export interface BriefFieldOptions {
+ /** Include assignee information */
+ assignee?: boolean;
+ /** Include issue description: false = exclude, true = full, "truncated" = truncated */
+ description?: boolean | "truncated";
+ /** Include custom fields: false = exclude, true = all non-empty, string[] = specific field names */
+ custom_fields?: boolean | string[];
+ /** Include start/due dates */
+ dates?: boolean;
+ /** Include category information */
+ category?: boolean;
+ /** Include target version information */
+ version?: boolean;
+ /** Include time tracking information (estimated/spent hours) */
+ time_tracking?: boolean;
+ /** Include journal entries (comments/history) */
+ journals?: boolean;
+ /** Include issue relations */
+ relations?: boolean;
+ /** Include attachment information */
+ attachments?: boolean;
+}
+
+/**
+ * Complete formatting options for issue output
+ */
+export interface FormatOptions {
+ /** Detail level for output formatting */
+ detail_level: OutputDetailLevel;
+ /** Selective field options for brief mode */
+ brief_fields?: BriefFieldOptions;
+ /** Maximum length for description text in brief mode */
+ max_description_length?: number;
+ /** Maximum number of journal entries to include in brief mode */
+ max_journal_entries?: number;
+}
+
+/**
+ * Default formatting options
+ */
+export const DEFAULT_FORMAT_OPTIONS: FormatOptions = {
+ detail_level: OutputDetailLevel.FULL,
+ max_description_length: 200,
+ max_journal_entries: 3
+};
+
+/**
+ * Parse formatting options from tool arguments
+ */
+export function parseFormatOptions(args: Record): FormatOptions {
+ const options: FormatOptions = { ...DEFAULT_FORMAT_OPTIONS };
+
+ // Parse detail level
+ if ('detail_level' in args && typeof args.detail_level === 'string') {
+ const level = args.detail_level.toLowerCase();
+ if (level === 'brief' || level === 'full') {
+ options.detail_level = level as OutputDetailLevel;
+ }
+ }
+
+ // Parse brief fields from JSON string
+ if ('brief_fields' in args && typeof args.brief_fields === 'string') {
+ try {
+ const briefFields = JSON.parse(args.brief_fields) as BriefFieldOptions;
+ options.brief_fields = briefFields;
+ } catch (error) {
+ // Invalid JSON, ignore and use defaults
+ console.warn('Invalid brief_fields JSON:', error);
+ }
+ }
+
+ // Parse numeric options
+ if ('max_description_length' in args && typeof args.max_description_length === 'number') {
+ options.max_description_length = Math.max(50, Math.min(1000, args.max_description_length));
+ }
+
+ if ('max_journal_entries' in args && typeof args.max_journal_entries === 'number') {
+ options.max_journal_entries = Math.max(0, Math.min(10, args.max_journal_entries));
+ }
+
+ return options;
+}
+
+/**
+ * Create default brief field options with commonly needed fields
+ */
+export function createDefaultBriefFields(): BriefFieldOptions {
+ return {
+ assignee: true,
+ dates: true,
+ description: "truncated",
+ custom_fields: [],
+ category: false,
+ version: false,
+ time_tracking: false,
+ journals: false,
+ relations: false,
+ attachments: false
+ };
+}
diff --git a/src/formatters/issues.ts b/src/formatters/issues.ts
index 5517090..b7587da 100644
--- a/src/formatters/issues.ts
+++ b/src/formatters/issues.ts
@@ -1,4 +1,8 @@
import type { RedmineApiResponse, RedmineIssue } from "../lib/types/index.js";
+import type { FormatOptions, BriefFieldOptions } from "./format-options.js";
+import { OutputDetailLevel, createDefaultBriefFields } from "./format-options.js";
+import { selectFields, skipEmptyCustomFields, type FieldSelectionResult } from "./field-selector.js";
+import { truncateDescription, limitJournalEntries } from "./text-truncation.js";
/**
* Escape XML special characters
@@ -65,12 +69,95 @@ function formatJournals(journals: Array<{
}
/**
- * Format a single issue
+ * Format a single issue in brief mode
*/
-export function formatIssue(issue: RedmineIssue): string {
- const safeDescription = escapeXml(issue.description);
+export function formatIssueBrief(issue: RedmineIssue, options: BriefFieldOptions, maxDescLength: number = 200, maxJournalEntries: number = 3): string {
+ // Select only the fields specified in options
+ const selectionResult = selectFields(issue, options);
+ const selectedIssue = selectionResult.issue;
+
+ // Build the brief XML output with only essential and selected fields
+ let briefXml = `
+ ${selectedIssue.id}
+ ${escapeXml(selectedIssue.subject)}
+ ${escapeXml(selectedIssue.project?.name)}
+ ${escapeXml(selectedIssue.tracker?.name)}
+ ${escapeXml(selectedIssue.status?.name)}
+ ${escapeXml(selectedIssue.priority?.name)}`;
+
+ // Add warnings if any
+ if (selectionResult.warnings && selectionResult.warnings.length > 0) {
+ briefXml += `\n `;
+ for (const warning of selectionResult.warnings) {
+ briefXml += `\n ${escapeXml(warning)}`;
+ }
+ briefXml += `\n `;
+ }
+
+ // Add optional fields based on selection
+ if (selectedIssue.assigned_to) {
+ briefXml += `\n ${escapeXml(selectedIssue.assigned_to.name)}`;
+ }
+
+ // Handle description with truncation support
+ if (selectedIssue.description && options.description) {
+ if (options.description === "truncated") {
+ const truncatedDesc = truncateDescription(selectedIssue.description, maxDescLength);
+ if (truncatedDesc) {
+ briefXml += `\n ${escapeXml(truncatedDesc)}`;
+ }
+ } else if (options.description === true) {
+ briefXml += `\n ${escapeXml(selectedIssue.description)}`;
+ }
+ }
- return `
+ if (selectedIssue.category && options.category) {
+ briefXml += `\n ${escapeXml(selectedIssue.category.name)}`;
+ }
+
+ if (selectedIssue.fixed_version && options.version) {
+ briefXml += `\n ${escapeXml(selectedIssue.fixed_version.name)}`;
+ }
+
+ if (options.dates) {
+ if (selectedIssue.start_date) briefXml += `\n ${selectedIssue.start_date}`;
+ if (selectedIssue.due_date) briefXml += `\n ${selectedIssue.due_date}`;
+ briefXml += `\n ${selectedIssue.created_on}`;
+ briefXml += `\n ${selectedIssue.updated_on}`;
+ }
+
+ if (options.time_tracking) {
+ if (selectedIssue.done_ratio !== undefined) briefXml += `\n `;
+ if (selectedIssue.estimated_hours !== undefined) briefXml += `\n ${selectedIssue.estimated_hours}`;
+ if (selectedIssue.spent_hours !== undefined) briefXml += `\n ${selectedIssue.spent_hours}`;
+ }
+
+ if (selectedIssue.custom_fields && options.custom_fields) {
+ if (selectedIssue.custom_fields.length > 0) {
+ briefXml += formatCustomFields(selectedIssue.custom_fields);
+ }
+ }
+
+ if (selectedIssue.journals && options.journals) {
+ const limitedJournals = limitJournalEntries(selectedIssue.journals, maxJournalEntries);
+ if (limitedJournals.length > 0) {
+ briefXml += formatJournals(limitedJournals);
+ }
+ }
+
+ briefXml += '\n';
+ return briefXml;
+}
+
+/**
+ * Format a single issue with optional formatting options
+ */
+export function formatIssue(issue: RedmineIssue, options?: FormatOptions): string {
+ // If no options provided or full mode, use original formatting
+ if (!options || options.detail_level === OutputDetailLevel.FULL) {
+ const safeDescription = escapeXml(issue.description);
+
+ return `
${issue.id}
${escapeXml(issue.subject)}
${escapeXml(issue.project?.name)}
@@ -94,18 +181,26 @@ export function formatIssue(issue: RedmineIssue): string {
${issue.updated_on ? `${issue.updated_on}` : ''}
${issue.closed_on ? `${issue.closed_on}` : ''}
`;
+ }
+
+ // Brief mode
+ const briefFields = options.brief_fields || createDefaultBriefFields();
+ const maxDescLength = options.max_description_length || 200;
+ const maxJournalEntries = options.max_journal_entries || 3;
+
+ return formatIssueBrief(issue, briefFields, maxDescLength, maxJournalEntries);
}
/**
- * Format list of issues
+ * Format list of issues with optional formatting options
*/
-export function formatIssues(response: RedmineApiResponse): string {
+export function formatIssues(response: RedmineApiResponse, options?: FormatOptions): string {
// response や response.issues が null/undefined の場合のチェックを追加
if (!response || !response.issues || !Array.isArray(response.issues) || response.issues.length === 0) {
return '\n';
}
- const issues = response.issues.map(formatIssue).join('\n');
+ const issues = response.issues.map(issue => formatIssue(issue, options)).join('\n');
return `
diff --git a/src/formatters/text-truncation.ts b/src/formatters/text-truncation.ts
new file mode 100644
index 0000000..6943882
--- /dev/null
+++ b/src/formatters/text-truncation.ts
@@ -0,0 +1,141 @@
+/**
+ * Text truncation and processing utilities for brief mode formatting
+ */
+
+/**
+ * Truncate text to a maximum length with ellipsis
+ */
+export function truncateText(text: string, maxLength: number): string {
+ if (!text || text.length <= maxLength) {
+ return text;
+ }
+
+ // Find the last space before the limit to avoid cutting words
+ const truncated = text.substring(0, maxLength);
+ const lastSpace = truncated.lastIndexOf(' ');
+
+ if (lastSpace > maxLength * 0.8) {
+ // If we found a space reasonably close to the limit, use it
+ return truncated.substring(0, lastSpace) + '...';
+ } else {
+ // Otherwise, just truncate at the limit
+ return truncated + '...';
+ }
+}
+
+/**
+ * Truncate and clean description text for brief mode
+ */
+export function truncateDescription(description: string | null | undefined, maxLength: number): string {
+ if (!description) {
+ return '';
+ }
+
+ // Remove excessive whitespace and normalize line breaks
+ const cleaned = description
+ .replace(/\r\n/g, '\n') // Normalize line endings
+ .replace(/\n{3,}/g, '\n\n') // Limit consecutive line breaks
+ .trim();
+
+ if (cleaned.length <= maxLength) {
+ return cleaned;
+ }
+
+ return truncateText(cleaned, maxLength);
+}
+
+/**
+ * Limit journal entries to a maximum count and truncate notes
+ */
+export function limitJournalEntries(
+ journals: Array<{
+ id: number;
+ user: { id: number; name: string };
+ notes?: string | null;
+ private_notes?: boolean;
+ created_on: string;
+ details?: Array<{ property: string; name: string; old_value?: string | null; new_value?: string | null }>;
+ }> | undefined,
+ maxEntries: number,
+ maxNoteLength: number = 100
+): Array<{
+ id: number;
+ user: { id: number; name: string };
+ notes?: string | null;
+ private_notes?: boolean;
+ created_on: string;
+ details?: Array<{ property: string; name: string; old_value?: string | null; new_value?: string | null }>;
+}> {
+ if (!journals || journals.length === 0) {
+ return [];
+ }
+
+ // Take the most recent entries
+ const limited = journals.slice(-maxEntries);
+
+ // Truncate notes in each entry
+ return limited.map(journal => ({
+ ...journal,
+ notes: journal.notes ? truncateText(journal.notes, maxNoteLength) : journal.notes
+ }));
+}
+
+/**
+ * Create a brief summary of custom fields (non-empty only)
+ */
+export function summarizeCustomFields(
+ customFields: Array<{
+ id: number;
+ name: string;
+ value: string | string[] | null;
+ }> | undefined,
+ maxFields: number = 5
+): string {
+ if (!customFields || customFields.length === 0) {
+ return '';
+ }
+
+ // Filter out empty fields and limit count
+ const nonEmpty = customFields
+ .filter(field => {
+ if (field.value === null || field.value === undefined) return false;
+ if (typeof field.value === 'string') return field.value.trim().length > 0;
+ if (Array.isArray(field.value)) return field.value.length > 0;
+ return true;
+ })
+ .slice(0, maxFields);
+
+ if (nonEmpty.length === 0) {
+ return '';
+ }
+
+ const summaries = nonEmpty.map(field => {
+ let value = '';
+ if (typeof field.value === 'string') {
+ value = truncateText(field.value, 50);
+ } else if (Array.isArray(field.value)) {
+ value = field.value.join(', ');
+ value = truncateText(value, 50);
+ }
+ return `${field.name}: ${value}`;
+ });
+
+ return summaries.join('; ');
+}
+
+/**
+ * Remove HTML tags from text (basic cleanup)
+ */
+export function stripHtmlTags(text: string): string {
+ if (!text) return text;
+
+ return text
+ .replace(/<[^>]*>/g, '') // Remove HTML tags
+ .replace(/ /g, ' ') // Replace non-breaking spaces
+ .replace(/&/g, '&') // Decode common HTML entities
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .trim();
+}
diff --git a/src/handlers/__tests__/issues-brief.test.ts b/src/handlers/__tests__/issues-brief.test.ts
new file mode 100644
index 0000000..1a0019c
--- /dev/null
+++ b/src/handlers/__tests__/issues-brief.test.ts
@@ -0,0 +1,239 @@
+import { jest, expect, describe, it, beforeEach } from '@jest/globals';
+import { createIssuesHandlers } from '../issues.js';
+import { mockSimpleIssue, mockComplexIssue } from '../../formatters/__tests__/fixtures/mock-issues.js';
+import type { RedmineApiResponse } from '../../lib/types/index.js';
+import type { HandlerContext } from '../types.js';
+
+// Mock the IssuesClient
+const mockGetIssue = jest.fn() as jest.MockedFunction;
+const mockGetIssues = jest.fn() as jest.MockedFunction;
+
+const mockClient = {
+ issues: {
+ getIssue: mockGetIssue,
+ getIssues: mockGetIssues
+ }
+};
+
+const mockContext: HandlerContext = {
+ client: mockClient as any,
+ config: {} as any,
+ logger: console as any
+};
+
+describe('Issues Handler - Brief Mode Integration', () => {
+ let handlers: ReturnType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ handlers = createIssuesHandlers(mockContext);
+ });
+
+ describe('get_issue handler', () => {
+ it('should handle brief mode with default fields', async () => {
+ mockGetIssue.mockResolvedValue({ issue: mockSimpleIssue });
+
+ const result = await handlers.get_issue({
+ id: '1001',
+ detail_level: 'brief'
+ });
+
+ expect(mockGetIssue).toHaveBeenCalledWith(1001, undefined);
+ expect(result.isError).toBe(false);
+ expect(result.content[0].text).toContain('');
+ expect(result.content[0].text).toContain('1001');
+ expect(result.content[0].text).toContain('Simple test issue');
+
+ // Brief mode characteristics
+ expect(result.content[0].text).not.toContain('');
+ expect(result.content[0].text).toContain(''); // Now included by default (truncated)
+ expect(result.content[0].text).not.toContain('');
+
+ // Should be significantly shorter than full mode
+ expect(result.content[0].text.length).toBeLessThan(500);
+ });
+
+ it('should handle brief mode with custom field configuration', async () => {
+ mockGetIssue.mockResolvedValue({ issue: mockComplexIssue });
+
+ const briefFields = JSON.stringify({
+ assignee: true,
+ dates: true,
+ description: true,
+ custom_fields: true,
+ category: false,
+ version: false,
+ time_tracking: false,
+ journals: false,
+ relations: false,
+ attachments: false
+ });
+
+ const result = await handlers.get_issue({
+ id: '1002',
+ detail_level: 'brief',
+ brief_fields: briefFields,
+ max_description_length: '150',
+ max_journal_entries: '2'
+ });
+
+ expect(mockGetIssue).toHaveBeenCalledWith(1002, undefined);
+ expect(result.isError).toBe(false);
+ expect(result.content[0].text).toContain('Test Assignee');
+ expect(result.content[0].text).toContain('2024-01-02');
+ expect(result.content[0].text).toContain('');
+ expect(result.content[0].text).toContain('');
+
+ // Should not contain disabled fields
+ expect(result.content[0].text).not.toContain('');
+ expect(result.content[0].text).not.toContain('');
+ expect(result.content[0].text).not.toContain('