cursor-azure-devops-mcp
Version:
MCP Server for Cursor IDE-Azure DevOps Integration
1,148 lines (1,147 loc) • 49.4 kB
JavaScript
import * as azdev from 'azure-devops-node-api';
import { configManager } from './config-manager.js';
/**
* Helper function to safely stringify objects with circular references
*/
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// Skip _httpMessage, socket and similar properties that cause circular references
if (key === '_httpMessage' || key === 'socket' || key === 'connection' || key === 'agent') {
return '[Circular]';
}
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
}
/**
* Service for interacting with Azure DevOps API
*/
class AzureDevOpsService {
connection = null;
projectClient = null;
workItemClient = null;
gitClient = null;
testPlanClient = null;
defaultProject;
constructor(connection, defaultProject) {
if (connection) {
this.connection = connection;
}
this.defaultProject = defaultProject;
}
/**
* Initialize the Azure DevOps API connection
*/
async initialize() {
if (this.connection) {
return;
}
const config = configManager.loadConfig();
const { organizationUrl, token } = config.azureDevOps;
if (!organizationUrl || !token) {
throw new Error('Azure DevOps organization URL and token are required');
}
// Create a connection to Azure DevOps
const authHandler = azdev.getPersonalAccessTokenHandler(token);
this.connection = new azdev.WebApi(organizationUrl, authHandler);
// Set default project if not provided in constructor
if (!this.defaultProject && config.azureDevOps.project) {
this.defaultProject = config.azureDevOps.project;
}
// Get API clients
this.projectClient = await this.connection.getCoreApi();
this.workItemClient = await this.connection.getWorkItemTrackingApi();
this.gitClient = await this.connection.getGitApi();
this.testPlanClient = await this.connection.getTestPlanApi();
}
/**
* Get all projects from Azure DevOps
*/
async getProjects() {
await this.initialize();
if (!this.projectClient) {
throw new Error('Project client not initialized');
}
// CoreApi provides the getProjects method to list all projects
const projects = await this.projectClient.getProjects();
return projects;
}
/**
* Get a specific work item by ID
*/
async getWorkItem(id) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
const workItem = await this.workItemClient.getWorkItem(id);
return workItem;
}
/**
* Get multiple work items by IDs
*/
async getWorkItems(ids) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
const workItems = await this.workItemClient.getWorkItems(ids);
return workItems;
}
/**
* Get repositories for a project
*/
async getRepositories(project) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
const repositories = await this.gitClient.getRepositories(projectName);
return repositories;
}
/**
* Get pull requests for a repository
*/
async getPullRequests(repositoryId, project) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
const pullRequests = await this.gitClient.getPullRequests(repositoryId, {
project: projectName,
});
return pullRequests;
}
/**
* Get a specific pull request by ID
*/
async getPullRequestById(repositoryId, pullRequestId, project) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
// Get all pull requests for the repository with all statuses
const pullRequests = await this.gitClient.getPullRequests(repositoryId, {
project: projectName,
status: 'all',
includeLinks: true,
});
// Find the specific pull request
const pullRequest = pullRequests.find((pr) => pr.pullRequestId === pullRequestId);
if (!pullRequest) {
throw new Error(`Pull request ${pullRequestId} not found in repository ${repositoryId}`);
}
return pullRequest;
}
/**
* Get threads (comments) for a pull request
*/
async getPullRequestThreads(repositoryId, pullRequestId, project) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
const threads = await this.gitClient.getThreads(repositoryId, pullRequestId, projectName);
return threads;
}
/**
* Test the connection to Azure DevOps
*/
async testConnection() {
await this.initialize();
// If we get here, the connection was successful
return true;
}
/**
* Get the API clients
* @returns Object containing all initialized clients
*/
getClients() {
if (!this.projectClient || !this.workItemClient || !this.gitClient || !this.testPlanClient) {
throw new Error('API clients not initialized. Call initialize() first.');
}
return {
projectClient: this.projectClient,
workItemClient: this.workItemClient,
gitClient: this.gitClient,
testPlanClient: this.testPlanClient,
};
}
/**
* Get attachments for a specific work item
* @param workItemId The ID of the work item
* @returns Array of attachments with metadata
*/
async getWorkItemAttachments(workItemId) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
// Get work item with relations (includes attachments)
const workItem = await this.workItemClient.getWorkItem(workItemId, undefined, undefined, 4 // 4 = WorkItemExpand.Relations in the SDK
);
if (!workItem || !workItem.relations) {
return [];
}
// Filter for attachment relations
const attachmentRelations = workItem.relations.filter((relation) => relation.rel === 'AttachedFile' || relation.rel === 'Hyperlink');
// Map relations to attachment objects
const attachments = attachmentRelations.map((relation) => {
const url = relation.url;
const attributes = relation.attributes || {};
const attachment = {
url,
name: attributes.name || url.split('/').pop() || 'unnamed',
comment: attributes.comment || '',
resourceSize: attributes.resourceSize || 0,
contentType: attributes.resourceType || '',
};
return attachment;
});
return attachments;
}
/**
* Get comments for a specific work item
* @param workItemId The ID of the work item
* @returns Array of comments with their metadata
*/
async getWorkItemComments(workItemId) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
try {
// First get the work item to determine its project
const workItem = await this.getWorkItem(workItemId);
if (!workItem || !workItem.fields) {
throw new Error('Work item not found or invalid');
}
// Get the project from the work item's fields
const projectId = workItem.fields['System.TeamProject'];
if (!projectId) {
throw new Error('Could not determine project for work item');
}
// Get comments for the work item using the discussions API
const comments = await this.workItemClient.getComments(projectId, workItemId);
if (!comments) {
return {
totalCount: 0,
count: 0,
comments: [],
};
}
// Process comments and their metadata
const processedComments = (comments.comments || []).map((comment) => ({
id: comment.id || 0,
workItemId,
text: comment.text || '',
createdBy: {
displayName: comment.createdBy?.displayName || 'Unknown',
id: comment.createdBy?.id || '',
uniqueName: comment.createdBy?.uniqueName || '',
},
createdDate: comment.createdDate || new Date().toISOString(),
modifiedDate: comment.modifiedDate,
mentions: comment.mentions?.map((mention) => ({
id: mention.id || '',
displayName: mention.displayName || '',
uniqueName: mention.uniqueName || '',
})) || [],
reactions: comment.reactions?.map((reaction) => ({
type: reaction.type || '',
count: reaction.count || 0,
users: reaction.users?.map((user) => ({
id: user.id || '',
displayName: user.displayName || '',
})) || [],
})) || [],
}));
return {
totalCount: comments.totalCount || processedComments.length,
count: processedComments.length,
comments: processedComments,
};
}
catch (error) {
console.error(`Error getting comments for work item ${workItemId}:`, error);
// Return empty response instead of throwing error
return {
totalCount: 0,
count: 0,
comments: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Get all links associated with a work item (parent, child, related, etc.)
* @param workItemId The ID of the work item
* @returns Object with work item links grouped by relationship type
*/
async getWorkItemLinks(workItemId) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
// Get work item with relations
const workItem = await this.workItemClient.getWorkItem(workItemId, undefined, undefined, 4 // 4 = WorkItemExpand.Relations in the SDK
);
if (!workItem || !workItem.relations) {
return {};
}
// Filter for work item link relations (exclude attachments and hyperlinks)
const linkRelations = workItem.relations.filter((relation) => relation.rel.includes('Link') &&
relation.rel !== 'AttachedFile' &&
relation.rel !== 'Hyperlink');
// Group relations by relationship type
const groupedRelations = {};
linkRelations.forEach((relation) => {
const relType = relation.rel;
// Extract work item ID from URL
// URL format is typically like: https://dev.azure.com/{org}/{project}/_apis/wit/workItems/{id}
let targetId = 0;
try {
const urlParts = relation.url.split('/');
targetId = parseInt(urlParts[urlParts.length - 1], 10);
}
catch (error) {
console.error('Failed to extract work item ID from URL:', relation.url);
}
if (!groupedRelations[relType]) {
groupedRelations[relType] = [];
}
const workItemLink = {
...relation,
targetId,
title: relation.attributes?.name || `Work Item ${targetId}`,
};
groupedRelations[relType].push(workItemLink);
});
return groupedRelations;
}
/**
* Get all linked work items with their full details
* @param workItemId The ID of the work item to get links from
* @returns Array of work items that are linked to the specified work item
*/
async getLinkedWorkItems(workItemId) {
await this.initialize();
if (!this.workItemClient) {
throw new Error('Work item client not initialized');
}
// First get all links
const linkGroups = await this.getWorkItemLinks(workItemId);
// Extract all target IDs from all link groups
const linkedIds = [];
Object.values(linkGroups).forEach(links => {
links.forEach(link => {
if (link.targetId > 0) {
linkedIds.push(link.targetId);
}
});
});
// If no linked items found, return empty array
if (linkedIds.length === 0) {
return [];
}
// Get the full work item details for all linked items
const linkedWorkItems = await this.getWorkItems(linkedIds);
return linkedWorkItems;
}
/**
* Get detailed changes for a pull request
*/
async getPullRequestChanges(repositoryId, pullRequestId, project) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
// Get the changes for the pull request
const changes = await this.gitClient.getPullRequestIterationChanges(repositoryId, pullRequestId, 1, // Iteration (usually 1 for the latest)
projectName);
// File size and content handling constants
const MAX_INLINE_FILE_SIZE = 500000; // Increased to 500KB for inline content
const MAX_CHUNK_SIZE = 100000; // 100KB chunks for larger files
const PREVIEW_SIZE = 10000; // 10KB preview for very large files
// Get detailed content for each change
const enhancedChanges = await Promise.all((changes.changeEntries || []).map(async (change) => {
const filePath = change.item?.path || '';
let originalContent = null;
let modifiedContent = null;
let originalContentSize = 0;
let modifiedContentSize = 0;
let originalContentPreview = null;
let modifiedContentPreview = null;
// Skip folders or binary files
const isBinary = this.isBinaryFile(filePath);
const isFolder = change.item?.isFolder === true;
if (!isFolder && !isBinary && change.item) {
try {
// Get original content if the file wasn't newly added
if (change.changeType !== 'add' && change.originalObjectId) {
try {
// First get the item metadata to check file size
const originalItem = await this.gitClient.getItem(repositoryId, filePath, projectName, change.originalObjectId);
originalContentSize = originalItem?.contentMetadata?.contentLength || 0;
// For files within the inline limit, get full content
if (originalContentSize <= MAX_INLINE_FILE_SIZE) {
const originalItemContent = await this.gitClient.getItemContent(repositoryId, filePath, projectName, change.originalObjectId, undefined, true, true);
originalContent = originalItemContent.toString('utf8');
}
// For large files, get a preview
else {
// Get just the beginning of the file for preview
const previewContent = await this.gitClient.getItemText(repositoryId, filePath, projectName, change.originalObjectId, 0, // Start at beginning
PREVIEW_SIZE // Get preview bytes
);
originalContentPreview = previewContent;
originalContent = `(File too large to display inline - ${Math.round(originalContentSize / 1024)}KB. Preview shown.)`;
}
}
catch (error) {
console.error(`Error getting original content for ${filePath}:`, error);
originalContent = '(Content unavailable)';
}
}
// Get modified content if the file wasn't deleted
if (change.changeType !== 'delete' && change.item.objectId) {
try {
// First get the item metadata to check file size
const modifiedItem = await this.gitClient.getItem(repositoryId, filePath, projectName, change.item.objectId);
modifiedContentSize = modifiedItem?.contentMetadata?.contentLength || 0;
// For files within the inline limit, get full content
if (modifiedContentSize <= MAX_INLINE_FILE_SIZE) {
const modifiedItemContent = await this.gitClient.getItemContent(repositoryId, filePath, projectName, change.item.objectId, undefined, true, true);
modifiedContent = modifiedItemContent.toString('utf8');
}
// For large files, get a preview
else {
// Get just the beginning of the file for preview
const previewContent = await this.gitClient.getItemText(repositoryId, filePath, projectName, change.item.objectId, 0, // Start at beginning
PREVIEW_SIZE // Get preview bytes
);
modifiedContentPreview = previewContent;
modifiedContent = `(File too large to display inline - ${Math.round(modifiedContentSize / 1024)}KB. Preview shown.)`;
}
}
catch (error) {
console.error(`Error getting modified content for ${filePath}:`, error);
modifiedContent = '(Content unavailable)';
}
}
}
catch (error) {
console.error(`Error processing file ${filePath}:`, error);
}
}
// Create enhanced change object
const enhancedChange = {
...change,
originalContent,
modifiedContent,
originalContentSize,
modifiedContentSize,
originalContentPreview,
modifiedContentPreview,
isBinary,
isFolder,
};
return enhancedChange;
}));
return {
changeEntries: enhancedChanges,
totalCount: enhancedChanges.length,
};
}
/**
* Helper method to get the Git API client
*/
async getGitApiClient() {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
return this.gitClient;
}
/**
* Retrieves complete file content by fetching chunks in parallel
* @param fetchChunk Function to fetch a single chunk of the file
* @returns Promise resolving to the complete file content as string
*/
async getCompleteFileContent(fetchChunk) {
try {
// First, fetch a tiny chunk to determine if it's binary and get content length
const initialChunk = await fetchChunk(0, 1024);
if (initialChunk.isBinary) {
return `[Binary file content not displayed. File size: ${initialChunk.contentLength || initialChunk.size} bytes]`;
}
const contentLength = initialChunk.contentLength || initialChunk.size;
// If the file is small enough, return it directly
if (contentLength <= 100000) {
if (contentLength <= initialChunk.content.length) {
return initialChunk.content;
}
const fullContent = await fetchChunk(0, contentLength);
return fullContent.content;
}
// For larger files, fetch in parallel chunks
const totalLength = contentLength;
const CHUNK_SIZE = 100000; // 100KB chunks
const NUM_PARALLEL_REQUESTS = 5; // Number of parallel requests
const chunks = [];
let position = 0;
// Process the file in batches of parallel requests
while (position < totalLength) {
const chunkPromises = [];
// Create batch of chunk requests
for (let i = 0; i < NUM_PARALLEL_REQUESTS && position < totalLength; i++) {
const chunkSize = Math.min(CHUNK_SIZE, totalLength - position);
chunkPromises.push(fetchChunk(position, chunkSize));
position += chunkSize;
}
// Wait for all chunks in this batch to be retrieved
const chunkResults = await Promise.all(chunkPromises);
// Add chunks to our collection in the correct order
for (const chunk of chunkResults) {
chunks.push(chunk.content);
}
// Small delay to avoid overwhelming the API
await new Promise(resolve => setTimeout(resolve, 100));
}
return chunks.join('');
}
catch (error) {
console.error('Error retrieving complete file content:', error);
if (error instanceof Error) {
throw new Error(`Failed to retrieve complete file content: ${error.message}`);
}
else {
throw new Error('Failed to retrieve complete file content due to an unknown error');
}
}
}
/**
* Get the content of a file in a pull request
*/
async getPullRequestFileContent(repositoryId, pullRequestId, filePath, objectId, startPosition = 0, length = 100000, project) {
try {
const gitClient = await this.getGitApiClient();
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided');
}
// Check if this is likely a binary file
const isBinaryFile = this.isBinaryFile(filePath);
try {
// Get the content with range
const content = await gitClient.getBlobContent(repositoryId, objectId, projectName, undefined, // download
undefined, // scopePath
{
startPosition: startPosition,
endPosition: startPosition + length - 1,
});
// Check if we got a Buffer
let contentStr = '';
let contentSize = 0;
let totalContentLength = 0;
if (Buffer.isBuffer(content)) {
contentSize = content.length;
// Attempt to get the total file size by requesting the file metadata
try {
const fileInfo = await gitClient.getBlobsZip(repositoryId, [objectId], projectName);
// This may not give us the exact size, but we'll try to estimate
totalContentLength = fileInfo?._response?.bodyAsText?.length || contentSize;
}
catch (error) {
// If we can't get the total size, use the current chunk size or try another method
totalContentLength = contentSize;
}
if (isBinaryFile) {
contentStr = '[Binary content]';
}
else {
try {
contentStr = content.toString('utf8');
}
catch (error) {
contentStr = '[Error converting content to string]';
}
}
}
else if (typeof content === 'string') {
contentStr = content;
contentSize = contentStr.length;
totalContentLength = contentSize;
}
else if (content === null || content === undefined) {
contentStr = '';
contentSize = 0;
totalContentLength = 0;
}
else {
// In case we got some other type
try {
contentStr = safeStringify(content);
contentSize = contentStr.length;
totalContentLength = contentSize;
}
catch (error) {
contentStr = '[Error: could not serialize content]';
contentSize = contentStr.length;
totalContentLength = contentSize;
}
}
return {
content: contentStr,
size: totalContentLength,
position: startPosition,
length: contentSize,
isBinary: isBinaryFile,
contentLength: totalContentLength,
};
}
catch (error) {
console.error('Error getting content directly:', error);
// Fallback to branch access if direct objectId access fails
try {
// Get the pull request details to find the source branch
const pullRequest = await gitClient.getPullRequestById(pullRequestId, repositoryId, projectName);
if (!pullRequest || !pullRequest.sourceRefName) {
throw new Error('Could not determine source branch for pull request');
}
// Extract branch name from ref (remove 'refs/heads/' prefix)
const branchName = pullRequest.sourceRefName.replace(/^refs\/heads\//, '');
// Use the getFileFromBranch method as a fallback
console.log(`Falling back to branch access for ${filePath} using branch ${branchName}`);
return await this.getFileFromBranch(repositoryId, filePath, branchName, startPosition, length, projectName);
}
catch (fallbackError) {
console.error('Fallback to branch access also failed:', fallbackError);
return {
content: '',
size: 0,
position: startPosition,
length: 0,
error: `Failed to retrieve content for file: ${filePath}. Direct access and branch fallback both failed.`,
isBinary: false,
contentLength: 0,
};
}
}
}
catch (error) {
console.error('Error in getPullRequestFileContent:', error);
return {
content: '',
size: 0,
position: startPosition,
length: 0,
error: `Failed to retrieve content: ${error instanceof Error ? error.message : String(error)}`,
isBinary: false,
contentLength: 0,
};
}
}
/**
* Get the complete content of a file from a pull request, automatically handling chunking
* This simplifies access to large files by combining chunks internally
*/
async getCompletePullRequestFileContent(repositoryId, pullRequestId, filePath, objectId, project) {
// Check if this is a binary file before proceeding
if (this.isBinaryFile(filePath)) {
return `[Binary file - content cannot be displayed as text]`;
}
try {
const content = await this.getCompleteFileContent((startPosition, length) => this.getPullRequestFileContent(repositoryId, pullRequestId, filePath, objectId, startPosition, length, project));
return content;
}
catch (error) {
console.error('Error retrieving complete pull request file content:', error);
throw error;
}
}
/**
* Get the complete content of a file from a branch, automatically handling chunking
* This simplifies access to large files by combining chunks internally
*/
async getCompleteFileFromBranch(repositoryId, filePath, branchName, project) {
// Check if this is a binary file before proceeding
if (this.isBinaryFile(filePath)) {
return `[Binary file - content cannot be displayed as text]`;
}
try {
const content = await this.getCompleteFileContent((startPosition, length) => this.getFileFromBranch(repositoryId, filePath, branchName, startPosition, length, project));
return content;
}
catch (error) {
console.error('Error retrieving complete branch file content:', error);
throw error;
}
}
/**
* Helper function to determine if a file is likely binary based on extension
*/
isBinaryFile(filePath) {
const binaryExtensions = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.ico',
'.svg',
'.pdf',
'.doc',
'.docx',
'.ppt',
'.pptx',
'.xls',
'.xlsx',
'.zip',
'.tar',
'.gz',
'.rar',
'.7z',
'.exe',
'.dll',
'.so',
'.dylib',
'.bin',
'.dat',
'.class',
];
const extension = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return binaryExtensions.includes(extension);
}
/**
* Helper method to get a file's content from a specific branch
* This is useful for accessing files in pull requests when direct object ID access fails
*/
async getFileFromBranch(repositoryId, filePath, branchName, startPosition = 0, length = 100000, project) {
try {
const gitClient = await this.getGitApiClient();
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided');
}
// Check if this is likely a binary file
const isBinaryFile = this.isBinaryFile(filePath);
try {
// Attempt to get the item by path
const gitItem = await gitClient.getItem(repositoryId, filePath, projectName, undefined, // version
undefined, // versionOptions
undefined, // versionType
undefined, // includeContent
undefined, // latestProcessedChange
branchName, // versionDescriptor
undefined // includeContentMetadata
);
if (!gitItem || !gitItem.objectId) {
throw new Error(`File not found at path: ${filePath}`);
}
// Get the content with range
const content = await gitClient.getItemContent(repositoryId, filePath, projectName, undefined, // version
undefined, // versionOptions
undefined, // versionType
branchName, // versionDescriptor
undefined, // download
undefined, // includeContent
undefined, // latestProcessedChange
{
startPosition: startPosition,
endPosition: startPosition + length - 1,
});
// Check if we got a Buffer
let contentStr = '';
let contentSize = 0;
let totalContentLength = 0;
if (Buffer.isBuffer(content)) {
contentSize = content.length;
// Attempt to get the total file size by requesting the file without content
try {
const fileInfo = await gitClient.getItem(repositoryId, filePath, projectName, undefined, undefined, undefined, undefined, // Don't request content
undefined, branchName, true // includeContentMetadata
);
totalContentLength = fileInfo.contentMetadata?.contentLength || contentSize;
}
catch (error) {
// If we can't get the total size, use the current chunk size
totalContentLength = contentSize;
}
if (isBinaryFile) {
contentStr = '[Binary content]';
}
else {
try {
contentStr = content.toString('utf8');
}
catch (error) {
contentStr = '[Error converting content to string]';
}
}
}
else if (typeof content === 'string') {
contentStr = content;
contentSize = contentStr.length;
totalContentLength = contentSize;
}
else if (content === null || content === undefined) {
contentStr = '';
contentSize = 0;
totalContentLength = 0;
}
else {
// In case we got some other type
try {
contentStr = safeStringify(content);
contentSize = contentStr.length;
totalContentLength = contentSize;
}
catch (error) {
contentStr = '[Error: could not serialize content]';
contentSize = contentStr.length;
totalContentLength = contentSize;
}
}
return {
content: contentStr,
size: totalContentLength,
position: startPosition,
length: contentSize,
isBinary: isBinaryFile,
contentLength: totalContentLength,
};
}
catch (error) {
// Log the error for debugging
console.error('Error in getFileFromBranch:', error);
// Return a structured error response
return {
content: '',
size: 0,
position: startPosition,
length: 0,
error: error instanceof Error ? error.message : String(error),
isBinary: false,
contentLength: 0,
};
}
}
catch (error) {
console.error('Error in getFileFromBranch:', error);
throw error;
}
}
/**
* Create a comment on a pull request
*/
async createPullRequestComment(params) {
await this.initialize();
if (!this.gitClient) {
throw new Error('Git client not initialized');
}
const { repositoryId, pullRequestId, project, content, threadId, filePath, lineNumber, parentCommentId, status, } = params;
// Use the provided project or fall back to the default project
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required');
}
let commentResponse = null;
try {
// Case 1: Adding a comment to an existing thread
if (threadId) {
// Create comment on existing thread
const comment = await this.gitClient.createComment({
content,
parentCommentId,
}, repositoryId, pullRequestId, threadId, projectName);
commentResponse = {
id: comment.id || 0,
content: comment.content || '',
threadId,
status: comment.status,
author: comment.author,
createdDate: comment.publishedDate,
url: comment.url,
};
}
// Case 2: Creating a new thread with a comment
else {
// Set up the new thread
const threadContext = {};
if (filePath) {
// Comment on a file
threadContext.filePath = filePath;
// If line number is provided, set up position
if (lineNumber) {
threadContext.rightFileStart = {
line: lineNumber,
offset: 1,
};
threadContext.rightFileEnd = {
line: lineNumber,
offset: 1,
};
}
}
// Create a new thread with our comment
const newThread = await this.gitClient.createThread({
comments: [
{
content,
parentCommentId,
},
],
status,
threadContext,
}, repositoryId, pullRequestId, projectName);
// Extract the created comment from the thread
const createdComment = newThread.comments && newThread.comments.length > 0 ? newThread.comments[0] : null;
if (createdComment && newThread.id) {
commentResponse = {
id: createdComment.id || 0,
content: createdComment.content || '',
threadId: newThread.id,
status: createdComment.status,
author: createdComment.author,
createdDate: createdComment.publishedDate,
url: createdComment.url,
};
}
else {
throw new Error('Failed to create comment on pull request');
}
}
return (commentResponse || {
id: 0,
content: '',
threadId: threadId || 0,
status: 'unknown',
});
}
catch (error) {
console.error('Error creating pull request comment:', error);
throw new Error(`Failed to create comment: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Helper function to truncate large response objects
* @param obj The object to truncate
* @param maxSize Maximum size in bytes (default 50KB)
* @returns Truncated object with metadata
*/
truncateResponse(obj, maxSize = 50000) {
const stringified = safeStringify(obj);
if (stringified.length <= maxSize) {
return obj;
}
// For arrays, truncate to fewer items
if (Array.isArray(obj)) {
const truncated = obj.slice(0, Math.max(1, Math.floor(obj.length * (maxSize / stringified.length))));
return {
items: truncated,
totalCount: obj.length,
isTruncated: true,
truncatedCount: obj.length - truncated.length,
message: `Response was truncated. Showing ${truncated.length} of ${obj.length} items.`,
};
}
// For objects, try to keep essential fields and truncate nested content
const truncated = {};
let currentSize = 0;
const essentialFields = ['id', 'name', 'url', 'project', 'state', 'createdBy', 'createdDate'];
// First, keep all essential fields
essentialFields.forEach(field => {
if (obj[field] !== undefined) {
truncated[field] = obj[field];
currentSize += safeStringify(obj[field]).length;
}
});
// Then add other fields until we reach size limit
Object.entries(obj).forEach(([key, value]) => {
if (!essentialFields.includes(key)) {
const valueSize = safeStringify(value).length;
if (currentSize + valueSize <= maxSize) {
truncated[key] = value;
currentSize += valueSize;
}
}
});
return {
...truncated,
isTruncated: true,
originalSize: stringified.length,
truncatedSize: currentSize,
message: 'Response was truncated to fit size limits. Essential information is preserved.',
};
}
/**
* Get test suites for a project and test plan
*/
async getTestSuites(testPlanId, project) {
await this.initialize();
try {
if (!this.testPlanClient) {
this.testPlanClient = await this.connection.getTestPlanApi();
}
// Use provided project or default project from config
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided in parameters or configuration');
}
console.log(`Fetching test suites for project ${projectName} and plan ${testPlanId}`);
const testSuites = await this.testPlanClient.getTestSuitesForPlan(projectName, testPlanId);
if (!testSuites) {
console.log('No test suites returned from API');
return [];
}
return this.truncateResponse(testSuites);
}
catch (error) {
console.error('Error getting test suites:', error);
throw error;
}
}
/**
* Get test suite by ID
*/
async getTestSuite(project, testPlanId, testSuiteId) {
await this.initialize();
try {
if (!this.testPlanClient) {
this.testPlanClient = await this.connection.getTestPlanApi();
}
// Use provided project or default project from config
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided in parameters or configuration');
}
console.log(`Fetching test suite ${testSuiteId} from project ${projectName} and plan ${testPlanId}`);
const testSuite = await this.testPlanClient.getTestSuiteById(projectName, testPlanId, testSuiteId);
if (!testSuite) {
console.log('No test suite found with the specified ID');
throw new Error(`Test suite ${testSuiteId} not found`);
}
return this.truncateResponse(testSuite);
}
catch (error) {
console.error('Error getting test suite:', error);
throw error;
}
}
/**
* Get test cases for a test suite
*/
async getTestCases(project, testPlanId, testSuiteId) {
await this.initialize();
try {
if (!this.testPlanClient) {
this.testPlanClient = await this.connection.getTestPlanApi();
}
// Use provided project or default project from config
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided in parameters or configuration');
}
console.log(`Fetching test cases for suite ${testSuiteId} in project ${projectName} and plan ${testPlanId}`);
const testCases = await this.testPlanClient.getTestCaseList(projectName, testPlanId, testSuiteId);
if (!testCases) {
console.log('No test cases returned from API');
return [];
}
return this.truncateResponse(testCases);
}
catch (error) {
console.error('Error getting test cases:', error);
throw error;
}
}
/**
* Get test plans for a project
*/
async getTestPlans(project) {
await this.initialize();
try {
if (!this.testPlanClient) {
this.testPlanClient = await this.connection.getTestPlanApi();
}
// Use provided project or default project from config
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided in parameters or configuration');
}
console.log(`Fetching test plans for project ${projectName}`);
const testPlans = await this.testPlanClient.getTestPlans(projectName);
if (!testPlans) {
console.log('No test plans returned from API');
return [];
}
return this.truncateResponse(testPlans);
}
catch (error) {
console.error('Error getting test plans:', error);
throw error;
}
}
/**
* Get test plan by ID
*/
async getTestPlan(project, testPlanId) {
await this.initialize();
try {
if (!this.testPlanClient) {
this.testPlanClient = await this.connection.getTestPlanApi();
}
// Use provided project or default project from config
const projectName = project || this.defaultProject;
if (!projectName) {
throw new Error('Project name is required but not provided in parameters or configuration');
}
console.log(`Fetching test plan ${testPlanId} from project ${projectName}`);
const testPlan = await this.testPlanClient.getTestPlanById(projectName, testPlanId);
if (!testPlan) {
console.log('No test plan found with the specified ID');
throw new Error(`Test plan ${testPlanId} not found`);
}
return this.truncateResponse(testPlan);
}
catch (error) {
console.error('Error getting test plan:', error);
throw error;
}
}
}
// Export the class and create a singleton instance
export { AzureDevOpsService };
export const azureDevOpsService = new AzureDevOpsService();