Skip to main content
Glama
test-problem-reporter.ts18.4 kB
/** * Test and Problem Reporter for TeamCity * Extracts and formats test results and build problems */ import { error } from '@/utils'; import type { TeamCityClientAdapter } from './client-adapter'; import { TeamCityAPIError } from './errors'; import { toBuildLocator } from './utils/build-locator'; /** * Test statistics for a build */ export interface BuildTestStatistics { totalTests: number; passedTests: number; failedTests: number; ignoredTests: number; mutedTests: number; newFailedTests: number; successRate: number; // Percentage } /** * Failed test details */ export interface TestRun { id: string; name: string; className?: string; status: 'SUCCESS' | 'FAILURE' | 'IGNORED' | 'UNKNOWN'; duration?: number; // milliseconds details?: string; // Error message or stack trace } /** * Build problem details */ export interface BuildProblem { id: string; type: string; identity: string; details: string; additionalData?: Record<string, string>; } /** * Categorized problems by type */ export interface CategorizedProblems { all: BuildProblem[]; categorized: Record<string, BuildProblem[]>; } /** * Complete test and problem summary */ export interface TestAndProblemSummary { statistics: BuildTestStatistics; failedTests?: TestRun[]; problems?: BuildProblem[] | CategorizedProblems; hasIssues: boolean; failureReason?: string; } /** * Options for summary generation */ export interface SummaryOptions { includeFailedTests?: boolean; includeProblems?: boolean; categorizeProblems?: boolean; maxFailedTestsToShow?: number; maxProblemsToShow?: number; } /** * Test and Problem Reporter implementation */ export class TestProblemReporter { constructor(private readonly client: TeamCityClientAdapter) {} private readonly isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === 'object' && value !== null; }; /** * Get test statistics for a build */ async getTestStatistics(buildId: string): Promise<BuildTestStatistics> { const response = await this.client.modules.builds.getBuild( toBuildLocator(buildId), 'testOccurrences(count,passed,failed,ignored,muted,newFailed)' ); const build = this.ensureTestStatisticsPayload(response.data, buildId); const testOccurrences = this.ensureTestOccurrences(build.testOccurrences, buildId); const { count: totalTests, passed: passedTests, failed: failedTests, ignored: ignoredTests, muted: mutedTests, newFailed: newFailedTests, } = testOccurrences; // Calculate success rate let successRate = 100; if (totalTests > 0) { successRate = Math.round((passedTests / totalTests) * 10000) / 100; // Round to 2 decimal places } return { totalTests, passedTests, failedTests, ignoredTests, mutedTests, newFailedTests, successRate, }; } /** * Get details of failed tests */ async getFailedTests(buildId: string, maxResults?: number): Promise<TestRun[]> { try { const locatorParts = [`build:(id:${buildId})`, 'status:FAILURE']; if (maxResults) { locatorParts.push(`count:${maxResults}`); } const locator = locatorParts.join(','); const response = await this.client.modules.tests.getAllTestOccurrences(locator); const occurrences = this.ensureFailedTestsResponse(response.data, buildId, locator); return occurrences .filter((test) => test.status === 'FAILURE') .map((test) => ({ id: test.id, name: test.name, className: test.test?.className, status: 'FAILURE' as const, duration: test.duration, details: test.details, })); } catch (err) { error('Failed to get failed tests', err as Error, { buildId }); return []; } } /** * Get build problems */ async getBuildProblems( buildId: string, categorize?: boolean ): Promise<BuildProblem[] | CategorizedProblems> { try { const response = await this.client.modules.problemOccurrences.getAllBuildProblemOccurrences( `build:(id:${buildId})` ); const occurrences = this.ensureProblemOccurrencesResponse(response.data, buildId); const problems: BuildProblem[] = occurrences.map((problem) => ({ id: problem.id, type: problem.type, identity: problem.identity, details: problem.details, additionalData: typeof problem.additionalData === 'object' && problem.additionalData !== null ? (problem.additionalData as Record<string, string>) : {}, })); if (!categorize) { return problems; } // Categorize problems by type const categorized: Record<string, BuildProblem[]> = {}; problems.forEach((problem) => { const key = problem.type; const bucket = categorized[key] ?? (categorized[key] = []); bucket.push(problem); }); return { all: problems, categorized, }; } catch (err) { error('Failed to get build problems', err as Error, { buildId }); return categorize ? { all: [], categorized: {} } : []; } } private ensureTestStatisticsPayload( data: unknown, buildId: string ): Record<string, unknown> & { testOccurrences?: unknown } { if (!this.isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned invalid test statistics payload', 'INVALID_RESPONSE', undefined, { buildId, } ); } return data as Record<string, unknown> & { testOccurrences?: unknown }; } private ensureTestOccurrences( occurrences: unknown, buildId: string ): { count: number; passed: number; failed: number; ignored: number; muted: number; newFailed: number; } { if (occurrences == null) { return { count: 0, passed: 0, failed: 0, ignored: 0, muted: 0, newFailed: 0, }; } if (!this.isRecord(occurrences)) { throw new TeamCityAPIError( 'TeamCity returned malformed test occurrences payload', 'INVALID_RESPONSE', undefined, { buildId, } ); } const { count, passed, failed, ignored, muted, newFailed } = occurrences as Record< string, unknown >; return { count: this.coerceCountField(count, 'count', buildId), passed: this.coerceCountField(passed, 'passed', buildId), failed: this.coerceCountField(failed, 'failed', buildId), ignored: this.coerceCountField(ignored, 'ignored', buildId), muted: this.coerceCountField(muted, 'muted', buildId), newFailed: this.coerceCountField(newFailed, 'newFailed', buildId), }; } private coerceCountField(value: unknown, field: string, buildId: string): number { if (value === undefined) { return 0; } if (typeof value === 'number') { return value; } if (typeof value === 'string') { const parsed = Number.parseInt(value, 10); if (!Number.isNaN(parsed)) { return parsed; } } throw new TeamCityAPIError( 'TeamCity test statistics field is not numeric', 'INVALID_RESPONSE', undefined, { buildId, field, receivedType: typeof value, } ); } private ensureFailedTestsResponse( data: unknown, buildId: string, locator: string ): Array<{ status: string; id: string; name: string; test?: { className?: string }; duration?: number; details?: string; }> { if (!this.isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned invalid failed tests payload', 'INVALID_RESPONSE', undefined, { buildId, locator, } ); } const { testOccurrence } = data as { testOccurrence?: unknown }; if (testOccurrence === undefined) { return []; } if (!Array.isArray(testOccurrence)) { throw new TeamCityAPIError( 'TeamCity failed tests payload is not an array', 'INVALID_RESPONSE', undefined, { buildId, locator, } ); } return testOccurrence.map((entry, index) => { if (!this.isRecord(entry)) { throw new TeamCityAPIError( 'TeamCity failed test entry is not an object', 'INVALID_RESPONSE', undefined, { buildId, locator, index, } ); } const { status, id, name, test, duration, details } = entry as Record<string, unknown>; if (typeof status !== 'string' || typeof id !== 'string' || typeof name !== 'string') { throw new TeamCityAPIError( 'TeamCity failed test entry is missing required fields', 'INVALID_RESPONSE', undefined, { buildId, locator, index, receivedKeys: Object.keys(entry), } ); } if (test !== undefined && test !== null && !this.isRecord(test)) { throw new TeamCityAPIError( 'TeamCity failed test entry has invalid test metadata', 'INVALID_RESPONSE', undefined, { buildId, locator, index, } ); } if (duration !== undefined && typeof duration !== 'number') { throw new TeamCityAPIError( 'TeamCity failed test entry has non-numeric duration', 'INVALID_RESPONSE', undefined, { buildId, locator, index, receivedType: typeof duration, } ); } if (details !== undefined && typeof details !== 'string') { throw new TeamCityAPIError( 'TeamCity failed test entry has non-string details', 'INVALID_RESPONSE', undefined, { buildId, locator, index, receivedType: typeof details, } ); } return { status, id, name, test: test as { className?: string } | undefined, duration: duration as number | undefined, details: details as string | undefined, }; }); } private ensureProblemOccurrencesResponse( data: unknown, buildId: string ): Array<{ id: string; type: string; identity: string; details: string; additionalData?: Record<string, string> | string; }> { if (!this.isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned invalid problem occurrences payload', 'INVALID_RESPONSE', undefined, { buildId, } ); } const { problemOccurrence } = data as { problemOccurrence?: unknown }; if (problemOccurrence === undefined) { return []; } if (!Array.isArray(problemOccurrence)) { throw new TeamCityAPIError( 'TeamCity problem occurrences payload is not an array', 'INVALID_RESPONSE', undefined, { buildId, } ); } return problemOccurrence.map((entry, index) => { if (!this.isRecord(entry)) { throw new TeamCityAPIError( 'TeamCity problem occurrence entry is not an object', 'INVALID_RESPONSE', undefined, { buildId, index, } ); } const { id, type, identity, details, additionalData } = entry as Record<string, unknown>; if ( typeof id !== 'string' || typeof type !== 'string' || typeof identity !== 'string' || typeof details !== 'string' ) { throw new TeamCityAPIError( 'TeamCity problem occurrence entry is missing required fields', 'INVALID_RESPONSE', undefined, { buildId, index, receivedKeys: Object.keys(entry), } ); } if ( additionalData !== undefined && additionalData !== null && typeof additionalData !== 'string' && !this.isRecord(additionalData) ) { throw new TeamCityAPIError( 'TeamCity problem occurrence entry has invalid additionalData', 'INVALID_RESPONSE', undefined, { buildId, index, } ); } return { id, type, identity, details, additionalData: additionalData as Record<string, string> | string | undefined, }; }); } /** * Get comprehensive test and problem summary */ async getTestAndProblemSummary( buildId: string, options: SummaryOptions = {} ): Promise<TestAndProblemSummary> { const { includeFailedTests = true, includeProblems = true, categorizeProblems = false, maxFailedTestsToShow = 100, } = options; // Get test statistics const statistics = await this.getTestStatistics(buildId); // Initialize result const summary: TestAndProblemSummary = { statistics, hasIssues: false, }; // Get failed tests if requested if (includeFailedTests && statistics.failedTests > 0) { summary.failedTests = await this.getFailedTests(buildId, maxFailedTestsToShow); summary.hasIssues = true; } // Get problems if requested if (includeProblems) { const problems = await this.getBuildProblems(buildId, categorizeProblems); const problemCount = Array.isArray(problems) ? problems.length : problems.all.length; if (problemCount > 0) { summary.problems = problems; summary.hasIssues = true; } } // Format failure reason if there are issues if (summary.hasIssues) { summary.failureReason = await this.formatFailureReason(buildId); } return summary; } /** * Format a human-readable failure reason */ async formatFailureReason(buildId: string): Promise<string> { const statistics = await this.getTestStatistics(buildId); const failedTests = statistics.failedTests > 0 ? await this.getFailedTests(buildId, 10) : []; const problems = (await this.getBuildProblems(buildId)) as BuildProblem[]; const reasons: string[] = []; // Add test failure summary if (statistics.failedTests > 0) { reasons.push(`${statistics.failedTests} test(s) failed`); if (failedTests.length > 0) { const testNames = failedTests .slice(0, 5) .map((t) => t.name) .join(', '); const moreCount = failedTests.length - 5; const testList = moreCount > 0 ? `${testNames}... and ${moreCount} more` : testNames; if (failedTests.length > 0) { reasons.push(`Failed tests: ${testList}`); } } } // Add problem summary if (problems.length > 0) { reasons.push(`${problems.length} build problem(s)`); const problemDetails = problems .slice(0, 3) .map((problem) => problem.details) .filter((detail): detail is string => typeof detail === 'string' && detail.length > 0) .join('; '); if (problemDetails.length > 0) { reasons.push(`Build problems: ${problemDetails}`); } } return reasons.join('. ') || 'Build failed for unknown reasons'; } /** * Check if a build has any test failures or problems */ async hasIssues(buildId: string): Promise<boolean> { const statistics = await this.getTestStatistics(buildId); if (statistics.failedTests > 0) { return true; } const problems = (await this.getBuildProblems(buildId)) as BuildProblem[]; return problems.length > 0; } /** * Get test trend for recent builds */ async getTestTrend( buildTypeId: string, count: number = 10 ): Promise<Array<{ buildId: string; statistics: BuildTestStatistics }>> { try { const locator = `buildType:(id:${buildTypeId}),count:${count}`; const response = await this.client.modules.builds.getAllBuilds(locator, 'build(id)'); const builds = response.data as { build?: Array<{ id: string }> }; if (builds.build == null || !Array.isArray(builds.build)) { return []; } const trend = await Promise.all( builds.build.map(async (build) => { const buildObj = build as { id: string }; return { buildId: buildObj.id, statistics: await this.getTestStatistics(buildObj.id), }; }) ); return trend; } catch (err) { error('Failed to get test trend', err as Error, { buildTypeId, count }); return []; } } /** * Get common failure patterns */ async getFailurePatterns( buildTypeId: string, count: number = 20 ): Promise<Record<string, number>> { try { const locator = `buildType:(id:${buildTypeId}),status:FAILURE,count:${count}`; const response = await this.client.modules.builds.getAllBuilds(locator, 'build(id)'); const builds = response.data as { build?: Array<{ id: string }> }; if (builds.build == null || !Array.isArray(builds.build)) { return {}; } const patterns: Record<string, number> = {}; // Analyze failed tests across builds for (const build of builds.build) { // Analyze builds sequentially to keep requests bounded // eslint-disable-next-line no-await-in-loop const failedTests = await this.getFailedTests(build.id); failedTests.forEach((test) => { const key = `${test.className ?? 'Unknown'}.${test.name}`; patterns[key] = (patterns[key] ?? 0) + 1; }); } // Sort by frequency const sorted = Object.entries(patterns) .sort(([, a], [, b]) => b - a) .reduce( (acc, [key, value]) => { acc[key] = value; return acc; }, {} as Record<string, number> ); return sorted; } catch (err) { error('Failed to get failure patterns', err as Error, { buildTypeId, count }); return {}; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server