MCP Atlassian
by samwang0723
- src
- services
import { JiraApi } from 'ts-jira-client';
import config from '@/config/env';
// Define interfaces for Jira issue types
interface JiraComment {
body: string;
created: string;
author: {
displayName?: string;
[key: string]: any;
};
[key: string]: any;
}
interface JiraIssueFields {
summary?: string;
description?: string;
created: string;
issuetype?: {
name: string;
[key: string]: any;
};
status?: {
name: string;
[key: string]: any;
};
comment?: {
comments: JiraComment[];
[key: string]: any;
};
[key: string]: any;
}
interface JiraIssue {
id: string;
key: string;
fields: JiraIssueFields;
[key: string]: any;
}
interface StructuredJiraIssue {
key: string;
title: string;
type: string;
status: string;
created: string;
description: string;
comments: {
body: string;
created: string;
author: string;
}[];
}
interface JiraSearchResult {
startAt: number;
maxResults: number;
total: number;
issues: JiraIssue[];
}
interface StructuredJiraSearchResult {
startAt: number;
maxResults: number;
total: number;
issues: StructuredJiraIssue[];
}
/**
* Jira service for interacting with the Jira API
*/
export class JiraService {
private client: JiraApi;
constructor() {
// Extract the hostname from the full URL
const host = config.atlassian.host.replace(/^https?:\/\//, '');
this.client = new JiraApi({
protocol: 'https',
host,
username: config.atlassian.email,
password: config.atlassian.apiToken,
apiVersion: 2,
strictSSL: true,
});
}
/**
* Clean text by removing HTML tags and normalizing whitespace
* @param text The text to clean
* @returns The cleaned text
*/
private cleanText(text: string): string {
if (!text) return '';
// Remove HTML tags
const withoutHtml = text.replace(/<[^>]*>/g, '');
// Normalize whitespace
return withoutHtml.replace(/\s+/g, ' ').trim();
}
/**
* Parse date string to a formatted date
* @param dateString The date string to parse
* @returns The formatted date string
*/
private parseDate(dateString: string): string {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toISOString();
} catch (error) {
console.error('Error parsing date:', error);
return dateString;
}
}
/**
* Get an issue by key with parsed and structured fields
* @param issueKey The key of the issue to retrieve
* @param expand Optional fields to expand in the response (comma-separated string)
* @returns The structured issue object
*/
async getIssue(
issueKey: string,
expand?: string,
): Promise<StructuredJiraIssue> {
try {
// The ts-jira-client findIssue method accepts issueKey as first parameter
// and a string for expand parameter
const issue = (await this.client.findIssue(
issueKey,
expand,
)) as JiraIssue;
// Extract fields
const fields = issue.fields;
// Process description
const description = this.cleanText(fields.description || '');
// Process comments
const comments = [];
if (fields.comment && fields.comment.comments) {
for (const comment of fields.comment.comments) {
comments.push({
body: this.cleanText(comment.body),
created: this.parseDate(comment.created),
author: comment.author?.displayName || 'Unknown',
});
}
}
// Format created date
const createdDate = this.parseDate(fields.created);
// Create structured response
const structuredIssue: StructuredJiraIssue = {
key: issueKey,
title: fields.summary || '',
type: fields.issuetype?.name || '',
status: fields.status?.name || '',
created: createdDate,
description,
comments,
};
return structuredIssue;
} catch (error) {
console.error(`Error getting issue ${issueKey}:`, error);
throw error;
}
}
/**
* Search for issues using JQL
* @param jql The JQL query
* @param maxResults The maximum number of results to return
* @param startAt The index of the first result to return (0-based)
* @param expand Optional fields to expand in the response (array of fields)
* @returns The structured search results
*/
async searchIssues(
jql: string,
maxResults = 20,
startAt = 0,
expand?: string[],
): Promise<StructuredJiraSearchResult> {
try {
// Build search options
const searchOptions: {
maxResults: number;
startAt: number;
expand?: string[];
} = {
maxResults,
startAt,
};
// Add expand if provided
if (expand && expand.length > 0) {
searchOptions.expand = expand;
}
// Execute search
const searchResult = (await this.client.searchJira(
jql,
searchOptions,
)) as JiraSearchResult;
// Process each issue in the results
const structuredIssues: StructuredJiraIssue[] = searchResult.issues.map(
(issue) => {
const fields = issue.fields;
// Process description
const description = this.cleanText(fields.description || '');
// Process comments
const comments = [];
if (fields.comment && fields.comment.comments) {
for (const comment of fields.comment.comments) {
comments.push({
body: this.cleanText(comment.body),
created: this.parseDate(comment.created),
author: comment.author?.displayName || 'Unknown',
});
}
}
// Format created date
const createdDate = this.parseDate(fields.created);
// Create structured issue
return {
key: issue.key,
title: fields.summary || '',
type: fields.issuetype?.name || '',
status: fields.status?.name || '',
created: createdDate,
description,
comments,
};
},
);
// Return structured search result
return {
startAt: searchResult.startAt,
maxResults: searchResult.maxResults,
total: searchResult.total,
issues: structuredIssues,
};
} catch (error) {
console.error(`Error searching issues with query ${jql}:`, error);
throw error;
}
}
}